diff --git a/.changeset/dockerignore-ascii-digit-class.md b/.changeset/dockerignore-ascii-digit-class.md new file mode 100644 index 0000000000..86ba6267ec --- /dev/null +++ b/.changeset/dockerignore-ascii-digit-class.md @@ -0,0 +1,5 @@ +--- +"@e2b/dockerfile-utils-python": patch +--- + +Keep bracket class escapes in Dockerignore patterns ASCII-only, matching Moby and the JavaScript utility. Non-ASCII decimal digits are no longer excluded by a pattern such as `[\d]`, and non-ASCII spaces by a pattern such as `[\s]`. diff --git a/.changeset/dockerignore-ascii-whitespace-class.md b/.changeset/dockerignore-ascii-whitespace-class.md new file mode 100644 index 0000000000..a8c853c22b --- /dev/null +++ b/.changeset/dockerignore-ascii-whitespace-class.md @@ -0,0 +1,5 @@ +--- +"@e2b/dockerfile-utils": patch +--- + +Keep bracket whitespace classes in Dockerignore patterns ASCII-only, matching Moby and the Python utility. Non-ASCII spaces such as U+00A0 are no longer matched by a pattern such as `[\s]`. diff --git a/packages/dockerfile-utils-js/src/dockerignore.ts b/packages/dockerfile-utils-js/src/dockerignore.ts index 6d5ae226d6..36581e9b4d 100644 --- a/packages/dockerfile-utils-js/src/dockerignore.ts +++ b/packages/dockerfile-utils-js/src/dockerignore.ts @@ -25,14 +25,23 @@ function escapeRegex(ch: string): string { } // Escapes that keep their regex meaning in a bracket expression, like in Python -const CLASS_ESCAPES = new Set('dDsSwWfnrtv') +const CLASS_ESCAPES = new Set('dDwWfnrtv') +// ASCII-only whitespace like Moby, as JS `\s` also matches Unicode spaces +const CLASS_WHITESPACE_ESCAPES: Record = { + s: '\\t-\\r ', + S: '\\u{0}-\\u{8}\\u{e}-\\u{1f}\\u{21}-\\u{10ffff}', +} const CLASS_CONTROL_ESCAPES: Record = { a: '\\x07', b: '\\x08' } const CLASS_HEX_ESCAPE_LENGTHS: Record = { x: 2, u: 4, U: 8 } // Characters that must stay escaped in a bracket expression with the `u` flag const CLASS_SYNTAX_CHARS = new Set('^$\\.*+?()[]{}|/-') // Make a bracket expression body valid with the regex `u` flag -function translateClass(body: string, backslashIsEscape: boolean): string { +function translateClass( + body: string, + backslashIsEscape: boolean, + unicodeWhitespace = false +): string { let cls = '' let i = 0 while (i < body.length) { @@ -45,6 +54,8 @@ function translateClass(body: string, backslashIsEscape: boolean): string { const next = body[i++] if (CLASS_ESCAPES.has(next)) { cls += '\\' + next + } else if (next in CLASS_WHITESPACE_ESCAPES) { + cls += unicodeWhitespace ? '\\' + next : CLASS_WHITESPACE_ESCAPES[next] } else if (next in CLASS_CONTROL_ESCAPES) { cls += CLASS_CONTROL_ESCAPES[next] } else if (next in CLASS_HEX_ESCAPE_LENGTHS) { @@ -78,6 +89,10 @@ function translateClass(body: string, backslashIsEscape: boolean): string { } } } + if (!unicodeWhitespace) { + // Reject ranges such as `[\s-x]` that the expanded set would allow + new RegExp('[' + translateClass(body, backslashIsEscape, true) + ']', 'u') + } return cls } diff --git a/packages/dockerfile-utils-js/tests/dockerignore.test.ts b/packages/dockerfile-utils-js/tests/dockerignore.test.ts index c778e6025f..fc7a382ca9 100644 --- a/packages/dockerfile-utils-js/tests/dockerignore.test.ts +++ b/packages/dockerfile-utils-js/tests/dockerignore.test.ts @@ -84,3 +84,20 @@ test('rejects invalid patterns with a plain Error', () => { "Invalid ignore pattern '[\\q]'" ) }) + +test.each([ + ['[\\d]', '5', true], + ['[\\d]', '\u0661', false], + ['[\\d]', 'd', false], + ['[\\s]', ' ', true], + ['[\\s]', '\u00a0', false], + ['[\\S]', '\u00a0', true], + ['[\\S]', '\t', false], +])('class %s matches %j: %s', (pattern, path, excluded) => { + assert.strictEqual(new PatternMatcher([pattern]).matches(path), excluded) +}) + +test('rejects ranges with a whitespace class escape', () => { + assert.throws(() => new PatternMatcher(['[\\s-x]']), Error) + assert.throws(() => new PatternMatcher(['[a-\\S]']), Error) +}) diff --git a/packages/dockerfile-utils-python/e2b_dockerfile_utils/dockerignore.py b/packages/dockerfile-utils-python/e2b_dockerfile_utils/dockerignore.py index 18e6d7448f..9353ffd4f5 100644 --- a/packages/dockerfile-utils-python/e2b_dockerfile_utils/dockerignore.py +++ b/packages/dockerfile-utils-python/e2b_dockerfile_utils/dockerignore.py @@ -144,7 +144,7 @@ def _compile(pattern: str, backslash_is_escape: bool) -> Callable[[str], bool]: return lambda path: path.endswith(suffix) or ( suffix.startswith("/") and path == suffix[1:] ) - compiled = re.compile(regex + r"\Z") + compiled = re.compile(regex + r"\Z", flags=re.ASCII) return lambda path: compiled.match(path) is not None diff --git a/packages/dockerfile-utils-python/tests/test_dockerignore.py b/packages/dockerfile-utils-python/tests/test_dockerignore.py index 4bed4766a3..5de2d1e79f 100644 --- a/packages/dockerfile-utils-python/tests/test_dockerignore.py +++ b/packages/dockerfile-utils-python/tests/test_dockerignore.py @@ -72,3 +72,26 @@ def test_rejects_invalid_patterns_with_value_error(): PatternMatcher(["[abc"]) with pytest.raises(ValueError, match=r"Invalid ignore pattern '\[\\q\]'"): PatternMatcher(["[\\q]"]) + + +@pytest.mark.parametrize( + ("pattern", "path", "excluded"), + [ + (r"[\d]", "5", True), + (r"[\d]", "\u0661", False), + (r"[\d]", "d", False), + (r"[\s]", " ", True), + (r"[\s]", "\u00a0", False), + (r"[\S]", "\u00a0", True), + (r"[\S]", "\t", False), + ], +) +def test_class_escapes_are_ascii_only(pattern, path, excluded): + assert PatternMatcher([pattern]).matches(path) is excluded + + +def test_rejects_ranges_with_a_whitespace_class_escape(): + with pytest.raises(ValueError): + PatternMatcher([r"[\s-x]"]) + with pytest.raises(ValueError): + PatternMatcher([r"[a-\S]"])