diff --git a/docs/TESTING.md b/docs/TESTING.md index b6d4c84..2961104 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -9,9 +9,21 @@ cargo test --workspace cargo clippy --workspace --all-targets -- -D warnings ``` -## Live FUSE acceptance suite +## Filesystem API and live FUSE acceptance suite -Kernel behavior and remote convergence need a real mount. The live suite runs +The account-free suite exercises the filesystem syscall contract against a +temporary local directory. This validates the runner and covers creation/open +flags, positioned and vectored I/O, truncation and sparse files, `mmap`, +`sendfile`, durability barriers, namespace/error semantics, open-file lifetime, +names and enumeration, and concurrent I/O. It never reads application state or +contacts Proton: + +```console +scripts/fuse-acceptance.sh --offline-only +``` + +Kernel/FUSE behavior and remote convergence need a real mount. Live testing is +explicitly opt-in and always runs the account-free contract first. It runs destructive POSIX operations only below a fresh `pdfs-acceptance-*` directory. It refuses paths that are not FUSE mounts and removes its directory on success, failure, or interruption. @@ -19,16 +31,35 @@ failure, or interruption. Use a dedicated test account without irreplaceable data: ```console -scripts/fuse-acceptance.sh /mnt/on-demand-testmount +scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount ``` +### Automated mode matrix + +The managed live runner accepts two existing, empty, unmounted directories. It +registers both as new sync folders, waits for initial synchronization, then +tests all four pairs in order: on-demand/on-demand, on-demand/mirror, +mirror/on-demand, and mirror/mirror. Every transition must become idle, and an +on-demand folder must actually appear as a mount before its contract runs. + +```console +mkdir -p /mnt/pdfs-test-a /mnt/pdfs-test-b +scripts/fuse-acceptance.sh --managed-live /mnt/pdfs-test-a /mnt/pdfs-test-b +``` + +This creates remote folders and permanently deletes those test remotes during +cleanup. It refuses non-empty paths, existing mounts, and paths already present +in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition +timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for +example `target/debug/pdfs`. + Passing more paths runs the same filesystem suite independently against each. The first path must be the FUSE mount under test; later paths may be another FUSE mount or a normal local mirror filesystem: ```console PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \ - scripts/fuse-acceptance.sh /mnt/on-demand-testmount /mnt/testmount + scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount /mnt/testmount ``` If two paths really are views of the **same remote folder**, enable an @@ -36,16 +67,15 @@ additional byte-for-byte convergence check explicitly: ```console PDFS_ACCEPTANCE_CONVERGENCE=1 \ - scripts/fuse-acceptance.sh /mnt/first-view /mnt/second-view + scripts/fuse-acceptance.sh --live /mnt/first-view /mnt/second-view ``` Do not enable that flag merely because two sync folders belong to the same account. Their configured remote UIDs must be identical. -The suite covers create/read/rewrite/truncate, multi-megabyte binary content, -unaligned random writes, rename replacement, cross-directory moves, important -POSIX error cases, open-then-unlink behavior, concurrent writers, directory -enumeration, and optional cross-mount byte-for-byte convergence. A pass is +Normally the live suite applies the same API contract independently to each +supplied mount. In convergence mode it exercises the primary once and verifies +that the resulting bytes appear through every secondary view. A pass is strong evidence for those paths, not a blanket data-loss guarantee. Before a release, also run the recovery drills in `docs/RECOVERY.md` and inspect `docs/BUGS.md` for live-verification items. diff --git a/scripts/fuse-acceptance.py b/scripts/fuse-acceptance.py new file mode 100755 index 0000000..57706d5 --- /dev/null +++ b/scripts/fuse-acceptance.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +"""Account-free filesystem API contract plus opt-in live FUSE acceptance.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import errno +import hashlib +import mmap +import os +from pathlib import Path +import shutil +import stat +import subprocess +import tempfile +import time +import uuid + + +def check(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def expect_errno(expected: set[int], operation, description: str) -> None: + try: + operation() + except OSError as error: + check(error.errno in expected, f"{description}: errno {error.errno}, expected {expected}") + else: + raise AssertionError(f"{description}: unexpectedly succeeded") + + +def read(path: Path) -> bytes: + with path.open("rb", buffering=0) as file: + return file.read() + + +def write_durable(path: Path, data: bytes) -> None: + fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + try: + view = memoryview(data) + while view: + written = os.write(fd, view) + check(written > 0, "write made no progress") + view = view[written:] + os.fsync(fd) + finally: + os.close(fd) + + +def test_create_flags(root: Path) -> None: + path = root / "flags" + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600) + os.write(fd, b"abcdef") + os.close(fd) + expect_errno({errno.EEXIST}, lambda: os.open(path, os.O_CREAT | os.O_EXCL), "O_EXCL") + fd = os.open(path, os.O_WRONLY | os.O_APPEND) + os.lseek(fd, 0, os.SEEK_SET) + os.write(fd, b"++") + os.close(fd) + check(read(path) == b"abcdef++", "O_APPEND ignored") + fd = os.open(path, os.O_WRONLY | os.O_TRUNC) + os.close(fd) + check(path.stat().st_size == 0, "O_TRUNC did not truncate") + + +def test_positioned_and_vectored_io(root: Path) -> None: + path = root / "positioned.bin" + base = bytes(range(256)) * 32768 + write_durable(path, base) + fd = os.open(path, os.O_RDWR) + try: + check(os.pread(fd, 19, 4093) == base[4093:4112], "pread returned wrong range") + check(os.pwrite(fd, b"boundary-write", 4093) == 14, "short pwrite") + if hasattr(os, "pwritev"): + check(os.pwritev(fd, [b"vector-", b"write"], 65531) == 12, "short pwritev") + if hasattr(os, "preadv"): + chunks = [bytearray(7), bytearray(5)] + check(os.preadv(fd, chunks, 65531) == 12, "short preadv") + check(b"".join(chunks) == b"vector-write", "preadv data mismatch") + os.fdatasync(fd) + finally: + os.close(fd) + expected = bytearray(base) + expected[4093:4107] = b"boundary-write" + expected[65531:65543] = b"vector-write" + got = read(path) + check(got == expected, "positioned write damaged surrounding bytes") + print(f" sha256 {hashlib.sha256(got).hexdigest()}") + + +def test_resize_and_sparse_io(root: Path) -> None: + path = root / "resize" + write_durable(path, b"0123456789") + os.truncate(path, 4) + check(read(path) == b"0123", "shrink did not preserve prefix") + os.truncate(path, 8193) + data = read(path) + check(data[:4] == b"0123" and data[4:] == bytes(8189), "grown range is not zero-filled") + + sparse = root / "sparse" + fd = os.open(sparse, os.O_CREAT | os.O_RDWR, 0o600) + try: + os.pwrite(fd, b"head", 0) + os.pwrite(fd, b"tail", 8 * 1024 * 1024 + 17) + os.fsync(fd) + finally: + os.close(fd) + check(sparse.stat().st_size == 8 * 1024 * 1024 + 21, "sparse size mismatch") + fd = os.open(sparse, os.O_RDONLY) + try: + check(os.pread(fd, 8, 4) == bytes(8), "sparse hole is not zero-filled") + check(os.pread(fd, 4, 8 * 1024 * 1024 + 17) == b"tail", "sparse tail missing") + check(os.pread(fd, 1, sparse.stat().st_size + 4096) == b"", "read beyond EOF not empty") + finally: + os.close(fd) + + +def test_mmap_and_copy_paths(root: Path) -> None: + source = root / "mapped" + data = bytearray((b"mmap-and-sendfile\0" * 65536)[:1024 * 1024]) + write_durable(source, data) + fd = os.open(source, os.O_RDWR) + try: + with mmap.mmap(fd, len(data), access=mmap.ACCESS_WRITE) as mapping: + mapping[4091:4107] = b"mapped-boundary!" + mapping.flush() + os.fsync(fd) + finally: + os.close(fd) + data[4091:4107] = b"mapped-boundary!" + check(read(source) == data, "shared mmap write/read mismatch") + + target = root / "sendfile-copy" + src = os.open(source, os.O_RDONLY) + dst = os.open(target, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + try: + offset = 0 + while offset < len(data): + count = os.sendfile(dst, src, offset, len(data) - offset) + check(count > 0, "sendfile made no progress") + offset += count + os.fsync(dst) + finally: + os.close(src) + os.close(dst) + check(read(target) == data, "sendfile copy mismatch") + + +def test_namespace_and_errors(root: Path) -> None: + a, b = root / "dir-a", root / "dir-b" + a.mkdir(); b.mkdir() + (a / "nested").mkdir() + write_durable(a / "nested" / "child", b"child") + write_durable(root / "source", b"source") + write_durable(root / "victim", b"victim") + os.replace(root / "source", root / "victim") + check(read(root / "victim") == b"source" and not (root / "source").exists(), "replace failed") + os.rename(root / "victim", b / "moved") + check(read(b / "moved") == b"source", "cross-directory move failed") + os.rename(b / "moved", b / "moved") + check(read(b / "moved") == b"source", "same-name rename changed data") + + expect_errno({errno.ENOTEMPTY, errno.EEXIST}, lambda: os.rmdir(a), "rmdir(non-empty)") + expect_errno({errno.ENOTDIR}, lambda: os.rmdir(b / "moved"), "rmdir(file)") + expect_errno({errno.EISDIR, errno.EPERM}, lambda: os.unlink(a), "unlink(directory)") + expect_errno({errno.EINVAL}, lambda: os.rename(a, a / "nested" / "cycle"), "directory cycle") + expect_errno({errno.ENOENT}, lambda: os.unlink(root / "absent"), "unlink(absent)") + expect_errno({errno.ENOENT}, lambda: os.rename(root / "absent", root / "new"), "rename(absent)") + + empty_src, empty_dst = root / "empty-src", root / "empty-dst" + empty_src.mkdir(); empty_dst.mkdir() + os.replace(empty_src, empty_dst) + check(empty_dst.is_dir() and not empty_src.exists(), "empty directory replacement failed") + nonempty = root / "nonempty-dst" + nonempty.mkdir(); write_durable(nonempty / "keep", b"keep") + replacement = root / "replacement-dir"; replacement.mkdir() + expect_errno({errno.ENOTEMPTY, errno.EEXIST}, lambda: os.replace(replacement, nonempty), "replace non-empty dir") + check(read(nonempty / "keep") == b"keep", "failed replacement damaged destination") + + +def test_open_lifetime(root: Path) -> None: + path = root / "open-unlink" + data = b"held-open\0" * 4096 + write_durable(path, data) + fd = os.open(path, os.O_RDWR) + os.unlink(path) + check(not path.exists(), "unlinked name remains visible") + check(os.pread(fd, len(data), 0) == data, "open file lost data after unlink") + os.pwrite(fd, b"still-open", 0) + check(os.pread(fd, 10, 0) == b"still-open", "unlinked open file is not writable") + os.close(fd) + + old, new = root / "open-rename-old", root / "open-rename-new" + write_durable(old, b"before") + fd = os.open(old, os.O_RDWR) + os.rename(old, new) + os.pwrite(fd, b"after!", 0) + os.fsync(fd); os.close(fd) + check(not old.exists() and read(new) == b"after!", "open handle did not follow rename") + + +def test_names_and_enumeration(root: Path) -> None: + names = ["space name.txt", "unicodé-文件", ".hidden", "trailing.dot.", "case", "CASE"] + for index, name in enumerate(names): + write_durable(root / name, f"name-{index}".encode()) + listed = set(os.listdir(root)) + check(set(names) <= listed, "directory enumeration omitted valid names") + for name in listed: + os.lstat(root / name) + expect_errno({errno.ENAMETOOLONG}, lambda: os.open(root / ("x" * 256), os.O_CREAT), "overlong name") + + +def test_concurrency(root: Path) -> None: + def independent(index: int) -> None: + data = bytes([index]) * (131072 + index) + path = root / f"concurrent-{index}" + write_durable(path, data) + check(read(path) == data, f"concurrent file {index} mismatch") + + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(independent, range(1, 17))) + + shared = root / "concurrent-ranges" + extent = 64 * 1024 + write_durable(shared, bytes(extent * 8)) + fd = os.open(shared, os.O_RDWR) + try: + def positioned(index: int) -> None: + payload = bytes([index + 1]) * extent + check(os.pwrite(fd, payload, index * extent) == extent, f"short concurrent pwrite {index}") + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(positioned, range(8))) + os.fsync(fd) + finally: + os.close(fd) + expected = b"".join(bytes([i + 1]) * extent for i in range(8)) + check(read(shared) == expected, "concurrent disjoint writes overlapped or vanished") + + +TESTS = [ + ("creation and open flags", test_create_flags), + ("positioned and vectored I/O", test_positioned_and_vectored_io), + ("truncate, growth, sparse ranges, and EOF", test_resize_and_sparse_io), + ("mmap and zero-copy copy paths", test_mmap_and_copy_paths), + ("namespace operations and POSIX errors", test_namespace_and_errors), + ("open-handle lifetime across unlink and rename", test_open_lifetime), + ("names, lookup, stat, and enumeration", test_names_and_enumeration), + ("independent and shared-file concurrency", test_concurrency), +] + + +def run_contract(parent: Path, label: str) -> tuple[Path, str]: + root = parent / f"pdfs-acceptance-{uuid.uuid4().hex}" + root.mkdir() + print(f"[target] {label}: {parent}") + try: + for name, operation in TESTS: + print(f" [test] {name}") + operation(root) + digest = hashlib.sha256(read(root / "positioned.bin")).hexdigest() + return root, digest + except BaseException: + shutil.rmtree(root, ignore_errors=True) + raise + + +def wait_for_copy(root: Path, relative: Path, digest: str, timeout: int) -> None: + deadline = time.monotonic() + timeout + target = root / relative + while time.monotonic() < deadline: + try: + if target.is_file() and hashlib.sha256(read(target)).hexdigest() == digest: + return + except OSError: + pass + time.sleep(2) + raise AssertionError(f"{target} did not converge byte-for-byte within {timeout}s") + + +class ManagedSyncPair: + """Two sync registrations created by this run and removed on exit.""" + + def __init__(self, paths: list[Path], timeout: int) -> None: + self.paths = [path.resolve() for path in paths] + self.timeout = timeout + self.pdfs = os.environ.get("PDFS_ACCEPTANCE_PDFS", "pdfs") + self.ids: dict[Path, int] = {} + self.sentinels = { + path: hashlib.sha256(f"pdfs-preservation:{path}:{uuid.uuid4()}".encode()).digest() + * 4096 + for path in self.paths + } + + def command(self, *args: str, json_output: bool = False) -> str: + command = [self.pdfs] + if json_output: + command.append("--json") + command.extend(args) + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"{' '.join(command)} failed: {detail}") + return result.stdout + + def folders(self) -> list[dict]: + import json + value = json.loads(self.command("sync", "list", json_output=True)) + return value["items"] + + def validate(self) -> None: + if shutil.which(self.pdfs) is None and not Path(self.pdfs).is_file(): + raise RuntimeError( + f"cannot find {self.pdfs!r}; set PDFS_ACCEPTANCE_PDFS to the CLI binary" + ) + if self.paths[0] == self.paths[1]: + raise ValueError("managed live paths must be different directories") + registered = {Path(item["local_path"]).resolve() for item in self.folders()} + for path in self.paths: + check(path.is_dir(), f"managed path is not a directory: {path}") + check(not any(path.iterdir()), f"managed path is not empty: {path}") + check(path not in registered, f"managed path is already registered: {path}") + check(not is_mountpoint(path), f"managed path is already a mount: {path}") + + def wait_for(self, path: Path, *, mode: str | None = None) -> dict: + deadline = time.monotonic() + self.timeout + last = None + while time.monotonic() < deadline: + for item in self.folders(): + if Path(item["local_path"]).resolve() != path: + continue + last = item + if ( + item["state"] == "idle" + and item.get("pending_mode") is None + and (mode is None or item["mode"] == mode) + ): + return item + if item["state"] in {"error", "conflict"}: + raise RuntimeError(f"sync folder {path} entered {item['state']}: {item}") + time.sleep(2) + raise TimeoutError(f"sync folder {path} did not become idle in mode {mode}: {last}") + + def create(self) -> None: + self.validate() + for path in self.paths: + print(f"[setup] registering empty sync folder {path}") + self.command("sync", "add", str(path)) + item = self.wait_for(path, mode="mirror") + self.ids[path] = int(item["id"]) + for path in self.paths: + write_durable(path / "pdfs-mode-preservation.bin", self.sentinels[path]) + self.force_sync(path) + + def force_sync(self, path: Path) -> None: + before = int(self.wait_for(path)["last_sync"]) + # last_sync has one-second resolution. Ensure this requested pass cannot + # finish with the same timestamp and look indistinguishable from no pass. + while int(time.time()) <= before: + time.sleep(0.1) + self.command("sync", "now", str(self.ids[path])) + deadline = time.monotonic() + self.timeout + last = None + while time.monotonic() < deadline: + last = next( + (item for item in self.folders() if Path(item["local_path"]).resolve() == path), + None, + ) + if last and last["state"] == "idle" and int(last["last_sync"]) > before: + return + if last and last["state"] in {"error", "conflict"}: + raise RuntimeError(f"forced sync for {path} entered {last['state']}: {last}") + time.sleep(1) + raise TimeoutError(f"forced sync for {path} did not complete: {last}") + + def set_modes(self, modes: tuple[str, str]) -> None: + for path, mode in zip(self.paths, modes, strict=True): + current = self.wait_for(path) + if current["mode"] != mode: + print(f"[setup] switching {path} to {mode}") + self.command("sync", "mode", str(self.ids[path]), mode) + for path, mode in zip(self.paths, modes, strict=True): + self.wait_for(path, mode=mode) + mounted = is_mountpoint(path) + check(mounted == (mode == "ondemand"), f"{path}: mode is {mode}, mounted={mounted}") + check( + read(path / "pdfs-mode-preservation.bin") == self.sentinels[path], + f"{path}: preservation sentinel changed or vanished after switch to {mode}", + ) + + def cleanup(self) -> None: + # An async add can create its row and then time out before create() has + # recorded the id. validate() proved these exact paths were unregistered + # at entry, so rows now at those paths belong to this run. + try: + for item in self.folders(): + path = Path(item["local_path"]).resolve() + if path in self.paths: + self.ids[path] = int(item["id"]) + except Exception as error: + print(f"WARNING: could not rediscover managed sync folders: {error}") + for path, folder_id in reversed(list(self.ids.items())): + try: + print(f"[cleanup] removing test sync folder {path}") + self.command("sync", "rm", str(folder_id), "--delete-remote") + except Exception as error: + print(f"WARNING: cleanup failed for sync folder {folder_id}: {error}") + + +def is_mountpoint(path: Path) -> bool: + """True for a distinct mount, including FUSE mounts over an existing dir.""" + return os.path.ismount(path) + + +def run_managed_matrix(paths: list[Path]) -> None: + timeout = int(os.environ.get("PDFS_ACCEPTANCE_SYNC_TIMEOUT", "180")) + pair = ManagedSyncPair(paths, timeout) + roots: list[Path] = [] + try: + pair.create() + matrices = [ + ("ondemand", "ondemand"), + ("ondemand", "mirror"), + ("mirror", "ondemand"), + ("mirror", "mirror"), + ] + for modes in matrices: + print(f"[matrix] first={modes[0]}, second={modes[1]}") + pair.set_modes(modes) + for path, mode in zip(pair.paths, modes, strict=True): + root, _ = run_contract(path, f"managed {mode}") + roots.append(root) + shutil.rmtree(root) + roots.remove(root) + # Mirror changes are asynchronous; force and await a pass before + # changing its mode so authored bytes/deletions cannot be lost. + if mode == "mirror": + pair.force_sync(path) + finally: + for root in roots: + shutil.rmtree(root, ignore_errors=True) + pair.cleanup() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + live_mode = parser.add_mutually_exclusive_group() + live_mode.add_argument("--live", nargs="+", type=Path, metavar="MOUNTPOINT") + live_mode.add_argument( + "--managed-live", + nargs=2, + type=Path, + metavar=("EMPTY_DIR_A", "EMPTY_DIR_B"), + ) + args = parser.parse_args() + + with tempfile.TemporaryDirectory(prefix="pdfs-api-reference-") as directory: + reference, _ = run_contract(Path(directory), "local filesystem API reference (no account)") + shutil.rmtree(reference) + print("[pass] account-free filesystem API contract") + + if args.managed_live: + run_managed_matrix(args.managed_live) + print("PASS: offline API and managed live mode-matrix suites completed") + return + + if not args.live: + print("PASS: offline acceptance suite completed; live FUSE test was not requested") + return + + completed: list[tuple[Path, Path, str]] = [] + convergence = os.environ.get("PDFS_ACCEPTANCE_CONVERGENCE", "0") == "1" + try: + # Views of one remote folder must not independently create the same + # fixtures. Exercise the primary, then observe that exact tree through + # every secondary. Without convergence mode, each mount is independent + # and receives the full contract. + targets = args.live[:1] if convergence and len(args.live) > 1 else args.live + for mountpoint in targets: + root, digest = run_contract(mountpoint.resolve(), "live FUSE") + completed.append((mountpoint.resolve(), root, digest)) + if convergence and len(args.live) > 1: + print(" [test] cross-mount remote convergence") + timeout = int(os.environ.get("PDFS_ACCEPTANCE_SYNC_TIMEOUT", "120")) + source_root, digest = completed[0][1], completed[0][2] + relative = source_root.relative_to(completed[0][0]) / "positioned.bin" + for mountpoint in args.live[1:]: + wait_for_copy(mountpoint.resolve(), relative, digest, timeout) + finally: + for _, root, _ in completed: + shutil.rmtree(root, ignore_errors=True) + print("PASS: offline API and live FUSE acceptance suites completed") + + +if __name__ == "__main__": + main() diff --git a/scripts/fuse-acceptance.sh b/scripts/fuse-acceptance.sh index 2c486a1..dfa8543 100755 --- a/scripts/fuse-acceptance.sh +++ b/scripts/fuse-acceptance.sh @@ -1,210 +1,47 @@ #!/usr/bin/env bash -# Destructive only inside a uniquely named directory created by this script. +# Filesystem syscall contract, with optional tests against mounted Drive views. set -Eeuo pipefail usage() { - echo "usage: $0 MOUNTPOINT [MOUNTPOINT ...]" >&2 - echo "example: $0 /mnt/on-demand-testmount /mnt/testmount" >&2 + cat >&2 <<'EOF' +usage: scripts/fuse-acceptance.sh [--offline-only] + scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...] + scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR + scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility + +The local reference suite always runs first and requires no account. --live then +runs the same contract on each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only +when all live mountpoints show the same remote folder. +EOF exit 2 } -(( $# > 0 )) || usage command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; } -command -v findmnt >/dev/null || { echo "findmnt is required" >&2; exit 2; } +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -mounts=("$@") -run_id="pdfs-acceptance-$(date +%Y%m%dT%H%M%S)-$$" -roots=() +case "${1:-}" in + -h|--help) usage ;; + --offline-only|"") + (( $# <= 1 )) || usage + exec python3 "$script_dir/fuse-acceptance.py" + ;; + --live) shift ; (( $# > 0 )) || usage ;; + --managed-live) + shift + (( $# == 2 )) || usage + exec python3 "$script_dir/fuse-acceptance.py" --managed-live "$@" + ;; + --*) usage ;; +esac -cleanup() { - local root - for root in "${roots[@]}"; do - [[ -n "$root" && "$root" == */pdfs-acceptance-* ]] || continue - rm -rf -- "$root" 2>/dev/null || true - done -} -trap cleanup EXIT INT TERM - -for index in "${!mounts[@]}"; do - mountpoint="${mounts[$index]}" +command -v findmnt >/dev/null || { echo "findmnt is required for --live" >&2; exit 2; } +for mountpoint in "$@"; do [[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; } - fs_type="$(findmnt -T "$mountpoint" -n -o FSTYPE | head -n 1)" - if (( index == 0 )) && [[ "$fs_type" != fuse* ]]; then - echo "refusing primary non-FUSE path $mountpoint (type: ${fs_type:-unknown})" >&2 - exit 2 - fi - roots+=("${mountpoint%/}/$run_id") done - -step() { printf '[test] %s\n' "$1"; } -assert_text() { - python3 - "$1" "$2" <<'PY' -import sys -p, expected = sys.argv[1], sys.argv[2].encode() -with open(p, "rb") as f: got = f.read() -if got != expected: - raise AssertionError( - f"{p}: expected {expected!r} ({len(expected)} bytes), " - f"got {got!r} ({len(got)} bytes)" - ) -PY +fstype="$(findmnt -T "$1" -n -o FSTYPE | head -n 1)" +[[ "$fstype" == fuse* ]] || { + echo "refusing primary non-FUSE path $1 (type: ${fstype:-unknown})" >&2 + exit 2 } -run_suite() { -local primary="$1" -printf '[mount] testing %s\n' "${primary%/*}" -mkdir -- "$primary" - -step "create, read, overwrite, truncate" -printf 'alpha\n' > "$primary/plain.txt" -assert_text "$primary/plain.txt" $'alpha\n' -printf 'a much longer replacement\n' > "$primary/plain.txt" -truncate -s 4 "$primary/plain.txt" -assert_text "$primary/plain.txt" "a mu" - -step "binary round-trip and random-access writes" -python3 - "$primary/binary.dat" <<'PY' -import hashlib, sys -p = sys.argv[1] -data = bytes(range(256)) * 16384 -with open(p, "wb") as f: f.write(data) -with open(p, "r+b", buffering=0) as f: - f.seek(4093); f.write(b"boundary-write") -with open(p, "rb") as f: got = f.read() -expected = data[:4093] + b"boundary-write" + data[4107:] -assert got == expected -print(hashlib.sha256(got).hexdigest()) -PY - -step "media-style header and distant seek reads" -python3 - "$primary/seek-sample.mkv" <<'PY' -import os, sys - -p = sys.argv[1] -size = 12 * 1024 * 1024 + 137 -header = b"\x1aE\xdf\xa3pdfs-media-header" -middle = b"pdfs-middle-cue" -trailer = b"pdfs-media-trailer" -middle_at = 7 * 1024 * 1024 + 19 -trailer_at = size - len(trailer) - -# A player probes the container header, jumps to cues/index data, then commonly -# seeks near EOF. Write a sparse fixture so the acceptance test stays cheap, -# and issue separate unbuffered reads so FUSE receives genuinely distant ranges -# rather than one accidental sequential read. -with open(p, "wb", buffering=0) as f: - f.truncate(size) - f.seek(0); f.write(header) - f.seek(middle_at); f.write(middle) - f.seek(trailer_at); f.write(trailer) - -with open(p, "rb", buffering=0) as f: - assert f.read(len(header)) == header - f.seek(middle_at); assert f.read(len(middle)) == middle - f.seek(trailer_at); assert f.read(len(trailer)) == trailer - assert f.read(1) == b"" - f.seek(size + 4096); assert f.read(16) == b"" -assert os.stat(p).st_size == size -PY - -step "rename, move, and replacement semantics" -mkdir -- "$primary/dir-a" "$primary/dir-b" -printf 'source' > "$primary/source" -printf 'victim' > "$primary/victim" -mv -- "$primary/source" "$primary/victim" -assert_text "$primary/victim" "source" -[[ ! -e "$primary/source" ]] -mv -- "$primary/victim" "$primary/dir-a/moved" -assert_text "$primary/dir-a/moved" "source" - -step "POSIX error behavior" -if rmdir -- "$primary/dir-a/moved" 2>/dev/null; then echo "rmdir removed a file" >&2; exit 1; fi -if rm -- "$primary/dir-a" 2>/dev/null; then echo "rm removed a directory" >&2; exit 1; fi -if mv -- "$primary/dir-a" "$primary/dir-a/child" 2>/dev/null; then - echo "moving a directory below itself succeeded" >&2; exit 1 -fi - -step "open file remains readable after unlink" -python3 - "$primary/open-unlink" <<'PY' -import os, sys -p = sys.argv[1]; data = b"held-open\0" * 4096 -with open(p, "wb") as f: f.write(data) -f = open(p, "rb", buffering=0); os.unlink(p) -assert not os.path.exists(p) and f.read() == data -f.close() -PY - -step "concurrent independent writers" -for i in $(seq 1 12); do - (python3 - "$primary/concurrent-$i" "$i" <<'PY' -import sys -p, seed = sys.argv[1], int(sys.argv[2]); data = bytes([seed]) * (131072 + seed) -with open(p, "wb") as f: f.write(data) -with open(p, "rb") as f: assert f.read() == data -PY - ) & -done -wait - -step "directory enumeration agrees with stat" -python3 - "$primary" <<'PY' -import os, sys -for name in os.listdir(sys.argv[1]): os.lstat(os.path.join(sys.argv[1], name)) -PY -} - -for root in "${roots[@]}"; do - run_suite "$root" -done - -if [[ "${PDFS_ACCEPTANCE_CONVERGENCE:-0}" == 1 ]] && (( ${#roots[@]} > 1 )); then - step "cross-mount convergence" - primary="${roots[0]}" - expected="$(sha256sum "$primary/binary.dat" | cut -d' ' -f1)" - for secondary in "${roots[@]:1}"; do - deadline=$((SECONDS + ${PDFS_ACCEPTANCE_SYNC_TIMEOUT:-120})) - while (( SECONDS < deadline )); do - if [[ -f "$secondary/binary.dat" ]] && - [[ "$(sha256sum "$secondary/binary.dat" 2>/dev/null | cut -d' ' -f1)" == "$expected" ]]; then break; fi - sleep 2 - done - [[ -f "$secondary/binary.dat" ]] || { echo "did not appear on $secondary" >&2; exit 1; } - [[ "$(sha256sum "$secondary/binary.dat" | cut -d' ' -f1)" == "$expected" ]] || { - echo "content mismatch on $secondary" >&2; exit 1 - } - - # The media fixture may drain after binary.dat. Wait independently, - # then probe only its sparse ranges on the other mount. Unlike the - # same-mount check above, this exercises remote FUSE hydration and seek - # reads instead of serving the still-local staged file. - deadline=$((SECONDS + ${PDFS_ACCEPTANCE_SYNC_TIMEOUT:-120})) - while (( SECONDS < deadline )); do - [[ -f "$secondary/seek-sample.mkv" ]] && - [[ "$(stat -c %s "$secondary/seek-sample.mkv" 2>/dev/null)" == 12583049 ]] && break - sleep 2 - done - [[ -f "$secondary/seek-sample.mkv" ]] || { - echo "media fixture did not appear on $secondary" >&2; exit 1 - } - python3 - "$secondary/seek-sample.mkv" <<'PY' -import os, sys -p = sys.argv[1] -size = 12 * 1024 * 1024 + 137 -checks = [ - (0, b"\x1aE\xdf\xa3pdfs-media-header"), - (7 * 1024 * 1024 + 19, b"pdfs-middle-cue"), - (size - len(b"pdfs-media-trailer"), b"pdfs-media-trailer"), -] -assert os.stat(p).st_size == size -with open(p, "rb", buffering=0) as f: - for offset, expected in checks: - f.seek(offset) - assert f.read(len(expected)) == expected -PY - done -fi - -step "cleanup" -cleanup -roots=() -echo "PASS: FUSE acceptance suite completed ($run_id)" +exec python3 "$script_dir/fuse-acceptance.py" --live "$@"