From 871d064f9ea44410b529ff2c0977afd9041b7252 Mon Sep 17 00:00:00 2001 From: haotianqi666 Date: Fri, 23 Oct 2026 08:22:45 +0800 Subject: [PATCH] fix(sandbox): preserve local files when revisions fail (#7299) * fix(sandbox): preserve local files when revisions fail * fix(sandbox): address revision cleanup and metadata review --------- Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com> --- README.md | 2 + .../harness/deerflow/sandbox/AGENTS.md | 2 + .../deerflow/sandbox/local/local_sandbox.py | 37 +- .../packages/harness/deerflow/utils/AGENTS.md | 11 + .../harness/deerflow/utils/atomic_write.py | 16 +- .../deerflow/utils/file_revision_metadata.py | 93 +++ .../tests/test_local_sandbox_atomic_writes.py | 654 ++++++++++++++++++ backend/tests/test_upload_companions.py | 17 +- 8 files changed, 819 insertions(+), 13 deletions(-) create mode 100644 backend/packages/harness/deerflow/utils/file_revision_metadata.py create mode 100644 backend/tests/test_local_sandbox_atomic_writes.py diff --git a/README.md b/README.md index 86001fa4be..c52ed94972 100644 --- a/README.md +++ b/README.md @@ -2700,6 +2700,8 @@ Each task gets its own execution environment with a full filesystem view — ski The read-before-write gate ties each read mark to that `read_file` call's result, including custom tools returning multi-message `Command` updates. An unrelated result cannot authorize a write after a failed read or hide a successful read. +On Linux and macOS, local sandbox `write_file` replacements and `str_replace` stage complete text before replacing the destination, so write, flush or publication errors retain the previous file. Existing write access is checked; ownership, permission bits, ACLs and extended attributes are transferred before writing staging content. Failure to transfer metadata aborts the save. Successful revisions get a fresh modification time, including empty saves; source-descriptor cleanup errors are warnings and do not change the save result. Files with set-id bits, macOS file flags or content-bound Linux privilege attributes are refused unchanged. Replacement requires parent-directory write access and publishes a new inode; other hard links retain the previous version. Symlinks follow the existing path-validation rules. Append writes, other host platforms (including Windows), shell writes and remote providers keep their existing behavior. This is not a concurrent-writer or power-loss recovery guarantee. + Loop warnings let agents save collected results before their final answer. If the save is blocked by the read-before-write gate, the agent may read the target file and retry the save once. A looping or stopped tool must be replaced by an available alternative; if no permitted save path exists or the retry fails, the final answer includes the collected results and reports that the file was not saved. Recovery calls still count toward the loop limits, and a hard stop prevents further tool execution. Ranged `read_file` calls count lines the same way on every sandbox provider: a line ends only at a newline. On E2B, BoxLite, Tenki, and OpenSandbox, a file with bare carriage returns (such as a saved progress log), form feeds, or Unicode line separators therefore returns the same lines as on the local sandbox, and the `start_line` a truncated read suggests points at the next unread line. diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index a7822c61ae..301ae4f202 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -2,6 +2,8 @@ Sandbox restore requires a thread ID, even forks. +On Linux/macOS, local non-append text saves use `_publish_file_revision` and the shared atomic byte publisher. Check write access without truncation and keep the source descriptor open through metadata transfer. Resolve symlinks under existing mapping rules; publish only regular files. Failure before replace retains the old file and cleans staging. Append and other platforms keep their in-place behavior. Replacement requires parent-directory write and metadata-preservation permissions; inode identity changes, so hard-link aliases retain the old version. It is not concurrent-writer arbitration or power-loss recovery. Regressions: `tests/test_local_sandbox_atomic_writes.py`. + PowerShell command mapping escapes host-path whitespace/metacharacters for the caller's quote context in both user-data and provider mappings. Preserve wildcards; Bash/cmd mapping stays unchanged. Keep portable string assertions alongside native Windows smoke tests. The public bash tool's existing POSIX-quoted cwd prefix requires PowerShell 7 (`&&`) and workspace paths without apostrophes; mapping does not repair that prefix. Local `grep` skips non-regular entries before reading. File reads and directory scans propagate errors, except `ENOENT`/`ENOTDIR`/`ELOOP`/`EISDIR`/`ESTALE` races (`ESTALE` when available). Error filenames are reverse-resolved at the local sandbox boundary, including custom mounts; permission errors identify the virtual failing path. diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index 446709ed85..483a107b6a 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -7,8 +7,11 @@ import os import re import shutil import signal +import stat import subprocess +import sys import threading +from contextlib import suppress from dataclasses import dataclass from functools import cached_property from pathlib import Path @@ -21,6 +24,7 @@ from deerflow.sandbox.local.list_dir import list_dir from deerflow.sandbox.path_patterns import replace_output_path_matches from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches +from deerflow.utils.atomic_write import publish_bytes_atomically logger = logging.getLogger(__name__) @@ -34,6 +38,28 @@ _COMMAND_CAPTURE_LIMIT_BYTES = 10 * 1024 * 1024 _PIPE_DRAIN_JOIN_TIMEOUT_SECONDS = 0.2 +def _publish_file_revision(path: str, content: str) -> None: + target = Path(os.path.realpath(path)) + # Rename checks the parent, not the target's write permission. Check the + # existing file without truncating it before staging a replacement inode. + try: + fd = os.open(target, os.O_WRONLY | getattr(os, "O_NONBLOCK", 0)) + except FileNotFoundError: + fd = None + try: + if fd is not None: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise OSError(errno.EINVAL, "Text revisions require a regular file", path) + publish_bytes_atomically(target, content.encode("utf-8"), temporary_prefix=".deerflow-write-", preserve_metadata_from=fd) + finally: + if fd is not None: + try: + os.close(fd) + except OSError: + with suppress(Exception): + logger.warning("Failed to close file revision source descriptor for %s", target, exc_info=True) + + class _BoundedPipeCapture: """Drain a subprocess pipe while keeping only bounded output in memory.""" @@ -1194,11 +1220,12 @@ class LocalSandbox(Sandbox): # Resolve container paths in content to local paths # using the content-specific resolver (forward-slash safe) resolved_content = self._resolve_paths_in_content(content) - mode = "a" if append else "w" - # newline="" writes the content as given; the default would turn - # every "\n" into "\r\n" on Windows (breaking `bash run.sh`). - with open(resolved_path, mode, encoding="utf-8", newline="") as f: - f.write(resolved_content) + if append or sys.platform not in {"darwin", "linux"}: + # Other hosts retain their existing write/ACL semantics. + with open(resolved_path, "a" if append else "w", encoding="utf-8", newline="") as f: + f.write(resolved_content) + else: + _publish_file_revision(resolved_path, resolved_content) # Track this path so read_file knows to reverse-resolve on read. # Only agent-written files get reverse-resolved; user uploads and # external tool output are left untouched. diff --git a/backend/packages/harness/deerflow/utils/AGENTS.md b/backend/packages/harness/deerflow/utils/AGENTS.md index 80a3164d86..6d86d476e4 100644 --- a/backend/packages/harness/deerflow/utils/AGENTS.md +++ b/backend/packages/harness/deerflow/utils/AGENTS.md @@ -14,6 +14,17 @@ temporary; pass a raw descriptor only until `fdopen` succeeds, or an explicitly transferred duplicate descriptor owned by the cleanup caller. Shelf discards must preserve both a rejected project's 404 and a dedup hit's 200 response. Callers retain their own serialization, permissions and publication policy. +Local Linux/macOS text revisions opt into a short `temporary_prefix` and +`preserve_metadata_from` (a caller-owned descriptor). Staging starts at `0600`; +`file_revision_metadata` transfers ownership, mode, ACLs and xattrs before data. +macOS uses native descriptor ACL APIs and `fcopyfile(COPYFILE_XATTR)`; Linux uses +descriptor xattrs, including its access ACL, removing inherited extras. Errors +abort; never ignore metadata permission failures. Refuse set-id bits, macOS file +flags and content-bound privilege attributes rather than reattach stale privileges to new +content. Other publisher callers retain their existing mode-only contract. +Do not restore old timestamps on a content revision, including an empty save. +Source-descriptor close failures are best-effort warnings, not save failures; +guard diagnostics so the publication result or original interruption survives. Failure cleanup must preserve the original publication exception, including interruptions. Descriptor close and sibling unlink are separate best-effort operations: a close failure must not prevent the unlink attempt. diff --git a/backend/packages/harness/deerflow/utils/atomic_write.py b/backend/packages/harness/deerflow/utils/atomic_write.py index 9b32ed58d7..b6393c341c 100644 --- a/backend/packages/harness/deerflow/utils/atomic_write.py +++ b/backend/packages/harness/deerflow/utils/atomic_write.py @@ -9,6 +9,8 @@ from contextlib import suppress from pathlib import Path from uuid import uuid4 +from deerflow.utils.file_revision_metadata import preserve_revision_metadata + logger = logging.getLogger(__name__) @@ -36,31 +38,37 @@ def cleanup_failed_atomic_write(temporary: Path, fd: int | None = None) -> None: logger.warning("Failed to remove temporary output file %s", temporary, exc_info=True) -def publish_bytes_atomically(path: Path, content: bytes) -> None: +def publish_bytes_atomically(path: Path, content: bytes, *, temporary_prefix: str | None = None, preserve_metadata_from: int | None = None) -> None: """Replace *path* with complete bytes, preserving its existing mode. New files use ``0666 & ~umask``, like ``Path.write_bytes``, so captures remain readable through sandbox mounts under the usual ``0022`` umask. A hidden, exclusively created sibling keeps partial writes away from the final path. Failure before replacement leaves the previous file intact. + A short temporary_prefix supports destinations near the filename limit. + Linux/macOS callers may supply an already-open source descriptor to preserve + access controls before writing content; ownership stays with the caller. """ try: mode = stat.S_IMODE(path.stat().st_mode) except FileNotFoundError: mode = None - temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp") + prefix = temporary_prefix if temporary_prefix is not None else f".{path.name}." + temporary = path.with_name(f"{prefix}{uuid4().hex}.tmp") flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0) # Let the kernel apply umask; reading it via os.umask would mutate # process-global state while other threads may be creating files. - fd = os.open(temporary, flags, 0o666) + fd = os.open(temporary, flags, 0o600 if preserve_metadata_from is not None else 0o666) try: handle = os.fdopen(fd, "wb") fd = None # The file object now owns the descriptor. with handle: + if preserve_metadata_from is not None: + preserve_revision_metadata(preserve_metadata_from, handle.fileno()) handle.write(content) handle.flush() - if mode is not None: + if mode is not None and preserve_metadata_from is None: if os.name == "nt": os.chmod(temporary, mode) else: diff --git a/backend/packages/harness/deerflow/utils/file_revision_metadata.py b/backend/packages/harness/deerflow/utils/file_revision_metadata.py new file mode 100644 index 0000000000..b11a872d34 --- /dev/null +++ b/backend/packages/harness/deerflow/utils/file_revision_metadata.py @@ -0,0 +1,93 @@ +"""Preserve access controls before publishing a Linux/macOS file revision.""" + +from __future__ import annotations + +import ctypes +import errno +import os +import stat +import sys +from functools import cache + + +@cache +def _darwin_library(): + library = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + signatures = { + "acl_get_fd_np": ([ctypes.c_int, ctypes.c_int], ctypes.c_void_p), + "acl_init": ([ctypes.c_int], ctypes.c_void_p), + "acl_set_fd_np": ([ctypes.c_int, ctypes.c_void_p, ctypes.c_int], ctypes.c_int), + "acl_free": ([ctypes.c_void_p], ctypes.c_int), + "fcopyfile": ([ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32], ctypes.c_int), + } + for name, (arguments, result) in signatures.items(): + function = getattr(library, name) + function.argtypes = arguments + function.restype = result + return library + + +def _darwin_error() -> OSError: + code = ctypes.get_errno() or errno.EIO + return OSError(code, os.strerror(code)) + + +def _copy_darwin_metadata(source: int, destination: int) -> None: + library = _darwin_library() + acl_type_extended = 0x100 + acl = library.acl_get_fd_np(source, acl_type_extended) + if not acl: + if ctypes.get_errno() != errno.ENOENT: + raise _darwin_error() + # An empty source ACL must remove any grants inherited by staging. + acl = library.acl_init(0) + if not acl: + raise _darwin_error() + try: + if library.acl_set_fd_np(destination, acl, acl_type_extended) != 0: + raise _darwin_error() + finally: + library.acl_free(acl) + # COPYFILE_XATTR only: do not copy old data or restore its modification time. + if library.fcopyfile(source, destination, None, 1 << 2) != 0: + raise _darwin_error() + + +def _copy_linux_metadata(source: int, destination: int) -> None: + try: + names = os.listxattr(source) + except OSError as error: + if error.errno == errno.ENOTSUP: + return # Both files are on the same filesystem, without xattrs. + raise + if {"security.capability", "security.ima", "security.evm"}.intersection(names): + raise OSError(errno.ENOTSUP, "Cannot safely transfer content-bound file privileges") + for name in os.listxattr(destination): + if name not in names: + os.removexattr(destination, name) + for name in names: + os.setxattr(destination, name, os.getxattr(source, name)) + + +def preserve_revision_metadata(source: int, destination: int) -> None: + """Copy metadata using open descriptors, or abort without publishing. + + Unlike shutil.copystat, errors copying ownership, ACLs and attributes are + not suppressed. Privileged/flagged files require an explicit editing policy. + Call before writing content so inherited staging grants cannot expose it. + """ + before = os.fstat(source) + if before.st_mode & (stat.S_ISUID | stat.S_ISGID) or getattr(before, "st_flags", 0): + raise OSError(errno.ENOTSUP, "Atomic text revisions do not support privileged or flagged files") + temporary = os.fstat(destination) + if temporary.st_dev != before.st_dev: + raise OSError(errno.EXDEV, "Atomic text revisions require the same filesystem") + if (temporary.st_uid, temporary.st_gid) != (before.st_uid, before.st_gid): + os.fchown(destination, before.st_uid, before.st_gid) + os.fchmod(destination, stat.S_IMODE(before.st_mode)) + if sys.platform == "darwin": + _copy_darwin_metadata(source, destination) + elif sys.platform == "linux": + _copy_linux_metadata(source, destination) + else: + raise OSError(errno.ENOTSUP, "File revision metadata is supported on Linux and macOS only") diff --git a/backend/tests/test_local_sandbox_atomic_writes.py b/backend/tests/test_local_sandbox_atomic_writes.py new file mode 100644 index 0000000000..7d1df727ff --- /dev/null +++ b/backend/tests/test_local_sandbox_atomic_writes.py @@ -0,0 +1,654 @@ +"""Local file revisions retain the last complete version on publication failure.""" + +from __future__ import annotations + +import errno +import json +import os +import stat +import subprocess +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from deerflow.sandbox.local import local_sandbox as local +from deerflow.utils import atomic_write, file_revision_metadata + +requires_atomic_revision = pytest.mark.skipif(sys.platform not in {"darwin", "linux"}, reason="Atomic revisions are scoped to Linux/macOS") + + +@pytest.fixture +def sandbox(tmp_path): + return local.LocalSandbox("local:atomic-write", [local.PathMapping("/mnt/user-data/outputs", str(tmp_path))]) + + +@pytest.mark.parametrize("existing", [False, True]) +@pytest.mark.parametrize("stage", ["write", "flush", "fsync", "replace"]) +@requires_atomic_revision +def test_failed_save_keeps_previous_file_and_cleans_staging(tmp_path, monkeypatch, sandbox, existing, stage): + target = tmp_path / "report.md" + if existing: + target.write_bytes(b"previous report\n") + real_open = open + real_fdopen = os.fdopen + + def fail(*args, **kwargs): + raise OSError(errno.ENOSPC, "No space left on device") + + class FailingWriter: + def __init__(self, handle): + self.handle = handle + + def __getattr__(self, name): + return getattr(self.handle, name) + + def __enter__(self): + return self + + def __exit__(self, *args): + try: + if args[0] is None and stage == "flush": + self.flush() + finally: + self.handle.close() + + def write(self, content): + if stage == "write": + self.handle.write(content[:3]) + self.handle.flush() + fail() + return self.handle.write(content) + + def flush(self): + self.handle.flush() + if stage == "flush": + fail() + + def wrapped_open(path, mode="r", **kwargs): + handle = real_open(path, mode, **kwargs) + return FailingWriter(handle) if "w" in mode else handle + + monkeypatch.setattr(local, "open", wrapped_open, raising=False) + monkeypatch.setattr(os, "fdopen", lambda *args, **kwargs: FailingWriter(real_fdopen(*args, **kwargs))) + if stage in {"fsync", "replace"}: + monkeypatch.setattr(os, stage, fail) + + with pytest.raises(OSError) as error: + sandbox.write_file("/mnt/user-data/outputs/report.md", "replacement report\n") + + assert error.value.errno == errno.ENOSPC + assert error.value.filename == "/mnt/user-data/outputs/report.md" + if existing: + assert target.read_bytes() == b"previous report\n" + else: + assert not target.exists() + assert set(tmp_path.iterdir()) == ({target} if existing else set()) + assert not sandbox._agent_written_paths + + +@pytest.mark.parametrize("existing", [False, True]) +@pytest.mark.parametrize("content", ["", "first\r\nsecond\r\n", "path=/mnt/user-data/outputs/data.csv\n"]) +def test_successful_save_preserves_bytes_and_path_translation(tmp_path, sandbox, existing, content): + target = tmp_path / "report.md" + if existing: + target.write_bytes(b"previous report\n") + + sandbox.write_file("/mnt/user-data/outputs/report.md", content) + + expected = content.replace("/mnt/user-data/outputs", tmp_path.as_posix()) + assert target.read_bytes() == expected.encode("utf-8") + assert sandbox.read_file("/mnt/user-data/outputs/report.md") == content + assert list(tmp_path.iterdir()) == [target] + + +@requires_atomic_revision +@pytest.mark.parametrize("content", ["", "new\n", "n" * (256 * 1024 + 1)], ids=["empty", "nonempty", "large-same-size"]) +def test_revision_updates_modification_time(tmp_path, sandbox, content): + target = tmp_path / "report.md" + target.write_bytes(b"o" * max(4, len(content))) + old_time = 946684800_000000000 + os.utime(target, ns=(old_time, old_time)) + + sandbox.write_file("/mnt/user-data/outputs/report.md", content) + + assert target.stat().st_mtime_ns > old_time + assert target.read_bytes() == content.encode("utf-8") + assert list(tmp_path.iterdir()) == [target] + + +@requires_atomic_revision +@pytest.mark.parametrize("outcome", ["success", "write-error", "interrupted"]) +@pytest.mark.parametrize("broken_logging", [False, True]) +def test_source_close_error_preserves_publication_outcome(tmp_path, monkeypatch, outcome, broken_logging): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + before = target.stat() + real_close = os.close + closed = [] + primary = KeyboardInterrupt("interrupted publication") if outcome == "interrupted" else OSError(errno.ENOSPC, "No space left on device") + + def close(fd): + source = os.fstat(fd).st_ino == before.st_ino + real_close(fd) + if source: + closed.append(fd) + raise OSError(errno.EIO, "Source descriptor close failed") + + def fail_fsync(fd): + raise primary + + warning = Mock(side_effect=RuntimeError("broken log handler") if broken_logging else None) + monkeypatch.setattr(local.logger, "warning", warning) + monkeypatch.setattr(os, "close", close) + if outcome != "success": + monkeypatch.setattr(os, "fsync", fail_fsync) + + if outcome == "success": + local._publish_file_revision(str(target), "new\n") + else: + with pytest.raises(type(primary)) as error: + local._publish_file_revision(str(target), "new\n") + assert error.value is primary + + assert len(closed) == 1 + assert target.read_bytes() == (b"new\n" if outcome == "success" else b"old\n") + assert list(tmp_path.iterdir()) == [target] + warning.assert_called_once() + assert warning.call_args.args[1] == target + assert warning.call_args.kwargs["exc_info"] is True + + +def test_append_keeps_existing_inode_and_content(tmp_path, sandbox): + target = tmp_path / "report.md" + target.write_bytes(b"first\r\n") + before = target.stat().st_ino + sandbox.write_file("/mnt/user-data/outputs/report.md", "second\r\n", append=True) + assert target.read_bytes() == b"first\r\nsecond\r\n" + assert target.stat().st_ino == before + assert list(tmp_path.iterdir()) == [target] + + +@requires_atomic_revision +def test_replacement_is_published_only_after_all_bytes_are_written(tmp_path, monkeypatch, sandbox): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + replace = os.replace + publications = [] + + def observe(source, destination): + assert target.read_bytes() == b"old\n" + assert Path(source).read_bytes() == b"new\r\n" + assert Path(source).parent == target.parent + assert destination == target + publications.append(destination) + replace(source, destination) + + monkeypatch.setattr(os, "replace", observe) + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\r\n") + assert publications == [target] + assert target.read_bytes() == b"new\r\n" + + +def test_new_file_keeps_normal_creation_permissions(tmp_path, sandbox): + reference = tmp_path / "reference.txt" + reference.write_bytes(b"reference") + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert stat.S_IMODE((tmp_path / "report.md").stat().st_mode) == stat.S_IMODE(reference.stat().st_mode) + + +def test_directory_target_is_not_replaced(tmp_path, sandbox): + target = tmp_path / "report.md" + target.mkdir() + with pytest.raises(OSError): + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert target.is_dir() + assert list(tmp_path.iterdir()) == [target] + + +@requires_atomic_revision +def test_hard_link_alias_retains_previous_version(tmp_path, sandbox): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + alias = tmp_path / "previous.md" + os.link(target, alias) + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert target.read_bytes() == b"new\n" + assert alias.read_bytes() == b"old\n" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX directory permissions") +def test_unwritable_parent_fails_without_destroying_previous_file(tmp_path, sandbox): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + mode = stat.S_IMODE(tmp_path.stat().st_mode) + tmp_path.chmod(0o555) + try: + if os.access(tmp_path, os.W_OK): + pytest.skip("current user can write read-only directories") + with pytest.raises(PermissionError): + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert target.read_bytes() == b"old\n" + finally: + tmp_path.chmod(mode) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_overwrite_preserves_permission_bits(tmp_path, sandbox): + target = tmp_path / "run.sh" + target.write_bytes(b"old\n") + target.chmod(0o750) + sandbox.write_file("/mnt/user-data/outputs/run.sh", "new\n") + assert target.read_bytes() == b"new\n" + assert stat.S_IMODE(target.stat().st_mode) == 0o750 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_read_only_target_is_not_bypassed_by_writable_parent(tmp_path, sandbox): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + target.chmod(0o444) + try: + if os.access(target, os.W_OK): + pytest.skip("current user can write read-only files") + with pytest.raises(PermissionError): + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert target.read_bytes() == b"old\n" + assert list(tmp_path.iterdir()) == [target] + finally: + target.chmod(0o644) + + +def test_read_only_mapping_is_still_rejected(tmp_path): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + sandbox = local.LocalSandbox("local:readonly", [local.PathMapping("/mnt/user-data/outputs", str(tmp_path), read_only=True)]) + with pytest.raises(OSError) as error: + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert error.value.errno == errno.EROFS + assert target.read_bytes() == b"old\n" + + +@pytest.mark.parametrize("mapped", [False, True]) +def test_symlink_write_updates_target_not_link(tmp_path, sandbox, mapped): + from support.symlinks import symlink_or_skip + + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + alias = tmp_path / "alias.md" + symlink_or_skip(alias, target) + path = "/mnt/user-data/outputs/alias.md" if mapped else str(alias) + sandbox.write_file(path, "new\n") + assert alias.is_symlink() + assert target.read_bytes() == b"new\n" + assert alias.read_bytes() == b"new\n" + + +def test_max_length_filename_can_be_overwritten(tmp_path, sandbox): + name = "r" * 240 + ".md" + target = tmp_path / name + target.write_bytes(b"old\n") + sandbox.write_file(f"/mnt/user-data/outputs/{name}", "new\n") + assert target.read_bytes() == b"new\n" + assert list(tmp_path.iterdir()) == [target] + + +def _darwin_acl_bytes(path): + """Compare native ACL data without DirectoryService's display-name lookup.""" + import ctypes + + library = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + signatures = { + "acl_get_file": ([ctypes.c_char_p, ctypes.c_int], ctypes.c_void_p), + "acl_size": ([ctypes.c_void_p], ctypes.c_ssize_t), + "acl_copy_ext": ([ctypes.c_void_p, ctypes.c_void_p, ctypes.c_ssize_t], ctypes.c_ssize_t), + "acl_free": ([ctypes.c_void_p], ctypes.c_int), + } + for name, (arguments, result) in signatures.items(): + function = getattr(library, name) + function.argtypes = arguments + function.restype = result + acl = library.acl_get_file(os.fsencode(path), 0x100) + if not acl: + assert ctypes.get_errno() == errno.ENOENT + return b"" + try: + size = library.acl_size(acl) + assert size > 0 + buffer = ctypes.create_string_buffer(size) + written = library.acl_copy_ext(buffer, acl, size) + assert written == size + return buffer.raw + finally: + assert library.acl_free(acl) == 0 + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS extended ACL regression") +def test_overwrite_preserves_existing_extended_acl(tmp_path, sandbox): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + empty = _darwin_acl_bytes(target) + subprocess.run(["chmod", "+a", "nobody deny read", str(target)], check=True, capture_output=True) + before = _darwin_acl_bytes(target) + assert before != empty, "The fixture must install an actual ACL" + + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + + after = _darwin_acl_bytes(target) + assert after == before + assert target.read_bytes() == b"new\n" + + # Keep the comparison sensitive to both lost and changed permissions. + subprocess.run(["chmod", "-N", str(target)], check=True, capture_output=True) + assert _darwin_acl_bytes(target) == empty + subprocess.run(["chmod", "+a", "nobody deny write", str(target)], check=True, capture_output=True) + assert _darwin_acl_bytes(target) != before + + +@requires_atomic_revision +def test_existing_private_file_is_staged_privately(tmp_path, sandbox, monkeypatch): + target = tmp_path / "report.md" + target.write_bytes(b"private old report\n") + target.chmod(0o600) + fdopen = os.fdopen + observed = [] + + def inspect(fd, *args, **kwargs): + mode = stat.S_IMODE(os.fstat(fd).st_mode) + observed.append(mode) + assert mode == 0o600 + return fdopen(fd, *args, **kwargs) + + monkeypatch.setattr(os, "fdopen", inspect) + sandbox.write_file("/mnt/user-data/outputs/report.md", "private new report\n") + assert observed == [0o600] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS inherited ACL regression") +def test_replacement_does_not_inherit_new_access_grants(tmp_path, sandbox): + target = tmp_path / "report.md" + target.write_bytes(b"private old report\n") + target.chmod(0o600) + subprocess.run(["chmod", "+a", "nobody allow read,file_inherit", str(tmp_path)], check=True, capture_output=True) + try: + sandbox.write_file("/mnt/user-data/outputs/report.md", "private new report\n") + acl = subprocess.check_output(["ls", "-le", str(target)], text=True).splitlines()[1:] + assert acl == [] + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + finally: + subprocess.run(["chmod", "-N", str(tmp_path)], check=True, capture_output=True) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS extended attributes") +def test_overwrite_preserves_extended_attributes(tmp_path, sandbox): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + subprocess.run(["xattr", "-w", "user.deerflow-test", "retain-me", str(target)], check=True, capture_output=True) + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + attribute = subprocess.check_output(["xattr", "-p", "user.deerflow-test", str(target)], text=True) + assert attribute.strip() == "retain-me" + + +@requires_atomic_revision +def test_metadata_failure_leaves_previous_file_untouched(tmp_path, sandbox, monkeypatch): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + before = target.stat() + + def fail(source, destination): + assert os.fstat(source).st_ino == before.st_ino + assert os.fstat(destination).st_size == 0 + raise PermissionError(errno.EACCES, "Cannot preserve file permissions") + + monkeypatch.setattr(atomic_write, "preserve_revision_metadata", fail) + with pytest.raises(PermissionError): + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert target.read_bytes() == b"old\n" + assert target.stat().st_ino == before.st_ino + assert list(tmp_path.iterdir()) == [target] + + +@requires_atomic_revision +def test_metadata_is_installed_before_staging_content(tmp_path, sandbox, monkeypatch): + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + target.chmod(0o640) + prepare = atomic_write.preserve_revision_metadata + called = [] + + def observe(source, destination): + assert os.fstat(destination).st_size == 0 + prepare(source, destination) + assert stat.S_IMODE(os.fstat(destination).st_mode) == 0o640 + called.append(destination) + + monkeypatch.setattr(atomic_write, "preserve_revision_metadata", observe) + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert len(called) == 1 + assert target.read_bytes() == b"new\n" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS explicit deny-write ACL") +def test_target_acl_write_denial_is_not_bypassed(tmp_path, sandbox): + import pwd + + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + user = pwd.getpwuid(os.getuid()).pw_name + subprocess.run(["chmod", "+a", f"{user} deny write", str(target)], check=True, capture_output=True) + try: + with pytest.raises(PermissionError): + sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n") + assert target.read_bytes() == b"old\n" + assert list(tmp_path.iterdir()) == [target] + finally: + subprocess.run(["chmod", "-N", str(target)], check=True, capture_output=True) + + +@pytest.mark.skipif(sys.platform != "linux", reason="Native Linux xattr and POSIX ACL contract") +def test_linux_overwrite_preserves_acl_and_attributes(tmp_path, sandbox): + import struct + + target = tmp_path / "report.md" + target.write_bytes(b"old\n") + # Linux posix_acl_xattr_header(version=2), followed by tag/perm/id entries. + entries = [(1, 6, 0xFFFFFFFF), (2, 0, 65534), (4, 0, 0xFFFFFFFF), (16, 4, 0xFFFFFFFF), (32, 0, 0xFFFFFFFF)] + acl = struct.pack("