diff --git a/.changeset/every-feet-attack.md b/.changeset/every-feet-attack.md new file mode 100644 index 0000000000..5291b15953 --- /dev/null +++ b/.changeset/every-feet-attack.md @@ -0,0 +1,6 @@ +--- +'@e2b/python-sdk': patch +'e2b': patch +--- + +fix: validate copy src paths are relative and within context directory diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index c40184ab35..78d9035907 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -44,6 +44,7 @@ import { padOctal, readDockerignore, readGCPServiceAccountJSON, + validateRelativePath, } from './utils' /** @@ -520,10 +521,16 @@ export class TemplateBase } const srcs = Array.isArray(src) ? src : [src] + const stackTrace = getCallerFrame(STACK_TRACE_DEPTH - 1) for (const src of srcs) { + const srcString = src.toString() + + // Validate that the source path is a relative path within the context directory + validateRelativePath(srcString, stackTrace) + const args = [ - src.toString(), + srcString, dest.toString(), options?.user ?? '', options?.mode ? padOctal(options.mode) : '', @@ -547,14 +554,23 @@ export class TemplateBase throw new Error('Browser runtime is not supported for copyItems') } + // Stack trace that will be used to re-throw the error with + const stackTrace = getCallerFrame(STACK_TRACE_DEPTH - 1) + this.runInNewStackTraceContext(() => { for (const item of items) { - this.copy(item.src, item.dest, { - forceUpload: item.forceUpload, - user: item.user, - mode: item.mode, - resolveSymlinks: item.resolveSymlinks, - }) + try { + this.copy(item.src, item.dest, { + forceUpload: item.forceUpload, + user: item.user, + mode: item.mode, + resolveSymlinks: item.resolveSymlinks, + }) + } catch (error) { + const copyError = error as Error + copyError.stack = stackTrace + throw copyError + } } }) diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 34670767c4..ad907ea834 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -7,6 +7,60 @@ import { BASE_STEP_NAME, FINALIZE_STEP_NAME } from './consts' import type { Path } from 'glob' import type { BuildOptions } from './types' +/** + * Validate that a source path for copy operations is a relative path that stays + * within the context directory. This prevents path traversal attacks and ensures + * files are copied from within the expected directory. + * + * @param src The source path to validate + * @param stackTrace Optional stack trace for error reporting + * @throws TemplateError if the path is absolute or escapes the context directory + * + * Invalid paths: + * - Absolute paths: /absolute/path, C:\Windows\path + * - Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar + * + * Valid paths: + * - Simple relative: foo, foo/bar + * - Current directory prefix: ./foo, ./foo/bar + * - Internal parent refs that don't escape: foo/../bar (stays within context) + */ +export function validateRelativePath( + src: string, + stackTrace: string | undefined +): void { + // Check for absolute paths using Node's cross-platform implementation + if (path.isAbsolute(src)) { + const error = new TemplateError( + `Invalid source path "${src}": absolute paths are not allowed. Use a relative path within the context directory.`, + stackTrace + ) + throw error + } + + // Normalize the path and check if it escapes the context directory + const normalized = path.normalize(src) + + // After normalization, a path that escapes would be '..' or start with '../' + // We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig' + // Examples: + // - '../foo' -> '../foo' (escapes) + // - 'foo/../../bar' -> '../bar' (escapes) + // - './foo/../../../bar' -> '../../bar' (escapes) + // - 'foo/../bar' -> 'bar' (doesn't escape) + // - './foo/bar' -> 'foo/bar' (doesn't escape) + // - '..myconfig' -> '..myconfig' (valid filename, doesn't escape) + const escapes = normalized === '..' || normalized.startsWith('..' + path.sep) + + if (escapes) { + const error = new TemplateError( + `Invalid source path "${src}": path escapes the context directory. The path must stay within the context directory.`, + stackTrace + ) + throw error + } +} + /** * Normalize build arguments from different overload signatures. * Handles string name or legacy options object with alias. diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts index 7a401f980a..65d1583389 100644 --- a/packages/js-sdk/tests/template/stacktrace.test.ts +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -9,7 +9,7 @@ import { apiUrl, buildTemplateTest } from '../setup' import { randomUUID } from 'node:crypto' const __fileContent = fs.readFileSync(__filename, 'utf8') // read current file content -const nonExistentPath = '/nonexistent/path' +const nonExistentPath = 'nonexistent/path' // map template alias -> failed step index const failureMap: Record = { @@ -212,6 +212,20 @@ buildTemplateTest('traces on copyItems', async ({ buildTemplate }) => { }, 'copyItems') }) +buildTemplateTest('traces on copy absolute path', async () => { + await expectToThrowAndCheckTrace(async () => { + Template().fromBaseImage().copy('/absolute/path', '/absolute/path') + }, 'copy') +}) + +buildTemplateTest('traces on copyItems absolute path', async () => { + await expectToThrowAndCheckTrace(async () => { + Template() + .fromBaseImage() + .copyItems([{ src: '/absolute/path', dest: '/absolute/path' }]) + }, 'copyItems') +}) + buildTemplateTest('traces on remove', async ({ buildTemplate }) => { let template = Template().fromBaseImage() template = template.skipCache().remove(nonExistentPath) diff --git a/packages/js-sdk/tests/template/utils/validateRelativePath.test.ts b/packages/js-sdk/tests/template/utils/validateRelativePath.test.ts new file mode 100644 index 0000000000..7c6e22e774 --- /dev/null +++ b/packages/js-sdk/tests/template/utils/validateRelativePath.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from 'vitest' +import { validateRelativePath } from '../../../src/template/utils' +import { TemplateError } from '../../../src/errors' + +const isWindows = process.platform === 'win32' + +describe('validateRelativePath', () => { + describe('valid paths', () => { + test('accepts simple relative path', () => { + expect(() => validateRelativePath('foo', undefined)).not.toThrow() + }) + + test('accepts nested relative path', () => { + expect(() => validateRelativePath('foo/bar', undefined)).not.toThrow() + }) + + test('accepts path with ./ prefix', () => { + expect(() => validateRelativePath('./foo', undefined)).not.toThrow() + }) + + test('accepts nested path with ./ prefix', () => { + expect(() => validateRelativePath('./foo/bar', undefined)).not.toThrow() + }) + + test('accepts path with internal parent ref that stays within context', () => { + expect(() => validateRelativePath('foo/../bar', undefined)).not.toThrow() + }) + + test('accepts current directory', () => { + expect(() => validateRelativePath('.', undefined)).not.toThrow() + }) + + test('accepts glob patterns', () => { + expect(() => validateRelativePath('*.txt', undefined)).not.toThrow() + expect(() => validateRelativePath('**/*.ts', undefined)).not.toThrow() + expect(() => validateRelativePath('src/**/*', undefined)).not.toThrow() + }) + + test('accepts hidden files and directories', () => { + expect(() => validateRelativePath('.hidden', undefined)).not.toThrow() + expect(() => + validateRelativePath('.config/settings', undefined) + ).not.toThrow() + }) + + test('accepts filenames starting with double dots', () => { + expect(() => validateRelativePath('..myconfig', undefined)).not.toThrow() + expect(() => validateRelativePath('..cache', undefined)).not.toThrow() + expect(() => + validateRelativePath('...something', undefined) + ).not.toThrow() + expect(() => + validateRelativePath('foo/..myconfig', undefined) + ).not.toThrow() + }) + }) + + describe('invalid paths - absolute', () => { + test('rejects Unix absolute path', () => { + expect(() => validateRelativePath('/absolute/path', undefined)).toThrow( + TemplateError + ) + expect(() => validateRelativePath('/absolute/path', undefined)).toThrow( + 'absolute paths are not allowed' + ) + }) + + test('rejects root path', () => { + expect(() => validateRelativePath('/', undefined)).toThrow(TemplateError) + }) + + // Windows path tests - only run on Windows where path.isAbsolute detects them + test.skipIf(!isWindows)('rejects Windows drive letter path', () => { + expect(() => + validateRelativePath('C:\\Windows\\System32', undefined) + ).toThrow(TemplateError) + expect(() => + validateRelativePath('C:\\Windows\\System32', undefined) + ).toThrow('absolute paths are not allowed') + }) + + test.skipIf(!isWindows)('rejects Windows UNC path', () => { + expect(() => + validateRelativePath('\\\\server\\share', undefined) + ).toThrow(TemplateError) + }) + }) + + describe('invalid paths - parent directory escape', () => { + test('rejects simple parent directory escape', () => { + expect(() => validateRelativePath('../foo', undefined)).toThrow( + TemplateError + ) + expect(() => validateRelativePath('../foo', undefined)).toThrow( + 'path escapes the context directory' + ) + }) + + test('rejects parent directory escape with forward slash', () => { + expect(() => validateRelativePath('../file.txt', undefined)).toThrow( + TemplateError + ) + }) + + test.skipIf(!isWindows)( + 'rejects parent directory escape with backslash', + () => { + expect(() => validateRelativePath('..\\file.txt', undefined)).toThrow( + TemplateError + ) + } + ) + + test('rejects double parent directory escape', () => { + expect(() => validateRelativePath('../../foo', undefined)).toThrow( + TemplateError + ) + }) + + test('rejects path that escapes via nested parent refs', () => { + expect(() => validateRelativePath('foo/../../bar', undefined)).toThrow( + TemplateError + ) + }) + + test('rejects path with ./ prefix that escapes', () => { + expect(() => + validateRelativePath('./foo/../../../bar', undefined) + ).toThrow(TemplateError) + }) + + test('rejects just parent directory', () => { + expect(() => validateRelativePath('..', undefined)).toThrow(TemplateError) + }) + + test('rejects current directory followed by parent', () => { + expect(() => validateRelativePath('./..', undefined)).toThrow( + TemplateError + ) + }) + + test('rejects deeply nested escape', () => { + expect(() => + validateRelativePath('a/b/c/../../../../escape', undefined) + ).toThrow(TemplateError) + }) + }) + + describe('error messages include path', () => { + test('absolute path error includes the path', () => { + try { + validateRelativePath('/etc/passwd', undefined) + expect.fail('Should have thrown') + } catch (e) { + expect(e.message).toContain('/etc/passwd') + } + }) + + test('escape path error includes the path', () => { + try { + validateRelativePath('../secret', undefined) + expect.fail('Should have thrown') + } catch (e) { + expect(e.message).toContain('../secret') + } + }) + }) +}) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index 9d20c5fc3c..be647b753d 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -17,10 +17,12 @@ from e2b.template.utils import ( calculate_files_hash, get_caller_directory, + make_traceback, pad_octal, read_dockerignore, read_gcp_service_account_json, get_caller_frame, + validate_relative_path, ) from types import TracebackType @@ -64,9 +66,18 @@ def copy( """ srcs = [src] if isinstance(src, (str, Path)) else src + # Get the caller frame for stack trace in validation errors + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + for src_item in srcs: + src_string = str(src_item) + + # Validate that the source path is a relative path within the context directory + validate_relative_path(src_string, stack_trace) + args = [ - str(src_item), + src_string, str(dest), user or "", pad_octal(mode) if mode else "", @@ -101,19 +112,28 @@ def copy_items(self, items: List[CopyItem]) -> "TemplateBuilder": ]) ``` """ - self._template._run_in_new_stack_trace_context( - lambda: [ - self.copy( - item["src"], - item["dest"], - item.get("forceUpload"), - item.get("user"), - item.get("mode"), - item.get("resolveSymlinks"), - ) - for item in items - ] - ) + # Get the stack trace at the copy_items call site + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + + def _copy_items(): + for item in items: + try: + self.copy( + item["src"], + item["dest"], + item.get("forceUpload"), + item.get("user"), + item.get("mode"), + item.get("resolveSymlinks"), + ) + except Exception as error: + # Re-raise the error with the captured stack trace + if stack_trace is not None: + raise error.with_traceback(stack_trace) + raise + + self._template._run_in_new_stack_trace_context(_copy_items) return self def remove( @@ -485,14 +505,7 @@ def add_mcp_server(self, servers: Union[str, List[str]]) -> "TemplateBuilder": """ if self._template._base_template != "mcp-gateway": caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) - stack_trace = None - if caller_frame is not None: - stack_trace = TracebackType( - tb_next=None, - tb_frame=caller_frame, - tb_lasti=caller_frame.f_lasti, - tb_lineno=caller_frame.f_lineno, - ) + stack_trace = make_traceback(caller_frame) raise BuildException( "MCP servers can only be added to mcp-gateway template" ).with_traceback(stack_trace) @@ -561,14 +574,7 @@ def beta_dev_container_prebuild( """ if self._template._base_template != "devcontainer": caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) - stack_trace = None - if caller_frame is not None: - stack_trace = TracebackType( - tb_next=None, - tb_frame=caller_frame, - tb_lasti=caller_frame.f_lasti, - tb_lineno=caller_frame.f_lineno, - ) + stack_trace = make_traceback(caller_frame) raise BuildException( "Devcontainers can only used in the devcontainer template" ).with_traceback(stack_trace) @@ -607,14 +613,7 @@ def beta_set_dev_container_start( """ if self._template._base_template != "devcontainer": caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) - stack_trace = None - if caller_frame is not None: - stack_trace = TracebackType( - tb_next=None, - tb_frame=caller_frame, - tb_lasti=caller_frame.f_lasti, - tb_lineno=caller_frame.f_lineno, - ) + stack_trace = make_traceback(caller_frame) raise BuildException( "Devcontainers can only used in the devcontainer template" ).with_traceback(stack_trace) @@ -830,19 +829,7 @@ def _collect_stack_trace( return self stack = get_caller_frame(stack_traces_depth) - if stack is None: - self._stack_traces.append(None) - return self - - # Create a traceback object from the caller frame - capture_stack_trace = TracebackType( - tb_next=None, - tb_frame=stack, - tb_lasti=stack.f_lasti, - tb_lineno=stack.f_lineno, - ) - - self._stack_traces.append(capture_stack_trace) + self._stack_traces.append(make_traceback(stack)) return self def _disable_stack_trace(self) -> "TemplateBase": @@ -1073,14 +1060,7 @@ def from_dockerfile(self, dockerfile_content_or_path: str) -> TemplateBuilder: # Get the caller frame to use for stack trace override # -1 as we're going up the call stack from the parse_dockerfile function caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) - stack_trace_override = None - if caller_frame is not None: - stack_trace_override = TracebackType( - tb_next=None, - tb_frame=caller_frame, - tb_lasti=caller_frame.f_lasti, - tb_lineno=caller_frame.f_lineno, - ) + stack_trace_override = make_traceback(caller_frame) # Parse the dockerfile using the builder as the interface base_image = self._run_in_stack_trace_override_context( diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index 09f87ac095..4477eee270 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -14,6 +14,74 @@ from e2b.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME +def make_traceback(caller_frame: Optional[FrameType]) -> Optional[TracebackType]: + """ + Create a TracebackType from a caller frame for error reporting. + + :param caller_frame: The caller's frame object, or None + :return: A TracebackType object for use with exception.with_traceback(), or None + """ + if caller_frame is None: + return None + return TracebackType( + tb_next=None, + tb_frame=caller_frame, + tb_lasti=caller_frame.f_lasti, + tb_lineno=caller_frame.f_lineno, + ) + + +def validate_relative_path( + src: str, + stack_trace: Optional[TracebackType], +) -> None: + """ + Validate that a source path for copy operations is a relative path that stays + within the context directory. This prevents path traversal attacks and ensures + files are copied from within the expected directory. + + :param src: The source path to validate + :param stack_trace: Optional stack trace for error reporting + + :raises TemplateException: If the path is absolute or escapes the context directory + + Invalid paths: + - Absolute paths: /absolute/path, C:\\Windows\\path + - Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar + + Valid paths: + - Simple relative: foo, foo/bar + - Current directory prefix: ./foo, ./foo/bar + - Internal parent refs that don't escape: foo/../bar (stays within context) + """ + # Check for absolute paths using Python's cross-platform implementation + if os.path.isabs(src): + raise TemplateException( + f'Invalid source path "{src}": absolute paths are not allowed. ' + "Use a relative path within the context directory." + ).with_traceback(stack_trace) + + # Normalize the path and check if it escapes the context directory + normalized = os.path.normpath(src) + + # After normalization, a path that escapes would be '..' or start with '../' + # We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig' + # Examples: + # - '../foo' -> '../foo' (escapes) + # - 'foo/../../bar' -> '../bar' (escapes) + # - './foo/../../../bar' -> '../../bar' (escapes) + # - 'foo/../bar' -> 'bar' (doesn't escape) + # - './foo/bar' -> 'foo/bar' (doesn't escape) + # - '..myconfig' -> '..myconfig' (valid filename, doesn't escape) + escapes = normalized == ".." or normalized.startswith(".." + os.sep) + + if escapes: + raise TemplateException( + f'Invalid source path "{src}": path escapes the context directory. ' + "The path must stay within the context directory." + ).with_traceback(stack_trace) + + def normalize_build_arguments( name: Optional[str] = None, alias: Optional[str] = None, diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index 08acc92068..03bc2ca162 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -11,7 +11,7 @@ import e2b.template_async.main as template_async_main import e2b.template_async.build_api as build_api_mod -non_existent_path = "/nonexistent/path" +non_existent_path = "nonexistent/path" # map template alias -> failed step index failure_map: dict[str, Optional[int]] = { @@ -179,6 +179,26 @@ async def test_traces_on_copyItems(async_build): ) +@pytest.mark.skip_debug() +async def test_traces_on_copy_absolute_path(): + await _expect_to_throw_and_check_trace( + lambda: AsyncTemplate() + .from_base_image() + .copy("/absolute/path", "/absolute/path"), + "copy", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_copyItems_absolute_path(): + await _expect_to_throw_and_check_trace( + lambda: AsyncTemplate() + .from_base_image() + .copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")]), + "copy_items", + ) + + @pytest.mark.skip_debug() async def test_traces_on_remove(async_build): template = AsyncTemplate() diff --git a/packages/python-sdk/tests/shared/template/utils/test_validate_relative_path.py b/packages/python-sdk/tests/shared/template/utils/test_validate_relative_path.py new file mode 100644 index 0000000000..0fbbb4357c --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_validate_relative_path.py @@ -0,0 +1,106 @@ +import sys +import pytest + +from e2b.template.utils import validate_relative_path +from e2b.exceptions import TemplateException + +is_windows = sys.platform == "win32" + + +class TestValidateRelativePathValid: + """Test cases for valid paths.""" + + def test_accepts_simple_relative_path(self): + validate_relative_path("foo", None) + + def test_accepts_nested_relative_path(self): + validate_relative_path("foo/bar", None) + + def test_accepts_path_with_dot_prefix(self): + validate_relative_path("./foo", None) + + def test_accepts_nested_path_with_dot_prefix(self): + validate_relative_path("./foo/bar", None) + + def test_accepts_internal_parent_ref_within_context(self): + validate_relative_path("foo/../bar", None) + + def test_accepts_current_directory(self): + validate_relative_path(".", None) + + def test_accepts_glob_patterns(self): + validate_relative_path("*.txt", None) + validate_relative_path("**/*.ts", None) + validate_relative_path("src/**/*", None) + + def test_accepts_filenames_starting_with_double_dots(self): + validate_relative_path("..myconfig", None) + validate_relative_path("..cache", None) + validate_relative_path("...something", None) + validate_relative_path("foo/..myconfig", None) + + +class TestValidateRelativePathInvalidAbsolute: + """Test cases for invalid absolute paths.""" + + def test_rejects_unix_absolute_path(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("/absolute/path", None) + assert "absolute paths are not allowed" in str(excinfo.value) + + def test_rejects_root_path(self): + with pytest.raises(TemplateException): + validate_relative_path("/", None) + + @pytest.mark.skipif(not is_windows, reason="Windows path test only runs on Windows") + def test_rejects_windows_drive_letter_path(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("C:\\Windows\\System32", None) + assert "absolute paths are not allowed" in str(excinfo.value) + + +class TestValidateRelativePathInvalidEscape: + """Test cases for paths that escape the context directory.""" + + def test_rejects_simple_parent_directory_escape(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("../foo", None) + assert "path escapes the context directory" in str(excinfo.value) + + def test_rejects_double_parent_directory_escape(self): + with pytest.raises(TemplateException): + validate_relative_path("../../foo", None) + + def test_rejects_nested_parent_refs_escape(self): + with pytest.raises(TemplateException): + validate_relative_path("foo/../../bar", None) + + def test_rejects_dot_prefix_escape(self): + with pytest.raises(TemplateException): + validate_relative_path("./foo/../../../bar", None) + + def test_rejects_just_parent_directory(self): + with pytest.raises(TemplateException): + validate_relative_path("..", None) + + def test_rejects_current_directory_followed_by_parent(self): + with pytest.raises(TemplateException): + validate_relative_path("./..", None) + + def test_rejects_deeply_nested_escape(self): + with pytest.raises(TemplateException): + validate_relative_path("a/b/c/../../../../escape", None) + + +class TestValidateRelativePathErrorMessages: + """Test cases for error message content.""" + + def test_absolute_path_error_includes_path(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("/etc/passwd", None) + assert "/etc/passwd" in str(excinfo.value) + + def test_escape_path_error_includes_path(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("../secret", None) + assert "../secret" in str(excinfo.value) diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index 489f451dc7..5801247523 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -11,7 +11,7 @@ import e2b.template_sync.main as template_sync_main import e2b.template_sync.build_api as build_api_mod -non_existent_path = "/nonexistent/path" +non_existent_path = "nonexistent/path" # map template alias -> failed step index failure_map: dict[str, Optional[int]] = { @@ -181,6 +181,24 @@ def test_traces_on_copyItems(build): ) +@pytest.mark.skip_debug() +def test_traces_on_copy_absolute_path(): + _expect_to_throw_and_check_trace( + lambda: Template().from_base_image().copy("/absolute/path", "/absolute/path"), + "copy", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_copyItems_absolute_path(): + _expect_to_throw_and_check_trace( + lambda: Template() + .from_base_image() + .copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")]), + "copy_items", + ) + + @pytest.mark.skip_debug() def test_traces_on_remove(build): template = Template()