Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/hot-weeks-marry.md
Original file line number Diff line number Diff line change
@@ -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. The suffix match is newline-safe: filenames containing newlines still match on the literal suffix, and a trailing newline does not satisfy it.
14 changes: 14 additions & 0 deletions packages/js-sdk/src/template/dockerignore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ 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(']')
) {
// '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 = '^'
const n = pattern.length
const backslashIsEscape = !backslashIsSeparator()
Expand Down
37 changes: 37 additions & 0 deletions packages/js-sdk/tests/template/dockerignore.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
43 changes: 43 additions & 0 deletions packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
Expand Down
14 changes: 14 additions & 0 deletions packages/python-sdk/e2b/template/dockerignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ 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
):
# ``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)
while i < n:
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ["."]
Expand Down