Skip to content
Open
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/resolved-directory-symlinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'e2b': patch
'@e2b/python-sdk': patch
---

Include directory symlink contents in template COPY uploads and cache hashes when symlink resolution is enabled, while honoring ignore rules. Report cyclic or out-of-context resolved paths as build errors at the user copy callsite.
9 changes: 9 additions & 0 deletions packages/js-sdk/src/template/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,15 @@ export class TemplateBase
return this
}

/**
* Copy local files or directories into the template.
*
* With `resolveSymlinks: true`, the build follows file and directory links
* within the file context. Detected directory-link cycles or targets outside
* that context fail the build with a `BuildError` before upload. Exclude the
* link, move its target into the context, or leave resolution disabled to
* copy the link itself (the default).
*/
copy(
src: PathLike | PathLike[],
dest: PathLike,
Expand Down
5 changes: 5 additions & 0 deletions packages/js-sdk/src/template/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,11 @@ export type CopyItem = {
forceUpload?: true
user?: string
mode?: number
/**
* Follow file and directory links within the file context during the build.
* Detected directory-link cycles or out-of-context targets cause a BuildError.
* Defaults to false, preserving the links themselves.
*/
resolveSymlinks?: boolean
gzip?: boolean
}
Expand Down
124 changes: 104 additions & 20 deletions packages/js-sdk/src/template/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from 'node:path'
import url from 'node:url'
import { parse, type StackFrame } from 'error-stack-parser-es'
import { dynamicImport } from '../utils'
import { TemplateError } from '../errors'
import { BuildError, TemplateError } from '../errors'
import { BASE_STEP_NAME, FINALIZE_STEP_NAME } from './consts'
import { PatternMatcher } from '@e2b/dockerfile-utils'
import type { IgnoreLike, Path } from 'glob'
Expand Down Expand Up @@ -166,8 +166,9 @@ function normalizeIgnorePattern(pattern: string, contextPath: string): string {
*/
function createIgnoreMatcher(
ignorePatterns: string[],
contextPath: string
): IgnoreLike {
contextPath: string,
resolveSymlinks: boolean = false
): IgnoreLike & { isIgnored: (entry: Path) => boolean } {
const absoluteContextPath = path.resolve(contextPath)
let matcher: PatternMatcher
try {
Expand All @@ -181,15 +182,18 @@ function createIgnoreMatcher(
throw new TemplateError((err as Error).message)
}

const ignored = (p: Path) => {
const isIgnored = (p: Path) => {
const relativePath = p.relativePosix()
return relativePath !== '' && matcher.matches(relativePath)
}

return {
ignored,
isIgnored,
ignored: (p) =>
isIgnored(p) &&
(!resolveSymlinks || !matcher.mayMatchUnder(p.relativePosix())),
childrenIgnored: (p) =>
ignored(p) && !matcher.mayMatchUnder(p.relativePosix()),
isIgnored(p) && !matcher.mayMatchUnder(p.relativePosix()),
}
}

Expand All @@ -205,11 +209,21 @@ export async function getAllFilesInPath(
src: string,
contextPath: string,
ignorePatterns: string[],
includeDirectories: boolean = true
includeDirectories: boolean = true,
resolveSymlinks: boolean = false
) {
const { glob } = await dynamicImport<typeof import('glob')>('glob')
const { glob, escape } = await dynamicImport<typeof import('glob')>('glob')
const files = new Map<string, Path>()
const ignore = createIgnoreMatcher(ignorePatterns, contextPath)
const linkCandidates = new Map<string, Path>()
const ignore = createIgnoreMatcher(
ignorePatterns,
contextPath,
resolveSymlinks
)
const addMatch = (entry: Path) => {
if (!ignore.isIgnored(entry)) files.set(entry.fullpath(), entry)
if (resolveSymlinks) linkCandidates.set(entry.fullpath(), entry)
}

const globFiles = await glob(src, {
ignore,
Expand Down Expand Up @@ -246,29 +260,89 @@ export async function getAllFilesInPath(
if (isUnderWalkedDir(file.relativePosix())) {
continue
}
if (resolveSymlinks) linkCandidates.set(file.fullpath(), file)
if (file.isDirectory()) {
walkedDirs.add(file.relativePosix())
// For directories, add the directory itself and all files inside it
if (includeDirectories) {
if (includeDirectories && !ignore.isIgnored(file)) {
files.set(file.fullpath(), file)
}
const dirPattern = normalizePath(
// When the matched directory is '.', `file.relative()` can be an empty string.
// In that case, we want to match all files under the current directory instead of
// creating an absolute glob like '/**/*' which would traverse the entire filesystem.
path.join(file.relative() || '.', '**/*')
)
const dirPattern = resolveSymlinks
? `${escape(file.relativePosix() || '.')}/**/*`
: normalizePath(
// When the matched directory is '.', `file.relative()` can be an empty string.
// In that case, we want to match all files under the current directory instead of
// creating an absolute glob like '/**/*' which would traverse the entire filesystem.
path.join(file.relative() || '.', '**/*')
)
const dirFiles = await glob(dirPattern, {
ignore,
withFileTypes: true,
dot: true,
cwd: contextPath,
})
dirFiles.forEach((f) => files.set(f.fullpath(), f))
dirFiles.forEach(addMatch)
} else {
// For files, just add the file
files.set(file.fullpath(), file)
addMatch(file)
}
}

if (resolveSymlinks) {
const contextRoot = path.resolve(contextPath)
let realContextRoot: string | undefined
const resolveInContext = async (file: Path) => {
realContextRoot ??= await fs.promises.realpath(contextRoot)
const realPath = await fs.promises.realpath(file.fullpath())
const relativeTarget = path.relative(realContextRoot, realPath)
if (
relativeTarget === '..' ||
relativeTarget.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeTarget)
) {
throw new BuildError(
`Path '${file.relativePosix()}' resolves outside the template context. Move the target inside the context, exclude the link via .dockerignore, or set \`resolveSymlinks: false\` to copy the link itself.`
)
}
return realPath
}
for (const file of files.values()) linkCandidates.set(file.fullpath(), file)
// Keep logical paths so both ignore rules and archive names use the link's
// location, rather than the resolved target's location.
// Ignored links can still contain explicitly re-included descendants.
for (const file of linkCandidates.values()) {
const entry = await fs.promises
.lstat(file.fullpath())
.catch(() => undefined)
if (!entry?.isSymbolicLink()) continue
const target = await fs.promises
.stat(file.fullpath())
.catch(() => undefined)
if (!target?.isDirectory()) continue

const realPath = await resolveInContext(file)
let parent = path.dirname(file.fullpath())
while (parent !== contextRoot || files.has(parent)) {
if ((await fs.promises.realpath(parent)) === realPath) {
throw new BuildError(
`Cannot resolve cyclic directory symlink '${file.relativePosix()}'. Remove the link, exclude it via .dockerignore, or set \`resolveSymlinks: false\` to copy the link itself.`
)
}
const next = path.dirname(parent)
if (next === parent) break
parent = next
}

const linkedFiles = await glob(
`${escape(file.relativePosix() || '.')}/**/*`,
{ ignore, withFileTypes: true, dot: true, cwd: contextPath }
Comment on lines +336 to +338

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Special-character links lose their contents

When a matched directory link has brackets in its name, getAllFilesInPath treats that name as a new glob pattern. Its target files disappear from the archive and cache hash, or files from a different directory appear instead.

Learn more

The initial glob can match a symlink with a filename containing glob metacharacters, especially when the source is a wildcard. Its relative() value is a filesystem path, not a glob-escaped prefix. Passing that value to glob again changes the meaning of characters such as [ and ]. The same construction already exists for ordinary matched directories, but this change newly uses it for matched symlink directories.

Example: With alias[1] -> real and real/app.py, copying * matches alias[1]. The generated pattern alias[1]/**/* looks under alias1 instead of alias[1], leaving the link's uploaded directory empty. An alias1 directory can contribute unrelated files.

Recommended fix: Escape glob metacharacters in the link's literal relative path before appending /**/*, or enumerate children starting at the literal directory path while preserving logical paths and applying ignore patterns.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

)
walkedDirs.add(file.relativePosix())
for (const child of linkedFiles) {
addMatch(child)
}
}
for (const file of files.values()) await resolveInContext(file)
}

// Sort by full path for a deterministic order — the default sort() would
Expand Down Expand Up @@ -329,7 +403,16 @@ export async function calculateFilesHash(

hash.update(content)

const files = await getAllFilesInPath(src, contextPath, ignorePatterns, true)
const files = await getAllFilesInPath(
src,
contextPath,
ignorePatterns,
true,
resolveSymlinks
).catch((error: unknown) => {
if (error instanceof BuildError && stackTrace) error.stack = stackTrace
throw error
})

if (files.length === 0) {
const error = new Error(`No files found in ${srcPath}`)
Expand Down Expand Up @@ -533,7 +616,8 @@ export async function spoolTarArchive(
fileName,
fileContextPath,
ignorePatterns,
true
true,
resolveSymlinks
)

const filePaths = allFiles.map((file) => file.relativePosix())
Expand Down
Loading