mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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>
This commit is contained in:
parent
aa978d1b32
commit
871d064f9e
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
654
backend/tests/test_local_sandbox_atomic_writes.py
Normal file
654
backend/tests/test_local_sandbox_atomic_writes.py
Normal file
@ -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("<I", 2) + b"".join(struct.pack("<HHI", *entry) for entry in entries)
|
||||
os.setxattr(target, "system.posix_acl_access", acl)
|
||||
os.setxattr(target, "user.deerflow-test", b"retain-me")
|
||||
before = {name: os.getxattr(target, name) for name in os.listxattr(target)}
|
||||
sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n")
|
||||
assert {name: os.getxattr(target, name) for name in os.listxattr(target)} == before
|
||||
assert target.read_bytes() == b"new\n"
|
||||
|
||||
|
||||
def test_other_hosts_keep_existing_in_place_behavior(tmp_path, sandbox, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
target = tmp_path / "report.md"
|
||||
target.write_bytes(b"old\n")
|
||||
before = target.stat().st_ino
|
||||
monkeypatch.setattr(local, "sys", SimpleNamespace(platform="other"))
|
||||
sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n")
|
||||
assert target.stat().st_ino == before
|
||||
assert target.read_bytes() == b"new\n"
|
||||
|
||||
|
||||
@requires_atomic_revision
|
||||
def test_privileged_file_is_not_republished_with_stale_privileges(tmp_path, sandbox):
|
||||
target = tmp_path / "run.sh"
|
||||
target.write_bytes(b"old\n")
|
||||
target.chmod(0o4750)
|
||||
try:
|
||||
with pytest.raises(OSError) as error:
|
||||
sandbox.write_file("/mnt/user-data/outputs/run.sh", "new\n")
|
||||
assert error.value.errno == errno.ENOTSUP
|
||||
assert target.read_bytes() == b"old\n"
|
||||
assert stat.S_IMODE(target.stat().st_mode) == 0o4750
|
||||
assert list(tmp_path.iterdir()) == [target]
|
||||
finally:
|
||||
target.chmod(0o600)
|
||||
|
||||
|
||||
def test_linux_metadata_copy_replaces_inherited_attributes(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
attributes = {1: {"system.posix_acl_access": b"source-acl", "user.tag": b"tag"}, 2: {"user.inherited": b"unexpected"}}
|
||||
monkeypatch.setattr(
|
||||
file_revision_metadata,
|
||||
"os",
|
||||
SimpleNamespace(
|
||||
listxattr=lambda fd: list(attributes[fd]),
|
||||
getxattr=lambda fd, name: attributes[fd][name],
|
||||
setxattr=lambda fd, name, value: attributes[fd].__setitem__(name, value),
|
||||
removexattr=lambda fd, name: attributes[fd].pop(name),
|
||||
),
|
||||
)
|
||||
file_revision_metadata._copy_linux_metadata(1, 2)
|
||||
assert attributes[2] == attributes[1]
|
||||
|
||||
|
||||
def test_linux_metadata_error_is_not_ignored(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
def fail(fd):
|
||||
raise PermissionError(errno.EACCES, "Metadata unavailable")
|
||||
|
||||
monkeypatch.setattr(file_revision_metadata, "os", SimpleNamespace(listxattr=fail))
|
||||
with pytest.raises(PermissionError):
|
||||
file_revision_metadata._copy_linux_metadata(1, 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attribute", ["security.capability", "security.ima", "security.evm"])
|
||||
def test_linux_content_bound_privileges_are_not_copied(monkeypatch, attribute):
|
||||
from types import SimpleNamespace
|
||||
|
||||
monkeypatch.setattr(file_revision_metadata, "os", SimpleNamespace(listxattr=lambda fd: [attribute]))
|
||||
with pytest.raises(OSError) as error:
|
||||
file_revision_metadata._copy_linux_metadata(1, 2)
|
||||
assert error.value.errno == errno.ENOTSUP
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS metadata API error handling")
|
||||
@pytest.mark.parametrize("stage", ["read_acl", "empty_acl", "set_acl", "xattr"])
|
||||
def test_native_metadata_errors_abort_publication(tmp_path, sandbox, monkeypatch, stage):
|
||||
import ctypes
|
||||
from types import SimpleNamespace
|
||||
|
||||
target = tmp_path / "report.md"
|
||||
target.write_bytes(b"old\n")
|
||||
freed = []
|
||||
|
||||
def get_acl(*args):
|
||||
if stage in {"read_acl", "empty_acl"}:
|
||||
ctypes.set_errno(errno.EACCES if stage == "read_acl" else errno.ENOENT)
|
||||
return None
|
||||
return 1
|
||||
|
||||
def empty_acl(*args):
|
||||
ctypes.set_errno(errno.ENOMEM)
|
||||
return None
|
||||
|
||||
def set_acl(*args):
|
||||
ctypes.set_errno(errno.EACCES)
|
||||
return -1 if stage == "set_acl" else 0
|
||||
|
||||
def copy_xattrs(*args):
|
||||
ctypes.set_errno(errno.EACCES)
|
||||
return -1
|
||||
|
||||
library = SimpleNamespace(acl_get_fd_np=get_acl, acl_init=empty_acl, acl_set_fd_np=set_acl, acl_free=freed.append, fcopyfile=copy_xattrs)
|
||||
monkeypatch.setattr(file_revision_metadata, "_darwin_library", lambda: library)
|
||||
with pytest.raises(OSError) as error:
|
||||
sandbox.write_file("/mnt/user-data/outputs/report.md", "new\n")
|
||||
assert error.value.errno == (errno.ENOMEM if stage == "empty_acl" else errno.EACCES)
|
||||
assert target.read_bytes() == b"old\n"
|
||||
assert list(tmp_path.iterdir()) == [target]
|
||||
assert freed == ([1] if stage in {"set_acl", "xattr"} else [])
|
||||
|
||||
|
||||
@requires_atomic_revision
|
||||
def test_failure_to_preserve_owner_aborts_publication(tmp_path, sandbox, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
target = tmp_path / "report.md"
|
||||
target.write_bytes(b"old\n")
|
||||
before = target.stat()
|
||||
|
||||
def fstat(fd):
|
||||
st = os.fstat(fd)
|
||||
if st.st_ino != before.st_ino:
|
||||
return st
|
||||
return SimpleNamespace(st_mode=st.st_mode, st_uid=st.st_uid + 1, st_gid=st.st_gid, st_dev=st.st_dev, st_flags=0)
|
||||
|
||||
def fchown(fd, uid, gid):
|
||||
raise PermissionError(errno.EPERM, "Cannot preserve ownership")
|
||||
|
||||
monkeypatch.setattr(file_revision_metadata, "os", SimpleNamespace(fstat=fstat, fchown=fchown))
|
||||
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_uid == before.st_uid
|
||||
assert list(tmp_path.iterdir()) == [target]
|
||||
|
||||
|
||||
_KERNEL_PROBE = r"""
|
||||
import json
|
||||
import resource
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from deerflow.sandbox import tools
|
||||
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
entry = sys.argv[2]
|
||||
limited = sys.argv[3] == "limited"
|
||||
target = root / "outputs" / "report.md"
|
||||
target.parent.mkdir()
|
||||
target.write_bytes(b"previous report\n")
|
||||
sandbox = LocalSandbox("local:atomic-write", [PathMapping("/mnt/user-data", str(root))])
|
||||
runtime = SimpleNamespace(state={
|
||||
"sandbox": {"sandbox_id": sandbox.id},
|
||||
"thread_data": {f"{kind}_path": str(root / kind) for kind in ("workspace", "uploads", "outputs")},
|
||||
}, context={}, config={})
|
||||
path = "/mnt/user-data/outputs/report.md"
|
||||
content = "new report\n" * 1000
|
||||
with patch.object(tools, "ensure_sandbox_initialized", return_value=sandbox), patch.object(tools, "_get_custom_mounts", return_value=[]):
|
||||
assert tools.read_file_tool.func(runtime=runtime, path=path) == "previous report\n"
|
||||
if limited:
|
||||
signal.signal(signal.SIGXFSZ, signal.SIG_IGN)
|
||||
resource.setrlimit(resource.RLIMIT_FSIZE, (1024, 1024))
|
||||
if entry == "write_file":
|
||||
result = tools.write_file_tool.func(runtime=runtime, path=path, content=content)
|
||||
else:
|
||||
result = tools.str_replace_tool.func(runtime=runtime, path=path, old_str="previous report\n", new_str=content)
|
||||
print(json.dumps({"result": result, "content": target.read_text(encoding="utf-8"), "files": sorted(p.name for p in target.parent.iterdir())}))
|
||||
"""
|
||||
|
||||
|
||||
@requires_atomic_revision
|
||||
@pytest.mark.parametrize("entry", ["write_file", "str_replace"])
|
||||
@pytest.mark.parametrize("limited", [False, True], ids=["control", "kernel-write-failure"])
|
||||
def test_real_tool_entry_preserves_previous_file_on_kernel_failure(tmp_path, entry, limited):
|
||||
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _KERNEL_PROBE, str(tmp_path), entry, "limited" if limited else "unlimited"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
outcome = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
if limited:
|
||||
assert "File too large" in outcome["result"]
|
||||
assert outcome["content"] == "previous report\n"
|
||||
else:
|
||||
assert outcome["result"] == "OK"
|
||||
assert outcome["content"] == "new report\n" * 1000
|
||||
assert outcome["files"] == ["report.md"]
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@ -47,15 +48,23 @@ def test_companion_record_accepts_uppercase_markdown_suffix(tmp_path):
|
||||
assert extract_outline_for_file(original)[0] == [{"title": "Uppercase suffix", "line": 1}]
|
||||
|
||||
|
||||
def test_same_size_sandbox_rewrite_rejects_stale_outline(converted_upload):
|
||||
@pytest.mark.parametrize("writer", ["in_place", "sandbox"])
|
||||
def test_same_size_rewrite_rejects_stale_outline(converted_upload, writer):
|
||||
original, markdown = converted_upload
|
||||
before = original.stat()
|
||||
sandbox = LocalSandbox("repro", [PathMapping("/mnt/user-data", str(original.parent.parent))])
|
||||
sandbox.write_file("/mnt/user-data/uploads/report.pdf", "%PDF REPLACED B")
|
||||
if writer == "in_place":
|
||||
original.write_text("%PDF REPLACED B", encoding="utf-8")
|
||||
else:
|
||||
sandbox = LocalSandbox("repro", [PathMapping("/mnt/user-data", str(original.parent.parent))])
|
||||
sandbox.write_file("/mnt/user-data/uploads/report.pdf", "%PDF REPLACED B")
|
||||
# Force a distinct modification time even on coarse-resolution filesystems.
|
||||
os.utime(original, ns=(before.st_atime_ns, before.st_mtime_ns + 2_000_000_000))
|
||||
after = original.stat()
|
||||
assert (before.st_dev, before.st_ino, before.st_size) == (after.st_dev, after.st_ino, after.st_size)
|
||||
assert (before.st_dev, before.st_size) == (after.st_dev, after.st_size)
|
||||
if writer == "sandbox" and sys.platform in {"darwin", "linux"}:
|
||||
assert before.st_ino != after.st_ino
|
||||
else:
|
||||
assert before.st_ino == after.st_ino
|
||||
|
||||
assert resolve_companion(original) is None
|
||||
assert companion_names(original.parent) == set()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user