From c8dcae2b3a7c98cc6417ea828cfd154636ab25e0 Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Sat, 1 Aug 2026 04:48:06 +0800 Subject: [PATCH] fix: make mirror publication crash-recoverable --- CHANGELOG.md | 14 ++ docs/releasing.md | 74 +++++++- scripts/mirror-receiver.py | 186 ++++++++++++++++---- scripts/mirror_receiver_test.py | 291 ++++++++++++++++++++++++++++++++ 4 files changed, 529 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ef103..afab9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,20 @@ All notable changes to this project are documented here. same-name NSS identity or a mailbox recreated during durability confirmation now stops cleanup and retains the recovery witness instead of being reported as a completed deletion. +- Make immutable mirror-file publication crash-recoverable: commit each missing + version file with Linux `renameat2(RENAME_NOREPLACE)` instead of a transient + two-link state, and recover only strictly named, owner/mode/size/link-checked + private temporaries under the deployment lock. This also repairs the exact + double-hard-link state an interrupted older receiver could leave. Create new + version directories, private temporaries, incoming stages, and the deployment + lock with their final safe modes so even an extreme process umask cannot leave + an unrecoverable crash state. Require an actual no-replace probe on the mirror + document-root filesystem before receiver installation, and repeat the parent + directory sync when resuming a visible version directory whose original + `mkdir` may not have become durable. Reject release and stable metadata below + v2 consistently, including when selecting a stable installer from complete + version directories; compare arbitrary-length numeric components without an + interpreter-dependent integer conversion. ## v2.9.1 - 2026-08-01 diff --git a/docs/releasing.md b/docs/releasing.md index 5a5b219..0807ae6 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -906,6 +906,10 @@ been revalidated: - `/www/wwwroot/dl.ll.cd/linux-temp-admin` is owned `ltamirror:www`, mode `0755`. Its parent directories remain root-owned and not group/world writable. +- The mirror host's Linux kernel and C library expose + `renameat2(RENAME_NOREPLACE)`, and an actual no-replace probe succeeds on the + filesystem containing the document root; immutable version-file publication + fails closed if that primitive is unavailable there. - [`deploy/nginx/linux-temp-admin.conf`](../deploy/nginx/linux-temp-admin.conf) is installed as `/www/server/panel/vhost/nginx/extension/dl.ll.cd/linux-temp-admin.conf`, @@ -929,8 +933,25 @@ complete staged checksum set. It rejects reads, traversal, arbitrary rsync modes, links, special files, and every other destination. Stable `install.sh` must match a complete non-prerelease version, and canonical `latest.json` is published last. The receiver refuses a stable downgrade or altered metadata for -the current version. The client-side `--ignore-existing` flag is not itself an -immutability boundary. +the current version, and both version uploads and stable selection reject tags +below v2. The client-side `--ignore-existing` flag is not itself an immutability +boundary. + +Each missing immutable file is copied to a same-directory private temporary, +synced, and committed with `renameat2(RENAME_NOREPLACE)` before the directory is +synced. A crash therefore leaves either the final name or a private temporary, +never a newly created two-link commit state. Under the persistent deployment +lock, a retry removes only a temporary whose exact name, owner, mode, size, and +link count match the receiver's private format. It also recognizes and repairs +the exact final-name hard link left by the older `link`/`unlink` implementation; +an unexpected link target or any other extra path still fails closed. New +version directories are created atomically with mode `0755`, and private +temporaries, incoming stages, and the deployment lock likewise receive their +final safe modes at creation independent of the receiver process umask. An +interruption therefore cannot strand a more restrictive object that retries +would reject. Every invocation also syncs the document root after accepting or +creating the version directory, so a retry makes an interrupted `mkdir` durable +before it publishes files inside that directory. The Nginx include claims the complete `/linux-temp-admin/` namespace before the virtual host's generic regular-expression locations. It serves only the two @@ -954,7 +975,9 @@ set -Eeuo pipefail python3 -B -m unittest -v scripts/mirror_receiver_test.py receiver_dir=/usr/local/libexec receiver_path="$receiver_dir/linux-temp-admin-mirror-receiver" +project_root=/www/wwwroot/dl.ll.cd/linux-temp-admin [[ "$(sudo stat -Lc '%F %U %G %a' -- "$receiver_dir")" == 'directory root root 755' ]] +[[ "$(sudo stat -Lc '%F %U %G %a' -- "$project_root")" == 'directory ltamirror www 755' ]] receiver_tmp="$(sudo mktemp "$receiver_dir/.linux-temp-admin-mirror-receiver.XXXXXXXXXX")" cleanup_receiver_tmp() { if [[ -n "$receiver_tmp" ]] && sudo test -e "$receiver_tmp"; then @@ -964,6 +987,48 @@ cleanup_receiver_tmp() { trap cleanup_receiver_tmp EXIT sudo install -o root -g root -m 0755 -- scripts/mirror-receiver.py "$receiver_tmp" [[ "$(sudo stat -Lc '%F %U %G %a' -- "$receiver_tmp")" == 'regular file root root 755' ]] +sudo -u ltamirror -- python3 -B - "$receiver_tmp" "$project_root" <<'PY' +import importlib.util +import os +from pathlib import Path +import sys +import tempfile + +source_path, project_path = sys.argv[1:] +spec = importlib.util.spec_from_file_location("mirror_receiver_probe", source_path) +if spec is None or spec.loader is None: + raise SystemExit("cannot load candidate mirror receiver") +receiver = importlib.util.module_from_spec(spec) +spec.loader.exec_module(receiver) +project = Path(project_path) +with receiver.unmasked_creation(): + probe = Path(tempfile.mkdtemp(prefix=".renameat2-probe-", dir=project)) +source = probe / "source" +destination = probe / "destination" +try: + source.write_bytes(b"first") + receiver.rename_noreplace(source, destination) + if source.exists() or destination.read_bytes() != b"first": + raise SystemExit("successful no-replace rename had an unexpected result") + source.write_bytes(b"second") + try: + receiver.rename_noreplace(source, destination) + except receiver.ReceiverError as exc: + if "appeared concurrently" not in str(exc): + raise + else: + raise SystemExit("no-replace rename overwrote an existing destination") + if source.read_bytes() != b"second" or destination.read_bytes() != b"first": + raise SystemExit("failed no-replace rename changed file bytes") +finally: + for path in (source, destination): + try: + path.unlink() + except FileNotFoundError: + pass + probe.rmdir() + receiver.fsync_directory(project) +PY sudo sync -f "$receiver_tmp" sudo mv -fT -- "$receiver_tmp" "$receiver_path" receiver_tmp= @@ -983,8 +1048,9 @@ reload, and keep the release gate closed. Recovery is deliberately narrow: 1. If a transfer stopped before all six immutable files arrived, dispatch the - mirror workflow again for the same immutable GitHub tag. The receiver checks - every existing byte and fills only the missing files. + mirror workflow again for the same immutable GitHub tag. Under the deployment + lock, the receiver first recovers only its strictly validated private commit + state, then checks every existing release byte and fills only missing files. 2. If `install.sh` changed but `latest.json` did not, rerun the current GitHub Latest tag. The workflow republishes the installer first and the manifest last; clients remain signature-verified during the interrupted state. diff --git a/scripts/mirror-receiver.py b/scripts/mirror-receiver.py index 57c8ce8..fa9a8e4 100644 --- a/scripts/mirror-receiver.py +++ b/scripts/mirror-receiver.py @@ -3,7 +3,11 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import contextmanager +import ctypes import datetime as dt +import errno import fcntl import hashlib import json @@ -49,6 +53,21 @@ "linux-temp-admin-linux-arm64.sig", ) STABLE_FILES = ("install.sh", "latest.json") +VersionKey = tuple[tuple[int, str], tuple[int, str], tuple[int, str]] +AT_FDCWD = -100 +RENAME_NOREPLACE = 1 + +LIBC = ctypes.CDLL(None, use_errno=True) +RENAMEAT2 = getattr(LIBC, "renameat2", None) +if RENAMEAT2 is not None: + RENAMEAT2.argtypes = ( + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ) + RENAMEAT2.restype = ctypes.c_int class ReceiverError(RuntimeError): @@ -59,6 +78,35 @@ def fail(message: str) -> None: raise ReceiverError(message) +@contextmanager +def unmasked_creation() -> Iterator[None]: + # The receiver is single-threaded. Set exact modes at creation so a crash + # cannot preserve an object whose permissions were reduced by the caller's + # umask before the following metadata validation runs. + previous_umask = os.umask(0) + try: + yield + finally: + os.umask(previous_umask) + + +def release_version_tuple( + tag: str, *, stable: bool +) -> VersionKey: + match = VERSION_PATTERN.fullmatch(tag) + if match is None or (stable and match.group(4) is not None): + kind = "stable metadata" if stable else "release destination" + fail(f"{kind} must name a canonical {'non-prerelease ' if stable else ''}tag") + major = match.group(1) + if major in ("0", "1"): + fail("release versions below v2 are not accepted") + # Canonical numeric components have no leading zeroes, so length followed by + # lexical bytes is arbitrary-precision numeric order without Python's + # interpreter-dependent decimal-to-int digit limit. + components = tuple(match.group(index) for index in range(1, 4)) + return tuple((len(component), component) for component in components) + + def lstat(path: Path) -> os.stat_result: try: return path.lstat() @@ -159,12 +207,9 @@ def parse_request(command: str) -> tuple[str, str]: expected_long_options = ["--delay-updates"] else: version = destination[:-1] if destination.endswith("/") else destination - version_match = VERSION_PATTERN.fullmatch(version) - if version_match is None: + if VERSION_PATTERN.fullmatch(version) is None: fail("destination is not an allowed stable file or canonical version directory") - major = version_match.group(1) - if len(major) == 1 and int(major) < 2: - fail("release versions below v2 are not accepted") + release_version_tuple(version, stable=False) if destination != f"{version}/": fail("version uploads must be directory-scoped") request_type = "version" @@ -234,11 +279,10 @@ def validate_version(directory: Path, *, owner: int, published: bool = False) -> fail(f"published release file mode is not 0644: {directory / name}") -def stable_version_tuple(tag: str) -> tuple[int, int, int]: - match = VERSION_PATTERN.fullmatch(tag) - if match is None or match.group(4) is not None: - fail("stable metadata must name a canonical non-prerelease tag") - return tuple(int(match.group(index)) for index in range(1, 4)) +def stable_version_tuple( + tag: str, +) -> VersionKey: + return release_version_tuple(tag, stable=True) def parse_latest(path: Path, *, owner: int) -> tuple[dict[str, str], bytes]: @@ -288,7 +332,7 @@ def validate_latest(path: Path, *, owner: int, project_root: Path) -> dict[str, def current_latest_state( project_root: Path, *, owner: int -) -> tuple[tuple[int, int, int], bytes] | None: +) -> tuple[VersionKey, bytes] | None: latest = project_root / "latest.json" if not latest.exists() and not latest.is_symlink(): return None @@ -313,13 +357,79 @@ def fsync_directory(path: Path) -> None: os.close(descriptor) +def rename_noreplace(source: Path, destination: Path) -> None: + if RENAMEAT2 is None: + fail("renameat2(RENAME_NOREPLACE) is unavailable on the mirror host") + ctypes.set_errno(0) + result = RENAMEAT2( + AT_FDCWD, + os.fsencode(source), + AT_FDCWD, + os.fsencode(destination), + RENAME_NOREPLACE, + ) + if result != 0: + error_number = ctypes.get_errno() + if error_number == errno.EEXIST: + fail(f"immutable release file appeared concurrently: {destination}") + fail( + "cannot atomically publish immutable release file " + f"{destination}: {os.strerror(error_number)}" + ) + + +def cleanup_private_temps(directory: Path, labels: tuple[str, ...], *, owner: int) -> None: + patterns = { + label: re.compile(rf"[.]mirror-{re.escape(label)}-[0-9a-f]{{32}}") + for label in labels + } + cleaned = False + with os.scandir(directory) as entries: + for entry in entries: + label = next( + (name for name, pattern in patterns.items() if pattern.fullmatch(entry.name)), + None, + ) + if label is None: + continue + path = directory / entry.name + info = lstat(path) + maximum = MAX_BINARY_BYTES if label in ( + "linux-temp-admin-linux-amd64", + "linux-temp-admin-linux-arm64", + ) else MAX_METADATA_BYTES + if ( + not stat.S_ISREG(info.st_mode) + or path.is_symlink() + or info.st_uid != owner + or stat.S_IMODE(info.st_mode) not in (0o600, 0o644) + or info.st_size < 0 + or info.st_size > maximum + or info.st_nlink not in (1, 2) + ): + fail(f"unsafe stale mirror temporary file: {path}") + if info.st_nlink == 2: + destination_info = lstat(directory / label) + if ( + not stat.S_ISREG(destination_info.st_mode) + or destination_info.st_dev != info.st_dev + or destination_info.st_ino != info.st_ino + ): + fail(f"stale mirror temporary file has an unexpected hard link: {path}") + path.unlink() + cleaned = True + if cleaned: + fsync_directory(directory) + + def copy_to_private_temp(source: Path, directory: Path, label: str) -> Path: temporary = directory / f".mirror-{label}-{secrets.token_hex(16)}" - descriptor = os.open( - temporary, - os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, - 0o600, - ) + with unmasked_creation(): + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) try: with source.open("rb", buffering=0) as source_handle, os.fdopen( descriptor, "wb", buffering=0, closefd=False @@ -340,12 +450,14 @@ def copy_to_private_temp(source: Path, directory: Path, label: str) -> Path: def publish_version(staged: Path, destination: Path, *, owner: int) -> None: + release_version_tuple(destination.name, stable=False) validate_version(staged, owner=owner) for name in EXPECTED_VERSION_FILES: os.chmod(staged / name, 0o644, follow_symlinks=False) fsync_file(staged / name) if destination.exists() or destination.is_symlink(): require_directory(destination, owner=owner, mode=0o755) + cleanup_private_temps(destination, EXPECTED_VERSION_FILES, owner=owner) existing = sorted(entry.name for entry in os.scandir(destination)) if not set(existing).issubset(EXPECTED_VERSION_FILES): fail(f"version directory contains a non-release path: {destination}") @@ -365,20 +477,21 @@ def publish_version(staged: Path, destination: Path, *, owner: int) -> None: ) else: try: - os.mkdir(destination, 0o755) + with unmasked_creation(): + os.mkdir(destination, 0o755) except FileExistsError: fail(f"version destination appeared concurrently: {destination}") - os.chmod(destination, 0o755, follow_symlinks=False) require_directory(destination, owner=owner, mode=0o755) - fsync_directory(destination.parent) + # Repeat this for an existing directory too: it may be the visible but not + # yet durable result of an interrupted mkdir from the previous invocation. + fsync_directory(destination.parent) try: for name in EXPECTED_VERSION_FILES: if (destination / name).exists() or (destination / name).is_symlink(): continue temporary = copy_to_private_temp(staged / name, destination, name) try: - os.link(temporary, destination / name, follow_symlinks=False) - temporary.unlink() + rename_noreplace(temporary, destination / name) fsync_directory(destination) finally: try: @@ -387,21 +500,25 @@ def publish_version(staged: Path, destination: Path, *, owner: int) -> None: pass validate_version(destination, owner=owner, published=True) except BaseException: - # Valid files already linked into an incomplete version are deliberately - # retained. A retry may fill the missing files but can never replace one. + # Valid files already committed into an incomplete version are deliberately + # retained. A retry may fill the missing files but can never replace one; + # narrowly validated private temporaries from an interrupted commit are + # removed under the deployment lock before that retry validates the directory. raise fsync_directory(destination) def matching_stable_installer_versions( project_root: Path, installer: Path, *, owner: int -) -> list[tuple[int, int, int]]: - matches: list[tuple[int, int, int]] = [] +) -> list[VersionKey]: + matches: list[VersionKey] = [] with os.scandir(project_root) as entries: for entry in entries: - version_match = VERSION_PATTERN.fullmatch(entry.name) - if (not entry.is_dir(follow_symlinks=False) or version_match is None - or version_match.group(4) is not None): + if not entry.is_dir(follow_symlinks=False): + continue + try: + version = stable_version_tuple(entry.name) + except ReceiverError: continue version_dir = project_root / entry.name try: @@ -409,7 +526,7 @@ def matching_stable_installer_versions( except ReceiverError: continue if files_equal(installer, version_dir / "install.sh"): - matches.append(tuple(int(version_match.group(index)) for index in range(1, 4))) + matches.append(version) return matches @@ -417,6 +534,7 @@ def atomic_replace(staged: Path, destination: Path, *, project_root: Path, owner require_regular(staged, owner=owner, maximum=MAX_METADATA_BYTES) os.chmod(staged, 0o644, follow_symlinks=False) fsync_file(staged) + cleanup_private_temps(project_root, (destination.name,), owner=owner) if destination.exists() or destination.is_symlink(): require_regular(destination, owner=owner, maximum=MAX_METADATA_BYTES) temporary = copy_to_private_temp(staged, project_root, destination.name) @@ -490,7 +608,10 @@ def run_rrsync(stage: Path, original_command: str) -> None: def open_lock(path: Path, *, owner: int) -> int: - descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600) + with unmasked_creation(): + descriptor = os.open( + path, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600 + ) info = os.fstat(descriptor) if (not stat.S_ISREG(info.st_mode) or info.st_uid != owner or stat.S_IMODE(info.st_mode) != 0o600): @@ -510,8 +631,9 @@ def main() -> int: original_command = os.environ.get("SSH_ORIGINAL_COMMAND", "") request_type, destination = parse_request(original_command) - stage = Path(tempfile.mkdtemp(prefix="transfer-", dir=INCOMING_ROOT)) - os.chmod(stage, 0o700) + with unmasked_creation(): + stage = Path(tempfile.mkdtemp(prefix="transfer-", dir=INCOMING_ROOT)) + require_directory(stage, owner=owner, mode=0o700) try: run_rrsync(stage, original_command) root_entries = sorted(entry.name for entry in os.scandir(stage)) diff --git a/scripts/mirror_receiver_test.py b/scripts/mirror_receiver_test.py index d2ea923..c3bf034 100644 --- a/scripts/mirror_receiver_test.py +++ b/scripts/mirror_receiver_test.py @@ -86,6 +86,14 @@ def test_rejects_unsafe_or_mutable_version_commands(self) -> None: with self.subTest(command=command), self.assertRaises(mirror_receiver.ReceiverError): mirror_receiver.parse_request(command) + def test_orders_unbounded_version_components_without_integer_conversion(self) -> None: + smaller = "v2." + "9" * 4999 + ".0" + larger = "v2." + "9" * 5000 + ".0" + self.assertGreater( + mirror_receiver.release_version_tuple(larger, stable=False), + mirror_receiver.release_version_tuple(smaller, stable=False), + ) + class TrustedExecutableTests(unittest.TestCase): def test_requires_canonical_owned_nonwritable_executable(self) -> None: @@ -141,6 +149,148 @@ def test_version_is_create_only_idempotent_and_repairable(self) -> None: (destination / "linux-temp-admin-linux-amd64").read_bytes(), b"amd64-binary" ) + def test_version_recovers_interrupted_private_commit_states(self) -> None: + staged = make_version(self.temporary / "interrupted") + destination = self.project / "v2.8.0" + destination.mkdir(mode=0o755) + + partial = destination / ( + ".mirror-linux-temp-admin-linux-amd64-" + "0" * 32 + ) + partial.write_bytes(b"partial") + partial.chmod(0o600) + + linked = destination / ( + ".mirror-linux-temp-admin-linux-arm64.sig-" + "1" * 32 + ) + linked.write_bytes((staged / "linux-temp-admin-linux-arm64.sig").read_bytes()) + linked.chmod(0o644) + os.link(linked, destination / "linux-temp-admin-linux-arm64.sig") + self.assertEqual(linked.stat().st_nlink, 2) + + mirror_receiver.publish_version(staged, destination, owner=self.owner) + + mirror_receiver.validate_version(destination, owner=self.owner, published=True) + self.assertFalse( + any(entry.name.startswith(".mirror-") for entry in os.scandir(destination)) + ) + self.assertEqual((destination / "linux-temp-admin-linux-arm64.sig").stat().st_nlink, 1) + + def test_no_replace_commit_never_overwrites_existing_bytes(self) -> None: + source = self.temporary / "no-replace-source" + destination = self.temporary / "no-replace-destination" + source.write_bytes(b"new") + destination.write_bytes(b"existing") + + with self.assertRaisesRegex( + mirror_receiver.ReceiverError, "appeared concurrently" + ): + mirror_receiver.rename_noreplace(source, destination) + + self.assertEqual(destination.read_bytes(), b"existing") + self.assertEqual(source.read_bytes(), b"new") + + def test_no_replace_fails_closed_when_primitive_is_unavailable(self) -> None: + source = self.temporary / "unsupported-source" + destination = self.temporary / "unsupported-destination" + source.write_bytes(b"source") + original_renameat2 = mirror_receiver.RENAMEAT2 + try: + mirror_receiver.RENAMEAT2 = None + with self.assertRaisesRegex( + mirror_receiver.ReceiverError, "is unavailable" + ): + mirror_receiver.rename_noreplace(source, destination) + + class UnsupportedRename: + def __call__(self, *args) -> int: + mirror_receiver.ctypes.set_errno(mirror_receiver.errno.EOPNOTSUPP) + return -1 + + mirror_receiver.RENAMEAT2 = UnsupportedRename() + with self.assertRaisesRegex( + mirror_receiver.ReceiverError, "cannot atomically publish" + ): + mirror_receiver.rename_noreplace(source, destination) + finally: + mirror_receiver.RENAMEAT2 = original_renameat2 + + self.assertEqual(source.read_bytes(), b"source") + self.assertFalse(destination.exists()) + + def test_version_retry_after_commit_before_directory_sync(self) -> None: + staged = make_version(self.temporary / "sync-interruption") + destination = self.project / "v2.8.0" + original_fsync_directory = mirror_receiver.fsync_directory + interrupted = False + + def fail_first_version_sync(path: Path) -> None: + nonlocal interrupted + if path == destination and not interrupted: + interrupted = True + raise OSError("simulated interruption after no-replace commit") + original_fsync_directory(path) + + mirror_receiver.fsync_directory = fail_first_version_sync + try: + with self.assertRaisesRegex(OSError, "simulated interruption"): + mirror_receiver.publish_version(staged, destination, owner=self.owner) + finally: + mirror_receiver.fsync_directory = original_fsync_directory + + self.assertTrue(interrupted) + self.assertFalse( + any(entry.name.startswith(".mirror-") for entry in os.scandir(destination)) + ) + mirror_receiver.publish_version(staged, destination, owner=self.owner) + mirror_receiver.validate_version(destination, owner=self.owner, published=True) + + def test_version_rejects_unsafe_stale_private_temp(self) -> None: + staged = make_version(self.temporary / "unsafe-private-temp") + destination = self.project / "v2.8.0" + destination.mkdir(mode=0o755) + temporary = destination / ( + ".mirror-linux-temp-admin-linux-amd64-" + "2" * 32 + ) + temporary.symlink_to(staged / "linux-temp-admin-linux-amd64") + + with self.assertRaisesRegex( + mirror_receiver.ReceiverError, "unsafe stale mirror temporary file" + ): + mirror_receiver.publish_version(staged, destination, owner=self.owner) + self.assertTrue(temporary.is_symlink()) + + def test_version_rejects_unexpected_private_temp_hardlinks(self) -> None: + staged = make_version(self.temporary / "unsafe-hardlinks") + + wrong_target = self.project / "v2.8.0" + wrong_target.mkdir(mode=0o755) + linked_elsewhere = wrong_target / ( + ".mirror-linux-temp-admin-linux-amd64-" + "4" * 32 + ) + linked_elsewhere.write_bytes(b"partial") + linked_elsewhere.chmod(0o644) + outside = self.temporary / "unexpected-link" + os.link(linked_elsewhere, outside) + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.publish_version(staged, wrong_target, owner=self.owner) + self.assertTrue(linked_elsewhere.exists()) + self.assertTrue(outside.exists()) + + too_many = self.project / "v2.8.1" + too_many.mkdir(mode=0o755) + private = too_many / (".mirror-SHA256SUMS-" + "5" * 32) + private.write_bytes(b"partial") + private.chmod(0o644) + os.link(private, too_many / "SHA256SUMS") + third = self.temporary / "third-link" + os.link(private, third) + with self.assertRaisesRegex( + mirror_receiver.ReceiverError, "unsafe stale mirror temporary file" + ): + mirror_receiver.publish_version(staged, too_many, owner=self.owner) + self.assertEqual(private.stat().st_nlink, 3) + def test_new_version_directory_ignores_restrictive_process_umask(self) -> None: staged = make_version(self.temporary / "restrictive-umask") destination = self.project / "v2.8.0" @@ -153,6 +303,96 @@ def test_new_version_directory_ignores_restrictive_process_umask(self) -> None: self.assertEqual(destination.stat().st_mode & 0o7777, 0o755) mirror_receiver.validate_version(destination, owner=self.owner, published=True) + def test_version_retry_after_crash_immediately_after_directory_creation(self) -> None: + staged = make_version(self.temporary / "mkdir-interruption") + destination = self.project / "v2.8.0" + child = os.fork() + if child == 0: + try: + original_require_directory = mirror_receiver.require_directory + + def stop_after_mkdir( + path: Path, *, owner: int, mode: int | None = None + ) -> None: + if path == destination: + os._exit(93) + original_require_directory(path, owner=owner, mode=mode) + + mirror_receiver.require_directory = stop_after_mkdir + os.umask(0o077) + mirror_receiver.publish_version(staged, destination, owner=self.owner) + except BaseException: + os._exit(95) + os._exit(94) + + waited, status = os.waitpid(child, 0) + self.assertEqual(waited, child) + self.assertTrue(os.WIFEXITED(status)) + self.assertEqual(os.WEXITSTATUS(status), 93) + self.assertEqual(destination.stat().st_mode & 0o7777, 0o755) + self.assertEqual(list(destination.iterdir()), []) + + synced = [] + original_fsync_directory = mirror_receiver.fsync_directory + + def record_directory_sync(path: Path) -> None: + synced.append(path) + original_fsync_directory(path) + + mirror_receiver.fsync_directory = record_directory_sync + try: + mirror_receiver.publish_version(staged, destination, owner=self.owner) + finally: + mirror_receiver.fsync_directory = original_fsync_directory + self.assertIn(self.project, synced) + mirror_receiver.validate_version(destination, owner=self.owner, published=True) + + def test_version_retry_after_crash_during_private_copy_with_extreme_umask(self) -> None: + staged = make_version(self.temporary / "copy-interruption") + destination = self.project / "v2.8.0" + destination.mkdir(mode=0o755) + child = os.fork() + if child == 0: + try: + mirror_receiver.secrets.token_hex = lambda size: "3" * (size * 2) + + def stop_during_copy(source, target, length=0) -> None: + target.write(b"partial") + target.flush() + os._exit(93) + + mirror_receiver.shutil.copyfileobj = stop_during_copy + os.umask(0o777) + mirror_receiver.copy_to_private_temp( + staged / "SHA256SUMS", destination, "SHA256SUMS" + ) + except BaseException: + os._exit(95) + os._exit(94) + + waited, status = os.waitpid(child, 0) + self.assertEqual(waited, child) + self.assertTrue(os.WIFEXITED(status)) + self.assertEqual(os.WEXITSTATUS(status), 93) + temporary = destination / (".mirror-SHA256SUMS-" + "3" * 32) + self.assertEqual(temporary.stat().st_mode & 0o7777, 0o600) + + mirror_receiver.publish_version(staged, destination, owner=self.owner) + mirror_receiver.validate_version(destination, owner=self.owner, published=True) + + def test_deployment_lock_creation_ignores_extreme_umask(self) -> None: + lock = self.temporary / ".deploy.lock" + previous_umask = os.umask(0o777) + try: + descriptor = mirror_receiver.open_lock(lock, owner=self.owner) + finally: + os.umask(previous_umask) + os.close(descriptor) + + self.assertEqual(lock.stat().st_mode & 0o7777, 0o600) + descriptor = mirror_receiver.open_lock(lock, owner=self.owner) + os.close(descriptor) + def test_version_rejects_extra_paths_and_bad_checksums(self) -> None: extra = make_version(self.temporary / "extra") (extra / "unexpected").write_text("no", encoding="ascii") @@ -191,6 +431,33 @@ def test_stable_files_must_bind_to_a_complete_version(self) -> None: wrong, "install.sh", project_root=self.project, owner=self.owner ) + def test_stable_publication_recovers_private_temporaries(self) -> None: + staged_version = make_version(self.temporary / "stable-recovery") + mirror_receiver.publish_version( + staged_version, self.project / "v2.8.0", owner=self.owner + ) + + installer_temp = self.project / (".mirror-install.sh-" + "6" * 32) + installer_temp.write_bytes(b"partial") + installer_temp.chmod(0o600) + mirror_receiver.publish_stable( + staged_version / "install.sh", + "install.sh", + project_root=self.project, + owner=self.owner, + ) + self.assertFalse(installer_temp.exists()) + + latest_temp = self.project / (".mirror-latest.json-" + "7" * 32) + latest_temp.write_bytes(b"partial") + latest_temp.chmod(0o600) + latest = make_latest(self.temporary / "stable-recovery.json", "v2.8.0") + mirror_receiver.publish_stable( + latest, "latest.json", project_root=self.project, owner=self.owner + ) + self.assertFalse(latest_temp.exists()) + self.assertEqual((self.project / "latest.json").read_bytes(), latest.read_bytes()) + def test_latest_rejects_noncanonical_or_inconsistent_content(self) -> None: staged_version = make_version(self.temporary / "version") mirror_receiver.publish_version(staged_version, self.project / "v2.8.0", owner=self.owner) @@ -219,6 +486,30 @@ def test_stable_metadata_rejects_prereleases(self) -> None: with self.assertRaises(mirror_receiver.ReceiverError): mirror_receiver.validate_latest(latest, owner=self.owner, project_root=self.project) + def test_release_and_stable_metadata_reject_versions_below_v2(self) -> None: + staged_version = make_version(self.temporary / "v1-version", tag="v1.9.9") + with self.assertRaisesRegex(mirror_receiver.ReceiverError, "below v2"): + mirror_receiver.publish_version( + staged_version, self.project / "v1.9.9", owner=self.owner + ) + + published_version = self.project / "v1.9.9" + shutil.copytree(staged_version, published_version) + published_version.chmod(0o755) + staged_installer = self.temporary / "v1-install.sh" + shutil.copyfile(staged_version / "install.sh", staged_installer) + staged_installer.chmod(0o644) + with self.assertRaisesRegex( + mirror_receiver.ReceiverError, "not byte-identical to a complete stable version" + ): + mirror_receiver.publish_stable( + staged_installer, "install.sh", project_root=self.project, owner=self.owner + ) + + latest = make_latest(self.temporary / "v1-latest.json", "v1.9.9") + with self.assertRaisesRegex(mirror_receiver.ReceiverError, "below v2"): + mirror_receiver.validate_latest(latest, owner=self.owner, project_root=self.project) + def test_stable_files_cannot_roll_back_or_mutate_current_metadata(self) -> None: old = make_version( self.temporary / "old-version", tag="v2.8.0", installer=b"#!/bin/sh\n# old\n"