diff --git a/scripts/bank_statement_import.py b/scripts/bank_statement_import.py
index 2fb640457..37b91aa3d 100644
--- a/scripts/bank_statement_import.py
+++ b/scripts/bank_statement_import.py
@@ -92,13 +92,16 @@
import csv
import datetime
import decimal
+import errno
import getpass
import hashlib
import io
import os
import pathlib
+import signal
import re
import shutil
+import stat
import subprocess
import sys
import tempfile
@@ -126,6 +129,32 @@ def __init__(self, category, message):
super().__init__(f"{category}: {message}")
+class OutputCleanupFailure(OSError):
+ """Committed output is present, but an old sensitive copy remains.
+
+ `retained_paths` gives the operator the exact private backup location to
+ protect or remove. A normal return would conceal that copy.
+ """
+
+ def __init__(self, retained_paths):
+ self.retained_paths = tuple(retained_paths)
+ super().__init__(
+ "output cleanup failed; prior output retained at "
+ + ", ".join(self.retained_paths)
+ )
+
+
+class OutputDescriptorCloseFailure(OSError):
+ """New output committed, but an ownership descriptor close was uncertain."""
+
+ def __init__(self, output_paths):
+ self.output_paths = tuple(output_paths)
+ super().__init__(
+ "output committed; ownership descriptor close failed for "
+ + ", ".join(self.output_paths)
+ )
+
+
# --------------------------------------------------------------------------- #
# PDF -> rows #
# --------------------------------------------------------------------------- #
@@ -1468,10 +1497,6 @@ def windows_destination_refusal(path, accept_inherited):
def _open_private(path, accept_inherited=False):
"""Create one output file readable only by its owner, and return the handle.
- The XML and the manifest carry counterparty names, amounts, an account
- label and every narration in the statement. On a shared host the default
- 022 umask would publish all of it as mode 0644.
-
Always `O_EXCL`: this only ever creates a file that did not exist. On
Windows that is the rule itself — an overwrite would keep the existing
file's ACL — and deciding it at create time rather than after an
@@ -1483,11 +1508,978 @@ def _open_private(path, accept_inherited=False):
if refusal:
raise refusal
try:
- return os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ # The retained ownership pin is also the final byte-verification
+ # authority. It must be readable without reopening the pathname,
+ # which could have been reclaimed by another writer.
+ return os.open(path, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
raise _existing_target_on_windows(path) from None
+def _file_identity(path):
+ stat_result = os.stat(path)
+ return stat_result.st_dev, stat_result.st_ino
+
+
+def _fd_identity(handle):
+ stat_result = os.fstat(handle)
+ return stat_result.st_dev, stat_result.st_ino
+
+
+def _entry_identity(path):
+ """The inode `unlink` would remove, without following a replacement link."""
+ stat_result = os.lstat(path)
+ return stat_result.st_dev, stat_result.st_ino
+
+
+def _cleanup_entry_state(path, owned_identity=None):
+ """Classify a cleanup name without following a replacement symlink.
+
+ ``lexists`` maps permission errors to false, which would make an
+ inaccessible entry indistinguishable from one that an unlink removed.
+ Recovery needs that distinction before it can close the descriptor pin.
+ """
+ try:
+ identity = _entry_identity(path)
+ except FileNotFoundError:
+ return "missing"
+ except OSError:
+ return "uninspectable"
+ if owned_identity is None:
+ return "present"
+ return "owned" if identity == owned_identity else "reclaimed"
+
+
+def _record_uninspectable_cleanup(path, failures):
+ failures.append(f"could not inspect owned output during cleanup: {path}")
+
+
+def _resolve_output_path(path):
+ """Resolve an operator path into the spelling this run is authorised to touch.
+
+ Python raises ``RuntimeError`` for a symlink loop on supported 3.10--3.12
+ versions. That is a changed/unverifiable output path, not an implementation
+ traceback that should escape the writer.
+ """
+ try:
+ return str(pathlib.Path(path).resolve())
+ except RuntimeError as error:
+ raise Refusal(
+ "output_path_changed",
+ f"{path} could not be resolved safely before output was written",
+ ) from error
+
+
+def _unlink_for_cleanup(path, owned_identity, failures):
+ """Remove a path only while it still names the inode this run created."""
+ state = _cleanup_entry_state(path, owned_identity)
+ if state == "missing":
+ return "missing"
+ if state == "uninspectable":
+ _record_uninspectable_cleanup(path, failures)
+ return "uncertain"
+ if state == "reclaimed":
+ # The pathname has been reclaimed. It is not ours to delete, and
+ # reporting it gives the operator a chance to find the private copy
+ # if it still exists without making a claim about foreign bytes.
+ failures.append(str(path))
+ return "reclaimed"
+ try:
+ os.unlink(path)
+ return "removed"
+ except FileNotFoundError:
+ # A caller holding a descriptor can distinguish this from a successful
+ # unlink. In particular, a parent-directory rename leaves the owned
+ # inode live at an unknown relative name rather than making it safe to
+ # call cleanup complete.
+ return "missing"
+ except OSError:
+ # A filesystem call can report an error after taking effect. Only retain
+ # the path when non-following reconciliation establishes that it remains.
+ state = _cleanup_entry_state(path, owned_identity)
+ if state == "missing":
+ return "missing"
+ if state == "reclaimed":
+ failures.append(str(path))
+ return "reclaimed"
+ if state == "owned":
+ failures.append(str(path))
+ elif state == "uninspectable":
+ _record_uninspectable_cleanup(path, failures)
+ return "uncertain"
+
+
+def _open_regular_output(path, expected_identity=None):
+ """Open and pin one existing regular output without waiting on a FIFO."""
+ handle = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
+ try:
+ stat_result = os.fstat(handle)
+ if not stat.S_ISREG(stat_result.st_mode):
+ raise Refusal(
+ "output_not_regular",
+ f"{path}: an existing output must be a regular file",
+ )
+ if stat_result.st_nlink != 1:
+ raise Refusal(
+ "output_has_multiple_links",
+ f"{path}: replacement requires a single-link output; rollback "
+ "cannot preserve hard-link topology",
+ )
+ if (expected_identity is not None
+ and (stat_result.st_dev, stat_result.st_ino) != expected_identity):
+ raise Refusal(
+ "output_path_changed",
+ f"{path} changed while its rollback copy was prepared",
+ )
+ return handle
+ except BaseException:
+ active_error = sys.exc_info()[1]
+ try:
+ os.close(handle)
+ except OSError:
+ # The validation refusal identifies the unsafe output shape. A
+ # failed pin close is separately actionable, but must not replace
+ # that typed outcome or be silently forgotten.
+ _append_cleanup_detail(
+ active_error,
+ "ownership descriptor close failed or could not be verified for: "
+ + str(path),
+ )
+ raise
+
+
+@contextlib.contextmanager
+def _defer_sigint_during_claim():
+ """Keep an ownership handoff whole until a pending SIGINT can unwind it."""
+ if hasattr(signal, "pthread_sigmask"):
+ try:
+ previous = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT})
+ except (OSError, ValueError):
+ previous = None
+ if previous is not None:
+ try:
+ yield
+ finally:
+ signal.pthread_sigmask(signal.SIG_SETMASK, previous)
+ return
+ # Windows has no pthread signal mask. Temporarily retain Ctrl-C as a
+ # pending event, restore the caller's handler after registration, and then
+ # deliver it through that original handler. A non-main-thread claim cannot
+ # install a signal handler, so its existing exception recovery remains the
+ # authority in that unsupported execution context.
+ pending = []
+ try:
+ previous = signal.getsignal(signal.SIGINT)
+ signal.signal(signal.SIGINT, lambda _signum, _frame: pending.append(True))
+ except (OSError, ValueError):
+ yield
+ return
+ try:
+ yield
+ finally:
+ signal.signal(signal.SIGINT, previous)
+ if pending:
+ signal.raise_signal(signal.SIGINT)
+
+
+def _owned_path(path, handle, *, created, owned_records=None):
+ """Record a pathname and retain the descriptor that pins its inode.
+
+ `created` is explicit because a registration failure has opposite cleanup
+ authority for a new private path and an existing output. Only the former
+ may be unlinked while recovery establishes descriptor ownership.
+ """
+ try:
+ identity = _fd_identity(handle)
+ except BaseException as error:
+ # The creator has already made `path`, but until its descriptor and
+ # pathname agree on one identity it has not entered any ownership list.
+ # Retry once for a transient fstat failure; if that still cannot prove
+ # ownership, preserve a possibly reclaimed pathname and say so rather
+ # than deleting a foreign file during failure cleanup.
+ failures = []
+ try:
+ try:
+ identity = _fd_identity(handle)
+ except BaseException:
+ identity = None
+ if created:
+ if identity is None:
+ state = _cleanup_entry_state(path)
+ if state == "present":
+ failures.append(str(path))
+ elif state == "uninspectable":
+ _record_uninspectable_cleanup(path, failures)
+ else:
+ outcome = _unlink_for_cleanup(path, identity, failures)
+ _reconcile_owned_pin_after_cleanup(
+ {"path": path, "identity": identity, "pin": handle},
+ outcome,
+ failures,
+ )
+ finally:
+ try:
+ os.close(handle)
+ except OSError:
+ state = _cleanup_entry_state(path) if created else "missing"
+ if state == "present":
+ failures.append(str(path))
+ elif state == "uninspectable":
+ _record_uninspectable_cleanup(path, failures)
+ if failures:
+ _append_cleanup_detail(
+ error,
+ "output ownership registration failed; retained path(s): "
+ + ", ".join(sorted(set(failures))),
+ )
+ raise
+ record = {"path": path, "identity": identity, "pin": handle}
+ if owned_records is not None:
+ # Insert before returning to the caller: an interrupt after successful
+ # registration still leaves one cleanup authority for this new inode.
+ owned_records.append(record)
+ return record
+
+
+def _claim_owned_output(create, *, created, owned_records):
+ """Register a created or opened descriptor before a caller can own it.
+
+ The factory returns ``(handle, path)``. If an interrupt lands after a
+ filesystem effect but before collection insertion, the still-pinned inode
+ is reconciled by identity; a replacement at that pathname is never
+ unlinked. On POSIX, apply the private mode through the descriptor because
+ creation modes are filtered by the process umask. Windows retains the
+ platform's existing ACL-based creation behavior.
+ """
+ handle = None
+ record = None
+ try:
+ # A private create can finish before its factory returns. Defer Ctrl-C
+ # until the descriptor and its cleanup owner have been registered.
+ with _defer_sigint_during_claim():
+ handle, path = create()
+ record = _owned_path(path, handle, created=created)
+ if created and os.name != "nt":
+ os.fchmod(handle, 0o600)
+ owned_records.append(record)
+ return record
+ except BaseException as error:
+ # If registration already reached its collection, the outer handler is
+ # its sole cleanup authority. Otherwise recover through the pin.
+ if handle is not None and not any(item.get("pin") == handle
+ for item in owned_records):
+ failures = []
+ try:
+ identity = _fd_identity(handle)
+ except OSError:
+ # `_owned_path` may already have reconciled and closed it.
+ pass
+ else:
+ fallback = {"path": path, "identity": identity, "pin": handle}
+ if created:
+ _cleanup_owned_path(fallback, failures)
+ else:
+ _close_owned_path(fallback, failures)
+ _note_cleanup_failures(error, failures)
+ raise
+
+
+def _claimed_output_changed(supplied_path, canonical_path, identity):
+ """Whether the pathname still names the descriptor-backed claimed inode.
+
+ This is deliberately a helper rather than an inner ``try`` in
+ ``write_outputs``. A signal raised while this validation runs must unwind
+ through the transaction's outer recovery handler, which owns every staged
+ replacement and backup.
+ """
+ try:
+ return (_resolve_output_path(supplied_path) != canonical_path
+ or _file_identity(supplied_path) != identity
+ or _entry_identity(canonical_path) != identity)
+ except (FileNotFoundError, OSError):
+ return True
+
+
+def _close_owned_path(record, failures, diagnostic_path=None, descriptor_failures=None):
+ """Release an ownership pin without guessing from a stale pathname.
+
+ ``close`` may report an error after releasing a descriptor. EBADF from a
+ retained-descriptor ``fstat`` is the one supported indication of that
+ after-effect. Every other failed or mismatched inspection remains a
+ descriptor uncertainty: never retry a close that could target a reused fd.
+ """
+ close_failed = False
+ with _defer_sigint_during_claim():
+ handle = record.get("pin")
+ if handle is None:
+ return
+ record["pin"] = None
+ try:
+ os.close(handle)
+ except OSError:
+ close_failed = True
+ if not close_failed:
+ return
+
+ diagnostic = str(diagnostic_path or record.get(
+ "cleanup_path", record["path"]))
+ destination = (descriptor_failures if descriptor_failures is not None
+ else failures)
+ try:
+ stat_result = os.fstat(handle)
+ except OSError as error:
+ if error.errno == errno.EBADF:
+ # The close took effect (or the descriptor was independently
+ # made invalid). Do not retry it: a later fd could be foreign.
+ return
+ destination.append(diagnostic)
+ return
+ if (stat_result.st_dev, stat_result.st_ino) == record["identity"]:
+ # The original descriptor is still open. Its pathname may already
+ # name a staged replacement, so this is never a retained-path claim.
+ destination.append(diagnostic)
+ return
+ # A mocked or platform-specific close could leave a live but different fd.
+ # It is neither safe to close again nor evidence about a pathname.
+ destination.append(diagnostic)
+
+
+def _reconcile_owned_pin_after_cleanup(record, outcome, failures):
+ """Disclose an owned inode whose descriptor proves it remains linked.
+
+ A successful unlink only removes the claimed spelling. A hard-link alias or
+ parent-directory rename can leave the pinned inode linked elsewhere, where
+ this command has neither a pathname nor authority to remove it.
+ """
+ if outcome not in ("removed", "missing", "reclaimed"):
+ return
+ pin = record.get("pin")
+ if pin is None:
+ return
+ try:
+ stat_result = os.fstat(pin)
+ except OSError:
+ _record_uninspectable_cleanup(record["path"], failures)
+ return
+ if ((stat_result.st_dev, stat_result.st_ino) == record["identity"]
+ and stat_result.st_nlink > 0):
+ failures.append(
+ f"owned output could not be located after cleanup: {record['path']}")
+
+
+def _record_windows_cleanup_alias(record, failures):
+ """Disclose a hard-link alias while Windows still permits pin inspection."""
+ pin = record.get("pin")
+ if pin is None:
+ return
+ try:
+ stat_result = os.fstat(pin)
+ except OSError:
+ # Windows must close before it can unlink. If this pre-close
+ # inspection cannot determine whether a hard-link alias exists, the
+ # later unlink of the known spelling cannot make that uncertainty go
+ # away.
+ _record_uninspectable_cleanup(record["path"], failures)
+ return
+ if ((stat_result.st_dev, stat_result.st_ino) == record["identity"]
+ and stat_result.st_nlink > 1):
+ failures.append(
+ f"unknown hard-link alias may retain output bytes: {record['path']}")
+
+
+def _cleanup_owned_path(
+ record, failures, descriptor_failures=None, suppress_reclaimed_name=False):
+ """Remove one owned pathname, releasing its pin first on Windows.
+
+ POSIX keeps the descriptor open through the identity decision so an inode
+ cannot be recycled before cleanup. Windows does not permit unlinking an
+ open file, so fresh exclusive outputs close first and retain the existing
+ Windows cleanup behavior; actual Windows filesystem evidence remains
+ required for that platform-specific branch.
+ """
+ if os.name == "nt":
+ # Windows requires closing before unlinking. A close can report an
+ # error after releasing the descriptor, so decide whether its
+ # pathname diagnostic remains only after the unlink outcome is known.
+ _record_windows_cleanup_alias(record, failures)
+ failure_start = len(failures)
+ _close_owned_path(record, failures, descriptor_failures=descriptor_failures)
+ outcome = _unlink_for_cleanup(
+ record.get("cleanup_path", record["path"]), record["identity"], failures)
+ if outcome == "removed" or (
+ outcome == "reclaimed" and suppress_reclaimed_name):
+ del failures[failure_start:]
+ if outcome == "reclaimed":
+ failures.append(
+ "owned output could not be located after cleanup: "
+ + str(record["path"]))
+ else:
+ cleanup_path = record.get("cleanup_path", record["path"])
+ failure_start = len(failures)
+ outcome = _unlink_for_cleanup(cleanup_path, record["identity"], failures)
+ if outcome == "reclaimed" and (
+ suppress_reclaimed_name or "cleanup_path" in record):
+ # A foreign claimant of the stale name is not a retained path of
+ # this output. Keep any earlier diagnostics, but replace this
+ # pathname with the separate pinned-inode conclusion below.
+ del failures[failure_start:]
+ _reconcile_owned_pin_after_cleanup(record, outcome, failures)
+ _close_owned_path(record, failures, descriptor_failures=descriptor_failures)
+
+
+def _metadata_from_handle(path, handle):
+ """Capture regular-output metadata while its original inode is pinned.
+
+ The private backup remains mode 0600. These values are applied only after
+ that backup has been atomically restored to its original pathname.
+ """
+ stat_result = os.fstat(handle)
+ metadata = {
+ "mode": stat.S_IMODE(stat_result.st_mode),
+ "uid": stat_result.st_uid,
+ "gid": stat_result.st_gid,
+ "atime_ns": stat_result.st_atime_ns,
+ "mtime_ns": stat_result.st_mtime_ns,
+ "xattrs": None,
+ }
+ if hasattr(os, "listxattr"):
+ try:
+ metadata["xattrs"] = {
+ name: os.getxattr(handle, name)
+ for name in os.listxattr(handle)
+ }
+ except OSError as error:
+ raise Refusal(
+ "output_metadata_unavailable",
+ f"{path}: could not record extended attributes for rollback: {error}",
+ ) from None
+ return metadata
+
+
+def _restore_metadata(handle, metadata):
+ """Restore captured metadata to an already-restored regular output."""
+ current = os.fstat(handle)
+ if (current.st_uid, current.st_gid) != (metadata["uid"], metadata["gid"]):
+ os.fchown(handle, metadata["uid"], metadata["gid"])
+ # The private backup is writable. Restore extended attributes before the
+ # final mode: Linux requires write permission for xattr changes, including
+ # removal of attributes introduced by the staged output.
+ original_xattrs = metadata["xattrs"]
+ if original_xattrs is not None:
+ for name in os.listxattr(handle):
+ if name not in original_xattrs:
+ os.removexattr(handle, name)
+ for name, value in original_xattrs.items():
+ os.setxattr(handle, name, value)
+ os.utime(handle, ns=(metadata["atime_ns"], metadata["mtime_ns"]))
+ os.fchmod(handle, metadata["mode"])
+
+
+def _pinned_original_still_has_one_link(record):
+ """Refuse if the original gained a hard link after its first pin."""
+ stat_result = os.fstat(record["pin"])
+ if ((stat_result.st_dev, stat_result.st_ino) != record["identity"]
+ or stat_result.st_nlink == 0):
+ raise Refusal(
+ "output_path_changed",
+ f"{record['path']} changed while its rollback copy was prepared",
+ )
+ if stat_result.st_nlink != 1:
+ raise Refusal(
+ "output_has_multiple_links",
+ f"{record['path']}: replacement requires a single-link output; rollback "
+ "cannot preserve hard-link topology",
+ )
+
+
+def _require_single_owned_link(record, description, alias_category):
+ """Refuse an owned output that moved or gained an unlocatable alias."""
+ stat_result = os.fstat(record["pin"])
+ if ((stat_result.st_dev, stat_result.st_ino) != record["identity"]
+ or stat_result.st_nlink == 0):
+ raise Refusal(
+ "output_path_changed",
+ f"{record['path']} {description} changed before commit",
+ )
+ if stat_result.st_nlink != 1:
+ error = Refusal(
+ alias_category,
+ f"{record['path']}: {description} gained a hard-link alias before commit",
+ )
+ _append_cleanup_detail(
+ error, f"{description} has an unknown hard-link alias; it may retain output bytes")
+ raise error
+
+
+def _pinned_backup_still_has_one_link(record):
+ _require_single_owned_link(record, "rollback copy", "rollback_backup_has_multiple_links")
+
+
+
+def _digest_pinned_bytes(handle):
+ """Hash a retained descriptor without changing its caller's file offset."""
+ digest = hashlib.sha256()
+ if hasattr(os, "pread"):
+ offset = 0
+ while True:
+ chunk = os.pread(handle, 1024 * 1024, offset)
+ if not chunk:
+ return digest.digest()
+ digest.update(chunk)
+ offset += len(chunk)
+ # Existing-output replacement is refused on Windows, where ``pread`` is
+ # not universally available. Keep that fallback ownership-local as well.
+ original_offset = os.lseek(handle, 0, os.SEEK_CUR)
+ try:
+ os.lseek(handle, 0, os.SEEK_SET)
+ while True:
+ chunk = os.read(handle, 1024 * 1024)
+ if not chunk:
+ return digest.digest()
+ digest.update(chunk)
+ finally:
+ os.lseek(handle, original_offset, os.SEEK_SET)
+
+
+def _mark_rollback_unavailable(swap, failures, *, retain_named=False):
+ """Record a partial destination and reconcile its still-pinned backup.
+
+ A missing pathname does not prove the backup inode vanished: its descriptor
+ can still reveal a moved or aliased private copy. Inspect before the outer
+ recovery releases that descriptor; zero links are the only no-path case.
+ """
+ swap["rollback_unavailable"] = True
+ if swap.get("rollback_unavailable_reported"):
+ return "unrollbackable"
+ swap["rollback_unavailable_reported"] = True
+ record = swap["backup"]
+ pin = record.get("pin")
+ if pin is None:
+ _record_uninspectable_cleanup(record["path"], failures)
+ return "unrollbackable"
+ try:
+ stat_result = os.fstat(pin)
+ except OSError:
+ _record_uninspectable_cleanup(record["path"], failures)
+ return "unrollbackable"
+ if (stat_result.st_dev, stat_result.st_ino) != record["identity"]:
+ _record_uninspectable_cleanup(record["path"], failures)
+ return "unrollbackable"
+ if stat_result.st_nlink == 0:
+ return "unrollbackable"
+ try:
+ still_named = _entry_identity(record["path"]) == record["identity"]
+ except FileNotFoundError:
+ still_named = False
+ except OSError:
+ _record_uninspectable_cleanup(record["path"], failures)
+ return "unrollbackable"
+ if stat_result.st_nlink > 1:
+ failures.append(
+ f"unknown hard-link alias may retain rollback bytes: {record['path']}")
+ elif still_named and retain_named:
+ failures.append(record["path"])
+ elif not still_named:
+ failures.append(
+ f"owned rollback copy could not be located after cleanup: {record['path']}")
+ return "unrollbackable"
+
+def _copy_private_backup(source_path, original_identity, backup_handle):
+ """Copy the original inode into an owner-only backup already opened O_EXCL.
+
+ The caller already pins the original inode through the transaction; this
+ read descriptor pins it while bytes are copied. The source path is checked
+ both before and after the copy; that catches an atomic path replacement
+ during preparation. Without filesystem locking, an adversary that modifies
+ the same inode while it is being read remains outside this command's
+ authority, so this does not promise a crash transaction.
+ """
+ try:
+ source_handle = _open_regular_output(source_path, original_identity)
+ except FileNotFoundError:
+ # The caller retains the established missing-source diagnostic and
+ # reconciliation path for this specific authority outcome.
+ raise
+ except OSError as error:
+ # This is the only I/O boundary translated here. Later read, write,
+ # sync, and verification errors describe a partially prepared private
+ # copy and must keep their original exception for recovery to handle.
+ refusal = Refusal(
+ "output_path_changed",
+ f"{source_path} could not be opened as its rollback source",
+ )
+ refusal.backup_source_open_failed = True
+ raise refusal from error
+ try:
+ if _fd_identity(source_handle) != original_identity:
+ raise Refusal(
+ "output_path_changed",
+ f"{source_path} changed while its rollback copy was prepared",
+ )
+ copied = hashlib.sha256()
+ while True:
+ chunk = os.read(source_handle, 1024 * 1024)
+ if not chunk:
+ break
+ copied.update(chunk)
+ view = memoryview(chunk)
+ while view:
+ written = os.write(backup_handle, view)
+ if written == 0:
+ raise OSError("private backup write made no progress")
+ view = view[written:]
+ os.fsync(backup_handle)
+ os.lseek(backup_handle, 0, os.SEEK_SET)
+ verified = hashlib.sha256()
+ while True:
+ chunk = os.read(backup_handle, 1024 * 1024)
+ if not chunk:
+ break
+ verified.update(chunk)
+ if copied.digest() != verified.digest():
+ raise OSError("private backup did not retain the copied bytes")
+ backup_digest = verified.digest()
+ try:
+ source_unchanged = (
+ _fd_identity(source_handle) == original_identity
+ and _file_identity(source_path) == original_identity)
+ except (FileNotFoundError, OSError):
+ source_unchanged = False
+ if not source_unchanged:
+ raise Refusal(
+ "output_path_changed",
+ f"{source_path} changed while its rollback copy was prepared",
+ )
+ return backup_digest
+ finally:
+ active_error = sys.exc_info()[1]
+ try:
+ os.close(source_handle)
+ except OSError:
+ if active_error is None:
+ raise
+ # The typed copy refusal is the transaction outcome. A failed
+ # source-pin close is separately actionable, but must not replace
+ # that refusal with an untyped descriptor exception.
+ _append_cleanup_detail(
+ active_error,
+ "ownership descriptor close failed or could not be verified for: "
+ + str(source_path),
+ )
+
+
+def _restore_backup(swap, failures, metadata_scope_warnings, descriptor_failures=None):
+ """Restore an owned private backup after a caught swap failure.
+
+ `os.replace` can report an exception after the filesystem call took effect.
+ Roll back only when the destination still names this run's staged inode.
+ A different inode may be a foreign writer's success, so keep the private
+ backup and report the conflict rather than overwriting it.
+ """
+ backup_record = swap["backup"]
+ backup, backup_identity = backup_record["path"], backup_record["identity"]
+ destination, original_identity = swap["destination"], swap["original_identity"]
+ staged_identity, metadata = swap["staged_identity"], swap["metadata"]
+ try:
+ current_identity = _entry_identity(destination)
+ except OSError:
+ # After a replacement started, an uninspectable destination might
+ # still name this run's staged inode. It is not evidence of a foreign
+ # writer, so keep the private backup and report the real destination
+ # as a partial result.
+ if swap["swap_started"]:
+ return _mark_rollback_unavailable(swap, failures, retain_named=True)
+ current_identity = None
+ if not swap["swap_started"] or current_identity == original_identity:
+ # Backup reads can update atime before any swap. Restore that effect
+ # through the original inode pin, never through a possibly foreign path.
+ # Keep the current mtime and all other metadata: this read did not alter
+ # them, and restoring their old values could erase an external change.
+ original = swap["original"]
+ if original is not None and metadata is not None:
+ try:
+ handle = original["pin"]
+ current = os.fstat(handle)
+ os.utime(handle, ns=(metadata["atime_ns"], current.st_mtime_ns))
+ except OSError:
+ failures.append(destination)
+ _cleanup_owned_path(
+ backup_record, failures, descriptor_failures=descriptor_failures,
+ suppress_reclaimed_name=True)
+ return
+ if current_identity != staged_identity:
+ # Keep the foreign destination untouched, but reconcile the pinned
+ # rollback inode before releasing it. A moved backup has no longer
+ # been disclosed merely by its stale private spelling.
+ return _mark_rollback_unavailable(swap, failures, retain_named=True)
+ if swap.get("rollback_unavailable"):
+ return _mark_rollback_unavailable(swap, failures)
+ restored = False
+ try:
+ _pinned_backup_still_has_one_link(backup_record)
+ except Refusal as refusal:
+ if refusal.category == "rollback_backup_has_multiple_links":
+ # Keep the original failure escaping. The private backup is still
+ # named here, but its unknown alias may retain prior statement
+ # bytes; moving it back would make that alias a live copy of the
+ # destination.
+ return _mark_rollback_unavailable(swap, failures)
+ # A missing or changed backup cannot restore a destination that still
+ # names this run's staged inode. Report that partial result by its
+ # actual destination, never by the vanished private spelling.
+ return _mark_rollback_unavailable(swap, failures)
+ except OSError:
+ # An uninspectable pin leaves the named backup insufficient evidence
+ # that rollback remains safe. Preserve its known name, but report the
+ # actual staged destination as a partial commit for the caller.
+ failures.append(backup)
+ return _mark_rollback_unavailable(swap, failures)
+ try:
+ if _entry_identity(backup) != backup_identity:
+ return _mark_rollback_unavailable(swap, failures)
+ try:
+ digest_matches = (
+ _digest_pinned_bytes(backup_record["pin"]) == backup_record["digest"])
+ except OSError:
+ return _mark_rollback_unavailable(swap, failures, retain_named=True)
+ if not digest_matches:
+ return _mark_rollback_unavailable(swap, failures, retain_named=True)
+ try:
+ destination_still_staged = (
+ _entry_identity(destination) == staged_identity)
+ except OSError:
+ destination_still_staged = False
+ if not destination_still_staged:
+ # A concurrent writer now owns the destination. Keep its bytes
+ # intact and reconcile this run's private rollback copy instead
+ # of restoring over the foreign inode.
+ return _mark_rollback_unavailable(swap, failures, retain_named=True)
+ try:
+ backup_still_current = _entry_identity(backup) == backup_identity
+ except OSError:
+ backup_still_current = False
+ if not backup_still_current:
+ # The named source of the restore can be retargeted after its
+ # digest check. Do not move a foreign inode over the destination.
+ return _mark_rollback_unavailable(swap, failures, retain_named=True)
+ # This observes ownership immediately before the replace. POSIX has no
+ # compare-and-swap rename, so a hostile concurrent rename after this
+ # check is still outside the CLI's locking authority.
+ os.replace(backup, destination)
+ restored = True
+ except OSError:
+ # A failed rename can still take effect. Re-inspect the destination
+ # before reporting a partial result; `current_identity` predates the
+ # restore attempt and cannot classify its outcome.
+ try:
+ destination_after_restore = _entry_identity(destination)
+ except OSError:
+ destination_after_restore = None
+ restored = destination_after_restore == backup_identity
+ if not restored:
+ try:
+ backup_retained = _entry_identity(backup) == backup_identity
+ except OSError:
+ backup_retained = False
+ if backup_retained:
+ if destination_after_restore is None:
+ # The failed replace may have taken effect. An unknown
+ # destination cannot be classified as a safe foreign
+ # inode, so disclose this actual partial result while the
+ # named backup remains inspectable.
+ return _mark_rollback_unavailable(
+ swap, failures, retain_named=True)
+ # Whether the failed restore left the staged inode in place
+ # or a foreign inode at the destination, the still-named
+ # private backup is the only recoverable old copy. Report it
+ # through the rollback path and let the caller disclose the
+ # destination as partial; never clean it as a successful
+ # rollback based on the pre-restore identity.
+ return _mark_rollback_unavailable(
+ swap, failures, retain_named=True)
+ elif destination_after_restore == staged_identity:
+ return _mark_rollback_unavailable(swap, failures)
+ else:
+ failures.append(destination)
+ if restored:
+ try:
+ restore_handle = _open_regular_output(destination, backup_identity)
+ try:
+ _restore_metadata(restore_handle, metadata)
+ try:
+ restored_still_current = (
+ _entry_identity(destination) == backup_identity)
+ except OSError:
+ return _mark_rollback_unavailable(swap, failures)
+ if not restored_still_current:
+ return _mark_rollback_unavailable(swap, failures)
+ try:
+ _pinned_backup_still_has_one_link(swap["backup"])
+ except (OSError, Refusal):
+ return _mark_rollback_unavailable(swap, failures)
+ finally:
+ _close_owned_path(
+ {"path": destination, "identity": backup_identity,
+ "pin": restore_handle},
+ failures, diagnostic_path=destination,
+ descriptor_failures=descriptor_failures)
+ metadata_scope_warnings.append(destination)
+ except (OSError, Refusal):
+ return _mark_rollback_unavailable(swap, failures)
+
+
+def _append_cleanup_detail(error, message):
+ """Keep recovery details visible on Python versions without add_note."""
+ # Python prints `SystemExit.code`, not exception notes. A Refusal is a
+ # SystemExit so that command-line validation exits without a traceback;
+ # put the retained location in its visible code rather than hiding it in
+ # an unrendered note.
+ if isinstance(error, Refusal):
+ error.code = f"{error.code}\n{message}"
+ return
+ add_note = getattr(error, "add_note", None)
+ if callable(add_note):
+ add_note(message)
+ return
+ # BaseException.add_note arrived in Python 3.11. OSError can render cached
+ # errno, strerror, and filename fields instead of its mutable `args`, so
+ # retain the original exception intact and write the recovery detail where
+ # an unhandled CLI failure will still show it on Python 3.10.
+ print(message, file=sys.stderr)
+
+
+def _note_cleanup_failures(error, failures):
+ if failures:
+ retained = ", ".join(sorted({str(failure) for failure in failures}))
+ message = "output cleanup or rollback failed; retained path(s): " + retained
+ _append_cleanup_detail(error, message)
+
+
+def _note_rollback_metadata_scope(error, restored_paths):
+ """Disclose metadata classes not established by this caught rollback."""
+ if not restored_paths:
+ return
+ restored = ", ".join(sorted(set(restored_paths)))
+ message = (
+ "rollback restored bytes and captured portable metadata for: "
+ f"{restored}; extended ACLs and file flags were not verified"
+ )
+ _append_cleanup_detail(error, message)
+
+
+def _cleanup_committed_outputs(replaced, claimed, retained_failures, descriptor_failures):
+ """Remove old private copies after every replacement has committed."""
+ for swap in replaced:
+ backup = swap["backup"]
+ _cleanup_owned_path(
+ backup, retained_failures, descriptor_failures=descriptor_failures,
+ suppress_reclaimed_name=True)
+ _close_owned_path(
+ swap["original"], retained_failures,
+ diagnostic_path=swap.get("destination", swap["original"]["path"]),
+ descriptor_failures=descriptor_failures)
+ for record in claimed:
+ # A claimed path did not exist before this run. Its close failure cannot
+ # retain a prior sensitive copy, so keep that diagnostic distinct from
+ # a backup that an operator must protect or remove.
+ _close_owned_path(
+ record, descriptor_failures, diagnostic_path=record.get("canonical_path"))
+
+
+def _reconcile_staged_output_pin(record, failures):
+ """Check a moved staged descriptor at its destination before release."""
+ if record.get("cleanup_path") == record.get("canonical_path"):
+ return
+ pin = record.get("pin")
+ if pin is None:
+ return
+ try:
+ stat_result = os.fstat(pin)
+ except OSError:
+ _record_uninspectable_cleanup(record["canonical_path"], failures)
+ return
+ if (stat_result.st_dev, stat_result.st_ino) != record["identity"]:
+ _record_uninspectable_cleanup(record["canonical_path"], failures)
+ return
+ # An unlinked inode cannot be a retained foreign destination. Its pin is
+ # still useful to prove that cleanup removed the staged output, but there
+ # is no pathname for an operator to recover.
+ if stat_result.st_nlink == 0:
+ return
+ try:
+ committed_here = (
+ _entry_identity(record["canonical_path"]) == record["identity"])
+ except OSError:
+ committed_here = False
+ if not committed_here:
+ failures.append(
+ "staged output could not be located after cleanup: "
+ + str(record["canonical_path"]))
+
+
+def _reconcile_interrupted_committed_cleanup(
+ replaced, claimed, retained_failures, descriptor_failures):
+ """Close every pin and disclose old copies without undoing a commit.
+
+ Recovery runs while another exception is already escaping. Inspection or
+ cleanup failures are diagnostics here: they must never replace that
+ original exception or skip closure of later ownership descriptors.
+ """
+ for swap in replaced:
+ backup = swap["backup"]
+ try:
+ try:
+ still_at_path = _entry_identity(backup["path"]) == backup["identity"]
+ except FileNotFoundError:
+ still_at_path = False
+ except OSError:
+ still_at_path = None
+ if still_at_path is True:
+ retained_failures.append(str(backup["path"]))
+ # A named backup can also have gained an alias after final
+ # validation. Inspect its pin before release so the operator
+ # does not remove the .bak and miss a second private copy.
+ pin = backup.get("pin")
+ if pin is not None:
+ try:
+ stat_result = os.fstat(pin)
+ except OSError:
+ retained_failures.append(
+ "could not inspect committed rollback copy: " + str(backup["path"]))
+ else:
+ if (stat_result.st_dev, stat_result.st_ino) != backup["identity"]:
+ retained_failures.append(
+ "could not inspect committed rollback copy: " + str(backup["path"]))
+ elif stat_result.st_nlink > 1:
+ retained_failures.append(
+ "unknown hard-link alias may retain rollback bytes: "
+ + str(backup["path"]))
+ elif still_at_path is False:
+ _cleanup_owned_path(
+ backup, retained_failures, descriptor_failures=descriptor_failures,
+ suppress_reclaimed_name=True)
+ else:
+ # We cannot identify an entry after an I/O/permission error.
+ # Preserve the original interruption and report no ownership
+ # claim about a possibly foreign pathname.
+ retained_failures.append(
+ "could not inspect committed rollback copy: " + str(backup["path"]))
+ except OSError:
+ retained_failures.append(
+ "could not reconcile committed rollback copy: " + str(backup["path"]))
+ finally:
+ _close_owned_path(backup, retained_failures,
+ descriptor_failures=descriptor_failures)
+ _close_owned_path(
+ swap["original"], retained_failures,
+ diagnostic_path=swap.get("destination", swap["original"]["path"]),
+ descriptor_failures=descriptor_failures)
+ for record in claimed:
+ _reconcile_staged_output_pin(record, retained_failures)
+ _close_owned_path(
+ record, descriptor_failures, diagnostic_path=record.get("canonical_path"))
+
+
def write_outputs(targets, accept_inherited=False, after_claim=None):
"""Claim **every** destination, then write them. All of them or none.
@@ -1515,8 +2507,44 @@ def write_outputs(targets, accept_inherited=False, after_claim=None):
does not exist is created exclusively under its own name, which is both the
reservation against a concurrent run and, on Windows, the rule itself —
there an existing destination is refused outright rather than staged.
+
+ A destination that is a **symlink** is written through to its target: the
+ sibling temporary file is created next to (and the final rename targets)
+ `os.path.realpath(path)`, not `path` itself. `os.replace` does not follow a
+ symlink at the destination — it replaces the link, the same as `unlink`
+ would — so renaming onto the link's own name would silently turn it into a
+ plain file and leave whatever else reads through that link looking at
+ stale content.
+
+ Before replacing an existing POSIX destination, its old inode is copied from
+ an opened descriptor to a private sibling backup. The destination therefore
+ remains present until one `os.replace` atomically changes it from old bytes
+ to new bytes. This is rollback for exceptions caught in this process, not a
+ multi-file crash transaction: a process or host crash can retain private
+ `.bak` files and leave different destinations at different committed
+ versions.
+
+ The supplied destination's resolved path and inode are revalidated right
+ before that replacement. A symlink (or symlinked parent) retargeted after
+ claiming is refused instead of silently writing the stale target. This
+ detects changes observed at the commit boundary; a hostile filesystem that
+ changes the path again after that check remains outside this CLI's locking
+ authority.
"""
- claimed, staged = [], []
+ claimed, staged, replaced = [], [], []
+ # These hold pins which successfully registered but have not yet been
+ # transferred into a staged swap. They close or clean up an interrupt in
+ # the small caller-side handoff interval.
+ unpaired_originals, unpaired_backups = [], []
+ # A record is the one ownership authority for a pathname: cleanup may
+ # unlink it only while its identity still equals record["identity"].
+ pending_backup = None
+ pending_swap = None
+ cleanup_failures = []
+ descriptor_close_failures = []
+ metadata_scope_warnings = []
+ partial_commit_failures = []
+ committed = False
try:
for path, _ in targets:
if os.path.exists(path):
@@ -1524,34 +2552,432 @@ def write_outputs(targets, accept_inherited=False, after_claim=None):
refusal = windows_destination_refusal(path, accept_inherited)
if refusal:
raise refusal
- handle, temporary = tempfile.mkstemp(
- dir=os.path.dirname(os.path.abspath(path)),
- prefix=os.path.basename(path) + ".", suffix=".part")
- claimed.append((temporary, handle))
- staged.append((temporary, path))
+ real_path = os.path.realpath(path)
+ # Commit the existing inode before any staging syscall makes
+ # a visible sibling. A replacement before this open is the
+ # requested current path; a replacement after it is detected
+ # before this run has authority to create or swap output.
+ try:
+ original = _claim_owned_output(
+ lambda: (_open_regular_output(real_path, None), real_path),
+ created=False, owned_records=unpaired_originals)
+ except OSError:
+ # A path which disappears between exists() and the pinned
+ # open is a claim-time retarget, not an untyped filesystem
+ # failure. Nothing has been staged yet, so refuse it in
+ # the same typed channel as the later revalidation.
+ raise Refusal(
+ "output_path_changed",
+ f"{path} changed while it was being claimed",
+ ) from None
+ original_identity = original["identity"]
+ state = {"temporary": None, "supplied_path": path,
+ "real_path": real_path,
+ "original_identity": original_identity,
+ "original": original}
+ staged.append(state)
+ try:
+ claimed_existing_output_changed = (
+ _file_identity(real_path) != original_identity)
+ except (FileNotFoundError, OSError):
+ claimed_existing_output_changed = True
+ if claimed_existing_output_changed:
+ raise Refusal(
+ "output_path_changed",
+ f"{path} changed while it was being claimed",
+ )
+ record = _claim_owned_output(
+ lambda: tempfile.mkstemp(
+ dir=os.path.dirname(real_path),
+ prefix=os.path.basename(real_path) + ".", suffix=".part"),
+ created=True, owned_records=claimed)
+ temporary = record["path"]
+ record["supplied_path"] = path
+ record["canonical_path"] = real_path
+ record["cleanup_path"] = temporary
+ state["temporary"] = record
else:
- claimed.append((path, _open_private(path, accept_inherited)))
+ supplied_path = path
+ canonical_path = _resolve_output_path(supplied_path)
+ # Register this newly created inode before returning to an
+ # interruptible caller line. The record is the cleanup owner
+ # even if a signal arrives before its path metadata is filled.
+ record = _claim_owned_output(
+ lambda: (_open_private(canonical_path, accept_inherited), canonical_path),
+ created=True, owned_records=claimed)
+ # Keep cleanup on the canonical inode path captured before the
+ # open. The supplied spelling remains an authority that must
+ # still resolve to that same inode at commit time.
+ record["supplied_path"] = supplied_path
+ record["canonical_path"] = canonical_path
+ record["cleanup_path"] = canonical_path
+ record["path"] = supplied_path
+ try:
+ claimed_path_changed = (
+ _resolve_output_path(supplied_path) != canonical_path
+ or _file_identity(canonical_path) != record["identity"])
+ except (FileNotFoundError, OSError):
+ claimed_path_changed = True
+ if claimed_path_changed:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} changed while it was being claimed",
+ )
if after_claim is not None:
after_claim()
- for index, ((_, text), (where, handle)) in enumerate(zip(targets, claimed)):
- claimed[index] = (where, None) # fdopen owns the handle from here
+ for (_, text), record in zip(targets, claimed):
+ where = record["path"]
+ # The record already owns the opened descriptor, so a duplicate
+ # failure here can still close it and unlink the created path.
+ handle = os.dup(record["pin"])
with os.fdopen(handle, "w", encoding="utf-8", newline="") as stream:
stream.write(text)
- os.chmod(where, 0o600)
- except BaseException:
- # Only paths this call created are removed. A created destination is
- # still this run's; a staged file never was the destination at all.
- for where, handle in claimed:
- if handle is not None:
- with contextlib.suppress(OSError):
- os.close(handle)
- with contextlib.suppress(OSError):
- os.unlink(where)
+ # Retain the exact UTF-8/no-translation payload digest. A later
+ # in-place edit preserves the entry identity and link count, so
+ # those checks alone cannot prove it is still this run's output.
+ record["digest"] = hashlib.sha256(text.encode("utf-8")).digest()
+ # Every payload is on disk. A private copy preserves the old bytes while
+ # the requested destination stays present until the atomic replacement.
+ for state in staged:
+ temporary = state["temporary"]
+ supplied_path, real_path = state["supplied_path"], state["real_path"]
+ original_identity = state["original_identity"]
+ pending_backup = _claim_owned_output(
+ lambda: tempfile.mkstemp(
+ dir=os.path.dirname(real_path),
+ prefix=os.path.basename(real_path) + ".", suffix=".bak"),
+ created=True, owned_records=unpaired_backups)
+ backup_handle, backup = pending_backup["pin"], pending_backup["path"]
+ pending_swap = {"backup": pending_backup, "destination": real_path,
+ "original_identity": original_identity,
+ "staged_identity": temporary["identity"],
+ "temporary": temporary,
+ "original": None, "metadata": None,
+ "swap_started": False}
+ pending_backup = None
+ # This ownership pin both prevents original-inode ABA reuse and
+ # captures metadata before the backup read can update atime.
+ pending_swap["original"] = state["original"]
+ pending_swap["metadata"] = _metadata_from_handle(
+ real_path, pending_swap["original"]["pin"])
+ try:
+ pending_swap["backup"]["digest"] = _copy_private_backup(
+ real_path, original_identity, backup_handle)
+ except FileNotFoundError:
+ # The existing destination remains pinned even when its path
+ # disappears before the backup source can open. Reconcile
+ # that inode before recovery releases it, then expose this as
+ # the same typed authority failure as later path races.
+ _reconcile_owned_pin_after_cleanup(
+ pending_swap["original"], "missing", cleanup_failures)
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} disappeared before its rollback source could be opened",
+ ) from None
+ except Refusal as refusal:
+ if getattr(refusal, "backup_source_open_failed", False):
+ # The original descriptor is still the only trustworthy
+ # evidence after a source-open denial or I/O failure.
+ # Reconcile it before recovery closes the pin, while
+ # leaving later copy I/O errors untyped and untouched.
+ _reconcile_owned_pin_after_cleanup(
+ pending_swap["original"], "missing", cleanup_failures)
+ raise
+ # The destination stays present until this one atomic replacement.
+ # `pending_swap` is set first because an interrupt may arrive after
+ # the filesystem call has taken effect but before it returns.
+ # Revalidate *after* the backup operation: it is a filesystem call
+ # an attacker can use to retarget the supplied symlink before this
+ # commit. The pending backup lets the refusal cleanly undo itself.
+ try:
+ existing_output_changed = (
+ os.path.realpath(supplied_path) != real_path
+ or _file_identity(real_path) != original_identity)
+ except FileNotFoundError:
+ # A missing leaf or parent is a commit-boundary path change.
+ # Recovery retains any pinned inode it cannot locate, but the
+ # operator still receives the typed path outcome.
+ existing_output_changed = True
+ except OSError:
+ existing_output_changed = True
+ if existing_output_changed:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} changed after it was claimed; no output was replaced",
+ )
+ try:
+ staged_entry_unchanged = (
+ _entry_identity(temporary["path"]) == temporary["identity"])
+ except OSError:
+ staged_entry_unchanged = False
+ if not staged_entry_unchanged:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} staged output changed before replacement",
+ )
+ try:
+ staged_digest_matches = (
+ _digest_pinned_bytes(temporary["pin"]) == temporary["digest"])
+ except OSError:
+ staged_digest_matches = False
+ if not staged_digest_matches:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} staged output changed before replacement",
+ )
+ _require_single_owned_link(
+ temporary, "staged output", "staged_output_has_multiple_links")
+ try:
+ pending_backup_unchanged = (
+ _entry_identity(pending_swap["backup"]["path"])
+ == pending_swap["backup"]["identity"])
+ except OSError:
+ pending_backup_unchanged = False
+ if not pending_backup_unchanged:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} rollback copy changed before replacement",
+ )
+ try:
+ _pinned_backup_still_has_one_link(pending_swap["backup"])
+ except OSError:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} rollback copy ownership could not be verified "
+ "before replacement",
+ ) from None
+ # The first pin checked that the original was single-linked. A
+ # backup hook can still add an alias before the commit boundary;
+ # recheck this pinned inode so replacement never detaches a new
+ # hard link while reporting a successful overwrite.
+ try:
+ _pinned_original_still_has_one_link(pending_swap["original"])
+ except OSError:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} original ownership could not be verified "
+ "before replacement",
+ ) from None
+ try:
+ original_still_current = (
+ _entry_identity(real_path) == original_identity)
+ except OSError:
+ original_still_current = False
+ if not original_still_current:
+ # The pre-claim original remains pinned, but its pathname no
+ # longer gives this run authority to replace it. Reconcile the
+ # pin before recovery closes it so a moved prior statement is
+ # not silently lost from the diagnostic.
+ _reconcile_owned_pin_after_cleanup(
+ pending_swap["original"], "missing", cleanup_failures)
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} changed before replacement; no output was replaced",
+ )
+ try:
+ staged_still_current = (
+ _entry_identity(temporary["path"]) == temporary["identity"])
+ except OSError:
+ staged_still_current = False
+ if not staged_still_current:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} staged output changed before replacement",
+ )
+ pending_swap["swap_started"] = True
+ os.replace(temporary["path"], real_path)
+ replaced.append(pending_swap)
+ pending_swap = None
+ # Keep this after every staged filesystem operation and before the
+ # committed boundary. Revalidate every fresh and replaced destination:
+ # an earlier swap can be invalidated while a later backup is prepared.
+ for record in claimed:
+ supplied_path = record["supplied_path"]
+ canonical_path = record["canonical_path"]
+ if _claimed_output_changed(
+ supplied_path, canonical_path, record["identity"]):
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} changed before commit; no output was committed",
+ )
+ try:
+ payload_digest_matches = (
+ _digest_pinned_bytes(record["pin"]) == record["digest"])
+ except OSError:
+ payload_digest_matches = False
+ if not payload_digest_matches:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} output contents changed before commit; "
+ "no output was committed",
+ )
+ try:
+ _require_single_owned_link(
+ record, "output", "output_has_multiple_links")
+ except OSError:
+ raise Refusal(
+ "output_path_changed",
+ f"{supplied_path} output ownership could not be verified before "
+ "commit; no output was committed",
+ ) from None
+ # Earlier rollback copies can also be changed during later swaps.
+ # A commit may retire them only while their ownership remains proved.
+ for swap in replaced:
+ backup = swap["backup"]
+ try:
+ backup_unchanged = (
+ _entry_identity(backup["path"]) == backup["identity"])
+ except OSError:
+ backup_unchanged = False
+ if not backup_unchanged:
+ _mark_rollback_unavailable(
+ swap, cleanup_failures, retain_named=True)
+ raise Refusal(
+ "output_path_changed",
+ f"{swap['destination']} rollback copy changed before commit; "
+ "this already replaced output could not be rolled back",
+ )
+ try:
+ _pinned_backup_still_has_one_link(backup)
+ except Refusal as refusal:
+ if refusal.category == "output_path_changed":
+ _mark_rollback_unavailable(swap, cleanup_failures)
+ raise
+ try:
+ digest_matches = (
+ _digest_pinned_bytes(backup["pin"]) == backup["digest"])
+ except OSError:
+ digest_matches = False
+ if not digest_matches:
+ _mark_rollback_unavailable(swap, cleanup_failures, retain_named=True)
+ raise Refusal(
+ "output_path_changed",
+ f"{swap['destination']} rollback copy content could not be verified before commit; "
+ "this already replaced output could not be rolled back",
+ )
+ # Final path validation is the boundary between rollback and committed
+ # cleanup. Keep it in this same handler so an interrupt before cleanup
+ # starts cannot skip both recovery paths.
+ committed = True
+ _cleanup_committed_outputs(
+ replaced, claimed, cleanup_failures, descriptor_close_failures)
+ if cleanup_failures:
+ raise OutputCleanupFailure(cleanup_failures)
+ if descriptor_close_failures:
+ raise OutputDescriptorCloseFailure(descriptor_close_failures)
+ except BaseException as error:
+ if committed:
+ # This is after the transaction committed. Never call
+ # `_restore_backup` here: an interrupt during old-copy cleanup must
+ # preserve the new output, close every ownership pin, and identify
+ # any old private copy that still needs operator cleanup.
+ _reconcile_interrupted_committed_cleanup(
+ replaced, claimed, cleanup_failures, descriptor_close_failures)
+ _note_cleanup_failures(error, cleanup_failures)
+ if descriptor_close_failures and not isinstance(error, OutputDescriptorCloseFailure):
+ _append_cleanup_detail(
+ error,
+ "ownership descriptor close failed for committed output(s): "
+ + ", ".join(sorted(set(descriptor_close_failures))),
+ )
+ raise
+ # Reconcile a swap which may have completed before raising, then undo
+ # earlier committed swaps. Cleanup failures remain attached to the
+ # original exception with their recoverable locations.
+ if pending_swap is not None:
+ backup = pending_swap["backup"]
+ if _restore_backup(
+ pending_swap, cleanup_failures, metadata_scope_warnings,
+ descriptor_close_failures) == "unrollbackable":
+ partial_commit_failures.append(str(pending_swap["destination"]))
+ _close_owned_path(backup, cleanup_failures,
+ descriptor_failures=descriptor_close_failures)
+ if pending_swap["original"] is not None:
+ _close_owned_path(
+ pending_swap["original"], cleanup_failures,
+ diagnostic_path=pending_swap["destination"],
+ descriptor_failures=descriptor_close_failures)
+ if pending_backup is not None and (
+ pending_swap is None or pending_backup is not pending_swap["backup"]):
+ _cleanup_owned_path(
+ pending_backup, cleanup_failures,
+ descriptor_failures=descriptor_close_failures,
+ suppress_reclaimed_name=True)
+ for swap in reversed(replaced):
+ # An interrupt can arrive after `_record_replaced_swap` appends but
+ # before its caller clears `pending_swap`. That one backup has
+ # already been reconciled above; restoring it twice risks treating
+ # the now-restored destination as a second transaction outcome.
+ if swap is pending_swap:
+ continue
+ backup = swap["backup"]
+ if _restore_backup(
+ swap, cleanup_failures, metadata_scope_warnings,
+ descriptor_close_failures) == "unrollbackable":
+ partial_commit_failures.append(str(swap["destination"]))
+ _close_owned_path(backup, cleanup_failures,
+ descriptor_failures=descriptor_close_failures)
+ _close_owned_path(
+ swap["original"], cleanup_failures,
+ diagnostic_path=swap.get("destination", swap["original"]["path"]),
+ descriptor_failures=descriptor_close_failures)
+ unrollbackable_temporaries = {
+ id(swap["temporary"]) for swap in replaced
+ if swap.get("rollback_unavailable")
+ }
+ if pending_swap is not None and pending_swap.get("rollback_unavailable"):
+ unrollbackable_temporaries.add(id(pending_swap["temporary"]))
+ for record in claimed:
+ if id(record) in unrollbackable_temporaries:
+ _reconcile_staged_output_pin(record, cleanup_failures)
+ _close_owned_path(
+ record, cleanup_failures,
+ diagnostic_path=record.get("canonical_path"),
+ descriptor_failures=descriptor_close_failures)
+ else:
+ _cleanup_owned_path(record, cleanup_failures,
+ descriptor_failures=descriptor_close_failures)
+ # Existing destinations are pinned at first claim, before they enter a
+ # swap record. Close any pin whose state never reached the rollback
+ # loops above; it is a descriptor-only ownership record and must never
+ # be unlinked as if it were a fresh output.
+ for state in staged:
+ _close_owned_path(state["original"], cleanup_failures,
+ diagnostic_path=state["real_path"],
+ descriptor_failures=descriptor_close_failures)
+ # A signal can interrupt after _owned_path registered a backup or
+ # original pin but before its caller transferred it to pending_swap or
+ # staged. Reconcile only those unpaired records here; paired records
+ # were handled above with their transaction state.
+ paired_originals = {id(state["original"]) for state in staged}
+ if pending_swap is not None and pending_swap["original"] is not None:
+ paired_originals.add(id(pending_swap["original"]))
+ paired_backups = {id(swap["backup"]) for swap in replaced}
+ if pending_swap is not None:
+ paired_backups.add(id(pending_swap["backup"]))
+ if pending_backup is not None:
+ paired_backups.add(id(pending_backup))
+ for record in unpaired_backups:
+ if id(record) not in paired_backups:
+ _cleanup_owned_path(
+ record, cleanup_failures,
+ descriptor_failures=descriptor_close_failures,
+ suppress_reclaimed_name=True)
+ for record in unpaired_originals:
+ if id(record) not in paired_originals:
+ _close_owned_path(record, cleanup_failures,
+ descriptor_failures=descriptor_close_failures)
+ _note_cleanup_failures(error, cleanup_failures)
+ if partial_commit_failures:
+ _append_cleanup_detail(
+ error, "partially committed output could not be rolled back: "
+ + ", ".join(sorted(set(partial_commit_failures))))
+ if descriptor_close_failures:
+ _append_cleanup_detail(
+ error, "ownership descriptor close failed or could not be verified for: "
+ + ", ".join(sorted(set(descriptor_close_failures))))
+ _note_rollback_metadata_scope(error, metadata_scope_warnings)
raise
- # Every payload is on disk. Replacing now cannot lose an existing output to
- # a failure that has already been ruled out.
- for temporary, path in staged:
- os.replace(temporary, path)
def _check_paths(args):
@@ -1561,7 +2987,7 @@ def _check_paths(args):
`--out` and `--manifest` sharing a path leaves whichever was written second,
with both success lines printed.
"""
- named = [(flag, pathlib.Path(value).expanduser().resolve())
+ named = [(flag, pathlib.Path(_resolve_output_path(pathlib.Path(value).expanduser())))
for flag, value in (("--pdf", args.pdf), ("--mapping", args.mapping),
("--out", args.out), ("--manifest", args.manifest))
if value]
diff --git a/scripts/bank_statement_import.test.py b/scripts/bank_statement_import.test.py
index cf0aff296..4d93227f1 100644
--- a/scripts/bank_statement_import.test.py
+++ b/scripts/bank_statement_import.test.py
@@ -31,12 +31,16 @@
import contextlib
import datetime
import decimal
+import errno
import hashlib
import io
import importlib.util
+import inspect
import os
import pathlib
+import signal
import stat
+import subprocess
import sys
import tempfile
import types
@@ -287,14 +291,60 @@ def test_parse_real_hdfc_capture(m):
# the account number is bound from the header block, not from the table
m.require_account_match(pages, bank, "HDFC CA xx1111")
- # every one of these is a real number printed in this capture's header —
- # phone, customer id, IFSC digits, MICR, postcode — and every one passed
- # before the binding was narrowed to the account-number line
+ # 1112 is the captured MICR tail, not an account-number value. 1113-1115
+ # occur in captured transaction-table references, a separate negative
+ # case: a table reference must not stand in for the account. 9876 is not
+ # printed in the capture.
for wrong in ("xx1112", "xx1113", "xx1114", "xx1115", "xx9876"):
refuses(m, "account_not_in_statement", m.require_account_match,
pages, bank, f"HDFC CA {wrong}")
+def test_real_hdfc_capture_binds_the_account_no_geometry_only(m):
+ """The unchanged capture pins the header field, not a convenient tail.
+
+ Customer values are sanitised, so several header numbers intentionally end
+ alike. Its captured labels and coordinates still prove which field the
+ production selector reads. A postcode label is not present in this
+ capture; this test makes no claim about an absent field.
+ """
+ bank = m.HDFC()
+ pages = capture("hdfc-bbox-capture.xml")
+
+ selected = [
+ [(round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3), text)
+ for x0, y0, x1, y1, text in group
+ if text in {"Account", "No"}]
+ for _, group in m._lines(pages[0])
+ if m._matches(group, bank.account_anchors)
+ ]
+ assert selected == [[
+ (340.157, 149.001, 367.261, 156.201, "Account"),
+ (369.261, 149.001, 379.037, 156.201, "No"),
+ ]], selected
+ account = m.require_account_match(pages, bank, "xx1111111")
+ assert account == "1" * 14
+
+ # Mutation controls select existing captured header geometry. They prove
+ # that the production Account/No selector excludes phone, customer-id,
+ # IFSC and MICR rows even where their sanitised numeric tails overlap.
+ original = bank.account_anchors
+ try:
+ bank.account_anchors = (("Phone", "no."),)
+ assert m.require_account_match(pages, bank, "xx1112") == "11111112"
+
+ bank.account_anchors = (("Cust", "ID"),)
+ assert m.require_account_match(pages, bank, "xx111111111") == "111111111"
+
+ bank.account_anchors = (("RTGS/NEFT", "IFSC"),)
+ assert m.require_account_match(pages, bank, "xx1111111") == "1111111"
+
+ bank.account_anchors = (("MICR",),)
+ assert m.require_account_match(pages, bank, "xx1112") == "111111112"
+ finally:
+ bank.account_anchors = original
+
+
def test_parse_real_sbi_capture(m):
"""SBI stacks the date over the year, repeats a three-line column header on
every page, and wraps the narration mid-token across five lines. All three
@@ -378,9 +428,9 @@ def test_account_binding(m):
It reads the line the statement labels as its account number, and nothing
else. Reading the whole document lets a transaction reference stand in for
- the account; reading the whole header block is barely better, because a
- header prints a phone number, a customer id, an IFSC, a MICR code and a
- postcode — on the real HDFC capture, four different wrong tails passed.
+ the account; reading the whole header block is barely better because it
+ includes non-account identifiers. The captured HDFC contract below keeps
+ that evidence tied to the bank-produced geometry.
"""
hdfc = m.HDFC()
m.require_account_match([HDFC_PAGE], hdfc, "HDFC CA xx1234")
@@ -392,7 +442,8 @@ def test_account_binding(m):
# 9012 ends the UPI reference on row 1
refuses(m, "account_not_in_statement", m.require_account_match,
[HDFC_PAGE], hdfc, "HDFC CA xx9012")
- # nor may any other number in the header: 4230 is the customer id
+ # nor may the other constructed header value; fixture-specific account
+ # binding against real header geometry remains in test_parse_real_hdfc_capture.
refuses(m, "account_not_in_statement", m.require_account_match,
[HDFC_PAGE], hdfc, "HDFC CA xx4230")
# and a document with no account-number line fails closed
@@ -1222,6 +1273,43 @@ def test_windows_refuses_to_claim_a_privacy_it_cannot_deliver(m):
assert pathlib.Path(target).read_text() == "", "refused, so not truncated"
+def test_windows_cleanup_closes_a_new_output_before_unlinking(m):
+ """Windows cannot unlink an open exclusive output, so caught cleanup
+ releases its pin before removal. The branch is simulated; ACL/filesystem
+ behavior still needs an affected Windows host."""
+ with tempfile.TemporaryDirectory() as directory, pretending_windows(m) as shim:
+ target = pathlib.Path(directory, "out.xml")
+ events = []
+ real_close = shim.close
+ real_unlink = m._unlink_for_cleanup
+
+ def observe_close(handle):
+ events.append("close")
+ return real_close(handle)
+
+ def observe_unlink(path, identity, failures):
+ assert events == ["close"]
+ events.append("unlink")
+ return real_unlink(path, identity, failures)
+
+ shim.close = observe_close
+ m._unlink_for_cleanup = observe_unlink
+ try:
+ try:
+ m.write_outputs([(str(target), "")], True,
+ after_claim=lambda: (_ for _ in ()).throw(
+ OSError("controlled failure")))
+ raise AssertionError("the controlled failure must escape")
+ except OSError as error:
+ assert "controlled failure" in str(error)
+ finally:
+ shim.close = real_close
+ m._unlink_for_cleanup = real_unlink
+
+ assert events == ["close", "unlink"]
+ assert not target.exists()
+
+
def test_a_windows_target_appearing_after_the_check_is_not_truncated(m):
"""The check and the create must be one operation.
@@ -1362,6 +1450,1555 @@ def test_a_failed_run_does_not_destroy_the_previous_output(m):
["new.csv", "previous.xml"]
+def test_write_outputs_writes_through_a_symlinked_destination(m):
+ """`os.replace` does not follow a symlink at the destination -- it
+ replaces the link itself, the same as `unlink` would. Renaming the staged
+ payload onto the link's own name would silently turn a live symlink into
+ a plain file, leaving whatever else reads through that link looking at
+ stale content forever. The swap must resolve through the link and land on
+ its target instead."""
+ with tempfile.TemporaryDirectory() as directory:
+ real_target = pathlib.Path(directory, "real-target.xml")
+ real_target.write_text("old")
+ link = pathlib.Path(directory, "out.xml")
+ link.symlink_to(real_target)
+
+ m.write_outputs([(str(link), "")])
+
+ assert link.is_symlink(), "the link itself must survive the write"
+ assert pathlib.Path(os.readlink(link)) == real_target
+ assert real_target.read_text() == "", \
+ "the payload must reach the link's target, not replace the link"
+
+
+def test_write_outputs_refuses_a_retargeted_symlink_before_commit(m):
+ """The destination observed after claiming must still name the same target
+ at the commit boundary. Otherwise a successful command updates a stale path
+ while the operator's requested path continues to expose old bytes."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.xml"
+ link = root / "out.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ link.symlink_to(first)
+
+ def retarget():
+ link.unlink()
+ link.symlink_to(second)
+
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(link), "new bytes")], False, retarget)
+ assert first.read_text() == "first old"
+ assert second.read_text() == "second old"
+ assert link.read_text() == "second old"
+ assert sorted(p.name for p in root.iterdir()) == ["first.xml", "out.xml", "second.xml"]
+
+
+def test_write_outputs_revalidates_a_symlink_after_backup_preparation(m):
+ """The check belongs directly before the swap, not before a backup syscall
+ which can itself be used to retarget the operator's path."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.xml"
+ link = root / "out.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ link.symlink_to(first)
+ real_copy = m._copy_private_backup
+
+ def retarget_after_backup(src, identity, backup_handle):
+ result = real_copy(src, identity, backup_handle)
+ link.unlink()
+ link.symlink_to(second)
+ return result
+
+ m._copy_private_backup = retarget_after_backup
+ try:
+ refuses(m, "output_path_changed", m.write_outputs, [(str(link), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert first.read_text() == "first old"
+ assert second.read_text() == "second old"
+ assert link.read_text() == "second old"
+ assert sorted(p.name for p in root.iterdir()) == ["first.xml", "out.xml", "second.xml"]
+
+
+def test_write_outputs_keeps_destination_during_private_backup_preparation(m):
+ """The requested output remains readable while its private backup is made.
+
+ `os.link` deliberately fails here: writable filesystems without hard-link
+ support still need the same caught-exception rollback behavior.
+ """
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory, "previous.xml")
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+ real_link = m.os.link
+ observed = []
+
+ def copy_while_observing(src, identity, backup_handle):
+ result = real_copy(src, identity, backup_handle)
+ observed.append((destination.exists(), destination.read_text()))
+ return result
+
+ m._copy_private_backup = copy_while_observing
+ m.os.link = lambda *_: (_ for _ in ()).throw(OSError("hard links unavailable"))
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+ m.os.link = real_link
+
+ assert observed == [(True, "old bytes")]
+ assert destination.read_text() == "new bytes"
+
+
+def test_an_interrupt_after_private_backup_preserves_the_previous_output(m):
+ """The exclusive backup is private while interruption can still occur, and
+ cleanup removes only that owned copy while leaving the destination intact."""
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory, "previous.xml")
+ destination.write_text("old bytes")
+ root = pathlib.Path(directory)
+ real_copy = m._copy_private_backup
+ modes = []
+
+ def interrupt_after_backup_copy(src, identity, backup_handle):
+ result = real_copy(src, identity, backup_handle)
+ backups = list(root.glob("*.bak"))
+ assert len(backups) == 1
+ modes.append(stat.S_IMODE(backups[0].stat().st_mode))
+ raise KeyboardInterrupt("controlled interrupt after private backup")
+
+ m._copy_private_backup = interrupt_after_backup_copy
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert modes == [0o600]
+ assert destination.read_text() == "old bytes"
+ assert sorted(p.name for p in pathlib.Path(directory).iterdir()) == ["previous.xml"]
+
+
+def test_an_interrupt_after_a_swap_restores_the_previous_output(m):
+ """A caught interrupt may arrive after rename(2) took effect. Pending swap
+ state must therefore be restored, not discarded as if the call had failed
+ before touching the filesystem."""
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory, "previous.xml")
+ destination.write_text("old bytes")
+ real_replace = m.os.replace
+
+ def interrupt_after_swap(src, dst):
+ result = real_replace(src, dst)
+ if str(src).endswith(".part"):
+ raise KeyboardInterrupt("controlled interrupt after swap")
+ return result
+
+ m.os.replace = interrupt_after_swap
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ m.os.replace = real_replace
+
+ assert destination.read_text() == "old bytes"
+ assert sorted(p.name for p in pathlib.Path(directory).iterdir()) == ["previous.xml"]
+
+
+def test_line_interrupt_after_recording_a_swap_restores_it_once(m):
+ """Trace the real line between append and clearing pending ownership."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_restore = m._restore_backup
+ restores = []
+ _, start = inspect.getsourcelines(m.write_outputs)
+ clear_line = start + [
+ index for index, line in enumerate(inspect.getsource(m.write_outputs).splitlines())
+ if line.strip() == "pending_swap = None"
+ ][-1]
+ old_trace = sys.gettrace()
+ fired = False
+
+ def interrupt_after_append(frame, event, _arg):
+ nonlocal fired
+ if (not fired and event == "line" and frame.f_code is m.write_outputs.__code__
+ and frame.f_lineno == clear_line):
+ fired = True
+ raise KeyboardInterrupt("controlled interrupt after swap append")
+ return interrupt_after_append
+
+ def observe_restore(*args, **kwargs):
+ restores.append(args[0])
+ return real_restore(*args, **kwargs)
+
+ m._restore_backup = observe_restore
+ sys.settrace(interrupt_after_append)
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ sys.settrace(old_trace)
+ m._restore_backup = real_restore
+
+ assert fired
+ assert len(restores) == 1, "one swap must have one rollback owner"
+ assert destination.read_text() == "old bytes"
+ assert sorted(path.name for path in root.iterdir()) == ["previous.xml"]
+
+
+def test_line_interrupt_before_clearing_pending_backup_has_one_cleanup_owner(m):
+ """An interrupt in the alias window must not clean one backup twice."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ _, start = inspect.getsourcelines(m.write_outputs)
+ clear_line = start + [
+ index for index, line in enumerate(inspect.getsource(m.write_outputs).splitlines())
+ if line.strip() == "pending_backup = None"
+ ][-1]
+ real_cleanup = m._cleanup_owned_path
+ cleanup_paths = []
+ old_trace = sys.gettrace()
+ fired = False
+
+ def interrupt_before_clear(frame, event, _arg):
+ nonlocal fired
+ if (not fired and event == "line" and frame.f_code is m.write_outputs.__code__
+ and frame.f_lineno == clear_line):
+ fired = True
+ raise KeyboardInterrupt("controlled pending-backup interrupt")
+ return interrupt_before_clear
+
+ def observe_cleanup(record, failures, **kwargs):
+ cleanup_paths.append(str(record["path"]))
+ return real_cleanup(record, failures, **kwargs)
+
+ m._cleanup_owned_path = observe_cleanup
+ sys.settrace(interrupt_before_clear)
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ sys.settrace(old_trace)
+ m._cleanup_owned_path = real_cleanup
+
+ assert fired
+ assert destination.read_text() == "old bytes"
+ assert sum(path.endswith(".bak") for path in cleanup_paths) == 1
+ assert sorted(path.name for path in root.iterdir()) == ["previous.xml"]
+
+
+def test_ownership_registration_failure_reconciles_a_created_path_and_closes_its_pin(m):
+ """The creator is not in an outer cleanup list until fstat succeeds."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ real_identity = m._fd_identity
+ real_close = m.os.close
+ closed = []
+ for position, failure in enumerate((OSError("fstat I/O"),
+ KeyboardInterrupt("fstat interrupt"))):
+ path = root / f"fresh-{position}.xml"
+ handle = m._open_private(path)
+ calls = 0
+
+ def fail_once(candidate):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ raise failure
+ return real_identity(candidate)
+
+ def observe_close(candidate):
+ closed.append(candidate)
+ return real_close(candidate)
+
+ m._fd_identity = fail_once
+ m.os.close = observe_close
+ try:
+ try:
+ m._owned_path(path, handle, created=True)
+ raise AssertionError("the original identity failure must escape")
+ except BaseException as error:
+ assert error is failure
+ finally:
+ m._fd_identity = real_identity
+ m.os.close = real_close
+
+ assert not path.exists(), "a reconciled fresh output must not strand a refusal"
+ assert handle in closed
+
+
+def test_ownership_registration_recovery_discloses_a_retained_hard_link(m):
+ """Registration recovery has the same post-unlink alias obligation."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ path, alias = root / "fresh.xml", root / "alias.xml"
+ handle = m._open_private(path)
+ os.link(path, alias)
+ real_identity = m._fd_identity
+ calls = 0
+
+ def fail_first_identity(candidate):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ raise OSError("controlled registration identity failure")
+ return real_identity(candidate)
+
+ m._fd_identity = fail_first_identity
+ try:
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ try:
+ m._owned_path(path, handle, created=True)
+ raise AssertionError("the registration failure must escape")
+ except OSError as error:
+ detail = (str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ + stderr.getvalue())
+ assert "controlled registration identity failure" in detail
+ assert "owned output could not be located after cleanup" in detail
+ finally:
+ m._fd_identity = real_identity
+
+ assert not path.exists()
+ assert alias.read_bytes() == b""
+
+
+def test_ownership_registration_preserves_an_unproven_reclaimed_path(m):
+ """When fstat cannot establish ownership, foreign bytes survive visibly."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ path = root / "fresh.xml"
+ foreign = root / "foreign.xml"
+ handle = m._open_private(path)
+ foreign.write_text("foreign writer bytes")
+ os.replace(foreign, path)
+ real_identity = m._fd_identity
+
+ def fail_identity(_handle):
+ raise OSError("persistent fstat I/O")
+
+ m._fd_identity = fail_identity
+ try:
+ try:
+ m._owned_path(path, handle, created=True)
+ raise AssertionError("the identity failure must escape")
+ except OSError as error:
+ notes = "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m._fd_identity = real_identity
+
+ assert path.read_text() == "foreign writer bytes"
+ assert str(path) in notes
+
+
+def test_original_pin_registration_failure_preserves_existing_output(m):
+ """A created=False pin failure re-raises the same error and closes its FD."""
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "previous.xml"
+ destination.write_text("old bytes")
+ handle = os.open(destination, os.O_RDONLY)
+ original = m._fd_identity
+ error = OSError("controlled original pin fstat failure")
+
+ def fail_original_pin(candidate):
+ assert candidate == handle
+ raise error
+
+ m._fd_identity = fail_original_pin
+ try:
+ try:
+ m._owned_path(destination, handle, created=False)
+ raise AssertionError("the controlled original-pin failure must escape")
+ except OSError as raised:
+ assert raised is error
+ finally:
+ m._fd_identity = original
+
+ try:
+ os.fstat(handle)
+ raise AssertionError("created=False registration failure must close its descriptor")
+ except OSError:
+ pass
+ assert destination.read_text() == "old bytes"
+
+
+def test_interrupted_committed_cleanup_preserves_interrupt_when_backup_inspection_fails(m):
+ """EACCES during reconciliation is diagnostic, not a replacement exception."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ backup_path, original_path, claimed_path = root / "old.bak", root / "old.xml", root / "new.xml"
+ for path in (backup_path, original_path, claimed_path):
+ path.write_text(path.name)
+ backup_fd, original_fd, claimed_fd = (os.open(path, os.O_RDONLY) for path in (backup_path, original_path, claimed_path))
+ backup = {"path": backup_path, "identity": m._fd_identity(backup_fd), "pin": backup_fd}
+ original_record = {"path": original_path, "identity": m._fd_identity(original_fd), "pin": original_fd}
+ claimed = {"path": claimed_path, "identity": m._fd_identity(claimed_fd), "pin": claimed_fd}
+ real_entry = m._entry_identity
+ def deny_backup(path):
+ if pathlib.Path(path) == backup_path:
+ raise PermissionError("controlled EACCES")
+ return real_entry(path)
+ m._entry_identity = deny_backup
+ retained, closes = [], []
+ real_close = m._close_owned_path
+ def record_close(record, failures, **kwargs):
+ closes.append(record["path"])
+ return real_close(record, failures, **kwargs)
+ m._close_owned_path = record_close
+ try:
+ original_error = KeyboardInterrupt("controlled interrupt")
+ try:
+ raise original_error
+ except BaseException as caught:
+ m._reconcile_interrupted_committed_cleanup(
+ [{"backup": backup, "original": original_record}], [claimed], retained, [])
+ assert caught is original_error
+ finally:
+ m._entry_identity = real_entry
+ m._close_owned_path = real_close
+ assert backup_path in closes and original_path in closes and claimed_path in closes
+ assert any("could not inspect committed rollback copy" in value for value in retained)
+ assert backup_path.read_text() == "old.bak"
+
+
+def test_restore_reconciles_a_backup_replace_that_raised_after_effect(m):
+ """A restore rename can report an error after it has moved the private
+ backup. Its new identity then proves recovery completed and must not be
+ reported as a missing retained backup."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.csv"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace = m.os.replace
+
+ def fail_second_swap_and_restore(src, dst):
+ if str(src).endswith(".part") and os.path.basename(dst) == "second.csv":
+ raise OSError("simulated second swap failure")
+ result = real_replace(src, dst)
+ if str(src).endswith(".bak"):
+ raise OSError("simulated restore failure after effect")
+ return result
+
+ m.os.replace = fail_second_swap_and_restore
+ try:
+ try:
+ m.write_outputs([(str(first), "new first"),
+ (str(second), "new second")])
+ raise AssertionError("the second target's swap must fail")
+ except OSError as error:
+ notes = getattr(error, "__notes__", [])
+ assert len(notes) == 1
+ assert str(first) in notes[0]
+ assert "extended ACLs and file flags were not verified" in notes[0]
+ assert ".bak" not in notes[0]
+ finally:
+ m.os.replace = real_replace
+
+ assert first.read_text() == "first old"
+ assert second.read_text() == "second old"
+ assert sorted(path.name for path in root.iterdir()) == ["first.xml", "second.csv"]
+
+
+def test_refusal_reports_rollback_metadata_scope_in_its_visible_code(m):
+ """Refusal is SystemExit, so rollback scope must be added to `code`, not
+ only an exception note that an unhandled process would omit."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.csv"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace = m.os.replace
+
+ def replace_first_then_refuse_second(src, dst):
+ if str(src).endswith(".part") and os.path.basename(dst) == "second.csv":
+ raise m.Refusal("controlled_refusal", "controlled second swap refusal")
+ return real_replace(src, dst)
+
+ m.os.replace = replace_first_then_refuse_second
+ try:
+ refusal = refuses(m, "controlled_refusal", m.write_outputs,
+ [(str(first), "new first"),
+ (str(second), "new second")])
+ finally:
+ m.os.replace = real_replace
+
+ assert str(first) in str(refusal.code)
+ assert "extended ACLs and file flags were not verified" in str(refusal.code)
+ assert first.read_text() == "first old"
+ assert second.read_text() == "second old"
+
+
+def test_write_outputs_reports_a_retained_backup_after_commit(m):
+ """Successful replacement is not a successful command when cleanup leaves
+ prior bank-statement bytes at an undisclosed random backup path."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_unlink = m.os.unlink
+
+ def fail_committed_backup(path):
+ if str(path).endswith(".bak") and destination.read_text() == "new bytes":
+ raise OSError("controlled backup cleanup failure")
+ return real_unlink(path)
+
+ m.os.unlink = fail_committed_backup
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("a retained backup must be reported")
+ except m.OutputCleanupFailure as failure:
+ assert len(failure.retained_paths) == 1
+ backup = pathlib.Path(failure.retained_paths[0])
+ assert backup.exists()
+ assert backup.read_text() == "old bytes"
+ finally:
+ m.os.unlink = real_unlink
+ for path in root.glob("*.bak"):
+ path.unlink()
+
+ assert destination.read_text() == "new bytes"
+
+
+def test_interrupt_before_committed_cleanup_keeps_new_output_and_reports_backup(m):
+ """The committed flag covers the line before old-copy cleanup starts."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_close = m._close_owned_path
+ closed_paths = []
+ _, start = inspect.getsourcelines(m._cleanup_committed_outputs)
+ cleanup_line = start + next(
+ index for index, line in enumerate(
+ inspect.getsource(m._cleanup_committed_outputs).splitlines())
+ if line.strip() == "for swap in replaced:")
+ old_trace = sys.gettrace()
+ fired = False
+
+ def interrupt_before_cleanup(frame, event, _arg):
+ nonlocal fired
+ if (not fired and event == "line"
+ and frame.f_code is m._cleanup_committed_outputs.__code__
+ and frame.f_lineno == cleanup_line):
+ fired = True
+ raise KeyboardInterrupt("controlled interrupt before cleanup")
+ return interrupt_before_cleanup
+
+ def observe_close(record, failures, **kwargs):
+ if record.get("pin") is not None:
+ closed_paths.append(str(record["path"]))
+ return real_close(record, failures, **kwargs)
+
+ m._close_owned_path = observe_close
+ sys.settrace(interrupt_before_cleanup)
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt as error:
+ notes = "\n".join(getattr(error, "__notes__", []))
+ backups = list(root.glob("*.bak"))
+ assert len(backups) == 1
+ assert backups[0].read_text() == "old bytes"
+ assert "retained path(s):" in notes
+ assert os.path.realpath(backups[0]) in notes
+ finally:
+ sys.settrace(old_trace)
+ m._close_owned_path = real_close
+ for path in root.glob("*.bak"):
+ path.unlink()
+
+ assert fired
+ assert destination.read_text() == "new bytes"
+ assert os.path.realpath(destination) in closed_paths, closed_paths
+ assert any(path.endswith(".bak") for path in closed_paths)
+ assert any(path.endswith(".part") for path in closed_paths)
+
+
+def test_interrupt_after_backup_unlink_does_not_report_a_phantom_path(m):
+ """A cleanup syscall may interrupt after deletion; name no absent backup."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_unlink = m.os.unlink
+ real_close = m._close_owned_path
+ closed_paths = []
+ interrupted_backup = None
+
+ def interrupt_after_backup_unlink(path):
+ nonlocal interrupted_backup
+ result = real_unlink(path)
+ if str(path).endswith(".bak"):
+ interrupted_backup = str(path)
+ raise KeyboardInterrupt("controlled interrupt after backup unlink")
+ return result
+
+ def observe_close(record, failures, **kwargs):
+ if record.get("pin") is not None:
+ closed_paths.append(str(record["path"]))
+ return real_close(record, failures, **kwargs)
+
+ m.os.unlink = interrupt_after_backup_unlink
+ m._close_owned_path = observe_close
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt as error:
+ notes = "\n".join(getattr(error, "__notes__", []))
+ assert interrupted_backup is not None
+ assert not pathlib.Path(interrupted_backup).exists()
+ assert os.path.realpath(interrupted_backup) not in notes
+ finally:
+ m.os.unlink = real_unlink
+ m._close_owned_path = real_close
+
+ assert destination.read_text() == "new bytes"
+ assert not list(root.glob("*.bak"))
+ assert os.path.realpath(destination) in closed_paths, closed_paths
+ assert any(path.endswith(".bak") for path in closed_paths)
+ assert any(path.endswith(".part") for path in closed_paths)
+
+
+def test_legacy_oserror_cleanup_diagnostic_reaches_stderr(m):
+ """Python 3.10's OSError rendering ignores args mutations and needs stderr."""
+ program = f'''\
+import errno
+import importlib.util
+
+script = {str(SCRIPT)!r}
+spec = importlib.util.spec_from_file_location("bank_statement_import_subprocess", script)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+class LegacyOSError(OSError):
+ add_note = None
+error = LegacyOSError(errno.EIO, "controlled original failure", "/tmp/legacy-output.xml")
+module._note_cleanup_failures(error, ["/tmp/owned-backup.bak"])
+raise error
+'''
+ done = subprocess.run([sys.executable, "-c", program], text=True,
+ capture_output=True, check=False)
+ assert done.returncode != 0
+ assert "controlled original failure" in done.stderr
+ assert "/tmp/legacy-output.xml" in done.stderr
+ assert "retained path(s): /tmp/owned-backup.bak" in done.stderr
+
+
+def test_refusal_reports_a_retained_backup_on_stderr(m):
+ """Refusal inherits SystemExit, whose unhandled rendering ignores
+ `BaseException.add_note`. Assert the CLI-visible error rather than the
+ in-process exception object so a sensitive retained backup is not hidden."""
+ program = f'''\
+import importlib.util
+import pathlib
+import tempfile
+
+script = {str(SCRIPT)!r}
+spec = importlib.util.spec_from_file_location("bank_statement_import_subprocess", script)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.xml"
+ link = root / "out.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ link.symlink_to(first)
+ real_copy = module._copy_private_backup
+ real_cleanup = module._unlink_for_cleanup
+ def retarget_after_backup(src, identity, backup_handle):
+ result = real_copy(src, identity, backup_handle)
+ link.unlink()
+ link.symlink_to(second)
+ return result
+ def retain_backup(path, identity, failures):
+ if str(path).endswith(".bak"):
+ failures.append(path)
+ else:
+ real_cleanup(path, identity, failures)
+ module._copy_private_backup = retarget_after_backup
+ module._unlink_for_cleanup = retain_backup
+ module.write_outputs([(str(link), "new bytes")])
+'''
+ done = subprocess.run([sys.executable, "-c", program], text=True,
+ capture_output=True, check=False)
+ assert done.returncode != 0
+ assert "output_path_changed" in done.stderr, done.stderr
+ assert "retained path(s):" in done.stderr, done.stderr
+ assert ".bak" in done.stderr, done.stderr
+
+
+def test_refusal_reports_rollback_metadata_scope_on_stderr(m):
+ """The ACL/file-flag limitation must survive unhandled SystemExit
+ rendering, where Python omits ordinary exception notes."""
+ program = f'''\
+import importlib.util
+import os
+import pathlib
+import tempfile
+
+script = {str(SCRIPT)!r}
+spec = importlib.util.spec_from_file_location("bank_statement_import_subprocess", script)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.csv"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace = module.os.replace
+ def replace_first_then_refuse_second(src, dst):
+ if str(src).endswith(".part") and os.path.basename(dst) == "second.csv":
+ raise module.Refusal("controlled_refusal", "controlled second swap refusal")
+ return real_replace(src, dst)
+ module.os.replace = replace_first_then_refuse_second
+ module.write_outputs([(str(first), "new first"), (str(second), "new second")])
+'''
+ done = subprocess.run([sys.executable, "-c", program], text=True,
+ capture_output=True, check=False)
+ assert done.returncode != 0
+ assert "controlled_refusal" in done.stderr, done.stderr
+ assert "extended ACLs and file flags were not verified" in done.stderr, done.stderr
+
+
+def test_a_failed_swap_rolls_back_every_staged_replacement(m):
+ """Two existing destinations are both staged; the first's swap succeeds
+ and the second's fails. The rollback used to run only the un-staged
+ cleanup, so the first destination was left holding the new run's content
+ with no way back -- a mid-sequence failure must undo every replacement
+ this call already committed, not only the one in progress when it failed.
+ """
+ with tempfile.TemporaryDirectory() as directory:
+ first = pathlib.Path(directory, "first.xml")
+ second = pathlib.Path(directory, "second.csv")
+ first.write_text("first old")
+ second.write_text("second old")
+
+ real_replace = m.os.replace
+ calls = {"n": 0}
+
+ def flaky_replace(src, dst):
+ calls["n"] += 1
+ # The first destination's swap lands, then the second swap fails.
+ if calls["n"] == 2:
+ raise OSError("simulated failure mid-sequence")
+ return real_replace(src, dst)
+
+ m.os.replace = flaky_replace
+ try:
+ try:
+ m.write_outputs([(str(first), ""),
+ (str(second), "new-second\n")])
+ raise AssertionError("the second target's swap must fail")
+ except OSError:
+ pass
+ finally:
+ m.os.replace = real_replace
+
+ assert first.read_text() == "first old", \
+ "the first destination's already-committed swap must be rolled back"
+ assert second.read_text() == "second old"
+ # no stray .part/.bak file is left behind by either destination
+ assert sorted(p.name for p in pathlib.Path(directory).iterdir()) == \
+ ["first.xml", "second.csv"], \
+ sorted(p.name for p in pathlib.Path(directory).iterdir())
+
+
+def test_backup_copy_refuses_a_replaced_original_before_commit(m):
+ """Rollback bytes need the original inode's provenance, not whatever
+ happened to occupy its name while the private copy was being prepared."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ replacement = root / "foreign.xml"
+ destination.write_text("old bytes")
+ replacement.write_text("foreign writer bytes")
+ real_copy = m._copy_private_backup
+ real_replace = m.os.replace
+
+ def replace_before_copy(src, identity, backup_handle):
+ real_replace(replacement, destination)
+ return real_copy(src, identity, backup_handle)
+
+ m._copy_private_backup = replace_before_copy
+ try:
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert destination.read_text() == "foreign writer bytes"
+ assert sorted(path.name for path in root.iterdir()) == ["previous.xml"]
+
+
+def test_claim_refuses_a_foreign_replacement_after_committing_original_identity(m):
+ """The descriptor, rather than a pre-open stat, commits the old inode.
+
+ An attacker can replace the path before this call opens it; without a
+ lock, that is the file the call is asked to replace. This regression covers
+ the actionable interval: a foreign replacement after the old descriptor
+ establishes identity but before the path is verified must remain untouched.
+ """
+ if os.name == "nt":
+ return # Existing destinations are deliberately refused on Windows.
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ foreign = root / "foreign.xml"
+ destination.write_text("old bytes")
+ foreign.write_text("foreign writer bytes")
+ real_owned_path = m._owned_path
+ real_replace = m.os.replace
+
+ def replace_after_identity(path, handle, *, created, **kwargs):
+ record = real_owned_path(path, handle, created=created, **kwargs)
+ if not created and os.path.realpath(path) == os.path.realpath(destination):
+ real_replace(foreign, destination)
+ return record
+
+ m._owned_path = replace_after_identity
+ try:
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._owned_path = real_owned_path
+
+ assert destination.read_text() == "foreign writer bytes"
+ assert sorted(path.name for path in root.iterdir()) == ["previous.xml"]
+
+
+def test_line_interrupt_during_final_validation_rolls_back_replacements(m):
+ """A real line-traced SIGINT at the final validation stays recoverable."""
+ if os.name == "nt":
+ return # Existing destinations are deliberately refused on Windows.
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.csv"
+ first.write_text("first old")
+ second.write_text("second old")
+ _, start = inspect.getsourcelines(m.write_outputs)
+ validation_line = start + next(
+ index for index, line in enumerate(
+ inspect.getsource(m.write_outputs).splitlines())
+ if line.strip() == "if _claimed_output_changed(")
+ old_trace = sys.gettrace()
+ old_signal_handler = signal.getsignal(signal.SIGINT)
+ fired = False
+
+ def interrupt_final_validation(frame, event, _arg):
+ nonlocal fired
+ if (not fired and event == "line" and frame.f_code is m.write_outputs.__code__
+ and frame.f_lineno == validation_line):
+ fired = True
+ signal.raise_signal(signal.SIGINT)
+ return interrupt_final_validation
+
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ sys.settrace(interrupt_final_validation)
+ try:
+ try:
+ m.write_outputs([(str(first), "new first"),
+ (str(second), "new second")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ sys.settrace(old_trace)
+ signal.signal(signal.SIGINT, old_signal_handler)
+
+ assert fired
+ assert first.read_text() == "first old"
+ assert second.read_text() == "second old"
+ assert sorted(path.name for path in root.iterdir()) == ["first.xml", "second.csv"]
+
+
+def test_backup_copy_refuses_a_fifo_before_reading_it(m):
+ """An existing output is data only when it is a regular file; opening a
+ FIFO for its rollback copy would otherwise wait for an unrelated writer."""
+ if not hasattr(os, "mkfifo"):
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ os.mkfifo(destination)
+
+ refuses(m, "output_not_regular", m.write_outputs,
+ [(str(destination), "new bytes")])
+
+ assert destination.is_fifo()
+ assert sorted(path.name for path in root.iterdir()) == ["previous.xml"]
+
+
+def test_duplicate_failure_after_claim_closes_and_removes_new_output(m):
+ """The claimed private descriptor is already recorded before a duplicate
+ is needed for writing, so descriptor exhaustion cannot leak the path."""
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory, "new.xml")
+ real_open_private = m._open_private
+ real_dup = m.os.dup
+ real_close = m.os.close
+ opened, closed = [], []
+
+ def observe_open(path, accept_inherited=False):
+ handle = real_open_private(path, accept_inherited)
+ opened.append(handle)
+ return handle
+
+ def exhausted_dup(_):
+ raise OSError("controlled descriptor exhaustion")
+
+ def observe_close(handle):
+ closed.append(handle)
+ return real_close(handle)
+
+ m._open_private = observe_open
+ m.os.dup = exhausted_dup
+ m.os.close = observe_close
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the duplicate failure must escape")
+ except OSError as error:
+ assert "controlled descriptor exhaustion" in str(error)
+ finally:
+ m._open_private = real_open_private
+ m.os.dup = real_dup
+ m.os.close = real_close
+
+ assert len(opened) == 1
+ assert opened[0] in closed
+ assert not destination.exists()
+
+
+def test_backup_refuses_when_original_metadata_cannot_be_recorded(m):
+ """A rollback cannot claim to restore metadata it was unable to capture."""
+ if not hasattr(m.os, "listxattr"):
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_listxattr = m.os.listxattr
+
+ def unavailable_xattrs(_):
+ raise OSError("controlled xattr metadata failure")
+
+ m.os.listxattr = unavailable_xattrs
+ try:
+ refusal = refuses(m, "output_metadata_unavailable", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m.os.listxattr = real_listxattr
+
+ assert "controlled xattr metadata failure" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert sorted(path.name for path in root.iterdir()) == ["previous.xml"]
+
+
+def test_cleanup_keeps_a_reclaimed_owned_path(m):
+ """A cleanup record proves only the path our run made. If that pathname
+ changes identity, reporting it is safe; unlinking it is not."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ owned = root / "output.xml.pending"
+ foreign = root / "foreign.xml"
+ owned.write_text("our temporary bytes")
+ identity = m._entry_identity(owned)
+ foreign.write_text("foreign writer bytes")
+ os.replace(foreign, owned)
+ failures = []
+
+ m._unlink_for_cleanup(owned, identity, failures)
+
+ assert owned.read_text() == "foreign writer bytes"
+ assert failures == [str(owned)]
+
+
+def test_cleanup_reconciles_a_reclaimed_path_after_unlink_error(m):
+ """An unlink error after a move must retain the moved inode diagnostic."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ path, moved, foreign = (
+ root / "output.xml.pending", root / "moved.xml.pending", root / "foreign.xml")
+ handle = m._open_private(path)
+ os.write(handle, b"generated statement")
+ record = m._owned_path(path, handle, created=True)
+ record["cleanup_path"] = path
+ foreign.write_text("foreign writer bytes")
+ real_unlink = m.os.unlink
+
+ def move_reclaim_then_fail(candidate):
+ if pathlib.Path(candidate) == path:
+ os.replace(path, moved)
+ os.replace(foreign, path)
+ raise OSError("controlled unlink failure before effect")
+ return real_unlink(candidate)
+
+ m.os.unlink = move_reclaim_then_fail
+ failures = []
+ try:
+ m._cleanup_owned_path(record, failures)
+ finally:
+ m.os.unlink = real_unlink
+
+ assert failures == [
+ f"owned output could not be located after cleanup: {path}"]
+ assert record["pin"] is None
+ assert path.read_text() == "foreign writer bytes"
+ assert moved.read_bytes() == b"generated statement"
+
+
+def test_pinned_backup_reclaimed_path_retains_cleanup_diagnostic(m):
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "rollback.bak"
+ moved = pathlib.Path(directory) / "moved.bak"
+ path.write_text("prior output")
+ identity = m._entry_identity(path)
+ handle = os.open(path, os.O_RDONLY)
+ record = {"path": path, "identity": identity, "pin": handle}
+ path.rename(moved)
+ path.write_text("foreign bytes")
+ failures = []
+ m._cleanup_owned_path(record, failures)
+ assert failures == [
+ str(path),
+ f"owned output could not be located after cleanup: {path}",
+ ]
+ assert record["pin"] is None
+ assert path.read_text() == "foreign bytes"
+ assert moved.read_text() == "prior output"
+
+
+def test_pinned_backup_parent_rename_reports_unlocated_copy(m):
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ before, after = root / "before", root / "after"
+ before.mkdir()
+ path = before / "rollback.bak"
+ path.write_text("prior output")
+ identity = m._entry_identity(path)
+ handle = os.open(path, os.O_RDONLY)
+ record = {"path": path, "identity": identity, "pin": handle}
+ try:
+ before.rename(after)
+ failures = []
+ m._cleanup_owned_path(record, failures)
+ finally:
+ if record["pin"] is not None:
+ os.close(record["pin"])
+ assert failures == [
+ f"owned output could not be located after cleanup: {path}"
+ ]
+ assert (after / "rollback.bak").read_text() == "prior output"
+
+
+def test_new_output_symlink_loop_is_a_typed_path_refusal(m):
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ real_resolve = m.pathlib.Path.resolve
+
+ def loop_resolve(path, *args, **kwargs):
+ if path == destination:
+ raise RuntimeError("controlled symlink loop")
+ return real_resolve(path, *args, **kwargs)
+
+ m.pathlib.Path.resolve = loop_resolve
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m.pathlib.Path.resolve = real_resolve
+ assert "could not be resolved safely" in str(refusal.code)
+ assert not destination.exists()
+
+
+def test_new_output_final_revalidation_loop_cleans_all_claimed_outputs(m):
+ """A loop discovered after claiming still rolls back every fresh output."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ real_resolve = m.pathlib.Path.resolve
+ resolve_calls = 0
+
+ def loop_after_claim(path):
+ nonlocal resolve_calls
+ if pathlib.Path(path) == first:
+ resolve_calls += 1
+ # Claiming resolves once to choose the canonical path and once
+ # to verify the new inode. The third call is final
+ # revalidation, after both outputs have been written.
+ if resolve_calls == 3:
+ raise RuntimeError("controlled post-claim symlink loop")
+ return real_resolve(path)
+
+ m.pathlib.Path.resolve = loop_after_claim
+ try:
+ refusal = refuses(
+ m,
+ "output_path_changed",
+ m.write_outputs,
+ [(str(first), "first bytes"), (str(second), "second bytes")],
+ )
+ finally:
+ m.pathlib.Path.resolve = real_resolve
+ assert resolve_calls == 3, "RuntimeError must be injected during final revalidation"
+ assert "could not be resolved safely" in str(refusal.code)
+ assert not first.exists(), "the first claimed output must be cleaned"
+ assert not second.exists(), "the earlier claimed output must be cleaned too"
+
+
+def test_unlinked_pinned_backup_does_not_claim_a_hard_link_alias(m):
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "rollback.bak"
+ path.write_text("prior output")
+ identity = m._entry_identity(path)
+ handle = os.open(path, os.O_RDONLY)
+ try:
+ path.unlink()
+ refusal = refuses(m, "output_path_changed", m._pinned_backup_still_has_one_link,
+ {"path": path, "identity": identity, "pin": handle})
+ assert "hard-link alias" not in str(refusal.code)
+ finally:
+ os.close(handle)
+
+
+def test_fresh_output_parent_rename_reports_an_unlocated_owned_descriptor(m):
+ """A parent rename preserves a newly created inode under a name cleanup
+ cannot discover. The failure must disclose that fact rather than calling
+ stale-path ENOENT a successful rollback."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory) / "before"
+ moved = pathlib.Path(directory) / "after"
+ root.mkdir()
+ destination = root / "output.xml"
+
+ def rename_parent():
+ os.rename(root, moved)
+
+ try:
+ m.write_outputs([(str(destination), "new bytes")], after_claim=rename_parent)
+ raise AssertionError("a moved fresh output must refuse before commit")
+ except m.Refusal as refusal:
+ assert refusal.category == "output_path_changed"
+ assert "owned output could not be located after cleanup" in str(refusal.code)
+
+ retained = moved / "output.xml"
+ assert retained.read_text() == "new bytes"
+
+
+def test_fresh_output_parent_rename_with_foreign_replacement_reports_only_owned_uncertainty(m):
+ """A stale name can be reclaimed after its parent moves. Cleanup must not
+ remove or present that foreign file as the location of our pinned output."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory) / "before"
+ moved = pathlib.Path(directory) / "after"
+ root.mkdir()
+ destination = root / "output.xml"
+
+ def rename_parent_and_reclaim_old_name():
+ os.rename(root, moved)
+ root.mkdir()
+ destination.write_text("foreign bytes")
+
+ try:
+ m.write_outputs(
+ [(str(destination), "new bytes")], after_claim=rename_parent_and_reclaim_old_name)
+ raise AssertionError("a moved fresh output must refuse before commit")
+ except m.Refusal as refusal:
+ assert refusal.category == "output_path_changed"
+ detail = str(refusal.code)
+ assert "owned output could not be located after cleanup" in detail
+ assert "retained path(s): " + str(destination) not in detail
+
+ assert destination.read_text() == "foreign bytes"
+ assert (moved / "output.xml").read_text() == "new bytes"
+
+
+def test_writer_refuses_when_the_private_backup_path_is_reclaimed(m):
+ """The commit boundary must still name this run's backup; if it does not,
+ do not overwrite the destination or claim a foreign backup as owned."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ foreign = root / "foreign.xml"
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+
+ def reclaim_backup_after_copy(src, identity, backup_handle):
+ result = real_copy(src, identity, backup_handle)
+ backup, = root.glob("previous.xml.*.bak")
+ foreign.write_text("foreign writer bytes")
+ os.replace(foreign, backup)
+ return result
+
+ m._copy_private_backup = reclaim_backup_after_copy
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ backups = list(root.glob("previous.xml.*.bak"))
+ assert destination.read_text() == "old bytes"
+ assert len(backups) == 1
+ assert backups[0].read_text() == "foreign writer bytes"
+ detail = str(refusal.code)
+ assert "retained path(s): " + str(backups[0]) not in detail
+
+
+def test_parent_rename_after_backup_preparation_discloses_unlocated_backup(m):
+ """A no-swap rollback must reconcile the pinned backup before closing it."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory) / "before"
+ moved = pathlib.Path(directory) / "after"
+ root.mkdir()
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+
+ def rename_parent_after_backup(source, identity, backup_handle):
+ result = real_copy(source, identity, backup_handle)
+ root.rename(moved)
+ return result
+
+ m._copy_private_backup = rename_parent_after_backup
+ detail = ""
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the renamed parent must prevent a swap")
+ except m.Refusal as error:
+ detail = str(error.code) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ assert error.category == "output_path_changed"
+ assert "owned output could not be located after cleanup" in detail
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert (moved / "previous.xml").read_text() == "old bytes"
+ backups = list(moved.glob("previous.xml.*.bak"))
+ assert len(backups) == 1
+ assert backups[0].read_text() == "old bytes"
+ old_backup_path = root.resolve() / backups[0].name
+ assert f"owned output could not be located after cleanup: {old_backup_path}" in detail
+
+
+def test_rollback_keeps_a_foreign_destination_and_private_backup(m):
+ """When an external writer replaces an already-swapped destination before
+ another target fails, rollback must retain the owned backup rather than
+ overwriting that writer's bytes."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.csv"
+ foreign = root / "foreign.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace = m.os.replace
+ real_open_regular = m._open_regular_output
+ real_owned_path = m._owned_path
+ real_close = m.os.close
+ swaps = []
+ first_handles, closed_handles = [], []
+ owned_handles = {}
+
+ def observe_first_handles(path, identity):
+ handle = real_open_regular(path, identity)
+ if os.path.samefile(path, first):
+ first_handles.append(handle)
+ return handle
+
+ def observe_closes(handle):
+ closed_handles.append(handle)
+ return real_close(handle)
+
+ def observe_owned_path(path, handle, *, created, **kwargs):
+ record = real_owned_path(path, handle, created=created, **kwargs)
+ if os.path.basename(path).startswith("first.xml."):
+ owned_handles[pathlib.Path(path).suffix] = record["pin"]
+ return record
+
+ def replace_then_conflict(src, dst):
+ if str(src).endswith(".part") and os.path.basename(dst) == "first.xml":
+ swaps.append("first")
+ assert len(first_handles) == 2
+ # The first open is the ownership pin captured before the
+ # backup read. It must survive the first swap and the foreign
+ # replacement so that its inode cannot be recycled.
+ assert first_handles[0] not in closed_handles
+ assert first_handles[1] in closed_handles
+ assert owned_handles[".part"] not in closed_handles
+ assert owned_handles[".bak"] not in closed_handles
+ result = real_replace(src, dst)
+ foreign.write_text("foreign writer bytes")
+ real_replace(foreign, first)
+ return result
+ if str(src).endswith(".part") and os.path.basename(dst) == "second.csv":
+ swaps.append("second")
+ raise OSError("simulated failure after foreign writer")
+ return real_replace(src, dst)
+
+ m.os.replace = replace_then_conflict
+ m._open_regular_output = observe_first_handles
+ m._owned_path = observe_owned_path
+ m.os.close = observe_closes
+ diagnostic_notes = ""
+ try:
+ try:
+ m.write_outputs([(str(first), "new first"),
+ (str(second), "new second")])
+ raise AssertionError("the second target's swap must fail")
+ except OSError as error:
+ diagnostic_notes = "\n".join(
+ str(note) for note in getattr(error, "__notes__", [])
+ )
+ finally:
+ m.os.replace = real_replace
+ m._open_regular_output = real_open_regular
+ m._owned_path = real_owned_path
+ m.os.close = real_close
+
+ backups = list(root.glob("first.xml.*.bak"))
+ assert len(backups) == 1
+ assert str(backups[0]) in diagnostic_notes
+ assert swaps == ["first", "second"]
+ assert first_handles[0] in closed_handles
+ assert owned_handles[".part"] in closed_handles
+ assert owned_handles[".bak"] in closed_handles
+ assert stat.S_IMODE(backups[0].stat().st_mode) == 0o600
+ assert backups[0].read_text() == "first old"
+ assert first.read_text() == "foreign writer bytes"
+ assert second.read_text() == "second old"
+
+
+def test_original_inode_pin_blocks_after_claim_replacement_before_backup(m):
+ """A same-name replacement after claim cannot make backup copy foreign bytes."""
+ if os.name == "nt":
+ return # Existing destinations are deliberately refused on Windows.
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "output.xml"
+ foreign = root / "foreign.xml"
+ destination.write_text("original bytes")
+ real_open = m._open_regular_output
+ original_handles = []
+
+ def observe_original(path, identity):
+ handle = real_open(path, identity)
+ if os.path.realpath(path) == os.path.realpath(destination):
+ original_handles.append(handle)
+ return handle
+
+ def replace_after_claim():
+ assert original_handles, "the original inode must be pinned before the hook"
+ assert os.pread(original_handles[0], 32, 0) == b"original bytes"
+ foreign.write_text("foreign bytes")
+ os.replace(foreign, destination)
+ assert os.pread(original_handles[0], 32, 0) == b"original bytes"
+
+ m._open_regular_output = observe_original
+ try:
+ refusal = refuses(
+ m,
+ "output_path_changed",
+ m.write_outputs,
+ [(str(destination), "new bytes")],
+ False,
+ replace_after_claim,
+ )
+ finally:
+ m._open_regular_output = real_open
+ assert "changed" in str(refusal.code)
+ assert destination.read_text() == "foreign bytes"
+ assert original_handles
+ try:
+ os.fstat(original_handles[0])
+ except OSError:
+ pass
+ else:
+ raise AssertionError("the original pin must be closed during recovery")
+
+
+def test_committed_close_after_effect_does_not_report_missing_backup(m):
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ destination.write_text("old bytes")
+ real_close = m.os.close
+ real_owned = m._owned_path
+ backup_handles = set()
+ fired = False
+
+ def observe_owned(path, handle, *, created, **kwargs):
+ record = real_owned(path, handle, created=created, **kwargs)
+ if str(path).endswith(".bak"):
+ backup_handles.add(handle)
+ return record
+
+ def close_then_error(handle):
+ nonlocal fired
+ real_close(handle)
+ if handle in backup_handles and not fired:
+ fired = True
+ raise OSError("controlled close after effect")
+
+ m._owned_path, m.os.close = observe_owned, close_then_error
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ finally:
+ m._owned_path, m.os.close = real_owned, real_close
+ assert fired
+ assert destination.read_text() == "new bytes"
+ assert list(pathlib.Path(directory).iterdir()) == [destination]
+
+
+def test_existing_hard_link_output_is_refused_with_topology_unchanged(m):
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ alias = pathlib.Path(directory) / "alias.xml"
+ destination.write_text("old bytes")
+ os.link(destination, alias)
+ refuses(m, "output_has_multiple_links", m.write_outputs,
+ [(str(destination), "new bytes")])
+ assert os.path.samefile(destination, alias)
+ assert destination.read_text() == alias.read_text() == "old bytes"
+ assert sorted(p.name for p in pathlib.Path(directory).iterdir()) == ["alias.xml", "output.xml"]
+
+
+def test_preswap_abort_restores_access_time_on_the_original_pin(m):
+ # Inject the access-time effect explicitly so this covers noatime hosts too.
+ for abort_before_replace in (True, False):
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ destination.write_text("old bytes")
+ old_atime = 1_600_000_000_000_000_000
+ old_mtime = 1_700_000_000_000_000_000
+ os.utime(destination, ns=(old_atime, old_mtime))
+ real_copy, real_replace = m._copy_private_backup, m.os.replace
+
+ def copy_with_access_time_effect(*args):
+ real_copy(*args)
+ os.utime(destination, ns=(old_mtime, old_mtime))
+ if abort_before_replace:
+ raise OSError("controlled copy abort")
+
+ def refuse_replace(*_args):
+ raise OSError("controlled replace before effect")
+
+ m._copy_private_backup, m.os.replace = copy_with_access_time_effect, refuse_replace
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the controlled abort must escape")
+ except OSError as error:
+ assert str(error).startswith("controlled")
+ finally:
+ m._copy_private_backup, m.os.replace = real_copy, real_replace
+ final_stat = destination.stat()
+ assert final_stat.st_atime_ns == old_atime
+ assert final_stat.st_mtime_ns == old_mtime
+ assert destination.read_text() == "old bytes"
+ assert list(pathlib.Path(directory).iterdir()) == [destination]
+
+
+def test_rollback_restores_original_output_metadata(m):
+ """A caught later swap failure restores the former bytes and portable
+ metadata, while a retained pre-rollback backup stays private."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first = root / "first.xml"
+ second = root / "second.csv"
+ first.write_text("first old")
+ second.write_text("second old")
+ first.chmod(0o640)
+ old_atime_ns = 1_600_000_000_123_456_789
+ old_mtime_ns = 1_700_000_000_123_456_789
+ os.utime(first, ns=(old_atime_ns, old_mtime_ns))
+ xattr_name = "user.bridge_rollback_test"
+ xattr_value = b"original metadata"
+ preserves_xattr = False
+ if hasattr(os, "setxattr"):
+ try:
+ os.setxattr(first, xattr_name, xattr_value)
+ preserves_xattr = True
+ except OSError:
+ pass
+ real_replace = m.os.replace
+
+ def fail_second_swap(src, dst):
+ if str(src).endswith(".part") and os.path.basename(dst) == "second.csv":
+ raise OSError("controlled second swap failure")
+ return real_replace(src, dst)
+
+ m.os.replace = fail_second_swap
+ try:
+ try:
+ m.write_outputs([(str(first), "new first"),
+ (str(second), "new second")])
+ raise AssertionError("the controlled swap failure must escape")
+ except OSError as error:
+ assert "controlled second swap failure" in str(error)
+ finally:
+ m.os.replace = real_replace
+
+ restored = first.stat()
+ assert first.read_text() == "first old"
+ assert stat.S_IMODE(restored.st_mode) == 0o640
+ assert restored.st_atime_ns == old_atime_ns
+ assert restored.st_mtime_ns == old_mtime_ns
+ if preserves_xattr:
+ assert os.getxattr(first, xattr_name) == xattr_value
+ assert second.read_text() == "second old"
+ assert sorted(path.name for path in root.iterdir()) == ["first.xml", "second.csv"]
+
+
def test_a_case_insensitive_collision_is_refused_before_anything_is_written(m):
"""`--out Result.xml --manifest result.XML` is one file on a case-insensitive
volume. The lexical preflight cannot see it and `samefile` needs both paths
@@ -1468,6 +3105,1581 @@ def test_ledger_key_folds_exactly_what_its_docstring_claims(m):
assert not same("A\u2013B", "A B"), "en dash is not an ASCII hyphen"
+def test_write_outputs_refuses_a_hard_link_added_after_backup_copy(m):
+ """The commit check must see a link created by a backup-time hook."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "output.xml"
+ alias = root / "alias.xml"
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+
+ def add_link_after_backup(*args):
+ result = real_copy(*args)
+ os.link(destination, alias)
+ return result
+
+ m._copy_private_backup = add_link_after_backup
+ try:
+ refuses(
+ m,
+ "output_has_multiple_links",
+ m.write_outputs,
+ [(str(destination), "new bytes")],
+ )
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert os.path.samefile(destination, alias)
+ assert destination.read_text() == alias.read_text() == "old bytes"
+ assert sorted(path.name for path in root.iterdir()) == ["alias.xml", "output.xml"]
+
+
+def test_write_outputs_refuses_a_hard_link_added_to_the_private_backup(m):
+ """The generated backup is ownership-only until commit. A link made after
+ its copy finishes leaves an unknown alias with old statement bytes, so the
+ run must refuse and describe the retention rather than report success."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "output.xml"
+ alias = root / "backup-alias.xml"
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+
+ def add_link_after_backup_copy(*args):
+ result = real_copy(*args)
+ backup, = root.glob("output.xml.*.bak")
+ os.link(backup, alias)
+ return result
+
+ m._copy_private_backup = add_link_after_backup_copy
+ try:
+ refusal = refuses(
+ m,
+ "rollback_backup_has_multiple_links",
+ m.write_outputs,
+ [(str(destination), "new bytes")],
+ )
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert "unknown hard-link alias" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert alias.read_text() == "old bytes"
+ assert not list(root.glob("output.xml.*.bak"))
+
+
+def test_committed_new_output_close_failure_is_not_a_retained_backup(m):
+ """A failed ownership-pin close does not create a prior-output backup."""
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ real_close = m.os.close
+ real_owned = m._owned_path
+ output_handles = set()
+ fired = False
+
+ def observe_owned(path, handle, *, created, **kwargs):
+ record = real_owned(path, handle, created=created, **kwargs)
+ if created and pathlib.Path(path).resolve() == destination.resolve():
+ output_handles.add(handle)
+ return record
+
+ def close_then_error(handle):
+ nonlocal fired
+ real_close(handle)
+ if handle in output_handles and not fired:
+ fired = True
+ raise OSError("controlled close after effect")
+
+ m._owned_path, m.os.close = observe_owned, close_then_error
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ finally:
+ m._owned_path, m.os.close = real_owned, real_close
+
+ # EBADF from the retained-descriptor probe proves this close took
+ # effect, so it is not a leaked-pin failure.
+ assert fired
+ assert destination.read_text() == "new bytes"
+ assert list(pathlib.Path(directory).iterdir()) == [destination]
+
+
+def test_new_output_unlink_after_claim_refuses_and_cleans_owned_canonical_path(m):
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ def unlink_after_claim():
+ destination.unlink()
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")], False, unlink_after_claim)
+ assert "owned output could not be located after cleanup" not in str(refusal.code)
+ assert not destination.exists()
+
+
+def test_cleanup_unlinked_pinned_path_does_not_report_a_phantom_output(m):
+ """A descriptor with zero links is not an undisclosed cleanup location."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "output.xml"
+ path.write_text("new bytes")
+ handle = os.open(path, os.O_RDONLY)
+ record = {"path": path, "identity": m._fd_identity(handle), "pin": handle}
+ failures = []
+ m._cleanup_owned_path(record, failures)
+ assert failures == []
+ assert record["pin"] is None
+ assert not path.exists()
+
+
+def test_cleanup_after_effect_unlink_without_alias_does_not_report_a_phantom(m):
+ """An unlink that removes its only link before raising has no retained path."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "output.xml"
+ path.write_text("new bytes")
+ handle = os.open(path, os.O_RDONLY)
+ record = {"path": path, "identity": m._fd_identity(handle), "pin": handle}
+ real_unlink = m.os.unlink
+
+ def unlink_then_fail(candidate):
+ real_unlink(candidate)
+ raise OSError("controlled unlink after effect")
+
+ m.os.unlink = unlink_then_fail
+ try:
+ failures = []
+ m._cleanup_owned_path(record, failures)
+ finally:
+ m.os.unlink = real_unlink
+
+ assert failures == []
+ assert record["pin"] is None
+ assert not path.exists()
+
+
+def test_write_outputs_after_effect_unlink_discloses_retained_hard_link(m):
+ """A cleanup EIO after unlink still reconciles a pinned hard-link alias."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, alias = root / "first.xml", root / "second.xml", root / "alias.xml"
+ real_dup, real_unlink = m.os.dup, m.os.unlink
+ dup_calls = 0
+
+ def fail_second_payload(handle):
+ nonlocal dup_calls
+ dup_calls += 1
+ if dup_calls == 2:
+ raise OSError("controlled later payload failure")
+ return real_dup(handle)
+
+ def unlink_after_effect(candidate):
+ real_unlink(candidate)
+ if pathlib.Path(candidate).resolve() == first.resolve():
+ raise OSError("controlled cleanup EIO after unlink")
+
+ m.os.dup, m.os.unlink = fail_second_payload, unlink_after_effect
+ try:
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ try:
+ m.write_outputs(
+ [(str(first), "first bytes"), (str(second), "second bytes")],
+ after_claim=lambda: os.link(first, alias),
+ )
+ raise AssertionError("the controlled payload failure must escape")
+ except OSError as error:
+ detail = (str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ + stderr.getvalue())
+ assert "controlled later payload failure" in detail
+ assert "owned output could not be located after cleanup" in detail
+ finally:
+ m.os.dup, m.os.unlink = real_dup, real_unlink
+
+ assert not first.exists()
+ assert not second.exists()
+ assert alias.read_text() == "first bytes"
+
+
+def test_new_output_alias_after_claim_and_later_payload_failure_is_disclosed(m):
+ """Cleanup may remove our name while an after-claim hard link stays live."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, alias = root / "first.xml", root / "second.xml", root / "alias.xml"
+ real_dup = m.os.dup
+ calls = 0
+
+ def fail_second_payload(handle):
+ nonlocal calls
+ calls += 1
+ if calls == 2:
+ raise OSError("controlled later payload failure")
+ return real_dup(handle)
+
+ m.os.dup = fail_second_payload
+ try:
+ try:
+ m.write_outputs(
+ [(str(first), "first bytes"), (str(second), "second bytes")],
+ after_claim=lambda: os.link(first, alias),
+ )
+ raise AssertionError("the controlled payload failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ assert "controlled later payload failure" in detail
+ assert "owned output could not be located after cleanup" in detail
+ finally:
+ m.os.dup = real_dup
+
+ assert not first.exists()
+ assert not second.exists()
+ assert alias.read_text() == "first bytes"
+
+
+def test_write_outputs_inspection_eacces_is_not_reported_as_missing(m):
+ """A real payload rollback retains an inaccessible claimed output visibly."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ real_entry = m._entry_identity
+ real_dup = m.os.dup
+ denied = False
+ dup_calls = 0
+
+ def deny_entry(path):
+ if denied and pathlib.Path(path).resolve() == first.resolve():
+ raise PermissionError("controlled EACCES")
+ return real_entry(path)
+
+ def fail_second_payload(handle):
+ nonlocal dup_calls
+ dup_calls += 1
+ if dup_calls == 2:
+ raise OSError("controlled later payload failure")
+ return real_dup(handle)
+
+ def deny_after_claim():
+ nonlocal denied
+ denied = True
+
+ m._entry_identity, m.os.dup = deny_entry, fail_second_payload
+ try:
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ try:
+ m.write_outputs(
+ [(str(first), "first bytes"), (str(second), "second bytes")],
+ after_claim=deny_after_claim,
+ )
+ raise AssertionError("the controlled payload failure must escape")
+ except OSError as error:
+ detail = (str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ + stderr.getvalue())
+ assert "controlled later payload failure" in detail
+ assert ("could not inspect owned output during cleanup: "
+ + str(first.resolve())) in detail
+ finally:
+ m._entry_identity, m.os.dup = real_entry, real_dup
+
+ assert first.read_text() == "first bytes"
+ assert not second.exists()
+
+
+def test_new_output_parent_retarget_refuses_and_preserves_foreign_path(m):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, foreign = root / "original", root / "foreign"
+ original.mkdir()
+ foreign.mkdir()
+ foreign_destination = foreign / "output.xml"
+ foreign_destination.write_text("foreign bytes")
+ destination = root / "linked" / "output.xml"
+ (root / "linked").symlink_to(original, target_is_directory=True)
+ def retarget_parent():
+ (root / "linked").unlink()
+ (root / "linked").symlink_to(foreign, target_is_directory=True)
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")], False, retarget_parent)
+ assert foreign_destination.read_text() == "foreign bytes"
+ assert original.exists() and not (original / "output.xml").exists()
+ assert (root / "linked").is_symlink(), "foreign retarget must not be deleted"
+ assert (root / "linked").joinpath("output.xml").read_text() == "foreign bytes"
+
+
+def test_new_output_replace_after_claim_preserves_foreign_bytes(m):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, foreign = root / "output.xml", root / "foreign.xml"
+ def replace_after_claim():
+ foreign.write_text("foreign bytes")
+ os.replace(foreign, destination)
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")], False, replace_after_claim)
+ assert destination.read_text() == "foreign bytes"
+
+
+def test_new_output_claim_inspection_failure_cleans_owned_path(m):
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ real_identity = m._file_identity
+ def fail_identity(path):
+ if pathlib.Path(path).resolve() == destination.resolve():
+ raise OSError("controlled claim inspection failure")
+ return real_identity(path)
+ m._file_identity = fail_identity
+ try:
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._file_identity = real_identity
+ assert not destination.exists()
+
+
+def test_new_output_parent_retarget_during_open_cleans_actual_created_path(m):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, foreign = root / "original", root / "foreign"
+ original.mkdir()
+ foreign.mkdir()
+ link = root / "linked"
+ link.symlink_to(original, target_is_directory=True)
+ foreign_destination = foreign / "output.xml"
+ foreign_destination.write_text("foreign bytes")
+ real_open = m._open_private
+ def retarget_open(path, accept_inherited):
+ link.unlink()
+ link.symlink_to(foreign, target_is_directory=True)
+ return real_open(path, accept_inherited)
+ m._open_private = retarget_open
+ try:
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(link / "output.xml"), "new bytes")])
+ finally:
+ m._open_private = real_open
+ assert not (original / "output.xml").exists()
+ assert foreign_destination.read_text() == "foreign bytes"
+
+
+def test_new_output_changed_during_later_swap_rolls_back_existing_output(m):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ fresh, existing = root / "fresh.xml", root / "existing.xml"
+ existing.write_text("old bytes")
+ real_replace = m.os.replace
+ def replace_then_unlink(source, destination):
+ result = real_replace(source, destination)
+ if pathlib.Path(destination).resolve() == existing.resolve() and str(source).endswith(".part"):
+ fresh.unlink()
+ return result
+ m.os.replace = replace_then_unlink
+ try:
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(fresh), "new fresh"), (str(existing), "new existing")])
+ finally:
+ m.os.replace = real_replace
+ assert not fresh.exists()
+ assert existing.read_text() == "old bytes"
+ assert list(root.iterdir()) == [existing]
+
+
+def test_staged_output_hard_link_before_commit_refuses_and_reports_alias(m):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, alias = root / "output.xml", root / "alias.xml"
+ destination.write_text("old bytes")
+ def link_staged():
+ staged, = root.glob("output.xml.*.part")
+ os.link(staged, alias)
+ refusal = refuses(m, "staged_output_has_multiple_links", m.write_outputs,
+ [(str(destination), "new bytes")], False, link_staged)
+ assert "unknown hard-link alias" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert alias.read_text() == "new bytes"
+ assert not list(root.glob("*.part"))
+ assert not list(root.glob("*.bak"))
+
+
+def test_fresh_output_hard_link_before_commit_refuses_and_reports_alias(m):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, alias = root / "output.xml", root / "alias.xml"
+ refusal = refuses(m, "output_has_multiple_links", m.write_outputs,
+ [(str(destination), "new bytes")], False,
+ lambda: os.link(destination, alias))
+ assert "unknown hard-link alias" in str(refusal.code)
+ assert not destination.exists()
+ assert alias.read_text() == "new bytes"
+
+
+def test_staged_parent_rename_reports_unlocated_output(m):
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root, moved = pathlib.Path(directory) / "before", pathlib.Path(directory) / "after"
+ root.mkdir()
+ destination = root / "output.xml"
+ destination.write_text("old bytes")
+ stderr = io.StringIO()
+ with contextlib.redirect_stderr(stderr):
+ try:
+ m.write_outputs([(str(destination), "new bytes")],
+ after_claim=lambda: root.rename(moved))
+ raise AssertionError("missing backup parent must fail")
+ except FileNotFoundError as error:
+ detail = stderr.getvalue() + "\n".join(getattr(error, "__notes__", []))
+ assert "owned output could not be located after cleanup" in detail
+ staged, = moved.glob("output.xml.*.part")
+ assert staged.read_text() == "new bytes"
+ assert (moved / "output.xml").read_text() == "old bytes"
+
+
+def test_earlier_replacement_is_revalidated_after_later_swap(m):
+ for change in ("replace", "unlink", "symlink"):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, foreign = root / "first.xml", root / "second.xml", root / "foreign.xml"
+ first.write_text("old first")
+ second.write_text("old second")
+ foreign.write_text("foreign bytes")
+ supplied = root / "first-link.xml" if change == "symlink" else first
+ if change == "symlink":
+ supplied.symlink_to(first)
+ real_replace = m.os.replace
+ changed_first = []
+ def replace_then_change_first(source, destination):
+ result = real_replace(source, destination)
+ if (str(source).endswith(".part")
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ changed_first.append(change)
+ if change == "replace":
+ real_replace(foreign, first)
+ elif change == "unlink":
+ first.unlink()
+ else:
+ supplied.unlink()
+ supplied.symlink_to(foreign)
+ return result
+ m.os.replace = replace_then_change_first
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(supplied), "new first"), (str(second), "new second")])
+ finally:
+ m.os.replace = real_replace
+ assert changed_first == [change], "interleave must run after the second swap"
+ assert second.read_text() == "old second"
+ assert "owned output could not be located after cleanup" not in str(refusal.code)
+ if change == "replace":
+ assert first.read_text() == "foreign bytes"
+ elif change == "unlink":
+ assert not first.exists()
+ else:
+ assert first.read_text() == "old first"
+ assert supplied.read_text() == "foreign bytes"
+
+
+def test_rollback_keeps_an_aliased_earlier_backup_after_a_later_swap_failure(m):
+ """Recovery must not restore an old copy after it gained an unknown alias."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, alias = root / "first.xml", root / "second.xml", root / "alias.xml"
+ first.write_text("old first")
+ second.write_text("old second")
+ real_replace = m.os.replace
+
+ def swap_first_alias_backup_then_fail_second(source, destination):
+ if (str(source).endswith(".part")
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ raise OSError("controlled later swap failure")
+ result = real_replace(source, destination)
+ if (str(source).endswith(".part")
+ and pathlib.Path(destination).resolve() == first.resolve()):
+ backup, = root.glob("first.xml.*.bak")
+ os.link(backup, alias)
+ return result
+
+ m.os.replace = swap_first_alias_backup_then_fail_second
+ stderr = io.StringIO()
+ try:
+ with contextlib.redirect_stderr(stderr):
+ try:
+ m.write_outputs([(str(first), "new first"), (str(second), "new second")])
+ raise AssertionError("the controlled later failure must escape")
+ except OSError as error:
+ notes = str(error) + "\n" + "\n".join(getattr(error, "__notes__", [])) + stderr.getvalue()
+ assert "unknown hard-link alias may retain rollback bytes" in notes
+ assert "partially committed output could not be rolled back" in notes
+ assert str(first.resolve()) in notes
+ finally:
+ m.os.replace = real_replace
+
+ backup, = root.glob("first.xml.*.bak")
+ assert first.read_text() == "new first"
+ assert second.read_text() == "old second"
+ assert backup.read_text() == alias.read_text() == "old first"
+
+
+def test_rollback_path_distinguishes_an_unlinked_backup_from_an_alias(m):
+ """A zero-link backup prevents restoration but must not claim an alias."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("old first")
+ second.write_text("old second")
+ real_replace = m.os.replace
+
+ def swap_first_unlink_backup_then_fail_second(source, destination):
+ if (str(source).endswith(".part")
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ raise OSError("controlled later swap failure")
+ result = real_replace(source, destination)
+ if (str(source).endswith(".part")
+ and pathlib.Path(destination).resolve() == first.resolve()):
+ backup, = root.glob("first.xml.*.bak")
+ backup.unlink()
+ return result
+
+ m.os.replace = swap_first_unlink_backup_then_fail_second
+ stderr = io.StringIO()
+ try:
+ with contextlib.redirect_stderr(stderr):
+ try:
+ m.write_outputs([(str(first), "new first"), (str(second), "new second")])
+ raise AssertionError("the controlled later failure must escape")
+ except OSError as error:
+ details = str(error) + "\n" + "\n".join(getattr(error, "__notes__", [])) + stderr.getvalue()
+ assert "controlled later swap failure" in str(error)
+ assert "unknown hard-link alias" not in details
+ finally:
+ m.os.replace = real_replace
+
+ assert first.read_text() == "new first"
+ assert second.read_text() == "old second"
+ assert not list(root.glob("*.bak"))
+
+
+def test_rollback_alias_diagnostic_reaches_stderr(m):
+ """The alias warning survives both exception-note and stderr render paths."""
+ if os.name == "nt":
+ return
+ program = f'''\
+import importlib.util
+import os
+import pathlib
+import tempfile
+
+script = {str(SCRIPT)!r}
+spec = importlib.util.spec_from_file_location("bank_statement_import_subprocess", script)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, alias = root / "first.xml", root / "second.xml", root / "alias.xml"
+ first.write_text("old first")
+ second.write_text("old second")
+ real_replace = module.os.replace
+ def replace_first_alias_then_fail_second(source, destination):
+ if str(source).endswith(".part") and pathlib.Path(destination).resolve() == second.resolve():
+ raise OSError("controlled later swap failure")
+ result = real_replace(source, destination)
+ if str(source).endswith(".part") and pathlib.Path(destination).resolve() == first.resolve():
+ backup, = root.glob("first.xml.*.bak")
+ os.link(backup, alias)
+ return result
+ module.os.replace = replace_first_alias_then_fail_second
+ module.write_outputs([(str(first), "new first"), (str(second), "new second")])
+'''
+ done = subprocess.run([sys.executable, "-c", program], text=True,
+ capture_output=True, check=False)
+ assert done.returncode != 0
+ assert "controlled later swap failure" in done.stderr, done.stderr
+ assert "unknown hard-link alias may retain rollback bytes" in done.stderr, done.stderr
+
+
+def test_restore_metadata_orders_xattrs_before_final_mode(m):
+ """POSIX call-order proof; Linux permission enforcement is tested separately."""
+ if os.name != "posix":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "output.xml"
+ path.write_text("bytes")
+ handle = os.open(path, os.O_RDWR)
+ current = os.fstat(handle)
+ metadata = {
+ "mode": 0o400,
+ "uid": current.st_uid,
+ "gid": current.st_gid,
+ "atime_ns": current.st_atime_ns,
+ "mtime_ns": current.st_mtime_ns,
+ "xattrs": {"user.bridge_order": b"value"},
+ }
+ missing = object()
+ real_listxattr = getattr(m.os, "listxattr", missing)
+ real_setxattr = getattr(m.os, "setxattr", missing)
+ real_fchmod = m.os.fchmod
+ events = []
+
+ def list_no_xattrs(_):
+ return []
+
+ def record_setxattr(*args):
+ events.append("xattr")
+
+ def record_fchmod(*args):
+ events.append("mode")
+
+ m.os.listxattr, m.os.setxattr, m.os.fchmod = (
+ list_no_xattrs, record_setxattr, record_fchmod)
+ try:
+ m._restore_metadata(handle, metadata)
+ finally:
+ for name, original in (("listxattr", real_listxattr),
+ ("setxattr", real_setxattr)):
+ if original is missing:
+ delattr(m.os, name)
+ else:
+ setattr(m.os, name, original)
+ m.os.fchmod = real_fchmod
+ os.close(handle)
+
+ assert events == ["xattr", "mode"]
+
+
+def test_existing_destination_is_pinned_before_staging_side_effects(m):
+ """A mkstemp-time replacement is foreign because the original pin predates it."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, foreign = root / "output.xml", root / "foreign.xml"
+ destination.write_text("old bytes")
+ foreign.write_text("foreign bytes")
+ real_mkstemp, real_replace = m.tempfile.mkstemp, m.os.replace
+ fired = False
+
+ def replace_from_part_mkstemp(*args, **kwargs):
+ nonlocal fired
+ handle, path = real_mkstemp(*args, **kwargs)
+ if kwargs.get("suffix") == ".part" and not fired:
+ fired = True
+ real_replace(foreign, destination)
+ return handle, path
+
+ m.tempfile.mkstemp = replace_from_part_mkstemp
+ try:
+ refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m.tempfile.mkstemp = real_mkstemp
+
+ assert fired
+ assert destination.read_text() == "foreign bytes"
+ assert sorted(path.name for path in root.iterdir()) == ["output.xml"]
+
+
+def test_windows_modeled_close_after_effect_then_unlink_has_no_stale_retention(m):
+ """Model the Windows close-before-unlink order; Windows filesystem proof is separate."""
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "fresh.xml"
+ handle = m._open_private(path)
+ record = m._owned_path(path, handle, created=True)
+ real_close, real_name = m.os.close, m.os.name
+ fired, failures = False, []
+
+ def close_then_error(candidate):
+ nonlocal fired
+ real_close(candidate)
+ if candidate == handle and not fired:
+ fired = True
+ raise OSError("controlled close after effect")
+
+ m.os.close, m.os.name = close_then_error, "nt"
+ try:
+ m._cleanup_owned_path(record, failures)
+ finally:
+ m.os.close, m.os.name = real_close, real_name
+
+ assert fired
+ assert failures == []
+ assert not path.exists()
+
+
+def test_windows_modeled_reclaimed_backup_name_is_not_retained(m):
+ """A foreign replacement after close is disclosed only as an unlocated copy."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ path, moved, foreign = (
+ root / "rollback.bak", root / "moved.bak", root / "foreign.bak")
+ handle = m._open_private(path)
+ os.write(handle, b"prior statement bytes")
+ record = m._owned_path(path, handle, created=True)
+ real_close, real_name = m.os.close, m.os.name
+
+ def close_then_reclaim(candidate):
+ result = real_close(candidate)
+ if candidate == handle:
+ path.rename(moved)
+ foreign.write_text("foreign backup bytes")
+ os.replace(foreign, path)
+ return result
+
+ failures = []
+ m.os.close, m.os.name = close_then_reclaim, "nt"
+ try:
+ m._cleanup_owned_path(record, failures, suppress_reclaimed_name=True)
+ finally:
+ m.os.close, m.os.name = real_close, real_name
+
+ assert record["pin"] is None
+ assert failures == [
+ f"owned output could not be located after cleanup: {path}"]
+ assert path.read_text() == "foreign backup bytes"
+ assert moved.read_bytes() == b"prior statement bytes"
+
+
+def test_windows_modeled_cleanup_reports_an_alias_before_closing_the_pin(m):
+ """The Windows close-before-unlink branch still discloses linked output."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ path, alias = root / "fresh.xml", root / "fresh-alias.xml"
+ handle = m._open_private(path)
+ os.write(handle, b"generated statement")
+ record = m._owned_path(path, handle, created=True)
+ os.link(path, alias)
+ real_name, failures = m.os.name, []
+
+ m.os.name = "nt"
+ try:
+ m._cleanup_owned_path(record, failures)
+ finally:
+ m.os.name = real_name
+
+ assert not path.exists()
+ assert alias.read_bytes() == b"generated statement"
+ assert failures == [
+ f"unknown hard-link alias may retain output bytes: {path}"]
+
+
+def test_rollback_restores_xattrs_before_a_readonly_final_mode(m):
+ """Linux xattrs need the backup's temporary write permission during rollback."""
+ if os.name != "posix" or not hasattr(os, "setxattr"):
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("old first")
+ second.write_text("old second")
+ name, value = "user.bridge_readonly_rollback", b"original xattr"
+ try:
+ os.setxattr(first, name, value)
+ except OSError:
+ return
+ first.chmod(0o400)
+ real_replace = m.os.replace
+
+ def fail_second_swap(source, destination):
+ if str(source).endswith(".part") and pathlib.Path(destination) == second:
+ raise OSError("controlled second swap failure")
+ return real_replace(source, destination)
+
+ m.os.replace = fail_second_swap
+ try:
+ try:
+ m.write_outputs([(str(first), "new first"), (str(second), "new second")])
+ raise AssertionError("the controlled second swap failure must escape")
+ except OSError as error:
+ assert "controlled second swap failure" in str(error)
+ finally:
+ m.os.replace = real_replace
+
+ assert first.read_text() == "old first"
+ assert stat.S_IMODE(first.stat().st_mode) == 0o400
+ assert os.getxattr(first, name) == value
+ assert second.read_text() == "old second"
+
+
+def test_earlier_backup_alias_is_revalidated_after_later_swap(m):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, alias = root / "first.xml", root / "second.xml", root / "alias.xml"
+ first.write_text("old first")
+ second.write_text("old second")
+ real_replace = m.os.replace
+ linked = []
+ def replace_then_link_first_backup(source, destination):
+ result = real_replace(source, destination)
+ if (str(source).endswith(".part")
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ backup, = root.glob("first.xml.*.bak")
+ os.link(backup, alias)
+ linked.append(True)
+ return result
+ m.os.replace = replace_then_link_first_backup
+ try:
+ refusal = refuses(m, "rollback_backup_has_multiple_links", m.write_outputs,
+ [(str(first), "new first"), (str(second), "new second")])
+ finally:
+ m.os.replace = real_replace
+ assert linked == [True]
+ assert "unknown hard-link alias may retain rollback bytes" in str(refusal.code)
+ backup, = root.glob("first.xml.*.bak")
+ assert first.read_text() == "new first"
+ assert second.read_text() == "old second"
+ assert backup.read_text() == alias.read_text() == "old first"
+
+
+def test_interrupt_during_fresh_registration_reconciles_the_created_output(m):
+ """A real SIGINT at record construction leaves the helper's pin to clean."""
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "fresh.xml"
+ _, start_line = inspect.getsourcelines(m._owned_path)
+ record_line = start_line + next(
+ offset for offset, line in enumerate(inspect.getsource(m._owned_path).splitlines())
+ if line.strip() == 'record = {"path": path, "identity": identity, "pin": handle}')
+ old_trace, old_handler = sys.gettrace(), signal.getsignal(signal.SIGINT)
+ fired = False
+
+ def interrupt_at_record_construction(frame, event, _arg):
+ nonlocal fired
+ if (not fired and event == "line" and frame.f_code is m._owned_path.__code__
+ and frame.f_lineno == record_line
+ and frame.f_locals.get("created")):
+ fired = True
+ signal.raise_signal(signal.SIGINT)
+ return interrupt_at_record_construction
+
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ sys.settrace(interrupt_at_record_construction)
+ try:
+ try:
+ m.write_outputs([(str(destination), "fresh bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ sys.settrace(old_trace)
+ signal.signal(signal.SIGINT, old_handler)
+
+ assert fired
+ assert not destination.exists()
+
+
+def test_created_output_fchmod_failure_preserves_a_foreign_replacement(m):
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, foreign = root / "fresh.xml", root / "foreign.xml"
+ foreign.write_text("foreign bytes")
+ real_fchmod, real_replace = m.os.fchmod, m.os.replace
+ fired = False
+
+ def replace_then_fail(handle, mode):
+ nonlocal fired
+ if not fired:
+ fired = True
+ real_replace(foreign, destination)
+ raise OSError("controlled fchmod failure")
+ return real_fchmod(handle, mode)
+
+ m.os.fchmod = replace_then_fail
+ try:
+ try:
+ m.write_outputs([(str(destination), "fresh bytes")])
+ raise AssertionError("the controlled mode failure must escape")
+ except OSError as error:
+ assert "controlled fchmod failure" in str(error)
+ finally:
+ m.os.fchmod = real_fchmod
+
+ assert fired
+ assert destination.read_text() == "foreign bytes"
+
+
+
+def test_interrupt_after_fresh_registration_cleans_the_requested_output(m):
+ """A SIGINT after registration has a claimed cleanup owner already."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "fresh.xml"
+ _, start = inspect.getsourcelines(m._claim_owned_output)
+ return_line = start + next(
+ offset for offset, line in enumerate(inspect.getsource(m._claim_owned_output).splitlines())
+ if line.strip() == "return record")
+ old_trace, old_handler = sys.gettrace(), signal.getsignal(signal.SIGINT)
+ fired = False
+
+ def interrupt_after_registration(frame, event, _arg):
+ nonlocal fired
+ if (not fired and event == "line" and frame.f_code is m._claim_owned_output.__code__
+ and frame.f_lineno == return_line
+ and frame.f_locals.get("created")
+ and frame.f_locals.get("owned_records") is not None):
+ fired = True
+ signal.raise_signal(signal.SIGINT)
+ return interrupt_after_registration
+
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ sys.settrace(interrupt_after_registration)
+ try:
+ try:
+ m.write_outputs([(str(destination), "fresh bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ sys.settrace(old_trace)
+ signal.signal(signal.SIGINT, old_handler)
+
+ assert fired
+ assert not destination.exists()
+ assert list(root.iterdir()) == []
+
+
+def test_sigint_during_created_factory_return_waits_for_ownership_handoff(m):
+ """A factory-side SIGINT cannot strand a file before registration."""
+ if not hasattr(signal, "pthread_sigmask"):
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "fresh.xml"
+ real_open = m._open_private
+ old_handler = signal.getsignal(signal.SIGINT)
+ fired = False
+
+ def create_then_interrupt(*args, **kwargs):
+ nonlocal fired
+ handle = real_open(*args, **kwargs)
+ fired = True
+ signal.raise_signal(signal.SIGINT)
+ return handle
+
+ m._open_private = create_then_interrupt
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ try:
+ try:
+ m.write_outputs([(str(destination), "fresh bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ m._open_private = real_open
+ signal.signal(signal.SIGINT, old_handler)
+
+ assert fired
+ assert not destination.exists()
+ assert list(root.iterdir()) == []
+
+
+def test_factory_sigint_without_pthread_mask_waits_for_ownership_handoff(m):
+ """The signal-handler fallback keeps no-pthread creation recoverable."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "fresh.xml"
+ real_open, real_signal = m._open_private, m.signal
+ old_handler = signal.getsignal(signal.SIGINT)
+ fired = False
+
+ def create_then_interrupt(*args, **kwargs):
+ nonlocal fired
+ handle = real_open(*args, **kwargs)
+ fired = True
+ signal.raise_signal(signal.SIGINT)
+ return handle
+
+ m.signal = types.SimpleNamespace(
+ SIGINT=signal.SIGINT,
+ getsignal=signal.getsignal,
+ signal=signal.signal,
+ raise_signal=signal.raise_signal,
+ )
+ m._open_private = create_then_interrupt
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ try:
+ try:
+ m.write_outputs([(str(destination), "fresh bytes")])
+ raise AssertionError("the controlled interrupt must escape")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ m._open_private, m.signal = real_open, real_signal
+ signal.signal(signal.SIGINT, old_handler)
+
+ assert fired
+ assert not destination.exists()
+ assert list(root.iterdir()) == []
+
+
+def test_initial_existing_path_revalidation_is_a_typed_refusal(m):
+ """The first post-pin check cannot leak a raw filesystem exception."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "output.xml"
+ destination.write_text("old bytes")
+ real_open = m._open_regular_output
+
+ def open_then_remove(path, *args):
+ handle = real_open(path, *args)
+ if pathlib.Path(path).resolve() == destination.resolve():
+ destination.unlink()
+ return handle
+
+ m._open_regular_output = open_then_remove
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._open_regular_output = real_open
+
+ assert "changed while it was being claimed" in str(refusal.code)
+ assert not destination.exists()
+ assert list(root.iterdir()) == []
+
+
+def test_backup_copy_removal_during_final_source_check_is_a_typed_refusal(m):
+ """A removed source during post-copy validation cannot leak a traceback."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "output.xml"
+ destination.write_text("old bytes")
+ real_copy, real_identity = m._copy_private_backup, m._fd_identity
+
+ def copy_then_remove_at_final_identity(*args):
+ calls = 0
+
+ def remove_on_final_identity(handle):
+ nonlocal calls
+ calls += 1
+ if calls == 2:
+ destination.unlink()
+ return real_identity(handle)
+
+ m._fd_identity = remove_on_final_identity
+ try:
+ return real_copy(*args)
+ finally:
+ m._fd_identity = real_identity
+
+ m._copy_private_backup = copy_then_remove_at_final_identity
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert "changed while its rollback copy was prepared" in str(refusal.code)
+ assert not destination.exists()
+ assert list(root.iterdir()) == []
+
+
+def test_existing_output_removed_after_backup_is_a_typed_path_refusal(m):
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "output.xml"
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+
+ def copy_then_remove(*args):
+ result = real_copy(*args)
+ destination.unlink()
+ return result
+
+ m._copy_private_backup = copy_then_remove
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert "changed after it was claimed" in str(refusal.code)
+ assert not destination.exists()
+ assert list(root.iterdir()) == []
+
+
+def test_committed_existing_output_close_failure_reports_destination(m):
+ """The post-rename staged pin must not inspect its stale .part spelling."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ destination.write_text("old bytes")
+ real_owned, real_close = m._owned_path, m.os.close
+ staged_handles, leaked = set(), []
+
+ def observe_owned(path, handle, *, created, **kwargs):
+ record = real_owned(path, handle, created=created, **kwargs)
+ if created and str(path).endswith(".part"):
+ staged_handles.add(handle)
+ return record
+
+ def fail_before_close(handle):
+ if handle in staged_handles:
+ leaked.append(handle)
+ raise OSError("controlled staged close failure")
+ return real_close(handle)
+
+ m._owned_path, m.os.close = observe_owned, fail_before_close
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the close failure must be reported")
+ except m.OutputDescriptorCloseFailure as failure:
+ assert failure.output_paths == (str(destination.resolve()),)
+ finally:
+ m._owned_path, m.os.close = real_owned, real_close
+ for handle in leaked:
+ try:
+ real_close(handle)
+ except OSError:
+ pass
+
+ assert leaked
+ assert destination.read_text() == "new bytes"
+
+
+
+def test_committed_original_pin_close_failure_reports_destination(m):
+ """The old inode pin can stay open after its pathname names staged bytes."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ destination.write_text("old bytes")
+ real_owned, real_close = m._owned_path, m.os.close
+ original_handles, leaked = set(), []
+
+ def observe_owned(path, handle, *, created, **kwargs):
+ record = real_owned(path, handle, created=created, **kwargs)
+ if not created and pathlib.Path(path).resolve() == destination.resolve():
+ original_handles.add(handle)
+ return record
+
+ def fail_before_close(handle):
+ if handle in original_handles:
+ leaked.append(handle)
+ raise OSError("controlled original-pin close failure")
+ return real_close(handle)
+
+ m._owned_path, m.os.close = observe_owned, fail_before_close
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the old descriptor leak must be reported")
+ except m.OutputDescriptorCloseFailure as failure:
+ assert failure.output_paths == (str(destination.resolve()),)
+ finally:
+ m._owned_path, m.os.close = real_owned, real_close
+ for handle in leaked:
+ try:
+ real_close(handle)
+ except OSError:
+ pass
+
+ assert leaked
+ assert destination.read_text() == "new bytes"
+
+
+def test_committed_close_unknown_descriptor_state_is_not_treated_as_after_effect(m):
+ """Only EBADF proves a failed close already released its descriptor."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ destination.write_text("old bytes")
+ real_owned, real_close, real_fstat = m._owned_path, m.os.close, m.os.fstat
+ original_handles, leaked = set(), []
+ close_attempted = set()
+
+ def observe_owned(path, handle, *, created, **kwargs):
+ record = real_owned(path, handle, created=created, **kwargs)
+ if not created and pathlib.Path(path).resolve() == destination.resolve():
+ original_handles.add(handle)
+ return record
+
+ def fail_before_close(handle):
+ if handle in original_handles:
+ close_attempted.add(handle)
+ leaked.append(handle)
+ raise OSError("controlled original-pin close failure")
+ return real_close(handle)
+
+ def unreadable_pin(handle):
+ if handle in close_attempted:
+ raise OSError(errno.EIO, "controlled descriptor inspection failure")
+ return real_fstat(handle)
+
+ m._owned_path, m.os.close, m.os.fstat = observe_owned, fail_before_close, unreadable_pin
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("unknown descriptor state must be reported")
+ except m.OutputDescriptorCloseFailure as failure:
+ assert failure.output_paths == (str(destination.resolve()),)
+ finally:
+ m._owned_path, m.os.close, m.os.fstat = real_owned, real_close, real_fstat
+ for handle in leaked:
+ try:
+ real_close(handle)
+ except OSError:
+ pass
+
+ assert destination.read_text() == "new bytes"
+
+
+def test_missing_earlier_backup_reports_the_partial_committed_destination(m):
+ """A vanished first rollback copy leaves its new destination explicit."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_copy = m._copy_private_backup
+
+ def remove_first_backup_during_second_prepare(source_path, *args):
+ result = real_copy(source_path, *args)
+ if pathlib.Path(source_path).resolve() == second.resolve():
+ first_backup, = root.glob("first.xml.*.bak")
+ first_backup.unlink()
+ return result
+
+ m._copy_private_backup = remove_first_backup_during_second_prepare
+ try:
+ refusal = refuses(
+ m, "output_path_changed", m.write_outputs,
+ [(str(first), "first new"), (str(second), "second new")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ message = str(refusal.code)
+ assert str(first.resolve()) in message
+ assert "partially committed output could not be rolled back" in message
+ assert ".bak" not in message
+ assert ".part" not in message
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert not list(root.glob("*.bak"))
+ assert not list(root.glob("*.part"))
+
+
+def test_later_backup_prepare_failure_reports_an_earlier_partial_destination(m):
+ """Rollback recovery, not just final validation, owns this diagnosis."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_copy = m._copy_private_backup
+ fired = False
+
+ def remove_first_backup_then_fail_second_prepare(source_path, *args):
+ nonlocal fired
+ result = real_copy(source_path, *args)
+ if pathlib.Path(source_path).resolve() == second.resolve():
+ first_backup, = root.glob("first.xml.*.bak")
+ first_backup.unlink()
+ fired = True
+ raise OSError("controlled later backup preparation failure")
+ return result
+
+ m._copy_private_backup = remove_first_backup_then_fail_second_prepare
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the later preparation failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ assert "controlled later backup preparation failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert ".bak" not in detail
+ assert ".part" not in detail
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert fired
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert not list(root.glob("*.bak"))
+ assert not list(root.glob("*.part"))
+
+
+def test_all_missing_backups_report_every_partial_committed_destination(m):
+ """One final-check refusal must still reconcile every already-swapped row."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_changed = m._claimed_output_changed
+ fired = False
+
+ def remove_backups_before_final_checks(*args):
+ nonlocal fired
+ if not fired:
+ fired = True
+ for backup in root.glob("*.bak"):
+ backup.unlink()
+ return real_changed(*args)
+
+ m._claimed_output_changed = remove_backups_before_final_checks
+ try:
+ refusal = refuses(
+ m, "output_path_changed", m.write_outputs,
+ [(str(first), "first new"), (str(second), "second new")])
+ finally:
+ m._claimed_output_changed = real_changed
+
+ detail = str(refusal.code)
+ assert fired
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert str(second.resolve()) in detail
+ assert ".bak" not in detail
+ assert ".part" not in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second new"
+ assert not list(root.glob("*.bak"))
+ assert not list(root.glob("*.part"))
+
+
+
+def test_digest_pinned_bytes_preserves_the_owned_pin_position(m):
+ """The integrity check must not disturb later ownership operations."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "backup.bak"
+ path.write_bytes(b"rollback bytes")
+ handle = os.open(path, os.O_RDONLY)
+ try:
+ os.lseek(handle, 3, os.SEEK_SET)
+ assert m._digest_pinned_bytes(handle) == hashlib.sha256(b"rollback bytes").digest()
+ assert os.lseek(handle, 0, os.SEEK_CUR) == 3
+ finally:
+ os.close(handle)
+
+
+def test_backup_digest_read_failure_preserves_original_error_and_untrusted_copy(m):
+ """An unreadable pin is untrusted, never permission to restore from it."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_copy, real_digest = m._copy_private_backup, m._digest_pinned_bytes
+ first_handles = set()
+
+ def remember_first_backup_then_fail_second_prepare(source_path, identity, handle):
+ result = real_copy(source_path, identity, handle)
+ if pathlib.Path(source_path).resolve() == first.resolve():
+ first_handles.add(handle)
+ if pathlib.Path(source_path).resolve() == second.resolve():
+ raise OSError("controlled later preparation failure")
+ return result
+
+ def fail_first_backup_read(handle):
+ if handle in first_handles:
+ raise OSError("controlled backup digest read failure")
+ return real_digest(handle)
+
+ m._copy_private_backup, m._digest_pinned_bytes = (
+ remember_first_backup_then_fail_second_prepare, fail_first_backup_read)
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the later failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ assert "controlled later preparation failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ backup, = root.glob("first.xml.*.bak")
+ assert str(backup) in detail
+ finally:
+ m._copy_private_backup, m._digest_pinned_bytes = real_copy, real_digest
+
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert not list(root.glob("*.part"))
+
+def test_mutated_backup_during_later_prepare_preserves_original_error_and_bytes(m):
+ """An inode-stable backup needs its verified content before restoration."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_copy = m._copy_private_backup
+ changed = []
+
+ def mutate_first_backup_then_fail_second_prepare(source_path, *args):
+ result = real_copy(source_path, *args)
+ if pathlib.Path(source_path).resolve() == second.resolve():
+ backup, = root.glob("first.xml.*.bak")
+ backup.write_text("injected bytes")
+ changed.append(backup)
+ raise OSError("controlled later preparation failure")
+ return result
+
+ m._copy_private_backup = mutate_first_backup_then_fail_second_prepare
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the later failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ assert "controlled later preparation failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert str(changed[0]) in detail
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert changed[0].read_text() == "injected bytes"
+ assert not list(root.glob("*.part"))
+
+
+def test_mutated_backup_before_final_check_is_not_restored(m):
+ """The final authority check uses the same pinned-byte digest."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_changed = m._claimed_output_changed
+ mutated = []
+
+ def mutate_first_backup_before_final_checks(*args):
+ if not mutated:
+ backup, = root.glob("first.xml.*.bak")
+ backup.write_text("injected bytes")
+ mutated.append(backup)
+ return real_changed(*args)
+
+ m._claimed_output_changed = mutate_first_backup_before_final_checks
+ try:
+ refusal = refuses(
+ m, "output_path_changed", m.write_outputs,
+ [(str(first), "first new"), (str(second), "second new")])
+ finally:
+ m._claimed_output_changed = real_changed
+
+ detail = str(refusal.code)
+ assert "rollback copy content could not be verified before commit" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert str(mutated[0]) in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert mutated[0].read_text() == "injected bytes"
+ assert not list(root.glob("*.part"))
+
+
+def test_moved_backup_during_later_prepare_is_disclosed_before_pin_close(m):
+ """A linked but renamed backup remains an unlocated retained old copy."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, moved = root / "first.xml", root / "second.xml", root / "moved.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_copy = m._copy_private_backup
+
+ def move_first_backup_then_fail_second_prepare(source_path, *args):
+ result = real_copy(source_path, *args)
+ if pathlib.Path(source_path).resolve() == second.resolve():
+ backup, = root.glob("first.xml.*.bak")
+ backup.rename(moved)
+ raise OSError("controlled later preparation failure")
+ return result
+
+ m._copy_private_backup = move_first_backup_then_fail_second_prepare
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the later failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ assert "controlled later preparation failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert "owned rollback copy could not be located after cleanup" in detail
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert moved.read_text() == "first old"
+ assert not list(root.glob("*.bak"))
+ assert not list(root.glob("*.part"))
+
+def test_owner_only_outputs_survive_a_restrictive_umask(m):
+ """Use a child process so an extreme umask cannot affect this test process."""
+ if os.name != "posix":
+ return
+ program = f'''\
+import importlib.util
+import os
+import pathlib
+import stat
+import tempfile
+
+script = {str(SCRIPT)!r}
+spec = importlib.util.spec_from_file_location("bank_statement_import_subprocess", script)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ fresh = root / "fresh.xml"
+ existing = root / "existing.xml"
+ existing.write_text("old bytes")
+ observed = []
+ real_copy = module._copy_private_backup
+ def observe_private_modes(*args):
+ result = real_copy(*args)
+ for path in root.glob("existing.xml.*"):
+ observed.append(stat.S_IMODE(path.stat().st_mode))
+ return result
+ old_umask = os.umask(0o777)
+ try:
+ module.write_outputs([(str(fresh), "fresh bytes")])
+ module._copy_private_backup = observe_private_modes
+ module.write_outputs([(str(existing), "new bytes")])
+ finally:
+ os.umask(old_umask)
+ module._copy_private_backup = real_copy
+ assert stat.S_IMODE(fresh.stat().st_mode) == 0o600
+ assert stat.S_IMODE(existing.stat().st_mode) == 0o600
+ assert observed and all(mode == 0o600 for mode in observed), observed
+'''
+ done = subprocess.run([sys.executable, "-c", program], text=True,
+ capture_output=True, check=False)
+ assert done.returncode == 0, done.stderr
+
+
+
def main():
module = load()
for name, test in sorted(globals().items()):
@@ -1477,6 +4689,1691 @@ def main():
print("all offline contract tests passed")
return 0
+def test_interrupted_committed_cleanup_parent_rename_retains_unlocated_backup(m):
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory) / "before"; moved = pathlib.Path(directory) / "after"; root.mkdir()
+ backup_path = root / "old.bak"; original_path = root / "old.xml"; claimed_path = root / "new.xml"
+ for path in (backup_path, original_path, claimed_path): path.write_text(path.name)
+ fds = [os.open(path, os.O_RDONLY) for path in (backup_path, original_path, claimed_path)]
+ records = [{"path": path, "identity": m._fd_identity(fd), "pin": fd} for path, fd in zip((backup_path, original_path, claimed_path), fds)]
+ root.rename(moved)
+ retained=[]
+ m._reconcile_interrupted_committed_cleanup([{"backup":records[0],"original":records[1]}], [records[2]], retained, [])
+ assert any("owned output could not be located after cleanup" in value for value in retained)
+ assert (moved / "old.bak").read_text() == "old.bak"
+
+
+def test_interrupted_committed_cleanup_reports_a_retained_backup_alias(m):
+ """A known .bak name cannot conceal a later hard-link alias."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ backup_path, alias = root / "old.bak", root / "old-alias.bak"
+ original_path, claimed_path = root / "old.xml", root / "new.xml"
+ for path in (backup_path, original_path, claimed_path):
+ path.write_text(path.name)
+ os.link(backup_path, alias)
+ fds = [os.open(path, os.O_RDONLY) for path in
+ (backup_path, original_path, claimed_path)]
+ backup, original, claimed = [
+ {"path": path, "identity": m._fd_identity(fd), "pin": fd}
+ for path, fd in zip((backup_path, original_path, claimed_path), fds)]
+ retained = []
+ m._reconcile_interrupted_committed_cleanup(
+ [{"backup": backup, "original": original}], [claimed], retained, [])
+
+ assert str(backup_path) in retained
+ assert any("unknown hard-link alias may retain rollback bytes" in value
+ for value in retained)
+ assert backup["pin"] is None and original["pin"] is None and claimed["pin"] is None
+
+
+def test_interrupted_committed_cleanup_keeps_a_named_backup_after_prior_close(m):
+ """A prior cleanup may close its pin while leaving the known backup named."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ backup_path, original_path, claimed_path = (
+ root / "old.bak", root / "old.xml", root / "new.xml")
+ for path in (backup_path, original_path, claimed_path):
+ path.write_text(path.name)
+ backup_fd, original_fd, claimed_fd = (
+ os.open(path, os.O_RDONLY) for path in
+ (backup_path, original_path, claimed_path))
+ backup = {"path": backup_path, "identity": m._fd_identity(backup_fd), "pin": backup_fd}
+ original = {"path": original_path, "identity": m._fd_identity(original_fd), "pin": original_fd}
+ claimed = {"path": claimed_path, "identity": m._fd_identity(claimed_fd), "pin": claimed_fd}
+ os.close(backup_fd)
+ backup["pin"] = None
+ retained = []
+ m._reconcile_interrupted_committed_cleanup(
+ [{"backup": backup, "original": original}], [claimed], retained, [])
+
+ assert retained == [str(backup_path)]
+ assert original["pin"] is None and claimed["pin"] is None
+
+
+def test_failed_restore_before_effect_reports_the_partial_destination(m):
+ """A retained backup does not make an unchanged staged destination safe."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace = m.os.replace
+
+ def fail_second_swap_and_first_restore(source, destination):
+ source = pathlib.Path(source)
+ destination = pathlib.Path(destination)
+ if source.suffix == ".part" and destination.resolve() == second.resolve():
+ raise OSError("controlled later swap failure")
+ if source.suffix == ".bak" and destination.resolve() == first.resolve():
+ raise OSError("controlled restore failure before effect")
+ return real_replace(source, destination)
+
+ m.os.replace = fail_second_swap_and_first_restore
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled swap failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m.os.replace = real_replace
+
+ assert "controlled later swap failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ backup, = root.glob("first.xml.*.bak")
+ assert backup.read_text() == "first old"
+
+
+def test_restore_backup_preserves_foreign_symlink_entry(m):
+ """A symlink to the staged-new inode is still a foreign directory entry."""
+ if os.name == "nt":
+ return
+ def make_swap(root):
+ original, destination, staged, backup = (root / name for name in
+ ("original-old.xml", "destination.xml", "staged-new.xml", "backup.bak"))
+ original.write_text("old original bytes")
+ destination.write_text("new destination bytes")
+ staged.write_text("new staged bytes")
+ backup.write_text("old backup bytes")
+ original_fd, backup_fd = os.open(original, os.O_RDONLY), os.open(backup, os.O_RDONLY)
+ swap = {"destination": destination, "original_identity": m._fd_identity(original_fd),
+ "staged_identity": m._entry_identity(staged), "metadata": None,
+ "swap_started": True, "original": None,
+ "backup": {"path": backup, "identity": m._fd_identity(backup_fd),
+ "pin": backup_fd, "digest": m._digest_pinned_bytes(backup_fd)}}
+ destination.unlink(); destination.symlink_to(staged)
+ return destination, staged, backup, original_fd, backup_fd, swap
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, staged, backup, original_fd, backup_fd, swap = make_swap(root)
+ try:
+ failures=[]
+ m._restore_backup(swap, failures, [])
+ assert destination.is_symlink()
+ assert staged.read_text() == "new staged bytes"
+ assert backup.read_text() == "old backup bytes"
+ assert failures == [backup]
+ finally:
+ os.close(original_fd); os.close(backup_fd)
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, staged, backup, original_fd, backup_fd, swap = make_swap(root)
+ original_entry = m._entry_identity
+ m._entry_identity = m._file_identity
+ try:
+ try:
+ m._restore_backup(swap, [], [])
+ except TypeError:
+ pass
+ assert not destination.is_symlink(), "old stat-following check replaces the foreign link"
+ assert destination.read_text() == "old backup bytes"
+ finally:
+ m._entry_identity = original_entry
+ os.close(original_fd); os.close(backup_fd)
+
+
+def test_interrupted_cleanup_skips_closed_missing_backup_and_closes_later_pins(m):
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ gone, later, original, claimed = (root / name for name in ("gone.bak", "later.bak", "old.xml", "new.xml"))
+ for path in (gone, later, original, claimed): path.write_text(path.name)
+ fds = [os.open(path, os.O_RDONLY) for path in (gone, later, original, claimed)]
+ records = [{"path": path, "identity": m._fd_identity(fd), "pin": fd} for path, fd in zip((gone, later, original, claimed), fds)]
+ m._cleanup_owned_path(records[0], [])
+ assert records[0]["pin"] is None and not gone.exists()
+ real_entry=m._entry_identity
+ def deny_later(path):
+ if pathlib.Path(path) == later: raise PermissionError("controlled EACCES")
+ return real_entry(path)
+ m._entry_identity=deny_later
+ retained=[]
+ try:
+ m._reconcile_interrupted_committed_cleanup(
+ [{"backup":records[0],"original":records[2]}, {"backup":records[1],"original":records[2]}],
+ [records[3]], retained, [])
+ finally:
+ m._entry_identity=real_entry
+ assert any("could not inspect committed rollback copy" in value for value in retained)
+ assert records[1]["pin"] is None and records[2]["pin"] is None and records[3]["pin"] is None
+
+
+def test_initial_existing_claim_disappearance_is_a_typed_refusal(m):
+ """A vanished existing leaf must not escape as an untyped open failure."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "previous.xml"
+ destination.write_text("old bytes")
+ real_open = m._open_regular_output
+
+ def remove_before_open(path, *args):
+ if pathlib.Path(path).resolve() == destination.resolve():
+ destination.unlink()
+ return real_open(path, *args)
+
+ m._open_regular_output = remove_before_open
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._open_regular_output = real_open
+
+ assert "changed while it was being claimed" in str(refusal.code)
+ assert not destination.exists()
+ assert not list(pathlib.Path(directory).glob("*.part"))
+ assert not list(pathlib.Path(directory).glob("*.bak"))
+
+
+def test_uninspectable_backup_pin_reports_partial_destination(m):
+ """Rollback pin errors retain the original failure and name the new destination."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace = m.os.replace
+ real_pin_check = m._pinned_backup_still_has_one_link
+ first_backup_checks = 0
+
+ def fail_second_swap(source, destination):
+ if (pathlib.Path(source).suffix == ".part"
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ raise OSError("controlled later swap failure")
+ return real_replace(source, destination)
+
+ def fail_first_backup_pin_during_rollback(record):
+ nonlocal first_backup_checks
+ if pathlib.Path(record["path"]).name.startswith("first.xml."):
+ first_backup_checks += 1
+ if first_backup_checks == 2:
+ raise OSError("controlled backup pin inspection failure")
+ return real_pin_check(record)
+
+ m.os.replace = fail_second_swap
+ m._pinned_backup_still_has_one_link = fail_first_backup_pin_during_rollback
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled later swap failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m.os.replace = real_replace
+ m._pinned_backup_still_has_one_link = real_pin_check
+
+ assert first_backup_checks == 2
+ assert "controlled later swap failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ backup, = root.glob("first.xml.*.bak")
+ assert backup.read_text() == "first old"
+ assert not list(root.glob("*.part"))
+
+
+def test_staged_output_in_place_mutation_refuses_before_replacement(m):
+ """A stable staged inode still needs byte-for-byte ownership at commit."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+
+ def mutate_staged_after_backup(source_path, *args):
+ result = real_copy(source_path, *args)
+ if pathlib.Path(source_path).resolve() == destination.resolve():
+ staged, = root.glob("previous.xml.*.part")
+ staged.write_text("foreign staged bytes")
+ return result
+
+ m._copy_private_backup = mutate_staged_after_backup
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert "staged output changed before replacement" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert not list(root.glob("*.part"))
+ assert not list(root.glob("*.bak"))
+
+
+def test_fresh_output_in_place_mutation_refuses_at_final_authority_boundary(m):
+ """Fresh output bytes need the same pinned-content proof as staged bytes."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "fresh.xml"
+ real_changed = m._claimed_output_changed
+
+ def mutate_before_final_validation(supplied_path, canonical_path, identity):
+ if pathlib.Path(canonical_path).resolve() == destination.resolve():
+ destination.write_text("foreign output bytes")
+ return real_changed(supplied_path, canonical_path, identity)
+
+ m._claimed_output_changed = mutate_before_final_validation
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "owned output bytes")])
+ finally:
+ m._claimed_output_changed = real_changed
+
+ assert "output contents changed before commit" in str(refusal.code)
+ assert not destination.exists()
+ assert not list(root.iterdir())
+
+
+def test_uninspectable_rollback_destination_reports_partial_output(m):
+ """An unknown post-swap destination cannot be treated as a foreign inode."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace = m.os.replace
+ real_entry = m._entry_identity
+ first_entry_checks = 0
+
+ def fail_second_swap(source, destination):
+ if (pathlib.Path(source).suffix == ".part"
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ raise OSError("controlled later swap failure")
+ return real_replace(source, destination)
+
+ def deny_first_destination(path):
+ nonlocal first_entry_checks
+ if pathlib.Path(path).resolve() == first.resolve():
+ first_entry_checks += 1
+ if first_entry_checks == 2:
+ raise PermissionError(
+ "controlled rollback destination inspection failure")
+ return real_entry(path)
+
+ m.os.replace = fail_second_swap
+ m._entry_identity = deny_first_destination
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled later swap failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m.os.replace = real_replace
+ m._entry_identity = real_entry
+
+ assert "controlled later swap failure" in detail
+ assert first_entry_checks == 3, first_entry_checks
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ backup, = root.glob("first.xml.*.bak")
+ assert backup.read_text() == "first old"
+ assert not list(root.glob("*.part"))
+
+
+def test_vanished_staged_output_is_a_typed_refusal(m):
+ """A removed .part after backup creation must not leak FileNotFoundError."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+
+ def remove_staged_after_backup(source_path, *args):
+ result = real_copy(source_path, *args)
+ if pathlib.Path(source_path).resolve() == destination.resolve():
+ staged, = root.glob("previous.xml.*.part")
+ staged.unlink()
+ return result
+
+ m._copy_private_backup = remove_staged_after_backup
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert "staged output changed before replacement" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert not list(root.glob("*.part"))
+ assert not list(root.glob("*.bak"))
+
+
+def test_windows_modeled_alias_inspection_failure_is_retained_before_close(m):
+ """Windows cleanup cannot erase uncertainty about an uninspectable pin."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ path, alias = root / "fresh.xml", root / "fresh-alias.xml"
+ handle = m._open_private(path)
+ os.write(handle, b"generated statement")
+ record = m._owned_path(path, handle, created=True)
+ os.link(path, alias)
+ real_name, real_fstat = m.os.name, m.os.fstat
+
+ def deny_owned_pin(candidate):
+ if candidate == handle:
+ raise OSError("controlled Windows alias inspection failure")
+ return real_fstat(candidate)
+
+ failures = []
+ m.os.name, m.os.fstat = "nt", deny_owned_pin
+ try:
+ m._cleanup_owned_path(record, failures)
+ finally:
+ m.os.name, m.os.fstat = real_name, real_fstat
+
+ assert not path.exists()
+ assert alias.read_bytes() == b"generated statement"
+ assert failures == [
+ f"could not inspect owned output during cleanup: {path}"]
+
+
+def test_vanished_pending_backup_is_a_typed_refusal(m):
+ """A removed .bak after copy creation must not leak FileNotFoundError."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination = root / "previous.xml"
+ destination.write_text("old bytes")
+ real_copy = m._copy_private_backup
+
+ def remove_backup_after_copy(source_path, *args):
+ result = real_copy(source_path, *args)
+ if pathlib.Path(source_path).resolve() == destination.resolve():
+ backup, = root.glob("previous.xml.*.bak")
+ backup.unlink()
+ return result
+
+ m._copy_private_backup = remove_backup_after_copy
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+
+ assert "rollback copy changed before replacement" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert not list(root.glob("*.part"))
+ assert not list(root.glob("*.bak"))
+
+
+def test_moved_backup_with_foreign_destination_is_reported_unlocated(m):
+ """Foreign bytes stay intact while the pinned moved rollback copy is named."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, foreign, moved = (
+ root / "first.xml", root / "second.xml", root / "foreign.xml", root / "moved.bak")
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace = m.os.replace
+
+ def replace_first_then_move_backup_and_fail_second(source, destination):
+ source, destination = pathlib.Path(source), pathlib.Path(destination)
+ if source.suffix == ".part" and destination.resolve() == first.resolve():
+ result = real_replace(source, destination)
+ foreign.write_text("foreign writer bytes")
+ real_replace(foreign, first)
+ backup, = root.glob("first.xml.*.bak")
+ backup.rename(moved)
+ return result
+ if source.suffix == ".part" and destination.resolve() == second.resolve():
+ raise OSError("controlled later swap failure")
+ return real_replace(source, destination)
+
+ m.os.replace = replace_first_then_move_backup_and_fail_second
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled later swap failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m.os.replace = real_replace
+
+ assert "controlled later swap failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert "owned rollback copy could not be located after cleanup" in detail
+ assert first.read_text() == "foreign writer bytes"
+ assert second.read_text() == "second old"
+ assert moved.read_text() == "first old"
+ assert not list(root.glob("*.part"))
+ assert not list(root.glob("first.xml.*.bak"))
+
+
+def test_rollback_rechecks_destination_before_restoring_backup(m):
+ """A foreign writer between backup digest and restore keeps its own bytes."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, foreign = (
+ root / "first.xml", root / "second.xml", root / "foreign.xml")
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace, real_copy, real_digest = (
+ m.os.replace, m._copy_private_backup, m._digest_pinned_bytes)
+ first_backup_handles, replaced_foreign = set(), []
+
+ def remember_first_backup(source_path, identity, backup_handle):
+ result = real_copy(source_path, identity, backup_handle)
+ if pathlib.Path(source_path).resolve() == first.resolve():
+ first_backup_handles.add(backup_handle)
+ return result
+
+ def replace_first_then_fail_second(source, destination):
+ if (pathlib.Path(source).suffix == ".part"
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ raise OSError("controlled later swap failure")
+ return real_replace(source, destination)
+
+ def replace_foreign_during_rollback_digest(handle):
+ if handle in first_backup_handles and not replaced_foreign:
+ foreign.write_text("foreign writer bytes")
+ real_replace(foreign, first)
+ replaced_foreign.append(True)
+ return real_digest(handle)
+
+ m._copy_private_backup = remember_first_backup
+ m.os.replace = replace_first_then_fail_second
+ m._digest_pinned_bytes = replace_foreign_during_rollback_digest
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled later swap failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m._copy_private_backup = real_copy
+ m.os.replace = real_replace
+ m._digest_pinned_bytes = real_digest
+
+ assert replaced_foreign == [True]
+ assert "controlled later swap failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert first.read_text() == "foreign writer bytes"
+ assert second.read_text() == "second old"
+ backup, = root.glob("first.xml.*.bak")
+ assert backup.read_text() == "first old"
+ assert not list(root.glob("*.part"))
+
+
+def test_existing_commit_rechecks_moved_original_before_replacement(m):
+ """A renamed prior statement is disclosed instead of silently stranded."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, moved, foreign = (
+ root / "previous.xml", root / "moved.xml", root / "foreign.xml")
+ destination.write_text("old bytes")
+ real_pin_check, real_replace = m._pinned_original_still_has_one_link, m.os.replace
+
+ def move_after_original_pin(record):
+ result = real_pin_check(record)
+ destination.rename(moved)
+ foreign.write_text("foreign writer bytes")
+ real_replace(foreign, destination)
+ return result
+
+ m._pinned_original_still_has_one_link = move_after_original_pin
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._pinned_original_still_has_one_link = real_pin_check
+
+ detail = str(refusal.code)
+ assert "changed before replacement" in detail
+ assert "owned output could not be located after cleanup" in detail
+ assert destination.read_text() == "foreign writer bytes"
+ assert moved.read_text() == "old bytes"
+ assert not list(root.glob("*.part"))
+ assert not list(root.glob("*.bak"))
+
+
+def test_backup_refusal_survives_source_close_failure(m):
+ """A path-race refusal remains typed even when its local source pin fails close."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ source, backup = root / "previous.xml", root / "rollback.bak"
+ source.write_text("old bytes")
+ backup_handle = os.open(backup, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
+ identity = m._file_identity(source)
+ real_open, real_identity, real_close = (
+ m._open_regular_output, m._file_identity, m.os.close)
+ source_handles = []
+
+ def remember_source_handle(path, expected_identity):
+ handle = real_open(path, expected_identity)
+ source_handles.append(handle)
+ return handle
+
+ def reject_final_source_identity(path):
+ if pathlib.Path(path).resolve() == source.resolve():
+ return (0, 0)
+ return real_identity(path)
+
+ def fail_source_close(handle):
+ if handle in source_handles:
+ raise OSError("controlled source close failure")
+ return real_close(handle)
+
+ m._open_regular_output = remember_source_handle
+ m._file_identity = reject_final_source_identity
+ m.os.close = fail_source_close
+ try:
+ refusal = refuses(
+ m, "output_path_changed", m._copy_private_backup,
+ str(source), identity, backup_handle)
+ finally:
+ m._open_regular_output = real_open
+ m._file_identity = real_identity
+ m.os.close = real_close
+ for handle in source_handles:
+ try:
+ real_close(handle)
+ except OSError:
+ pass
+ real_close(backup_handle)
+
+ detail = str(refusal.code)
+ assert "changed while its rollback copy was prepared" in detail
+ assert "ownership descriptor close failed or could not be verified" in detail
+
+
+def test_regular_output_refusal_survives_pin_close_failure(m):
+ """Validation keeps its category when releasing the rejected pin fails."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ path, alias = root / "previous.xml", root / "previous-alias.xml"
+ path.write_text("old bytes")
+ os.link(path, alias)
+ real_open, real_close = m.os.open, m.os.close
+ opened = []
+
+ def remember_open(*args):
+ handle = real_open(*args)
+ opened.append(handle)
+ return handle
+
+ def fail_rejected_pin_close(handle):
+ if handle in opened:
+ raise OSError("controlled rejected-pin close failure")
+ return real_close(handle)
+
+ m.os.open, m.os.close = remember_open, fail_rejected_pin_close
+ try:
+ refusal = refuses(m, "output_has_multiple_links", m._open_regular_output, path)
+ finally:
+ m.os.open, m.os.close = real_open, real_close
+ for handle in opened:
+ try:
+ real_close(handle)
+ except OSError:
+ pass
+
+ detail = str(refusal.code)
+ assert "replacement requires a single-link output" in detail
+ assert "ownership descriptor close failed or could not be verified" in detail
+
+
+def test_uninspectable_post_failed_restore_reports_partial_destination(m):
+ """A failed restore with an unknown destination remains an in-band partial."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace, real_entry = m.os.replace, m._entry_identity
+ deny_after_restore_failure = False
+
+ def fail_second_swap_and_first_restore(source, destination):
+ nonlocal deny_after_restore_failure
+ source, destination = pathlib.Path(source), pathlib.Path(destination)
+ if source.suffix == ".part" and destination.resolve() == second.resolve():
+ raise OSError("controlled later swap failure")
+ if source.suffix == ".bak" and destination.resolve() == first.resolve():
+ deny_after_restore_failure = True
+ raise OSError("controlled restore failure before effect")
+ return real_replace(source, destination)
+
+ def deny_first_after_failed_restore(path):
+ if deny_after_restore_failure and pathlib.Path(path).resolve() == first.resolve():
+ raise OSError("controlled post-restore destination inspection failure")
+ return real_entry(path)
+
+ m.os.replace, m._entry_identity = (
+ fail_second_swap_and_first_restore, deny_first_after_failed_restore)
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled later swap failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m.os.replace, m._entry_identity = real_replace, real_entry
+
+ assert "controlled later swap failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ backup, = root.glob("first.xml.*.bak")
+ assert backup.read_text() == "first old"
+ assert not list(root.glob("*.part"))
+
+
+def test_transient_final_backup_inspection_reports_named_backup(m):
+ """One transient backup lstat failure still leaves its private name visible."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_entry = m._entry_identity
+ first_backup_checks = 0
+
+ def fail_only_final_first_backup_check(path):
+ nonlocal first_backup_checks
+ name = pathlib.Path(path).name
+ if name.startswith("first.xml.") and name.endswith(".bak"):
+ first_backup_checks += 1
+ if first_backup_checks == 2:
+ raise OSError("controlled transient final backup inspection failure")
+ return real_entry(path)
+
+ m._entry_identity = fail_only_final_first_backup_check
+ try:
+ refusal = refuses(
+ m, "output_path_changed", m.write_outputs,
+ [(str(first), "first new"), (str(second), "second new")])
+ finally:
+ m._entry_identity = real_entry
+
+ detail = str(refusal.code)
+ backup, = root.glob("first.xml.*.bak")
+ assert first_backup_checks == 3
+ assert "rollback copy changed before commit" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert str(backup) in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert backup.read_text() == "first old"
+ assert not list(root.glob("*.part"))
+
+
+def test_rollback_rechecks_backup_path_before_restoring(m):
+ """A retargeted backup source never overwrites the staged destination."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, foreign, moved = (
+ root / "first.xml", root / "second.xml", root / "foreign.bak", root / "moved.bak")
+ first.write_text("first old")
+ second.write_text("second old")
+ real_replace, real_entry = m.os.replace, m._entry_identity
+ first_destination_checks, retargeted = 0, []
+
+ def fail_second_swap(source, destination):
+ if (pathlib.Path(source).suffix == ".part"
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ raise OSError("controlled later swap failure")
+ return real_replace(source, destination)
+
+ def retarget_backup_after_rollback_destination_check(path):
+ nonlocal first_destination_checks
+ if pathlib.Path(path).resolve() == first.resolve():
+ first_destination_checks += 1
+ if first_destination_checks == 3:
+ backup, = root.glob("first.xml.*.bak")
+ backup.rename(moved)
+ foreign.write_text("foreign backup bytes")
+ real_replace(foreign, backup)
+ retargeted.append(backup)
+ return real_entry(path)
+
+ m.os.replace, m._entry_identity = fail_second_swap, retarget_backup_after_rollback_destination_check
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled later swap failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m.os.replace, m._entry_identity = real_replace, real_entry
+
+ assert first_destination_checks == 4 and retargeted
+ assert "controlled later swap failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert moved.read_text() == "first old"
+ assert retargeted[0].read_text() == "foreign backup bytes"
+ assert not list(root.glob("*.part"))
+
+
+def test_commit_rechecks_staged_path_before_replacement(m):
+ """A retargeted .part is refused instead of being installed as output."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, moved, foreign = (
+ root / "previous.xml", root / "moved.part", root / "foreign.part")
+ destination.write_text("old bytes")
+ real_pin_check, real_replace = m._pinned_original_still_has_one_link, m.os.replace
+ retargeted = []
+
+ def retarget_staged_after_original_check(record):
+ result = real_pin_check(record)
+ staged, = root.glob("previous.xml.*.part")
+ staged.rename(moved)
+ foreign.write_text("foreign staged bytes")
+ real_replace(foreign, staged)
+ retargeted.append(staged)
+ return result
+
+ m._pinned_original_still_has_one_link = retarget_staged_after_original_check
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._pinned_original_still_has_one_link = real_pin_check
+
+ assert retargeted
+ assert "staged output changed before replacement" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert moved.read_text() == "new bytes"
+ assert retargeted[0].read_text() == "foreign staged bytes"
+ assert not list(root.glob("*.bak"))
+
+
+def test_reclaimed_committed_backup_is_not_reported_as_its_old_path(m):
+ """A foreign claimant of a backup name is never an owned retained output."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ backup_path, moved, foreign = (
+ root / "old.bak", root / "moved.bak", root / "foreign.bak")
+ original_path, claimed_path = root / "old.xml", root / "new.xml"
+ for path in (backup_path, original_path, claimed_path):
+ path.write_text(path.name)
+ backup_fd, original_fd, claimed_fd = (
+ os.open(path, os.O_RDONLY) for path in
+ (backup_path, original_path, claimed_path))
+ backup, original, claimed = [
+ {"path": path, "identity": m._fd_identity(fd), "pin": fd}
+ for path, fd in zip((backup_path, original_path, claimed_path),
+ (backup_fd, original_fd, claimed_fd))]
+ backup_path.rename(moved)
+ foreign.write_text("foreign backup bytes")
+ os.replace(foreign, backup_path)
+ retained = []
+
+ m._reconcile_interrupted_committed_cleanup(
+ [{"backup": backup, "original": original}], [claimed], retained, [])
+
+ assert str(backup_path) not in retained
+ assert retained == [
+ f"owned output could not be located after cleanup: {backup_path}"]
+ assert backup_path.read_text() == "foreign backup bytes"
+ assert moved.read_text() == "old.bak"
+
+
+def test_uninspectable_post_swap_destination_retains_named_backup(m):
+ """An unknown post-swap destination keeps the inspectable backup visible."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, destination, backup = (
+ root / "original.xml", root / "destination.xml", root / "rollback.bak")
+ original.write_text("old bytes")
+ destination.write_text("new bytes")
+ backup.write_text("old bytes")
+ backup_fd = os.open(backup, os.O_RDONLY)
+ swap = {
+ "backup": {"path": backup, "identity": m._fd_identity(backup_fd),
+ "pin": backup_fd, "digest": m._digest_pinned_bytes(backup_fd)},
+ "destination": destination,
+ "original_identity": m._entry_identity(original),
+ "staged_identity": m._entry_identity(destination),
+ "metadata": None,
+ "swap_started": True,
+ "original": None,
+ }
+ real_entry = m._entry_identity
+
+ def deny_destination(path):
+ if pathlib.Path(path) == destination:
+ raise PermissionError("controlled post-swap inspection failure")
+ return real_entry(path)
+
+ m._entry_identity = deny_destination
+ try:
+ failures = []
+ assert m._restore_backup(swap, failures, []) == "unrollbackable"
+ finally:
+ m._entry_identity = real_entry
+ os.close(backup_fd)
+
+ assert failures == [backup]
+ assert backup.read_text() == "old bytes"
+ assert destination.read_text() == "new bytes"
+
+
+def test_interrupted_committed_cleanup_reconciles_moved_staged_output_pin(m):
+ """A committed .part pin is checked at its destination before close."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ staged, destination, moved = (
+ root / "output.xml.owned.part", root / "output.xml", root / "moved.xml")
+ staged.write_text("committed bytes")
+ pin = os.open(staged, os.O_RDONLY)
+ record = {
+ "path": staged,
+ "identity": m._fd_identity(pin),
+ "pin": pin,
+ "cleanup_path": staged,
+ "canonical_path": destination,
+ }
+ os.replace(staged, destination)
+ destination.rename(moved)
+ retained = []
+
+ m._reconcile_interrupted_committed_cleanup([], [record], retained, [])
+
+ assert retained == [
+ "staged output could not be located after cleanup: "
+ + str(destination)]
+ assert record["pin"] is None
+ assert moved.read_text() == "committed bytes"
+
+
+def test_zero_link_original_is_a_typed_path_change(m):
+ """An unlinked original is a move, never a multiple-link topology."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "previous.xml"
+ path.write_text("old bytes")
+ pin = os.open(path, os.O_RDONLY)
+ record = {"path": path, "identity": m._fd_identity(pin), "pin": pin}
+ try:
+ path.unlink()
+ refusal = refuses(m, "output_path_changed", m._pinned_original_still_has_one_link,
+ record)
+ finally:
+ os.close(pin)
+
+ assert "changed while its rollback copy was prepared" in str(refusal.code)
+
+
+def test_restored_output_close_recovers_after_effect_without_a_false_retention(m):
+ """A restored descriptor close may report after release without failing rollback."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, destination, backup = (
+ root / "original.xml", root / "destination.xml", root / "rollback.bak")
+ original.write_text("old bytes")
+ destination.write_text("new bytes")
+ backup.write_text("old bytes")
+ backup_fd = os.open(backup, os.O_RDONLY)
+ swap = {
+ "backup": {"path": backup, "identity": m._fd_identity(backup_fd),
+ "pin": backup_fd, "digest": m._digest_pinned_bytes(backup_fd)},
+ "destination": destination,
+ "original_identity": m._entry_identity(original),
+ "staged_identity": m._entry_identity(destination),
+ "metadata": {},
+ "swap_started": True,
+ "original": None,
+ }
+ real_open, real_close, real_restore = (
+ m._open_regular_output, m.os.close, m._restore_metadata)
+ restored_handles = []
+
+ def remember_restored_handle(*args):
+ handle = real_open(*args)
+ restored_handles.append(handle)
+ return handle
+
+ def close_then_report_failure(handle):
+ if handle in restored_handles:
+ real_close(handle)
+ raise OSError("controlled close failure after effect")
+ return real_close(handle)
+
+ m._open_regular_output = remember_restored_handle
+ m.os.close = close_then_report_failure
+ m._restore_metadata = lambda *_: None
+ try:
+ failures, descriptor_failures, restored = [], [], []
+ m._restore_backup(swap, failures, restored, descriptor_failures)
+ finally:
+ m._open_regular_output = real_open
+ m.os.close = real_close
+ m._restore_metadata = real_restore
+ os.close(backup_fd)
+
+ assert destination.read_text() == "old bytes"
+ assert failures == []
+ assert descriptor_failures == []
+ assert restored == [destination]
+
+
+def test_precommit_unrollbackable_staged_pin_is_reconciled_before_close(m):
+ """A partial staged output moved during recovery stays visible before close."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second, moved, foreign = (
+ root / "first.xml", root / "second.xml", root / "moved.xml", root / "foreign.xml")
+ first.write_text("first old")
+ second.write_text("second old")
+ real_copy, real_digest, real_reconcile, real_replace = (
+ m._copy_private_backup, m._digest_pinned_bytes,
+ m._reconcile_staged_output_pin, m.os.replace)
+ first_backup_pins, reconciled = set(), []
+
+ def fail_second_prepare(source_path, identity, backup_handle):
+ if pathlib.Path(source_path).resolve() == second.resolve():
+ raise OSError("controlled later preparation failure")
+ result = real_copy(source_path, identity, backup_handle)
+ first_backup_pins.add(backup_handle)
+ return result
+
+ def fail_first_backup_digest(handle):
+ if handle in first_backup_pins:
+ raise OSError("controlled rollback digest failure")
+ return real_digest(handle)
+
+ def move_partial_before_reconciliation(record, failures):
+ if pathlib.Path(record.get("canonical_path", "")).resolve() == first.resolve():
+ first.rename(moved)
+ foreign.write_text("foreign bytes")
+ real_replace(foreign, first)
+ reconciled.append(record)
+ return real_reconcile(record, failures)
+
+ m._copy_private_backup = fail_second_prepare
+ m._digest_pinned_bytes = fail_first_backup_digest
+ m._reconcile_staged_output_pin = move_partial_before_reconciliation
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled preparation failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m._copy_private_backup = real_copy
+ m._digest_pinned_bytes = real_digest
+ m._reconcile_staged_output_pin = real_reconcile
+
+ assert reconciled
+ assert "controlled later preparation failure" in detail
+ assert "staged output could not be located after cleanup" in detail
+ assert first.read_text() == "foreign bytes"
+ assert moved.read_text() == "first new"
+ assert second.read_text() == "second old"
+
+
+def test_close_owned_path_defers_sigint_until_the_pin_is_closed(m):
+ """The record cannot lose its fd ownership between clearing and close."""
+ if not hasattr(signal, "pthread_sigmask"):
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "output.xml"
+ path.write_text("bytes")
+ handle = os.open(path, os.O_RDONLY)
+
+ class InterruptOnClear(dict):
+ def __setitem__(self, key, value):
+ result = super().__setitem__(key, value)
+ if key == "pin" and value is None:
+ signal.raise_signal(signal.SIGINT)
+ return result
+
+ record = InterruptOnClear(path=path, identity=m._fd_identity(handle), pin=handle)
+ old_handler = signal.getsignal(signal.SIGINT)
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ try:
+ try:
+ m._close_owned_path(record, [])
+ raise AssertionError("the deferred SIGINT must escape after close")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ signal.signal(signal.SIGINT, old_handler)
+
+ assert record["pin"] is None
+ try:
+ os.fstat(handle)
+ raise AssertionError("the descriptor must close before the deferred interrupt")
+ except OSError as error:
+ assert error.errno == errno.EBADF
+
+
+def test_restore_revalidates_destination_after_metadata_before_reporting_success(m):
+ """Metadata work cannot turn a retargeted restored destination into success."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, destination, backup, moved, foreign = (
+ root / "original.xml", root / "destination.xml", root / "rollback.bak",
+ root / "moved.xml", root / "foreign.xml")
+ original.write_text("old bytes")
+ destination.write_text("new bytes")
+ backup.write_text("old bytes")
+ backup_fd = os.open(backup, os.O_RDONLY)
+ swap = {
+ "backup": {"path": backup, "identity": m._fd_identity(backup_fd),
+ "pin": backup_fd, "digest": m._digest_pinned_bytes(backup_fd)},
+ "destination": destination,
+ "original_identity": m._entry_identity(original),
+ "staged_identity": m._entry_identity(destination),
+ "metadata": {}, "swap_started": True, "original": None,
+ }
+ real_restore = m._restore_metadata
+
+ def retarget_after_metadata(*_):
+ destination.rename(moved)
+ foreign.write_text("foreign bytes")
+ os.replace(foreign, destination)
+
+ m._restore_metadata = retarget_after_metadata
+ try:
+ failures, restored = [], []
+ assert m._restore_backup(swap, failures, restored) == "unrollbackable"
+ finally:
+ m._restore_metadata = real_restore
+ os.close(backup_fd)
+
+ assert restored == []
+ assert failures == [
+ f"owned rollback copy could not be located after cleanup: {backup}"]
+ assert destination.read_text() == "foreign bytes"
+ assert moved.read_text() == "old bytes"
+
+
+def test_restore_reconciles_backup_when_post_metadata_destination_is_uninspectable(m):
+ """A failed post-metadata inspection is not evidence that restore held."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, destination, backup = (
+ root / "original.xml", root / "destination.xml", root / "rollback.bak")
+ original.write_text("old bytes")
+ destination.write_text("new bytes")
+ backup.write_text("old bytes")
+ backup_fd = os.open(backup, os.O_RDONLY)
+ swap = {
+ "backup": {"path": backup, "identity": m._fd_identity(backup_fd),
+ "pin": backup_fd, "digest": m._digest_pinned_bytes(backup_fd)},
+ "destination": destination,
+ "original_identity": m._entry_identity(original),
+ "staged_identity": m._entry_identity(destination),
+ "metadata": {}, "swap_started": True, "original": None,
+ }
+ real_restore, real_entry = m._restore_metadata, m._entry_identity
+ metadata_finished = False
+
+ def finish_metadata(*_):
+ nonlocal metadata_finished
+ metadata_finished = True
+
+ def deny_only_post_metadata_destination(path):
+ if metadata_finished and pathlib.Path(path) == destination:
+ raise PermissionError("controlled post-metadata inspection failure")
+ return real_entry(path)
+
+ m._restore_metadata, m._entry_identity = (
+ finish_metadata, deny_only_post_metadata_destination)
+ try:
+ failures = []
+ assert m._restore_backup(swap, failures, []) == "unrollbackable"
+ finally:
+ m._restore_metadata, m._entry_identity = real_restore, real_entry
+ os.close(backup_fd)
+
+ assert failures == [
+ f"owned rollback copy could not be located after cleanup: {backup}"]
+ assert destination.read_text() == "old bytes"
+
+
+def test_restore_open_refusal_reconciles_the_pinned_backup_once(m):
+ """A restored-open refusal reports the pinned backup, without a second path claim."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, destination, backup = (
+ root / "original.xml", root / "destination.xml", root / "rollback.bak")
+ original.write_text("old bytes")
+ destination.write_text("new bytes")
+ backup.write_text("old bytes")
+ backup_fd = os.open(backup, os.O_RDONLY)
+ swap = {
+ "backup": {"path": backup, "identity": m._fd_identity(backup_fd),
+ "pin": backup_fd, "digest": m._digest_pinned_bytes(backup_fd)},
+ "destination": destination,
+ "original_identity": m._entry_identity(original),
+ "staged_identity": m._entry_identity(destination),
+ "metadata": {}, "swap_started": True, "original": None,
+ }
+ real_open = m._open_regular_output
+
+ def refuse_restored_open(path, expected_identity=None):
+ if (pathlib.Path(path) == destination
+ and expected_identity == swap["backup"]["identity"]):
+ raise m.Refusal("output_path_changed", "controlled restored-open refusal")
+ return real_open(path, expected_identity)
+
+ m._open_regular_output = refuse_restored_open
+ try:
+ failures = []
+ assert m._restore_backup(swap, failures, []) == "unrollbackable"
+ finally:
+ m._open_regular_output = real_open
+ os.close(backup_fd)
+
+ assert failures == [
+ f"owned rollback copy could not be located after cleanup: {backup}"]
+ assert destination.read_text() == "old bytes"
+
+
+def test_restore_reports_alias_added_during_metadata(m):
+ """Metadata cannot add a restored-output alias without recovery disclosure."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, destination, backup, alias = (
+ root / "original.xml", root / "destination.xml", root / "rollback.bak",
+ root / "alias.xml")
+ original.write_text("old bytes")
+ destination.write_text("new bytes")
+ backup.write_text("old bytes")
+ backup_fd = os.open(backup, os.O_RDONLY)
+ swap = {
+ "backup": {"path": backup, "identity": m._fd_identity(backup_fd),
+ "pin": backup_fd, "digest": m._digest_pinned_bytes(backup_fd)},
+ "destination": destination,
+ "original_identity": m._entry_identity(original),
+ "staged_identity": m._entry_identity(destination),
+ "metadata": {}, "swap_started": True, "original": None,
+ }
+ real_restore = m._restore_metadata
+
+ def add_alias(*_):
+ os.link(destination, alias)
+
+ m._restore_metadata = add_alias
+ try:
+ failures = []
+ assert m._restore_backup(swap, failures, []) == "unrollbackable"
+ finally:
+ m._restore_metadata = real_restore
+ os.close(backup_fd)
+
+ assert failures == [
+ f"unknown hard-link alias may retain rollback bytes: {backup}"]
+ assert destination.read_text() == alias.read_text() == "old bytes"
+
+
+def test_close_owned_path_reconciles_failed_close_before_deferred_sigint(m):
+ """A deferred interrupt cannot preempt failed-close descriptor disclosure."""
+ if not hasattr(signal, "pthread_sigmask"):
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "output.xml"
+ path.write_text("bytes")
+ handle = os.open(path, os.O_RDONLY)
+
+ class InterruptOnClear(dict):
+ def __setitem__(self, key, value):
+ result = super().__setitem__(key, value)
+ if key == "pin" and value is None:
+ signal.raise_signal(signal.SIGINT)
+ return result
+
+ record = InterruptOnClear(path=path, identity=m._fd_identity(handle), pin=handle)
+ real_close = m.os.close
+
+ def fail_close(candidate):
+ if candidate == handle:
+ raise OSError("controlled close failure")
+ return real_close(candidate)
+
+ old_handler = signal.getsignal(signal.SIGINT)
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ m.os.close = fail_close
+ try:
+ descriptor_failures = []
+ try:
+ m._close_owned_path(record, [], descriptor_failures=descriptor_failures)
+ raise AssertionError("the deferred SIGINT must escape after reconciliation")
+ except KeyboardInterrupt:
+ pass
+ finally:
+ m.os.close = real_close
+ signal.signal(signal.SIGINT, old_handler)
+ os.close(handle)
+
+ assert record["pin"] is None
+ assert descriptor_failures == [str(path)]
+
+
+def test_final_output_pin_fstat_failure_is_a_typed_path_change(m):
+ """The final output ownership check cannot leak a raw descriptor error."""
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "output.xml"
+ real_owned_path, real_fstat = m._owned_path, m.os.fstat
+ owned_pins = set()
+
+ def arm_final_pin_failure(*args, **kwargs):
+ record = real_owned_path(*args, **kwargs)
+ owned_pins.add(record["pin"])
+ m.os.fstat = fail_final_pin_fstat
+ return record
+
+ def fail_final_pin_fstat(handle):
+ if handle in owned_pins:
+ m.os.fstat = real_fstat
+ raise OSError("controlled final ownership fstat failure")
+ return real_fstat(handle)
+
+ m._owned_path = arm_final_pin_failure
+ try:
+ refusal = refuses(
+ m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._owned_path = real_owned_path
+ m.os.fstat = real_fstat
+
+ assert "ownership could not be verified" in str(refusal.code)
+ assert not destination.exists()
+
+
+def test_backup_source_disappearance_is_typed_and_reconciles_the_original_pin(m):
+ """A source lost before backup open keeps its moved original visible."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, moved = root / "previous.xml", root / "moved.xml"
+ destination.write_text("old bytes")
+ real_open = m._open_regular_output
+
+ def remove_before_backup_source_open(path, expected_identity=None):
+ if (expected_identity is not None
+ and pathlib.Path(path).resolve() == destination.resolve()):
+ destination.rename(moved)
+ return real_open(path, expected_identity)
+
+ m._open_regular_output = remove_before_backup_source_open
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._open_regular_output = real_open
+
+ detail = str(refusal.code)
+ assert "disappeared before its rollback source could be opened" in detail
+ assert "owned output could not be located after cleanup" in detail
+ assert not destination.exists()
+ assert moved.read_text() == "old bytes"
+ assert not list(root.glob("*.part"))
+ assert not list(root.glob("*.bak"))
+
+
+def test_backup_source_open_permission_is_typed_and_reconciles_the_original_pin(m):
+ """A denied rollback-source open remains typed and retains a moved old file."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ destination, moved = root / "previous.xml", root / "moved.xml"
+ destination.write_text("old bytes")
+ real_open = m._open_regular_output
+
+ def deny_after_moving_source(path, expected_identity=None):
+ if (expected_identity is not None
+ and pathlib.Path(path).resolve() == destination.resolve()):
+ destination.rename(moved)
+ raise PermissionError("controlled rollback-source open denial")
+ return real_open(path, expected_identity)
+
+ m._open_regular_output = deny_after_moving_source
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._open_regular_output = real_open
+
+ detail = str(refusal.code)
+ assert "could not be opened as its rollback source" in detail
+ assert "owned output could not be located after cleanup" in detail
+ assert moved.read_text() == "old bytes"
+ assert not destination.exists()
+ assert not list(root.glob("*.part"))
+ assert not list(root.glob("*.bak"))
+
+
+def test_backup_source_read_error_is_not_retyped_as_an_open_failure(m):
+ """Only source open errors are typed; later copy I/O retains its OSError."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "previous.xml"
+ destination.write_text("old bytes")
+ real_open, real_read = m._open_regular_output, m.os.read
+ source_pins = set()
+
+ def remember_source_pin(path, expected_identity=None):
+ handle = real_open(path, expected_identity)
+ if expected_identity is not None:
+ source_pins.add(handle)
+ return handle
+
+ def fail_copy_read(handle, size):
+ if handle in source_pins:
+ raise OSError("controlled rollback-source read failure")
+ return real_read(handle, size)
+
+ m._open_regular_output, m.os.read = remember_source_pin, fail_copy_read
+ try:
+ try:
+ m.write_outputs([(str(destination), "new bytes")])
+ raise AssertionError("the controlled copy read failure must escape")
+ except OSError as error:
+ assert str(error) == "controlled rollback-source read failure"
+ finally:
+ m._open_regular_output, m.os.read = real_open, real_read
+
+ assert destination.read_text() == "old bytes"
+
+
+def test_pre_replacement_backup_pin_fstat_failure_is_a_typed_path_change(m):
+ """A backup descriptor inspection error cannot escape as an OSError."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "previous.xml"
+ destination.write_text("old bytes")
+ real_copy, real_fstat = m._copy_private_backup, m.os.fstat
+ backup_pins = set()
+
+ def arm_backup_pin_failure(source_path, identity, backup_handle):
+ result = real_copy(source_path, identity, backup_handle)
+ backup_pins.add(backup_handle)
+ m.os.fstat = fail_backup_pin_fstat
+ return result
+
+ def fail_backup_pin_fstat(handle):
+ if handle in backup_pins:
+ m.os.fstat = real_fstat
+ raise OSError("controlled backup pin fstat failure")
+ return real_fstat(handle)
+
+ m._copy_private_backup = arm_backup_pin_failure
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._copy_private_backup = real_copy
+ m.os.fstat = real_fstat
+
+ assert "rollback copy ownership could not be verified" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert not list(pathlib.Path(directory).glob("*.part"))
+ assert not list(pathlib.Path(directory).glob("*.bak"))
+
+
+def test_pre_replacement_original_pin_fstat_failure_is_a_typed_path_change(m):
+ """An original descriptor inspection error cannot escape as an OSError."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ destination = pathlib.Path(directory) / "previous.xml"
+ destination.write_text("old bytes")
+ real_open, real_copy, real_fstat = (
+ m._open_regular_output, m._copy_private_backup, m.os.fstat)
+ original_pins = set()
+
+ def remember_original_pin(path, expected_identity=None):
+ handle = real_open(path, expected_identity)
+ if (expected_identity is None
+ and pathlib.Path(path).resolve() == destination.resolve()):
+ original_pins.add(handle)
+ return handle
+
+ def arm_original_pin_failure(*args):
+ result = real_copy(*args)
+ m.os.fstat = fail_original_pin_fstat
+ return result
+
+ def fail_original_pin_fstat(handle):
+ if handle in original_pins:
+ m.os.fstat = real_fstat
+ raise OSError("controlled original pin fstat failure")
+ return real_fstat(handle)
+
+ m._open_regular_output = remember_original_pin
+ m._copy_private_backup = arm_original_pin_failure
+ try:
+ refusal = refuses(m, "output_path_changed", m.write_outputs,
+ [(str(destination), "new bytes")])
+ finally:
+ m._open_regular_output = real_open
+ m._copy_private_backup = real_copy
+ m.os.fstat = real_fstat
+
+ assert "original ownership could not be verified" in str(refusal.code)
+ assert destination.read_text() == "old bytes"
+ assert not list(pathlib.Path(directory).glob("*.part"))
+ assert not list(pathlib.Path(directory).glob("*.bak"))
+
+
+def test_failed_restore_rechecks_destination_before_partial_report(m):
+ """A foreign destination installed during failed restore is never overwritten."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ original, destination, backup, foreign = (
+ root / "original.xml", root / "destination.xml", root / "rollback.bak",
+ root / "foreign.xml")
+ original.write_text("old bytes")
+ destination.write_text("staged bytes")
+ backup.write_text("old bytes")
+ backup_fd = os.open(backup, os.O_RDONLY)
+ swap = {
+ "backup": {"path": backup, "identity": m._fd_identity(backup_fd),
+ "pin": backup_fd, "digest": m._digest_pinned_bytes(backup_fd)},
+ "destination": destination,
+ "original_identity": m._entry_identity(original),
+ "staged_identity": m._entry_identity(destination),
+ "metadata": None, "swap_started": True, "original": None,
+ }
+ real_replace = m.os.replace
+
+ def replace_destination_then_fail(source, target):
+ if pathlib.Path(source) == backup and pathlib.Path(target) == destination:
+ foreign.write_text("foreign bytes")
+ real_replace(foreign, destination)
+ raise OSError("controlled restore failure after foreign replacement")
+ return real_replace(source, target)
+
+ m.os.replace = replace_destination_then_fail
+ try:
+ failures = []
+ assert m._restore_backup(swap, failures, []) == "unrollbackable"
+ finally:
+ m.os.replace = real_replace
+ os.close(backup_fd)
+
+ assert failures == [backup]
+ assert destination.read_text() == "foreign bytes"
+ assert backup.read_text() == "old bytes"
+
+
+def test_final_rollback_backup_pin_fstat_failure_is_typed_and_conservative(m):
+ """The post-swap rollback pin check cannot replace the original failure."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ first, second = root / "first.xml", root / "second.xml"
+ first.write_text("first old")
+ second.write_text("second old")
+ real_copy, real_check, real_fstat, real_replace = (
+ m._copy_private_backup, m._pinned_backup_still_has_one_link,
+ m.os.fstat, m.os.replace)
+ first_backup_pins, armed, failed = set(), False, []
+
+ def remember_first_backup(source_path, identity, backup_handle):
+ result = real_copy(source_path, identity, backup_handle)
+ if pathlib.Path(source_path).resolve() == first.resolve():
+ first_backup_pins.add(backup_handle)
+ return result
+
+ def fail_final_backup_fstat(handle):
+ if handle in first_backup_pins:
+ failed.append(handle)
+ m.os.fstat = real_fstat
+ raise OSError("controlled final rollback backup-pin fstat failure")
+ return real_fstat(handle)
+
+ def arm_after_pre_replacement_check(record):
+ nonlocal armed
+ result = real_check(record)
+ if record.get("pin") in first_backup_pins and not armed:
+ armed = True
+ m.os.fstat = fail_final_backup_fstat
+ return result
+
+ def fail_second_swap(source, destination):
+ if (pathlib.Path(source).suffix == ".part"
+ and pathlib.Path(destination).resolve() == second.resolve()):
+ raise OSError("controlled later swap failure")
+ return real_replace(source, destination)
+
+ m._copy_private_backup = remember_first_backup
+ m._pinned_backup_still_has_one_link = arm_after_pre_replacement_check
+ m.os.replace = fail_second_swap
+ try:
+ try:
+ m.write_outputs([(str(first), "first new"), (str(second), "second new")])
+ raise AssertionError("the controlled later swap failure must escape")
+ except OSError as error:
+ detail = str(error) + "\n" + "\n".join(getattr(error, "__notes__", []))
+ finally:
+ m._copy_private_backup = real_copy
+ m._pinned_backup_still_has_one_link = real_check
+ m.os.fstat = real_fstat
+ m.os.replace = real_replace
+
+ backup, = root.glob("first.xml.*.bak")
+ assert armed and failed
+ assert "controlled later swap failure" in detail
+ assert "partially committed output could not be rolled back" in detail
+ assert str(first.resolve()) in detail
+ assert str(backup) in detail
+ assert first.read_text() == "first new"
+ assert second.read_text() == "second old"
+ assert backup.read_text() == "first old"
+ assert not list(root.glob("*.part"))
+
+
+def test_zero_link_unrollbackable_staged_pin_is_removed_not_retained(m):
+ """An unlinked staged inode has no foreign destination to disclose."""
+ if os.name == "nt":
+ return
+ with tempfile.TemporaryDirectory() as directory:
+ root = pathlib.Path(directory)
+ staged, destination = root / "staged.part", root / "output.xml"
+ staged.write_text("staged bytes")
+ pin = os.open(staged, os.O_RDONLY)
+ record = {
+ "path": staged, "cleanup_path": root / "other.part",
+ "canonical_path": destination, "identity": m._fd_identity(pin), "pin": pin,
+ }
+ staged.unlink()
+ try:
+ failures = []
+ m._reconcile_staged_output_pin(record, failures)
+ finally:
+ os.close(pin)
+
+ assert failures == []
+ assert not destination.exists()
+
if __name__ == "__main__":
raise SystemExit(main())