-
Notifications
You must be signed in to change notification settings - Fork 4.7k
fix(sandbox): reject apply_patch create_file on an existing file #4893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2e2509a
4a55a8d
b0e8ffa
c18d1cf
999dec0
592c2a0
9820c3f
8049c24
bd47f64
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,9 @@ | |
| import asyncio | ||
| import io | ||
| import shlex | ||
| import uuid | ||
| from collections.abc import Awaitable, Callable, Mapping, Sequence | ||
| from contextlib import suppress | ||
| from pathlib import Path, PurePath | ||
| from typing import Literal, NoReturn, TypeVar | ||
|
|
||
|
|
@@ -149,6 +151,31 @@ | |
| fi | ||
| done | ||
| """.strip() | ||
| _EXCLUSIVE_CREATE_EXISTS_CODE = 13 | ||
| # Classify an already-visible target before any payload is staged. Without this the | ||
| # staging write runs first, so a target inside an executable but non-writable parent | ||
| # fails on permissions and the caller sees a write error instead of the collision error | ||
| # that tells it to use update_file. The atomic claim below still decides real races. | ||
| _TARGET_EXISTS_SCRIPT = 'target="$1"\nif [ -e "$target" ] || [ -L "$target" ]; then exit 13; fi\n' | ||
|
|
||
| # ``ln`` claims the target name and fails when that name is already taken. Linking a | ||
| # fully written staging file means the content is complete before the name exists, so a | ||
| # failed or cancelled upload cannot leave a file behind that holds the name. The leading | ||
| # test rejects a name held by a directory, which ``ln`` would otherwise treat as a target | ||
| # directory and populate; the trailing test only classifies a failure, so "already | ||
| # exists" stays separable from any other error without parsing shell-specific stderr. | ||
| # ``ln`` is a regular command, unlike ``:``, so its failure still reaches the explicit | ||
| # exit mapping on shells where ``:`` is a special builtin. The caller creates the parent, | ||
| # so this script never has to create one as a different identity. | ||
| _EXCLUSIVE_CREATE_SCRIPT = ( | ||
| 'target="$1"\n' | ||
| 'source="$2"\n' | ||
| 'if [ -e "$target" ] || [ -L "$target" ]; then exit 13; fi\n' | ||
| 'ln "$source" "$target" 2>/dev/null && exit 0\n' | ||
|
seratch marked this conversation as resolved.
|
||
| 'if [ -e "$target" ] || [ -L "$target" ]; then exit 13; fi\n' | ||
| "exit 14\n" | ||
| ) | ||
|
seratch marked this conversation as resolved.
|
||
|
|
||
| _WRITE_ACCESS_CHECK_SCRIPT = ( | ||
| 'target="$1"\n' | ||
| 'if [ -e "$target" ]; then\n' | ||
|
|
@@ -945,6 +972,79 @@ async def write( | |
| :param user: Optional sandbox user to perform the write as. | ||
| """ | ||
|
|
||
| async def write_new_file( | ||
| self, | ||
| path: Path, | ||
| data: io.IOBase, | ||
| *, | ||
| user: str | User | None = None, | ||
| ) -> None: | ||
| """Write a file that must not already exist. | ||
|
|
||
| The target name is claimed in a single atomic step once the payload is complete, | ||
| so a concurrent creator either loses the race or keeps its own content, and a | ||
| failed write does not leave a partial file holding the name. | ||
|
|
||
| :param path: Absolute path in the container or path relative to the | ||
| workspace root. | ||
| :param data: A file-like object positioned at the start of the payload. | ||
| :param user: Optional sandbox user to perform the write as. | ||
| :raises FileExistsError: If the path already exists, including a dangling symlink. | ||
| """ | ||
| # Validate the parent so grants and symlinked parents are still enforced, then | ||
| # keep the final component unresolved. A path policy that resolves symlinks would | ||
| # otherwise turn a dangling link at the target name into its absent target and let | ||
| # the create land there instead of being rejected. | ||
| requested = Path(path) | ||
| parent_path = await self._validate_path_access(requested.parent, for_write=True) | ||
| workspace_path = parent_path / requested.name | ||
| path_arg = sandbox_path_str(workspace_path) | ||
| # A fixed-length staging basename. Deriving it from the destination made the | ||
| # staging name longer than the destination, so a name that fits the filesystem's | ||
| # component limit could still fail to stage. | ||
| staging_path = parent_path / f".apply-patch-create-{uuid.uuid4().hex}" | ||
| staging_arg = sandbox_path_str(staging_path) | ||
|
|
||
| preflight = await self.exec( | ||
| "sh", "-c", _TARGET_EXISTS_SCRIPT, "sh", path_arg, shell=False, user=user | ||
| ) | ||
| if preflight.exit_code == _EXCLUSIVE_CREATE_EXISTS_CODE: | ||
| raise FileExistsError(path_arg) | ||
|
|
||
| try: | ||
| # Create the parent as the bound user so a fresh nested path is owned the same | ||
| # way the ordinary write path owned it. | ||
| await self.mkdir(parent_path, parents=True, user=user) | ||
| await self.write(staging_path, data, user=user) | ||
|
ayaangazali marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: AGENTS.md:L102-L103 Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one I agree with and am deliberately not patching, because it is evidence that the mechanism is wrong rather than a missing condition. With That is the second independent case where the shared hard-link approach fails on a supported configuration; the first is writable rclone, Mountpoint and Blobfuse mounts, which have no POSIX hard links at all. Since So I do not think another condition fixes this. There is an open question further up the thread about whether the shared shell implementation should exist at all, or whether exclusive create should be a per-backend primitive with UnixLocal being the one I can actually verify. I have asked the maintainers to rule on that and I would rather not keep hardening a path that may be removed. |
||
| # -c rather than -lc: this runs on a filesystem-only capability set, so it | ||
| # must not source workspace-writable shell startup files. | ||
| result = await self.exec( | ||
| "sh", | ||
| "-c", | ||
| _EXCLUSIVE_CREATE_SCRIPT, | ||
|
ayaangazali marked this conversation as resolved.
|
||
| "sh", | ||
| path_arg, | ||
| staging_arg, | ||
| shell=False, | ||
| user=user, | ||
|
seratch marked this conversation as resolved.
|
||
| ) | ||
| if result.exit_code == _EXCLUSIVE_CREATE_EXISTS_CODE: | ||
| raise FileExistsError(path_arg) | ||
| if not result.ok(): | ||
| raise WorkspaceArchiveWriteError( | ||
| path=workspace_path, | ||
| context={ | ||
| "command": ["sh", "-c", "<exclusive_create>", path_arg, staging_arg], | ||
| "stdout": result.stdout.decode("utf-8", errors="replace"), | ||
| "stderr": result.stderr.decode("utf-8", errors="replace"), | ||
| }, | ||
| ) | ||
| finally: | ||
| # The staging entry is an implementation detail, and removing it must not | ||
| # replace the outcome of the create. | ||
| with suppress(Exception): | ||
| await self.rm(staging_path, user=user) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an AGENTS.md reference: AGENTS.md:L104-L104 Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Real, and it is the AGENTS.md rule about stale cleanup disposing state owned by surviving concurrent work, so I am not disputing the pattern. Two things narrow it since the comment was written. The staging entry now only exists when the probe found the target absent, so the window is gone entirely for the common rejected-create case. And the name is a random That is smaller, not zero, and the correct fix is what you describe: verify the staged entry identity before removing it, or use a primitive that keeps the staging entry private. I am holding it for the same reason as the hard-link thread above. Doing it locally is a cheap |
||
|
|
||
| async def _check_read_with_exec( | ||
| self, path: Path | str, *, user: str | User | None = None | ||
| ) -> Path: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.