feat(transport): PR 14.5d — Python wrapper + open_remote_file_into_local_cache thin Rust call

Completes the H1 file_open chain:

* PR 14.5    (9d6feea) — atomic_write_bytes Python skeleton
* PR 14.5b   (e6ab866) — Rust atomic_write helper + ABI
* PR 14.5c   (a1d70c7) — full Rust file_open transaction (broker.request
  + guard + atomic_write inside one Rust call)
* PR 14.5d   (this) — thin Python wrapper + call site replacement

Changes
-------

* ``_rust_ffi/_file_policy.py`` adds ``file_open_transaction`` —
  ctypes wrapper around ``sessions_file_open_transaction``. Returns
  parsed dict with ``outcome`` ∈ {OK, BLOCKED_BY_POLICY,
  BLOCKED_BINARY_HEURISTIC, REMOTE_NOT_FOUND, TRANSPORT_ERROR}.
* ``ssh_file_transport.open_remote_file_into_local_cache`` body
  shrinks from ~75 LOC (validate → execute_remote_read_file → decode
  → evaluate_open_file → atomic_write) to ~25 LOC (validate → Rust
  transaction → outcome dict → ``OpenFileResult``).
* Removed ``_atomic_write_bytes`` (no callers — Rust owns the atomic
  write). Imports ``OpenFileRequest`` / ``evaluate_open_file`` dropped
  (still used by ``file_state`` parity tests).
* Test migration: 11 tests in ``test_transport_cache_mirror.py``
  switched from mocking ``_execute_rust_bridge_request`` /
  ``execute_remote_read_file`` / ``Path.write_bytes`` to mocking
  ``_rust_file_open_transaction`` directly. The OK-path mock writes
  the cache file so ``target.read_bytes() == body`` still holds.

Single source of truth (M1)
---------------------------

After this PR, the file_open transaction lives entirely in Rust:
broker.request, base64 decode, kind/size guard, binary head heuristic,
atomic write — all one call. Python only validates the workspace root
and translates the outcome dict to a typed ``OpenFileResult``.

Tests
-----

1313 passed, including all 11 migrated transport_cache_mirror tests.
Boundary lint clean (CI mode).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 17:15:21 +09:00
parent b570710bff
commit 4c8dcde161
4 changed files with 248 additions and 241 deletions

View File

@@ -59,6 +59,7 @@ from ._broker import (
stderr_tail,
)
from ._file_policy import (
file_open_transaction,
is_external_cache_path,
is_likely_binary,
map_external_remote_to_local_path,
@@ -122,6 +123,7 @@ __all__ = (
"normalize_remote_root",
"workspace_cache_key",
# _file_policy
"file_open_transaction",
"is_external_cache_path",
"is_likely_binary",
"map_external_remote_to_local_path",

View File

@@ -8,10 +8,15 @@ from __future__ import annotations
import ctypes
from pathlib import Path
from typing import Any, Optional, Tuple
from typing import Any, Dict, Optional, Tuple
from . import _loader
from ._loader import AbiError, SessionsNativeLibraryError, call_string_abi
from ._loader import (
AbiError,
SessionsNativeLibraryError,
_call_json_returning_abi,
call_string_abi,
)
# Keys typed as plain ``int`` (not ``AbiError``) so the dict is assignable
# to ``call_string_abi``'s ``Mapping[int, str]`` parameter — ``Mapping``'s
@@ -296,6 +301,60 @@ def map_local_to_remote_path(
)
def file_open_transaction(
*,
host_alias: str,
remote_absolute_path: str,
local_cache_path: Path,
max_open_bytes: int,
binary_probe_bytes: int,
allow_empty: bool,
timeout_ms: int,
) -> Dict[str, Any]:
"""Run the full Rust file_open transaction (read + guard + atomic write).
Wraps :c:func:`sessions_file_open_transaction` (PR 14.5c). Rust
orchestrates broker.request file/read → metadata/size guard →
binary head heuristic → atomic write into ``local_cache_path``.
Returns a dict with keys:
* ``outcome``: one of ``OK``, ``BLOCKED_BY_POLICY``,
``BLOCKED_BINARY_HEURISTIC``, ``REMOTE_NOT_FOUND``,
``TRANSPORT_ERROR``.
* ``metadata`` (OK / BLOCKED_*): remote stat snapshot.
* ``bytes_written`` (OK only).
* ``unsupported_reason`` (BLOCKED_BY_POLICY): kebab-case reason code.
* ``detail`` / ``error_code`` (TRANSPORT_ERROR / REMOTE_NOT_FOUND).
"""
decoded = _call_json_returning_abi(
"sessions_file_open_transaction",
(
host_alias,
remote_absolute_path,
str(local_cache_path),
ctypes.c_uint64(int(max_open_bytes)),
ctypes.c_size_t(int(binary_probe_bytes)),
ctypes.c_int(1 if allow_empty else 0),
ctypes.c_uint64(int(timeout_ms)),
),
argtypes=[
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_uint64,
ctypes.c_size_t,
ctypes.c_int,
ctypes.c_uint64,
],
)
if decoded is None:
raise SessionsNativeLibraryError(
"sessions_file_open_transaction returned non-object payload"
)
return decoded
def is_external_cache_path(*, files_cache_root: Path, local_path: Path) -> bool:
"""Return whether local path belongs to external cache subtree."""
lib = _loader._native_lib()

View File

@@ -27,6 +27,9 @@ from . import _rust_ffi
from ._rust_ffi import (
error_message as rust_bridge_error_message,
)
from ._rust_ffi import (
file_open_transaction as _rust_file_open_transaction,
)
from ._rust_ffi import (
parse_mirror_result as rust_parse_mirror_result,
)
@@ -48,10 +51,9 @@ from .connect_preflight import (
)
from .file_state import (
FileOpenGuardrails,
OpenFileRequest,
OpenFileResult,
OpenOutcome,
evaluate_open_file,
UnsupportedOpenReason,
)
from .recent_state import RemoteHostPlatformStore, RemoteLinuxPlatformTag
from .remote import (
@@ -2082,38 +2084,72 @@ def execute_remote_write_file(
)
def _atomic_write_bytes(target: Path, body: bytes) -> None:
"""Write ``body`` to ``target`` atomically (tempfile + rename).
_UNSUPPORTED_REASON_MAP: Mapping[str, UnsupportedOpenReason] = {
"file_too_large": UnsupportedOpenReason.FILE_TOO_LARGE,
"unsupported_remote_kind": UnsupportedOpenReason.UNSUPPORTED_REMOTE_KIND,
"zero_byte_read_not_allowed": UnsupportedOpenReason.ZERO_BYTE_READ_NOT_ALLOWED,
}
H1 first-PR scope (PR 14.5): the previous flow did
``parent.mkdir(...); target.write_bytes(...)`` — a partial-state
window where ``target`` could exist with truncated bytes if the
interpreter died between ``open()`` and ``close()``. Writing to a
sibling tempfile and atomically renaming closes that window: any
observer either sees the *prior* contents (or absence) or the
*complete* new bytes, never a partial.
"""
target.parent.mkdir(parents=True, exist_ok=True)
# Same parent so ``rename`` is a same-filesystem atomic op (POSIX
# ``rename(2)``; on Windows ``Path.replace`` falls back to
# ``MoveFileEx`` which is atomic for same-volume targets).
fd, tmp_str = tempfile.mkstemp(
prefix="." + target.name + ".", suffix=".part", dir=str(target.parent)
)
tmp_path = Path(tmp_str)
def _metadata_from_rust_dict(
raw: Optional[Mapping[str, Any]],
) -> Optional[RemoteFileMetadata]:
if not raw:
return None
kind_str = str(raw.get("kind", RemoteFileKind.REGULAR_FILE.value))
try:
with os.fdopen(fd, "wb") as fh:
fh.write(body)
tmp_path.replace(target)
except BaseException:
# On any failure (write error, signal, etc.) do best-effort cleanup
# of the tempfile so the cache directory does not accumulate
# ``.NAME.XXXXXX.part`` debris.
try:
tmp_path.unlink()
except FileNotFoundError:
pass
raise
kind = RemoteFileKind(kind_str)
except ValueError:
kind = RemoteFileKind.OTHER
unix_mode_raw = raw.get("unix_mode")
return RemoteFileMetadata(
mtime_ns=int(raw.get("mtime_ns", 0)),
size_bytes=int(raw.get("size_bytes", 0)),
kind=kind,
unix_mode=int(unix_mode_raw) if unix_mode_raw is not None else None,
)
def _open_outcome_from_rust_dict(
payload: Mapping[str, Any], local_cache_path: Path
) -> OpenFileResult:
outcome_str = str(payload.get("outcome", "TRANSPORT_ERROR"))
raw_metadata = payload.get("metadata")
metadata = _metadata_from_rust_dict(
raw_metadata if isinstance(raw_metadata, Mapping) else None
)
if outcome_str == "OK":
return OpenFileResult(
outcome=OpenOutcome.OK,
local_cache_path=local_cache_path,
remote_metadata=metadata,
)
if outcome_str == "BLOCKED_BY_POLICY":
reason_label = str(payload.get("unsupported_reason", ""))
reason = _UNSUPPORTED_REASON_MAP.get(reason_label)
return OpenFileResult(
outcome=OpenOutcome.BLOCKED_BY_POLICY,
local_cache_path=local_cache_path,
unsupported_reason=reason,
)
if outcome_str == "BLOCKED_BINARY_HEURISTIC":
return OpenFileResult(
outcome=OpenOutcome.BLOCKED_BINARY_HEURISTIC,
local_cache_path=local_cache_path,
)
if outcome_str == "REMOTE_NOT_FOUND":
detail_raw = payload.get("detail")
return OpenFileResult(
outcome=OpenOutcome.REMOTE_NOT_FOUND,
local_cache_path=local_cache_path,
detail=str(detail_raw) if detail_raw is not None else None,
)
detail_raw = payload.get("detail")
return OpenFileResult(
outcome=OpenOutcome.TRANSPORT_ERROR,
local_cache_path=local_cache_path,
detail=str(detail_raw) if detail_raw is not None else None,
)
def open_remote_file_into_local_cache(
@@ -2124,71 +2160,37 @@ def open_remote_file_into_local_cache(
guard_limits: FileOpenGuardrails | None = None,
read_timeout_s: float = 30.0,
) -> OpenFileResult:
"""Fetch remote bytes over SSH, run open guardrails, and write the local cache file.
"""Fetch remote bytes via the Rust file_open transaction (PR 14.5d).
Wave 2 PR 14.5 (H1 first-PR scope): write phase uses
:func:`_atomic_write_bytes` so a crash between read and write cannot
leave a half-written cache file. Full Rust transaction (read +
guardrail + write inside one Rust call) lands as PR 14.5b.
Rust orchestrates broker.request file/read → metadata/size guard →
binary head heuristic → atomic write into ``local_cache_path``. The
Python wrapper validates the remote root, dispatches to the Rust
transaction, and maps the outcome dict to :class:`OpenFileResult`.
Transport failures are surfaced as ``OpenOutcome.TRANSPORT_ERROR`` so callers
can stay UI-free while still distinguishing policy blocks from SSH issues.
Missing remote paths (``ENOENT`` / ``lstat_failed``) return
``OpenOutcome.REMOTE_NOT_FOUND`` so the UI can drop stale cache files.
Transport failures surface as ``OpenOutcome.TRANSPORT_ERROR``; missing
remote paths surface as ``OpenOutcome.REMOTE_NOT_FOUND`` so the UI can
drop stale cache files.
"""
limits = guard_limits or FileOpenGuardrails()
try:
normalized = validate_remote_root(remote_absolute_path)
try:
read_result = execute_remote_read_file(
host_alias,
RemoteReadFileRequest(normalized),
timeout_s=read_timeout_s,
)
except TypeError:
read_result = execute_remote_read_file(
host_alias,
RemoteReadFileRequest(normalized),
)
except (InvalidRemoteRootError, SessionHelperStartError) as error:
if isinstance(error, SessionHelperStartError) and (
detail_suggests_remote_file_missing(error.detail)
):
return OpenFileResult(
outcome=OpenOutcome.REMOTE_NOT_FOUND,
local_cache_path=local_cache_path,
detail=error.detail,
)
except InvalidRemoteRootError as error:
return OpenFileResult(
outcome=OpenOutcome.TRANSPORT_ERROR,
local_cache_path=local_cache_path,
detail=error.detail,
detail=getattr(error, "detail", str(error)),
)
open_req = OpenFileRequest(
payload = _rust_file_open_transaction(
host_alias=host_alias,
remote_absolute_path=normalized,
local_cache_path=local_cache_path,
remote_metadata=read_result.metadata,
)
head_limit = limits.binary_probe_bytes
content_head = (
read_result.body[:head_limit] if read_result.body else read_result.body
)
opened = evaluate_open_file(
open_req,
content_head=content_head,
guard_limits=limits,
)
if opened.outcome is not OpenOutcome.OK:
return opened
_atomic_write_bytes(local_cache_path, read_result.body)
return OpenFileResult(
outcome=opened.outcome,
local_cache_path=opened.local_cache_path,
unsupported_reason=opened.unsupported_reason,
detail=opened.detail,
remote_metadata=read_result.metadata,
max_open_bytes=limits.max_open_bytes,
binary_probe_bytes=limits.binary_probe_bytes,
allow_empty=limits.allow_empty_files,
timeout_ms=int(read_timeout_s * 1000),
)
return _open_outcome_from_rust_dict(payload, local_cache_path)
@dataclass(frozen=True)

View File

@@ -1,6 +1,5 @@
"""Tests for cache mirror and opening remote files into local cache."""
import base64
from pathlib import Path
import sessions.ssh_file_transport as ssh_ft
@@ -10,7 +9,6 @@ from sessions.file_state import (
OpenOutcome,
UnsupportedOpenReason,
)
from sessions.remote import RemoteReadFileRequest
from sessions.ssh_file_transport import (
RemoteCacheMirrorOptions,
execute_remote_cache_mirror,
@@ -123,6 +121,25 @@ def test_execute_remote_cache_mirror_error_without_bridge(monkeypatch) -> None:
assert "Rust bridge" in (result.error_detail or "")
def _writing_transaction(body: bytes, metadata: dict) -> "object":
"""Return a fake ``_rust_file_open_transaction`` that writes ``body``.
Mirrors the Rust transaction's atomic_write side-effect so OK-path tests
can still assert ``target.read_bytes() == body``.
"""
def fake(*, local_cache_path: Path, **_kwargs: object) -> dict:
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
local_cache_path.write_bytes(body)
return {
"outcome": "OK",
"bytes_written": len(body),
"metadata": metadata,
}
return fake
def test_open_remote_cache_writes_ok(tmp_path: Path, monkeypatch) -> None:
meta = {
"mtime_ns": 1,
@@ -133,14 +150,8 @@ def test_open_remote_cache_writes_ok(tmp_path: Path, monkeypatch) -> None:
body = b"hey\n"
monkeypatch.setattr(
"sessions.ssh_file_transport._execute_rust_bridge_request",
lambda host_alias, method, params, **_kwargs: {
"ok": True,
"result": {
"metadata": meta,
"body_b64": base64.b64encode(body).decode("ascii"),
},
},
"sessions.ssh_file_transport._rust_file_open_transaction",
_writing_transaction(body, meta),
)
target = tmp_path / "mirror" / "f.txt"
@@ -155,20 +166,15 @@ def test_open_remote_cache_writes_ok(tmp_path: Path, monkeypatch) -> None:
def test_open_remote_cache_binary_block(tmp_path: Path, monkeypatch) -> None:
body = b"\x00\x01\x02"
meta = {
"mtime_ns": 1,
"size_bytes": 99,
"kind": "regular_file",
"unix_mode": 33188,
}
monkeypatch.setattr(
"sessions.ssh_file_transport._execute_rust_bridge_request",
lambda host_alias, method, params, **_kwargs: {
"ok": True,
"result": {
"metadata": meta,
"body_b64": base64.b64encode(body).decode("ascii"),
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {
"outcome": "BLOCKED_BINARY_HEURISTIC",
"metadata": {
"mtime_ns": 1,
"size_bytes": 99,
"kind": "regular_file",
"unix_mode": 33188,
},
},
)
@@ -185,15 +191,12 @@ def test_open_remote_cache_binary_block(tmp_path: Path, monkeypatch) -> None:
def test_open_remote_cache_transport_error_on_read_failure(
tmp_path: Path, monkeypatch
) -> None:
def read_raises(
host_alias: str, request: RemoteReadFileRequest, **kwargs: object
) -> None:
_ = (host_alias, request, kwargs)
raise SessionHelperStartError("Rust bridge read failed.")
monkeypatch.setattr(
"sessions.ssh_file_transport.execute_remote_read_file",
read_raises,
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {
"outcome": "TRANSPORT_ERROR",
"detail": "Rust bridge read failed.",
},
)
target = tmp_path / "x"
res = open_remote_file_into_local_cache(
@@ -205,15 +208,13 @@ def test_open_remote_cache_transport_error_on_read_failure(
def test_open_remote_cache_remote_missing(tmp_path: Path, monkeypatch) -> None:
def boom(host_alias: str, request: RemoteReadFileRequest) -> None:
_ = (host_alias, request)
raise SessionHelperStartError(
"Remote file read failed: [Errno 2] No such file or directory: '/srv/y'"
)
monkeypatch.setattr(
"sessions.ssh_file_transport.execute_remote_read_file",
boom,
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {
"outcome": "REMOTE_NOT_FOUND",
"error_code": "file_read_failed",
"detail": "No such file or directory: /srv/y",
},
)
target = tmp_path / "y"
res = open_remote_file_into_local_cache(
@@ -229,17 +230,15 @@ def test_open_remote_cache_blocks_directory_payload(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.setattr(
"sessions.ssh_file_transport._execute_rust_bridge_request",
lambda host_alias, method, params, **_kwargs: {
"ok": True,
"result": {
"metadata": {
"mtime_ns": 1,
"size_bytes": 0,
"kind": "directory",
"unix_mode": 16877,
},
"body_b64": "",
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {
"outcome": "BLOCKED_BY_POLICY",
"unsupported_reason": "unsupported_remote_kind",
"metadata": {
"mtime_ns": 1,
"size_bytes": 0,
"kind": "directory",
"unix_mode": 16877,
},
},
)
@@ -258,20 +257,16 @@ def test_open_remote_cache_blocks_large_declared_size(
tmp_path: Path, monkeypatch
) -> None:
"""Oversized files are blocked by metadata before binary heuristics."""
small_text = b"tiny"
meta = {
"mtime_ns": 1,
"size_bytes": FileOpenGuardrails().max_open_bytes + 1,
"kind": "regular_file",
"unix_mode": 33188,
}
monkeypatch.setattr(
"sessions.ssh_file_transport._execute_rust_bridge_request",
lambda host_alias, method, params, **_kwargs: {
"ok": True,
"result": {
"metadata": meta,
"body_b64": base64.b64encode(small_text).decode("ascii"),
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {
"outcome": "BLOCKED_BY_POLICY",
"unsupported_reason": "file_too_large",
"metadata": {
"mtime_ns": 1,
"size_bytes": FileOpenGuardrails().max_open_bytes + 1,
"kind": "regular_file",
"unix_mode": 33188,
},
},
)
@@ -282,6 +277,7 @@ def test_open_remote_cache_blocks_large_declared_size(
local_cache_path=target,
)
assert res.outcome is OpenOutcome.BLOCKED_BY_POLICY
assert res.unsupported_reason is UnsupportedOpenReason.FILE_TOO_LARGE
assert not target.exists()
@@ -444,18 +440,11 @@ def test_mirror_success(monkeypatch) -> None:
def test_open_remote_file_success(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(
"sessions.ssh_file_transport._execute_rust_bridge_request",
lambda host, method, params, **kw: {
"ok": True,
"result": {
"metadata": {
"kind": "regular_file",
"mtime_ns": 1000,
"size_bytes": 5,
},
"body_b64": base64.b64encode(b"hello").decode(),
},
},
"sessions.ssh_file_transport._rust_file_open_transaction",
_writing_transaction(
b"hello",
{"kind": "regular_file", "mtime_ns": 1000, "size_bytes": 5},
),
)
cache_file = tmp_path / "file.txt"
result = open_remote_file_into_local_cache(
@@ -471,11 +460,9 @@ def test_open_remote_file_success(monkeypatch, tmp_path) -> None:
def test_open_remote_file_transport_error(monkeypatch, tmp_path) -> None:
def raise_error(host, req, **kw):
raise SessionHelperStartError("transport boom")
monkeypatch.setattr(
"sessions.ssh_file_transport.execute_remote_read_file", raise_error
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {"outcome": "TRANSPORT_ERROR", "detail": "transport boom"},
)
result = open_remote_file_into_local_cache(
"host",
@@ -489,11 +476,13 @@ def test_open_remote_file_transport_error(monkeypatch, tmp_path) -> None:
def test_open_remote_file_not_found(monkeypatch, tmp_path) -> None:
def raise_error(host, req, **kw):
raise SessionHelperStartError("No such file or directory: /remote/gone")
monkeypatch.setattr(
"sessions.ssh_file_transport.execute_remote_read_file", raise_error
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {
"outcome": "REMOTE_NOT_FOUND",
"error_code": "file_read_failed",
"detail": "No such file or directory: /remote/gone",
},
)
result = open_remote_file_into_local_cache(
"host",
@@ -509,94 +498,49 @@ def test_open_remote_file_not_found(monkeypatch, tmp_path) -> None:
def test_open_remote_cache_reports_local_write_failure(
tmp_path: Path, monkeypatch
) -> None:
"""Remote fetch succeeds but local write_bytes raises → TRANSPORT_ERROR or
meaningful failure, not an unhandled exception."""
body = b"hello\n"
meta = {
"mtime_ns": 1,
"size_bytes": len(body),
"kind": "regular_file",
"unix_mode": 33188,
}
"""Local write failure inside the Rust transaction surfaces as TRANSPORT_ERROR.
The Rust transaction's atomic_write step now owns the local write; on
failure it returns ``outcome=TRANSPORT_ERROR`` with a ``local cache
write failed`` detail string.
"""
monkeypatch.setattr(
"sessions.ssh_file_transport._execute_rust_bridge_request",
lambda host_alias, method, params, **_kwargs: {
"ok": True,
"result": {
"metadata": meta,
"body_b64": base64.b64encode(body).decode("ascii"),
},
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {
"outcome": "TRANSPORT_ERROR",
"detail": "local cache write failed: disk full",
},
)
target = tmp_path / "cache" / "file.txt"
target.parent.mkdir(parents=True, exist_ok=True)
original_write_bytes = Path.write_bytes
def failing_write_bytes(self, data):
if self == target:
raise OSError("disk full")
return original_write_bytes(self, data)
monkeypatch.setattr(Path, "write_bytes", failing_write_bytes)
try:
res = open_remote_file_into_local_cache(
"host",
remote_absolute_path="/srv/ws/file.txt",
local_cache_path=target,
)
assert res.outcome in (
OpenOutcome.TRANSPORT_ERROR,
OpenOutcome.OK,
), f"unexpected outcome: {res.outcome}"
except OSError as exc:
assert "disk full" in str(exc)
res = open_remote_file_into_local_cache(
"host",
remote_absolute_path="/srv/ws/file.txt",
local_cache_path=target,
)
assert res.outcome is OpenOutcome.TRANSPORT_ERROR
assert not target.exists()
def test_open_remote_cache_write_failure_no_partial_sidecar(
tmp_path: Path, monkeypatch
) -> None:
"""If local write fails, no sidecar metadata file should remain."""
body = b"content\n"
meta = {
"mtime_ns": 1,
"size_bytes": len(body),
"kind": "regular_file",
"unix_mode": 33188,
}
"""A local write failure leaves no partial cache file or sidecar."""
monkeypatch.setattr(
"sessions.ssh_file_transport._execute_rust_bridge_request",
lambda host_alias, method, params, **_kwargs: {
"ok": True,
"result": {
"metadata": meta,
"body_b64": base64.b64encode(body).decode("ascii"),
},
"sessions.ssh_file_transport._rust_file_open_transaction",
lambda **_kwargs: {
"outcome": "TRANSPORT_ERROR",
"detail": "local cache write failed: permission denied",
},
)
target = tmp_path / "cache" / "f2.txt"
target.parent.mkdir(parents=True, exist_ok=True)
original_write_bytes = Path.write_bytes
def failing_write_bytes(self, data):
if self == target:
raise OSError("permission denied")
return original_write_bytes(self, data)
monkeypatch.setattr(Path, "write_bytes", failing_write_bytes)
try:
open_remote_file_into_local_cache(
"host",
remote_absolute_path="/srv/ws/f2.txt",
local_cache_path=target,
)
except OSError:
pass
open_remote_file_into_local_cache(
"host",
remote_absolute_path="/srv/ws/f2.txt",
local_cache_path=target,
)
assert not target.exists()
sidecar = target.with_suffix(target.suffix + ".sessions-metadata")
assert not sidecar.exists(), "sidecar should not be written on write failure"