diff --git a/README.md b/README.md index 7a96720d2..94a538513 100644 --- a/README.md +++ b/README.md @@ -545,7 +545,10 @@ Paths support git-style glob patterns on macOS, similar to `.gitignore` syntax: - `*` - Matches any characters except `/` (e.g., `*.ts` matches `foo.ts` but not `foo/bar.ts`) - `**` - Matches any characters including `/` (e.g., `src/**/*.ts` matches all `.ts` files in `src/`) - `?` - Matches any single character except `/` (e.g., `file?.txt` matches `file1.txt`) -- `[abc]` - Matches any character in the set (e.g., `file[0-9].txt` matches `file3.txt`). Whatever stands between the brackets is a member, a `*` or a `?` too (`[*]` matches a `*` in a name). Brackets that spell no set, because a range in them runs backwards (`[user-id]`, `backup[2024-01-15]`) or there is nothing in them (`list[].ts`), are the text they are, on every platform; the rest of the entry is still a pattern (`app/**/[user-id]/*.env`) +- `[abc]` - Matches any character in the set (e.g., `file[0-9].txt` matches `file3.txt`). Whatever stands between the brackets is a member, a `*` or a `?` too (`[*]` matches a `*` in a name), but for a `!` or `^` that negates (next entry). Brackets that spell no set, because a range in them runs backwards (`[user-id]`, `backup[2024-01-15]`) or there is nothing in them (`list[].ts`), are the text they are, on every platform; the rest of the entry is still a pattern (`app/**/[user-id]/*.env`) +- `[!abc]` and `[^abc]` - Matches any character outside the set, and never `/` (e.g., `file[!a-c].txt` matches `filed.txt` but not `fileb.txt`). Only a `!` or `^` straight after the `[` negates, and it is no member itself. With nothing after it (`[!]`, `[^]`) the brackets spell no set + +Two things a macOS sandbox profile's own regex engine does with these. It matches bytes, so `?` and a set, negated or not, match no character outside ASCII. And in a deny rule it reads a negated set that lists a `-` and one of `,` and `.`, not both (`[!-.]`, `[!+--]`), as any character but `/`, so that deny covers the characters it lists as well: wider than written, never narrower. Examples: diff --git a/src/sandbox/glob-pattern.ts b/src/sandbox/glob-pattern.ts index 391304f60..6078cd823 100644 --- a/src/sandbox/glob-pattern.ts +++ b/src/sandbox/glob-pattern.ts @@ -18,7 +18,7 @@ export enum GlobTokenKind { Globstar = 'globstar', /** `**` with the `/` after it: any number of directories. */ GlobstarSlash = 'globstarSlash', - /** `[abc]`, `[a-z]`: one character of a set. */ + /** `[abc]`, `[a-z]`: one character of a set. `[!abc]`: one outside it. */ Set = 'set', } @@ -26,9 +26,11 @@ export type GlobToken = | { kind: GlobTokenKind.Text; text: string } | { kind: GlobTokenKind.Set - /** What is written between the brackets. */ + /** What is written between the brackets, after a `!` or `^` that negates. */ members: string - /** `[+-9]` does: a `/` lies between the two. */ + /** `[!abc]`, `[^abc]`: a character that is no member, and no `/`. */ + negated: boolean + /** `[+-9]` does: a `/` lies between the two. A negated set never does. */ matchesSeparator: boolean } | { kind: GlobTokenKind.Separator | GlobTokenKind.AnyChar | WildcardKind } @@ -66,11 +68,12 @@ const SEPARATOR = '/'.charCodeAt(0) * is a pattern, and no character escapes another: * * - A set runs from a `[` to the first `]` after it, be that the next - * character. All between the two are members, a `*`, a `?`, a `/` or another - * `[` too, and `^` and `!` do not negate. + * character. A `!` or a `^` straight after the `[` negates it. All else + * between the two are members, a `*`, a `?`, a `/` or another `[` too. * - Brackets that spell no set (see {@link setRanges}) are text, as POSIX - * reads a `[` that opens none, and what is between them keeps its meaning. - * So are a `[` that nothing closes and a `]` that nothing opens. + * reads a `[` that opens none, and what is between them keeps its meaning: + * `[!]` and `[^]` have no members. So are a `[` that nothing closes and a + * `]` that nothing opens. * - Of a run of `*`, the last two go with a `/` that follows. Two or more of * the rest are a `**`. */ @@ -151,10 +154,11 @@ export function parseGlob(pattern: string): GlobToken[] { i++ break } - const members = pattern.slice(i + 1, close) + const negated = pattern[i + 1] === '!' || pattern[i + 1] === '^' + const members = pattern.slice(i + (negated ? 2 : 1), close) const ranges = setRanges(members) if (ranges === undefined) { - noSets.push(`[${members}]`) + noSets.push(pattern.slice(i, close + 1)) bracketsAreTextUntil = close + 1 i++ break @@ -163,9 +167,10 @@ export function parseGlob(pattern: string): GlobToken[] { tokens.push({ kind: GlobTokenKind.Set, members, - matchesSeparator: ranges.some( - ([from, to]) => from <= SEPARATOR && SEPARATOR <= to, - ), + negated, + matchesSeparator: + !negated && + ranges.some(([from, to]) => from <= SEPARATOR && SEPARATOR <= to), }) textStart = i = close + 1 break @@ -189,6 +194,69 @@ export function escapeRegexLiteral(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +const DASH = '-'.charCodeAt(0) +const CARET = '^'.charCodeAt(0) + +/** + * The regular expression for a set, written the way JavaScript and the regex + * engine of a macOS sandbox profile read alike. What that engine does with a + * set, as measured on macOS 14, 15 and 26, and what is done about it: + * + * - A backslash is a member (`[^/\$]` leaves out `\` as well as `$`). So a + * member is written as it is, and a backslash doubled: one `\` to both. + * - `[.`, `[:` and `[=` open a POSIX element, and the whole profile is refused + * where nothing closes it (`[[.]`). So the members go in ascending order, + * in which none of the three follows a `[`. + * - A `-` is a member only where it is the first: a set that ends in one + * (`[a-]`) and a range that starts at one (`[--9]`) are refused. So one + * that stands for itself, or at either end of a range, goes first, and such + * a range is the `-` and the rest of it, from `.` or up to `,`. + * - In a rule with the tail of {@link denyGlobRegex}, a negated set in which + * two neighbours are written apart (`[^/0-9]`, `[^/abc]`) is read as `[^/]`. + * So there they are written as one range (`[^/-9]`, `[^/a-c]`). That leaves + * a `-` beside a `,` or a `.`, which the line above keeps apart. + * + * In a set that is not negated a `^` goes last, where neither takes it to + * negate. Beyond ASCII nothing is moved: sorted by code unit, the halves of + * two characters outside the basic plane would part. + */ +function setToRegex(members: string, negated: boolean): string { + const ranges = setRanges(members) ?? [] + if (negated) ranges.push([SEPARATOR, SEPARATOR]) + const isAscii = ([, to]: (typeof ranges)[number]): boolean => to < 0x80 + const merged: typeof ranges = [] + for (const [from, to] of ranges.filter(isAscii).sort(([a], [b]) => a - b)) { + const last = merged[merged.length - 1] + if (last !== undefined && from <= last[1] + (negated ? 1 : 0)) { + last[1] = Math.max(last[1], to) + } else { + merged.push([from, to]) + } + } + const member = (code: number): string => + String.fromCharCode(code).replace('\\', '\\\\') + let dash = '' + let caret = '' + let rest = '' + for (let [from, to] of [...merged, ...ranges.filter(r => !isAscii(r))]) { + if (from === DASH || to === DASH) { + dash = '-' + if (from === to) continue + if (from === DASH) from++ + else to-- + } + if (from === CARET && !negated) { + caret = '^' + if (from === to) continue + from++ + } + rest += from === to ? member(from) : `${member(from)}-${member(to)}` + } + const body = dash + rest + caret + // Alone in its set, the `-` is as well written without one. + return body === '-' ? body : `[${negated ? '^' : ''}${body}]` +} + /** The regular expression for what `token` matches. */ export function globTokenToRegex(token: GlobToken): string { switch (token.kind) { @@ -205,8 +273,7 @@ export function globTokenToRegex(token: GlobToken): string { case GlobTokenKind.GlobstarSlash: return '(.*/)?' case GlobTokenKind.Set: - // A `-` stays what it is to the set, and the rest are its members. - return `[${token.members.replace(/[.^$+{}()|\\]/g, '\\$&')}]` + return setToRegex(token.members, token.negated) } } @@ -221,6 +288,7 @@ export function globTokenToRegex(token: GlobToken): string { * - ** matches any characters including / (e.g., src/**\/*.ts matches all .ts files in src/) * - ? matches any single character except / (e.g., file?.txt matches file1.txt) * - [abc] matches any character in the set (e.g., file[0-9].txt matches file3.txt) + * - [!abc] and [^abc] match any character outside the set, never a `/` * * Whatever a pattern spells, what is returned compiles, so that no entry keeps * a sandbox from starting (see {@link parseGlob}). Only its size can be more diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 38e8f5827..90a4fe1fc 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -1005,6 +1005,14 @@ describe.if(!isWindows)('walkGlobPattern', () => { join(root, 'outside', 'cert', 'x.pem'), join(root, 'outside', 'cert9x.pem'), ]) + // A negated set is one character of a name, whatever it leaves out. + expect(found('*/cert[!s/]x.pem')).toEqual([ + join(root, 'outside', 'cert9x.pem'), + ]) + expect(found('*/cert[^0-8]x.pem')).toEqual([ + join(root, 'outside', 'cert9x.pem'), + join(root, 'outside', 'certsx.pem'), + ]) } finally { rmSync(root, { recursive: true, force: true }) } @@ -2410,19 +2418,20 @@ describe('globToRegex (shared)', () => { expect(new RegExp(letters).test('/tmp/test/1bc.txt')).toBe(false) }) - it('never negates a bracket set: ^ and ! are members of it', () => { + it('negates a bracket set written with ^ or !', () => { // Both spellings used for negation elsewhere (regex `^`, gitignore `!`) - // land in the set as ordinary characters, so such a pattern matches - // fewer names than its author meant, not more. + // negate the set rather than joining it as ordinary characters. const caret = globToRegex('/tmp/test/file[^0-9].txt') - expect(new RegExp(caret).test('/tmp/test/file^.txt')).toBe(true) - expect(new RegExp(caret).test('/tmp/test/file3.txt')).toBe(true) - expect(new RegExp(caret).test('/tmp/test/fileA.txt')).toBe(false) + expect(new RegExp(caret).test('/tmp/test/fileA.txt')).toBe(true) + expect(new RegExp(caret).test('/tmp/test/file3.txt')).toBe(false) const bang = globToRegex('/tmp/test/file[!0-9].txt') - expect(new RegExp(bang).test('/tmp/test/file!.txt')).toBe(true) - expect(new RegExp(bang).test('/tmp/test/file3.txt')).toBe(true) - expect(new RegExp(bang).test('/tmp/test/fileA.txt')).toBe(false) + expect(new RegExp(bang).test('/tmp/test/fileA.txt')).toBe(true) + expect(new RegExp(bang).test('/tmp/test/file3.txt')).toBe(false) + + // The character that negates the set is not also a member of it. + expect(new RegExp(globToRegex('/tmp/[^^]')).test('/tmp/^')).toBe(false) + expect(new RegExp(globToRegex('/tmp/[!!]')).test('/tmp/!')).toBe(false) }) it('treats a bracket that opens no set as a literal character', () => { diff --git a/test/sandbox/glob-pattern.test.ts b/test/sandbox/glob-pattern.test.ts index 1aba7b277..07728f32b 100644 --- a/test/sandbox/glob-pattern.test.ts +++ b/test/sandbox/glob-pattern.test.ts @@ -11,8 +11,15 @@ const text = (t: string): GlobToken => ({ kind: Kind.Text, text: t }) const set = (members: string, matchesSeparator = false): GlobToken => ({ kind: Kind.Set, members, + negated: false, matchesSeparator, }) +const allBut = (members: string): GlobToken => ({ + kind: Kind.Set, + members, + negated: true, + matchesSeparator: false, +}) const SEPARATOR: GlobToken = { kind: Kind.Separator } const ANY_CHAR: GlobToken = { kind: Kind.AnyChar } const STAR: GlobToken = { kind: Kind.Star } @@ -73,8 +80,8 @@ describe('parseGlob', () => { ['[-a]', [set('-a')]], ['[a-]', [set('a-')]], ['[a-a]', [set('a-a')]], - ['[^a]', [set('^a')]], - ['[!a]', [set('!a')]], + ['[a^]', [set('a^')]], + ['[a!]', [set('a!')]], ['[a[b]', [set('a[b')]], ['[a]]', [set('a'), text(']')]], ['[s/]', [set('s/', true)]], @@ -87,11 +94,35 @@ describe('parseGlob', () => { expect(parseGlob(pattern)).toEqual(tokens) }) + it.each<[string, GlobToken[]]>([ + ['[^a]', [allBut('a')]], + ['[!a]', [allBut('a')]], + ['x[!a-z]y', [text('x'), allBut('a-z'), text('y')]], + // Only the first of them negates. + ['[!!]', [allBut('!')]], + ['[^^]', [allBut('^')]], + ['[!^]', [allBut('^')]], + ['[^!a]', [allBut('!a')]], + ['[!-a]', [allBut('-a')]], + ['[!*]', [allBut('*')]], + // Never the separator, be it a member or not. + ['[!s/]', [allBut('s/')]], + ['[!+-9]', [allBut('+-9')]], + ['[!a]]', [allBut('a'), text(']')]], + ])('reads %p as a negated set', (pattern, tokens) => { + expect(parseGlob(pattern)).toEqual(tokens) + }) + it.each<[string, GlobToken[]]>([ ['[z-a]', [text('[z-a]')]], ['[user-id]', [text('[user-id]')]], ['[]', [text('[]')]], ['[]a]', [text('[]a]')]], + ['[!]', [text('[!]')]], + ['[^]', [text('[^]')]], + ['[!]a]', [text('[!]a]')]], + ['[!z-a]', [text('[!z-a]')]], + ['[^user-id]', [text('[^user-id]')]], ['a]b', [text('a]b')]], ['a[b', [text('a[b')]], ['a[b[c', [text('a[b[c')]], @@ -106,6 +137,7 @@ describe('parseGlob', () => { it.each<[string, GlobToken[]]>([ ['[z-a*]', [text('[z-a'), STAR, text(']')]], + ['[!z-a*]', [text('[!z-a'), STAR, text(']')]], ['[z-a/?]', [text('[z-a'), SEPARATOR, ANY_CHAR, text(']')]], ['a[b[c*', [text('a[b[c'), STAR]], ['[**/', [text('['), GLOBSTAR_SLASH]], @@ -173,8 +205,18 @@ function referenceGlobMatch(pattern: string, pathText: string): boolean { if (head === '?') return pathText[s] !== '/' && rest(p + 1, s + 1) if (head === '[' && p >= bracketsAreTextUntil) { const close = pattern.indexOf(']', p + 1) - const has = close === -1 ? undefined : setOf(pattern.slice(p + 1, close)) - if (has !== undefined) return has(pathText[s]!) && rest(close + 1, s + 1) + const negated = pattern[p + 1] === '!' || pattern[p + 1] === '^' + const has = + close === -1 + ? undefined + : setOf(pattern.slice(p + (negated ? 2 : 1), close)) + if (has !== undefined) { + const char = pathText[s]! + return ( + (negated ? !has(char) && char !== '/' : has(char)) && + rest(close + 1, s + 1) + ) + } bracketsAreTextUntil = close === -1 ? pattern.length : close + 1 } return pathText[s] === head && from(p + 1, s + 1, bracketsAreTextUntil) @@ -219,17 +261,24 @@ describe('globToRegex', () => { it('agrees with a reference matcher over generated patterns and paths', () => { const piece = fc.constantFrom( - ...'[[]]--**??//az09+.^!\\', + ...'[[]]--**??//az09+,.^!\\', '**/', '[a-z]', '[z-a]', '[+-9]', '[*]', '[]', + '[!a-z]', + '[^09]', + '[!z-a]', + '[!-a]', + '[!]', + '[+--]', + '[--0]', ) const pattern = fc.array(piece, { maxLength: 8 }).map(p => p.join('')) // A path like the pattern, so that a match is not rare, or any at all. - const char = fc.constantFrom(...'[]-*?/az09+.^!\\') + const char = fc.constantFrom(...'[]-*?/az09+,.^!\\') const pathFor = (p: string): fc.Arbitrary => fc.oneof( fc.array(char, { maxLength: 6 }).map(chars => chars.join('')), diff --git a/test/sandbox/glob-walk.property.test.ts b/test/sandbox/glob-walk.property.test.ts index 80a0f4d3d..0ffb98767 100644 --- a/test/sandbox/glob-walk.property.test.ts +++ b/test/sandbox/glob-walk.property.test.ts @@ -30,7 +30,8 @@ import { isWindows } from '../helpers/platform.js' /** Names that put the interesting readings next to each other: a bracket set * that can match a separator (`[+-9]` spans `/`), a `**` written against * text, a name that is a prefix of another, a wildcard that is a member of a - * set, and brackets that spell no set (`b-a` runs backwards). */ + * set, brackets that spell no set (`b-a` runs backwards), and negated sets, + * which never match one, whatever their members. */ const NAME = fc.constantFrom( 'a', 'b', @@ -43,6 +44,7 @@ const NAME = fc.constantFrom( 's', '*', '[b-a]', + '[!b-a]', ) const SEGMENT = fc.constantFrom( @@ -61,6 +63,11 @@ const SEGMENT = fc.constantFrom( '[a*]', '**.pem', '[b-a]', + '[!ab]', + '[^s/]', + '[!+-9]', + 'cert[!x]', + '[!b-a]', ) const LAST_SEGMENT = fc.constantFrom( @@ -71,6 +78,8 @@ const LAST_SEGMENT = fc.constantFrom( '?.pem', '[xy].pem', '[+-9].pem', + '[!x].pem', + '[^+-9].pem', ) const PATTERN_TAIL = fc diff --git a/test/sandbox/macos-glob-sets.test.ts b/test/sandbox/macos-glob-sets.test.ts new file mode 100644 index 000000000..8c1c2c326 --- /dev/null +++ b/test/sandbox/macos-glob-sets.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, afterAll, beforeAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { denyGlobRegex, globToRegex } from '../../src/sandbox/glob-pattern.js' +import { isMacOS } from '../helpers/platform.js' + +/** + * globToRegex writes one string for two regex engines, JavaScript's and a + * macOS sandbox profile's. What a pattern means is glob-pattern.test.ts's; + * here it is that both engines read what is written for a set alike. + */ + +/** The characters the two disagree about, and a neighbour of each. */ +const TRICKY = [...'-,.0:=[\\^ab'] +/** Every set of one or two of them, and what only a range shows. */ +const SETS = [ + ...TRICKY, + ...TRICKY.flatMap(a => TRICKY.map(b => a + b)), + ...['--0', '+--', '+-9', 'a-c.', '0-9', 'x^-z', '[:alpha:', '$', 'cab'], + ...['\\-a', '-+.', ',.-'], +].flatMap(body => [`[${body}]`, `[!${body}]`]) + +describe('a bracket set, as it is written', () => { + it.each([ + // Neighbours as one range, the `/` of a negated set among them. + ['[!0-9]', '[^/-9]'], + ['[!.]', '[^.-/]'], + ['[!cab]', '[^/a-c]'], + ['[!$]', '[^$/]'], + // In ascending order, so that no `[` opens a POSIX element. + ['[[.]', '[.[]'], + ['[[:alpha:]', '[:[ahlp]'], + // A `-` first, and a range that starts or ends at one without it. + ['[a-]', '[-a]'], + ['[!-a]', '[^-/a]'], + ['[--0]', '[-.-0]'], + ['[+--]', '[-+-,]'], + // A `^` last, a backslash doubled, and a `-` alone without a set. + ['[a^]', '[a^]'], + ['[x^-z]', '[_-z^]'], + ['[!\\]', '[^/\\\\]'], + ['[-]', '-'], + // Beyond ASCII as written, after the rest. + ['[😀😁a]', '[a😀😁]'], + ['[!😀😁]', '[^/😀😁]'], + ])('%s is %s', (set, regex) => { + expect(globToRegex(`/d/${set}`)).toBe(`^/d/${regex}$`) + }) + + it('holds nothing the profile engine was seen to refuse or misread', () => { + const one = String.raw`(?:\\\\|[^\\\]-])` + const wellFormed = new RegExp(`^\\^?-?(?:${one}(?:-${one})?)+$`) + const bad: string[] = [] + let bodies = [''] + for (let length = 1; length <= 4; length++) { + bodies = bodies.flatMap(body => TRICKY.map(char => body + char)) + for (const glob of bodies.flatMap(b => [`[${b}]`, `[!${b}]`])) { + const regex = globToRegex(glob).slice(1, -1) + // Text, or a `-` that needs no set. + if (!regex.startsWith('[')) continue + const set = regex.slice(1, -1) + // Apart from the `-`, which has to stand first, no two neighbours of + // a negated set are written apart. + const rest = [...set.replace(/^\^-?/, '').replace(/\\\\/g, '\\')] + const codes = (beside: -1 | 1): number[] => + rest + .filter((char, i) => char !== '-' && rest[i + beside] !== '-') + .map(char => char.charCodeAt(0)) + const ends = codes(1) + if ( + !wellFormed.test(set) || + /\[[.:=]/.test(set) || + set.startsWith('^') !== /^\[[!^]/.test(glob) || + (set.startsWith('^') && + codes(-1) + .slice(1) + .some((start, i) => start <= ends[i]! + 1)) + ) { + bad.push(`${glob} is written ${regex}`) + } + } + } + expect(bad.slice(0, 10)).toEqual([]) + }) +}) + +describe.if(isMacOS)('a bracket set, as a sandbox profile reads it', () => { + const NAMES = [...new Set([...TRICKY, ...'+1]c!$z'])].map(char => `x${char}`) + const RULES = ['deny', 'allow', 'carved'] + let dir: string + + beforeAll(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'glob-sets-'))) + for (const rule of RULES) { + mkdirSync(join(dir, rule)) + for (const name of NAMES) writeFileSync(join(dir, rule, name), '') + } + }) + + afterAll(() => rmSync(dir, { recursive: true, force: true })) + + // The three places a pattern's regex goes: a deny, with its tail, an allow, + // and what is carved out of a deny. + it('as JavaScript does, in a deny, in an allow and in a carve-out', () => { + const differing: string[] = [] + for (const set of SETS) { + const regex = (rule: string, compile = globToRegex): string => + JSON.stringify(compile(join(dir, rule, `x${set}`))) + const { stdout } = spawnSync( + '/usr/bin/sandbox-exec', + [ + '-p', + `(version 1)(allow default) + (deny file-read* (regex ${regex('deny', denyGlobRegex)})) + (deny file-read* (subpath ${JSON.stringify(join(dir, 'allow'))})) + (allow file-read* (regex ${regex('allow')})) + (deny file-read* (require-all + (subpath ${JSON.stringify(join(dir, 'carved'))}) + (require-not (regex ${regex('carved')}))))`, + '/bin/sh', + '-c', + // By name: a directory denied whole lists nothing. + 'for f; do /bin/cat "$f" 2>/dev/null && echo "$f"; done', + 'sh', + ...RULES.flatMap(rule => NAMES.map(name => `${rule}/${name}`)), + ], + { cwd: dir, encoding: 'utf8', timeout: 10000 }, + ) + // A profile that does not load reads nothing. + const readable = stdout.split('\n') + const matches = new RegExp(globToRegex(`x${set}`)) + // A `-` cannot be written into a range with its neighbours, and with + // one of them a negated set is read as `[^/]` in a deny: wider. + const widened = /^\^x\[\^-.*(,|\.-)/.test(matches.source) + const wrong = RULES.flatMap(rule => + NAMES.filter( + name => + readable.includes(`${rule}/${name}`) !== + (rule === 'deny' + ? !widened && !matches.test(name) + : matches.test(name)), + ).map(name => `${rule}/${name}`), + ) + if (wrong.length > 0) { + differing.push(`${set}, ${matches.source}: ${wrong.join(' ')}`) + } + } + expect(differing).toEqual([]) + }, 120_000) +})