From d8881c13085f1e7a70607b1ef895c86fd2a96f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9B=9B=E9=87=8E?= <528040597@qq.com> Date: Fri, 2 Oct 2026 23:05:00 +0800 Subject: [PATCH 1/2] fix(sdk): match literal suffixes after leading dockerignore globstars Fixes e2b-dev/E2B#1933. --- .changeset/hot-weeks-marry.md | 6 +++ packages/js-sdk/src/template/dockerignore.ts | 11 +++++ .../template/utils/getAllFilesInPath.test.ts | 43 +++++++++++++++++++ .../python-sdk/e2b/template/dockerignore.py | 10 +++++ .../utils/test_get_all_files_in_path.py | 33 ++++++++++++++ 5 files changed, 103 insertions(+) create mode 100644 .changeset/hot-weeks-marry.md diff --git a/.changeset/hot-weeks-marry.md b/.changeset/hot-weeks-marry.md new file mode 100644 index 0000000000..ee276e7bd3 --- /dev/null +++ b/.changeset/hot-weeks-marry.md @@ -0,0 +1,6 @@ +--- +'e2b': patch +'@e2b/python-sdk': patch +--- + +Fix Dockerignore patterns with a leading `**` and a literal suffix, such as `**.txt`, to match filenames at any depth when copying template files. Preserve regex matching when the suffix contains additional wildcard syntax. diff --git a/packages/js-sdk/src/template/dockerignore.ts b/packages/js-sdk/src/template/dockerignore.ts index 7bb99f0149..7cfeccc3c3 100644 --- a/packages/js-sdk/src/template/dockerignore.ts +++ b/packages/js-sdk/src/template/dockerignore.ts @@ -37,6 +37,17 @@ function clean(pattern: string): string { } function compile(pattern: string): RegExp { + // Moby treats a leading ** followed only by literals as a suffix match. + const suffix = pattern.slice(2) + if ( + pattern.startsWith('**') && + !pattern.startsWith('**/') && + !WILDCARD_CHARS.test(suffix) && + !suffix.includes(']') + ) { + return new RegExp('^.*' + escapeRegex(suffix) + '$') + } + let regex = '^' const n = pattern.length const backslashIsEscape = !backslashIsSeparator() diff --git a/packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts b/packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts index b5b8c6e544..7a94b78a6c 100644 --- a/packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts +++ b/packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts @@ -390,6 +390,49 @@ describe('getAllFilesInPath', () => { expect(files).toEqual(['.']) }) + test.each(['.txt', '(1).txt'])( + 'should match a leading globstar with literal suffix %s', + async (suffix) => { + const ignored = [ + `root${suffix}`, + `src/nested${suffix}`, + `src/generated/deep${suffix}`, + ] + for (const name of [...ignored, `keep${suffix}.bak`, 'keep1.txt']) { + await writeFile(join(testDir, name), 'x') + } + const files = await relativePaths('.', [`**${suffix}`]) + for (const name of ignored) { + expect(files).not.toContain(name) + } + expect(files).toContain(`keep${suffix}.bak`) + if (suffix === '(1).txt') { + expect(files).toContain('keep1.txt') + } + } + ) + + test.each(['**.t?t', '**.[t]xt', '**.*txt'])( + 'should keep globstar regex matching with more wildcards for %s', + async (pattern) => { + for (const name of ['root.txt', 'src/nested.txt']) { + await writeFile(join(testDir, name), 'x') + } + const files = await relativePaths('.', [pattern]) + expect(files).toContain('root.txt') + expect(files).toContain('src/nested.txt') + } + ) + + test('should re-include a literal globstar suffix', async () => { + expect(await relativePaths('.', ['**', '!**.ts'])).toEqual([ + '.', + 'src/app.spec.ts', + 'src/app.ts', + 'src/generated/api.ts', + ]) + }) + test('should not copy paths inside an ignored directory', async () => { const files = await relativePaths('node_modules/pkg', ['node_modules']) expect(files).toEqual([]) diff --git a/packages/python-sdk/e2b/template/dockerignore.py b/packages/python-sdk/e2b/template/dockerignore.py index d6909a15d3..ebc8b6faec 100644 --- a/packages/python-sdk/e2b/template/dockerignore.py +++ b/packages/python-sdk/e2b/template/dockerignore.py @@ -27,6 +27,16 @@ def _clean(pattern: str) -> str: def _compile(pattern: str) -> "re.Pattern[str]": + # Moby treats a leading ** followed only by literals as a suffix match. + suffix = pattern[2:] + if ( + pattern.startswith("**") + and not pattern.startswith("**/") + and not _WILDCARD_CHARS.search(suffix) + and "]" not in suffix + ): + return re.compile("^.*" + re.escape(suffix) + "$") + regex = "^" i, n = 0, len(pattern) while i < n: diff --git a/packages/python-sdk/tests/shared/template/utils/test_get_all_files_in_path.py b/packages/python-sdk/tests/shared/template/utils/test_get_all_files_in_path.py index 88a9bf0d35..271dad046a 100644 --- a/packages/python-sdk/tests/shared/template/utils/test_get_all_files_in_path.py +++ b/packages/python-sdk/tests/shared/template/utils/test_get_all_files_in_path.py @@ -384,6 +384,39 @@ def test_should_ignore_a_leading_slash_and_a_trailing_slash(self, test_dir): files = self.relative_paths(".", test_dir, ["/node_modules", "/.git/", "src/"]) assert files == [".", ".env"] + @pytest.mark.parametrize("suffix", [".txt", "(1).txt"]) + def test_should_match_a_leading_globstar_with_a_literal_suffix( + self, test_dir, suffix + ): + ignored = [ + f"root{suffix}", + f"src/nested{suffix}", + f"src/generated/deep{suffix}", + ] + for name in [*ignored, f"keep{suffix}.bak", "keep1.txt"]: + with open(os.path.join(test_dir, name), "w") as f: + f.write("x") + files = self.relative_paths(".", test_dir, [f"**{suffix}"]) + assert not set(ignored).intersection(files) + assert f"keep{suffix}.bak" in files + if suffix == "(1).txt": + assert "keep1.txt" in files + + @pytest.mark.parametrize("pattern", ["**.t?t", "**.[t]xt", "**.*txt"]) + def test_should_keep_globstar_regex_matching_with_more_wildcards( + self, test_dir, pattern + ): + for name in ["root.txt", "src/nested.txt"]: + with open(os.path.join(test_dir, name), "w") as f: + f.write("x") + files = self.relative_paths(".", test_dir, [pattern]) + assert "root.txt" in files + assert "src/nested.txt" in files + + def test_should_reinclude_a_literal_globstar_suffix(self, test_dir): + files = self.relative_paths(".", test_dir, ["**", "!**.ts"]) + assert files == [".", "src/app.spec.ts", "src/app.ts", "src/generated/api.ts"] + def test_should_never_exclude_the_context_root(self, test_dir): files = self.relative_paths(".", test_dir, [".", ".*", "src", "node_modules"]) assert files == ["."] From 78010e7282dfe4d1f17c2f4443ca66036d94f777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9B=9B=E9=87=8E?= <528040597@qq.com> Date: Sat, 3 Oct 2026 19:34:50 +0800 Subject: [PATCH 2/2] fix(sdk): keep literal globstar suffix matches newline-safe Follow-up to the leading-globstar literal-suffix fix (#1933), addressing a review comment on #1934: the suffix branch compiled to a plain regex where '.' does not cross newlines and (in Python) '$' also matches just before a trailing newline, so a filename containing a newline was matched wrongly in both directions. - Python: compile the suffix branch with re.DOTALL and anchor with '\Z', not '$'. - JS: add the 's' (dotAll) flag; '$' without 'm' already anchors to the absolute end. - Add string-level PatternMatcher unit tests in both SDKs covering normal, embedded-newline and trailing-newline names (deterministic, no filesystem). - Extend the changeset with the newline semantics. --- .changeset/hot-weeks-marry.md | 2 +- packages/js-sdk/src/template/dockerignore.ts | 5 +- .../tests/template/dockerignore.test.ts | 37 ++++++++++++++ .../python-sdk/e2b/template/dockerignore.py | 6 ++- .../utils/test_dockerignore_matcher.py | 50 +++++++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 packages/js-sdk/tests/template/dockerignore.test.ts create mode 100644 packages/python-sdk/tests/shared/template/utils/test_dockerignore_matcher.py diff --git a/.changeset/hot-weeks-marry.md b/.changeset/hot-weeks-marry.md index ee276e7bd3..7806bfeaeb 100644 --- a/.changeset/hot-weeks-marry.md +++ b/.changeset/hot-weeks-marry.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': patch --- -Fix Dockerignore patterns with a leading `**` and a literal suffix, such as `**.txt`, to match filenames at any depth when copying template files. Preserve regex matching when the suffix contains additional wildcard syntax. +Fix Dockerignore patterns with a leading `**` and a literal suffix, such as `**.txt`, to match filenames at any depth when copying template files. Preserve regex matching when the suffix contains additional wildcard syntax. The suffix match is newline-safe: filenames containing newlines still match on the literal suffix, and a trailing newline does not satisfy it. diff --git a/packages/js-sdk/src/template/dockerignore.ts b/packages/js-sdk/src/template/dockerignore.ts index 7cfeccc3c3..af60815921 100644 --- a/packages/js-sdk/src/template/dockerignore.ts +++ b/packages/js-sdk/src/template/dockerignore.ts @@ -45,7 +45,10 @@ function compile(pattern: string): RegExp { !WILDCARD_CHARS.test(suffix) && !suffix.includes(']') ) { - return new RegExp('^.*' + escapeRegex(suffix) + '$') + // 's' (dotAll): a filename can contain newlines and JS '.' does not + // match them by default, so without the flag '**.txt' misses + // 'a\n.txt'. ($ without 'm' already anchors to the absolute end.) + return new RegExp('^.*' + escapeRegex(suffix) + '$', 's') } let regex = '^' diff --git a/packages/js-sdk/tests/template/dockerignore.test.ts b/packages/js-sdk/tests/template/dockerignore.test.ts new file mode 100644 index 0000000000..1882fe5c45 --- /dev/null +++ b/packages/js-sdk/tests/template/dockerignore.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest' +import { PatternMatcher } from '../../src/template/dockerignore' + +describe('PatternMatcher', () => { + describe('leading globstar with a literal suffix', () => { + test.each([ + 'a.txt', + 'keep1.txt', + 'src/nested.txt', + 'src/generated/deep.txt', + 'weird\nname\nx.txt', + ])('matches %s', (path) => { + expect(new PatternMatcher(['**.txt']).matches(path)).toBe(true) + }) + + test.each(['keep.txt.bak', 'keep1.txtx', 'a.txt\n', 'notes.md'])( + 'does not match %s', + (path) => { + expect(new PatternMatcher(['**.txt']).matches(path)).toBe(false) + } + ) + + test('keeps regex characters in the suffix literal', () => { + const matcher = new PatternMatcher(['**(1).txt']) + expect(matcher.matches('root(1).txt')).toBe(true) + expect(matcher.matches('root1.txt')).toBe(false) + }) + + test('is newline-safe in both directions', () => { + // A filename can contain newlines; JS '.' does not match them without + // the dotAll flag, and '$' (without 'm') anchors to the absolute end. + const matcher = new PatternMatcher(['**.txt']) + expect(matcher.matches('a\n.txt')).toBe(true) + expect(matcher.matches('a.txt\n')).toBe(false) + }) + }) +}) diff --git a/packages/python-sdk/e2b/template/dockerignore.py b/packages/python-sdk/e2b/template/dockerignore.py index ebc8b6faec..5bcdeedce6 100644 --- a/packages/python-sdk/e2b/template/dockerignore.py +++ b/packages/python-sdk/e2b/template/dockerignore.py @@ -35,7 +35,11 @@ def _compile(pattern: str) -> "re.Pattern[str]": and not _WILDCARD_CHARS.search(suffix) and "]" not in suffix ): - return re.compile("^.*" + re.escape(suffix) + "$") + # ``re.DOTALL`` and ``\Z`` (not ``$``): a filename can contain + # newlines, Python's ``$`` also matches just before a trailing one, and + # '.' does not cross newlines by default — so a literal suffix match + # needs dot-all matching anchored at the absolute end of the path. + return re.compile("^.*" + re.escape(suffix) + r"\Z", re.DOTALL) regex = "^" i, n = 0, len(pattern) diff --git a/packages/python-sdk/tests/shared/template/utils/test_dockerignore_matcher.py b/packages/python-sdk/tests/shared/template/utils/test_dockerignore_matcher.py new file mode 100644 index 0000000000..adc3e0e52e --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_dockerignore_matcher.py @@ -0,0 +1,50 @@ +"""String-level unit tests for the .dockerignore ``PatternMatcher``. + +These cover the pattern semantics directly, without walking a filesystem, so +they run the same on every platform. +""" + +import pytest + +from e2b.template.dockerignore import PatternMatcher + + +class TestLeadingGlobstarLiteralSuffix: + """A leading ``**`` followed only by literals is a suffix match.""" + + @pytest.mark.parametrize( + "path", + [ + "a.txt", + "keep1.txt", + "src/nested.txt", + "src/generated/deep.txt", + "weird\nname\nx.txt", + ], + ) + def test_matches_a_path_ending_in_the_suffix(self, path: str) -> None: + assert PatternMatcher(["**.txt"]).matches(path) + + @pytest.mark.parametrize( + "path", + [ + "keep.txt.bak", + "keep1.txtx", + "a.txt\n", + "notes.md", + ], + ) + def test_does_not_match_without_the_suffix(self, path: str) -> None: + assert not PatternMatcher(["**.txt"]).matches(path) + + def test_regex_characters_in_the_suffix_stay_literal(self) -> None: + matcher = PatternMatcher(["**(1).txt"]) + assert matcher.matches("root(1).txt") + assert not matcher.matches("root1.txt") + + def test_a_newline_inside_the_name_does_not_hide_the_suffix(self) -> None: + """Filenames may contain newlines; the match must remain a literal + suffix match in both directions.""" + matcher = PatternMatcher(["**.txt"]) + assert matcher.matches("a\n.txt") + assert not matcher.matches("a.txt\n")