From 60fd7ba0c2886cc984468272d2845638f1981db9 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 10 Aug 2026 13:59:18 +0200 Subject: [PATCH] feat(peer)!: cut over to authenticated catalog sharing Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed --- Cargo.lock | 321 +- Cargo.toml | 28 +- cert.pem | 36 - crates/lanspread-compat/Cargo.toml | 3 +- .../src/bin/lanspread-catalog-publisher.rs | 25 + .../src/bin/lanspread-fixture-catalog.rs | 210 + crates/lanspread-compat/src/catalog_bundle.rs | 724 +++ .../src/catalog_publisher/catalog.rs | 220 + .../src/catalog_publisher/cli.rs | 277 + .../src/catalog_publisher/fixture.rs | 504 ++ .../src/catalog_publisher/mod.rs | 932 +++ .../src/catalog_publisher/package.rs | 419 ++ .../src/catalog_publisher/unrar.rs | 779 +++ crates/lanspread-compat/src/eti.rs | 37 +- crates/lanspread-compat/src/lib.rs | 2 + .../tests/catalog_publisher_cli.rs | 179 + crates/lanspread-db/Cargo.toml | 2 + .../src/content_manifest/bundle.rs | 334 ++ .../src/content_manifest/digest.rs | 165 + .../src/content_manifest/encoding.rs | 161 + .../src/content_manifest/index.rs | 413 ++ .../lanspread-db/src/content_manifest/mod.rs | 51 + .../src/content_manifest/model.rs | 1174 ++++ .../lanspread-db/src/content_manifest/path.rs | 272 + .../src/content_manifest/store.rs | 1065 ++++ crates/lanspread-db/src/db.rs | 75 +- crates/lanspread-db/src/lib.rs | 1 + crates/lanspread-mdns/Cargo.toml | 1 - crates/lanspread-mdns/src/lib.rs | 336 +- crates/lanspread-peer-cli/Cargo.toml | 7 + crates/lanspread-peer-cli/Dockerfile | 5 +- .../catalogs/default/game.db | Bin 0 -> 25600 bytes .../default/manifests/alienswarm.json | 62 + .../catalogs/default/manifests/bf1942.json | 53 + .../catalogs/default/manifests/bfbc2.json | 53 + .../manifests/catalog-content-index-v1.jsonl | 1 + .../catalogs/default/manifests/cnc4.json | 53 + .../catalogs/default/manifests/cnctw.json | 53 + .../catalogs/default/manifests/cod5.json | 53 + .../catalogs/default/manifests/cod6.json | 53 + .../catalogs/default/manifests/coh.json | 53 + .../catalogs/default/manifests/css.json | 89 + .../catalogs/default/manifests/ggoo.json | 53 + .../lanspread-peer-cli/catalogs/multi/game.db | Bin 0 -> 14336 bytes .../manifests/catalog-content-index-v1.jsonl | 1 + .../catalogs/multi/manifests/cnctw.json | 50 + .../lanspread-peer-cli/catalogs/solid/game.db | Bin 0 -> 14336 bytes .../manifests/catalog-content-index-v1.jsonl | 1 + .../catalogs/solid/manifests/cnctw.json | 53 + .../catalogs/unknown/game.db | Bin 0 -> 14336 bytes .../manifests/catalog-content-index-v1.jsonl | 1 + .../catalogs/unknown/manifests/cod2.json | 28 + .../fixture-unknown/cod2/catalog-unknown.txt | 1 + .../fixtures/fixture-unknown/cod2/version.ini | 1 + crates/lanspread-peer-cli/src/lib.rs | 403 +- crates/lanspread-peer-cli/src/main.rs | 1876 +++++- crates/lanspread-peer/Cargo.toml | 10 +- crates/lanspread-peer/src/call_to_play.rs | 3079 +++++++--- crates/lanspread-peer/src/config.rs | 30 +- .../lanspread-peer/src/content_quarantine.rs | 119 + crates/lanspread-peer/src/context.rs | 563 +- .../src/download/confined_fs.rs | 302 +- .../lanspread-peer/src/download/manifest.rs | 980 +--- crates/lanspread-peer/src/download/mod.rs | 21 +- .../src/download/orchestrator.rs | 702 ++- .../lanspread-peer/src/download/ownership.rs | 3438 +++++++++-- .../lanspread-peer/src/download/planning.rs | 469 +- .../lanspread-peer/src/download/progress.rs | 138 +- crates/lanspread-peer/src/download/retry.rs | 583 +- crates/lanspread-peer/src/download/storage.rs | 58 +- .../src/download/transfer_error.rs | 104 + .../lanspread-peer/src/download/transport.rs | 1066 +++- .../src/download/version_ini.rs | 162 +- crates/lanspread-peer/src/events.rs | 100 +- crates/lanspread-peer/src/game_paths.rs | 12 - crates/lanspread-peer/src/handlers.rs | 5099 +++++++++++++---- crates/lanspread-peer/src/identity.rs | 1115 +++- crates/lanspread-peer/src/install/intent.rs | 696 ++- crates/lanspread-peer/src/install/mod.rs | 9 +- .../src/install/mutation_root.rs | 880 +++ .../lanspread-peer/src/install/transaction.rs | 2170 +++++-- crates/lanspread-peer/src/install/unpack.rs | 16 +- crates/lanspread-peer/src/launch_settings.rs | 334 +- crates/lanspread-peer/src/lib.rs | 835 ++- crates/lanspread-peer/src/library.rs | 458 +- crates/lanspread-peer/src/local_games.rs | 1283 ++++- crates/lanspread-peer/src/migration.rs | 427 +- crates/lanspread-peer/src/network.rs | 514 +- .../lanspread-peer/src/network_generation.rs | 1532 +++++ crates/lanspread-peer/src/peer.rs | 473 +- crates/lanspread-peer/src/peer_db.rs | 2296 ++++---- crates/lanspread-peer/src/quic_runtime.rs | 592 ++ .../lanspread-peer/src/recovery_quarantine.rs | 126 + crates/lanspread-peer/src/remote_peer.rs | 21 - crates/lanspread-peer/src/scoped_blocking.rs | 142 + crates/lanspread-peer/src/scoped_process.rs | 557 ++ crates/lanspread-peer/src/services.rs | 12 +- .../lanspread-peer/src/services/advertise.rs | 37 +- .../lanspread-peer/src/services/discovery.rs | 605 +- .../lanspread-peer/src/services/handshake.rs | 618 +- .../lanspread-peer/src/services/liveness.rs | 542 +- .../src/services/local_monitor.rs | 852 ++- .../src/services/remote_state.rs | 510 ++ crates/lanspread-peer/src/services/server.rs | 685 ++- .../lanspread-peer/src/services/state_sync.rs | 727 +++ crates/lanspread-peer/src/services/stream.rs | 1111 ++-- .../lanspread-peer/src/services/transfer.rs | 952 +++ crates/lanspread-peer/src/startup.rs | 1095 +++- crates/lanspread-peer/src/state_paths.rs | 125 +- crates/lanspread-peer/src/stream_install.rs | 2535 +++++++- crates/lanspread-peer/src/test_support.rs | 55 +- crates/lanspread-peer/src/tls.rs | 382 ++ .../lanspread-peer/src/tls_identity_spike.rs | 555 ++ crates/lanspread-peer/src/transfer_status.rs | 601 ++ crates/lanspread-proto/Cargo.toml | 1 - crates/lanspread-proto/src/lib.rs | 1859 +++++- .../tests/stream_install_frame.rs | 4 +- .../src-tauri/Cargo.toml | 10 +- .../src-tauri/build.rs | 59 + .../src-tauri/build_support/catalog_gate.rs | 431 ++ .../src-tauri/src/lib.rs | 4250 ++++++++++++-- .../src-tauri/src/sharing_policy.rs | 483 ++ .../src-tauri/tauri.conf.json | 1 + .../src-tauri/tauri.dev.conf.json | 10 + .../src-tauri/tauri.production.conf.json | 10 + .../src-tauri/tests/catalog_build_gate.rs | 2 + justfile | 112 +- key.pem | 52 - 128 files changed, 51759 insertions(+), 10784 deletions(-) delete mode 100644 cert.pem create mode 100644 crates/lanspread-compat/src/bin/lanspread-catalog-publisher.rs create mode 100644 crates/lanspread-compat/src/bin/lanspread-fixture-catalog.rs create mode 100644 crates/lanspread-compat/src/catalog_bundle.rs create mode 100644 crates/lanspread-compat/src/catalog_publisher/catalog.rs create mode 100644 crates/lanspread-compat/src/catalog_publisher/cli.rs create mode 100644 crates/lanspread-compat/src/catalog_publisher/fixture.rs create mode 100644 crates/lanspread-compat/src/catalog_publisher/mod.rs create mode 100644 crates/lanspread-compat/src/catalog_publisher/package.rs create mode 100644 crates/lanspread-compat/src/catalog_publisher/unrar.rs create mode 100644 crates/lanspread-compat/tests/catalog_publisher_cli.rs create mode 100644 crates/lanspread-db/src/content_manifest/bundle.rs create mode 100644 crates/lanspread-db/src/content_manifest/digest.rs create mode 100644 crates/lanspread-db/src/content_manifest/encoding.rs create mode 100644 crates/lanspread-db/src/content_manifest/index.rs create mode 100644 crates/lanspread-db/src/content_manifest/mod.rs create mode 100644 crates/lanspread-db/src/content_manifest/model.rs create mode 100644 crates/lanspread-db/src/content_manifest/path.rs create mode 100644 crates/lanspread-db/src/content_manifest/store.rs create mode 100644 crates/lanspread-peer-cli/catalogs/default/game.db create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/alienswarm.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/bf1942.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/bfbc2.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/catalog-content-index-v1.jsonl create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/cnc4.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/cnctw.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/cod5.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/cod6.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/coh.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/css.json create mode 100644 crates/lanspread-peer-cli/catalogs/default/manifests/ggoo.json create mode 100644 crates/lanspread-peer-cli/catalogs/multi/game.db create mode 100644 crates/lanspread-peer-cli/catalogs/multi/manifests/catalog-content-index-v1.jsonl create mode 100644 crates/lanspread-peer-cli/catalogs/multi/manifests/cnctw.json create mode 100644 crates/lanspread-peer-cli/catalogs/solid/game.db create mode 100644 crates/lanspread-peer-cli/catalogs/solid/manifests/catalog-content-index-v1.jsonl create mode 100644 crates/lanspread-peer-cli/catalogs/solid/manifests/cnctw.json create mode 100644 crates/lanspread-peer-cli/catalogs/unknown/game.db create mode 100644 crates/lanspread-peer-cli/catalogs/unknown/manifests/catalog-content-index-v1.jsonl create mode 100644 crates/lanspread-peer-cli/catalogs/unknown/manifests/cod2.json create mode 100644 crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2/catalog-unknown.txt create mode 100644 crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2/version.ini create mode 100644 crates/lanspread-peer/src/content_quarantine.rs create mode 100644 crates/lanspread-peer/src/download/transfer_error.rs create mode 100644 crates/lanspread-peer/src/install/mutation_root.rs create mode 100644 crates/lanspread-peer/src/network_generation.rs create mode 100644 crates/lanspread-peer/src/quic_runtime.rs create mode 100644 crates/lanspread-peer/src/recovery_quarantine.rs delete mode 100644 crates/lanspread-peer/src/remote_peer.rs create mode 100644 crates/lanspread-peer/src/scoped_blocking.rs create mode 100644 crates/lanspread-peer/src/scoped_process.rs create mode 100644 crates/lanspread-peer/src/services/remote_state.rs create mode 100644 crates/lanspread-peer/src/services/state_sync.rs create mode 100644 crates/lanspread-peer/src/services/transfer.rs create mode 100644 crates/lanspread-peer/src/tls.rs create mode 100644 crates/lanspread-peer/src/tls_identity_spike.rs create mode 100644 crates/lanspread-peer/src/transfer_status.rs create mode 100644 crates/lanspread-tauri-deno-ts/src-tauri/build_support/catalog_gate.rs create mode 100644 crates/lanspread-tauri-deno-ts/src-tauri/src/sharing_policy.rs create mode 100644 crates/lanspread-tauri-deno-ts/src-tauri/tauri.dev.conf.json create mode 100644 crates/lanspread-tauri-deno-ts/src-tauri/tauri.production.conf.json create mode 100644 crates/lanspread-tauri-deno-ts/src-tauri/tests/catalog_build_gate.rs delete mode 100644 key.pem diff --git a/Cargo.lock b/Cargo.lock index 1fbf9b8..1f5f29e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,57 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "atk" version = "0.18.2" @@ -151,7 +202,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -160,6 +211,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -175,6 +235,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -433,6 +507,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cookie" version = "0.18.2" @@ -632,6 +712,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -643,6 +729,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1024,15 +1124,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "futures" version = "0.3.33" @@ -1778,26 +1869,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "inotify" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" -dependencies = [ - "bitflags 2.13.1", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" -dependencies = [ - "libc", -] - [[package]] name = "intrusive-collections" version = "0.10.3" @@ -1978,34 +2049,16 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "kqueue" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" -dependencies = [ - "bitflags 2.13.1", - "libc", -] - [[package]] name = "lanspread-compat" version = "0.1.0" dependencies = [ + "blake3", "eyre", "lanspread-db", "serde", "sqlx", + "tokio", "tracing", ] @@ -2013,10 +2066,12 @@ dependencies = [ name = "lanspread-db" version = "0.1.0" dependencies = [ + "blake3", "eyre", "serde", "serde_json", "tracing", + "unicode-normalization", ] [[package]] @@ -2032,6 +2087,8 @@ dependencies = [ name = "lanspread-peer" version = "0.1.0" dependencies = [ + "base64 0.23.1", + "blake3", "bytes", "cap-fs-ext", "cap-primitives", @@ -2045,15 +2102,16 @@ dependencies = [ "lanspread-proto", "lanspread-utils", "log", - "notify", + "rcgen", + "rustls", "s2n-quic", + "s2n-quic-core", "serde", "serde_json", "strum", "tokio", "tokio-util", "unicode-normalization", - "uuid", "walkdir", ] @@ -2065,9 +2123,11 @@ dependencies = [ "lanspread-compat", "lanspread-db", "lanspread-peer", + "rustix", "serde", "serde_json", "tokio", + "tokio-util", ] [[package]] @@ -2086,6 +2146,8 @@ name = "lanspread-tauri-deno-ts" version = "0.1.0" dependencies = [ "base64 0.23.1", + "cap-fs-ext", + "cap-primitives", "eyre", "lanspread-compat", "lanspread-db", @@ -2094,6 +2156,7 @@ dependencies = [ "mimalloc", "serde", "serde_json", + "sqlx", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -2292,6 +2355,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2366,30 +2435,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] -name = "notify" -version = "8.2.0" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "bitflags 2.13.1", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.13.1", + "memchr", + "minimal-lexical", ] [[package]] @@ -2401,6 +2453,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2663,6 +2725,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2996,6 +3067,19 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "aws-lc-rs", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3152,6 +3236,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3242,7 +3335,7 @@ dependencies = [ "s2n-quic-core", "s2n-quic-crypto", "s2n-quic-platform", - "s2n-quic-tls-default", + "s2n-quic-rustls", "s2n-quic-transport", "tokio", "tracing", @@ -3315,31 +3408,6 @@ dependencies = [ "s2n-quic-crypto", ] -[[package]] -name = "s2n-quic-tls" -version = "0.85.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf73b03c8a4d14821fe4b7882508cc0134da3678360938da28b03b65560c2c97" -dependencies = [ - "bytes", - "errno", - "libc", - "s2n-codec", - "s2n-quic-core", - "s2n-quic-crypto", - "s2n-tls", -] - -[[package]] -name = "s2n-quic-tls-default" -version = "0.85.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0b7498aeb71298f1a1dd04f5609116eb1255fcd2c621957aa8611fe49eab209" -dependencies = [ - "s2n-quic-rustls", - "s2n-quic-tls", -] - [[package]] name = "s2n-quic-transport" version = "0.85.0" @@ -3358,31 +3426,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "s2n-tls" -version = "0.3.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b20cf2736f71fa3ee0783fbef55eaf93702f3fd8da5b0ac3d56fd9ae54e899c4" -dependencies = [ - "errno", - "hex", - "libc", - "pin-project-lite", - "s2n-tls-sys", -] - -[[package]] -name = "s2n-tls-sys" -version = "0.3.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b51c89b30aafcb9b0135478d3e920c1a463636ae5b7209d4baa2f526ce211f" -dependencies = [ - "aws-lc-rs", - "cc", - "libc", - "rustc_version", -] - [[package]] name = "same-file" version = "1.0.6" @@ -5817,6 +5860,34 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 70d131d..b4c88ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ [workspace.dependencies] base64 = "0.23" +blake3 = "1" bytes = { version = "1", features = ["serde"] } cap-fs-ext = { version = "4", default-features = false } cap-primitives = "4" @@ -24,8 +25,27 @@ if-addrs = "0.15" log = "0.4" mdns-sd = "0.20" mimalloc = { version = "0.1", features = ["secure"] } -notify = "8" -s2n-quic = { version = "1", features = ["provider-event-tracing"] } +rcgen = { + version = "=0.14.8", + default-features = false, + features = ["aws_lc_rs"] +} +rustix = "1" +rustls = { + version = "=0.23.43", + default-features = false, + features = ["aws-lc-rs", "logging", "std"] +} +s2n-quic = { + version = "=1.85.0", + default-features = false, + features = [ + "provider-address-token-default", + "provider-event-tracing", + "provider-tls-rustls", + ] +} +s2n-quic-core = "=0.85.0" serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { @@ -49,12 +69,14 @@ tracing = "0.1" tracing-log = "0.2" tracing-subscriber = "0.3" unicode-normalization = "0.1" -uuid = { version = "1", features = ["v7"] } walkdir = "2" windows = { version = "0.62", features = [ "Win32", + "Win32_Foundation", + "Win32_System_Registry", + "Win32_System_Threading", "Win32_UI", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", diff --git a/cert.pem b/cert.pem deleted file mode 100644 index e288ae9..0000000 --- a/cert.pem +++ /dev/null @@ -1,36 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIGSzCCBDOgAwIBAgIUfYHFI/0fNNXGMwPas9U1/Ija/DEwDQYJKoZIhvcNAQEL -BQAwgZUxCzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCZXJsaW4xDzANBgNVBAcMBkJl -cmxpbjESMBAGA1UECgwJUGF1bG9zb2Z0MREwDwYDVQQLDAhTb2Z0d2FyZTEkMCIG -CSqGSIb3DQEJARYVZGRpZGRlcnJAcGF1bC5uZXR3b3JrMRcwFQYDVQQDDA5NYXN0 -ZXJEZXNhc3RlcjAeFw0yNjA1MTgxNDE2MjhaFw0zNjA1MTUxNDE2MjhaMIGVMQsw -CQYDVQQGEwJERTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJsaW4xEjAQ -BgNVBAoMCVBhdWxvc29mdDERMA8GA1UECwwIU29mdHdhcmUxJDAiBgkqhkiG9w0B -CQEWFWRkaWRkZXJyQHBhdWwubmV0d29yazEXMBUGA1UEAwwOTWFzdGVyRGVzYXN0 -ZXIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJRaZ3P386Sf0O69KH -m8UX8gBpyRObFDkXCjhSmv9vzdjcv7hCq1ZT2gdbmnEmFBi0OWp4FJt6Yn50ySdN -9diCod3zgwa889p/eRxyy2PrQ2DA87/TsN4FudeHMRqQqKhLRdg0QSZfmHYFXVYI -oMEKn0thqzQzMiv1DSZWqSg2iow7BSC7brRb8vSJq4z/ziXrbUODFWqjvej1Z2pu -S3rWPQs07teWYWQzKkj8Yzk8nt2PtbS4gy0PqlSXiPAvNJ96KyT8kECFFxf6JBpa -vLN5ny6fLZbvxQja0LpSlZsAGT1aM0XAiMM1J2EOQWt2I50Ho75jxnxfLSbVqKW4 -Ev0clRa4avUgxCUG0SiHXqEKuOdh4oCaOrxSNnAY+OUm3bepkFaxcyXa3a8vcyhJ -hRjdQH05bAPQJd1PW+6VvEzs5YLy4PFThc+0aMsjZZWRFVOLJMDySsJitllQNewb -vSW8tRN1mh/D/acR8TN3IzNJUCw75pFdt0NSr0vCS0BaLyoQPrVb8+DUlvFbJWlD -bzhkMLeJw1M64jInHc4gSXFiNYzJGuDQDnz2A6ZhQvp6zh0/+7uW9y9oCt+2QfUw -JCkLnIC7R0nbRVtnysHmouX6tcAkilZcG+eA6FHw+h9sEtiCh0ZsZZfpT0A7i7Wz -ZMWmAe/4RhT/qfA+DlDyAp4tqwIDAQABo4GQMIGNMB0GA1UdDgQWBBQQRxB73r+0 -qF3wHT7hw7PfMWoFsDAfBgNVHSMEGDAWgBQQRxB73r+0qF3wHT7hw7PfMWoFsDAJ -BgNVHRMEAjAAMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB -BQUHAwIwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4ICAQDB -ANotdXwIls3X2SY3Sfb5EN7Q/r5Gow96STnM0HBRFeGLeSOrndHVYQsLRxtEeV54 -nJee14g4FCHIBzdsFvk1nbWAqnSwBzPE0KHIXTivC4E+p0hPlJ771qTVFoz6yPKf -WmH9YIDQGiCB5QhQ6MYYsfXXXGFMbWVWVFrn1IAbmPRxECxb3OT8TJCeo+jyPekt -iQEXngy/8Vp/Q4SAFL8FIU9fipRG10KnKCM3J1mlNJvFLvwzocBc+S9ebYO7oY+0 -aOamI87FNCuuwe/bHEyaWMI6LzkhOQdnA0nsmJGbn9rwc2iAlJfPjRAAf+lttZfC -/E76/SY8DCwPtSDfIJPK59u2EIwW+zgG5hPc90DAJS9QserRIp8DGvnaOPvO3CH7 -fEadQ9FyJVfwR+V1OJuMTo+8yzrsBq3BEPVSUvzFZe8GNWox3XihEJTcNASIHNj7 -jZvthkYpag82zJu+Fj4LG4Q/Y4XMIOkd7gRJDqkXsZCBtRMN2z47MU66cw0Nb/v5 -BB7RF8WlTbF0y5uFIGyVb+vL10HwNE7SK4EiJElTyxWUkX9GJpgNg4hEEbK+K7DE -+94AhcQu/Eu0RjaZlLJTWkuiS7krw2Mh93IMBlLiVLcGBvZFT8K/G7kuJSQeHbz2 -9pPEm5F7Qb6r30372RPamaEtWnWneZ5sTNPbKkjS+w== ------END CERTIFICATE----- diff --git a/crates/lanspread-compat/Cargo.toml b/crates/lanspread-compat/Cargo.toml index aaf5456..5947621 100644 --- a/crates/lanspread-compat/Cargo.toml +++ b/crates/lanspread-compat/Cargo.toml @@ -5,15 +5,16 @@ edition = "2024" [lib] doctest = false -test = false [dependencies] # local lanspread-db = { path = "../lanspread-db" } +blake3 = { workspace = true } eyre = { workspace = true } serde = { workspace = true } sqlx = { workspace = true } +tokio = { workspace = true } tracing = { workspace = true } [lints.clippy] diff --git a/crates/lanspread-compat/src/bin/lanspread-catalog-publisher.rs b/crates/lanspread-compat/src/bin/lanspread-catalog-publisher.rs new file mode 100644 index 0000000..6d27459 --- /dev/null +++ b/crates/lanspread-compat/src/bin/lanspread-catalog-publisher.rs @@ -0,0 +1,25 @@ +use std::io::{self, Write}; + +use lanspread_compat::catalog_publisher::cli::{HELP, ParseOutcome, execute, parse_args}; + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("error: {error:#}"); + std::process::exit(1); + } +} + +async fn run() -> eyre::Result<()> { + match parse_args(std::env::args_os().skip(1))? { + ParseOutcome::Help => println!("{HELP}"), + ParseOutcome::Command(command) => { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + for line in execute(&command).await? { + writeln!(stdout, "{line}")?; + } + } + } + Ok(()) +} diff --git a/crates/lanspread-compat/src/bin/lanspread-fixture-catalog.rs b/crates/lanspread-compat/src/bin/lanspread-fixture-catalog.rs new file mode 100644 index 0000000..7d4959c --- /dev/null +++ b/crates/lanspread-compat/src/bin/lanspread-fixture-catalog.rs @@ -0,0 +1,210 @@ +//! Test-only catalog generator used by the peer-CLI acceptance fixtures. + +use std::{ + collections::BTreeSet, + ffi::OsString, + io::{self, Write}, + path::PathBuf, +}; + +use lanspread_compat::catalog_publisher::fixture::{ + FixtureCatalogOptions, + FixturePackage, + generate_fixture_catalog, +}; + +const HELP: &str = "\ +Usage: + lanspread-fixture-catalog --source-catalog-db PATH --output-dir PATH --unrar PATH --game-root PATH... [--no-stream-install GAME_ID...] + +This test-only tool creates a reduced game.db and sibling manifests/ from the +selected fixture packages. --no-stream-install is only for synthetic transfer +fixtures whose .eti bytes intentionally are not RAR archives."; + +#[derive(Debug, Eq, PartialEq)] +enum ParseOutcome { + Help, + Options(FixtureCatalogOptions), +} + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("error: {error:#}"); + std::process::exit(1); + } +} + +async fn run() -> eyre::Result<()> { + match parse_args(std::env::args_os().skip(1))? { + ParseOutcome::Help => println!("{HELP}"), + ParseOutcome::Options(options) => { + let reports = generate_fixture_catalog(&options).await?; + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + for report in &reports { + writeln!( + stdout, + "generated fixture game_id={} game_version={} content_id={}", + report.game_id, report.game_version, report.content_id + )?; + } + writeln!(stdout, "generated fixture total={}", reports.len())?; + } + } + Ok(()) +} + +fn parse_args(args: impl IntoIterator) -> eyre::Result { + let mut source_catalog_db = None; + let mut output_dir = None; + let mut unrar = None; + let mut game_roots = Vec::new(); + let mut no_streamed_install = BTreeSet::new(); + let mut args = args.into_iter(); + + while let Some(argument) = args.next() { + match argument.to_str() { + Some("--help" | "-h") => return Ok(ParseOutcome::Help), + Some("--source-catalog-db") => set_once( + &mut source_catalog_db, + next_path(&mut args, "--source-catalog-db")?, + "--source-catalog-db", + )?, + Some("--output-dir") => set_once( + &mut output_dir, + next_path(&mut args, "--output-dir")?, + "--output-dir", + )?, + Some("--unrar") => set_once(&mut unrar, next_path(&mut args, "--unrar")?, "--unrar")?, + Some("--game-root") => game_roots.push(next_path(&mut args, "--game-root")?), + Some("--no-stream-install") => { + let game_id = next_utf8(&mut args, "--no-stream-install")?; + if !no_streamed_install.insert(game_id.clone()) { + eyre::bail!("duplicate --no-stream-install game ID: {game_id}"); + } + } + Some(other) => eyre::bail!("unknown argument: {other}"), + None => eyre::bail!("argument is not valid UTF-8: {argument:?}"), + } + } + + let mut selected_ids = BTreeSet::new(); + let packages = game_roots + .into_iter() + .map(|package_root| { + let game_id = package_root + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| eyre::eyre!("--game-root needs a UTF-8 final component"))? + .to_owned(); + if !selected_ids.insert(game_id.clone()) { + eyre::bail!("duplicate --game-root game ID: {game_id}"); + } + Ok(FixturePackage { + streamed_install: !no_streamed_install.contains(&game_id), + game_id, + package_root, + }) + }) + .collect::>>()?; + if packages.is_empty() { + eyre::bail!("at least one --game-root is required"); + } + if let Some(game_id) = no_streamed_install + .iter() + .find(|game_id| !selected_ids.contains(*game_id)) + { + eyre::bail!("--no-stream-install selects missing game root: {game_id}"); + } + + Ok(ParseOutcome::Options(FixtureCatalogOptions { + source_catalog_db: source_catalog_db + .ok_or_else(|| eyre::eyre!("--source-catalog-db is required"))?, + output_dir: output_dir.ok_or_else(|| eyre::eyre!("--output-dir is required"))?, + unrar: unrar.ok_or_else(|| eyre::eyre!("--unrar is required"))?, + packages, + })) +} + +fn next_path(args: &mut impl Iterator, option: &str) -> eyre::Result { + args.next() + .map(PathBuf::from) + .ok_or_else(|| eyre::eyre!("{option} requires a value")) +} + +fn next_utf8(args: &mut impl Iterator, option: &str) -> eyre::Result { + args.next() + .ok_or_else(|| eyre::eyre!("{option} requires a value"))? + .into_string() + .map_err(|value| eyre::eyre!("{option} value is not valid UTF-8: {value:?}")) +} + +fn set_once(slot: &mut Option, value: T, option: &str) -> eyre::Result<()> { + if slot.replace(value).is_some() { + eyre::bail!("{option} may be specified only once"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(arguments: &[&str]) -> eyre::Result { + parse_args(arguments.iter().map(OsString::from)) + } + + #[test] + fn parses_explicit_packages_and_synthetic_streaming_opt_out() { + let parsed = parse(&[ + "--source-catalog-db", + "source.db", + "--output-dir", + "catalog", + "--unrar", + "unrar", + "--game-root", + "fixtures/alienswarm", + "--game-root", + "fixtures/bf1942", + "--no-stream-install", + "bf1942", + ]) + .expect("fixture options should parse"); + + let ParseOutcome::Options(options) = parsed else { + panic!("expected parsed fixture options"); + }; + assert!(options.packages[0].streamed_install); + assert!(!options.packages[1].streamed_install); + } + + #[test] + fn rejects_implicit_or_mismatched_fixture_authority() { + for arguments in [ + vec![ + "--source-catalog-db", + "source.db", + "--output-dir", + "catalog", + "--unrar", + "unrar", + ], + vec![ + "--source-catalog-db", + "source.db", + "--output-dir", + "catalog", + "--unrar", + "unrar", + "--game-root", + "fixtures/g", + "--no-stream-install", + "other", + ], + ] { + assert!(parse(&arguments).is_err(), "accepted {arguments:?}"); + } + } +} diff --git a/crates/lanspread-compat/src/catalog_bundle.rs b/crates/lanspread-compat/src/catalog_bundle.rs new file mode 100644 index 0000000..ad8ec08 --- /dev/null +++ b/crates/lanspread-compat/src/catalog_bundle.rs @@ -0,0 +1,724 @@ +use std::{ + collections::{BTreeMap, HashSet}, + fs, + path::Path, + sync::Arc, +}; + +use eyre::WrapErr; +use lanspread_db::{ + content_manifest::CatalogBundle, + db::{Game, GameDB}, +}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; + +use crate::eti::EtiGame; + +/// Application catalog state loaded from one coherent database snapshot. +#[derive(Clone, Debug)] +pub struct LoadedCatalog { + game_db: GameDB, + bundle: Arc, +} + +impl LoadedCatalog { + /// Returns the UI-facing game database. + #[must_use] + pub const fn game_db(&self) -> &GameDB { + &self.game_db + } + + /// Returns the immutable content authority paired with the UI database. + #[must_use] + pub fn bundle(&self) -> &CatalogBundle { + &self.bundle + } + + /// Clones the shared handle used by peer runtime consumers. + #[must_use] + pub fn shared_bundle(&self) -> Arc { + Arc::clone(&self.bundle) + } + + /// Splits the loaded state into its UI and content-authority components. + #[must_use] + pub fn into_parts(self) -> (GameDB, Arc) { + (self.game_db, self.bundle) + } +} + +/// Loads the UI catalog and its exact content authority from one database +/// snapshot. +/// +/// Manifest filenames and filesystem shapes are checked eagerly. Manifest +/// bodies remain on-demand so application startup does not parse every catalog +/// artifact. +/// +/// # Errors +/// +/// Returns an error when the database or manifest root is unsafe or invalid, +/// database identities are ambiguous, genre expansion is not exactly one row +/// per game, or manifest filenames do not exactly cover the database catalog. +pub async fn load_catalog_bundle( + game_db_path: &Path, + manifests_root: &Path, +) -> eyre::Result { + validate_regular_file(game_db_path, "catalog database")?; + let options = SqliteConnectOptions::new() + .filename(game_db_path) + .read_only(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .wrap_err_with(|| format!("failed to open catalog database {}", game_db_path.display()))?; + + let query_result = query_catalog_snapshot(&pool).await; + let (authority_rows, ui_rows) = close_pool_after_query(&pool, query_result) + .await + .wrap_err_with(|| format!("failed to read catalog database {}", game_db_path.display()))?; + + assemble_catalog(authority_rows, ui_rows, manifests_root) +} + +#[derive(Clone, Debug, sqlx::FromRow)] +struct CatalogAuthorityRow { + db_id: i64, + game_id: String, + game_version: String, +} + +#[derive(Debug, sqlx::FromRow)] +struct JoinedCatalogRow { + db_id: i64, + game_id: String, + game_title: String, + game_key: String, + game_release: String, + game_publisher: String, + game_size: f64, + game_readme_de: String, + game_readme_en: String, + game_readme_fr: String, + game_maxplayers: u32, + game_master_req: i32, + genre_de: String, + game_version: String, +} + +impl JoinedCatalogRow { + fn into_eti_game(self) -> EtiGame { + EtiGame { + game_id: self.game_id, + game_title: self.game_title, + game_key: self.game_key, + game_release: self.game_release, + game_publisher: self.game_publisher, + game_size: self.game_size, + game_readme_de: self.game_readme_de, + game_readme_en: self.game_readme_en, + game_readme_fr: self.game_readme_fr, + game_maxplayers: self.game_maxplayers, + game_master_req: self.game_master_req, + genre_de: self.genre_de, + game_version: self.game_version, + } + } +} + +async fn query_catalog_snapshot( + pool: &SqlitePool, +) -> Result<(Vec, Vec), sqlx::Error> { + let mut transaction = pool.begin().await?; + let authority_rows = sqlx::query_as::<_, CatalogAuthorityRow>( + "SELECT CAST(db_id AS INTEGER) AS db_id, game_id, game_version + FROM games + ORDER BY db_id, game_id", + ) + .fetch_all(&mut *transaction) + .await?; + let ui_rows = sqlx::query_as::<_, JoinedCatalogRow>( + "SELECT + CAST(g.db_id AS INTEGER) AS db_id, + g.game_id, g.game_title, g.game_key, g.game_release, + g.game_publisher, CAST(g.game_size AS REAL) AS game_size, + g.game_readme_de, g.game_readme_en, g.game_readme_fr, + CAST(g.game_maxplayers AS INTEGER) AS game_maxplayers, + g.game_master_req, ge.genre_de, g.game_version + FROM games g + JOIN genre ge ON g.genre_id = ge.genre_id + ORDER BY g.db_id, g.game_id", + ) + .fetch_all(&mut *transaction) + .await?; + transaction.commit().await?; + Ok((authority_rows, ui_rows)) +} + +async fn close_pool_after_query( + pool: &SqlitePool, + query_result: Result, +) -> Result { + // `Pool::close` is infallible. Await it on both result paths before the + // caller propagates a database error. + pool.close().await; + debug_assert!(pool.is_closed()); + query_result +} + +fn assemble_catalog( + authority_rows: Vec, + ui_rows: Vec, + manifests_root: &Path, +) -> eyre::Result { + if authority_rows.is_empty() { + eyre::bail!("catalog database contains no games"); + } + + let mut authorities_by_db_id = BTreeMap::new(); + let mut expected_versions = BTreeMap::new(); + for row in authority_rows { + if authorities_by_db_id.contains_key(&row.db_id) { + eyre::bail!( + "catalog database contains duplicate raw game db_id: {}", + row.db_id + ); + } + if expected_versions.contains_key(&row.game_id) { + eyre::bail!( + "catalog database contains duplicate raw game ID: {}", + row.game_id + ); + } + expected_versions.insert(row.game_id.clone(), row.game_version.clone()); + authorities_by_db_id.insert(row.db_id, row); + } + + let mut expanded_db_ids = HashSet::new(); + let mut games = Vec::with_capacity(authorities_by_db_id.len()); + for row in ui_rows { + let authority = authorities_by_db_id + .get(&row.db_id) + .ok_or_else(|| eyre::eyre!("genre join produced unknown game db_id: {}", row.db_id))?; + if !expanded_db_ids.insert(row.db_id) { + eyre::bail!( + "duplicate genre join expansion for game {} (db_id {})", + authority.game_id, + authority.db_id + ); + } + if row.game_id != authority.game_id || row.game_version != authority.game_version { + eyre::bail!( + "catalog identity/version mismatch for game db_id {}", + authority.db_id + ); + } + games.push(Game::from(row.into_eti_game())); + } + + for (db_id, authority) in &authorities_by_db_id { + if !expanded_db_ids.contains(db_id) { + eyre::bail!( + "missing genre join expansion for game {} (db_id {})", + authority.game_id, + authority.db_id + ); + } + } + + let bundle = Arc::new(CatalogBundle::new(manifests_root, expected_versions)?); + Ok(LoadedCatalog { + game_db: GameDB::from(games), + bundle, + }) +} + +fn validate_regular_file(path: &Path, label: &str) -> eyre::Result<()> { + let metadata = fs::symlink_metadata(path) + .wrap_err_with(|| format!("failed to inspect {label} {}", path.display()))?; + if is_link_or_reparse(&metadata) || !metadata.is_file() { + eyre::bail!("{label} is not a regular non-link file: {}", path.display()); + } + Ok(()) +} + +#[cfg(unix)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(any(unix, windows)))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(test)] +mod tests { + use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use lanspread_db::content_manifest::{ + CATALOG_CONTENT_INDEX_NAME, + CatalogContentIdentity, + CatalogContentIndex, + CatalogContentIndexEntry, + ContentId, + write_canonical_content_index_atomic, + }; + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + + static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should follow epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "lanspread-catalog-bundle-{}-{nanos}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("test directory should be created"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[derive(Clone, Copy)] + struct GameRow<'a> { + db_id: i64, + game_id: &'a str, + game_version: &'a str, + genre_id: i64, + } + + async fn create_catalog_db(path: &Path, games: &[GameRow<'_>], genres: &[(i64, &str)]) { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("fixture database should open"); + sqlx::query( + "CREATE TABLE games ( + game_id TEXT NOT NULL, db_id INTEGER NOT NULL, + game_title TEXT NOT NULL, game_key TEXT NOT NULL, + game_release TEXT NOT NULL, game_publisher TEXT NOT NULL, + game_size REAL NOT NULL, game_readme_de TEXT NOT NULL, + game_readme_en TEXT NOT NULL, game_readme_fr TEXT NOT NULL, + game_maxplayers INTEGER NOT NULL, game_master_req INTEGER NOT NULL, + genre_id INTEGER NOT NULL, game_version TEXT NOT NULL + )", + ) + .execute(&pool) + .await + .expect("games table should be created"); + sqlx::query("CREATE TABLE genre (genre_id INTEGER NOT NULL, genre_de TEXT NOT NULL)") + .execute(&pool) + .await + .expect("genre table should be created"); + + for (genre_id, genre_de) in genres { + sqlx::query("INSERT INTO genre (genre_id, genre_de) VALUES (?, ?)") + .bind(genre_id) + .bind(genre_de) + .execute(&pool) + .await + .expect("genre should insert"); + } + for game in games { + sqlx::query( + "INSERT INTO games ( + game_id, db_id, game_title, game_key, game_release, + game_publisher, game_size, game_readme_de, game_readme_en, + game_readme_fr, game_maxplayers, game_master_req, genre_id, + game_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(game.game_id) + .bind(game.db_id) + .bind(format!("Game {}", game.game_id)) + .bind("key") + .bind("2024") + .bind("publisher") + .bind(1.0_f64) + .bind("readme de") + .bind("readme en") + .bind("readme fr") + .bind(4_i64) + .bind(0_i64) + .bind(game.genre_id) + .bind(game.game_version) + .execute(&pool) + .await + .expect("game should insert"); + } + pool.close().await; + } + + fn create_manifest_root( + root: &Path, + indexed_games: &[(&str, &str)], + artifact_game_ids: &[&str], + ) -> PathBuf { + let manifests = root.join("manifests"); + fs::create_dir(&manifests).expect("manifest root should be created"); + for game_id in artifact_game_ids { + fs::write( + manifests.join(format!("{game_id}.json")), + b"not parsed at startup\n", + ) + .expect("manifest artifact should be created"); + } + let index = CatalogContentIndex::from_entries(indexed_games.iter().enumerate().map( + |(position, (game_id, game_version))| CatalogContentIndexEntry { + game_id: (*game_id).to_owned(), + game_version: (*game_version).to_owned(), + identity: CatalogContentIdentity { + content_id: ContentId::from_bytes( + [u8::try_from(position + 1).expect("test position should fit u8"); 32], + ), + supports_streamed_install: false, + }, + }, + )) + .expect("test content index should validate"); + write_canonical_content_index_atomic(&manifests.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("test content index should publish"); + manifests + } + + fn joined_row(db_id: i64, game_id: &str, game_version: &str) -> JoinedCatalogRow { + JoinedCatalogRow { + db_id, + game_id: game_id.to_owned(), + game_title: format!("Game {game_id}"), + game_key: "key".to_owned(), + game_release: "2024".to_owned(), + game_publisher: "publisher".to_owned(), + game_size: 1.0, + game_readme_de: "readme de".to_owned(), + game_readme_en: "readme en".to_owned(), + game_readme_fr: "readme fr".to_owned(), + game_maxplayers: 4, + game_master_req: 0, + genre_de: "Strategy".to_owned(), + game_version: game_version.to_owned(), + } + } + + #[tokio::test] + async fn loader_pairs_ui_catalog_with_lazy_exact_authority() { + let root = TestDir::new(); + let db = root.path().join("game.db"); + create_catalog_db( + &db, + &[GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }], + &[(10, "Strategy")], + ) + .await; + let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); + + let loaded = load_catalog_bundle(&db, &manifests) + .await + .expect("filename coverage should load without parsing JSON"); + + let game = loaded + .game_db() + .get_game_by_id("g") + .expect("UI game should exist"); + assert_eq!(game.genre, "Strategy"); + assert_eq!(game.eti_game_version.as_deref(), Some("20240101")); + assert!(loaded.bundle().catalog().contains("g")); + assert_eq!( + loaded.bundle().catalog().expected_version("g"), + Some("20240101") + ); + assert_eq!( + loaded + .bundle() + .content_identity("g") + .expect("indexed identity should be available") + .content_id, + ContentId::from_bytes([1; 32]) + ); + assert!(loaded.bundle().cached_manifest("g").is_err()); + assert!(loaded.bundle().manifest("g").is_err()); + fs::remove_file(&db).expect("successful loading must close the catalog database"); + } + + #[tokio::test] + async fn loader_rejects_incomplete_manifest_publication() { + let root = TestDir::new(); + let db = root.path().join("game.db"); + create_catalog_db( + &db, + &[GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }], + &[(10, "Strategy")], + ) + .await; + let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); + fs::write( + manifests.join(lanspread_db::content_manifest::CATALOG_PUBLICATION_MARKER_NAME), + b"lanspread catalog publication v1\n", + ) + .expect("publication marker should be created"); + + let error = load_catalog_bundle(&db, &manifests) + .await + .expect_err("runtime loading must reject an interrupted publication"); + + assert!(error.to_string().contains("publication is incomplete")); + fs::remove_file(&db).expect("validation failure must leave the database closed"); + } + + #[tokio::test] + async fn loader_rejects_duplicate_raw_game_ids_before_hash_authority() { + let root = TestDir::new(); + let db = root.path().join("game.db"); + create_catalog_db( + &db, + &[ + GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }, + GameRow { + db_id: 2, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }, + ], + &[(10, "Strategy")], + ) + .await; + let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); + + let error = load_catalog_bundle(&db, &manifests) + .await + .expect_err("duplicate raw IDs must fail"); + assert!(error.to_string().contains("duplicate raw game ID: g")); + fs::remove_file(&db).expect("validation failure must leave the database closed"); + } + + #[tokio::test] + async fn loader_rejects_missing_genre_expansion() { + let root = TestDir::new(); + let db = root.path().join("game.db"); + create_catalog_db( + &db, + &[GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }], + &[], + ) + .await; + let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); + + let error = load_catalog_bundle(&db, &manifests) + .await + .expect_err("a game without a joined genre must fail"); + assert!(error.to_string().contains("missing genre join expansion")); + } + + #[tokio::test] + async fn loader_rejects_duplicate_genre_expansion() { + let root = TestDir::new(); + let db = root.path().join("game.db"); + create_catalog_db( + &db, + &[GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }], + &[(10, "Strategy"), (10, "Duplicate")], + ) + .await; + let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); + + let error = load_catalog_bundle(&db, &manifests) + .await + .expect_err("ambiguous genre expansion must fail"); + assert!(error.to_string().contains("duplicate genre join expansion")); + } + + #[tokio::test] + async fn production_loader_rejects_identity_and_genre_corruption_matrix() { + let cases = [ + ( + "duplicate raw database ID", + vec![ + GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }, + GameRow { + db_id: 1, + game_id: "h", + game_version: "20240102", + genre_id: 10, + }, + ], + vec![(10, "Strategy")], + "duplicate raw game db_id", + ), + ( + "missing genre join", + vec![GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }], + vec![], + "missing genre join expansion", + ), + ( + "duplicate genre join", + vec![GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }], + vec![(10, "Strategy"), (10, "Duplicate")], + "duplicate genre join expansion", + ), + ]; + + for (label, games, genres, expected_error) in cases { + let root = TestDir::new(); + let db = root.path().join("game.db"); + create_catalog_db(&db, &games, &genres).await; + let indexed_games = games + .iter() + .map(|game| (game.game_id, game.game_version)) + .collect::>() + .into_iter() + .collect::>(); + let game_ids = indexed_games + .iter() + .map(|(game_id, _)| *game_id) + .collect::>(); + let manifests = create_manifest_root(root.path(), &indexed_games, &game_ids); + + let error = load_catalog_bundle(&db, &manifests).await.expect_err(label); + + assert!( + error.to_string().contains(expected_error), + "{label} produced unexpected error: {error:#}" + ); + fs::remove_file(&db).expect("loader failure must leave the database closed"); + } + } + + #[tokio::test] + async fn loader_rejects_inexact_manifest_filename_coverage() { + let root = TestDir::new(); + let db = root.path().join("game.db"); + create_catalog_db( + &db, + &[GameRow { + db_id: 1, + game_id: "g", + game_version: "20240101", + genre_id: 10, + }], + &[(10, "Strategy")], + ) + .await; + let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g", "other"]); + + let error = load_catalog_bundle(&db, &manifests) + .await + .expect_err("unexpected JSON artifacts must fail"); + assert!( + error + .to_string() + .contains("unexpected catalog manifest artifact: other.json") + ); + } + + #[test] + fn assembler_rejects_ui_authority_version_drift() { + let root = TestDir::new(); + let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); + let authority = CatalogAuthorityRow { + db_id: 1, + game_id: "g".to_owned(), + game_version: "20240101".to_owned(), + }; + + let error = assemble_catalog( + vec![authority], + vec![joined_row(1, "g", "20250101")], + &manifests, + ) + .expect_err("UI rows must not drift from the authority snapshot"); + + assert!(error.to_string().contains("identity/version mismatch")); + } + + #[tokio::test] + async fn query_error_is_returned_only_after_pool_close() { + let pool = + SqlitePoolOptions::new().connect_lazy_with(SqliteConnectOptions::new().in_memory(true)); + let query_result: Result<(), sqlx::Error> = Err(sqlx::Error::RowNotFound); + + let error = close_pool_after_query(&pool, query_result) + .await + .expect_err("query failure should propagate"); + + assert!(matches!(error, sqlx::Error::RowNotFound)); + assert!(pool.is_closed()); + } +} diff --git a/crates/lanspread-compat/src/catalog_publisher/catalog.rs b/crates/lanspread-compat/src/catalog_publisher/catalog.rs new file mode 100644 index 0000000..ac78d83 --- /dev/null +++ b/crates/lanspread-compat/src/catalog_publisher/catalog.rs @@ -0,0 +1,220 @@ +use std::{ + collections::{BTreeMap, HashSet}, + ffi::OsStr, + fs, + path::Path, +}; + +use eyre::WrapErr; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; + +/// The authoritative identity/version fields used by manifest publishing. +#[derive(Clone, Debug, Eq, PartialEq, sqlx::FromRow)] +pub struct CatalogGame { + pub game_id: String, + pub game_version: String, +} + +/// Loads every catalog identity directly from `games`, rejecting duplicate IDs +/// instead of inheriting the application's historical last-row-wins behavior. +/// +/// # Errors +/// +/// Returns an error when the database cannot be read, has no games, or contains +/// duplicate game IDs. +pub async fn load_catalog_games(path: &Path) -> eyre::Result> { + validate_regular_file(path, "catalog database")?; + let options = SqliteConnectOptions::new().filename(path).read_only(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .wrap_err_with(|| format!("failed to open catalog database {}", path.display()))?; + let query_result = sqlx::query_as::<_, CatalogGame>( + "SELECT game_id, game_version FROM games ORDER BY game_id, db_id", + ) + .fetch_all(&pool) + .await; + let rows = close_pool_after_query(&pool, query_result) + .await + .wrap_err_with(|| format!("failed to read catalog database {}", path.display()))?; + + let mut games = BTreeMap::new(); + for game in rows { + let game_id = game.game_id.clone(); + if games.insert(game_id.clone(), game).is_some() { + eyre::bail!("catalog database contains duplicate game ID: {game_id}"); + } + } + if games.is_empty() { + eyre::bail!("catalog database contains no games"); + } + Ok(games) +} + +async fn close_pool_after_query( + pool: &SqlitePool, + query_result: Result, +) -> Result { + // `Pool::close` is infallible, so preserve the original query result after + // waiting for every SQLite connection to close on both result paths. + pool.close().await; + debug_assert!(pool.is_closed()); + query_result +} + +pub(super) fn reject_unexpected_manifest_artifacts( + root: &Path, + catalog: &BTreeMap, +) -> eyre::Result<()> { + match fs::symlink_metadata(root) { + Ok(_) => validate_regular_directory(root)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } + + let mut portable_names = HashSet::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + if !path + .extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + continue; + } + let file_name = entry + .file_name() + .into_string() + .map_err(|name| eyre::eyre!("manifest filename is not valid UTF-8: {name:?}"))?; + let game_id = file_name + .strip_suffix(".json") + .ok_or_else(|| eyre::eyre!("manifest suffix must be lowercase .json: {file_name}"))?; + if !catalog.contains_key(game_id) { + eyre::bail!("unexpected catalog manifest artifact: {file_name}"); + } + if !portable_names.insert(game_id.to_uppercase()) { + eyre::bail!("duplicate or platform-alias manifest artifact: {file_name}"); + } + } + Ok(()) +} + +pub(super) fn validate_regular_directory(path: &Path) -> eyre::Result<()> { + let metadata = fs::symlink_metadata(path) + .wrap_err_with(|| format!("failed to inspect directory {}", path.display()))?; + if is_link_or_reparse(&metadata) || !metadata.is_dir() { + eyre::bail!("expected a regular non-link directory: {}", path.display()); + } + Ok(()) +} + +fn validate_regular_file(path: &Path, label: &str) -> eyre::Result<()> { + let metadata = fs::symlink_metadata(path) + .wrap_err_with(|| format!("failed to inspect {label} {}", path.display()))?; + if is_link_or_reparse(&metadata) || !metadata.is_file() { + eyre::bail!("{label} is not a regular non-link file: {}", path.display()); + } + Ok(()) +} + +#[cfg(unix)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(test)] +mod tests { + use std::{ + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + + static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct TempDb(PathBuf); + + impl TempDb { + fn new() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow epoch") + .as_nanos(); + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + Self(std::env::temp_dir().join(format!( + "lanspread-duplicate-catalog-{}-{nanos}-{sequence}.db", + std::process::id() + ))) + } + } + + impl Drop for TempDb { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } + } + + #[tokio::test] + async fn duplicate_database_game_ids_are_rejected() { + let db = TempDb::new(); + let options = SqliteConnectOptions::new() + .filename(&db.0) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("temporary database should open"); + sqlx::query( + "CREATE TABLE games (game_id TEXT NOT NULL, game_version TEXT NOT NULL, db_id INTEGER NOT NULL)", + ) + .execute(&pool) + .await + .expect("table should be created"); + sqlx::query( + "INSERT INTO games (game_id, game_version, db_id) VALUES ('g', '20240101', 1), ('g', '20240101', 2)", + ) + .execute(&pool) + .await + .expect("duplicate rows should be inserted"); + pool.close().await; + + let error = load_catalog_games(&db.0) + .await + .expect_err("duplicate IDs should fail"); + assert!(error.to_string().contains("duplicate game ID: g")); + } + + #[tokio::test] + async fn query_error_is_returned_only_after_pool_close() { + let pool = + SqlitePoolOptions::new().connect_lazy_with(SqliteConnectOptions::new().in_memory(true)); + let query_result: Result<(), sqlx::Error> = Err(sqlx::Error::RowNotFound); + + let error = close_pool_after_query(&pool, query_result) + .await + .expect_err("query failure should propagate"); + + assert!(matches!(error, sqlx::Error::RowNotFound)); + assert!(pool.is_closed()); + } +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(any(unix, windows)))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} diff --git a/crates/lanspread-compat/src/catalog_publisher/cli.rs b/crates/lanspread-compat/src/catalog_publisher/cli.rs new file mode 100644 index 0000000..5ffb0f3 --- /dev/null +++ b/crates/lanspread-compat/src/catalog_publisher/cli.rs @@ -0,0 +1,277 @@ +//! Minimal, dependency-free command-line surface for the catalog publisher. + +use std::{collections::BTreeSet, ffi::OsString, path::PathBuf}; + +use super::{ + CatalogSelection, + CheckOptions, + GenerateOptions, + check_catalog_manifests, + default_manifests_dir, + generate_catalog_manifests, +}; + +pub const HELP: &str = "\ +Usage: + lanspread-catalog-publisher generate --catalog-db PATH --packages-dir PATH --unrar PATH [--manifests-dir PATH] (--all | --game-id ID...) + lanspread-catalog-publisher check --catalog-db PATH [--manifests-dir PATH] (--all | --game-id ID...) + +The default manifests directory is manifests/ beside game.db."; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PublisherCommand { + Generate(GenerateOptions), + Check(CheckOptions), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ParseOutcome { + Help, + Command(PublisherCommand), +} + +#[derive(Default)] +struct RawOptions { + catalog_db: Option, + packages_dir: Option, + manifests_dir: Option, + unrar: Option, + all: bool, + game_ids: BTreeSet, +} + +/// Parses arguments after the executable name. +/// +/// # Errors +/// +/// Returns an error for an unknown, missing, repeated, or conflicting option. +pub fn parse_args(args: impl IntoIterator) -> eyre::Result { + let mut args = args.into_iter(); + let Some(subcommand) = args.next() else { + eyre::bail!("missing subcommand; use --help for usage"); + }; + if matches!(subcommand.to_str(), Some("--help" | "-h")) { + return Ok(ParseOutcome::Help); + } + let subcommand = subcommand + .into_string() + .map_err(|value| eyre::eyre!("subcommand is not valid UTF-8: {value:?}"))?; + if !matches!(subcommand.as_str(), "generate" | "check") { + eyre::bail!("unknown subcommand: {subcommand}"); + } + + let mut raw = RawOptions::default(); + while let Some(argument) = args.next() { + match argument.to_str() { + Some("--help" | "-h") => return Ok(ParseOutcome::Help), + Some("--catalog-db") => { + set_once( + &mut raw.catalog_db, + next_path(&mut args, "--catalog-db")?, + "--catalog-db", + )?; + } + Some("--packages-dir") => { + set_once( + &mut raw.packages_dir, + next_path(&mut args, "--packages-dir")?, + "--packages-dir", + )?; + } + Some("--manifests-dir") => { + set_once( + &mut raw.manifests_dir, + next_path(&mut args, "--manifests-dir")?, + "--manifests-dir", + )?; + } + Some("--unrar") => { + set_once(&mut raw.unrar, next_path(&mut args, "--unrar")?, "--unrar")?; + } + Some("--all") => { + if raw.all { + eyre::bail!("--all may be specified only once"); + } + raw.all = true; + } + Some("--game-id") => { + let game_id = next_utf8(&mut args, "--game-id")?; + if !raw.game_ids.insert(game_id.clone()) { + eyre::bail!("duplicate --game-id selector: {game_id}"); + } + } + Some(other) => eyre::bail!("unknown argument: {other}"), + None => eyre::bail!("argument is not valid UTF-8: {argument:?}"), + } + } + + let catalog_db = raw + .catalog_db + .ok_or_else(|| eyre::eyre!("--catalog-db is required"))?; + let manifests_dir = raw + .manifests_dir + .unwrap_or_else(|| default_manifests_dir(&catalog_db)); + let selection = parse_selection(raw.all, raw.game_ids)?; + + match subcommand.as_str() { + "generate" => Ok(ParseOutcome::Command(PublisherCommand::Generate( + GenerateOptions { + catalog_db, + packages_dir: raw + .packages_dir + .ok_or_else(|| eyre::eyre!("--packages-dir is required for generate"))?, + manifests_dir, + unrar: raw + .unrar + .ok_or_else(|| eyre::eyre!("--unrar is required for generate"))?, + selection, + }, + ))), + "check" => { + if raw.packages_dir.is_some() { + eyre::bail!("--packages-dir is not valid for check"); + } + if raw.unrar.is_some() { + eyre::bail!("--unrar is not valid for check"); + } + Ok(ParseOutcome::Command(PublisherCommand::Check( + CheckOptions { + catalog_db, + manifests_dir, + selection, + }, + ))) + } + _ => unreachable!("subcommand was validated above"), + } +} + +/// Runs one parsed command and returns deterministic stdout lines. +/// +/// # Errors +/// +/// Returns an error when generation or checking fails. +pub async fn execute(command: &PublisherCommand) -> eyre::Result> { + let (verb, reports) = match command { + PublisherCommand::Generate(options) => { + ("generated", generate_catalog_manifests(options).await?) + } + PublisherCommand::Check(options) => ("checked", check_catalog_manifests(options).await?), + }; + let mut lines = reports + .into_iter() + .map(|report| { + format!( + "{verb} game_id={} game_version={} content_id={}", + report.game_id, report.game_version, report.content_id + ) + }) + .collect::>(); + lines.push(format!("{verb} total={}", lines.len())); + Ok(lines) +} + +fn parse_selection(all: bool, game_ids: BTreeSet) -> eyre::Result { + match (all, game_ids.is_empty()) { + (true, true) => Ok(CatalogSelection::All), + (false, false) => Ok(CatalogSelection::GameIds(game_ids)), + (true, false) => eyre::bail!("--all cannot be combined with --game-id"), + (false, true) => eyre::bail!("select games with --all or at least one --game-id"), + } +} + +fn next_path(args: &mut impl Iterator, option: &str) -> eyre::Result { + args.next() + .map(PathBuf::from) + .ok_or_else(|| eyre::eyre!("{option} requires a value")) +} + +fn next_utf8(args: &mut impl Iterator, option: &str) -> eyre::Result { + args.next() + .ok_or_else(|| eyre::eyre!("{option} requires a value"))? + .into_string() + .map_err(|value| eyre::eyre!("{option} value is not valid UTF-8: {value:?}")) +} + +fn set_once(slot: &mut Option, value: T, option: &str) -> eyre::Result<()> { + if slot.replace(value).is_some() { + eyre::bail!("{option} may be specified only once"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(arguments: &[&str]) -> eyre::Result { + parse_args(arguments.iter().map(OsString::from)) + } + + #[test] + fn generate_defaults_to_sibling_manifests_and_sorts_selectors() { + let outcome = parse(&[ + "generate", + "--catalog-db", + "/catalog/game.db", + "--packages-dir", + "/packages", + "--unrar", + "/tools/unrar", + "--game-id", + "zeta", + "--game-id", + "alpha", + ]) + .expect("arguments should parse"); + let ParseOutcome::Command(PublisherCommand::Generate(options)) = outcome else { + panic!("expected generate command"); + }; + assert_eq!(options.manifests_dir, PathBuf::from("/catalog/manifests")); + assert_eq!( + options.selection, + CatalogSelection::GameIds(BTreeSet::from(["alpha".to_owned(), "zeta".to_owned()])) + ); + } + + #[test] + fn selectors_are_explicit_and_mutually_exclusive() { + for arguments in [ + vec!["check", "--catalog-db", "game.db"], + vec![ + "check", + "--catalog-db", + "game.db", + "--all", + "--game-id", + "g", + ], + vec![ + "check", + "--catalog-db", + "game.db", + "--game-id", + "g", + "--game-id", + "g", + ], + ] { + assert!(parse(&arguments).is_err(), "accepted {arguments:?}"); + } + } + + #[test] + fn check_rejects_generation_only_inputs() { + assert!( + parse(&[ + "check", + "--catalog-db", + "game.db", + "--packages-dir", + "packages", + "--all", + ]) + .is_err() + ); + } +} diff --git a/crates/lanspread-compat/src/catalog_publisher/fixture.rs b/crates/lanspread-compat/src/catalog_publisher/fixture.rs new file mode 100644 index 0000000..08e965f --- /dev/null +++ b/crates/lanspread-compat/src/catalog_publisher/fixture.rs @@ -0,0 +1,504 @@ +//! Test-only catalog authority generation for peer-CLI fixtures. +//! +//! This module deliberately lives beside the production publisher, but is not +//! used by either application runtime. It lets acceptance tests derive an +//! isolated catalog from explicitly selected fixture packages without +//! reimplementing manifest hashing in the scenario harness. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use eyre::WrapErr; +use lanspread_db::content_manifest::{ + CATALOG_CONTENT_INDEX_NAME, + CatalogContentIndex, + write_canonical_content_index_atomic, + write_canonical_manifest_atomic, +}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + +use super::{ + CatalogGame, + CatalogSelection, + CheckOptions, + ManifestReport, + catalog::{load_catalog_games, validate_regular_directory}, + check_catalog_manifests, + package::{StreamedInstallPolicy, build_manifest_from_package_with_policy}, +}; +use crate::catalog_bundle::load_catalog_bundle; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// One canonical package selected for a generated acceptance-test catalog. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FixturePackage { + pub game_id: String, + pub package_root: PathBuf, + pub streamed_install: bool, +} + +/// Inputs for generating a complete, reduced acceptance-test catalog. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FixtureCatalogOptions { + pub source_catalog_db: PathBuf, + pub output_dir: PathBuf, + pub unrar: PathBuf, + pub packages: Vec, +} + +/// Builds an isolated `game.db` plus sibling `manifests/` from fixture bytes. +/// +/// The output database retains the selected production catalog rows and their +/// exact genre rows. Manifests are generated twice from each package before a +/// fully checked staging directory replaces the requested output. Setting +/// `FixturePackage::streamed_install` to false is reserved for synthetic +/// transfer fixtures whose `.eti` files intentionally are not RAR archives; +/// such manifests never authorize Stream Install. +/// +/// # Errors +/// +/// Returns an error for an unknown or duplicate game, unsafe package/output +/// shape, database failure, non-reproducible package, or invalid manifest. +pub async fn generate_fixture_catalog( + options: &FixtureCatalogOptions, +) -> eyre::Result> { + let source_catalog = load_catalog_games(&options.source_catalog_db).await?; + let packages = validate_packages(&source_catalog, &options.packages)?; + let staging = StagingDirectory::create(&options.output_dir)?; + let staged_db = staging.path().join("game.db"); + let staged_manifests = staging.path().join("manifests"); + + copy_filtered_catalog( + &options.source_catalog_db, + &staged_db, + packages.keys().map(String::as_str), + ) + .await?; + fs::create_dir(&staged_manifests).wrap_err_with(|| { + format!( + "failed to create fixture manifest directory {}", + staged_manifests.display() + ) + })?; + + let staged_catalog = load_catalog_games(&staged_db).await?; + let mut generated_manifests = Vec::with_capacity(packages.len()); + for (game_id, package) in &packages { + let game = staged_catalog + .get(game_id) + .ok_or_else(|| eyre::eyre!("filtered catalog lost selected game ID: {game_id}"))?; + let policy = if package.streamed_install { + StreamedInstallPolicy::Required + } else { + StreamedInstallPolicy::DisabledForSyntheticFixture + }; + let first = build_manifest_from_package_with_policy( + game, + &package.package_root, + &options.unrar, + policy, + ) + .wrap_err_with(|| format!("failed to generate fixture manifest for {game_id}"))?; + let second = build_manifest_from_package_with_policy( + game, + &package.package_root, + &options.unrar, + policy, + ) + .wrap_err_with(|| format!("failed to verify fixture package for {game_id}"))?; + if first != second { + eyre::bail!("fixture package changed between hashing passes: {game_id}"); + } + write_canonical_manifest_atomic(&staged_manifests.join(format!("{game_id}.json")), &first)?; + generated_manifests.push(first); + } + let content_index = CatalogContentIndex::from_manifests(&generated_manifests)?; + write_canonical_content_index_atomic( + &staged_manifests.join(CATALOG_CONTENT_INDEX_NAME), + &content_index, + )?; + + let reports = check_catalog_manifests(&CheckOptions { + catalog_db: staged_db.clone(), + manifests_dir: staged_manifests.clone(), + selection: CatalogSelection::All, + }) + .await?; + load_catalog_bundle(&staged_db, &staged_manifests) + .await + .wrap_err("generated fixture catalog failed application loader validation")?; + + staging.install(&options.output_dir)?; + Ok(reports) +} + +fn validate_packages<'a>( + catalog: &BTreeMap, + packages: &'a [FixturePackage], +) -> eyre::Result> { + if packages.is_empty() { + eyre::bail!("fixture catalog requires at least one package"); + } + let mut selected = BTreeMap::new(); + let mut portable_ids = BTreeSet::new(); + for package in packages { + let game = catalog + .get(&package.game_id) + .ok_or_else(|| eyre::eyre!("unknown fixture game ID: {}", package.game_id))?; + if game.game_id != package.game_id { + eyre::bail!("fixture game ID does not exactly match game.db"); + } + if package + .package_root + .file_name() + .and_then(|name| name.to_str()) + != Some(package.game_id.as_str()) + { + eyre::bail!( + "fixture package root must be named exactly {}: {}", + package.game_id, + package.package_root.display() + ); + } + let portable = package.game_id.to_uppercase(); + if !portable_ids.insert(portable) + || selected.insert(package.game_id.clone(), package).is_some() + { + eyre::bail!( + "duplicate or platform-alias fixture game ID: {}", + package.game_id + ); + } + } + Ok(selected) +} + +async fn copy_filtered_catalog<'a>( + source: &Path, + destination: &Path, + selected_ids: impl Iterator, +) -> eyre::Result<()> { + fs::copy(source, destination).wrap_err_with(|| { + format!( + "failed to copy source catalog {} to {}", + source.display(), + destination.display() + ) + })?; + let selected_ids = selected_ids.map(str::to_owned).collect::>(); + let all_games = load_catalog_games(destination).await?; + let options = SqliteConnectOptions::new().filename(destination); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .wrap_err_with(|| format!("failed to open fixture catalog {}", destination.display()))?; + let mutation_result = async { + let mut transaction = pool.begin().await?; + for game_id in all_games.keys() { + if !selected_ids.contains(game_id) { + sqlx::query("DELETE FROM games WHERE game_id = ?") + .bind(game_id) + .execute(&mut *transaction) + .await?; + } + } + sqlx::query( + "DELETE FROM genre + WHERE genre_id NOT IN (SELECT DISTINCT genre_id FROM games)", + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + sqlx::query("VACUUM").execute(&pool).await?; + Ok::<(), sqlx::Error>(()) + } + .await; + pool.close().await; + mutation_result.wrap_err("failed to filter fixture catalog database")?; + Ok(()) +} + +struct StagingDirectory { + path: Option, +} + +impl StagingDirectory { + fn create(output: &Path) -> eyre::Result { + let parent = output + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + validate_regular_directory(parent)?; + if let Ok(metadata) = fs::symlink_metadata(output) + && (!metadata.is_dir() || is_link_or_reparse(&metadata)) + { + eyre::bail!( + "fixture catalog output is not a regular non-link directory: {}", + output.display() + ); + } + let stem = output + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| eyre::eyre!("fixture catalog output needs a UTF-8 directory name"))?; + for _ in 0..100 { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let candidate = parent.join(format!(".{stem}.tmp-{}-{sequence}", std::process::id())); + match fs::create_dir(&candidate) { + Ok(()) => { + return Ok(Self { + path: Some(candidate), + }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + } + eyre::bail!("could not allocate a fixture catalog staging directory") + } + + fn path(&self) -> &Path { + match self.path.as_deref() { + Some(path) => path, + None => panic!("staging directory is present until installation"), + } + } + + fn install(mut self, output: &Path) -> eyre::Result<()> { + let staging = self.path().to_path_buf(); + if !output.exists() { + fs::rename(&staging, output)?; + self.path = None; + return Ok(()); + } + + validate_regular_directory(output)?; + let parent = output + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let stem = output + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| eyre::eyre!("fixture catalog output needs a UTF-8 directory name"))?; + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let backup = parent.join(format!(".{stem}.old-{}-{sequence}", std::process::id())); + fs::rename(output, &backup)?; + if let Err(error) = fs::rename(&staging, output) { + let restore = fs::rename(&backup, output); + return match restore { + Ok(()) => Err(error.into()), + Err(restore_error) => Err(eyre::eyre!( + "failed to install fixture catalog: {error}; failed to restore previous output: {restore_error}" + )), + }; + } + self.path = None; + fs::remove_dir_all(&backup).wrap_err_with(|| { + format!( + "installed fixture catalog but failed to remove backup {}", + backup.display() + ) + })?; + Ok(()) + } +} + +impl Drop for StagingDirectory { + fn drop(&mut self) { + if let Some(path) = self.path.take() { + let _ = fs::remove_dir_all(path); + } + } +} + +#[cfg(unix)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(test)] +mod tests { + use std::{ + path::Path, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "lanspread-fixture-catalog-{}-{nanos}", + std::process::id() + )); + fs::create_dir(&path).expect("test directory should be created"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + async fn create_source_catalog(path: &Path) { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("source catalog should open"); + sqlx::query( + "CREATE TABLE games ( + game_id TEXT, db_id INTEGER PRIMARY KEY, game_title TEXT, + game_key TEXT, game_release TEXT, game_publisher TEXT, + game_size NUMERIC, game_readme_de TEXT, game_readme_en TEXT, + game_readme_fr TEXT, game_maxplayers INTEGER, + game_master_req INTEGER, genre_id INTEGER, game_version TEXT + )", + ) + .execute(&pool) + .await + .expect("games table should be created"); + sqlx::query( + "CREATE TABLE genre ( + genre_id INTEGER PRIMARY KEY, genre_de TEXT, + genre_en TEXT, genre_fr TEXT + )", + ) + .execute(&pool) + .await + .expect("genre table should be created"); + sqlx::query( + "INSERT INTO genre VALUES + (1, 'Strategy', 'Strategy', 'Strategy'), + (2, 'Shooter', 'Shooter', 'Shooter')", + ) + .execute(&pool) + .await + .expect("genres should be inserted"); + sqlx::query( + "INSERT INTO games VALUES + ('keep', 1, 'Keep', '', '', '', 1, '', '', '', 1, 0, 1, '20250101'), + ('drop', 2, 'Drop', '', '', '', 1, '', '', '', 1, 0, 2, '20250102')", + ) + .execute(&pool) + .await + .expect("games should be inserted"); + pool.close().await; + } + + #[tokio::test] + async fn generates_exact_reduced_catalog_and_replaces_it_coherently() { + let root = TestDirectory::new(); + let source_db = root.path().join("source.db"); + create_source_catalog(&source_db).await; + let package_root = root.path().join("packages/keep"); + fs::create_dir_all(&package_root).expect("package root should be created"); + fs::write(package_root.join("version.ini"), "20250101") + .expect("fixture version should be written"); + fs::write(package_root.join("keep.eti"), b"not a RAR: first") + .expect("fixture payload should be written"); + let output_dir = root.path().join("output"); + let options = FixtureCatalogOptions { + source_catalog_db: source_db, + output_dir: output_dir.clone(), + unrar: root.path().join("unused-unrar"), + packages: vec![FixturePackage { + game_id: "keep".to_owned(), + package_root: package_root.clone(), + streamed_install: false, + }], + }; + + let first = generate_fixture_catalog(&options) + .await + .expect("first fixture catalog should generate"); + assert_eq!(first.len(), 1); + let games = load_catalog_games(&output_dir.join("game.db")) + .await + .expect("generated catalog should load"); + assert_eq!( + games.keys().map(String::as_str).collect::>(), + vec!["keep"] + ); + let manifest_bytes = fs::read(output_dir.join("manifests/keep.json")) + .expect("fixture manifest should be readable"); + let manifest = lanspread_db::content_manifest::CatalogContentManifest::from_json_slice( + &manifest_bytes, + ) + .expect("fixture manifest should parse"); + assert!(!manifest.supports_streamed_install()); + let first_index_bytes = fs::read( + output_dir + .join("manifests") + .join(CATALOG_CONTENT_INDEX_NAME), + ) + .expect("fixture content index should be readable"); + let first_index = CatalogContentIndex::from_json_slice(&first_index_bytes) + .expect("fixture content index should parse"); + assert_eq!( + first_index + .content_identity("keep") + .expect("fixture identity should be indexed") + .content_id, + manifest.content_id() + ); + + fs::write(package_root.join("keep.eti"), b"not a RAR: second") + .expect("fixture payload should change"); + let second = generate_fixture_catalog(&options) + .await + .expect("replacement fixture catalog should generate"); + assert_ne!(first[0].content_id, second[0].content_id); + let second_index_bytes = fs::read( + output_dir + .join("manifests") + .join(CATALOG_CONTENT_INDEX_NAME), + ) + .expect("replacement content index should be readable"); + assert_ne!(first_index_bytes, second_index_bytes); + check_catalog_manifests(&CheckOptions { + catalog_db: output_dir.join("game.db"), + manifests_dir: output_dir.join("manifests"), + selection: CatalogSelection::All, + }) + .await + .expect("installed replacement profile should be coherent"); + } +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(any(unix, windows)))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} diff --git a/crates/lanspread-compat/src/catalog_publisher/mod.rs b/crates/lanspread-compat/src/catalog_publisher/mod.rs new file mode 100644 index 0000000..e106f4d --- /dev/null +++ b/crates/lanspread-compat/src/catalog_publisher/mod.rs @@ -0,0 +1,932 @@ +//! Reproducible catalog-manifest publishing and checking. +//! +//! Production generation is deliberately a separate workflow from the peer +//! runtime: only the canonical package corpus and `game.db` may establish the +//! expected content hashes. + +mod catalog; +pub mod cli; +pub mod fixture; +mod package; +mod unrar; + +#[cfg(unix)] +use std::fs::File; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +pub use catalog::{CatalogGame, load_catalog_games}; +use eyre::WrapErr; +use lanspread_db::content_manifest::{ + CATALOG_CONTENT_INDEX_NAME, + CATALOG_PUBLICATION_MARKER_NAME, + CatalogContentIdentity, + CatalogContentIndex, + CatalogContentIndexEntry, + CatalogContentManifest, + CatalogManifestStore, + reject_incomplete_catalog_publication, + write_canonical_content_index_atomic, + write_canonical_manifest_atomic, +}; +pub use package::{build_manifest_from_package, verify_manifest_against_package}; + +/// The explicit set of catalog rows operated on by the publisher. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CatalogSelection { + /// Operate on every row in `game.db`. + All, + /// Operate on these exact game IDs. + GameIds(BTreeSet), +} + +impl CatalogSelection { + fn select<'a>( + &self, + catalog: &'a BTreeMap, + ) -> eyre::Result> { + match self { + Self::All => Ok(catalog.values().collect()), + Self::GameIds(game_ids) => { + if game_ids.is_empty() { + eyre::bail!("catalog selection cannot be empty"); + } + game_ids + .iter() + .map(|game_id| { + catalog + .get(game_id) + .ok_or_else(|| eyre::eyre!("unknown catalog game ID: {game_id}")) + }) + .collect() + } + } + } +} + +/// Inputs for reproducibly generating one or more catalog manifests. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GenerateOptions { + pub catalog_db: PathBuf, + pub packages_dir: PathBuf, + pub manifests_dir: PathBuf, + pub unrar: PathBuf, + pub selection: CatalogSelection, +} + +/// Inputs for checking already-published manifest artifacts without packages. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckOptions { + pub catalog_db: PathBuf, + pub manifests_dir: PathBuf, + pub selection: CatalogSelection, +} + +/// Stable information reported after generating or checking one artifact. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ManifestReport { + pub game_id: String, + pub game_version: String, + pub content_id: String, +} + +#[derive(Debug)] +struct PreparedManifest { + game_id: String, + artifact: PathBuf, + manifest: CatalogContentManifest, +} + +/// Generates, independently rereads, and publishes selected manifests in +/// game-ID order. +/// +/// The second build is intentional: every selected package and archive output +/// must reproduce its exact first-pass manifest before any artifact becomes +/// visible. Publication is guarded by a durable marker, so an interrupted +/// multi-artifact update makes subsequent checks fail closed. +/// +/// # Errors +/// +/// Returns an error when catalog loading, package validation, independent +/// verification, atomic publication, or published-artifact rereading fails. +pub async fn generate_catalog_manifests( + options: &GenerateOptions, +) -> eyre::Result> { + let catalog = load_catalog_games(&options.catalog_db).await?; + let versions = catalog_versions(&catalog); + reject_incomplete_publication(&options.manifests_dir)?; + let selected = options.selection.select(&catalog)?; + let prepared = selected + .into_iter() + .map(|game| prepare_manifest(game, options)) + .collect::>>()?; + + // This is only an early usability check. It is deliberately discarded: + // the authoritative incremental snapshot is loaded again after this + // publisher owns the durable marker. + if matches!(&options.selection, CatalogSelection::GameIds(_)) { + let preflight = CatalogManifestStore::new(&options.manifests_dir, versions.clone()) + .wrap_err("incremental generation requires a complete indexed catalog corpus")?; + preflight + .validate_coverage() + .wrap_err("incremental generation requires complete manifest coverage")?; + } + + publish_prepared_catalog_manifests(options, &catalog, versions, &prepared) +} + +fn publish_prepared_catalog_manifests( + options: &GenerateOptions, + catalog: &BTreeMap, + versions: BTreeMap, + prepared: &[PreparedManifest], +) -> eyre::Result> { + fs::create_dir_all(&options.manifests_dir).wrap_err_with(|| { + format!( + "failed to create manifest directory {}", + options.manifests_dir.display() + ) + })?; + catalog::validate_regular_directory(&options.manifests_dir)?; + + // `create_new` on the marker is the writer-exclusion boundary. Every + // source used for a mixed incremental index is acquired after this point. + let marker = begin_publication(&options.manifests_dir, prepared)?; + catalog::reject_unexpected_manifest_artifacts(&options.manifests_dir, catalog)?; + + for prepared_manifest in prepared { + package::validate_manifest_destination(&prepared_manifest.artifact)?; + } + let index_artifact = options.manifests_dir.join(CATALOG_CONTENT_INDEX_NAME); + package::validate_manifest_destination(&index_artifact)?; + + let unselected_identities = if matches!(&options.selection, CatalogSelection::All) { + BTreeMap::new() + } else { + let store = CatalogManifestStore::new(&options.manifests_dir, versions.clone()) + .wrap_err("incremental generation requires a complete indexed catalog corpus")?; + store + .validate_coverage() + .wrap_err("incremental generation requires complete manifest coverage")?; + validated_unselected_identities(catalog, prepared, &store)? + }; + let content_index = proposed_content_index(catalog, prepared, &unselected_identities)?; + + for prepared_manifest in prepared { + let game_id = &prepared_manifest.game_id; + write_canonical_manifest_atomic(&prepared_manifest.artifact, &prepared_manifest.manifest) + .wrap_err_with(|| format!("failed to publish manifest for {game_id}"))?; + } + write_canonical_content_index_atomic(&index_artifact, &content_index) + .wrap_err("failed to publish catalog content index")?; + + let store = CatalogManifestStore::new(&options.manifests_dir, versions)?; + if matches!(&options.selection, CatalogSelection::All) { + store.validate_all()?; + } else { + store.validate_coverage()?; + } + + let mut reports = Vec::with_capacity(prepared.len()); + for prepared_manifest in prepared { + let published = store.load(&prepared_manifest.game_id).wrap_err_with(|| { + format!( + "failed to reread manifest for {}", + prepared_manifest.game_id + ) + })?; + if published.as_ref() != &prepared_manifest.manifest { + eyre::bail!( + "published manifest differs from verified manifest for {}", + prepared_manifest.game_id + ); + } + reports.push(report(&prepared_manifest.manifest)); + } + + finish_publication(&marker)?; + Ok(reports) +} + +/// Checks canonical JSON, content IDs, versions, filenames, and selected +/// `game.db` coverage without requiring the production package corpus. +/// +/// # Errors +/// +/// Returns an error when the catalog or any required artifact is invalid. +pub async fn check_catalog_manifests(options: &CheckOptions) -> eyre::Result> { + let catalog = load_catalog_games(&options.catalog_db).await?; + catalog::validate_regular_directory(&options.manifests_dir)?; + reject_incomplete_publication(&options.manifests_dir)?; + let store = CatalogManifestStore::new(&options.manifests_dir, catalog_versions(&catalog))?; + let selected = options.selection.select(&catalog)?; + + let result = (|| { + if matches!(&options.selection, CatalogSelection::All) { + store.validate_all()?; + } else { + store.validate_coverage()?; + } + + selected + .into_iter() + .map(|game| { + let manifest = store + .load(&game.game_id) + .wrap_err_with(|| format!("failed to check manifest for {}", game.game_id))?; + Ok(report(&manifest)) + }) + .collect() + })(); + reject_incomplete_publication(&options.manifests_dir)?; + result +} + +fn proposed_content_index( + catalog: &BTreeMap, + prepared: &[PreparedManifest], + validated_unselected: &BTreeMap, +) -> eyre::Result { + let prepared = prepared + .iter() + .map(|prepared| (prepared.game_id.as_str(), &prepared.manifest)) + .collect::>(); + let mut entries = Vec::with_capacity(catalog.len()); + for game in catalog.values() { + let identity = if let Some(manifest) = prepared.get(game.game_id.as_str()) { + CatalogContentIdentity::from_manifest(manifest) + } else { + validated_unselected + .get(&game.game_id) + .copied() + .ok_or_else(|| { + eyre::eyre!( + "complete catalog generation did not prepare game {}", + game.game_id + ) + })? + }; + entries.push(CatalogContentIndexEntry { + game_id: game.game_id.clone(), + game_version: game.game_version.clone(), + identity, + }); + } + CatalogContentIndex::from_entries(entries) +} + +fn validated_unselected_identities( + catalog: &BTreeMap, + prepared: &[PreparedManifest], + store: &CatalogManifestStore, +) -> eyre::Result> { + let selected = prepared + .iter() + .map(|prepared| prepared.game_id.as_str()) + .collect::>(); + let mut identities = BTreeMap::new(); + for game_id in catalog + .keys() + .filter(|game_id| !selected.contains(game_id.as_str())) + { + let manifest = store + .load(game_id) + .wrap_err_with(|| format!("failed to validate unselected manifest for {game_id}"))?; + identities.insert( + game_id.clone(), + CatalogContentIdentity::from_manifest(&manifest), + ); + } + Ok(identities) +} + +fn prepare_manifest( + game: &CatalogGame, + options: &GenerateOptions, +) -> eyre::Result { + let package_root = options.packages_dir.join(&game.game_id); + let manifest = build_manifest_from_package(game, &package_root, &options.unrar) + .wrap_err_with(|| format!("failed to generate manifest for {}", game.game_id))?; + verify_manifest_against_package(&manifest, &package_root, &options.unrar) + .wrap_err_with(|| format!("independent verification failed for {}", game.game_id))?; + Ok(PreparedManifest { + game_id: game.game_id.clone(), + artifact: options.manifests_dir.join(format!("{}.json", game.game_id)), + manifest, + }) +} + +fn reject_incomplete_publication(root: &Path) -> eyre::Result<()> { + reject_incomplete_catalog_publication(root) +} + +fn begin_publication(root: &Path, prepared: &[PreparedManifest]) -> eyre::Result { + reject_incomplete_publication(root)?; + let marker = root.join(CATALOG_PUBLICATION_MARKER_NAME); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&marker) + .wrap_err_with(|| format!("failed to create publication marker {}", marker.display()))?; + file.write_all(b"lanspread catalog publication v1\n")?; + for prepared_manifest in prepared { + writeln!( + file, + "{} {}", + prepared_manifest.game_id, + prepared_manifest.manifest.content_id() + )?; + } + file.sync_all()?; + drop(file); + sync_directory(root)?; + Ok(marker) +} + +fn finish_publication(marker: &Path) -> eyre::Result<()> { + fs::remove_file(marker) + .wrap_err_with(|| format!("failed to remove publication marker {}", marker.display()))?; + sync_directory(marker.parent().unwrap_or_else(|| Path::new("."))) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> eyre::Result<()> { + File::open(path)?.sync_all()?; + Ok(()) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> eyre::Result<()> { + Ok(()) +} + +fn catalog_versions(catalog: &BTreeMap) -> BTreeMap { + catalog + .iter() + .map(|(game_id, game)| (game_id.clone(), game.game_version.clone())) + .collect() +} + +fn report(manifest: &CatalogContentManifest) -> ManifestReport { + ManifestReport { + game_id: manifest.game_id().to_owned(), + game_version: manifest.game_version().to_owned(), + content_id: manifest.content_id().to_string(), + } +} + +/// Returns the default manifest directory beside `game.db`. +#[must_use] +pub fn default_manifests_dir(catalog_db: &Path) -> PathBuf { + catalog_db + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .join("manifests") +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use lanspread_db::content_manifest::Blake3Digest; + + use super::*; + + static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow epoch") + .as_nanos(); + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "lanspread-catalog-publisher-{}-{nanos}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("temporary directory should be created"); + Self(path) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn fixture_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../lanspread-peer-cli/fixtures") + .join(relative) + } + + fn test_unrar() -> Option { + #[cfg(target_os = "linux")] + { + let bundled = Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu", + ); + if bundled.is_file() { + return Some(bundled); + } + } + ["/usr/local/bin/unrar", "/usr/bin/unrar"] + .into_iter() + .map(PathBuf::from) + .find(|path| path.is_file()) + } + + fn simple_package(temp: &TempDir, game_id: &str, version: &str) -> PathBuf { + let root = temp.0.join("packages").join(game_id); + fs::create_dir_all(&root).expect("game root should be created"); + fs::write(root.join("version.ini"), version).expect("version should be written"); + fs::write(root.join("payload.bin"), b"payload").expect("payload should be written"); + root + } + + async fn create_catalog(path: &Path, games: &[(&str, &str)]) { + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("temporary catalog should open"); + sqlx::query( + "CREATE TABLE games (game_id TEXT NOT NULL, game_version TEXT NOT NULL, db_id INTEGER NOT NULL)", + ) + .execute(&pool) + .await + .expect("games table should be created"); + for (db_id, (game_id, game_version)) in games.iter().enumerate() { + sqlx::query("INSERT INTO games (game_id, game_version, db_id) VALUES (?, ?, ?)") + .bind(*game_id) + .bind(*game_version) + .bind(i64::try_from(db_id).expect("test catalog row count should fit i64")) + .execute(&pool) + .await + .expect("catalog row should be inserted"); + } + pool.close().await; + } + + #[test] + fn real_rar_fixture_builds_and_reproduces_extracted_hashes() { + let Some(unrar) = test_unrar() else { + return; + }; + let game = CatalogGame { + game_id: "css".to_owned(), + game_version: "20240623".to_owned(), + }; + let root = fixture_path("fixture-persona/css"); + let manifest = build_manifest_from_package(&game, &root, &unrar) + .expect("real RAR fixture should publish"); + + assert_eq!(manifest.files().len(), 2); + assert_eq!(manifest.streamed_install_files().len(), 10); + let readme = manifest + .streamed_install_entry("readme.txt") + .expect("readme should be described"); + assert_eq!(readme.size(), 17); + assert_eq!( + readme.file_blake3(), + Some(Blake3Digest::hash(b"css game payload\n")) + ); + verify_manifest_against_package(&manifest, &root, &unrar) + .expect("independent real-RAR reread should match"); + } + + #[test] + fn real_solid_and_multi_archive_fixtures_are_supported() { + let Some(unrar) = test_unrar() else { + return; + }; + let game = CatalogGame { + game_id: "cnctw".to_owned(), + game_version: "20160128".to_owned(), + }; + for (fixture, expected_files) in [ + ( + "fixture-solid/cnctw", + &["bin/cnctw-solid-payload.bin", "data/cnctw-solid-assets.dat"][..], + ), + ( + "fixture-multi/cnctw", + &["order/first.txt", "order/second.txt"][..], + ), + ] { + let root = fixture_path(fixture); + let manifest = build_manifest_from_package(&game, &root, &unrar) + .expect("RAR fixture should publish"); + for path in expected_files { + assert!( + manifest.streamed_install_entry(path).is_some(), + "missing {path} from {fixture}" + ); + } + verify_manifest_against_package(&manifest, &root, &unrar) + .expect("RAR fixture should reproduce"); + } + } + + #[tokio::test] + async fn full_bootstrap_and_indexed_incremental_generation_are_reproducible() { + let Some(unrar) = test_unrar() else { + return; + }; + let temp = TempDir::new(); + let fixture_catalog_db = temp.0.join("game.db"); + create_catalog(&fixture_catalog_db, &[("css", "20240623")]).await; + let manifests_dir = temp.0.join("manifests"); + let bootstrap_options = GenerateOptions { + catalog_db: fixture_catalog_db, + packages_dir: fixture_path("fixture-persona"), + manifests_dir: manifests_dir.clone(), + unrar, + selection: CatalogSelection::All, + }; + + let first = generate_catalog_manifests(&bootstrap_options) + .await + .expect("complete fixture should bootstrap"); + assert!(!manifests_dir.join(CATALOG_PUBLICATION_MARKER_NAME).exists()); + assert!(manifests_dir.join(CATALOG_CONTENT_INDEX_NAME).is_file()); + let incremental_options = GenerateOptions { + selection: CatalogSelection::GameIds(BTreeSet::from(["css".to_owned()])), + ..bootstrap_options + }; + let second = generate_catalog_manifests(&incremental_options) + .await + .expect("indexed atomic overwrite should regenerate"); + assert!(!manifests_dir.join(CATALOG_PUBLICATION_MARKER_NAME).exists()); + assert_eq!(first, second); + + let checked = check_catalog_manifests(&CheckOptions { + catalog_db: incremental_options.catalog_db.clone(), + manifests_dir: manifests_dir.clone(), + selection: incremental_options.selection.clone(), + }) + .await + .expect("published fixture should check without packages"); + assert_eq!(checked, first); + + let stdout = cli::execute(&cli::PublisherCommand::Check(CheckOptions { + catalog_db: incremental_options.catalog_db.clone(), + manifests_dir: manifests_dir.clone(), + selection: incremental_options.selection.clone(), + })) + .await + .expect("CLI check should succeed"); + assert_eq!( + stdout, + [ + format!( + "checked game_id=css game_version=20240623 content_id={}", + first[0].content_id + ), + "checked total=1".to_owned(), + ] + ); + + fs::OpenOptions::new() + .append(true) + .open(manifests_dir.join("css.json")) + .and_then(|mut file| std::io::Write::write_all(&mut file, b" ")) + .expect("artifact should be tampered"); + assert!( + check_catalog_manifests(&CheckOptions { + catalog_db: incremental_options.catalog_db, + manifests_dir, + selection: incremental_options.selection, + }) + .await + .is_err(), + "noncanonical artifact must fail closed" + ); + } + + #[tokio::test] + async fn incremental_publication_rebases_after_completed_writer_interleaving() { + let temp = TempDir::new(); + let catalog_db = temp.0.join("game.db"); + create_catalog(&catalog_db, &[("a", "20240101"), ("b", "20240102")]).await; + simple_package(&temp, "a", "20240101"); + simple_package(&temp, "b", "20240102"); + let manifests_dir = temp.0.join("manifests"); + let bootstrap = GenerateOptions { + catalog_db: catalog_db.clone(), + packages_dir: temp.0.join("packages"), + manifests_dir: manifests_dir.clone(), + unrar: PathBuf::from("missing-unrar"), + selection: CatalogSelection::All, + }; + generate_catalog_manifests(&bootstrap) + .await + .expect("baseline corpus should generate"); + + fs::write(temp.0.join("packages/a/payload.bin"), b"prepared a update") + .expect("selected package should change"); + let catalog = load_catalog_games(&catalog_db) + .await + .expect("test catalog should load"); + let a_options = GenerateOptions { + catalog_db: catalog_db.clone(), + packages_dir: temp.0.join("packages"), + manifests_dir: manifests_dir.clone(), + unrar: PathBuf::from("missing-unrar"), + selection: CatalogSelection::GameIds(BTreeSet::from(["a".to_owned()])), + }; + let prepared_a = prepare_manifest( + catalog.get("a").expect("catalog should contain a"), + &a_options, + ) + .expect("a should prepare before the interleaving"); + + fs::write( + temp.0.join("packages/b/payload.bin"), + b"concurrent b update", + ) + .expect("unselected package should change"); + let b_reports = generate_catalog_manifests(&GenerateOptions { + selection: CatalogSelection::GameIds(BTreeSet::from(["b".to_owned()])), + ..a_options.clone() + }) + .await + .expect("completed concurrent publication should update b"); + let updated_b = b_reports[0].content_id.clone(); + + let a_reports = publish_prepared_catalog_manifests( + &a_options, + &catalog, + catalog_versions(&catalog), + &[prepared_a], + ) + .expect("a publication should rebase after acquiring the marker"); + let checked = check_catalog_manifests(&CheckOptions { + catalog_db, + manifests_dir, + selection: CatalogSelection::All, + }) + .await + .expect("rebased corpus should validate completely"); + let checked = checked + .into_iter() + .map(|report| (report.game_id, report.content_id)) + .collect::>(); + + assert_eq!(checked.get("a"), Some(&a_reports[0].content_id)); + assert_eq!(checked.get("b"), Some(&updated_b)); + } + + #[tokio::test] + async fn incremental_publication_rejects_unselected_body_index_drift_before_writes() { + let temp = TempDir::new(); + let catalog_db = temp.0.join("game.db"); + create_catalog(&catalog_db, &[("a", "20240101"), ("b", "20240102")]).await; + simple_package(&temp, "a", "20240101"); + let b_package = simple_package(&temp, "b", "20240102"); + let manifests_dir = temp.0.join("manifests"); + generate_catalog_manifests(&GenerateOptions { + catalog_db: catalog_db.clone(), + packages_dir: temp.0.join("packages"), + manifests_dir: manifests_dir.clone(), + unrar: PathBuf::from("missing-unrar"), + selection: CatalogSelection::All, + }) + .await + .expect("baseline corpus should generate"); + + fs::write(temp.0.join("packages/a/payload.bin"), b"selected a update") + .expect("selected package should change"); + fs::write(b_package.join("payload.bin"), b"unindexed b drift") + .expect("unselected package should drift"); + let drifted_b = build_manifest_from_package( + &CatalogGame { + game_id: "b".to_owned(), + game_version: "20240102".to_owned(), + }, + &b_package, + Path::new("missing-unrar"), + ) + .expect("drifted b body should still be structurally valid"); + write_canonical_manifest_atomic(&manifests_dir.join("b.json"), &drifted_b) + .expect("drifted unselected body should publish without its index"); + + let a_artifact = manifests_dir.join("a.json"); + let index_artifact = manifests_dir.join(CATALOG_CONTENT_INDEX_NAME); + let a_before = fs::read(&a_artifact).expect("selected artifact should read"); + let index_before = fs::read(&index_artifact).expect("index should read"); + let error = generate_catalog_manifests(&GenerateOptions { + catalog_db, + packages_dir: temp.0.join("packages"), + manifests_dir: manifests_dir.clone(), + unrar: PathBuf::from("missing-unrar"), + selection: CatalogSelection::GameIds(BTreeSet::from(["a".to_owned()])), + }) + .await + .expect_err("unselected body/index drift must stop incremental publication"); + + assert!( + error + .to_string() + .contains("failed to validate unselected manifest for b") + ); + assert_eq!( + fs::read(a_artifact).expect("selected artifact should remain readable"), + a_before + ); + assert_eq!( + fs::read(index_artifact).expect("index should remain readable"), + index_before + ); + assert!( + manifests_dir + .join(CATALOG_PUBLICATION_MARKER_NAME) + .is_file(), + "failed under-marker validation must remain visibly fail-closed" + ); + } + + #[tokio::test] + async fn entire_selection_is_prepared_before_any_manifest_is_published() { + let temp = TempDir::new(); + let catalog_db = temp.0.join("game.db"); + create_catalog(&catalog_db, &[("a", "20240101"), ("b", "20240102")]).await; + simple_package(&temp, "a", "20240101"); + simple_package(&temp, "b", "wrong-version"); + let manifests_dir = temp.0.join("manifests"); + fs::create_dir(&manifests_dir).expect("manifest directory should be created"); + let existing = manifests_dir.join("a.json"); + fs::write(&existing, b"existing artifact").expect("existing artifact should be seeded"); + + let error = generate_catalog_manifests(&GenerateOptions { + catalog_db, + packages_dir: temp.0.join("packages"), + manifests_dir: manifests_dir.clone(), + unrar: PathBuf::from("missing-unrar"), + selection: CatalogSelection::All, + }) + .await + .expect_err("late package failure should reject the whole selection"); + + assert!( + error + .to_string() + .contains("failed to generate manifest for b") + ); + assert_eq!( + fs::read(existing).expect("existing artifact should remain readable"), + b"existing artifact" + ); + assert!( + !manifests_dir.join(CATALOG_PUBLICATION_MARKER_NAME).exists(), + "preparation failure must happen before publication starts" + ); + } + + #[tokio::test] + async fn incremental_generation_requires_an_existing_complete_indexed_corpus() { + let temp = TempDir::new(); + let catalog_db = temp.0.join("game.db"); + create_catalog(&catalog_db, &[("a", "20240101")]).await; + simple_package(&temp, "a", "20240101"); + let manifests_dir = temp.0.join("manifests"); + + let error = generate_catalog_manifests(&GenerateOptions { + catalog_db, + packages_dir: temp.0.join("packages"), + manifests_dir: manifests_dir.clone(), + unrar: PathBuf::from("missing-unrar"), + selection: CatalogSelection::GameIds(BTreeSet::from(["a".to_owned()])), + }) + .await + .expect_err("incremental generation must not bootstrap partial authority"); + + assert!( + error + .to_string() + .contains("complete indexed catalog corpus") + ); + assert!(!manifests_dir.exists()); + } + + #[tokio::test] + async fn interrupted_multi_manifest_publication_makes_check_fail_closed() { + let temp = TempDir::new(); + let catalog_db = temp.0.join("game.db"); + create_catalog(&catalog_db, &[("a", "20240101"), ("b", "20240102")]).await; + let manifests_dir = temp.0.join("manifests"); + fs::create_dir(&manifests_dir).expect("manifest directory should be created"); + + let mut prepared = Vec::new(); + for game in [ + CatalogGame { + game_id: "a".to_owned(), + game_version: "20240101".to_owned(), + }, + CatalogGame { + game_id: "b".to_owned(), + game_version: "20240102".to_owned(), + }, + ] { + let package_root = simple_package(&temp, &game.game_id, &game.game_version); + let manifest = + build_manifest_from_package(&game, &package_root, Path::new("missing-unrar")) + .expect("simple package should build"); + prepared.push(PreparedManifest { + artifact: manifests_dir.join(format!("{}.json", game.game_id)), + game_id: game.game_id, + manifest, + }); + } + + let marker = begin_publication(&manifests_dir, &prepared) + .expect("publication marker should be durable before writes"); + write_canonical_manifest_atomic(&prepared[0].artifact, &prepared[0].manifest) + .expect("first artifact should publish before simulated interruption"); + + let error = check_catalog_manifests(&CheckOptions { + catalog_db, + manifests_dir, + selection: CatalogSelection::All, + }) + .await + .expect_err("an interrupted publication must not be accepted"); + assert!(error.to_string().contains("publication is incomplete")); + assert!(marker.is_file(), "failed publication marker must remain"); + } + + #[test] + fn package_version_mismatch_is_rejected() { + let temp = TempDir::new(); + let root = simple_package(&temp, "g", "20240101"); + let game = CatalogGame { + game_id: "g".to_owned(), + game_version: "20240102".to_owned(), + }; + let error = build_manifest_from_package(&game, &root, Path::new("missing-unrar")) + .expect_err("version skew should fail"); + assert!(error.to_string().contains("package version mismatch")); + } + + #[test] + fn independent_verification_detects_changed_package_bytes() { + let temp = TempDir::new(); + let root = simple_package(&temp, "g", "20240101"); + let game = CatalogGame { + game_id: "g".to_owned(), + game_version: "20240101".to_owned(), + }; + let manifest = build_manifest_from_package(&game, &root, Path::new("missing-unrar")) + .expect("simple package should build"); + fs::write(root.join("payload.bin"), b"changed").expect("payload should change"); + assert!( + verify_manifest_against_package(&manifest, &root, Path::new("missing-unrar")).is_err() + ); + } + + #[cfg(unix)] + #[test] + fn package_links_special_files_and_portable_aliases_fail_closed() { + use std::os::unix::{fs::symlink, net::UnixListener}; + + let temp = TempDir::new(); + let root = simple_package(&temp, "g", "20240101"); + let game = CatalogGame { + game_id: "g".to_owned(), + game_version: "20240101".to_owned(), + }; + + symlink(root.join("payload.bin"), root.join("alias.bin")) + .expect("symlink should be created"); + assert!(build_manifest_from_package(&game, &root, Path::new("missing-unrar")).is_err()); + fs::remove_file(root.join("alias.bin")).expect("symlink should be removed"); + + let listener = UnixListener::bind(root.join("socket")).expect("socket should be created"); + assert!(build_manifest_from_package(&game, &root, Path::new("missing-unrar")).is_err()); + drop(listener); + fs::remove_file(root.join("socket")).expect("socket should be removed"); + + fs::create_dir(root.join("Data")).expect("first alias directory should be created"); + fs::create_dir(root.join("data")).expect("second alias directory should be created"); + assert!(build_manifest_from_package(&game, &root, Path::new("missing-unrar")).is_err()); + } +} diff --git a/crates/lanspread-compat/src/catalog_publisher/package.rs b/crates/lanspread-compat/src/catalog_publisher/package.rs new file mode 100644 index 0000000..43c1535 --- /dev/null +++ b/crates/lanspread-compat/src/catalog_publisher/package.rs @@ -0,0 +1,419 @@ +use std::{ + ffi::OsStr, + fs::{self, File}, + io::{Read, Take}, + path::{Path, PathBuf}, +}; + +use eyre::WrapErr; +use lanspread_db::content_manifest::{ + Blake3Digest, + CATALOG_CHUNK_SIZE, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + MAX_CATALOG_ENTRIES, + MAX_CATALOG_FILE_BYTES, + MAX_CATALOG_TOTAL_BYTES, +}; + +use super::{CatalogGame, catalog::validate_regular_directory, unrar::scan_extracted_files}; + +const HASH_BUFFER_SIZE: usize = 1024 * 1024; +const MAX_VERSION_INI_BYTES: u64 = 64 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum StreamedInstallPolicy { + Required, + DisabledForSyntheticFixture, +} + +#[derive(Debug)] +pub(super) struct FileHashes { + pub file: Blake3Digest, + pub chunks: Vec, +} + +/// Builds one sealed manifest exclusively from a canonical package directory. +/// +/// # Errors +/// +/// Returns an error when the package shape, version, path set, archive output, +/// or file contents cannot be validated and hashed. +pub fn build_manifest_from_package( + game: &CatalogGame, + package_root: &Path, + unrar: &Path, +) -> eyre::Result { + build_manifest_from_package_with_policy( + game, + package_root, + unrar, + StreamedInstallPolicy::Required, + ) +} + +pub(super) fn build_manifest_from_package_with_policy( + game: &CatalogGame, + package_root: &Path, + unrar: &Path, + streamed_install: StreamedInstallPolicy, +) -> eyre::Result { + validate_regular_directory(package_root)?; + validate_package_version(package_root, &game.game_version)?; + + let files = scan_ordinary_package(package_root)?; + let streamed_install_files = match streamed_install { + StreamedInstallPolicy::Required => { + let archives = root_eti_archives(package_root)?; + scan_extracted_files(unrar, &archives)? + } + StreamedInstallPolicy::DisabledForSyntheticFixture => Vec::new(), + }; + CatalogContentManifest::seal(CatalogContentManifestBody::new( + &game.game_id, + &game.game_version, + files, + streamed_install_files, + )?) +} + +/// Independently rebuilds a manifest and requires byte-authority equivalence. +/// +/// # Errors +/// +/// Returns an error when rereading fails or produces a different manifest. +pub fn verify_manifest_against_package( + expected: &CatalogContentManifest, + package_root: &Path, + unrar: &Path, +) -> eyre::Result<()> { + let game = CatalogGame { + game_id: expected.game_id().to_owned(), + game_version: expected.game_version().to_owned(), + }; + let actual = build_manifest_from_package(&game, package_root, unrar)?; + if &actual != expected { + eyre::bail!( + "package reread did not reproduce content ID {} (reread {})", + expected.content_id(), + actual.content_id() + ); + } + Ok(()) +} + +pub(super) fn validate_manifest_destination(path: &Path) -> eyre::Result<()> { + if let Some(parent) = path.parent().filter(|parent| parent.exists()) { + validate_regular_directory(parent)?; + } + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if is_link_or_reparse(&metadata) || !metadata.is_file() { + eyre::bail!( + "refusing to replace non-regular manifest artifact: {}", + path.display() + ); + } + Ok(()) +} + +fn validate_package_version(package_root: &Path, expected: &str) -> eyre::Result<()> { + let path = package_root.join("version.ini"); + let bytes = read_bounded_regular_file(&path, MAX_VERSION_INI_BYTES)?; + let version = std::str::from_utf8(&bytes) + .wrap_err("version.ini is not valid UTF-8")? + .trim(); + if version != expected { + eyre::bail!( + "package version mismatch: game.db expects {expected}, version.ini contains {version:?}" + ); + } + Ok(()) +} + +fn scan_ordinary_package(root: &Path) -> eyre::Result> { + let mut entries = Vec::new(); + let mut total_bytes = 0_u64; + scan_directory(root, Path::new(""), &mut entries, &mut total_bytes)?; + entries.sort_by(|left, right| left.canonical_path().cmp(right.canonical_path())); + Ok(entries) +} + +fn scan_directory( + root: &Path, + relative_dir: &Path, + entries: &mut Vec, + total_bytes: &mut u64, +) -> eyre::Result<()> { + let directory = root.join(relative_dir); + let before = fs::symlink_metadata(&directory)?; + if is_link_or_reparse(&before) || !before.is_dir() { + eyre::bail!( + "package entry is not a regular non-link directory: {}", + directory.display() + ); + } + + let mut children = fs::read_dir(&directory)?.collect::, _>>()?; + children.sort_by_key(fs::DirEntry::file_name); + for child in children { + if entries.len() >= MAX_CATALOG_ENTRIES { + eyre::bail!("package exceeds the {MAX_CATALOG_ENTRIES}-entry limit"); + } + let name = child + .file_name() + .into_string() + .map_err(|name| eyre::eyre!("package path is not valid UTF-8: {name:?}"))?; + let relative_path = relative_dir.join(name); + let canonical_path = path_to_catalog_string(&relative_path)?; + let path = child.path(); + let metadata = fs::symlink_metadata(&path)?; + if is_link_or_reparse(&metadata) { + eyre::bail!( + "package contains a link or reparse point: {}", + path.display() + ); + } + if metadata.is_dir() { + entries.push(CatalogFileEntry::directory(&canonical_path)?); + scan_directory(root, &relative_path, entries, total_bytes)?; + } else if metadata.is_file() { + if metadata.len() > MAX_CATALOG_FILE_BYTES { + eyre::bail!( + "package file exceeds the {MAX_CATALOG_FILE_BYTES}-byte limit: {}", + path.display() + ); + } + *total_bytes = total_bytes + .checked_add(metadata.len()) + .ok_or_else(|| eyre::eyre!("package byte total overflow"))?; + if *total_bytes > MAX_CATALOG_TOTAL_BYTES { + eyre::bail!("package exceeds the {MAX_CATALOG_TOTAL_BYTES}-byte total limit"); + } + let hashes = hash_regular_file(&path, &metadata)?; + entries.push(CatalogFileEntry::file( + &canonical_path, + metadata.len(), + hashes.file, + hashes.chunks, + )?); + } else { + eyre::bail!("package contains a special file: {}", path.display()); + } + } + + let after = fs::symlink_metadata(&directory)?; + if is_link_or_reparse(&after) || !after.is_dir() || !same_file(&before, &after) { + eyre::bail!( + "package directory changed while scanning: {}", + directory.display() + ); + } + Ok(()) +} + +fn root_eti_archives(root: &Path) -> eyre::Result> { + let mut archives = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if !is_link_or_reparse(&metadata) + && metadata.is_file() + && path + .extension() + .is_some_and(|extension| extension == OsStr::new("eti")) + { + archives.push(path); + } + } + archives.sort(); + Ok(archives) +} + +fn hash_regular_file(path: &Path, expected: &fs::Metadata) -> eyre::Result { + let mut file = File::open(path) + .wrap_err_with(|| format!("failed to open package file {}", path.display()))?; + let opened = file.metadata()?; + if !opened.is_file() || !same_file(expected, &opened) || opened.len() != expected.len() { + eyre::bail!( + "package file changed shape while opening: {}", + path.display() + ); + } + let hashes = hash_exact(&mut file, expected.len(), CATALOG_CHUNK_SIZE)?; + let mut extra = [0_u8; 1]; + if file.read(&mut extra)? != 0 { + eyre::bail!("package file grew while hashing: {}", path.display()); + } + let after = file.metadata()?; + if !after.is_file() || !same_file(expected, &after) || after.len() != expected.len() { + eyre::bail!("package file changed while hashing: {}", path.display()); + } + Ok(hashes) +} + +pub(super) fn hash_exact( + reader: &mut impl Read, + size: u64, + chunk_size: u64, +) -> eyre::Result { + if chunk_size == 0 { + eyre::bail!("hash chunk size cannot be zero"); + } + let mut remaining = size; + let mut whole = blake3::Hasher::new(); + let mut chunk = blake3::Hasher::new(); + let mut chunk_bytes = 0_u64; + let mut chunks = Vec::new(); + let mut buffer = vec![0_u8; HASH_BUFFER_SIZE]; + + while remaining > 0 { + let wanted = usize::try_from(remaining.min(u64::try_from(buffer.len())?))?; + let read = reader.read(&mut buffer[..wanted])?; + if read == 0 { + eyre::bail!("input ended with {remaining} expected byte(s) missing"); + } + let bytes = &buffer[..read]; + whole.update(bytes); + + let mut offset = 0; + while offset < bytes.len() { + let available = chunk_size - chunk_bytes; + let take = usize::try_from(available.min(u64::try_from(bytes.len() - offset)?))?; + chunk.update(&bytes[offset..offset + take]); + offset += take; + chunk_bytes += u64::try_from(take)?; + if chunk_bytes == chunk_size { + chunks.push(Blake3Digest::from_bytes(*chunk.finalize().as_bytes())); + chunk = blake3::Hasher::new(); + chunk_bytes = 0; + } + } + remaining -= u64::try_from(read)?; + } + + if chunk_bytes != 0 { + chunks.push(Blake3Digest::from_bytes(*chunk.finalize().as_bytes())); + } + Ok(FileHashes { + file: Blake3Digest::from_bytes(*whole.finalize().as_bytes()), + chunks, + }) +} + +fn read_bounded_regular_file(path: &Path, limit: u64) -> eyre::Result> { + let before = fs::symlink_metadata(path) + .wrap_err_with(|| format!("failed to inspect package file {}", path.display()))?; + if is_link_or_reparse(&before) || !before.is_file() { + eyre::bail!( + "package file is not a regular non-link file: {}", + path.display() + ); + } + if before.len() > limit { + eyre::bail!( + "package file exceeds the {limit}-byte limit: {}", + path.display() + ); + } + let mut file = File::open(path)?; + let opened = file.metadata()?; + if !opened.is_file() || !same_file(&before, &opened) || opened.len() > limit { + eyre::bail!( + "package file changed shape while opening: {}", + path.display() + ); + } + let mut bytes = Vec::with_capacity(usize::try_from(opened.len())?); + let mut bounded: Take<&mut File> = file.by_ref().take(limit + 1); + bounded.read_to_end(&mut bytes)?; + if u64::try_from(bytes.len())? > limit { + eyre::bail!( + "package file exceeds the {limit}-byte limit: {}", + path.display() + ); + } + let after = file.metadata()?; + if !after.is_file() || !same_file(&before, &after) || after.len() != opened.len() { + eyre::bail!("package file changed while reading: {}", path.display()); + } + Ok(bytes) +} + +fn path_to_catalog_string(path: &Path) -> eyre::Result { + let components = path + .iter() + .map(|component| { + component + .to_str() + .ok_or_else(|| eyre::eyre!("package path is not valid UTF-8: {path:?}")) + }) + .collect::>>()?; + Ok(components.join("/")) +} + +#[cfg(unix)] +fn same_file(before: &fs::Metadata, after: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + before.dev() == after.dev() && before.ino() == after.ino() +} + +#[cfg(not(unix))] +fn same_file(_before: &fs::Metadata, _after: &fs::Metadata) -> bool { + true +} + +#[cfg(unix)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(any(unix, windows)))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn hashes_whole_input_and_each_exact_chunk() { + let bytes = b"abcdefghij"; + let hashes = hash_exact(&mut Cursor::new(bytes), 10, 4).expect("hashing should succeed"); + assert_eq!(hashes.file, Blake3Digest::hash(bytes)); + assert_eq!( + hashes.chunks, + [&b"abcd"[..], &b"efgh"[..], &b"ij"[..]].map(Blake3Digest::hash) + ); + } + + #[test] + fn empty_input_has_a_whole_hash_and_no_chunks() { + let hashes = hash_exact(&mut Cursor::new([]), 0, 4).expect("hashing should succeed"); + assert_eq!(hashes.file, Blake3Digest::hash(&[])); + assert!(hashes.chunks.is_empty()); + } + + #[test] + fn truncated_input_is_rejected() { + let error = + hash_exact(&mut Cursor::new(b"abc"), 4, 4).expect_err("truncated hashing should fail"); + assert!(error.to_string().contains("1 expected byte")); + } +} diff --git a/crates/lanspread-compat/src/catalog_publisher/unrar.rs b/crates/lanspread-compat/src/catalog_publisher/unrar.rs new file mode 100644 index 0000000..f87a0c6 --- /dev/null +++ b/crates/lanspread-compat/src/catalog_publisher/unrar.rs @@ -0,0 +1,779 @@ +use std::{ + collections::BTreeMap, + io::{self, Read}, + path::{Path, PathBuf}, + process::{Child, ChildStderr, ChildStdout, Command, ExitStatus, Stdio}, + sync::mpsc::{self, Receiver, TryRecvError}, + thread, + time::Duration, +}; + +use eyre::WrapErr; +use lanspread_db::content_manifest::{ + CATALOG_CHUNK_SIZE, + CanonicalCatalogPath, + CatalogExtractedEntry, + MAX_CATALOG_ENTRIES, + MAX_CATALOG_FILE_BYTES, + MAX_CATALOG_TOTAL_BYTES, +}; + +use super::package::hash_exact; + +// Retain enough technical listing data for the maximum catalog shape without +// permitting a subprocess to grow publisher memory without bound. +const MAX_UNRAR_LISTING_BYTES: usize = 128 * 1024 * 1024; +const MAX_UNRAR_ERROR_BYTES: usize = 64 * 1024; +const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(5); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RarEntryKind { + File, + Directory, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RarEntry { + path: String, + kind: RarEntryKind, + size: u64, +} + +#[derive(Default)] +struct RarEntryDraft { + path: Option, + kind: Option, + size: Option, +} + +enum ExtractedValue { + Directory, + File { + size: u64, + hash: lanspread_db::content_manifest::Blake3Digest, + }, +} + +pub(super) fn scan_extracted_files( + program: &Path, + archives: &[PathBuf], +) -> eyre::Result> { + let mut outputs = BTreeMap::::new(); + let mut listed_entries = 0_usize; + let mut listed_bytes = 0_u64; + for archive in archives { + let entries = list_archive(program, archive)?; + listed_entries = listed_entries + .checked_add(entries.len()) + .ok_or_else(|| eyre::eyre!("RAR entry count overflow"))?; + if listed_entries > MAX_CATALOG_ENTRIES { + eyre::bail!("RAR inputs exceed the {MAX_CATALOG_ENTRIES}-entry limit"); + } + for entry in &entries { + listed_bytes = listed_bytes + .checked_add(entry.size) + .ok_or_else(|| eyre::eyre!("RAR extracted-byte total overflow"))?; + if listed_bytes > MAX_CATALOG_TOTAL_BYTES { + eyre::bail!("RAR inputs exceed the {MAX_CATALOG_TOTAL_BYTES}-byte extracted limit"); + } + } + hash_archive_outputs(program, archive, &entries, &mut outputs)?; + } + outputs + .into_iter() + .map(|(path, value)| match value { + ExtractedValue::Directory => CatalogExtractedEntry::directory(path), + ExtractedValue::File { size, hash } => CatalogExtractedEntry::file(path, size, hash), + }) + .collect() +} + +fn list_archive(program: &Path, archive: &Path) -> eyre::Result> { + let mut command = non_interactive_unrar_command(program, "lt"); + command.arg(archive); + let output = + capture_command_output(&mut command, MAX_UNRAR_LISTING_BYTES, MAX_UNRAR_ERROR_BYTES) + .wrap_err_with(|| format!("failed to run unrar for {}", archive.display()))?; + if output.stdout.truncated { + eyre::bail!( + "unrar listing exceeds the {MAX_UNRAR_LISTING_BYTES}-byte limit for {}", + archive.display() + ); + } + if output.stderr.truncated { + eyre::bail!( + "unrar diagnostic output exceeds the {MAX_UNRAR_ERROR_BYTES}-byte limit for {}", + archive.display() + ); + } + if !output.status.success() { + eyre::bail!( + "unrar listing failed for {} with status {}: {}", + archive.display(), + output.status, + String::from_utf8_lossy(&output.stderr.bytes).trim() + ); + } + let listing = std::str::from_utf8(&output.stdout.bytes) + .wrap_err_with(|| format!("unrar listing is not UTF-8 for {}", archive.display()))?; + parse_listing(listing) + .wrap_err_with(|| format!("invalid unrar listing for {}", archive.display())) +} + +fn parse_listing(listing: &str) -> eyre::Result> { + let mut entries = Vec::new(); + let mut draft = RarEntryDraft::default(); + for line in listing.lines() { + let line = line.trim_start(); + if let Some(path) = line.strip_prefix("Name:") { + push_entry(&mut entries, std::mem::take(&mut draft))?; + draft.path = Some(path.strip_prefix(' ').unwrap_or(path).to_owned()); + } else if let Some(kind) = line.strip_prefix("Type:") { + if draft.kind.is_some() { + eyre::bail!("RAR entry repeats its Type field"); + } + draft.kind = Some(match kind.trim() { + "File" => RarEntryKind::File, + "Directory" => RarEntryKind::Directory, + unsupported => eyre::bail!("unsupported RAR entry type: {unsupported}"), + }); + } else if let Some(size) = line.strip_prefix("Size:") { + if draft.size.is_some() { + eyre::bail!("RAR entry repeats its Size field"); + } + draft.size = Some(size.trim().parse()?); + } + } + push_entry(&mut entries, draft)?; + Ok(entries) +} + +fn push_entry(entries: &mut Vec, draft: RarEntryDraft) -> eyre::Result<()> { + let Some(path) = draft.path else { + if draft.kind.is_some() || draft.size.is_some() { + eyre::bail!("RAR entry metadata appears before a Name field"); + } + return Ok(()); + }; + CanonicalCatalogPath::new(&path)?; + let kind = draft + .kind + .ok_or_else(|| eyre::eyre!("RAR entry {path:?} has no Type field"))?; + let size = match kind { + RarEntryKind::File => draft + .size + .ok_or_else(|| eyre::eyre!("RAR file entry {path:?} has no Size field"))?, + RarEntryKind::Directory => { + if draft.size.is_some_and(|size| size != 0) { + eyre::bail!("RAR directory entry {path:?} has a nonzero size"); + } + 0 + } + }; + if size > MAX_CATALOG_FILE_BYTES { + eyre::bail!("RAR file entry {path:?} exceeds the per-file size limit"); + } + entries.push(RarEntry { path, kind, size }); + Ok(()) +} + +fn hash_archive_outputs( + program: &Path, + archive: &Path, + entries: &[RarEntry], + outputs: &mut BTreeMap, +) -> eyre::Result<()> { + let child = non_interactive_unrar_command(program, "p") + .arg("-inul") + .arg(archive) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .wrap_err_with(|| format!("failed to stream archive {} with unrar", archive.display()))?; + let mut child = ChildGuard::new(child); + let mut stdout = child + .child + .stdout + .take() + .ok_or_else(|| eyre::eyre!("unrar stdout was not captured"))?; + + for entry in entries { + match entry.kind { + RarEntryKind::Directory => { + insert_output(outputs, &entry.path, ExtractedValue::Directory)?; + } + RarEntryKind::File => { + let hashes = hash_exact(&mut stdout, entry.size, CATALOG_CHUNK_SIZE) + .wrap_err_with(|| format!("failed to hash extracted file {}", entry.path))?; + insert_output( + outputs, + &entry.path, + ExtractedValue::File { + size: entry.size, + hash: hashes.file, + }, + )?; + } + } + } + + let mut extra = [0_u8; 1]; + if stdout.read(&mut extra)? != 0 { + eyre::bail!( + "unrar produced bytes not described by its listing for {}", + archive.display() + ); + } + drop(stdout); + let status = child.wait()?; + if !status.success() { + eyre::bail!( + "unrar streaming failed for {} with status {status}", + archive.display() + ); + } + Ok(()) +} + +fn non_interactive_unrar_command(program: &Path, mode: &str) -> Command { + let mut command = Command::new(program); + command + .arg(mode) + .arg("-cfg-") + // Never prompt for an encrypted archive. A canonical package that + // requires a password is unsupported and must fail closed. + .arg("-p-") + // Publisher processes must not consume the invoking terminal or build + // runner's stdin, even if unrar encounters an unexpected prompt. + .stdin(Stdio::null()); + command +} + +#[derive(Debug)] +struct CapturedChildOutput { + status: ExitStatus, + stdout: CapturedPipe, + stderr: CapturedPipe, +} + +#[derive(Debug)] +struct CapturedPipe { + bytes: Vec, + truncated: bool, +} + +fn capture_command_output( + command: &mut Command, + stdout_limit: usize, + stderr_limit: usize, +) -> eyre::Result { + capture_command_output_with_readers( + command, + stdout_limit, + stderr_limit, + read_bounded_pipe, + read_bounded_pipe, + ) +} + +fn capture_command_output_with_readers( + command: &mut Command, + stdout_limit: usize, + stderr_limit: usize, + stdout_reader: StdoutReader, + stderr_reader: StderrReader, +) -> eyre::Result +where + StdoutReader: FnOnce(ChildStdout, usize) -> io::Result + Send, + StderrReader: FnOnce(ChildStderr, usize) -> io::Result + Send, +{ + let program = command.get_program().to_string_lossy().into_owned(); + let child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .wrap_err_with(|| format!("failed to spawn child process {program}"))?; + let mut child = ChildGuard::new(child); + let stdout = child.child.stdout.take().ok_or_else(|| { + child.terminate_for_error(format!( + "child process {program} started without its requested stdout pipe" + )) + })?; + let stderr = child.child.stderr.take().ok_or_else(|| { + child.terminate_for_error(format!( + "child process {program} started without its requested stderr pipe" + )) + })?; + + thread::scope(move |scope| { + // `child` lives inside this scope so it is killed and reaped before an + // unwind or early return can join a reader still waiting for pipe EOF. + let mut child = child; + let (stdout_tx, stdout_rx) = mpsc::sync_channel(1); + let stdout_thread = thread::Builder::new() + .name("lanspread-unrar-stdout".to_owned()) + .spawn_scoped(scope, move || { + let _ = stdout_tx.send(stdout_reader(stdout, stdout_limit)); + }) + .map_err(|error| { + child.terminate_for_error(format!( + "failed to start stdout reader for child process {program}: {error}" + )) + })?; + + let (stderr_tx, stderr_rx) = mpsc::sync_channel(1); + let stderr_thread = match thread::Builder::new() + .name("lanspread-unrar-stderr".to_owned()) + .spawn_scoped(scope, move || { + let _ = stderr_tx.send(stderr_reader(stderr, stderr_limit)); + }) { + Ok(thread) => thread, + Err(error) => { + let error = child.terminate_for_error(format!( + "failed to start stderr reader for child process {program}: {error}" + )); + let _ = stdout_thread.join(); + return Err(error); + } + }; + + let result = collect_captured_output(&mut child, &stdout_rx, &stderr_rx, &program); + let stdout_join = stdout_thread.join(); + let stderr_join = stderr_thread.join(); + match result { + Ok(_output) if stdout_join.is_err() => Err(eyre::eyre!( + "stdout reader for child process {program} panicked" + )), + Ok(_output) if stderr_join.is_err() => Err(eyre::eyre!( + "stderr reader for child process {program} panicked" + )), + result => result, + } + }) +} + +fn collect_captured_output( + child: &mut ChildGuard, + stdout_rx: &Receiver>, + stderr_rx: &Receiver>, + program: &str, +) -> eyre::Result { + let mut status = None; + let mut stdout = None; + let mut stderr = None; + loop { + poll_pipe_reader(child, stdout_rx, &mut stdout, "stdout", program)?; + poll_pipe_reader(child, stderr_rx, &mut stderr, "stderr", program)?; + + if status.is_none() { + match child.try_wait() { + Ok(Some(exit_status)) => status = Some(exit_status), + Ok(None) => {} + Err(error) => { + return Err(child.terminate_for_error(format!( + "failed to wait for child process {program}: {error}" + ))); + } + } + } + + if status.is_some() && stdout.is_some() && stderr.is_some() { + return Ok(CapturedChildOutput { + status: status + .take() + .ok_or_else(|| eyre::eyre!("child status disappeared"))?, + stdout: stdout + .take() + .ok_or_else(|| eyre::eyre!("captured stdout disappeared"))?, + stderr: stderr + .take() + .ok_or_else(|| eyre::eyre!("captured stderr disappeared"))?, + }); + } + thread::sleep(PROCESS_POLL_INTERVAL); + } +} + +fn poll_pipe_reader( + child: &mut ChildGuard, + receiver: &Receiver>, + captured: &mut Option, + pipe_name: &str, + program: &str, +) -> eyre::Result<()> { + if captured.is_some() { + return Ok(()); + } + match receiver.try_recv() { + Ok(Ok(output)) => { + *captured = Some(output); + Ok(()) + } + Ok(Err(error)) => Err(child.terminate_for_error(format!( + "failed to read {pipe_name} from child process {program}: {error}" + ))), + Err(TryRecvError::Empty) => Ok(()), + Err(TryRecvError::Disconnected) => Err(child.terminate_for_error(format!( + "{pipe_name} reader for child process {program} ended without a result" + ))), + } +} + +fn read_bounded_pipe(mut pipe: impl Read, max_bytes: usize) -> io::Result { + let mut bytes = Vec::with_capacity(max_bytes.min(8 * 1024)); + let mut buffer = [0_u8; 8 * 1024]; + let mut truncated = false; + loop { + let read = pipe.read(&mut buffer)?; + if read == 0 { + break; + } + let remaining = max_bytes.saturating_sub(bytes.len()); + let retained = read.min(remaining); + bytes.extend_from_slice(&buffer[..retained]); + truncated |= retained != read; + } + Ok(CapturedPipe { bytes, truncated }) +} + +fn insert_output( + outputs: &mut BTreeMap, + path: &str, + value: ExtractedValue, +) -> eyre::Result<()> { + if let Some(previous) = outputs.get(path) { + match (previous, &value) { + (ExtractedValue::Directory, ExtractedValue::Directory) => return Ok(()), + (ExtractedValue::File { .. }, ExtractedValue::File { .. }) => { + eyre::bail!("RAR archives emit extracted file more than once: {path}"); + } + _ => { + eyre::bail!("RAR archives change the file/directory shape of {path}"); + } + } + } + outputs.insert(path.to_owned(), value); + Ok(()) +} + +struct ChildGuard { + child: Child, + waited: bool, +} + +impl ChildGuard { + fn new(child: Child) -> Self { + Self { + child, + waited: false, + } + } + + fn wait(&mut self) -> std::io::Result { + let status = self.child.wait()?; + self.waited = true; + Ok(status) + } + + fn try_wait(&mut self) -> io::Result> { + let status = self.child.try_wait()?; + if status.is_some() { + self.waited = true; + } + Ok(status) + } + + fn terminate_for_error(&mut self, reason: impl std::fmt::Display) -> eyre::Report { + if self.waited { + return eyre::eyre!("{reason}"); + } + + let kill_error = self.child.kill().err(); + match self.child.wait() { + Ok(_status) => { + self.waited = true; + let kill_context = kill_error + .map(|error| format!("; kill reported: {error}")) + .unwrap_or_default(); + eyre::eyre!("{reason}{kill_context}") + } + Err(wait_error) => eyre::eyre!( + "{reason}; failed to reap child: {wait_error}; kill error: {kill_error:?}" + ), + } + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if !self.waited { + let kill_error = self.child.kill().err(); + if let Err(wait_error) = self.child.wait() { + tracing::error!( + "failed to reap guarded unrar child: {wait_error}; kill error: {kill_error:?}" + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_pipe_capture_drains_bytes_beyond_the_retention_limit() { + let mut input = io::Cursor::new(b"abcdef".to_vec()); + + let captured = read_bounded_pipe(&mut input, 3).expect("pipe should be readable"); + + assert_eq!(captured.bytes, b"abc"); + assert!(captured.truncated); + assert_eq!(input.position(), 6); + } + + #[test] + fn parses_file_and_directory_entries() { + let listing = "\ + Name: bin/a.txt\n\ + Type: File\n\ + Size: 3\n\ + Name: bin\n\ + Type: Directory\n"; + assert_eq!( + parse_listing(listing).expect("listing should parse"), + vec![ + RarEntry { + path: "bin/a.txt".to_owned(), + kind: RarEntryKind::File, + size: 3, + }, + RarEntry { + path: "bin".to_owned(), + kind: RarEntryKind::Directory, + size: 0, + }, + ] + ); + } + + #[test] + fn rejects_unsafe_and_special_entries() { + for listing in [ + "Name: ../escape\nType: File\nSize: 1\n", + "Name: link\nType: Unix symlink\nSize: 1\n", + "Name: file\nType: File\n", + ] { + assert!(parse_listing(listing).is_err(), "accepted {listing:?}"); + } + } + + #[test] + fn rejects_duplicate_extracted_files_but_allows_repeated_directories() { + let mut outputs = BTreeMap::new(); + insert_output(&mut outputs, "shared", ExtractedValue::Directory) + .expect("first directory should be accepted"); + insert_output(&mut outputs, "shared", ExtractedValue::Directory) + .expect("archives may repeat a directory entry"); + insert_output( + &mut outputs, + "shared/payload.bin", + ExtractedValue::File { + size: 1, + hash: lanspread_db::content_manifest::Blake3Digest::hash(b"a"), + }, + ) + .expect("first file should be accepted"); + + let error = insert_output( + &mut outputs, + "shared/payload.bin", + ExtractedValue::File { + size: 1, + hash: lanspread_db::content_manifest::Blake3Digest::hash(b"b"), + }, + ) + .expect_err("duplicate extracted file should be rejected"); + + assert!(error.to_string().contains("more than once")); + } + + #[cfg(target_os = "linux")] + #[test] + fn every_unrar_invocation_disables_passwords_and_parent_stdin() { + use std::{ + fs, + io::Write as _, + os::unix::fs::PermissionsExt, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lanspread-unrar-noninteractive-{}-{nanos}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).expect("temporary directory should be created"); + let script = root.join("unrar"); + let mut script_file = fs::File::create(&script).expect("fake unrar should be created"); + script_file + .write_all( + "#!/bin/sh\n\ + case \" $* \" in *\" -p- \"*) ;; *) exit 80 ;; esac\n\ + test \"$(readlink /proc/$$/fd/0)\" = /dev/null || exit 81\n\ + case \"$1\" in\n\ + lt) printf 'Name: payload.bin\\nType: File\\nSize: 3\\n' ;;\n\ + p) printf 'abc' ;;\n\ + *) exit 82 ;;\n\ + esac\n" + .as_bytes(), + ) + .expect("fake unrar should be written"); + script_file + .sync_all() + .expect("fake unrar should be durable before execution"); + drop(script_file); + let mut permissions = fs::metadata(&script) + .expect("fake unrar should exist") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&script, permissions).expect("fake unrar should be executable"); + let archive = root.join("fixture.eti"); + fs::write(&archive, []).expect("archive placeholder should be written"); + + let entries = scan_extracted_files(&script, &[archive]) + .expect("both fake unrar invocations should be non-interactive"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].canonical_path().as_str(), "payload.bin"); + assert_eq!( + entries[0].file_blake3(), + Some(lanspread_db::content_manifest::Blake3Digest::hash(b"abc")) + ); + + fs::remove_dir_all(root).expect("temporary directory should be removed"); + } + + #[cfg(target_os = "linux")] + #[test] + fn listing_pipe_read_failure_kills_and_reaps_the_direct_child() { + use std::{ + fs, + io::Write as _, + os::unix::fs::PermissionsExt, + sync::atomic::{AtomicU64, Ordering}, + time::{Instant, SystemTime, UNIX_EPOCH}, + }; + + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lanspread-unrar-read-failure-{}-{nanos}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).expect("temporary directory should be created"); + let script = root.join("controlled-child"); + let mut script_file = fs::File::create(&script).expect("child script should be created"); + script_file + .write_all(b"#!/bin/sh\nset -eu\nprintf '%s\\n' \"$$\" > \"$1\"\nwhile :; do :; done\n") + .expect("child script should be written"); + script_file + .sync_all() + .expect("child script should be durable before execution"); + drop(script_file); + let mut permissions = fs::metadata(&script) + .expect("child script should exist") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&script, permissions).expect("child script should be executable"); + + let pid_marker = root.join("pid"); + let marker_for_reader = pid_marker.clone(); + // Invoke the controlled script through the system shell. Some shared + // build filesystems can transiently reject direct execution of a file + // whose creation was just closed with ETXTBSY; the lifecycle under test + // is the captured direct `sh` child either way. + let mut command = Command::new("sh"); + command.arg(&script).arg(&pid_marker); + let error = capture_command_output_with_readers( + &mut command, + 1024, + 1024, + move |_stdout, _limit| { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if fs::read_to_string(&marker_for_reader) + .ok() + .and_then(|pid| pid.trim().parse::().ok()) + .is_some() + { + return Err(io::Error::other("injected stdout pipe read failure")); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "controlled child did not publish its PID", + )); + } + thread::sleep(Duration::from_millis(5)); + } + }, + read_bounded_pipe, + ) + .expect_err("injected pipe failure should fail capture"); + + assert!( + error + .to_string() + .contains("injected stdout pipe read failure"), + "unexpected capture failure: {error:#}" + ); + let pid = fs::read_to_string(&pid_marker) + .expect("controlled child should publish its PID") + .trim() + .parse::() + .expect("published PID should parse"); + assert!( + !Path::new(&format!("/proc/{pid}")).exists(), + "capture returned before child PID {pid} was reaped" + ); + + fs::remove_dir_all(root).expect("temporary directory should be removed"); + } + + #[cfg(target_os = "linux")] + #[test] + fn child_guard_kills_and_reaps_during_unwind() { + let child = Command::new("sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("controlled child should start"); + let pid = child.id(); + let process_path = PathBuf::from(format!("/proc/{pid}")); + assert!(process_path.exists(), "controlled child should be live"); + + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _guard = ChildGuard::new(child); + panic!("injected parent unwind"); + })); + + assert!(unwind.is_err(), "injected unwind should be observed"); + assert!( + !process_path.exists(), + "guard unwind returned before child PID {pid} was reaped" + ); + } +} diff --git a/crates/lanspread-compat/src/eti.rs b/crates/lanspread-compat/src/eti.rs index 5253907..f390c1e 100644 --- a/crates/lanspread-compat/src/eti.rs +++ b/crates/lanspread-compat/src/eti.rs @@ -2,7 +2,7 @@ use std::path::Path; use lanspread_db::db::{Availability, Game}; use serde::{Deserialize, Serialize}; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; #[derive(Clone, Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct EtiGame { @@ -26,7 +26,7 @@ pub async fn get_games(db: &Path) -> eyre::Result> { let options = SqliteConnectOptions::new().filename(db).read_only(true); let pool = SqlitePoolOptions::new().connect_with(options).await?; - let mut games = sqlx::query_as::<_, EtiGame>( + let query_result = sqlx::query_as::<_, EtiGame>( "SELECT g.game_id, g.game_title, g.game_key, g.game_release, g.game_publisher, CAST(g.game_size AS REAL) as game_size, g.game_readme_de, @@ -36,7 +36,8 @@ pub async fn get_games(db: &Path) -> eyre::Result> { JOIN genre ge ON g.genre_id = ge.genre_id", ) .fetch_all(&pool) - .await?; + .await; + let mut games = close_pool_after_query(&pool, query_result).await?; games.sort_by(|a, b| a.game_title.cmp(&b.game_title)); @@ -48,6 +49,17 @@ pub async fn get_games(db: &Path) -> eyre::Result> { Ok(games) } +async fn close_pool_after_query( + pool: &SqlitePool, + query_result: Result, +) -> Result { + // `Pool::close` is infallible, so preserve the original query result after + // waiting for every SQLite connection to close on both result paths. + pool.close().await; + debug_assert!(pool.is_closed()); + query_result +} + impl From for Game { fn from(eti_game: EtiGame) -> Self { Self { @@ -70,3 +82,22 @@ impl From for Game { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn query_error_is_returned_only_after_pool_close() { + let pool = + SqlitePoolOptions::new().connect_lazy_with(SqliteConnectOptions::new().in_memory(true)); + let query_result: Result<(), sqlx::Error> = Err(sqlx::Error::RowNotFound); + + let error = close_pool_after_query(&pool, query_result) + .await + .expect_err("query failure should propagate"); + + assert!(matches!(error, sqlx::Error::RowNotFound)); + assert!(pool.is_closed()); + } +} diff --git a/crates/lanspread-compat/src/lib.rs b/crates/lanspread-compat/src/lib.rs index 28c66a1..4bc3861 100644 --- a/crates/lanspread-compat/src/lib.rs +++ b/crates/lanspread-compat/src/lib.rs @@ -1 +1,3 @@ +pub mod catalog_bundle; +pub mod catalog_publisher; pub mod eti; diff --git a/crates/lanspread-compat/tests/catalog_publisher_cli.rs b/crates/lanspread-compat/tests/catalog_publisher_cli.rs new file mode 100644 index 0000000..b127f3a --- /dev/null +++ b/crates/lanspread-compat/tests/catalog_publisher_cli.rs @@ -0,0 +1,179 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use lanspread_compat::catalog_publisher::cli::HELP; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow epoch") + .as_nanos(); + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "lanspread-publisher-cli-{}-{nanos}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("temporary directory should be created"); + Self(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn publisher() -> Command { + Command::new(env!("CARGO_BIN_EXE_lanspread-catalog-publisher")) +} + +fn catalog_db() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../lanspread-tauri-deno-ts/src-tauri/game.db") +} + +fn packages_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../lanspread-peer-cli/fixtures/fixture-persona") +} + +fn test_unrar() -> Option { + #[cfg(target_os = "linux")] + { + let bundled = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu"); + if bundled.is_file() { + return Some(bundled); + } + } + ["/usr/local/bin/unrar", "/usr/bin/unrar"] + .into_iter() + .map(PathBuf::from) + .find(|path| path.is_file()) +} + +async fn create_single_game_catalog(path: &Path) { + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with( + SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), + ) + .await + .expect("test catalog should open"); + sqlx::query( + "CREATE TABLE games (game_id TEXT NOT NULL, game_version TEXT NOT NULL, db_id INTEGER NOT NULL)", + ) + .execute(&pool) + .await + .expect("games table should be created"); + sqlx::query("INSERT INTO games (game_id, game_version, db_id) VALUES ('css', '20240623', 1)") + .execute(&pool) + .await + .expect("css row should be inserted"); + pool.close().await; +} + +#[test] +fn help_is_exact_and_error_output_is_separate() { + let help = publisher() + .arg("--help") + .output() + .expect("publisher should run"); + assert!(help.status.success()); + assert_eq!( + String::from_utf8(help.stdout).expect("UTF-8 stdout"), + format!("{HELP}\n") + ); + assert!(help.stderr.is_empty()); + + let error = publisher() + .args(["check", "--catalog-db"]) + .arg(catalog_db()) + .output() + .expect("publisher should run"); + assert!(!error.status.success()); + assert!(error.stdout.is_empty()); + assert_eq!( + String::from_utf8(error.stderr).expect("UTF-8 stderr"), + "error: select games with --all or at least one --game-id\n" + ); +} + +#[tokio::test] +async fn complete_real_package_round_trips_through_generate_and_check_commands() { + let Some(unrar) = test_unrar() else { + return; + }; + let temp = TempDir::new(); + let fixture_catalog = temp.0.join("game.db"); + create_single_game_catalog(&fixture_catalog).await; + let manifests = temp.0.join("manifests"); + let generated = publisher() + .arg("generate") + .arg("--catalog-db") + .arg(&fixture_catalog) + .arg("--packages-dir") + .arg(packages_dir()) + .arg("--manifests-dir") + .arg(&manifests) + .arg("--unrar") + .arg(unrar) + .arg("--all") + .output() + .expect("publisher should run"); + assert!( + generated.status.success(), + "generation failed: {}", + String::from_utf8_lossy(&generated.stderr) + ); + assert!(generated.stderr.is_empty()); + let generated = String::from_utf8(generated.stdout).expect("UTF-8 stdout"); + let generated_lines = generated.lines().collect::>(); + assert_eq!(generated_lines.len(), 2); + let prefix = "generated game_id=css game_version=20240623 content_id="; + let content_id = generated_lines[0] + .strip_prefix(prefix) + .expect("stable generated record prefix"); + assert_eq!(content_id.len(), 64); + assert!( + content_id + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + ); + assert_eq!(generated_lines[1], "generated total=1"); + + let checked = publisher() + .arg("check") + .arg("--catalog-db") + .arg(fixture_catalog) + .arg("--manifests-dir") + .arg(manifests) + .args(["--game-id", "css"]) + .output() + .expect("publisher should run"); + assert!( + checked.status.success(), + "check failed: {}", + String::from_utf8_lossy(&checked.stderr) + ); + assert!(checked.stderr.is_empty()); + assert_eq!( + String::from_utf8(checked.stdout).expect("UTF-8 stdout"), + format!( + "checked game_id=css game_version=20240623 content_id={content_id}\nchecked total=1\n" + ) + ); +} diff --git a/crates/lanspread-db/Cargo.toml b/crates/lanspread-db/Cargo.toml index 2ca4ef1..eced2bd 100644 --- a/crates/lanspread-db/Cargo.toml +++ b/crates/lanspread-db/Cargo.toml @@ -7,10 +7,12 @@ edition = "2024" doctest = false [dependencies] +blake3 = { workspace = true } eyre = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } +unicode-normalization = { workspace = true } [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/crates/lanspread-db/src/content_manifest/bundle.rs b/crates/lanspread-db/src/content_manifest/bundle.rs new file mode 100644 index 0000000..2ec7c87 --- /dev/null +++ b/crates/lanspread-db/src/content_manifest/bundle.rs @@ -0,0 +1,334 @@ +use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; + +use super::{ + CatalogContentIdentity, + CatalogContentManifest, + CatalogManifestStore, + reject_incomplete_catalog_publication, +}; +use crate::db::GameCatalog; + +/// Immutable catalog authority pairing exact game versions with their +/// on-demand content manifests. +#[derive(Debug)] +pub struct CatalogBundle { + catalog: GameCatalog, + manifests: CatalogManifestStore, + manifests_root: Option, +} + +impl CatalogBundle { + /// Constructs one exact catalog authority and validates artifact coverage + /// without eagerly parsing manifest bodies. + /// + /// # Errors + /// + /// Returns an error for invalid catalog identities, a non-regular manifest + /// root, or missing, unexpected, linked, or non-file manifest artifacts. + pub fn new( + manifests_root: impl Into, + expected_versions: BTreeMap, + ) -> eyre::Result { + let manifests_root = manifests_root.into(); + reject_incomplete_catalog_publication(&manifests_root)?; + let manifests = CatalogManifestStore::new(&manifests_root, expected_versions.clone())?; + manifests.validate_coverage()?; + reject_incomplete_catalog_publication(&manifests_root)?; + + let mut catalog = GameCatalog::empty(); + for (game_id, version) in expected_versions { + catalog.insert(game_id, Some(version)); + } + Ok(Self { + catalog, + manifests, + manifests_root: Some(manifests_root), + }) + } + + /// Constructs an immutable authority from a complete in-memory manifest + /// set. + /// + /// # Errors + /// + /// Returns an error if any sealed manifest is invalid, if game IDs collide + /// exactly or under portable case-folding, or if catalog limits are + /// exceeded. + pub fn from_manifests( + manifests: impl IntoIterator, + ) -> eyre::Result { + let manifests = CatalogManifestStore::from_manifests(manifests)?; + let mut catalog = GameCatalog::empty(); + for (game_id, version) in manifests.expected_versions() { + catalog.insert(game_id.clone(), Some(version.clone())); + } + Ok(Self { + catalog, + manifests, + manifests_root: None, + }) + } + + /// Returns the exact ID/version catalog used by peer policy. + #[must_use] + pub const fn catalog(&self) -> &GameCatalog { + &self.catalog + } + + /// Returns one catalog-owned content identity without filesystem access. + #[must_use] + pub fn content_identity(&self, game_id: &str) -> Option { + self.manifests.content_identity(game_id) + } + + /// Loads and fully validates one manifest on demand. + /// + /// # Errors + /// + /// Returns an error if the ID is unknown or its artifact is invalid. + pub fn manifest(&self, game_id: &str) -> eyre::Result> { + self.reject_incomplete_disk_publication()?; + let result = self.manifests.load(game_id); + self.reject_incomplete_disk_publication()?; + result + } + + /// Returns a previously validated manifest without performing filesystem + /// I/O or parsing. + /// + /// # Errors + /// + /// Returns an error for an unknown or not-yet-loaded manifest. + pub fn cached_manifest(&self, game_id: &str) -> eyre::Result> { + self.manifests.load_cached(game_id) + } + + /// Eagerly validates exact coverage and every body against the compact + /// index, rejecting an overlapping or interrupted disk publication. + /// + /// # Errors + /// + /// Returns an error for a publication marker, invalid coverage, an invalid + /// body, or an index/body identity mismatch. + pub fn validate_all(&self) -> eyre::Result<()> { + self.reject_incomplete_disk_publication()?; + let result = self.manifests.validate_all(); + self.reject_incomplete_disk_publication()?; + result + } + + fn reject_incomplete_disk_publication(&self) -> eyre::Result<()> { + if let Some(root) = &self.manifests_root { + reject_incomplete_catalog_publication(root)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + use crate::content_manifest::{ + Blake3Digest, + CATALOG_CONTENT_INDEX_NAME, + CatalogContentIndex, + CatalogContentManifestBody, + CatalogFileEntry, + write_canonical_content_index_atomic, + write_canonical_manifest_atomic, + }; + + fn manifest(id: &str) -> CatalogContentManifest { + let version = "20240101"; + let digest = Blake3Digest::hash(version.as_bytes()); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + id, + version, + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("version length should fit u64"), + digest, + vec![digest], + ) + .expect("version.ini entry should be valid"), + ], + Vec::new(), + ) + .expect("test body should be valid"), + ) + .expect("test manifest should seal") + } + + static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should follow epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "lanspread-catalog-authority-{}-{nanos}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("test directory should be created"); + Self(path) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn bundle_pairs_exact_versions_without_eager_manifest_parsing() { + let root = TestDir::new(); + let valid_manifest = manifest("g"); + let index = CatalogContentIndex::from_manifests([&valid_manifest]) + .expect("test index should validate"); + write_canonical_content_index_atomic(&root.0.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("test index should publish"); + fs::write(root.0.join("g.json"), b"not JSON\n").expect("opaque artifact should write"); + + let bundle = CatalogBundle::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("bundle construction should only validate coverage"); + + assert!(bundle.catalog().contains("g")); + assert_eq!(bundle.catalog().expected_version("g"), Some("20240101")); + assert_eq!( + bundle.content_identity("g"), + Some(CatalogContentIdentity::from_manifest(&valid_manifest)) + ); + assert!(bundle.cached_manifest("g").is_err()); + assert!(bundle.manifest("g").is_err()); + assert!(bundle.cached_manifest("g").is_err()); + } + + #[test] + fn bundle_rejects_missing_or_non_directory_manifest_root() { + let root = TestDir::new(); + let expected = BTreeMap::from([("g".to_owned(), "20240101".to_owned())]); + assert!(CatalogBundle::new(root.0.join("missing"), expected.clone()).is_err()); + + let file = root.0.join("file"); + fs::write(&file, b"not a directory\n").expect("file should write"); + assert!(CatalogBundle::new(file, expected).is_err()); + } + + #[test] + fn bundle_rejects_incomplete_publication_without_parsing_manifests() { + let root = TestDir::new(); + fs::write(root.0.join("g.json"), b"not JSON\n").expect("opaque artifact should write"); + fs::write( + root.0.join(super::super::CATALOG_PUBLICATION_MARKER_NAME), + b"lanspread catalog publication v1\n", + ) + .expect("publication marker should write"); + + let error = CatalogBundle::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect_err("runtime authority must reject an interrupted publication"); + + assert!(error.to_string().contains("publication is incomplete")); + } + + #[test] + fn disk_bundle_rechecks_publication_marker_without_invalidating_cached_snapshot() { + let root = TestDir::new(); + let valid_manifest = manifest("g"); + write_canonical_manifest_atomic(&root.0.join("g.json"), &valid_manifest) + .expect("test manifest should publish"); + let index = CatalogContentIndex::from_manifests([&valid_manifest]) + .expect("test index should validate"); + write_canonical_content_index_atomic(&root.0.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("test index should publish"); + let bundle = CatalogBundle::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("complete disk bundle should construct"); + let loaded = bundle.manifest("g").expect("manifest should preload"); + + fs::write( + root.0.join(super::super::CATALOG_PUBLICATION_MARKER_NAME), + b"lanspread catalog publication v1\n", + ) + .expect("publication marker should write"); + + assert!(bundle.manifest("g").is_err()); + assert!(bundle.validate_all().is_err()); + assert!(Arc::ptr_eq( + &loaded, + &bundle + .cached_manifest("g") + .expect("explicit cache-only snapshot should remain immutable") + )); + } + + #[cfg(unix)] + #[test] + fn bundle_rejects_symlink_manifest_root() { + use std::os::unix::fs::symlink; + + let root = TestDir::new(); + let real = root.0.join("real"); + fs::create_dir(&real).expect("real root should be created"); + fs::write(real.join("g.json"), b"opaque\n").expect("artifact should write"); + let linked = root.0.join("linked"); + symlink(&real, &linked).expect("root symlink should be created"); + + assert!( + CatalogBundle::new( + linked, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]) + ) + .is_err() + ); + } + + #[test] + fn in_memory_bundle_is_exact_and_loadable() { + let manifest = manifest("g"); + + let bundle = CatalogBundle::from_manifests([manifest.clone()]) + .expect("complete in-memory authority should load"); + + assert_eq!(bundle.catalog().expected_version("g"), Some("20240101")); + assert_eq!( + bundle.content_identity("g"), + Some(CatalogContentIdentity::from_manifest(&manifest)) + ); + assert_eq!( + bundle + .manifest("g") + .expect("known in-memory manifest should load") + .as_ref(), + &manifest + ); + assert!(bundle.manifest("missing").is_err()); + } + + #[test] + fn in_memory_bundle_rejects_portable_aliases() { + assert!(CatalogBundle::from_manifests([manifest("game"), manifest("GAME")]).is_err()); + } +} diff --git a/crates/lanspread-db/src/content_manifest/digest.rs b/crates/lanspread-db/src/content_manifest/digest.rs new file mode 100644 index 0000000..d89d706 --- /dev/null +++ b/crates/lanspread-db/src/content_manifest/digest.rs @@ -0,0 +1,165 @@ +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +const DIGEST_BYTES: usize = 32; +const DIGEST_HEX_BYTES: usize = DIGEST_BYTES * 2; + +macro_rules! digest_type { + ($name:ident, $description:literal) => { + #[doc = $description] + #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub struct $name([u8; DIGEST_BYTES]); + + impl $name { + /// Constructs a value from its exact binary representation. + #[must_use] + pub const fn from_bytes(bytes: [u8; DIGEST_BYTES]) -> Self { + Self(bytes) + } + + /// Returns the exact binary representation. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; DIGEST_BYTES] { + &self.0 + } + + /// Consumes the value and returns its exact binary representation. + #[must_use] + pub const fn into_bytes(self) -> [u8; DIGEST_BYTES] { + self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } + } + + impl FromStr for $name { + type Err = eyre::Report; + + fn from_str(value: &str) -> Result { + parse_lower_hex(value).map(Self) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(de::Error::custom) + } + } + }; +} + +digest_type!( + Blake3Digest, + "A BLAKE3 byte digest stored as 64 lowercase hex characters in JSON." +); +digest_type!( + ContentId, + "The catalog-owned identity of one complete content manifest." +); + +impl Blake3Digest { + /// Hashes one byte slice. + #[must_use] + pub fn hash(bytes: &[u8]) -> Self { + Self(*blake3::hash(bytes).as_bytes()) + } +} + +fn parse_lower_hex(value: &str) -> eyre::Result<[u8; DIGEST_BYTES]> { + let encoded = value.as_bytes(); + if encoded.len() != DIGEST_HEX_BYTES { + eyre::bail!( + "digest must contain exactly {DIGEST_HEX_BYTES} lowercase hexadecimal characters" + ); + } + + let mut decoded = [0_u8; DIGEST_BYTES]; + for (output, pair) in decoded.iter_mut().zip(encoded.chunks_exact(2)) { + *output = (decode_nibble(pair[0])? << 4) | decode_nibble(pair[1])?; + } + Ok(decoded) +} + +fn decode_nibble(value: u8) -> eyre::Result { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + _ => eyre::bail!("digest must use lowercase hexadecimal characters"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digest_json_is_exact_lowercase_hex() { + let digest = Blake3Digest::from_bytes([0xab; 32]); + let encoded = serde_json::to_string(&digest).expect("digest should serialize"); + assert_eq!(encoded, format!("\"{}\"", "ab".repeat(32))); + assert_eq!( + serde_json::from_str::(&encoded).expect("digest should deserialize"), + digest + ); + } + + #[test] + fn digest_json_rejects_noncanonical_hex() { + for encoded in [ + format!("\"{}\"", "AB".repeat(32)), + format!("\"{}\"", "a".repeat(63)), + format!("\"{}g\"", "a".repeat(63)), + ] { + assert!(serde_json::from_str::(&encoded).is_err()); + } + } + + #[test] + fn digest_types_deserialize_from_owned_json_values() { + let encoded = "ab".repeat(32); + assert_eq!( + serde_json::from_value::(serde_json::Value::String(encoded.clone())) + .expect("owned digest string should deserialize"), + Blake3Digest::from_bytes([0xab; 32]) + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String(encoded)) + .expect("owned content-ID string should deserialize"), + ContentId::from_bytes([0xab; 32]) + ); + } + + #[test] + fn digest_and_content_id_are_distinct_types() { + let digest = Blake3Digest::from_bytes([7; 32]); + let content_id = ContentId::from_bytes(digest.into_bytes()); + assert_eq!(content_id.to_string(), digest.to_string()); + } +} diff --git a/crates/lanspread-db/src/content_manifest/encoding.rs b/crates/lanspread-db/src/content_manifest/encoding.rs new file mode 100644 index 0000000..1389998 --- /dev/null +++ b/crates/lanspread-db/src/content_manifest/encoding.rs @@ -0,0 +1,161 @@ +use super::{ + CatalogContentManifestBody, + CatalogEntryKind, + CatalogExtractedEntry, + CatalogFileEntry, + ContentId, +}; + +const CONTENT_ID_DOMAIN: &[u8] = b"lanspread/catalog-content-manifest/content-id"; + +trait TranscriptSink { + fn put(&mut self, bytes: &[u8]); +} + +impl TranscriptSink for blake3::Hasher { + fn put(&mut self, bytes: &[u8]) { + self.update(bytes); + } +} + +impl TranscriptSink for Vec { + fn put(&mut self, bytes: &[u8]) { + self.extend_from_slice(bytes); + } +} + +pub(super) fn compute_content_id(body: &CatalogContentManifestBody) -> eyre::Result { + compute_content_id_fields( + body.schema_version, + &body.game_id, + &body.game_version, + body.chunk_size, + &body.files, + &body.streamed_install_files, + ) +} + +pub(super) fn compute_content_id_fields( + schema_version: u32, + game_id: &str, + game_version: &str, + chunk_size: u64, + files: &[CatalogFileEntry], + streamed_install_files: &[CatalogExtractedEntry], +) -> eyre::Result { + let mut hasher = blake3::Hasher::new(); + encode_fields( + schema_version, + game_id, + game_version, + chunk_size, + files, + streamed_install_files, + &mut hasher, + )?; + Ok(ContentId::from_bytes(*hasher.finalize().as_bytes())) +} + +fn encode_fields( + schema_version: u32, + game_id: &str, + game_version: &str, + chunk_size: u64, + files: &[CatalogFileEntry], + streamed_install_files: &[CatalogExtractedEntry], + sink: &mut impl TranscriptSink, +) -> eyre::Result<()> { + put_bytes(sink, CONTENT_ID_DOMAIN)?; + put_u32(sink, schema_version); + put_bytes(sink, game_id.as_bytes())?; + put_bytes(sink, game_version.as_bytes())?; + put_u64(sink, chunk_size); + + put_len(sink, files.len())?; + for entry in files { + encode_file_entry(sink, entry)?; + } + + put_len(sink, streamed_install_files.len())?; + for entry in streamed_install_files { + encode_extracted_entry(sink, entry)?; + } + Ok(()) +} + +fn encode_file_entry(sink: &mut impl TranscriptSink, entry: &CatalogFileEntry) -> eyre::Result<()> { + put_bytes(sink, entry.canonical_path.as_str().as_bytes())?; + put_kind(sink, entry.kind); + put_u64(sink, entry.size); + put_optional_digest(sink, entry.file_blake3.as_ref())?; + put_len(sink, entry.chunk_blake3.len())?; + for digest in &entry.chunk_blake3 { + put_bytes(sink, digest.as_bytes())?; + } + Ok(()) +} + +fn encode_extracted_entry( + sink: &mut impl TranscriptSink, + entry: &CatalogExtractedEntry, +) -> eyre::Result<()> { + put_bytes(sink, entry.canonical_path.as_str().as_bytes())?; + put_kind(sink, entry.kind); + put_u64(sink, entry.size); + put_optional_digest(sink, entry.file_blake3.as_ref()) +} + +fn put_kind(sink: &mut impl TranscriptSink, kind: CatalogEntryKind) { + sink.put(&[match kind { + CatalogEntryKind::Directory => 0, + CatalogEntryKind::File => 1, + }]); +} + +fn put_optional_digest( + sink: &mut impl TranscriptSink, + digest: Option<&super::Blake3Digest>, +) -> eyre::Result<()> { + match digest { + None => sink.put(&[0]), + Some(digest) => { + sink.put(&[1]); + put_bytes(sink, digest.as_bytes())?; + } + } + Ok(()) +} + +fn put_bytes(sink: &mut impl TranscriptSink, bytes: &[u8]) -> eyre::Result<()> { + put_len(sink, bytes.len())?; + sink.put(bytes); + Ok(()) +} + +fn put_len(sink: &mut impl TranscriptSink, len: usize) -> eyre::Result<()> { + put_u64(sink, u64::try_from(len)?); + Ok(()) +} + +fn put_u32(sink: &mut impl TranscriptSink, value: u32) { + sink.put(&value.to_be_bytes()); +} + +fn put_u64(sink: &mut impl TranscriptSink, value: u64) { + sink.put(&value.to_be_bytes()); +} + +#[cfg(test)] +pub(super) fn content_id_transcript(body: &CatalogContentManifestBody) -> eyre::Result> { + let mut transcript = Vec::new(); + encode_fields( + body.schema_version, + &body.game_id, + &body.game_version, + body.chunk_size, + &body.files, + &body.streamed_install_files, + &mut transcript, + )?; + Ok(transcript) +} diff --git a/crates/lanspread-db/src/content_manifest/index.rs b/crates/lanspread-db/src/content_manifest/index.rs new file mode 100644 index 0000000..5b7320f --- /dev/null +++ b/crates/lanspread-db/src/content_manifest/index.rs @@ -0,0 +1,413 @@ +use std::collections::{BTreeMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use super::{ + CatalogContentManifest, + ContentId, + MAX_CATALOG_ENTRIES, + path::{validate_game_id, validate_game_version}, +}; + +/// The required compact identity index stored beside catalog manifests. +/// +/// The `.jsonl` suffix deliberately keeps this authority artifact outside the +/// `.json` manifest-body namespace. +pub const CATALOG_CONTENT_INDEX_NAME: &str = "catalog-content-index-v1.jsonl"; +/// The only supported compact identity-index schema. +pub const CATALOG_CONTENT_INDEX_SCHEMA_VERSION: u32 = 1; +/// Maximum encoded size accepted for the compact identity index (128 MiB). +pub const MAX_CATALOG_CONTENT_INDEX_BYTES: u64 = 128 * 1024 * 1024; + +/// Non-I/O content authority needed to join remote availability to the local +/// catalog without loading a manifest body. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CatalogContentIdentity { + pub content_id: ContentId, + pub supports_streamed_install: bool, +} + +impl CatalogContentIdentity { + /// Derives the compact identity from a validated manifest. + #[must_use] + pub fn from_manifest(manifest: &CatalogContentManifest) -> Self { + Self { + content_id: manifest.content_id(), + supports_streamed_install: manifest.supports_streamed_install(), + } + } +} + +/// One exact game/version entry in the compact identity index. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatalogContentIndexEntry { + pub game_id: String, + pub game_version: String, + pub identity: CatalogContentIdentity, +} + +impl CatalogContentIndexEntry { + /// Builds one entry from an already validated manifest. + #[must_use] + pub fn from_manifest(manifest: &CatalogContentManifest) -> Self { + Self { + game_id: manifest.game_id().to_owned(), + game_version: manifest.game_version().to_owned(), + identity: CatalogContentIdentity::from_manifest(manifest), + } + } +} + +/// Canonical, exact-coverage compact catalog identity authority. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatalogContentIndex { + entries: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct IndexedContent { + game_version: String, + identity: CatalogContentIdentity, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct RawCatalogContentIndex { + schema_version: u32, + games: BTreeMap, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct RawCatalogContentIndexEntry { + game_version: String, + content_id: ContentId, + supports_streamed_install: bool, +} + +impl CatalogContentIndex { + /// Builds a validated compact index from exact entries. + pub fn from_entries( + entries: impl IntoIterator, + ) -> eyre::Result { + let mut indexed = BTreeMap::new(); + let mut portable_ids = HashSet::new(); + for entry in entries { + validate_game_id(&entry.game_id)?; + validate_game_version(&entry.game_version)?; + if !portable_ids.insert(entry.game_id.to_uppercase()) { + eyre::bail!( + "catalog content index contains duplicate or platform-alias game ID: {}", + entry.game_id + ); + } + if indexed + .insert( + entry.game_id.clone(), + IndexedContent { + game_version: entry.game_version, + identity: entry.identity, + }, + ) + .is_some() + { + eyre::bail!( + "catalog content index contains duplicate game ID: {}", + entry.game_id + ); + } + } + if indexed.len() > MAX_CATALOG_ENTRIES { + eyre::bail!("catalog content index exceeds the {MAX_CATALOG_ENTRIES}-game limit"); + } + + Ok(Self { entries: indexed }) + } + + /// Builds a validated compact index from sealed manifest bodies. + pub fn from_manifests<'a>( + manifests: impl IntoIterator, + ) -> eyre::Result { + let manifests = manifests.into_iter().collect::>(); + for manifest in &manifests { + manifest.validate()?; + } + Self::from_entries( + manifests + .into_iter() + .map(CatalogContentIndexEntry::from_manifest), + ) + } + + /// Parses only the canonical, bounded JSON representation. + pub fn from_json_slice(bytes: &[u8]) -> eyre::Result { + if u64::try_from(bytes.len())? > MAX_CATALOG_CONTENT_INDEX_BYTES { + eyre::bail!( + "catalog content index exceeds the {MAX_CATALOG_CONTENT_INDEX_BYTES}-byte limit" + ); + } + let raw: RawCatalogContentIndex = serde_json::from_slice(bytes)?; + if raw.schema_version != CATALOG_CONTENT_INDEX_SCHEMA_VERSION { + eyre::bail!( + "unsupported catalog content index schema {}", + raw.schema_version + ); + } + + let entries = raw + .games + .into_iter() + .map(|(game_id, entry)| CatalogContentIndexEntry { + game_id, + game_version: entry.game_version, + identity: CatalogContentIdentity { + content_id: entry.content_id, + supports_streamed_install: entry.supports_streamed_install, + }, + }) + .collect::>(); + let index = Self::from_entries(entries)?; + if index.to_canonical_json()? != bytes { + eyre::bail!("catalog content index JSON is not canonical"); + } + Ok(index) + } + + /// Produces deterministic pretty JSON with exactly one trailing newline. + pub fn to_canonical_json(&self) -> eyre::Result> { + let games = self + .entries + .iter() + .map(|(game_id, entry)| { + ( + game_id.clone(), + RawCatalogContentIndexEntry { + game_version: entry.game_version.clone(), + content_id: entry.identity.content_id, + supports_streamed_install: entry.identity.supports_streamed_install, + }, + ) + }) + .collect::>(); + let mut bytes = serde_json::to_vec(&RawCatalogContentIndex { + schema_version: CATALOG_CONTENT_INDEX_SCHEMA_VERSION, + games, + })?; + if u64::try_from(bytes.len())? >= MAX_CATALOG_CONTENT_INDEX_BYTES { + eyre::bail!( + "canonical catalog content index exceeds the {MAX_CATALOG_CONTENT_INDEX_BYTES}-byte limit" + ); + } + bytes.push(b'\n'); + Ok(bytes) + } + + /// Returns one expected compact identity without filesystem access. + #[must_use] + pub fn content_identity(&self, game_id: &str) -> Option { + self.entries.get(game_id).map(|entry| entry.identity) + } + + pub(super) fn validate_expected_versions( + &self, + expected_versions: &BTreeMap, + ) -> eyre::Result<()> { + if self.entries.len() != expected_versions.len() { + eyre::bail!( + "catalog content index coverage mismatch: expected {} games, found {}", + expected_versions.len(), + self.entries.len() + ); + } + for (game_id, expected_version) in expected_versions { + let entry = self.entries.get(game_id).ok_or_else(|| { + eyre::eyre!("catalog content index is missing game ID: {game_id}") + })?; + if entry.game_version != *expected_version { + eyre::bail!( + "catalog content index version mismatch for {game_id}: expected {expected_version}, found {}", + entry.game_version + ); + } + } + Ok(()) + } + + pub(super) fn validate_manifest( + &self, + game_id: &str, + manifest: &CatalogContentManifest, + ) -> eyre::Result<()> { + let expected = self + .entries + .get(game_id) + .ok_or_else(|| eyre::eyre!("catalog content index is missing game ID: {game_id}"))?; + let actual = CatalogContentIdentity::from_manifest(manifest); + if actual != expected.identity { + eyre::bail!( + "catalog manifest identity mismatch for {game_id}: index expects {} (streamed install {}), manifest computes {} (streamed install {})", + expected.identity.content_id, + expected.identity.supports_streamed_install, + actual.content_id, + actual.supports_streamed_install, + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content_manifest::{ + Blake3Digest, + CatalogContentManifestBody, + CatalogExtractedEntry, + CatalogFileEntry, + }; + + fn manifest(game_id: &str, version: &str) -> CatalogContentManifest { + let digest = Blake3Digest::hash(version.as_bytes()); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + game_id, + version, + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("version length should fit u64"), + digest, + vec![digest], + ) + .expect("version.ini entry should validate"), + ], + Vec::new(), + ) + .expect("manifest body should validate"), + ) + .expect("manifest should seal") + } + + #[test] + fn compact_index_has_one_canonical_encoding() { + let index = CatalogContentIndex::from_entries([CatalogContentIndexEntry { + game_id: "g".to_owned(), + game_version: "20240101".to_owned(), + identity: CatalogContentIdentity { + content_id: ContentId::from_bytes([0xab; 32]), + supports_streamed_install: false, + }, + }]) + .expect("index entry should validate"); + let expected = format!( + "{{\"schema_version\":1,\"games\":{{\"g\":{{\"game_version\":\"20240101\",\"content_id\":\"{}\",\"supports_streamed_install\":false}}}}}}\n", + "ab".repeat(32) + ); + + let bytes = index.to_canonical_json().expect("index should encode"); + assert_eq!(bytes, expected.as_bytes()); + assert_eq!( + CatalogContentIndex::from_json_slice(&bytes).expect("canonical index should parse"), + index + ); + let mut noncanonical = bytes; + noncanonical.insert(0, b' '); + assert!(CatalogContentIndex::from_json_slice(&noncanonical).is_err()); + } + + #[test] + fn compact_index_rejects_duplicate_json_game_keys() { + let entry = format!( + "{{\"game_version\":\"20240101\",\"content_id\":\"{}\",\"supports_streamed_install\":false}}", + "ab".repeat(32) + ); + let duplicate = + format!("{{\"schema_version\":1,\"games\":{{\"g\":{entry},\"g\":{entry}}}}}\n"); + + assert!(CatalogContentIndex::from_json_slice(duplicate.as_bytes()).is_err()); + } + + #[test] + fn index_requires_exact_catalog_ids_and_versions() { + let index = CatalogContentIndex::from_manifests([&manifest("g", "20240101")]) + .expect("manifest-derived index should validate"); + + index + .validate_expected_versions(&BTreeMap::from([("g".to_owned(), "20240101".to_owned())])) + .expect("exact catalog should match"); + assert!( + index + .validate_expected_versions(&BTreeMap::from([( + "g".to_owned(), + "20250101".to_owned(), + )])) + .is_err() + ); + assert!( + index + .validate_expected_versions(&BTreeMap::from([ + ("g".to_owned(), "20240101".to_owned()), + ("other".to_owned(), "20240101".to_owned()), + ])) + .is_err() + ); + } + + #[test] + fn index_rejects_portable_aliases_and_manifest_identity_drift() { + let expected = manifest("game", "20240101"); + assert!( + CatalogContentIndex::from_manifests([&expected, &manifest("GAME", "20240101"),]) + .is_err() + ); + + let index = + CatalogContentIndex::from_manifests([&expected]).expect("single entry should validate"); + assert!( + index + .validate_manifest("game", &manifest("game", "20250101")) + .is_err() + ); + } + + #[test] + fn index_rejects_stream_support_drift_with_the_correct_content_id() { + let version = "20240101"; + let version_digest = Blake3Digest::hash(version.as_bytes()); + let extracted_digest = Blake3Digest::hash(b"payload"); + let supported = CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "g", + version, + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("version length should fit u64"), + version_digest, + vec![version_digest], + ) + .expect("version.ini entry should validate"), + ], + vec![ + CatalogExtractedEntry::file("payload.bin", 7, extracted_digest) + .expect("extracted entry should validate"), + ], + ) + .expect("manifest body should validate"), + ) + .expect("manifest should seal"); + assert!(supported.supports_streamed_install()); + + let index = CatalogContentIndex::from_entries([CatalogContentIndexEntry { + game_id: "g".to_owned(), + game_version: version.to_owned(), + identity: CatalogContentIdentity { + content_id: supported.content_id(), + supports_streamed_install: false, + }, + }]) + .expect("index entry should validate"); + + assert!(index.validate_manifest("g", &supported).is_err()); + } +} diff --git a/crates/lanspread-db/src/content_manifest/mod.rs b/crates/lanspread-db/src/content_manifest/mod.rs new file mode 100644 index 0000000..52645b8 --- /dev/null +++ b/crates/lanspread-db/src/content_manifest/mod.rs @@ -0,0 +1,51 @@ +//! Trusted catalog content manifests. +//! +//! Manifest JSON is a reproducible transport for catalog-publisher output. The +//! content identity is derived from the versioned binary transcript in +//! [`encoding`], never from JSON formatting. Sealed manifests deliberately do +//! not implement [`serde::Deserialize`]; untrusted bytes must pass through the +//! bounded canonical loader [`CatalogContentManifest::from_json_slice`]. + +#![allow(clippy::missing_errors_doc)] + +mod bundle; +mod digest; +mod encoding; +mod index; +mod model; +mod path; +mod store; + +pub use bundle::CatalogBundle; +pub use digest::{Blake3Digest, ContentId}; +pub use index::{ + CATALOG_CONTENT_INDEX_NAME, + CATALOG_CONTENT_INDEX_SCHEMA_VERSION, + CatalogContentIdentity, + CatalogContentIndex, + CatalogContentIndexEntry, + MAX_CATALOG_CONTENT_INDEX_BYTES, +}; +pub use model::{ + CATALOG_CHUNK_SIZE, + CATALOG_CONTENT_MANIFEST_SCHEMA_VERSION, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogEntryKind, + CatalogExtractedEntry, + CatalogFileEntry, + MAX_CATALOG_COMPONENT_BYTES, + MAX_CATALOG_ENTRIES, + MAX_CATALOG_FILE_BYTES, + MAX_CATALOG_MANIFEST_BYTES, + MAX_CATALOG_PATH_BYTES, + MAX_CATALOG_TOTAL_BYTES, +}; +pub use path::CanonicalCatalogPath; +pub use store::{ + CATALOG_PUBLICATION_MARKER_NAME, + CatalogManifestStore, + reject_incomplete_catalog_publication, + write_canonical_content_index_atomic, + write_canonical_manifest_atomic, +}; diff --git a/crates/lanspread-db/src/content_manifest/model.rs b/crates/lanspread-db/src/content_manifest/model.rs new file mode 100644 index 0000000..db1962a --- /dev/null +++ b/crates/lanspread-db/src/content_manifest/model.rs @@ -0,0 +1,1174 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{ + CanonicalCatalogPath, + ContentId, + digest::Blake3Digest, + encoding::{compute_content_id, compute_content_id_fields}, + path::{ + is_download_protected_root_name, + is_stream_install_protected_root_name, + validate_game_id, + validate_game_version, + }, +}; + +/// The only supported content-manifest schema. +pub const CATALOG_CONTENT_MANIFEST_SCHEMA_VERSION: u32 = 1; +/// The fixed ordinary-transfer chunk size (128 MiB). +pub const CATALOG_CHUNK_SIZE: u64 = 128 * 1024 * 1024; +/// Maximum encoded JSON size accepted by the strict loader. +pub const MAX_CATALOG_MANIFEST_BYTES: u64 = 128 * 1024 * 1024; +/// Maximum number of entries in either manifest entry list. +pub const MAX_CATALOG_ENTRIES: usize = 100_000; +/// Maximum size of one ordinary or extracted file (1 TiB). +pub const MAX_CATALOG_FILE_BYTES: u64 = 1024 * 1024 * 1024 * 1024; +/// Maximum sum of file sizes in either entry list (16 TiB). +pub const MAX_CATALOG_TOTAL_BYTES: u64 = 16 * MAX_CATALOG_FILE_BYTES; +/// Maximum encoded byte length of one root-relative path. +pub const MAX_CATALOG_PATH_BYTES: usize = 900; +/// Maximum encoded byte length of one path component. +pub const MAX_CATALOG_COMPONENT_BYTES: usize = 255; + +const MAX_VERSION_INI_BYTES: u64 = 64 * 1024; +const MAX_CATALOG_CHUNK_DIGESTS: usize = 2_000_000; +const VERSION_INI: &str = "version.ini"; + +/// The filesystem shape of one catalog entry. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CatalogEntryKind { + Directory, + File, +} + +/// One ordinary downloadable entry below the game root. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CatalogFileEntry { + pub(super) canonical_path: CanonicalCatalogPath, + pub(super) kind: CatalogEntryKind, + pub(super) size: u64, + pub(super) file_blake3: Option, + pub(super) chunk_blake3: Vec, +} + +impl CatalogFileEntry { + /// Constructs an explicit directory entry. + pub fn directory(path: impl Into) -> eyre::Result { + Ok(Self { + canonical_path: CanonicalCatalogPath::new(path)?, + kind: CatalogEntryKind::Directory, + size: 0, + file_blake3: None, + chunk_blake3: Vec::new(), + }) + } + + /// Constructs a regular file entry and validates its hash shape. + pub fn file( + path: impl Into, + size: u64, + file_blake3: Blake3Digest, + chunk_blake3: Vec, + ) -> eyre::Result { + let entry = Self { + canonical_path: CanonicalCatalogPath::new(path)?, + kind: CatalogEntryKind::File, + size, + file_blake3: Some(file_blake3), + chunk_blake3, + }; + validate_ordinary_entry(&entry)?; + Ok(entry) + } + + #[must_use] + pub fn canonical_path(&self) -> &CanonicalCatalogPath { + &self.canonical_path + } + + #[must_use] + pub const fn kind(&self) -> CatalogEntryKind { + self.kind + } + + #[must_use] + pub const fn size(&self) -> u64 { + self.size + } + + #[must_use] + pub const fn file_blake3(&self) -> Option { + self.file_blake3 + } + + #[must_use] + pub fn chunk_blake3(&self) -> &[Blake3Digest] { + &self.chunk_blake3 + } +} + +/// One catalog-owned final output entry for Stream Install. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CatalogExtractedEntry { + pub(super) canonical_path: CanonicalCatalogPath, + pub(super) kind: CatalogEntryKind, + pub(super) size: u64, + pub(super) file_blake3: Option, +} + +impl CatalogExtractedEntry { + /// Constructs an optional extracted directory entry. + pub fn directory(path: impl Into) -> eyre::Result { + Ok(Self { + canonical_path: CanonicalCatalogPath::new(path)?, + kind: CatalogEntryKind::Directory, + size: 0, + file_blake3: None, + }) + } + + /// Constructs an extracted regular file entry. + pub fn file( + path: impl Into, + size: u64, + file_blake3: Blake3Digest, + ) -> eyre::Result { + let entry = Self { + canonical_path: CanonicalCatalogPath::new(path)?, + kind: CatalogEntryKind::File, + size, + file_blake3: Some(file_blake3), + }; + validate_extracted_entry(&entry)?; + Ok(entry) + } + + #[must_use] + pub fn canonical_path(&self) -> &CanonicalCatalogPath { + &self.canonical_path + } + + #[must_use] + pub const fn kind(&self) -> CatalogEntryKind { + self.kind + } + + #[must_use] + pub const fn size(&self) -> u64 { + self.size + } + + #[must_use] + pub const fn file_blake3(&self) -> Option { + self.file_blake3 + } +} + +/// The content-ID-bearing portion of a manifest before it is sealed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatalogContentManifestBody { + pub(super) schema_version: u32, + pub(super) game_id: String, + pub(super) game_version: String, + pub(super) chunk_size: u64, + pub(super) files: Vec, + pub(super) streamed_install_files: Vec, +} + +impl CatalogContentManifestBody { + /// Constructs and fully validates a schema-1 manifest body. + pub fn new( + game_id: impl Into, + game_version: impl Into, + files: Vec, + streamed_install_files: Vec, + ) -> eyre::Result { + let body = Self { + schema_version: CATALOG_CONTENT_MANIFEST_SCHEMA_VERSION, + game_id: game_id.into(), + game_version: game_version.into(), + chunk_size: CATALOG_CHUNK_SIZE, + files, + streamed_install_files, + }; + body.validate()?; + Ok(body) + } + + fn validate(&self) -> eyre::Result<()> { + validate_header( + self.schema_version, + &self.game_id, + &self.game_version, + self.chunk_size, + )?; + validate_ordinary_entries(&self.files)?; + validate_extracted_entries(&self.streamed_install_files) + } + + #[must_use] + pub fn game_id(&self) -> &str { + &self.game_id + } + + #[must_use] + pub fn game_version(&self) -> &str { + &self.game_version + } + + #[must_use] + pub fn files(&self) -> &[CatalogFileEntry] { + &self.files + } + + /// Finds one ordinary entry by its exact canonical path. + #[must_use] + pub fn file_entry(&self, canonical_path: &str) -> Option<&CatalogFileEntry> { + self.files + .binary_search_by(|entry| entry.canonical_path.as_str().cmp(canonical_path)) + .ok() + .map(|index| &self.files[index]) + } + + #[must_use] + pub fn streamed_install_files(&self) -> &[CatalogExtractedEntry] { + &self.streamed_install_files + } + + /// Finds one extracted entry by its exact canonical path. + #[must_use] + pub fn streamed_install_entry(&self, canonical_path: &str) -> Option<&CatalogExtractedEntry> { + self.streamed_install_files + .binary_search_by(|entry| entry.canonical_path.as_str().cmp(canonical_path)) + .ok() + .map(|index| &self.streamed_install_files[index]) + } + + /// Returns whether this game has catalog-owned Stream Install output. + #[must_use] + pub fn supports_streamed_install(&self) -> bool { + !self.streamed_install_files.is_empty() + } +} + +/// A validated catalog content manifest with a verified content identity. +/// +/// This type intentionally does not implement [`Deserialize`]. Read manifest +/// artifacts with [`Self::from_json_slice`] so size and canonical-encoding +/// checks cannot be bypassed accidentally. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CatalogContentManifest { + schema_version: u32, + game_id: String, + game_version: String, + chunk_size: u64, + files: Vec, + streamed_install_files: Vec, + content_id: ContentId, +} + +impl CatalogContentManifest { + /// Validates a manifest body and seals it with its deterministic content ID. + pub fn seal(body: CatalogContentManifestBody) -> eyre::Result { + body.validate()?; + let content_id = compute_content_id(&body)?; + Ok(Self { + schema_version: body.schema_version, + game_id: body.game_id, + game_version: body.game_version, + chunk_size: body.chunk_size, + files: body.files, + streamed_install_files: body.streamed_install_files, + content_id, + }) + } + + /// Parses only the canonical, bounded JSON representation. + pub fn from_json_slice(bytes: &[u8]) -> eyre::Result { + if u64::try_from(bytes.len())? > MAX_CATALOG_MANIFEST_BYTES { + eyre::bail!( + "catalog content manifest exceeds the {MAX_CATALOG_MANIFEST_BYTES}-byte limit" + ); + } + let raw: RawCatalogContentManifest = serde_json::from_slice(bytes)?; + let manifest = Self::try_from(raw)?; + if manifest.to_canonical_json()? != bytes { + eyre::bail!("catalog content manifest JSON is not canonical"); + } + Ok(manifest) + } + + /// Produces deterministic pretty JSON with exactly one trailing newline. + pub fn to_canonical_json(&self) -> eyre::Result> { + self.validate()?; + let mut bytes = serde_json::to_vec_pretty(self)?; + if u64::try_from(bytes.len())? >= MAX_CATALOG_MANIFEST_BYTES { + eyre::bail!( + "canonical catalog manifest exceeds the {MAX_CATALOG_MANIFEST_BYTES}-byte limit" + ); + } + bytes.push(b'\n'); + Ok(bytes) + } + + /// Revalidates every structural invariant and the stored content ID. + pub fn validate(&self) -> eyre::Result<()> { + validate_header( + self.schema_version, + &self.game_id, + &self.game_version, + self.chunk_size, + )?; + validate_ordinary_entries(&self.files)?; + validate_extracted_entries(&self.streamed_install_files)?; + let expected = compute_content_id_fields( + self.schema_version, + &self.game_id, + &self.game_version, + self.chunk_size, + &self.files, + &self.streamed_install_files, + )?; + if self.content_id != expected { + eyre::bail!( + "catalog content ID mismatch: stored {}, computed {expected}", + self.content_id + ); + } + Ok(()) + } + + #[must_use] + pub const fn schema_version(&self) -> u32 { + self.schema_version + } + + #[must_use] + pub fn game_id(&self) -> &str { + &self.game_id + } + + #[must_use] + pub fn game_version(&self) -> &str { + &self.game_version + } + + #[must_use] + pub const fn chunk_size(&self) -> u64 { + self.chunk_size + } + + #[must_use] + pub fn files(&self) -> &[CatalogFileEntry] { + &self.files + } + + /// Finds one ordinary entry by its exact canonical path. + #[must_use] + pub fn file_entry(&self, canonical_path: &str) -> Option<&CatalogFileEntry> { + self.files + .binary_search_by(|entry| entry.canonical_path.as_str().cmp(canonical_path)) + .ok() + .map(|index| &self.files[index]) + } + + #[must_use] + pub fn streamed_install_files(&self) -> &[CatalogExtractedEntry] { + &self.streamed_install_files + } + + /// Finds one extracted entry by its exact canonical path. + #[must_use] + pub fn streamed_install_entry(&self, canonical_path: &str) -> Option<&CatalogExtractedEntry> { + self.streamed_install_files + .binary_search_by(|entry| entry.canonical_path.as_str().cmp(canonical_path)) + .ok() + .map(|index| &self.streamed_install_files[index]) + } + + /// Returns whether this game has catalog-owned Stream Install output. + #[must_use] + pub fn supports_streamed_install(&self) -> bool { + !self.streamed_install_files.is_empty() + } + + #[must_use] + pub const fn content_id(&self) -> ContentId { + self.content_id + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCatalogContentManifest { + schema_version: u32, + game_id: String, + game_version: String, + chunk_size: u64, + files: Vec, + streamed_install_files: Vec, + content_id: ContentId, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCatalogFileEntry { + canonical_path: CanonicalCatalogPath, + kind: CatalogEntryKind, + size: u64, + #[serde(deserialize_with = "deserialize_required_nullable")] + file_blake3: Option, + chunk_blake3: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCatalogExtractedEntry { + canonical_path: CanonicalCatalogPath, + kind: CatalogEntryKind, + size: u64, + #[serde(deserialize_with = "deserialize_required_nullable")] + file_blake3: Option, +} + +fn deserialize_required_nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + // The field-level `deserialize_with` annotation is the requiredness gate: + // Serde reports an absent field before calling this function. For a field + // that is present, JSON `null` remains the canonical directory encoding. + Option::::deserialize(deserializer) +} + +impl TryFrom for CatalogContentManifest { + type Error = eyre::Report; + + fn try_from(raw: RawCatalogContentManifest) -> Result { + let manifest = Self { + schema_version: raw.schema_version, + game_id: raw.game_id, + game_version: raw.game_version, + chunk_size: raw.chunk_size, + files: raw.files.into_iter().map(Into::into).collect(), + streamed_install_files: raw + .streamed_install_files + .into_iter() + .map(Into::into) + .collect(), + content_id: raw.content_id, + }; + manifest.validate()?; + Ok(manifest) + } +} + +impl From for CatalogFileEntry { + fn from(raw: RawCatalogFileEntry) -> Self { + Self { + canonical_path: raw.canonical_path, + kind: raw.kind, + size: raw.size, + file_blake3: raw.file_blake3, + chunk_blake3: raw.chunk_blake3, + } + } +} + +impl From for CatalogExtractedEntry { + fn from(raw: RawCatalogExtractedEntry) -> Self { + Self { + canonical_path: raw.canonical_path, + kind: raw.kind, + size: raw.size, + file_blake3: raw.file_blake3, + } + } +} + +fn validate_header( + schema_version: u32, + game_id: &str, + game_version: &str, + chunk_size: u64, +) -> eyre::Result<()> { + if schema_version != CATALOG_CONTENT_MANIFEST_SCHEMA_VERSION { + eyre::bail!("unsupported catalog content manifest schema {schema_version}"); + } + if chunk_size != CATALOG_CHUNK_SIZE { + eyre::bail!("schema 1 requires a {CATALOG_CHUNK_SIZE}-byte chunk size"); + } + validate_game_id(game_id)?; + validate_game_version(game_version) +} + +fn validate_ordinary_entries(entries: &[CatalogFileEntry]) -> eyre::Result<()> { + if entries.len() > MAX_CATALOG_ENTRIES { + eyre::bail!("ordinary manifest exceeds the {MAX_CATALOG_ENTRIES}-entry limit"); + } + let mut total_bytes = 0_u64; + let mut total_chunks = 0_usize; + let mut version_count = 0_usize; + for entry in entries { + validate_ordinary_entry(entry)?; + total_bytes = account_size(total_bytes, entry.size)?; + total_chunks = total_chunks + .checked_add(entry.chunk_blake3.len()) + .ok_or_else(|| eyre::eyre!("ordinary chunk count overflow"))?; + if total_chunks > MAX_CATALOG_CHUNK_DIGESTS { + eyre::bail!("ordinary manifest contains too many chunk digests"); + } + if entry.canonical_path.as_str() == VERSION_INI && entry.kind == CatalogEntryKind::File { + version_count += 1; + if entry.size > MAX_VERSION_INI_BYTES { + eyre::bail!("root version.ini exceeds the {MAX_VERSION_INI_BYTES}-byte limit"); + } + } + } + if version_count != 1 { + eyre::bail!("ordinary manifest must contain exactly one regular root version.ini"); + } + validate_topology( + entries + .iter() + .map(|entry| (&entry.canonical_path, entry.kind)), + true, + is_download_protected_root_name, + ) +} + +fn validate_extracted_entries(entries: &[CatalogExtractedEntry]) -> eyre::Result<()> { + if entries.len() > MAX_CATALOG_ENTRIES { + eyre::bail!("extracted manifest exceeds the {MAX_CATALOG_ENTRIES}-entry limit"); + } + let mut total_bytes = 0_u64; + for entry in entries { + validate_extracted_entry(entry)?; + total_bytes = account_size(total_bytes, entry.size)?; + } + validate_topology( + entries + .iter() + .map(|entry| (&entry.canonical_path, entry.kind)), + false, + is_stream_install_protected_root_name, + ) +} + +fn validate_ordinary_entry(entry: &CatalogFileEntry) -> eyre::Result<()> { + match entry.kind { + CatalogEntryKind::Directory => { + if entry.size != 0 || entry.file_blake3.is_some() || !entry.chunk_blake3.is_empty() { + eyre::bail!( + "catalog directory has file metadata: {}", + entry.canonical_path + ); + } + } + CatalogEntryKind::File => { + validate_file_size_and_hash(entry.size, entry.file_blake3)?; + let expected_chunks = if entry.size == 0 { + 0 + } else { + usize::try_from(entry.size.div_ceil(CATALOG_CHUNK_SIZE))? + }; + if entry.chunk_blake3.len() != expected_chunks { + eyre::bail!( + "catalog file {} has {} chunk hashes; expected {expected_chunks}", + entry.canonical_path, + entry.chunk_blake3.len() + ); + } + if expected_chunks == 1 && entry.chunk_blake3.first() != entry.file_blake3.as_ref() { + eyre::bail!( + "single-chunk file hash does not match its chunk hash: {}", + entry.canonical_path + ); + } + } + } + Ok(()) +} + +fn validate_extracted_entry(entry: &CatalogExtractedEntry) -> eyre::Result<()> { + match entry.kind { + CatalogEntryKind::Directory => { + if entry.size != 0 || entry.file_blake3.is_some() { + eyre::bail!( + "catalog directory has file metadata: {}", + entry.canonical_path + ); + } + } + CatalogEntryKind::File => validate_file_size_and_hash(entry.size, entry.file_blake3)?, + } + Ok(()) +} + +fn validate_file_size_and_hash(size: u64, digest: Option) -> eyre::Result<()> { + if size > MAX_CATALOG_FILE_BYTES { + eyre::bail!("catalog file exceeds the {MAX_CATALOG_FILE_BYTES}-byte limit"); + } + let digest = digest.ok_or_else(|| eyre::eyre!("catalog file is missing its BLAKE3 digest"))?; + if size == 0 && digest != Blake3Digest::hash(&[]) { + eyre::bail!("empty catalog file has an incorrect BLAKE3 digest"); + } + Ok(()) +} + +fn account_size(current: u64, size: u64) -> eyre::Result { + let total = current + .checked_add(size) + .ok_or_else(|| eyre::eyre!("catalog manifest byte total overflow"))?; + if total > MAX_CATALOG_TOTAL_BYTES { + eyre::bail!("catalog manifest exceeds the {MAX_CATALOG_TOTAL_BYTES}-byte total limit"); + } + Ok(total) +} + +fn validate_topology<'a>( + entries: impl IntoIterator, + require_explicit_parents: bool, + is_protected_root_name: fn(&str) -> bool, +) -> eyre::Result<()> { + let entries = entries.into_iter().collect::>(); + for pair in entries.windows(2) { + if pair[0].0.as_str() >= pair[1].0.as_str() { + eyre::bail!("catalog entries are not strictly sorted by canonical path"); + } + } + + let mut exact_shapes = BTreeMap::new(); + let mut alias_shapes = BTreeMap::new(); + for (path, kind) in &entries { + let root = path.components().next().unwrap_or_default(); + if is_protected_root_name(root) { + eyre::bail!("catalog path is reserved for application state: {path}"); + } + if exact_shapes.insert(path.as_str(), *kind).is_some() { + eyre::bail!("duplicate catalog path: {path}"); + } + if alias_shapes.insert(path.portable_alias(), *kind).is_some() { + eyre::bail!("duplicate or platform-alias catalog path: {path}"); + } + } + + for (path, _) in &entries { + for parent in path.parent_paths() { + match exact_shapes.get(parent) { + Some(CatalogEntryKind::File) => { + eyre::bail!("catalog path descends through a file: {path}"); + } + None if require_explicit_parents => { + eyre::bail!("catalog path is missing explicit parent directory {parent}"); + } + Some(CatalogEntryKind::Directory) | None => {} + } + } + + let alias = path.portable_alias(); + for (separator, _) in alias.match_indices('/') { + let parent = &alias[..separator]; + if alias_shapes.get(parent) == Some(&CatalogEntryKind::File) { + eyre::bail!("catalog path descends through a platform-alias file: {path}"); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeSet, fmt::Write as _}; + + use serde_json::Value; + + use super::*; + use crate::content_manifest::encoding::content_id_transcript; + + const GOLDEN_CONTENT_ID: &str = + "507be1d8d72fde69e60e019ff0a10de7780c69a01cca81823447535eabcfadb0"; + const GOLDEN_TRANSCRIPT_HEX: &str = concat!( + "000000000000002d6c616e7370726561642f636174616c6f672d636f6e74656e742d6d616e69666573742f636f6e7465", + "6e742d696400000001000000000000000b676f6c64656e2d67616d650000000000000007323032342e30310000000008", + "000000000000000000000500000000000000066173736574730000000000000000000000000000000000000000000000", + "00000b6173736574732f6461746100000000000000000000000000000000000000000000000000176173736574732f64", + "6174612f617263686976652e62696e010000000008000007010000000000000020111111111111111111111111111111", + "111111111111111111111111111111111100000000000000020000000000000020212121212121212121212121212121", + "212121212121212121212121212121212100000000000000202222222222222222222222222222222222222222222222", + "2222222222222222220000000000000009656d7074792e747874010000000000000000010000000000000020af1349b9", + "f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f32620000000000000000000000000000000b76657273", + "696f6e2e696e690100000000000000080100000000000000203361edb4b45d1743e25e893ed1f7926b9ae99e6688c46a", + "df1d24b8102065cef8000000000000000100000000000000203361edb4b45d1743e25e893ed1f7926b9ae99e6688c46a", + "df1d24b8102065cef800000000000000030000000000000013696e7374616c6c5f696e74656e742e6a736f6e01000000", + "0000000006010000000000000020f0877c0e1ab8f4c605052fb919df4dc8cb7ff4df5a9cd5e85e8796a5727533d90000", + "0000000000056c6f63616c0000000000000000000000000000000000116c6f63616c2f70726f66696c652e6461740100", + "000000000000040100000000000000205f6d3989c7fb86d3c961c0b217cb3b4939cff7416ed8980a18c7e662ec3e0761", + ); + + fn golden_body() -> CatalogContentManifestBody { + let version = Blake3Digest::hash(b"2024.01\n"); + let empty = Blake3Digest::hash(&[]); + CatalogContentManifestBody::new( + "golden-game", + "2024.01", + vec![ + CatalogFileEntry::directory("assets").expect("asset directory should validate"), + CatalogFileEntry::directory("assets/data").expect("data directory should validate"), + CatalogFileEntry::file( + "assets/data/archive.bin", + CATALOG_CHUNK_SIZE + 7, + Blake3Digest::from_bytes([0x11; 32]), + vec![ + Blake3Digest::from_bytes([0x21; 32]), + Blake3Digest::from_bytes([0x22; 32]), + ], + ) + .expect("multi-chunk entry should validate"), + CatalogFileEntry::file("empty.txt", 0, empty, Vec::new()) + .expect("empty entry should validate"), + CatalogFileEntry::file("version.ini", 8, version, vec![version]) + .expect("version entry should validate"), + ], + vec![ + CatalogExtractedEntry::file( + "install_intent.json", + 6, + Blake3Digest::hash(b"intent"), + ) + .expect("extracted intent-named entry should validate"), + CatalogExtractedEntry::directory("local") + .expect("extracted local directory should validate"), + CatalogExtractedEntry::file("local/profile.dat", 4, Blake3Digest::hash(b"user")) + .expect("extracted profile should validate"), + ], + ) + .expect("golden body should validate") + } + + fn golden_manifest() -> CatalogContentManifest { + CatalogContentManifest::seal(golden_body()).expect("golden manifest should seal") + } + + fn decode_json_value(value: Value) -> eyre::Result { + let raw = serde_json::from_value::(value)?; + CatalogContentManifest::try_from(raw) + } + + #[test] + fn golden_transcript_and_content_id_are_frozen() { + let body = golden_body(); + let transcript = content_id_transcript(&body).expect("transcript should encode"); + let mut transcript_hex = String::with_capacity(transcript.len() * 2); + for byte in transcript { + write!(&mut transcript_hex, "{byte:02x}").expect("writing to a String cannot fail"); + } + assert_eq!(transcript_hex, GOLDEN_TRANSCRIPT_HEX); + assert_eq!( + CatalogContentManifest::seal(body) + .expect("manifest should seal") + .content_id() + .to_string(), + GOLDEN_CONTENT_ID + ); + } + + #[test] + fn canonical_json_round_trips_and_ends_in_one_newline() { + let manifest = golden_manifest(); + let json = manifest + .to_canonical_json() + .expect("manifest should encode"); + assert!(json.ends_with(b"\n")); + assert!(!json.ends_with(b"\n\n")); + assert_eq!( + CatalogContentManifest::from_json_slice(&json).expect("manifest should load"), + manifest + ); + + let compact = serde_json::to_vec(&manifest).expect("manifest should serialize"); + assert!(CatalogContentManifest::from_json_slice(&compact).is_err()); + } + + #[test] + fn semantic_mutation_with_old_content_id_is_rejected() { + let manifest = golden_manifest(); + let mut json: Value = serde_json::to_value(&manifest).expect("manifest should serialize"); + json["game_version"] = Value::String("20250101".to_owned()); + assert!(decode_json_value(json).is_err()); + } + + #[test] + fn every_transcript_field_affects_content_identity() { + let body = golden_body(); + let baseline = compute_content_id(&body).expect("baseline should hash"); + let mut mutations = Vec::new(); + + let mut changed = body.clone(); + changed.schema_version = 2; + mutations.push(changed); + let mut changed = body.clone(); + changed.game_id = "h".to_owned(); + mutations.push(changed); + let mut changed = body.clone(); + changed.game_version = "20240102".to_owned(); + mutations.push(changed); + let mut changed = body.clone(); + changed.chunk_size += 1; + mutations.push(changed); + let mut changed = body.clone(); + changed.files[2].canonical_path = + CanonicalCatalogPath::new("assets/data/archive2.bin").expect("path should validate"); + mutations.push(changed); + let mut changed = body.clone(); + changed.files[2].kind = CatalogEntryKind::Directory; + mutations.push(changed); + let mut changed = body.clone(); + changed.files[2].size += 1; + mutations.push(changed); + let mut changed = body.clone(); + changed.files[2].file_blake3 = Some(Blake3Digest::hash(b"other whole file")); + mutations.push(changed); + let mut changed = body.clone(); + changed.files[2].chunk_blake3[0] = Blake3Digest::hash(b"other chunk"); + mutations.push(changed); + let mut changed = body.clone(); + changed.files.clear(); + mutations.push(changed); + let mut changed = body.clone(); + changed.streamed_install_files[0].canonical_path = + CanonicalCatalogPath::new("bin/b.txt").expect("path should validate"); + mutations.push(changed); + let mut changed = body.clone(); + changed.streamed_install_files[0].kind = CatalogEntryKind::Directory; + mutations.push(changed); + let mut changed = body.clone(); + changed.streamed_install_files[0].size += 1; + mutations.push(changed); + let mut changed = body.clone(); + changed.streamed_install_files[0].file_blake3 = + Some(Blake3Digest::hash(b"other extracted file")); + mutations.push(changed); + let mut changed = body; + changed.streamed_install_files.clear(); + mutations.push(changed); + + let mut identities = BTreeSet::from([baseline]); + for mutation in mutations { + let identity = compute_content_id(&mutation).expect("mutation should hash"); + assert_ne!(identity, baseline); + assert!( + identities.insert(identity), + "mutations collided in test vector" + ); + } + } + + #[test] + fn unknown_and_missing_fields_are_rejected() { + let manifest = golden_manifest(); + let mut unknown: Value = + serde_json::to_value(&manifest).expect("manifest should serialize"); + unknown["surprise"] = Value::Bool(true); + assert!(serde_json::from_value::(unknown).is_err()); + + let mut missing: Value = + serde_json::to_value(&manifest).expect("manifest should serialize"); + missing["files"][0] + .as_object_mut() + .expect("file should be an object") + .remove("file_blake3"); + assert!(serde_json::from_value::(missing).is_err()); + + let canonical = String::from_utf8( + manifest + .to_canonical_json() + .expect("manifest should serialize"), + ) + .expect("canonical JSON should be UTF-8"); + let duplicate = canonical.replacen( + " \"schema_version\": 1,", + " \"schema_version\": 1,\n \"schema_version\": 1,", + 1, + ); + assert!(serde_json::from_str::(&duplicate).is_err()); + } + + #[test] + fn nullable_hash_fields_require_presence_for_both_entry_shapes() { + let digest = Blake3Digest::hash(b"file"); + + let ordinary_directory = + CatalogFileEntry::directory("dir").expect("ordinary directory should validate"); + let ordinary_null = + serde_json::to_value(&ordinary_directory).expect("ordinary directory should serialize"); + assert!( + serde_json::from_value::(ordinary_null.clone()) + .expect("present null ordinary hash should deserialize") + .file_blake3 + .is_none() + ); + let ordinary_file = CatalogFileEntry::file("file.bin", 4, digest, vec![digest]) + .expect("ordinary file should validate"); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&ordinary_file).expect("ordinary file should serialize"), + ) + .expect("present ordinary hash should deserialize") + .file_blake3, + Some(digest) + ); + let mut ordinary_missing = ordinary_null; + ordinary_missing + .as_object_mut() + .expect("ordinary entry should be an object") + .remove("file_blake3"); + assert!(serde_json::from_value::(ordinary_missing).is_err()); + + let extracted_directory = + CatalogExtractedEntry::directory("dir").expect("extracted directory should validate"); + let extracted_null = serde_json::to_value(&extracted_directory) + .expect("extracted directory should serialize"); + assert!( + serde_json::from_value::(extracted_null.clone()) + .expect("present null extracted hash should deserialize") + .file_blake3 + .is_none() + ); + let extracted_file = CatalogExtractedEntry::file("file.bin", 4, digest) + .expect("extracted file should validate"); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&extracted_file).expect("extracted file should serialize"), + ) + .expect("present extracted hash should deserialize") + .file_blake3, + Some(digest) + ); + let mut extracted_missing = extracted_null; + extracted_missing + .as_object_mut() + .expect("extracted entry should be an object") + .remove("file_blake3"); + assert!(serde_json::from_value::(extracted_missing).is_err()); + } + + #[test] + fn rejects_unsupported_headers_and_unbounded_identity_text() { + let mut body = golden_body(); + body.schema_version = 2; + assert!(body.validate().is_err()); + + let mut body = golden_body(); + body.chunk_size = CATALOG_CHUNK_SIZE / 2; + assert!(body.validate().is_err()); + + let mut body = golden_body(); + body.game_id = "../g".to_owned(); + assert!(body.validate().is_err()); + + let mut body = golden_body(); + body.game_id = "cafe\u{301}".to_owned(); + assert!(body.validate().is_err()); + + let mut body = golden_body(); + body.game_version = "v".repeat(256); + assert!(body.validate().is_err()); + } + + #[test] + fn ordinary_entries_require_explicit_parents() { + let version = Blake3Digest::hash(b"20240101"); + let payload = Blake3Digest::hash(b"x"); + let result = CatalogContentManifestBody::new( + "g", + "20240101", + vec![ + CatalogFileEntry::file("bin/a", 1, payload, vec![payload]) + .expect("file should validate alone"), + CatalogFileEntry::file("version.ini", 8, version, vec![version]) + .expect("version should validate alone"), + ], + Vec::new(), + ); + assert!(result.is_err()); + + let body = CatalogContentManifestBody::new( + "g", + "20240101", + vec![ + CatalogFileEntry::directory("bin").expect("directory should validate"), + CatalogFileEntry::file("bin/a", 1, payload, vec![payload]) + .expect("file should validate alone"), + CatalogFileEntry::file("version.ini", 8, version, vec![version]) + .expect("version should validate alone"), + ], + Vec::new(), + ) + .expect("explicit directory should make topology valid"); + assert_eq!( + body.file_entry("bin/a").map(CatalogFileEntry::size), + Some(1) + ); + } + + #[test] + fn extracted_entries_allow_implicit_parents() { + let body = golden_body(); + assert!(body.validate().is_ok()); + assert!(body.supports_streamed_install()); + assert_eq!( + body.streamed_install_entry("local/profile.dat") + .map(CatalogExtractedEntry::size), + Some(4) + ); + } + + #[test] + fn extracted_entries_allow_game_root_state_names_but_not_staging_marker() { + let body = golden_body(); + assert!(body.streamed_install_entry("install_intent.json").is_some()); + assert!(body.streamed_install_entry("local").is_some()); + + let mut protected = body; + protected.streamed_install_files = vec![ + CatalogExtractedEntry::directory(".lanspread_owned") + .expect("path should validate independently"), + ]; + assert!(protected.validate().is_err()); + } + + #[test] + fn rejects_aliases_unsorted_entries_and_shape_conflicts() { + let version = Blake3Digest::hash(b"20240101"); + let payload = Blake3Digest::hash(b"x"); + let file = |path| { + CatalogFileEntry::file(path, 1, payload, vec![payload]) + .expect("entry should validate alone") + }; + let version_entry = || { + CatalogFileEntry::file("version.ini", 8, version, vec![version]) + .expect("version should validate alone") + }; + + assert!( + CatalogContentManifestBody::new( + "g", + "20240101", + vec![file("z"), version_entry()], + Vec::new() + ) + .is_err() + ); + assert!( + CatalogContentManifestBody::new( + "g", + "20240101", + vec![file("A"), file("a"), version_entry()], + Vec::new() + ) + .is_err() + ); + assert!( + CatalogContentManifestBody::new( + "g", + "20240101", + vec![file("bin"), file("bin/a"), version_entry()], + Vec::new() + ) + .is_err() + ); + } + + #[test] + fn enforces_file_hash_and_chunk_shapes() { + let empty = Blake3Digest::hash(&[]); + assert!(CatalogFileEntry::file("empty", 0, empty, Vec::new()).is_ok()); + assert!(CatalogFileEntry::file("empty", 0, Blake3Digest::hash(b"x"), Vec::new()).is_err()); + assert!(CatalogFileEntry::file("one", 1, Blake3Digest::hash(b"x"), Vec::new()).is_err()); + assert!( + CatalogFileEntry::file( + "one", + 1, + Blake3Digest::hash(b"x"), + vec![Blake3Digest::hash(b"y")] + ) + .is_err() + ); + + let one_chunk = Blake3Digest::from_bytes([1; 32]); + assert!( + CatalogFileEntry::file("one-chunk", CATALOG_CHUNK_SIZE, one_chunk, vec![one_chunk]) + .is_ok() + ); + assert!( + CatalogFileEntry::file( + "two-chunks", + CATALOG_CHUNK_SIZE + 1, + Blake3Digest::from_bytes([2; 32]), + vec![ + Blake3Digest::from_bytes([3; 32]), + Blake3Digest::from_bytes([4; 32]) + ] + ) + .is_ok() + ); + assert!( + CatalogFileEntry::file( + "exact-two-chunks", + 2 * CATALOG_CHUNK_SIZE, + Blake3Digest::from_bytes([5; 32]), + vec![ + Blake3Digest::from_bytes([6; 32]), + Blake3Digest::from_bytes([7; 32]) + ] + ) + .is_ok() + ); + assert!( + CatalogFileEntry::file( + "too-large", + MAX_CATALOG_FILE_BYTES + 1, + Blake3Digest::from_bytes([8; 32]), + Vec::new() + ) + .is_err() + ); + } + + #[test] + fn enforces_directory_metadata_exact_version_sentinel_and_reserved_roots() { + let version = Blake3Digest::hash(b"20240101"); + let mut body = golden_body(); + body.files[4].kind = CatalogEntryKind::Directory; + assert!(body.validate().is_err()); + + let mut body = golden_body(); + body.files.clear(); + assert!(body.validate().is_err()); + + let mut body = golden_body(); + body.files[4].canonical_path = + CanonicalCatalogPath::new("VERSION.INI").expect("path should validate"); + assert!(body.validate().is_err()); + + assert!( + CatalogContentManifestBody::new( + "g", + "20240101", + vec![ + CatalogFileEntry::file("local", 1, version, vec![version]) + .expect("file should validate alone"), + CatalogFileEntry::file("version.ini", 8, version, vec![version]) + .expect("version should validate alone"), + ], + Vec::new() + ) + .is_err() + ); + } +} diff --git a/crates/lanspread-db/src/content_manifest/path.rs b/crates/lanspread-db/src/content_manifest/path.rs new file mode 100644 index 0000000..1d314ff --- /dev/null +++ b/crates/lanspread-db/src/content_manifest/path.rs @@ -0,0 +1,272 @@ +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use unicode_normalization::is_nfc; + +use super::model::{MAX_CATALOG_COMPONENT_BYTES, MAX_CATALOG_PATH_BYTES}; + +/// A canonical, portable path relative to one catalog game root. +#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CanonicalCatalogPath(String); + +impl CanonicalCatalogPath { + /// Validates and constructs a canonical catalog path. + pub fn new(path: impl Into) -> eyre::Result { + let path = path.into(); + validate_path(&path)?; + Ok(Self(path)) + } + + /// Returns the canonical `/`-separated representation. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + pub(crate) fn components(&self) -> impl Iterator { + self.0.split('/') + } + + pub(crate) fn portable_alias(&self) -> String { + self.components() + .map(portable_name_key) + .collect::>() + .join("/") + } + + pub(crate) fn parent_paths(&self) -> impl Iterator { + self.0 + .match_indices('/') + .map(|(separator, _)| &self.0[..separator]) + } +} + +impl fmt::Debug for CanonicalCatalogPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CanonicalCatalogPath") + .field(&self.0) + .finish() + } +} + +impl fmt::Display for CanonicalCatalogPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl AsRef for CanonicalCatalogPath { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl FromStr for CanonicalCatalogPath { + type Err = eyre::Report; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl Serialize for CanonicalCatalogPath { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for CanonicalCatalogPath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} + +pub(crate) fn validate_game_id(game_id: &str) -> eyre::Result<()> { + validate_bounded_text(game_id, 255, "catalog game ID")?; + if game_id.contains(['/', '\\']) { + eyre::bail!("catalog game ID must be one path component: {game_id}"); + } + if !is_nfc(game_id) { + eyre::bail!("catalog game ID must use Unicode NFC normalization: {game_id}"); + } + validate_component(game_id)?; + if is_download_protected_root_name(game_id) { + eyre::bail!("catalog game ID is reserved for application state: {game_id}"); + } + Ok(()) +} + +pub(crate) fn validate_game_version(game_version: &str) -> eyre::Result<()> { + validate_bounded_text(game_version, 255, "catalog game version") +} + +pub(crate) fn validate_bounded_text(value: &str, limit: usize, label: &str) -> eyre::Result<()> { + if value.is_empty() { + eyre::bail!("{label} cannot be empty"); + } + if value.len() > limit { + eyre::bail!("{label} exceeds the {limit}-byte limit"); + } + if value.chars().any(char::is_control) { + eyre::bail!("{label} contains a control character"); + } + Ok(()) +} + +pub(crate) fn is_download_protected_root_name(name: &str) -> bool { + let key = portable_name_key(name); + key == "LOCAL" + || key.starts_with(".LOCAL.") + || key.starts_with(".VERSION.INI.") + || matches!( + key.as_str(), + ".SYNC" + | ".LANSPREAD" + | ".LANSPREAD.JSON" + | ".LANSPREAD.JSON.TMP" + | ".LANSPREAD_OWNED" + | ".SOFTLAN_FIRST_START_DONE" + | ".SOFTLAN_GAME_INSTALLED" + | "INSTALL_INTENT.JSON" + | "INSTALL_INTENT.JSON.TMP" + ) +} + +/// Returns whether an extracted path would overwrite Stream Install's staging marker. +pub(crate) fn is_stream_install_protected_root_name(name: &str) -> bool { + portable_name_key(name) == ".LANSPREAD_OWNED" +} + +fn validate_path(path: &str) -> eyre::Result<()> { + if path.is_empty() || path.starts_with('/') || path.ends_with('/') { + eyre::bail!("catalog path is not canonical: {path:?}"); + } + if path.len() > MAX_CATALOG_PATH_BYTES { + eyre::bail!("catalog path exceeds the {MAX_CATALOG_PATH_BYTES}-byte limit"); + } + if !is_nfc(path) { + eyre::bail!("catalog path must use Unicode NFC normalization: {path}"); + } + if path.contains('\\') { + eyre::bail!("catalog path must use canonical '/' separators: {path}"); + } + for component in path.split('/') { + validate_component(component)?; + } + Ok(()) +} + +fn validate_component(component: &str) -> eyre::Result<()> { + if component.is_empty() || matches!(component, "." | "..") { + eyre::bail!("catalog path contains a non-canonical component: {component:?}"); + } + if component.len() > MAX_CATALOG_COMPONENT_BYTES { + eyre::bail!("catalog path component exceeds the {MAX_CATALOG_COMPONENT_BYTES}-byte limit"); + } + if component.ends_with([' ', '.']) { + eyre::bail!("catalog path component has a trailing dot or space: {component}"); + } + if component.chars().any(|character| { + character <= '\u{1f}' || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*') + }) { + eyre::bail!("catalog path component is not portable: {component}"); + } + + let device_stem = component.split('.').next().unwrap_or_default().trim_end(); + if is_windows_device_name(device_stem) { + eyre::bail!("catalog path uses a Windows device name: {component}"); + } + if looks_like_dos_short_name(component) { + eyre::bail!("catalog path resembles a Windows short-name alias: {component}"); + } + Ok(()) +} + +fn portable_name_key(name: &str) -> String { + name.to_uppercase() +} + +fn is_windows_device_name(stem: &str) -> bool { + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || upper + .strip_prefix("COM") + .or_else(|| upper.strip_prefix("LPT")) + .is_some_and(|number| { + (number.len() == 1 && number.as_bytes()[0].is_ascii_digit()) + || matches!(number, "¹" | "²" | "³") + }) +} + +fn looks_like_dos_short_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or_default(); + stem.rsplit_once('~').is_some_and(|(prefix, suffix)| { + !prefix.is_empty() + && !suffix.is_empty() + && suffix.len() <= 6 + && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_canonical_portable_paths() { + for path in ["version.ini", "bin/a.txt", "Données/été.dat"] { + assert!(CanonicalCatalogPath::new(path).is_ok(), "rejected {path}"); + } + } + + #[test] + fn rejects_noncanonical_or_nonportable_paths() { + for path in [ + "", + "/absolute", + "trailing/", + "two//parts", + "./relative", + "../escape", + "back\\slash", + "trailing. ", + "bad:name", + "NUL.txt", + "com1", + "LPT¹.log", + "LOCAL~1/file", + "Donne\u{301}es/file", + ] { + assert!( + CanonicalCatalogPath::new(path).is_err(), + "accepted {path:?}" + ); + } + } + + #[test] + fn aliases_are_conservative_across_platforms() { + let left = CanonicalCatalogPath::new("Straße/FILE").expect("path should validate"); + let right = CanonicalCatalogPath::new("STRASSE/file").expect("path should validate"); + assert_eq!(left.portable_alias(), right.portable_alias()); + assert!(is_download_protected_root_name(".Å¿ync")); + } + + #[test] + fn extracted_staging_policy_only_reserves_its_ownership_marker() { + for allowed in ["local", "install_intent.json", ".local.installing"] { + assert!(!is_stream_install_protected_root_name(allowed)); + } + for protected in [".lanspread_owned", ".LANSPREAD_OWNED", ".lanÅ¿pread_owned"] { + assert!(is_stream_install_protected_root_name(protected)); + } + } +} diff --git a/crates/lanspread-db/src/content_manifest/store.rs b/crates/lanspread-db/src/content_manifest/store.rs new file mode 100644 index 0000000..24f922b --- /dev/null +++ b/crates/lanspread-db/src/content_manifest/store.rs @@ -0,0 +1,1065 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + ffi::OsStr, + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{ + Arc, + RwLock, + atomic::{AtomicU64, Ordering}, + }, +}; + +use eyre::WrapErr; + +use super::{ + CATALOG_CONTENT_INDEX_NAME, + CatalogContentIdentity, + CatalogContentIndex, + CatalogContentManifest, + MAX_CATALOG_CONTENT_INDEX_BYTES, + MAX_CATALOG_ENTRIES, + MAX_CATALOG_MANIFEST_BYTES, + path::{validate_game_id, validate_game_version}, +}; + +static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Durable marker left visible while a multi-manifest publication is in flight. +/// +/// Runtime authority loaders reject a root containing this marker. The +/// publisher intentionally keeps ordinary store validation usable while the +/// marker exists so it can verify the complete new set before removing the +/// marker as its final commit step. +pub const CATALOG_PUBLICATION_MARKER_NAME: &str = ".lanspread-catalog-publication-in-progress"; + +/// Rejects a manifest root whose publisher transaction did not finish. +/// +/// A missing root is left for the caller's normal coverage validation to +/// diagnose. When the root exists, it must be a regular non-link directory. +/// +/// # Errors +/// +/// Returns an error for an unsafe root, an incomplete-publication marker, or a +/// filesystem inspection failure. +pub fn reject_incomplete_catalog_publication(root: &Path) -> eyre::Result<()> { + match fs::symlink_metadata(root) { + Ok(_) => validate_regular_directory(root)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } + + let marker = root.join(CATALOG_PUBLICATION_MARKER_NAME); + match fs::symlink_metadata(&marker) { + Ok(_) => eyre::bail!( + "catalog manifest publication is incomplete; reconcile and remove {} before continuing", + marker.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +/// On-demand access to the exact manifest set described by `game.db`. +#[derive(Debug)] +pub struct CatalogManifestStore { + source: CatalogManifestSource, + expected_versions: BTreeMap, + content_index: CatalogContentIndex, + cache: RwLock>>, +} + +#[derive(Debug)] +enum CatalogManifestSource { + Disk(PathBuf), + Memory(BTreeMap), +} + +impl CatalogManifestStore { + /// Constructs a store for an exact game-ID/version map. + pub fn new( + root: impl Into, + expected_versions: BTreeMap, + ) -> eyre::Result { + validate_expected_versions(&expected_versions)?; + let root = root.into(); + validate_regular_directory(&root)?; + let index_path = root.join(CATALOG_CONTENT_INDEX_NAME); + let index_bytes = read_bounded_regular_file( + &index_path, + MAX_CATALOG_CONTENT_INDEX_BYTES, + "catalog content index", + )?; + let content_index = CatalogContentIndex::from_json_slice(&index_bytes) + .wrap_err_with(|| format!("invalid catalog content index {}", index_path.display()))?; + content_index.validate_expected_versions(&expected_versions)?; + Ok(Self { + source: CatalogManifestSource::Disk(root), + expected_versions, + content_index, + cache: RwLock::new(HashMap::new()), + }) + } + + /// Constructs an exact in-memory store from already sealed manifests. + /// + /// Every manifest is revalidated and duplicate or platform-alias game IDs + /// are rejected. Unlike the disk-backed store, all manifest bodies are + /// necessarily present at construction time. + pub fn from_manifests( + manifests: impl IntoIterator, + ) -> eyre::Result { + let manifests = manifests.into_iter().collect::>(); + let content_index = CatalogContentIndex::from_manifests(&manifests)?; + let mut in_memory = BTreeMap::new(); + let mut expected_versions = BTreeMap::new(); + for manifest in manifests { + let game_id = manifest.game_id().to_owned(); + let game_version = manifest.game_version().to_owned(); + if in_memory.insert(game_id.clone(), manifest).is_some() { + eyre::bail!("duplicate in-memory catalog manifest for {game_id}"); + } + expected_versions.insert(game_id, game_version); + } + + validate_expected_versions(&expected_versions)?; + Ok(Self { + source: CatalogManifestSource::Memory(in_memory), + expected_versions, + content_index, + cache: RwLock::new(HashMap::new()), + }) + } + + pub(super) const fn expected_versions(&self) -> &BTreeMap { + &self.expected_versions + } + + /// Returns one catalog-owned content identity without filesystem access. + #[must_use] + pub fn content_identity(&self, game_id: &str) -> Option { + self.content_index.content_identity(game_id) + } + + fn validate_expected_versions( + expected_versions: &BTreeMap, + ) -> eyre::Result<()> { + if expected_versions.len() > MAX_CATALOG_ENTRIES { + eyre::bail!("catalog exceeds the {MAX_CATALOG_ENTRIES}-game limit"); + } + let mut portable_ids = HashSet::new(); + for (game_id, version) in expected_versions { + validate_game_id(game_id)?; + validate_game_version(version)?; + if !portable_ids.insert(game_id.to_uppercase()) { + eyre::bail!("catalog contains duplicate or platform-alias game ID: {game_id}"); + } + } + Ok(()) + } + + /// Loads and validates one known manifest on demand. + pub fn load(&self, game_id: &str) -> eyre::Result> { + let expected_version = self + .expected_versions + .get(game_id) + .ok_or_else(|| eyre::eyre!("unknown catalog game ID: {game_id}"))?; + if let Some(manifest) = self + .cache + .read() + .map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))? + .get(game_id) + .cloned() + { + return Ok(manifest); + } + + let manifest = Arc::new(self.load_uncached(game_id, expected_version)?); + let mut cache = self + .cache + .write() + .map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))?; + Ok(cache + .entry(game_id.to_owned()) + .or_insert_with(|| Arc::clone(&manifest)) + .clone()) + } + + /// Returns a previously validated manifest without falling back to disk. + /// + /// # Errors + /// + /// Returns an error when the game ID is unknown, the cache lock is + /// poisoned, or the known manifest has not been loaded yet. + pub fn load_cached(&self, game_id: &str) -> eyre::Result> { + if !self.expected_versions.contains_key(game_id) { + eyre::bail!("unknown catalog game ID: {game_id}"); + } + self.cache + .read() + .map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))? + .get(game_id) + .cloned() + .ok_or_else(|| eyre::eyre!("catalog manifest is not preloaded for {game_id}")) + } + + /// Validates exact catalog coverage and every artifact from disk. + pub fn validate_all(&self) -> eyre::Result<()> { + self.cache + .write() + .map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))? + .clear(); + let discovered = self.discover_coverage()?; + + let mut validated = HashMap::new(); + for (game_id, expected_version) in &self.expected_versions { + let manifest = Arc::new(self.load_uncached(game_id, expected_version)?); + validated.insert(game_id.clone(), manifest); + } + *self + .cache + .write() + .map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))? = validated; + debug_assert_eq!(discovered.len(), self.expected_versions.len()); + Ok(()) + } + + /// Validates the manifest root and exact artifact filename coverage without + /// reading or parsing any manifest body. + pub fn validate_coverage(&self) -> eyre::Result<()> { + self.discover_coverage().map(|_| ()) + } + + fn discover_coverage(&self) -> eyre::Result> { + let CatalogManifestSource::Disk(root) = &self.source else { + let discovered = match &self.source { + CatalogManifestSource::Memory(manifests) => { + manifests.keys().cloned().collect::>() + } + CatalogManifestSource::Disk(_) => unreachable!(), + }; + if discovered.len() != self.expected_versions.len() + || self + .expected_versions + .keys() + .any(|game_id| !discovered.contains(game_id)) + { + eyre::bail!("in-memory catalog manifest coverage is inconsistent"); + } + return Ok(discovered); + }; + + validate_regular_directory(root)?; + let mut discovered = HashSet::new(); + let mut entries = fs::read_dir(root) + .wrap_err_with(|| format!("failed to read manifest directory {}", root.display()))? + .collect::, _>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + if !has_json_extension(&path) { + continue; + } + let file_name = entry + .file_name() + .into_string() + .map_err(|_| eyre::eyre!("manifest filename is not valid UTF-8"))?; + let game_id = file_name.strip_suffix(".json").ok_or_else(|| { + eyre::eyre!("manifest suffix must be lowercase .json: {file_name}") + })?; + if !self.expected_versions.contains_key(game_id) { + eyre::bail!("unexpected catalog manifest artifact: {file_name}"); + } + let metadata = fs::symlink_metadata(&path).wrap_err_with(|| { + format!("failed to inspect catalog manifest {}", path.display()) + })?; + if is_link_or_reparse(&metadata) || !metadata.is_file() { + eyre::bail!( + "catalog manifest is not a regular non-link file: {}", + path.display() + ); + } + if !discovered.insert(game_id.to_owned()) { + eyre::bail!("duplicate catalog manifest artifact: {file_name}"); + } + } + + for game_id in self.expected_versions.keys() { + if !discovered.contains(game_id) { + eyre::bail!("missing catalog manifest artifact for {game_id}"); + } + } + Ok(discovered) + } + + fn load_uncached( + &self, + game_id: &str, + expected_version: &str, + ) -> eyre::Result { + let CatalogManifestSource::Disk(root) = &self.source else { + let CatalogManifestSource::Memory(manifests) = &self.source else { + unreachable!(); + }; + let manifest = manifests + .get(game_id) + .ok_or_else(|| eyre::eyre!("missing in-memory catalog manifest for {game_id}"))? + .clone(); + manifest.validate()?; + if manifest.game_version() != expected_version { + eyre::bail!( + "in-memory manifest version mismatch for {game_id}: expected {expected_version}, found {}", + manifest.game_version() + ); + } + self.content_index.validate_manifest(game_id, &manifest)?; + return Ok(manifest); + }; + + validate_regular_directory(root)?; + // The membership check occurs before this function, so untrusted input + // never becomes a filename. + let path = root.join(format!("{game_id}.json")); + let bytes = + read_bounded_regular_file(&path, MAX_CATALOG_MANIFEST_BYTES, "catalog manifest")?; + let manifest = CatalogContentManifest::from_json_slice(&bytes) + .wrap_err_with(|| format!("invalid catalog manifest {}", path.display()))?; + if manifest.game_id() != game_id { + eyre::bail!( + "manifest filename/body game ID mismatch: expected {game_id}, found {}", + manifest.game_id() + ); + } + if manifest.game_version() != expected_version { + eyre::bail!( + "manifest version mismatch for {game_id}: expected {expected_version}, found {}", + manifest.game_version() + ); + } + self.content_index.validate_manifest(game_id, &manifest)?; + Ok(manifest) + } +} + +fn validate_expected_versions(expected_versions: &BTreeMap) -> eyre::Result<()> { + CatalogManifestStore::validate_expected_versions(expected_versions) +} + +/// Atomically publishes the canonical JSON representation of one manifest. +pub fn write_canonical_manifest_atomic( + path: &Path, + manifest: &CatalogContentManifest, +) -> eyre::Result<()> { + write_catalog_artifact_atomic(path, &manifest.to_canonical_json()?, "manifest") +} + +/// Atomically publishes the canonical compact content-identity index. +pub fn write_canonical_content_index_atomic( + path: &Path, + index: &CatalogContentIndex, +) -> eyre::Result<()> { + write_catalog_artifact_atomic(path, &index.to_canonical_json()?, "content index") +} + +fn write_catalog_artifact_atomic(path: &Path, bytes: &[u8], label: &str) -> eyre::Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + validate_regular_directory(parent)?; + let temp_path = unique_temp_path(path)?; + let mut cleanup = TempFileCleanup::new(temp_path.clone()); + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .wrap_err_with(|| { + format!( + "failed to create temporary catalog {label} {}", + temp_path.display() + ) + })?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temp_path, path).wrap_err_with(|| { + format!( + "failed to atomically publish catalog {label} {} to {}", + temp_path.display(), + path.display() + ) + })?; + cleanup.published = true; + sync_directory(parent)?; + Ok(()) +} + +fn read_bounded_regular_file(path: &Path, max_bytes: u64, label: &str) -> eyre::Result> { + let link_metadata = fs::symlink_metadata(path) + .wrap_err_with(|| format!("failed to inspect {label} {}", path.display()))?; + if is_link_or_reparse(&link_metadata) || !link_metadata.is_file() { + eyre::bail!("{label} is not a regular non-link file: {}", path.display()); + } + if link_metadata.len() > max_bytes { + eyre::bail!("{label} exceeds size limit: {}", path.display()); + } + + let mut file = File::open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() > max_bytes || !same_file(&link_metadata, &metadata) { + eyre::bail!("{label} changed shape while opening: {}", path.display()); + } + let capacity = usize::try_from(metadata.len())?; + let mut bytes = Vec::with_capacity(capacity); + Read::by_ref(&mut file) + .take(max_bytes + 1) + .read_to_end(&mut bytes)?; + if u64::try_from(bytes.len())? > max_bytes { + eyre::bail!("{label} exceeds size limit: {}", path.display()); + } + Ok(bytes) +} + +#[cfg(unix)] +fn same_file(before_open: &fs::Metadata, after_open: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + before_open.dev() == after_open.dev() && before_open.ino() == after_open.ino() +} + +#[cfg(not(unix))] +fn same_file(_before_open: &fs::Metadata, _after_open: &fs::Metadata) -> bool { + true +} + +fn validate_regular_directory(path: &Path) -> eyre::Result<()> { + let metadata = fs::symlink_metadata(path) + .wrap_err_with(|| format!("failed to inspect directory {}", path.display()))?; + if is_link_or_reparse(&metadata) || !metadata.is_dir() { + eyre::bail!( + "catalog manifest root is not a regular non-link directory: {}", + path.display() + ); + } + Ok(()) +} + +fn has_json_extension(path: &Path) -> bool { + path.extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) +} + +fn unique_temp_path(path: &Path) -> eyre::Result { + let file_name = path + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| eyre::eyre!("manifest destination filename is not valid UTF-8"))?; + let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + Ok(path.with_file_name(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + sequence + ))) +} + +struct TempFileCleanup { + path: PathBuf, + published: bool, +} + +impl TempFileCleanup { + fn new(path: PathBuf) -> Self { + Self { + path, + published: false, + } + } +} + +impl Drop for TempFileCleanup { + fn drop(&mut self) { + if !self.published { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> eyre::Result<()> { + File::open(path)?.sync_all()?; + Ok(()) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> eyre::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(any(unix, windows)))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(test)] +mod tests { + use std::{ + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + use crate::content_manifest::{ + Blake3Digest, + CatalogContentIndexEntry, + CatalogContentManifestBody, + CatalogFileEntry, + ContentId, + }; + + static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should follow epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "lanspread-db-manifest-{}-{nanos}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("test directory should be created"); + Self(path) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn manifest(game_id: &str, version: &str) -> CatalogContentManifest { + let version_hash = Blake3Digest::hash(version.as_bytes()); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + game_id, + version, + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("version should fit u64"), + version_hash, + vec![version_hash], + ) + .expect("version file should validate"), + ], + Vec::new(), + ) + .expect("body should validate"), + ) + .expect("manifest should seal") + } + + fn write_index(root: &Path, manifests: &[CatalogContentManifest]) { + let index = CatalogContentIndex::from_manifests(manifests) + .expect("test content index should validate"); + write_canonical_content_index_atomic(&root.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("test content index should publish"); + } + + fn write_synthetic_index(root: &Path, games: &[(&str, &str)]) { + let entries = games + .iter() + .enumerate() + .map( + |(position, (game_id, game_version))| CatalogContentIndexEntry { + game_id: (*game_id).to_owned(), + game_version: (*game_version).to_owned(), + identity: CatalogContentIdentity { + content_id: ContentId::from_bytes( + [u8::try_from(position + 1).expect("test position should fit u8"); 32], + ), + supports_streamed_install: false, + }, + }, + ); + let index = CatalogContentIndex::from_entries(entries) + .expect("synthetic test index should validate"); + write_canonical_content_index_atomic(&root.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("synthetic test index should publish"); + } + + #[test] + fn store_loads_known_exact_manifest_and_validates_coverage() { + let root = TestDir::new(); + let manifest = manifest("g", "20240101"); + write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest) + .expect("manifest should publish"); + write_index(&root.0, std::slice::from_ref(&manifest)); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + store.validate_all().expect("coverage should validate"); + assert_eq!( + store.load("g").expect("manifest should load").content_id(), + manifest.content_id() + ); + assert!(store.load("../g").is_err()); + } + + #[test] + fn content_identity_is_eager_while_manifest_body_remains_lazy() { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + fs::write(root.0.join("g.json"), b"not JSON\n").expect("malformed body should write"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("canonical index should construct the store"); + + assert_eq!( + store.content_identity("g"), + Some(CatalogContentIdentity { + content_id: ContentId::from_bytes([1; 32]), + supports_streamed_install: false, + }) + ); + assert_eq!(store.content_identity("unknown"), None); + assert!(store.load_cached("g").is_err()); + assert!(store.load("g").is_err()); + assert!(store.load_cached("g").is_err()); + } + + #[test] + fn lazy_load_recomputes_against_the_index_captured_at_construction() { + let root = TestDir::new(); + let manifest = manifest("g", "20240101"); + write_synthetic_index(&root.0, &[("g", "20240101")]); + write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest) + .expect("manifest should publish"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("synthetic index should construct the store"); + + write_index(&root.0, std::slice::from_ref(&manifest)); + assert!( + store.load("g").is_err(), + "replacing the disk index must not replace captured authority" + ); + assert!(store.validate_all().is_err()); + } + + #[test] + fn store_requires_a_canonical_exact_index_at_construction() { + let root = TestDir::new(); + let expected = BTreeMap::from([("g".to_owned(), "20240101".to_owned())]); + assert!(CatalogManifestStore::new(&root.0, expected.clone()).is_err()); + + fs::write(root.0.join(CATALOG_CONTENT_INDEX_NAME), b"not JSON\n") + .expect("malformed index should write"); + assert!(CatalogManifestStore::new(&root.0, expected.clone()).is_err()); + + write_synthetic_index(&root.0, &[("g", "20240101")]); + OpenOptions::new() + .append(true) + .open(root.0.join(CATALOG_CONTENT_INDEX_NAME)) + .and_then(|mut file| file.write_all(b" ")) + .expect("index should become noncanonical"); + assert!(CatalogManifestStore::new(&root.0, expected.clone()).is_err()); + + let oversized = File::create(root.0.join(CATALOG_CONTENT_INDEX_NAME)) + .expect("oversized index should be created"); + oversized + .set_len(MAX_CATALOG_CONTENT_INDEX_BYTES + 1) + .expect("sparse index should resize"); + assert!(CatalogManifestStore::new(&root.0, expected.clone()).is_err()); + drop(oversized); + + write_synthetic_index(&root.0, &[("g", "20250101")]); + assert!(CatalogManifestStore::new(&root.0, expected).is_err()); + } + + #[test] + fn cached_load_is_fail_closed_until_a_known_manifest_is_preloaded() { + let store = CatalogManifestStore::from_manifests([manifest("g", "20240101")]) + .expect("in-memory store should construct"); + assert!(store.load_cached("unknown").is_err()); + assert!(store.load_cached("g").is_err()); + + let loaded = store.load("g").expect("known manifest should preload"); + let cached = store + .load_cached("g") + .expect("preloaded manifest should be cache-readable"); + assert!(Arc::ptr_eq(&loaded, &cached)); + } + + #[test] + fn store_rejects_platform_alias_catalog_ids_before_path_lookup() { + assert!( + CatalogManifestStore::new( + ".", + BTreeMap::from([ + ("Game".to_owned(), "20240101".to_owned()), + ("game".to_owned(), "20240101".to_owned()), + ]) + ) + .is_err() + ); + } + + #[test] + fn validate_all_rejects_missing_and_unexpected_json() { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + assert!(store.validate_all().is_err()); + + write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest("g", "20240101")) + .expect("manifest should publish"); + fs::write(root.0.join("other.json"), b"{}\n").expect("unexpected file should write"); + assert!(store.validate_all().is_err()); + } + + #[test] + fn validate_coverage_checks_shape_without_parsing_manifest_bodies() { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + fs::write(root.0.join("g.json"), b"not JSON\n") + .expect("opaque manifest artifact should write"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + + store + .validate_coverage() + .expect("coverage validation must not parse manifest bodies"); + assert!(store.load("g").is_err()); + } + + #[test] + fn validate_coverage_rejects_non_file_json_artifact() { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + fs::create_dir(root.0.join("g.json")).expect("artifact directory should be created"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + + assert!(store.validate_coverage().is_err()); + } + + #[test] + fn validate_coverage_rejects_filename_case_aliases() { + for alias in ["G.json", "g.JSON"] { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + fs::write(root.0.join(alias), b"opaque\n").expect("alias artifact should write"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + + assert!(store.validate_coverage().is_err(), "accepted alias {alias}"); + } + } + + #[test] + fn validate_coverage_allows_unrelated_non_json_packaging_files() { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + fs::write(root.0.join("g.json"), b"opaque\n").expect("manifest artifact should write"); + fs::write(root.0.join("README.txt"), b"packaging metadata\n") + .expect("packaging metadata should write"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + + store + .validate_coverage() + .expect("unrelated non-JSON packaging files should be ignored"); + } + + #[test] + fn store_rejects_body_game_id_mismatch() { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest("other", "20240101")) + .expect("manifest should publish"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + assert!(store.load("g").is_err()); + } + + #[test] + fn store_rejects_expected_version_mismatch() { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20250101")]); + write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest("g", "20240101")) + .expect("manifest should publish"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20250101".to_owned())]), + ) + .expect("store should construct"); + assert!(store.load("g").is_err()); + } + + #[test] + fn store_rejects_oversized_artifact_before_json_parsing() { + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + let file = File::create(root.0.join("g.json")).expect("artifact should be created"); + file.set_len(MAX_CATALOG_MANIFEST_BYTES + 1) + .expect("sparse artifact should resize"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + assert!(store.load("g").is_err()); + } + + #[test] + fn validate_all_rechecks_disk_and_drops_a_stale_cache_on_failure() { + let root = TestDir::new(); + let artifact = root.0.join("g.json"); + let manifest = manifest("g", "20240101"); + write_canonical_manifest_atomic(&artifact, &manifest).expect("manifest should publish"); + write_index(&root.0, std::slice::from_ref(&manifest)); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + store.load("g").expect("manifest should populate cache"); + fs::write(&artifact, b"not JSON\n").expect("artifact should be corrupted"); + assert!(store.validate_all().is_err()); + assert!(store.load("g").is_err()); + } + + #[test] + fn atomic_writer_emits_exact_canonical_bytes() { + let root = TestDir::new(); + let artifact = root.0.join("g.json"); + let manifest = manifest("g", "20240101"); + write_canonical_manifest_atomic(&artifact, &manifest).expect("manifest should publish"); + assert_eq!( + fs::read(&artifact).expect("artifact should read"), + manifest + .to_canonical_json() + .expect("manifest should encode") + ); + assert_eq!( + fs::read_dir(&root.0) + .expect("directory should read") + .count(), + 1 + ); + } + + #[test] + fn atomic_writer_replaces_an_existing_manifest() { + let root = TestDir::new(); + let artifact = root.0.join("g.json"); + let initial = manifest("g", "20240101"); + let replacement = manifest("g", "20250101"); + + write_canonical_manifest_atomic(&artifact, &initial) + .expect("initial manifest should publish"); + write_canonical_manifest_atomic(&artifact, &replacement) + .expect("replacement manifest should publish"); + + assert_eq!( + fs::read(&artifact).expect("replacement artifact should read"), + replacement + .to_canonical_json() + .expect("replacement manifest should encode") + ); + assert_eq!( + fs::read_dir(&root.0) + .expect("directory should read") + .count(), + 1 + ); + } + + #[cfg(unix)] + #[test] + fn store_rejects_symlink_manifest_artifact() { + use std::os::unix::fs::symlink; + + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + let target = root.0.join("target"); + fs::write( + &target, + manifest("g", "20240101") + .to_canonical_json() + .expect("JSON should encode"), + ) + .expect("target should write"); + symlink(&target, root.0.join("g.json")).expect("symlink should be created"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + assert!(store.load("g").is_err()); + } + + #[cfg(unix)] + #[test] + fn store_rejects_symlink_content_index_artifact() { + use std::os::unix::fs::symlink; + + let root = TestDir::new(); + let target = root.0.join("index-target"); + let index = CatalogContentIndex::from_entries([CatalogContentIndexEntry { + game_id: "g".to_owned(), + game_version: "20240101".to_owned(), + identity: CatalogContentIdentity { + content_id: ContentId::from_bytes([1; 32]), + supports_streamed_install: false, + }, + }]) + .expect("test content index should validate"); + write_canonical_content_index_atomic(&target, &index).expect("index target should publish"); + symlink(&target, root.0.join(CATALOG_CONTENT_INDEX_NAME)) + .expect("index symlink should be created"); + + assert!( + CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn store_rejects_symlink_manifest_root_during_index_loading() { + use std::os::unix::fs::symlink; + + let parent = TestDir::new(); + let real_root = parent.0.join("real"); + fs::create_dir(&real_root).expect("real root should be created"); + let manifest = manifest("g", "20240101"); + write_canonical_manifest_atomic(&real_root.join("g.json"), &manifest) + .expect("manifest should publish"); + write_index(&real_root, std::slice::from_ref(&manifest)); + let linked_root = parent.0.join("linked"); + symlink(&real_root, &linked_root).expect("root symlink should be created"); + assert!( + CatalogManifestStore::new( + &linked_root, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn validate_coverage_rejects_symlink_manifest_artifact() { + use std::os::unix::fs::symlink; + + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + let target = root.0.join("target"); + fs::write(&target, b"opaque\n").expect("target should write"); + symlink(&target, root.0.join("g.json")).expect("symlink should be created"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + + assert!(store.validate_coverage().is_err()); + } + + #[cfg(unix)] + #[test] + fn load_rejects_manifest_root_replaced_with_symlink_after_coverage_check() { + use std::os::unix::fs::symlink; + + let parent = TestDir::new(); + let root = parent.0.join("manifests"); + fs::create_dir(&root).expect("manifest root should be created"); + let manifest = manifest("g", "20240101"); + write_canonical_manifest_atomic(&root.join("g.json"), &manifest) + .expect("manifest should publish"); + write_index(&root, std::slice::from_ref(&manifest)); + let store = CatalogManifestStore::new( + &root, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + store + .validate_coverage() + .expect("initial coverage should validate"); + + let moved = parent.0.join("moved"); + fs::rename(&root, &moved).expect("root should move"); + symlink(&moved, &root).expect("replacement symlink should be created"); + + assert!(store.load("g").is_err()); + } + + #[cfg(unix)] + #[test] + fn validate_coverage_rejects_special_manifest_artifact() { + use std::os::unix::net::UnixListener; + + let root = TestDir::new(); + write_synthetic_index(&root.0, &[("g", "20240101")]); + let _socket = UnixListener::bind(root.0.join("g.json")) + .expect("Unix socket artifact should be created"); + let store = CatalogManifestStore::new( + &root.0, + BTreeMap::from([("g".to_owned(), "20240101".to_owned())]), + ) + .expect("store should construct"); + + assert!(store.validate_coverage().is_err()); + } +} diff --git a/crates/lanspread-db/src/db.rs b/crates/lanspread-db/src/db.rs index 32335fe..be78bbf 100644 --- a/crates/lanspread-db/src/db.rs +++ b/crates/lanspread-db/src/db.rs @@ -252,46 +252,11 @@ impl GameCatalog { } } -#[derive(Clone, Serialize, Deserialize)] -pub struct GameFileDescription { - pub game_id: String, - pub relative_path: String, - pub is_dir: bool, - pub size: u64, -} - -impl GameFileDescription { - #[must_use] - pub fn is_version_ini(&self) -> bool { - let expected = format!("{}/version.ini", self.game_id); - self.relative_path.replace('\\', "/") == expected - } - - #[must_use] - pub fn file_size(&self) -> u64 { - if self.is_dir { 0 } else { self.size } - } -} - -impl fmt::Debug for GameFileDescription { - #[allow(clippy::cast_precision_loss)] - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}: [{}] path:{} size:{}", - self.game_id, - if self.is_dir { 'D' } else { 'F' }, - self.relative_path, - self.size, - ) - } -} - #[cfg(test)] mod tests { use serde_json::json; - use super::{Availability, Game, GameFileDescription}; + use super::{Availability, Game}; fn game_fixture() -> Game { Game { @@ -364,42 +329,4 @@ mod tests { game.downloaded = true; assert_eq!(game.normalized_availability(), Availability::Ready); } - - #[test] - fn version_ini_predicate_matches_only_game_root_sentinel() { - let root = GameFileDescription { - game_id: "aoe2".to_string(), - relative_path: "aoe2/version.ini".to_string(), - is_dir: false, - size: 8, - }; - assert!(root.is_version_ini()); - - let nested = GameFileDescription { - game_id: "aoe2".to_string(), - relative_path: "aoe2/local/version.ini".to_string(), - is_dir: false, - size: 8, - }; - assert!(!nested.is_version_ini()); - - let other_game = GameFileDescription { - game_id: "aoe2".to_string(), - relative_path: "other/version.ini".to_string(), - is_dir: false, - size: 8, - }; - assert!(!other_game.is_version_ini()); - } - - #[test] - fn version_ini_predicate_accepts_windows_separators() { - let root = GameFileDescription { - game_id: "aoe2".to_string(), - relative_path: r"aoe2\version.ini".to_string(), - is_dir: false, - size: 8, - }; - assert!(root.is_version_ini()); - } } diff --git a/crates/lanspread-db/src/lib.rs b/crates/lanspread-db/src/lib.rs index dec1023..80b5014 100644 --- a/crates/lanspread-db/src/lib.rs +++ b/crates/lanspread-db/src/lib.rs @@ -1 +1,2 @@ +pub mod content_manifest; pub mod db; diff --git a/crates/lanspread-mdns/Cargo.toml b/crates/lanspread-mdns/Cargo.toml index fd0f41a..5f29ca1 100644 --- a/crates/lanspread-mdns/Cargo.toml +++ b/crates/lanspread-mdns/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" [lib] doctest = false -test = false [dependencies] eyre = { workspace = true } diff --git a/crates/lanspread-mdns/src/lib.rs b/crates/lanspread-mdns/src/lib.rs index af398d2..b603bf9 100644 --- a/crates/lanspread-mdns/src/lib.rs +++ b/crates/lanspread-mdns/src/lib.rs @@ -3,18 +3,74 @@ use std::{ collections::HashMap, net::SocketAddr, + thread, time::{Duration, Instant}, }; -use eyre::bail; +use eyre::{WrapErr as _, bail}; pub use mdns_sd::DaemonEvent; -use mdns_sd::{Receiver, ResolvedService, ServiceDaemon, ServiceEvent, ServiceInfo}; +use mdns_sd::{ + DaemonStatus, + Error as MdnsError, + Receiver, + ResolvedService, + ServiceDaemon, + ServiceEvent, + ServiceInfo, + UnregisterStatus, +}; pub const LANSPREAD_SERVICE_TYPE: &str = "_lanspread._udp.local."; pub type MdnsMonitor = Receiver; +const DAEMON_COMMAND_RETRY_DELAY: Duration = Duration::from_millis(1); + +#[derive(Debug, PartialEq, Eq)] +enum UnregisterOutcome { + Removed, + AlreadyAbsent, +} + +struct DaemonOwner { + daemon: Option, +} + +impl DaemonOwner { + fn new(daemon: ServiceDaemon) -> Self { + Self { + daemon: Some(daemon), + } + } + + fn daemon(&self) -> &ServiceDaemon { + self.daemon + .as_ref() + .expect("mDNS daemon is available until its owner is closed") + } + + fn is_closed(&self) -> bool { + self.daemon.is_none() + } + + fn close(&mut self) -> eyre::Result<()> { + let Some(daemon) = self.daemon.as_ref() else { + return Ok(()); + }; + shutdown_and_wait(daemon)?; + self.daemon.take(); + Ok(()) + } +} + +impl Drop for DaemonOwner { + fn drop(&mut self) { + if let Err(err) = self.close() { + log::error!("Failed to stop mDNS daemon during cleanup: {err:#}"); + } + } +} pub struct MdnsAdvertiser { - daemon: ServiceDaemon, + daemon: DaemonOwner, service_info: ServiceInfo, pub monitor: Receiver, } @@ -26,8 +82,19 @@ impl MdnsAdvertiser { address: SocketAddr, properties: Option>, ) -> eyre::Result { - let host_name = format!("{}.local.", address.ip()); let daemon = ServiceDaemon::new()?; + Self::new_with_daemon(daemon, service_type, instance_name, address, properties) + } + + fn new_with_daemon( + daemon: ServiceDaemon, + service_type: &str, + instance_name: &str, + address: SocketAddr, + properties: Option>, + ) -> eyre::Result { + let daemon = DaemonOwner::new(daemon); + let host_name = format!("{}.local.", address.ip()); let service_info = ServiceInfo::new( service_type, instance_name, @@ -37,10 +104,10 @@ impl MdnsAdvertiser { properties, )?; - let monitor = daemon.monitor()?; + let monitor = daemon.daemon().monitor()?; // Register the service - daemon.register(service_info.clone())?; + daemon.daemon().register(service_info.clone())?; Ok(Self { daemon, @@ -48,17 +115,33 @@ impl MdnsAdvertiser { monitor, }) } + + /// Unregisters the service and waits for the daemon's shutdown acknowledgement. + pub fn close(mut self) -> eyre::Result<()> { + self.close_inner() + } + + fn close_inner(&mut self) -> eyre::Result<()> { + if self.daemon.is_closed() { + return Ok(()); + } + let unregister_result = + unregister_and_wait(self.daemon.daemon(), self.service_info.get_fullname()).map(|_| ()); + let shutdown_result = self.daemon.close(); + combine_cleanup_results(unregister_result, shutdown_result) + } } impl Drop for MdnsAdvertiser { fn drop(&mut self) { - let _ = self.daemon.unregister(self.service_info.get_fullname()); - let _ = self.daemon.shutdown(); + if let Err(err) = self.close_inner() { + log::error!("Failed to close mDNS advertiser during cleanup: {err:#}"); + } } } pub struct MdnsBrowser { - daemon: ServiceDaemon, + daemon: DaemonOwner, receiver: Receiver, service_type: String, } @@ -81,7 +164,12 @@ pub enum MdnsServicePoll { impl MdnsBrowser { pub fn new(service_type: &str) -> eyre::Result { let daemon = ServiceDaemon::new()?; - let receiver = daemon.browse(service_type)?; + Self::new_with_daemon(daemon, service_type) + } + + fn new_with_daemon(daemon: ServiceDaemon, service_type: &str) -> eyre::Result { + let daemon = DaemonOwner::new(daemon); + let receiver = daemon.daemon().browse(service_type)?; Ok(Self { daemon, receiver, @@ -89,6 +177,11 @@ impl MdnsBrowser { }) } + /// Stops browsing and waits for the daemon's shutdown acknowledgement. + pub fn close(mut self) -> eyre::Result<()> { + self.close_inner() + } + pub fn next_service( &self, ignore_addr: Option, @@ -207,11 +300,99 @@ impl MdnsBrowser { log::error!("No address found in mDNS response: {info:?}"); None } + + fn close_inner(&mut self) -> eyre::Result<()> { + self.daemon.close() + } } impl Drop for MdnsBrowser { fn drop(&mut self) { - let _ = self.daemon.shutdown(); + if let Err(err) = self.close_inner() { + log::error!("Failed to close mDNS browser during cleanup: {err:#}"); + } + } +} + +fn unregister_and_wait(daemon: &ServiceDaemon, fullname: &str) -> eyre::Result { + let receiver = loop { + match daemon.unregister(fullname) { + Ok(receiver) => break receiver, + Err(MdnsError::Again) => thread::sleep(DAEMON_COMMAND_RETRY_DELAY), + Err(err) => return Err(err).wrap_err("failed to request mDNS service unregister"), + } + }; + + match receiver + .recv() + .wrap_err("mDNS unregister response channel closed")? + { + UnregisterStatus::OK => Ok(UnregisterOutcome::Removed), + // Cleanup is idempotent: the desired postcondition already holds if + // the daemon no longer has this service in its registration table. + UnregisterStatus::NotFound => Ok(UnregisterOutcome::AlreadyAbsent), + } +} + +fn shutdown_and_wait(daemon: &ServiceDaemon) -> eyre::Result<()> { + let receiver = loop { + match daemon.shutdown() { + Ok(receiver) => break receiver, + Err(MdnsError::Again) => thread::sleep(DAEMON_COMMAND_RETRY_DELAY), + Err(MdnsError::DaemonShutdown) => return Ok(()), + // `mdns-sd` enqueues the command before waking its daemon socket. A + // wake failure can therefore return an error even though shutdown + // is already queued. Retry until the daemon confirms it stopped. + Err(err) => { + log::warn!("Failed to signal mDNS daemon shutdown; retrying: {err}"); + thread::sleep(DAEMON_COMMAND_RETRY_DELAY); + } + } + }; + + match receiver.recv() { + Ok(DaemonStatus::Shutdown) => Ok(()), + Ok(status) => bail!("mDNS daemon returned unexpected shutdown status: {status:?}"), + Err(_) => wait_for_reported_shutdown(daemon), + } +} + +fn wait_for_reported_shutdown(daemon: &ServiceDaemon) -> eyre::Result<()> { + loop { + let receiver = match daemon.status() { + Ok(receiver) => receiver, + Err(MdnsError::Again) => { + thread::sleep(DAEMON_COMMAND_RETRY_DELAY); + continue; + } + Err(MdnsError::DaemonShutdown) => return Ok(()), + Err(err) => { + log::warn!("Failed to query mDNS daemon shutdown status; retrying: {err}"); + thread::sleep(DAEMON_COMMAND_RETRY_DELAY); + continue; + } + }; + + match receiver.recv() { + Ok(DaemonStatus::Shutdown) => return Ok(()), + Ok(DaemonStatus::Running) | Err(_) => { + thread::sleep(DAEMON_COMMAND_RETRY_DELAY); + } + Ok(status) => bail!("mDNS daemon returned unexpected status: {status:?}"), + } + } +} + +fn combine_cleanup_results( + first: eyre::Result<()>, + shutdown: eyre::Result<()>, +) -> eyre::Result<()> { + match (first, shutdown) { + (Ok(()), Ok(())) => Ok(()), + (Err(err), Ok(())) | (Ok(()), Err(err)) => Err(err), + (Err(first_err), Err(shutdown_err)) => bail!( + "mDNS cleanup failed: {first_err:#}; daemon shutdown also failed: {shutdown_err:#}" + ), } } @@ -221,8 +402,135 @@ pub fn discover_service( ) -> eyre::Result { // Currently unused; kept for potential one-off discovery callers that just need a single address. let browser = MdnsBrowser::new(service_type)?; - match browser.next_address(ignore_addr)? { - Some(addr) => Ok(addr), - None => bail!("No server found."), + let result = browser + .next_address(ignore_addr) + .and_then(|address| address.ok_or_else(|| eyre::eyre!("No server found."))); + let shutdown_result = browser.close(); + match (result, shutdown_result) { + (Ok(address), Ok(())) => Ok(address), + (Err(err), Ok(())) | (Ok(_), Err(err)) => Err(err), + (Err(discovery_err), Err(shutdown_err)) => bail!( + "mDNS discovery failed: {discovery_err:#}; daemon shutdown also failed: {shutdown_err:#}" + ), + } +} + +#[cfg(test)] +mod tests { + use std::{net::SocketAddr, time::Duration}; + + use mdns_sd::{DaemonStatus, ServiceDaemon}; + + use super::{ + MdnsAdvertiser, + MdnsBrowser, + UnregisterOutcome, + shutdown_and_wait, + unregister_and_wait, + }; + + // mdns-sd rejects service names longer than 15 bytes asynchronously. Keep + // the lifecycle fixture valid so it exercises a real registration. + const TEST_SERVICE_TYPE: &str = "_ls-lifecycle._udp.local."; + + #[test] + fn advertiser_close_waits_for_unregister_and_daemon_shutdown() { + let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start"); + let probe = daemon.clone(); + let advertiser = MdnsAdvertiser::new_with_daemon( + daemon, + TEST_SERVICE_TYPE, + "advertiser-close", + SocketAddr::from(([127, 0, 0, 1], 41_234)), + None, + ) + .expect("test advertiser should register"); + + advertiser.close().expect("advertiser should close cleanly"); + + assert_daemon_stopped(&probe); + } + + #[test] + fn advertiser_drop_waits_for_unregister_and_daemon_shutdown() { + let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start"); + let probe = daemon.clone(); + let advertiser = MdnsAdvertiser::new_with_daemon( + daemon, + TEST_SERVICE_TYPE, + "advertiser-drop", + SocketAddr::from(([127, 0, 0, 1], 41_235)), + None, + ) + .expect("test advertiser should register"); + + drop(advertiser); + + assert_daemon_stopped(&probe); + } + + #[test] + fn lifecycle_fixture_reaches_the_daemon_registration_table() { + let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start"); + let advertiser = MdnsAdvertiser::new_with_daemon( + daemon, + TEST_SERVICE_TYPE, + "registered-fixture", + SocketAddr::from(([127, 0, 0, 1], 41_236)), + None, + ) + .expect("test advertiser should register"); + + let outcome = unregister_and_wait( + advertiser.daemon.daemon(), + advertiser.service_info.get_fullname(), + ) + .expect("test registration should be removable"); + assert_eq!(outcome, UnregisterOutcome::Removed); + + advertiser.close().expect("advertiser should close cleanly"); + } + + #[test] + fn unregister_is_idempotent_when_service_is_already_absent() { + let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start"); + + let outcome = unregister_and_wait(&daemon, "absent._ls-lifecycle._udp.local.") + .expect("an already-absent service satisfies the cleanup postcondition"); + assert_eq!(outcome, UnregisterOutcome::AlreadyAbsent); + shutdown_and_wait(&daemon).expect("test daemon should stop"); + } + + #[test] + fn browser_close_waits_for_daemon_shutdown() { + let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start"); + let probe = daemon.clone(); + let browser = MdnsBrowser::new_with_daemon(daemon, TEST_SERVICE_TYPE) + .expect("test browser should start"); + + browser.close().expect("browser should close cleanly"); + + assert_daemon_stopped(&probe); + } + + #[test] + fn browser_drop_waits_for_daemon_shutdown() { + let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start"); + let probe = daemon.clone(); + let browser = MdnsBrowser::new_with_daemon(daemon, TEST_SERVICE_TYPE) + .expect("test browser should start"); + + drop(browser); + + assert_daemon_stopped(&probe); + } + + fn assert_daemon_stopped(daemon: &ServiceDaemon) { + let status = daemon + .status() + .expect("daemon status should remain observable") + .recv_timeout(Duration::from_secs(1)) + .expect("daemon status should arrive"); + assert_eq!(status, DaemonStatus::Shutdown); } } diff --git a/crates/lanspread-peer-cli/Cargo.toml b/crates/lanspread-peer-cli/Cargo.toml index 7c18e7a..c7e61ed 100644 --- a/crates/lanspread-peer-cli/Cargo.toml +++ b/crates/lanspread-peer-cli/Cargo.toml @@ -19,6 +19,13 @@ eyre = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +tokio-util = { workspace = true } + +[target."cfg(unix)".dependencies] +rustix = { workspace = true, features = ["fs"] } + +[target."cfg(unix)".dev-dependencies] +rustix = { workspace = true, features = ["process"] } [lints.clippy] needless_pass_by_value = "allow" diff --git a/crates/lanspread-peer-cli/Dockerfile b/crates/lanspread-peer-cli/Dockerfile index ad636f3..86dd695 100644 --- a/crates/lanspread-peer-cli/Dockerfile +++ b/crates/lanspread-peer-cli/Dockerfile @@ -11,9 +11,10 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY --from=build /work/target/release/lanspread-peer-cli /usr/local/bin/lanspread-peer-cli -COPY crates/lanspread-tauri-deno-ts/src-tauri/game.db /app/game.db +COPY crates/lanspread-peer-cli/catalogs/default/game.db /app/game.db +COPY crates/lanspread-peer-cli/catalogs/default/manifests /app/manifests COPY crates/lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu /usr/local/bin/unrar RUN chmod +x /usr/local/bin/unrar ENTRYPOINT ["lanspread-peer-cli"] -CMD ["--games-dir", "/games", "--state-dir", "/state", "--catalog-db", "/app/game.db"] +CMD ["--games-dir", "/games", "--state-dir", "/state", "--catalog-db", "/app/game.db", "--manifests-dir", "/app/manifests"] diff --git a/crates/lanspread-peer-cli/catalogs/default/game.db b/crates/lanspread-peer-cli/catalogs/default/game.db new file mode 100644 index 0000000000000000000000000000000000000000..ac2607d3b3e5896b003e2376c27e07803488eb36 GIT binary patch literal 25600 zcmWFz^vNtqRY=P(%1ta$FlJz3U}R))P*7lCU`l0RV31^h1I8^33=AxAE&~H25*r7T zDV0I5_ctp@7NV1lF`UsK#2u!XjfD;Dvqk}Cc5!)m#>V`T#H5_ml+5Df#G;hc6gY#$ zImp#9#8n~0(aFbEK?yFdq@cme#hH+jniHRylECTd7vk#f8l(dfOHa&A1q+6_MuZ?l zvQsNj)CB~2`Z@+hDtNm_YQW6V>A8s&1v!b8sYS(zu!Gr=n^;_u zS`=TDT8Ju_npXq~e{}Dcr4|)u<|C{|jRA0wX>w^c3NwPkzljwR{vhMhK?D<8_=Cit z;SXYBgg96bB?Q1CD1HZvAo&`_Nnl$+#%gkD@-i?mFf)5IFz;b*V)h27-BDsR1V%%E z^bjy*W@XUT)eXrn&~?c#&r=A_$j>iHEyBq!hR9nmu`(!|n!2Xv>q2$7Wfm2e=oX|F z73afEK@o%MbYf&>Ff_DxP0lE(O3f_M4K684EJ;nzObtp+%+W2$%uQ7&h6q)Hr4Fx5 z&rDTF%~L2z%`GTaC_20{H3w8TvoSvc`BsSeH}f~Vet}cCk*`mP ztCz94c}Pf@hg-N;gqdlSlbgT0iNBw}hiSNxfuT8y4uz1^#9RllG%#>UELO-%ElpLx zZG%E;QF&@+NorAvLP}z>LTOQPLDAtodFeTs$r&j66cS5|6;e`j6}%ENiWH0t3=I@Y za}^SEa#D+R6cS6*z}ho2iWJgQ%M*)AQcDyv5|c`-6hiY#Qj3a94(}5wMa+7H?g2tN5MBgrC3KHH#MV3Av`fHEj3S}G%p3h2dgZ} zOifEw49Q4U$V)B9>#2&8)V!2pg_4X^h5Vw-^vt}(9Eh8dLP8;_QX#c0wWzWrBQq~u zp)$Wzy)0EBDK#}up*%6O1SFQ0Uu30FTv||&UsM8W^(vGWD3s(YSSUc713C)1V29^I z9G+j4s!*PqSdgDrtdIzDIYq5=%hdPOY+1$SN%^ELB%HygMMZ$zM%scWQLl!>cx zppUUrsA;HiSfHDuhq-ZpJ|hc*qJK_)PH|#hN>YA>lcTSRbCh$4m!oN@XOwZck(rB8 zXmC`3bEvy_q??bc3#k5QX8s8Bn*MIB5Da2Ch5Ch<8XHGBM?{1~n0c8PnL7GjC`sLivF^~toBQp|rb5M2^RTN=LpM4lvlu*@ zhwNnJkrk+iK!ZJ_U3%GcuJjFqMvG>(LOPQwTJ3FbRn= z$oh-wvNr}cS~jLKa&XF~r|0Lp7o}Dzxaa3PIR%HCnVa~ydODdz`Z)U<`|N@`hZPJTgZ3TQAA zG_9Um1UFTo3}R}rqJl?edPYvALQ!gRer|4RUP@|;VrX8fLL$N#h~bbid#HC5QVy>K zn_h5uWvW6!Vv$0MdSYHOXw+Gu?C{Ex!z+st^FU*|a2pjBJQ7PwQgc)DN}zTn=A|57 zsmNkvU}RumZe$3~{|xgPK!{GUPl`2dam+%J47&cJ((KKt&DAaDpmFx3G($@hBd5fY zlAP4E%+#C|1u);+EZ9BFDKNq*G}7EF!Z*m(%r(s4Gs-y9*x27cEWp*sz`)4WQNhL2 z+0{Yb7!1(O&{4=PEviy*aswHt07^p&PHrYfItrP^B?_sTc?!meBc4lf;$uHp>PI5^C|MR4CLBq}6By{Z5n z?oKPp&sD(cSJ1F{QYvVCyBIW*4jQ0O15IElKqs*Bixf&g6ClZnISSB;D+TZjG?KSH zOB6C8WBX98iA5y}`Dx(Meq?tSrxsbfR7WbLsOJ}@WacH7Kw3h|fUPY)+y=K~ZXM=HV4ZsnDRvOI64(1<93U7J&=}2TNgT zDsn*NC={zFrh}tV0o4ewB_Q27i3-JsR~BWaDj)|9$R!FnnW?1;1*v%{iFqX;?U2w& zRd6p&EhJD?&CR3l+o7LwFq3IL8q&AV1-mMa)Q)# zD@x2u&d4lIEe4lVpfW5avnVyYBvrvLF*zeGKPNr42wa5Xa+DqyE*S+QPUR5!4%{xuA-oSWf{_4TGGKnh5ehX0bwk z9=IMVOD#%I1to*jijvH{$mgp&X5zg0C?!9$ zM4=cKv#CX(*i=YPEh&LntEb?jo|#utl$l5%#^ zr4$~;NOi1FqCyC`It2N!xU>i~D_@YGnO6d;TvIZO3lfVG53egJ0xy**O3VbU!bwR5 z8Jk>`pIKZC4pLZ@BUzyX%8OvnfD8sj_u-WwBTI@B%Mvqla#D*x&HH4Kb)Yye0C_*L zsOa!2P*TiE1U1~?5f8Bs6z_*ul%(n@IHy8ubsdGw9EC~+uxbTRlouz0q8yS83QJRq zK`R6x;RcWX6i66?oR^}WSXxq)s>cFaW@2DuU;?WDnHe$}7_%6zGk_)sKm`IVaECs- zkSM5XQ)F-DZOLy{2essr^HWTn6LWGvN!F#bq|yr7a7!!!Wl5*V&?s{=S95=pKzCFB z&~VoXZxio8^#>C7WP#5wh8 z)IHM0!`nC@z&*&{(;rUZH7*slx<|=?QP;q`vjzTV|6%4A7av=?j)V!4R!+UZ{GSk6JdQwtL z6v|VJQd0Bu6kJk^6g(31QgTZ3(u>Pdi*i8AmtcJ($O5NSNDZK1XbN8Ml&X-In4AH( z9+cdQk;({=(_D*EKnv(nGfF_Kq0);$)exw21eBYVrZRFoXo($zzxdkDWHP9NFk*t6I9&7dz=_Ch!%kgNRgkPr{LF!PH-w{7EvKR_3)Cs6i_10Q^*5thsZ%_2fG2< z$u7-HPc2T)$O8oy$OcfP>bj<6f*J%mpcWLUATLh^jV2|QCTD;wa7isKDNfGF0hKx6 zNL0wl%*`wTLP};@T51ufvkMC6%>2A!Jq7UERFHy1(70G;T4rhrGz7ts0ct3Le4YtuN0sI! zmzIFDIU>}`GcrLT1s(z@N=*YTvdhaafrL^j*bVNfMY)N2m3ls)e5{aI1}j~4K=pD7 zs9r{Cqd@9U)P{)~v>-wRTA~7Y&2zWS|TbYI(`9AvSQZfx6|Hc_o=8r8%h> z;g(vWP@G>>l3A1pT3ibbtyIu}Olbwo|Ha^NJG>GUaH++53NGr2N%=)73Mrsw3#g$F z4NPdTfqLpGnQ56IC!~Uwapn~(6N{2F z6cEFw=p8{&l>n-g!QDDU%?t{L!`nd1KTE)M38+d;NkwjOBeHUFNor0Gq{v7+yeBUW zlt&RoQesYyZho3BsI34U1xraR0_V=uqGE9NhO9>o&&*57F9$6^O-xqs4_1iKQHV&& zuTU^HGtf~8$VsdWE=dGsSOsHLu91#{UuGU?&1JrVOEB2r@XSo4iorFnB;)XsoDzko z{M@9>R9)Zv6h*w@r;w-s9@B!Y=f>Jwg$FGtpTM(TQl$b?7=rsS1{TPn2I{7ON9W*! zXKCntE~Eg1l@TC2K;;D7p0xZT1u6v?$ZXUAbIu1Z(< z$t(i3XHf^j!0Y1kQjrJ4L8YVuJP={|Q6V`awF)^1!Tkl)AoKxGh$(=q%}G>9&de(; z#Z!XnG(I6e4MfMX9hrK^}ET1C0ql#v5`HK|TRz zVM8;d;>0G&&J!}s4bI0IiFxU;!3mHfKpozEP@@98?nogY+_bF(73$fk3VvapE}o7G z?x}8|kp%_UiULrxIxjgDEq|dpRKW)gz?#$^lhY zc`2aIGN?fV&WT9zkD4Xm2_UruwG;+*iVY1E^1w-;2vo-#fZ{*37*utD8gRu5r8%Ja zt<2QYBG9-$sPha>3#lb2Wp;6CW?3evqD4*%sM)+YvjTr6hbM|u@GyFDDQJ%@WMB={ zR#qUC79fEI9*qYTDGH!!9=saAB(W$3(nvs02Em!2Fvv*oXRScR`DNC(V$Vn{*?Py3XD#$Mew?d0Sb2qv85{M9J{LjF^*w7Tz|7T*j z&A{l%aGRX6V-V}a>UMHSgQ0^8F&{GsC;X2@y93bY|6wFq8nC>SYdc!rx)XgZlWg_}8f z`-b}YgqoW=g@!o!It7MCIr+Lc2L*%}xtkdo7+4~*m4i8Cs+)k>uvtq`>kTD4<7vW` zr{*MQfQG7a6$}mZ6ySw2$N>uFsW~N}sYUem9e58AyyAoBa!{WawU{qUEyC6i3@$jl z1XMtRCIvtZzA(s++SEKqYYv*xLB=5!`pEekYx}V{H94agWE{TsBgjxt{~DCLL30ys z`9&qgsgSKrpm0MDV$5P+AtkX0R6eJI9GaH~9a<=cdM6Vx^k8VF0O`Y`7XIO>d3oS{ zR*-%ycuWGcCCf9fq$pn@Cn3K};71s$k2ke!&9 z2-@790UC+OPb(=;EJ{twEXqw(z>=Ymk|jzoK_|yOGV{t)GmEVdH+PUgG5Gg zeqJ$TYCSVm!87EeZIdWM27Xh&ThsC@?F>ZazUD1gEWADVGC9Z zsydN2uY#&U*m@Xn!y`vMHLs*7wV)`oI8`AT9Du2LpsqryLQZ~qCa8~Jj4k0|3qH@h zl+5H*qyY3wRmcTRNK`@k)v3kMKr9Arw*!r_gSX$MDj31`=YhH|rFjZ@nc)3_hgasL zmK|QHpsSF3cok@CE_mjlSRue!AtSLA)Z+yugxpjeSUaFBQK48pH7zYaFQo{S9l^;B zJQ0(Us*s$Ymz-Jv?!u;&D&*#aRw*cQfLHZc8j!G-7gYZAy8Q`OmLeTmyj@n zrN5{?dvkuXWpgUHHZCr9&MyVk;GnJ2nc1mU3c>lMMaij7?tZ3W#-Wjsk*2<$jvhuX z#=d3&Mn+Md{+>=@;Ss@MMg|5ZVTn0qsS3{dMFsgq;IRV-EKGD zs8|8j9iTC5aLEWNi6L%B8n=OZTo+QH>3}MB(C%Pxv6z{s;E|Yuh;g#u$IgoM8(vr-a%wo_aB&h#^ z>{rnGA0rb3Gb3YAelIQoPcMMFS>O~2sux|HwY9<3xnn_Ug^RO7Qetr?XxtYxqYwgG zWtf&)q#F#YnZWJ8oc!#>oKywB)RL<5)S~QE&|`OvZ4H1orKhJBq~)Zhm*^;@7AvHc z=4FFQRPeZbB4{K6+#Ulput2jq#h`Vd>7^xl$lIyFL)HqQ?tO6ytgi}6=b%&x4nxpL z1tJVV3jm;@hp5*~>KeWHB5lS%c;TQcB^KGIW|euQ&~KY(Y{YDA9su=D{JDTA~0AK=5D! zsJmNqcx7>FVrhi}DA-CX^b`(vLPDwvTtKE}f`)=Y8{|PVWoel?x#0cq(8=#&(BOF9 z;gzYN^@^n>pqvUxe4s^hDe91rD~5&~+)8L@<$<$SF(`Z#z$p(j_+A8>gGWktXh8@W zkq7tx!2y_>R|-02AhQ@Wm!FmoSxb@!T1irxm0wy^44O7uQLK=ZnVP2u%Ad5xIjNwS z180BmYMQXjqLR|Y9H;yWP_qxz;)JaQg)FZrPAw`+Em8ot{POb46+lZlav`fwaw-*4 z^2_sb@)J`)GmfAZ4RpO`VseQ>VzELZ_^hjvqRgbylKdhiC!-xI2L|TuNqfwnA}1Vsa{UaSv#~GBYtJ z6OsoMG(djHNi9i*J5UqUyDiF1%mGi7DS(Fki$Ke-L1_~{SCx`llv4>>Tig<|Ttigi7;3Ba~&JY69GX&PxWTL~ue( z%~LQ`NY2Sj)>8-rof=U9I+CJF0aE^_7J;V7VBrT2Pw?8E#In?6g)m=GhYUOx2U-{( zo?nz*44oOtFI8|2j>rTrSv|ZmH?gP)ViGL-f@*5eZb-8 zgi=dU(@`1#sfaS4dG$g`~Tb%wo`LUIjnM>@mnh@Nyk!{s$f10;%i4b$<$I zNH4W04Kyo=SQ!LLVBj`BG`;1Q7VBmuCa30==qUteD&!#y&{0TEMJmct)Dx4FLCc>C zQcFRpSmE$0&=Mv{8UPh`*i#v({m;bg%)mT}*_qbm%&7UJAutR>U})|C2dxdr1Rb|j z3Oe6Y7?l4R8D=wp&>)MOz9?osZ3bn3SXot;fXjUog6Ji~ng90R>Q!XsgaAs87L7=YGxf%S&@!aM0optBl$ z5=-;IW8V-%l@u~_6%2K~b25{&b%RSXbMzEkN)@sb^FYIbDVeDXh`Q7_5w!RqFBQC$ z3tycI8WIK9s(Ga)RjGN9Ek+=-;I(O3B4}ZC3DhV^>j%^l19i$k3zc#el2UUrQNg&j*e5fyPml!a%E{GILUklt71krIqHTgZqNWjW;LIawvtQ z)WqZrP!lV$7_{UiRRP>A05><_K?Lqofx^Kv4_YBZI(G`miF%MK)kz_^AQg04Sy4d_ zXw)GoH8HQapr|xCqXgDu1D|nL3|bKm*_Y*-nU`LanU)4xg#;SGO-U>Q5A=Z638#a$ z*C~MOQ{7-l`wcQ4oLZEYUj%C4WhQ6nDFo-|S3kigO<5u=H({lDC8a923m4h zoSK=Zqfnf$kbQVt9%$Vcc&)rbRB1YB&Nd|xG|!Nf3W`?PvPA3^E@;gLBK0XGDj4d5 zT5j12Sd*ZhLS%j^Xe1LfWC0$y1t+@19C(8m(qhNcj4OgR)R6r?F>JnhEF>J3~+j2U?FBkeHnc>fS8T2tdC57Ype{LR$v4DPkg*BShy`Q`2dJx+4%*F2ZHG967Qq*mrj~$)&LP|N;5`kn zQ$QVWcyAxv_W-RV0QKw@sO=Umlu${At&kdkP~ie)v#L~u%o05XM+M)?;6NX6_cST7 zI2F8@G$l0$wAUxGBsHf}AwL&X%BSclxR${WS%mgulk@Y^GSf?oQd1PlGC@n;azN`G zL4{pWYGG++5x8%Mi0tyzBuIa-2)v6WH#09YH!(*~!46s7oKyv{9BAN9kC!VHyk_h03do+BV$gCDa1dmIRf3aUSw5)025N^u z#+5(=y2YSA;^7tPpv^v@?m9>o)=JUi1tU3Utm$1dSNLM%fgQ zYyz*J0j-P$ZOT2o60)8K#0Mqd9MGUEcoY#dHVJbSIGsU!i%4GJ?Z3&TIgrs!&~RsB za&mrA3dpF#3qXS}aNEHf3KB~)i%T-Wp=%8qe9Q;!$N|mz6+;pos33xz(@_B$d;txG zpbu0OgZzQi%g@O?ys}s!u@sgTK}kOogm$2G6~L8tV$+IRhm7 zLG#jxX*h6uG6mFD&PoNfh(XqZw&0eh=4GZUgoE;0oa8{7K1lcfOfIKn$LO)PN~J9V{1U`{TxfvQ}c3D!TTnQQo!L0 zG8MdvySOAX8#Z;Lke&*e&PgmiJRh_P#d2QTVK1y}V&5L-c~bc05!i$NW|2wxvv zH_*Txcp3*(!owT_D%mqrGg84R!zncrywpV(q#hc6dc0hY3eaM;9FoMqM{I*;g+MtH zbP#b7c%T+ivV#Y=LBq}Ip!2$mAzML_lN(yP@+^T?#o)CLkcuY}G-O{1w>7a6)HMMc qoS0XcpO*?T6tqtg)S6Ss&dV>)Nli%yuZmDeE-fy}&js!FlK=qa3R*w_ literal 0 HcmV?d00001 diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/alienswarm.json b/crates/lanspread-peer-cli/catalogs/default/manifests/alienswarm.json new file mode 100644 index 0000000..7b0e3a5 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/alienswarm.json @@ -0,0 +1,62 @@ +{ + "schema_version": 1, + "game_id": "alienswarm", + "game_version": "20190317", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "alienswarm.eti", + "kind": "file", + "size": 125829913, + "file_blake3": "77fd653681a1643973dd59b981417eed231304d1f785b1b529c1c28ce94d0a5b", + "chunk_blake3": [ + "77fd653681a1643973dd59b981417eed231304d1f785b1b529c1c28ce94d0a5b" + ] + }, + { + "canonical_path": "notes.txt", + "kind": "file", + "size": 34, + "file_blake3": "9a2cbf6fa18632be6e6262671952f781a3b53f41c3ca31219b7e19763d40a65e", + "chunk_blake3": [ + "9a2cbf6fa18632be6e6262671952f781a3b53f41c3ca31219b7e19763d40a65e" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "813c1bf52f38019051bfbc2cbfcdbf8620a46124ddb14dc7a55df9daa6186bee", + "chunk_blake3": [ + "813c1bf52f38019051bfbc2cbfcdbf8620a46124ddb14dc7a55df9daa6186bee" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "target/peer-cli-large-fixture/alienswarm-payload", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "target/peer-cli-large-fixture/alienswarm-payload/payload-a.bin", + "kind": "file", + "size": 41943040, + "file_blake3": "d9d3cd4605ec26ada09b3e618546c45dfd8b7dd42ba7d1e825cff3062037cedf" + }, + { + "canonical_path": "target/peer-cli-large-fixture/alienswarm-payload/payload-b.bin", + "kind": "file", + "size": 41943040, + "file_blake3": "3835c341be211b8549328f31b82732b88e47e18db4748e296b9e3fdbad339c07" + }, + { + "canonical_path": "target/peer-cli-large-fixture/alienswarm-payload/payload-c.bin", + "kind": "file", + "size": 41943040, + "file_blake3": "4ba18b14e0ba7e5822aa1c8d494a1646b82f53581f65217bb6dcee3f69e7ba7d" + } + ], + "content_id": "e871472e2dc154623369c7a05825d65f50f1412509569dab0d15c4565489dcfc" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/bf1942.json b/crates/lanspread-peer-cli/catalogs/default/manifests/bf1942.json new file mode 100644 index 0000000..1ec457d --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/bf1942.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "game_id": "bf1942", + "game_version": "20160130", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "bf1942.eti", + "kind": "file", + "size": 3146118, + "file_blake3": "65f7729f0a353b20d3996331ef6c1813ce2259e90d3632eac2bae463700c1126", + "chunk_blake3": [ + "65f7729f0a353b20d3996331ef6c1813ce2259e90d3632eac2bae463700c1126" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "37c38c7fe5ec9239e6b970165c5793fe6d503bee41344abd2a0e329c7afbb39b", + "chunk_blake3": [ + "37c38c7fe5ec9239e6b970165c5793fe6d503bee41344abd2a0e329c7afbb39b" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "bin/bf1942-payload.bin", + "kind": "file", + "size": 2097152, + "file_blake3": "de7d369a819879597920fa6eea41e421cb5b56763e279b4cf95cf9c2338f7af2" + }, + { + "canonical_path": "data", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "data/bf1942-assets.dat", + "kind": "file", + "size": 1048576, + "file_blake3": "cfbe618ef17da6a5197912649bf31619bfa02db8db60c176f82473bab37d46d9" + } + ], + "content_id": "157b952fd2226fc6edc90b698606510956301ff867e6ffb11c724584b1fa9b5a" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/bfbc2.json b/crates/lanspread-peer-cli/catalogs/default/manifests/bfbc2.json new file mode 100644 index 0000000..c4dd7cb --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/bfbc2.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "game_id": "bfbc2", + "game_version": "20210416", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "bfbc2.eti", + "kind": "file", + "size": 3146114, + "file_blake3": "2e4022096dfd3114ec7bacf69c6c25bd83b9a6d3af71e81df4ba8584fc23a82b", + "chunk_blake3": [ + "2e4022096dfd3114ec7bacf69c6c25bd83b9a6d3af71e81df4ba8584fc23a82b" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "6eacd1c0e8efe8f809658ee66b9272d32daa1daadd904469ce5f7d128d23c9d5", + "chunk_blake3": [ + "6eacd1c0e8efe8f809658ee66b9272d32daa1daadd904469ce5f7d128d23c9d5" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "bin/bfbc2-payload.bin", + "kind": "file", + "size": 2097152, + "file_blake3": "4d6d0c9ee250f23e4828fd50872eecf945c022d0d82cc1a11bf25c96ef0ec807" + }, + { + "canonical_path": "data", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "data/bfbc2-assets.dat", + "kind": "file", + "size": 1048576, + "file_blake3": "16daa7902e05888c38b984bac96bb5af496e43331bd3d8e858f019c8fd09dda5" + } + ], + "content_id": "ddd30de1727ec7bb37dad961217de619b514a71f47ccb4d7acbfcb8ad21f50d3" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/catalog-content-index-v1.jsonl b/crates/lanspread-peer-cli/catalogs/default/manifests/catalog-content-index-v1.jsonl new file mode 100644 index 0000000..02ce298 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/catalog-content-index-v1.jsonl @@ -0,0 +1 @@ +{"schema_version":1,"games":{"alienswarm":{"game_version":"20190317","content_id":"e871472e2dc154623369c7a05825d65f50f1412509569dab0d15c4565489dcfc","supports_streamed_install":true},"bf1942":{"game_version":"20160130","content_id":"157b952fd2226fc6edc90b698606510956301ff867e6ffb11c724584b1fa9b5a","supports_streamed_install":true},"bfbc2":{"game_version":"20210416","content_id":"ddd30de1727ec7bb37dad961217de619b514a71f47ccb4d7acbfcb8ad21f50d3","supports_streamed_install":true},"cnc4":{"game_version":"20170204","content_id":"d356f42b1989d6c36011ff3c5f52ac663bcc0eb8efe144872f11e8d8b0e2e9a1","supports_streamed_install":true},"cnctw":{"game_version":"20160128","content_id":"f01e4b38e052cbd9ba1c468e31af78cf95f0c9bc3371875289cb3ec2e8be8067","supports_streamed_install":true},"cod5":{"game_version":"20160920","content_id":"56e7bd1e80e32d50a1f8022766f87c68c0dfb6f7ad041485641b8ffb7098ca81","supports_streamed_install":true},"cod6":{"game_version":"20200315","content_id":"dc2cfa0e4da3fdc7138dc37930a8503ead08883faa0f56f59598489b48de3153","supports_streamed_install":true},"coh":{"game_version":"20200907","content_id":"7cf80604d1811b8683c415a60415a6e980bb25e42146b4b474324d47e068f34d","supports_streamed_install":true},"css":{"game_version":"20240623","content_id":"9d8a0aa232c27023b41491fa0218abd0592a3e0b3e5b5168b337a608e3cf8507","supports_streamed_install":true},"ggoo":{"game_version":"20200721","content_id":"30a6358eff3b0ed6d797aee10766ca69741eb1198f83d341d0876b5ce1009ae0","supports_streamed_install":true}}} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/cnc4.json b/crates/lanspread-peer-cli/catalogs/default/manifests/cnc4.json new file mode 100644 index 0000000..7141fb6 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/cnc4.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "game_id": "cnc4", + "game_version": "20170204", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "cnc4.eti", + "kind": "file", + "size": 3146110, + "file_blake3": "e96019bec5b92e3f5a241d1a5bd1a1608c4bb6b44e2767a7eaa01ae46a1d35d9", + "chunk_blake3": [ + "e96019bec5b92e3f5a241d1a5bd1a1608c4bb6b44e2767a7eaa01ae46a1d35d9" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "6744ce6acc6d7548c8a87eea79a5bb7480fe3848cfab7f272e2bcbe43d652d3f", + "chunk_blake3": [ + "6744ce6acc6d7548c8a87eea79a5bb7480fe3848cfab7f272e2bcbe43d652d3f" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "bin/cnc4-payload.bin", + "kind": "file", + "size": 2097152, + "file_blake3": "f1394ba123b31bbdbd109d0a241d0d580b99e78750f1cff7838caf8e62f6e3e0" + }, + { + "canonical_path": "data", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "data/cnc4-assets.dat", + "kind": "file", + "size": 1048576, + "file_blake3": "ee16d8c5e60a48ce5fabfd230bf889865e847eb8a0cc5dd3d7615291fe04c043" + } + ], + "content_id": "d356f42b1989d6c36011ff3c5f52ac663bcc0eb8efe144872f11e8d8b0e2e9a1" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/cnctw.json b/crates/lanspread-peer-cli/catalogs/default/manifests/cnctw.json new file mode 100644 index 0000000..651b828 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/cnctw.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "game_id": "cnctw", + "game_version": "20160128", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "cnctw.eti", + "kind": "file", + "size": 3146114, + "file_blake3": "f4d31de3bcff86de8c78f766f1e7809ffd5d47a951742ac9db6a97551d84eb6f", + "chunk_blake3": [ + "f4d31de3bcff86de8c78f766f1e7809ffd5d47a951742ac9db6a97551d84eb6f" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a", + "chunk_blake3": [ + "995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "bin/cnctw-payload.bin", + "kind": "file", + "size": 2097152, + "file_blake3": "495fcaac274d7c6cf9a2db7587280b73d4c18d7ac473305082c4fe1a0b93ec82" + }, + { + "canonical_path": "data", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "data/cnctw-assets.dat", + "kind": "file", + "size": 1048576, + "file_blake3": "372e932a9316a3c76517520b91540cc4eb4035e8e5726a0df4910c018117f344" + } + ], + "content_id": "f01e4b38e052cbd9ba1c468e31af78cf95f0c9bc3371875289cb3ec2e8be8067" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/cod5.json b/crates/lanspread-peer-cli/catalogs/default/manifests/cod5.json new file mode 100644 index 0000000..75016c5 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/cod5.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "game_id": "cod5", + "game_version": "20160920", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "cod5.eti", + "kind": "file", + "size": 3146110, + "file_blake3": "53fa2d2c9daade4cc16a4b562643f18916cfe2533696d5c5db7dfe1f4a48eddf", + "chunk_blake3": [ + "53fa2d2c9daade4cc16a4b562643f18916cfe2533696d5c5db7dfe1f4a48eddf" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "caec0ebe8cf7509acb9b9feefbd704c204ff2689a8fdefcaf8f477fd53c54163", + "chunk_blake3": [ + "caec0ebe8cf7509acb9b9feefbd704c204ff2689a8fdefcaf8f477fd53c54163" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "bin/cod5-payload.bin", + "kind": "file", + "size": 2097152, + "file_blake3": "169fa6cc2a0a802b72612ae35444735bf592c30bc787739b4c2307a8fd67a95b" + }, + { + "canonical_path": "data", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "data/cod5-assets.dat", + "kind": "file", + "size": 1048576, + "file_blake3": "f122d70e41a567ddf06b77d650683db42f5ecb1bdf5648366f16486e3adb7cd7" + } + ], + "content_id": "56e7bd1e80e32d50a1f8022766f87c68c0dfb6f7ad041485641b8ffb7098ca81" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/cod6.json b/crates/lanspread-peer-cli/catalogs/default/manifests/cod6.json new file mode 100644 index 0000000..9cfc38a --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/cod6.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "game_id": "cod6", + "game_version": "20200315", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "cod6.eti", + "kind": "file", + "size": 3146110, + "file_blake3": "ce096cadb67834be368fa1fe2141426810591a0febe6c4e960d729a4a436d30f", + "chunk_blake3": [ + "ce096cadb67834be368fa1fe2141426810591a0febe6c4e960d729a4a436d30f" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "fb541cd98ca1ce773b04cd44d27f182830ee5e04e63f0a268486826f5b33c424", + "chunk_blake3": [ + "fb541cd98ca1ce773b04cd44d27f182830ee5e04e63f0a268486826f5b33c424" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "bin/cod6-payload.bin", + "kind": "file", + "size": 2097152, + "file_blake3": "491b9181f503d31917709d5429e1f52686d52bf13562faedbc84031885aeca13" + }, + { + "canonical_path": "data", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "data/cod6-assets.dat", + "kind": "file", + "size": 1048576, + "file_blake3": "ac874e3ac3438dee4c2e42165ead2fb9f84210760d1645294e4eed8ec6792cf4" + } + ], + "content_id": "dc2cfa0e4da3fdc7138dc37930a8503ead08883faa0f56f59598489b48de3153" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/coh.json b/crates/lanspread-peer-cli/catalogs/default/manifests/coh.json new file mode 100644 index 0000000..7d1c529 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/coh.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "game_id": "coh", + "game_version": "20200907", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "coh.eti", + "kind": "file", + "size": 3146106, + "file_blake3": "51c8a7d80b2424c262f55beabbc26b31a878ab3b5df7b8470b1f5838d02fa214", + "chunk_blake3": [ + "51c8a7d80b2424c262f55beabbc26b31a878ab3b5df7b8470b1f5838d02fa214" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "5f033915cb29613e39a19fba14b466689a996a621c8b3e3f1f3bd133bed9a50a", + "chunk_blake3": [ + "5f033915cb29613e39a19fba14b466689a996a621c8b3e3f1f3bd133bed9a50a" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "bin/coh-payload.bin", + "kind": "file", + "size": 2097152, + "file_blake3": "cf081a2e71ae96aa1505605e75ae3651dfc59e395e7fcfd5f5520fa1eb133fb3" + }, + { + "canonical_path": "data", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "data/coh-assets.dat", + "kind": "file", + "size": 1048576, + "file_blake3": "ac5eadb10f22a57f9072c19d141212cdb847fee759f38af31c5a96a6ff80d748" + } + ], + "content_id": "7cf80604d1811b8683c415a60415a6e980bb25e42146b4b474324d47e068f34d" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/css.json b/crates/lanspread-peer-cli/catalogs/default/manifests/css.json new file mode 100644 index 0000000..acf51a0 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/css.json @@ -0,0 +1,89 @@ +{ + "schema_version": 1, + "game_id": "css", + "game_version": "20240623", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "css.eti", + "kind": "file", + "size": 654, + "file_blake3": "a5123abeb6b462d0fba6e58516cd2e6a7986eb18bd9084134c07c8422552bccd", + "chunk_blake3": [ + "a5123abeb6b462d0fba6e58516cd2e6a7986eb18bd9084134c07c8422552bccd" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "6568056191954d307f80dcf71bdb0052a6873a770098141d34edd801bcaf2194", + "chunk_blake3": [ + "6568056191954d307f80dcf71bdb0052a6873a770098141d34edd801bcaf2194" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "engine", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "engine/bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "engine/bin/win64", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "engine/bin/win64/steam_settings", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "engine/bin/win64/steam_settings/SmartSteamEmu.ini", + "kind": "file", + "size": 71, + "file_blake3": "6b2e288b261c0d2464f6f422cc1038b4c49de58daf89dcc758481adfd99dc026" + }, + { + "canonical_path": "profiles", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "profiles/local", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "profiles/local/account_name.txt", + "kind": "file", + "size": 11, + "file_blake3": "626d0787808ca0087e3d959495e57e41d15c7fff51113f6627aa908825c29830" + }, + { + "canonical_path": "profiles/local/language.txt", + "kind": "file", + "size": 7, + "file_blake3": "3e59c7a7502a0a63099171b709c5eed0191275c51d83de063d4cac191bac4813" + }, + { + "canonical_path": "readme.txt", + "kind": "file", + "size": 17, + "file_blake3": "815192d90dcdecf7dff970c24ae3b20b18351d75da2aba226ba6f6e0bac2c7b6" + } + ], + "content_id": "9d8a0aa232c27023b41491fa0218abd0592a3e0b3e5b5168b337a608e3cf8507" +} diff --git a/crates/lanspread-peer-cli/catalogs/default/manifests/ggoo.json b/crates/lanspread-peer-cli/catalogs/default/manifests/ggoo.json new file mode 100644 index 0000000..1d47ebb --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/default/manifests/ggoo.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "game_id": "ggoo", + "game_version": "20200721", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "ggoo.eti", + "kind": "file", + "size": 3146110, + "file_blake3": "ca521fcb90514c2d4c6d33fe79d1252066547f5f83f5a98bf92d93e473fbebe4", + "chunk_blake3": [ + "ca521fcb90514c2d4c6d33fe79d1252066547f5f83f5a98bf92d93e473fbebe4" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 8, + "file_blake3": "a5354e30ec514c1a2b1d7db06e88345a861b5cd90dc7f287a44c917612f851aa", + "chunk_blake3": [ + "a5354e30ec514c1a2b1d7db06e88345a861b5cd90dc7f287a44c917612f851aa" + ] + } + ], + "streamed_install_files": [ + { + "canonical_path": "bin", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "bin/ggoo-payload.bin", + "kind": "file", + "size": 2097152, + "file_blake3": "0c793d5caa4d76d70ae2251950b74c89fe8d795d9467f8d00a115b254a5483ad" + }, + { + "canonical_path": "data", + "kind": "directory", + "size": 0, + "file_blake3": null + }, + { + "canonical_path": "data/ggoo-assets.dat", + "kind": "file", + "size": 1048576, + "file_blake3": "e0177aff792b3796037edddd40e4cf834448f638f24225fc891c645619004b35" + } + ], + "content_id": "30a6358eff3b0ed6d797aee10766ca69741eb1198f83d341d0876b5ce1009ae0" +} diff --git a/crates/lanspread-peer-cli/catalogs/multi/game.db b/crates/lanspread-peer-cli/catalogs/multi/game.db new file mode 100644 index 0000000000000000000000000000000000000000..573567da77eba5f75d362cb654b15b74c2a91ead GIT binary patch literal 14336 zcmWFz^vNtqRY=P(%1ta$FlJz3U}R))P*7lCU`l0RVBlkb1I8^33=AxAE&~H25*r7T zDV0I5_ctp@7NV1lF`UsK#2u!XjfD;Dvqk}Cc5!)m#>V`T#H5_ml+5Df#G;hc6gY#$ zImp#9#8n~0(aFbEK?yFdq@cme#hH+jniHRylECTd7vk#f8l(dfOHa&A1q+6_MuZ?l zvQsNj)CB~2`Z@+hDtNm_YQW6V>A8s&1v!b8sYS(zu!Gr=n^;_u zS`=TDT8Ju_npXq~e{}Dcr4|)u<|C{|jRA0wX>w^c3NwPkzljwR{vhMhK?D<8_=Cit z;SXYBgg96bB?Q1CD1HZvAo&`_Nnl$+#%gkD@-i?mFf#99VBP~xx1+>p2#kin5D5V% zMpgzxLwnccjFPI<%o5$;lA^?t)bz~Mpwz@1-IC1QRE1)QP$gLE@XGYeRE5+$g_6|V zf?|cD!z)vBAo)L+fjM`GczV>CqaiR85949s0~Qj<%H^7As26&#C7iXF_;z`!wA!N@>Q!LgtyGe^P5 zz`#tQytIgcfq@;A|5=zl8JN#AFJ&%c_8h7~JL>My5E#%9&}Ly_kn~SZEG#^5cV%lHgYm`jEeG&2=p>_jdY7LaWxL~ zF?I?y4K)r6baV7DHxAHeWMNSB&&kgzPRvV5%CB&8^fhsgat`rwG!6BPG7dK~b1@1H zjtX!Nb@z^R^Ko?n_05@?KZ1gekNG$AHwY8N9mS&|Fd70xgn%y-w z1cV!Vg*tgUxx1R1xOsa8hPb=?1O&p()H6cpQpjXrVBi6b#c?w;Ffed2f$M(;O9tjF z##fBt43P2<>1uWafc`r8F-+wKzE=4-{A+8@vM0CEGkjRPXmi!#0a8;SPY7hVukX=VsNx1XC~(4R4OFq<>i;=C8wq+BvmT7 zI_fESf-F=3kJqOtD8T|792H97&S!aMPL4uSssePdJ_Y1_P-uhblJb0ol+3iW)S}e9 z5^y+Y=I0gbDY#`8LBc;#AvZrIGc7YUMFA3mFhyXaGxNZnD9uYQEm1%XwepNiP)HSn z6cnYVEjI5KGO;NzDcM3>0d4$%j{hO@jnmjzY0| zW?o5VNoh_hM!2PxC=}-xm1Gtrf(F;Xp_K|sE2R}M{}+S9?eI!az@--JDY&R7Cgm5U zD5NCj6)TkHrNY!fgDn*rjvyzbmOxbHs2^SlNn|Ktr;wMQr;waqT5xz}s*XZoX{JJ+ zdTMcrLP~yKvO+;(F(jpd4a!q+1o<#AJug*{!^ptU%)rpd0+j!YOHyI^1f0SXOVbow zoVB&Vsot?5wZg?&At|vq6BO^@tX!0sSDcnwq#K-{R#Ki=l&X++cu$c+PJVV`PO5@m zYDrajYEgEoooE!s)9>mNorc@Ucp?@7wZ1R3n(=%*WySX5G}8=9M*ngmv&$7PdL zWCy}r9+`RNshP!A3h)L{IVej(vS4m%Mv+2#YC&2~YI=!|LTa%>T4`Q3C>MkCWnxll zkwRW#a)v^AW~xG2evv{kSVL)v9^5ozg-mdgRme#!0R=j!=*i5_gJoB67=rRVA`DYg z6iT3>r%((Jy?jvMB`4-6Bo?J6D&(b>l;;;^7wajwRwQSD%2b84%$(F>9fjQdvdq+C zaAwLc$p97Ei6x0(Zc=JqYFcKALRwLNE+`WzSZ& zb}%RvS1OdJ=Hws;5h!4C5Wa}aS-o>`Ki5SCd~Qkt0KlwYBcpQn(RR|!fx1*IjaMS2P$8L0}z zsYPX}MGD}mA}_yOA+fY1KR2-?GZ|dxq~w?9<>V)(C?w|OD1c^=Aqh4yxkMqcSRoNS z*IiPSnN(VmU!lSI2DrA%0GCv$MLCt3dFcv1nR%rZ3dNPhC8@cfY!9lR@=}vi zi;EMBD)khCGxAGwQb2Wfu|g^+bdpjP;L_mwyf{@MF&U&@AqA9clN0km^=xTzYKj$7 zAQa{2mni5f5X-PKc>_3Wf^FIho0N z3Ss%B#R>)arDdsA3Z*5PIhn<&MWCt>7JlIH1l4p3iDjwD3SqwB@;d-L7p|ia4k~v_ zz)4>rzf{3BI3g27ryO3Hn^;r?F$q@4733Fzdwx)ZAn^oBGoXwM&RF?HDVceRC8?!F zdI~&f%5G8Hq*dsYQwK)Kgdr@lXlK2cS%zm0DT>%C?E=(3-UbltBwi zQ<0KTYAI?O3QH}`%*jcu0;i%Pg~C$x#Db#3D~c6z)M4oXlCu>wa#B%T0xAZ<={zU3 zL=)Vqzznkd`d!&X4L%A5EzCbFto=1 zKz)NuP~B7tTHnV3%KwZEvl&2Wki|`36f>VTgR;M@XcuRPe`h3kAhftLFF79400CEx z!Js<3$jLw0$lKe^D99}^DA3*8+1WJIC^FE`Gu$V@G0@v1JQ6k-YGhzw;F6gNsaC^$ zLA`H<)XY2uCD2%{Phx3aGN@S%F;odO++e8dos*fIts7jDnWLxRQmT-hn3o3)LquKb zn+O_P$V*ko%`CxNr-H^=z_n^#X-QRTo72^wGsN=?qlOD$3;E=dH9Tj(jcq!uYaT(6Lnnx2`LSE7(t3L1!jw9tx^ zGeFIcoduBad zxTPc(E2JbAm4e!H3OS%*q`Z_=Nc&AU7}9=&^Z-+f((;Q^Qj2mjlQZ-bg7b57Ku!ns zuu}4qGeGGgH!(*c@9?&g%yfm~)XY2`h2ngL?8DpgK;u3snW@F83Q?u$iFxV8DT$zA zqoh<&w3aB8rxvB8=3%dJK@Dhd$_1xBg+v8IT~Ny{8+#JeQ;5tjRRFaWON&9R;#382 zqD#zyH;5rEc0A3vB4{%Xno7W}YDj8hU|`?`=YPg32F9vEmivdjb6S~M_+%Mu{iOw) zl$+#RtQnHhAmy4w`7cq&*UgQgSl$QbRH_ixmnIld}`k zQx!7vl5}Gc7+SC%+uj)k;syO)aLjL!3dQd4;8^B?`&;c_o>k z7Bal20d@+g;|=fagZm!ErI}@*p1lIK-NJONzDPRK}sx1&8bw#&jpq8DS8U7WvNA#B^jW|0+mfk zsh~hj%S0$`@ZgJ%LRx7NXhba+H1rHge^A#IE94i! z2Mmf~F3QQw%Z3a~NBH`H2G>A)Cv+8xQz1okaUy7>K>;iW8o1Nr6xPd z8_rHSys|7cC%>TJ@Ji5FL2e>+;H?DQ6E23>nV6QDrvU0qgSzUe3I(Y}#rb)8sS1ge z&|Y>gtGmDE8^FY1Lg3`?5;#7qkxcWr!0E3=FXkIF4*beNwl>9u%=o%zB zA-O6iGwJZkqQfh}eeL|*%wo_OF=!a4Ahona0n%ZGMd{&{;8+Hg7HOG~;XKeNLa{<> z1*qu*b`4}W5E50TMGD{$&Ce@=B`45GyK_b&$dj;9HU%V`5|c|Z%RnRJ`K1aehgTNE z`Je=xld1r<78HaqM}gBB#J7m#1)8DBOfJoVjBbL)Y7&!^^NUhIMju`P8hnAF#Id5*QrfY~&d3>uM72;%{Q^AL4235)tU_ z2UH({r!71)ic&$NXpo5n9qhI%LiO$^Oh@(C|@7d1i8UYEB8b zJ(&V(D`%yGTErl0LA{ak)V$1eg>X<_%Tp-MOjQ8+zqmLx4`Qo=V`&SaE90YDIXM((- z3+VwRmKK92RTFbcQen+!Jq4%K;(YKlQL%z!X?kj2Zfa4s0(b@o6uux+gP}!vHf-uf zAw3l`os(F4cs{5Vo(pfUL)tgs999Ie)df0IT@32zMfm#Yx`Bp+!84nn5+3Fd1zk`a zWTb*qhEr-LcrIEOq#hc6dc0hY3eaM;9FoMqOYA|jLZBR}PzfriKV`T#H5_ml+5Df#G;hc6gY#$ zImp#9#8n~0(aFbEK?yFdq@cme#hH+jniHRylECTd7vk#f8l(dfOHa&A1q+6_MuZ?l zvQsNj)CB~2`Z@+hDtNm_YQW6V>A8s&1v!b8sYS(zu!Gr=n^;_u zS`=TDT8Ju_npXq~e{}Dcr4|)u<|C{|jRA0wX>w^c3NwPkzljwR{vhMhK?D<8_=Cit z;SXYBgg96bB?Q1CD1HZvAo&`_Nnl$+#%gkD@-i?mFf#99VBP~xx1+>p2#kin5D5V% zMpgzxLwnccjFPI<%o5$;lA^?t)bz~Mpwz@1-IC1QRE1)QP$gLE@XGYeRE5+$g_6|V zf?|cD!z)vBAo)L+fjM`GczV>CqaiR85949s0~Qj<%H^7As26&#C7iXF_;z`!wA!N@>Q!LgtyGe^P5 zz`#tQytIgcfq@;A|5=zl8JN#AFJ&%c_8h7~JL>My5E#%9&}Ly_kn~SZEG#^5cV%lHgYm`jEeG&2=p>_jdY7LaWxL~ zF?I?y4K)r6baV7DHxAHeWMNSB&&kgzPRvV5%CB&8^fhsgat`rwG!6BPG7dK~b1@1H zjtX!Nb@z^R^Ko?n_05@?KZ1gekNG$AHwY8N9mS&|Fd70xgn%y-w z1cV!Vg*tgUxx1R1xOsa8hPb=?1O&p()H6cpQpjXrVBi6b#c?w;Ffed2f$M(;O9tjF z##fBt43P2<>1uWafc`r8F-+wKzE=4-{A+8@vM0CEGkjRPXmi!#0a8;SPY7hVukX=VsNx1XC~(4R4OFq<>i;=C8wq+BvmT7 zI_fESf-F=3kJqOtD8T|792H97&S!aMPL4uSssePdJ_Y1_P-uhblJb0ol+3iW)S}e9 z5^y+Y=I0gbDY#`8LBc;#AvZrIGc7YUMFA3mFhyXaGxNZnD9uYQEm1%XwepNiP)HSn z6cnYVEjI5KGO;NzDcM3>0d4$%j{hO@jnmjzY0| zW?o5VNoh_hM!2PxC=}-xm1Gtrf(F;Xp_K|sE2R}M{}+S9?eI!az@--JDY&R7Cgm5U zD5NCj6)TkHrNY!fgDn*rjvyzbmOxbHs2^SlNn|Ktr;wMQr;waqT5xz}s*XZoX{JJ+ zdTMcrLP~yKvO+;(F(jpd4a!q+1o<#AJug*{!^ptU%)rpd0+j!YOHyI^1f0SXOVbow zoVB&Vsot?5wZg?&At|vq6BO^@tX!0sSDcnwq#K-{R#Ki=l&X++cu$c+PJVV`PO5@m zYDrajYEgEoooE!s)9>mNorc@Ucp?@7wZ1R3n(=%*WySX5G}8=9M*ngmv&$7PdL zWCy}r9+`RNshP!A3h)L{IVej(vS4m%Mv+2#YC&2~YI=!|LTa%>T4`Q3C>MkCWnxll zkwRW#a)v^AW~xG2evv{kSVL)v9^5ozg-mdgRme#!0R=j!=*i5_gJoB67=rRVA`DYg z6iT3>r%((Jy?jvMB`4-6Bo?J6D&(b>l;;;^7wajwRwQSD%2b84%$(F>9fjQdvdq+C zaAwLc$p97Ei6x0(Zc=JqYFcKALRwLNE+`WzSZ& zb}%RvS1OdJ=Hws;5h!4C5Wa}aS-o>`Ki5SCd~Qkt0KlwYBcpQn(RR|!fx1*IjaMS2P$8L0}z zsYPX}MGD}mA}_yOA+fY1KR2-?GZ|dxq~w?9<>V)(C?w|OD1c^=Aqh4yxkMqcSRoNS z*IiPSnN(VmU!lSI2DrA%0GCv$MLCt3dFcv1nR%rZ3dNPhC8@cfY!9lR@=}vi zi;EMBD)khCGxAGwQb2Wfu|g^+bdpjP;L_mwyf{@MF&U&@AqA9clN0km^=xTzYKj$7 zAQa{2mni5f5X-PKc>_3Wf^FIho0N z3Ss%B#R>)arDdsA3Z*5PIhn<&MWCt>7JlIH1l4p3iDjwD3SqwB@;d-L7p|ia4k~v_ zz)4>rzf{3BI3g27ryO3Hn^;r?F$q@4733Fzdwx)ZAn^oBGoXwM&RF?HDVceRC8?!F zdI~&f%5G8Hq*dsYQwK)Kgdr@lXlK2cS%zm0DT>%C?E=(3-UbltBwi zQ<0KTYAI?O3QH}`%*jcu0;i%Pg~C$x#Db#3D~c6z)M4oXlCu>wa#B%T0xAZ<={zU3 zL=)Vqzznkd`d!&X4L%A5EzCbFto=1 zKz)NuP~B7tTHnV3%KwZEvl&2Wki|`36f>VTgR;M@XcuRPe`h3kAhftLFF79400CEx z!Js<3$jLw0$lKe^D99}^DA3*8+1WJIC^FE`Gu$V@G0@v1JQ6k-YGhzw;F6gNsaC^$ zLA`H<)XY2uCD2%{Phx3aGN@S%F;odO++e8dos*fIts7jDnWLxRQmT-hn3o3)LquKb zn+O_P$V*ko%`CxNr-H^=z_n^#X-QRTo72^wGsN=?qlOD$3;E=dH9Tj(jcq!uYaT(6Lnnx2`LSE7(t3L1!jw9tx^ zGeFIcoduBad zxTPc(E2JbAm4e!H3OS%*q`Z_=Nc&AU7}9=&^Z-+f((;Q^Qj2mjlQZ-bg7b57Ku!ns zuu}4qGeGGgH!(*c@9?&g%yfm~)XY2`h2ngL?8DpgK;u3snW@F83Q?u$iFxV8DT$zA zqoh<&w3aB8rxvB8=3%dJK@Dhd$_1xBg+v8IT~Ny{8+#JeQ;5tjRRFaWON&9R;#382 zqD#zyH;5rEc0A3vB4{%Xno7W}YDj8hU|`?`=YPg32F9vEmivdjb6S~M_+%Mu{iOw) zl$+#RtQnHhAmy4w`7cq&*UgQgSl$QbRH_ixmnIld}`k zQx!7vl5}Gc7+SC%+uj)k;syO)aLjL!3dQd4;8^B?`&;c_o>k z7Bal20d@+g;|=fagZm!ErI}@*p1lIK-NJONzDPRK}sx1&8bw#&jpq8DS8U7WvNA#B^jW|0+mfk zsh~hj%S0$`@ZgJ%LRx7NXhba+H1rHge^A#IE94i! z2Mmf~F3QQw%Z3a~NBH`H2G>A)Cv+8xQz1okaUy7>K>;iW8o1Nr6xPd z8_rHSys|7cC%>TJ@Ji5FL2e>+;H?DQ6E23>nV6QDrvU0qgSzUe3I(Y}#rb)8sS1ge z&|Y>gtGmDE8^FY1Lg3`?5;#7qkxcWr!0E3=FXkIF4*beNwl>9u%=o%zB zA-O6iGwJZkqQfh}eeL|*%wo_OF=!a4Ahona0n%ZGMd{&{;8+Hg7HOG~;XKeNLa{<> z1*qu*b`4}W5E50TMGD{$&Ce@=B`45GyK_b&$dj;9HU%V`5|c|Z%RnRJ`K1aehgTNE z`Je=xld1r<78HaqM}gBB#J7m#1)8DBOfJoVjBbL)Y7&!^^NUhIMju`P8hnAF#Id5*QrfY~&d3>uM72;%{Q^AL4235)tU_ z2UH({r!71)ic&$NXpo5n9qhI%LiO$^Oh@(C|@7d1i8UYEB8b zJ(&V(D`%yGTErl0LA{ak)V$1eg>X<_%Tp-MOjQ8+zqmLx4`Qo=V`&SaE90YDIXM((- z3+VwRmKK92RTFbcQen+!Jq4%K;(YKlQL%z!X?kj2Zfa4s0(b@o6uux+gP}!vHf-uf zAw3l`os(F4cs{5Vo(pfUL)tgs999Ie)df0IT@32zMfm#Yx`Bp+!84nn5+3Fd1zk`a zWTb*qhEr-LcrIEOq#hc6dc0hY3eaM;9FoMqOYA|jLZBR}PzfriKV`T#H5_ml+5Df#G;hc6gY#$ zImp#9#8n~0(aFbEK?yFdq@cme#hH+jniHRylECTd7vk#f8l(dfOHa&A1q+6_MuZ?l zvQsNj)CB~2`Z@+hDtNm_YQW6V>A8s&1v!b8sYS(zu!Gr=n^;_u zS`=TDT8Ju_npXq~e{}Dcr4|)u<|C{|jRA0wX>w^c3NwPkzljwR{vhMhK?D<8_=Cit z;SXYBgg96bB?Q1CD1HZvAo&`_Nnl$+#%gkD@-i?mFft!wU_J&;x1+>p2#kinkP86| zCRPSzQ&ZRUeBI!T{QQ#CBDc(<;u770)S}}2JOylG#SnGi{LjeCzzAXtIe$~x!Mz#s zLZS?s{-TPE-R{kv-P(*CjIzo3DMrqTIXMdXX$mf-C6x+BP9{bk&PL|$jv?m$Zsv}r zegWZ;-XWg{S6}WP@C!rx09lcu7fW z5zO}D%)AtktE*BoOVU&G4sROG=9}i%TG4Iv|xG;h@y?)EtGxq|_pX zw9M?1%(B!xgBo`X?ur7N;6Jg@u|$g@wC$JBNB1JG=V(dPf>N zdU-iJnt23z2f0K<>N2x1$ody&u+7Uep*7)AJ+h5NYsxCVrpJNkK%@GFtIQQ`xhr0Ihi^}MfpYqdYQULx<#3|8VC9qJB6Bt8ixhCIeM5I2k0}h zFev)x7=;E$1vrPgdq=wYxVnJ)=FH3= zL2<;#{G0h3gbCt~;?WQo4FMuTz?X?zl|kNLmY<)WpEI>2Gd?FVuec;NG1te@Pazn@ za0>McF*P=ha*l`yi7@jrF*0@Z^^Ed&4fpa04h!(}^GPkqR0v5;%r!7DFz8@rf`8F45Af`ojk)s{C&+$Jt89l!i~K`ojje~UCmA0yuAWL z+}(Ww0^w%r86k8jWHK-?@PNkRxS1Ik7`T|g^*@6p19KMRE5>jJOCtOWQPL#M0v^t2 z5d{xtl;r2<6oUy?)Zq-U7E#zvIjgIrvk5G8quxv2<6U|rd% zm1rWliJ5sNiJ5t+MQ9SBfg>~_=qOMMx~Wj{)I99!)6k7h$t(ts<{>*7d1M9ZA<$sY z==dLKSaNjyF9Dp!6F^B~P>%nBGPAC4eoASvLT+hsF{F!=r-0nWQ1DKzgm)jkQ!CR` z^As{N^GZ^Sa#GVv6v|VJQd0BuP&>KB8TmyekY28PYEf=tos(b5cQl`$UD}{G61`)FMzABr23A z7AX`b!#c1yeOFSHm{OVy@|8kjPEKlWVqQur7XPIdgF2_F3dM(47G;8Z?Fw0`r4x( z5|c7ZN>f1&NzE%!$VpWwI=m$ZY#+#TiJ%UBUa>-HohFEG@~;%}a%J+*3;wKm!SfR~F@$=AH`WL$AcescEVm8pMi+AOvlDYt75q|5s>)M~vQzaGLJ~_sLsAMZi6yC-sd+jIZkai`sbFJ4 z`J^ZnERzQoC@9KL%Paxep^%c8l$r@10|MCwDvCe}u`~}<@PKVPyeA1X-jk}}YW%keixOq>!FkkOnHnbQDsH z71BVXCz<(qnW;r3kl~%Y#N-Txa?oH*evv{kSVL)v9&&T47+i+rB$j}3ICLTiRGNUw zEN~bm=A|eg!Z0-jQV^!47AX|NLodHbAtygMF-IY>C^bhMpO>Cm1j;UuMpufCLQ-jo zLS~6VaY<%Qjsn=hpdqVDh4R##9ONJZ1x!vNC}HJg9$rxd8Q99q&jY2|oc#36OmB1kGsErA7K0VE?7D-<1GS)7_!S^*BW(h5C=!<~?jssay)re%T# z&WegbL#)N1z{@RGNG(xFQBN!_DFS88{JgxwD^rUVO7crf6jJjPAc+rD0;Q-!LarDZ za&Rl5p_Kf-p}V6b9g;1l(#(%`1hLJefHPY595J>LV`| zEl@~I0>zz~ zfq{a%6DY%_WEN*D6c;2Wrz#|-m4IvU%#zH+9B3X;&;YeAa#BlD6<`k3)Kl;SEqeg9 z2ogbq*Cil-<$>$+Oi0_HG%qE!D5o+rFI~YWGq1Elp}4ZRBsEt7oGD5a@=}vii;EMB zD)m59ETuUq3MCnt#R{pQ&`C-~Nb7*SQk<%gm<&=6$+e)iOj4>sX>n?b71H>4QGR}j zg1$mdVxCW;LYS{YaAjVy0x0=_>THnvNGi z21jIq=#;}Na}$e-ASS^Ixq|#6aL*5F5G0;JX$F*W!5J&RC?zv55!9vu)!v{6T^XpQ zQgV1@&f%5G8Hq*dsYQwK)Kgdr@lXlK2cXId+(Jq%QAkYBEJ_6B(h^VxEi6q%N+8$a4LWkU%o<$dMYH{rDPTtmZri|71%dA3Yj?y(EOiRtdMtjB`5`eng|Lh z>fkmsxP?#z3B;UKZ~{BLA~zM5-ttR}b+Zyd?E^i9;7nu#bQF@2i?S5;#N=d9u~Cp( zS_W#yK-vJ1Gyp2>u%|Lm|DTE3nSpr{voo#BnNjmcLtq$&z|b21D=q=g-{z&3g4XwO zfbu^h!)yi+8f0HuCm% zGYWDG3<`Ajc6K%mHHr-M^9=V1a18YJ2#Tf)Z$~)+ezv zFB#OVh8U^@nnO0!_0Gvm&ejbs$;{DHa4A*DPRz@Lh9ROZ^-Tm#)#jxtf>M(+ z@<3gQlEk8t)I8`kJ~&7el2X$%^YTg*@=EjIb!>5R2B=w<3Z3d#$Ve?J1r6j>mF6UZ z`dXm*^TfR3r^FWQbVg;qJ)S{Bq%$(FBC50+b|2Z!m)KAMpZoD~VmXxF_ zB&8-MXMmbmiN&DqZz`lI32tt{g9zNG0)>NT9;AT=>R6WMr7I*S>OrbhCxzgG)J#xJ z6yzk9RDn#*D=sK1P0j$#sDqnqu%!r~IrY>`@N{~5QD#~iXzV5>5i}40@t8tRW@o04-(6P0UfqJG`wVGhLxL zH8W2~p*UY5`|!3r(BykcW@>S&LR4vbVqSVNWc@=@DkxfELt@w~T+nb7IOT#M2C#mnwkTilxP%R&lBVIMF5Mz#GJn7CWA1T#*7e!GSukpi}~CiYh=- z8v_FaCpiBzRxvPE4YJ%n?48rf%)%$jVCyd}*rePf-(t;>lm;o+oDxe)a#GVWQ*%-j ze8Fo`q2j)NzP^rLZr*0zX3pkO;r@X^CLY0&5w1>=MlKPa#-2%OCZK^%Lr@7fV-Q9A1)B0?qi4!T~&t0xEaX@^irXBM(|6SScV|@06IL09tee>H`|-phy{` za!qs;P^3K)i&8*~T|z(|qJqTa?8Nj`h0MIjN5G1MQvA zRVYq{6w$?rxdl0?3Sc?Vz?~j1S7@F>Zhp$)70|JG(3T5u5M+W?f|FfYK4{Dq)DD4+ zD;1VzDinkIh=*6C7o|cvh#*;5D@BhNyt)fUdgds=hTV}z=RqUJiO_*J$gol|#LmRD z%sd59XByO12aUjkR*L4ODkN4yd)YaKrK#Z2^Ss2&;^M?SP;axKG_$xk6*LwNSy`Hw z2p(Y2QwRmET?3_=6wvqzbaV}poRD0VlbLjQWzpf4;J$W#Ze}rPj2JYGQ;=E;8?=T+ z>EV^&SO%3Au;Dz=C_=G9Y6Yn219lB$I1mz5r9}$h5Cu&KK$8ON(C~PEDQH5Y7|sVJ;G9$isI{O85}2dF=?voAM0oNlNGvMJOfJoVjBbL)Y7&!^ z^NUhIMuTQP5)rniq$8)d1_u}x6N^s z#Jp^n?McO;8IGLHE_9YW!Dzq3y@}HgpsGH=N z2WcULCsq^^K`UAFz@Z3fMSz#jLNlA5f>$aiv${bGZzO9#?F?_I72q5MZZ2nnyr2u| z0VS3er>7PtXC&s7q{5oddJ0ad#h|G`(1^WbX?kj2Zfa4s0(b@o6uux+gP}!vHf-uf zAw3l`os(F4cs^)iIv3tvhpcV}=ddD(tuD}!>S9nwFT&SH*A3LSC;^2TsDy_(L_rr6 z2cWgqnW+j+shQwKySgCtpnwMr((`gTDnN_Xa!3+W08Kf9W`#gGQlS!5P=V4Dq+|yV zZbMQOXx>Nx+=K^7<0{%qpj9z=&J|MeB!XO73AZ(|64W&T8=RO|nV**mF*H#jCll0| aRLIWDFV9I$NrzOb$)&|5`MIE_K)e8eD&cVe literal 0 HcmV?d00001 diff --git a/crates/lanspread-peer-cli/catalogs/unknown/manifests/catalog-content-index-v1.jsonl b/crates/lanspread-peer-cli/catalogs/unknown/manifests/catalog-content-index-v1.jsonl new file mode 100644 index 0000000..5fb7029 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/unknown/manifests/catalog-content-index-v1.jsonl @@ -0,0 +1 @@ +{"schema_version":1,"games":{"cod2":{"game_version":"20160922","content_id":"6f60950ccdb7ec12e8e9b68210bb345994490dd5ca761c063ac27a6caf250071","supports_streamed_install":false}}} diff --git a/crates/lanspread-peer-cli/catalogs/unknown/manifests/cod2.json b/crates/lanspread-peer-cli/catalogs/unknown/manifests/cod2.json new file mode 100644 index 0000000..389a7a6 --- /dev/null +++ b/crates/lanspread-peer-cli/catalogs/unknown/manifests/cod2.json @@ -0,0 +1,28 @@ +{ + "schema_version": 1, + "game_id": "cod2", + "game_version": "20160922", + "chunk_size": 134217728, + "files": [ + { + "canonical_path": "catalog-unknown.txt", + "kind": "file", + "size": 73, + "file_blake3": "11bd8035dcf904c547896565bbc8b9ae47b1044ac21ebf732a51820caddfc819", + "chunk_blake3": [ + "11bd8035dcf904c547896565bbc8b9ae47b1044ac21ebf732a51820caddfc819" + ] + }, + { + "canonical_path": "version.ini", + "kind": "file", + "size": 9, + "file_blake3": "91c51abb1c7e7a6426bc9003603608587648bd1fed32b0cf3e81bc5ad76531b4", + "chunk_blake3": [ + "91c51abb1c7e7a6426bc9003603608587648bd1fed32b0cf3e81bc5ad76531b4" + ] + } + ], + "streamed_install_files": [], + "content_id": "6f60950ccdb7ec12e8e9b68210bb345994490dd5ca761c063ac27a6caf250071" +} diff --git a/crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2/catalog-unknown.txt b/crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2/catalog-unknown.txt new file mode 100644 index 0000000..129e7df --- /dev/null +++ b/crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2/catalog-unknown.txt @@ -0,0 +1 @@ +This package is known only to the source peer's fixture catalog profile. diff --git a/crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2/version.ini b/crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2/version.ini new file mode 100644 index 0000000..b113d99 --- /dev/null +++ b/crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2/version.ini @@ -0,0 +1 @@ +20160922 diff --git a/crates/lanspread-peer-cli/src/lib.rs b/crates/lanspread-peer-cli/src/lib.rs index 798774a..84de151 100644 --- a/crates/lanspread-peer-cli/src/lib.rs +++ b/crates/lanspread-peer-cli/src/lib.rs @@ -3,17 +3,27 @@ #![allow(clippy::missing_errors_doc)] use std::{ + fmt::Write as _, net::SocketAddr, path::{Path, PathBuf}, time::Duration, }; use eyre::{Context, OptionExt}; -use lanspread_peer::{CallToPlayEvent, UnpackFuture, Unpacker}; +use lanspread_peer::{ + CallToPlayLocalIntent, + PeerEndpoint, + PeerId, + ScopedProcess, + UnpackFuture, + Unpacker, +}; use serde::Serialize; use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; pub const DEFAULT_FIXTURE_VERSION: &str = "20250101"; +const EXTERNAL_UNRAR_CAPTURE_LIMIT: usize = 64 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandEnvelope { @@ -28,7 +38,10 @@ pub enum CliCommand { ListGames, ListCallToPlay, PublishCallToPlay { - event: CallToPlayEvent, + intent: CallToPlayLocalIntent, + }, + SetCallToPlayDisplayName { + display_name: String, }, SetGameDir { path: PathBuf, @@ -39,6 +52,9 @@ pub enum CliCommand { }, StreamInstall { game_id: String, + account_name: Option, + language: Option, + persona_name: Option, }, CancelDownload { game_id: String, @@ -59,7 +75,7 @@ pub enum CliCommand { timeout: Duration, }, Connect { - addr: SocketAddr, + endpoint: PeerEndpoint, }, Shutdown, } @@ -73,6 +89,7 @@ impl CliCommand { Self::ListGames => "list-games", Self::ListCallToPlay => "list-call-to-play", Self::PublishCallToPlay { .. } => "publish-call-to-play", + Self::SetCallToPlayDisplayName { .. } => "set-call-to-play-display-name", Self::SetGameDir { .. } => "set-game-dir", Self::Download { .. } => "download", Self::StreamInstall { .. } => "stream-install", @@ -110,13 +127,16 @@ pub fn parse_command_value(value: &Value) -> eyre::Result { "list-games" => CliCommand::ListGames, "list-call-to-play" => CliCommand::ListCallToPlay, "publish-call-to-play" => CliCommand::PublishCallToPlay { - event: serde_json::from_value( + intent: serde_json::from_value( object - .get("event") + .get("intent") .cloned() - .ok_or_eyre("publish-call-to-play must include event")?, + .ok_or_eyre("publish-call-to-play must include intent")?, ) - .wrap_err("invalid Call to Play event")?, + .wrap_err("invalid Call to Play intent")?, + }, + "set-call-to-play-display-name" => CliCommand::SetCallToPlayDisplayName { + display_name: required_str(object, "display_name")?, }, "set-game-dir" => CliCommand::SetGameDir { path: PathBuf::from(required_str(object, "path")?), @@ -127,6 +147,9 @@ pub fn parse_command_value(value: &Value) -> eyre::Result { }, "stream-install" => CliCommand::StreamInstall { game_id: game_id(object)?, + account_name: optional_str(object, "account_name")?, + language: optional_str(object, "language")?, + persona_name: optional_str(object, "persona_name")?, }, "cancel-download" => CliCommand::CancelDownload { game_id: game_id(object)?, @@ -151,11 +174,17 @@ pub fn parse_command_value(value: &Value) -> eyre::Result { .wrap_err("count does not fit in usize")?, timeout: Duration::from_millis(required_u64(object, "timeout_ms")?), }, - "connect" | "direct-connect" => CliCommand::Connect { - addr: required_str(object, "addr")? - .parse() - .wrap_err("addr must be a socket address like 127.0.0.1:12345")?, - }, + "connect" | "direct-connect" => { + let peer_id = required_str(object, "peer_id")? + .parse::() + .wrap_err("peer_id is not a canonical peer ID")?; + let addr = required_str(object, "addr")? + .parse::() + .wrap_err("addr must be a socket address like 127.0.0.1:12345")?; + CliCommand::Connect { + endpoint: PeerEndpoint::new(peer_id, addr), + } + } "shutdown" => CliCommand::Shutdown, other => eyre::bail!("unknown command: {other}"), }; @@ -177,6 +206,21 @@ fn required_str( .ok_or_else(|| eyre::eyre!("missing string field {field}")) } +fn optional_str( + object: &serde_json::Map, + field: &'static str, +) -> eyre::Result> { + object + .get(field) + .map(|value| { + value + .as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| eyre::eyre!("{field} must be a string when provided")) + }) + .transpose() +} + fn required_u64(object: &serde_json::Map, field: &'static str) -> eyre::Result { object .get(field) @@ -241,11 +285,28 @@ pub fn seed_fixture_game(game_dir: &Path, fixture_name: &str) -> eyre::Result(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { + fn unpack<'a>( + &'a self, + archive: &'a Path, + dest: &'a Path, + cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { Box::pin(async move { - tokio::fs::create_dir_all(dest).await?; - let payload = tokio::fs::read(archive).await?; - tokio::fs::write(dest.join("fixture-payload.txt"), payload).await?; + if cancel_token.is_cancelled() { + eyre::bail!("fixture extraction for {} was cancelled", archive.display()); + } + // These fixture files are deliberately tiny. Keep each operation + // lexical: selecting against Tokio's filesystem helpers could drop + // their futures while blocking-pool work still touches staging. + std::fs::create_dir_all(dest)?; + if cancel_token.is_cancelled() { + eyre::bail!("fixture extraction for {} was cancelled", archive.display()); + } + let payload = std::fs::read(archive)?; + if cancel_token.is_cancelled() { + eyre::bail!("fixture extraction for {} was cancelled", archive.display()); + } + std::fs::write(dest.join("fixture-payload.txt"), payload)?; Ok(()) }) } @@ -263,20 +324,46 @@ impl ExternalUnrarUnpacker { } impl Unpacker for ExternalUnrarUnpacker { - fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { + fn unpack<'a>( + &'a self, + archive: &'a Path, + dest: &'a Path, + cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { Box::pin(async move { - tokio::fs::create_dir_all(dest).await?; - let status = tokio::process::Command::new(&self.program) - .arg("x") - .arg("-o+") - .arg(archive) - .arg(dest) - .status() - .await?; - if !status.success() { + if cancel_token.is_cancelled() { + eyre::bail!("unrar extraction for {} was cancelled", archive.display()); + } + // Directory creation is small and synchronous; keeping it lexical + // prevents a dropped Tokio fs future from outliving rollback. + std::fs::create_dir_all(dest)?; + if cancel_token.is_cancelled() { + eyre::bail!("unrar extraction for {} was cancelled", archive.display()); + } + + let process = ScopedProcess::spawn( + &self.program, + [ + std::ffi::OsString::from("x"), + std::ffi::OsString::from("-o+"), + std::ffi::OsString::from("-p-"), + archive.as_os_str().to_owned(), + dest.as_os_str().to_owned(), + ], + &cancel_token, + EXTERNAL_UNRAR_CAPTURE_LIMIT, + )?; + let output = process.wait().await?; + if !output.status.success() { eyre::bail!( - "unrar failed for {} with status {status}", - archive.display() + "unrar failed for {} with status {}: {}", + archive.display(), + output.status, + format_captured_process_output( + &output.stderr, + output.stderr_truncated, + "stderr" + ) ); } Ok(()) @@ -284,6 +371,14 @@ impl Unpacker for ExternalUnrarUnpacker { } } +fn format_captured_process_output(bytes: &[u8], truncated: bool, stream: &str) -> String { + let mut output = String::from_utf8_lossy(bytes).into_owned(); + if truncated { + let _ = write!(output, "\n[{stream} truncated]"); + } + output +} + pub fn result_line(id: &Option, command: &str, data: Value) -> eyre::Result { output_line(json!({ "type": "result", @@ -317,6 +412,8 @@ fn output_line(value: Value) -> eyre::Result { #[cfg(test)] mod tests { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; use std::sync::Arc; use super::*; @@ -374,6 +471,14 @@ mod tests { assert_eq!(parsed["data"]["peer_count"], 0); } + #[test] + fn captured_process_output_marks_truncation() { + assert_eq!( + format_captured_process_output(b"partial", true, "stderr"), + "partial\n[stderr truncated]" + ); + } + #[test] fn parses_stream_install_command() { let parsed = parse_command_line(r#"{"cmd":"stream-install","game_id":"cnctw"}"#) @@ -383,6 +488,27 @@ mod tests { parsed.command, CliCommand::StreamInstall { game_id: "cnctw".to_string(), + account_name: None, + language: None, + persona_name: None, + } + ); + } + + #[test] + fn parses_optional_stream_install_settings_without_sanitizing_them() { + let parsed = parse_command_line( + r#"{"cmd":"stream-install","game_id":"cnctw","account_name":" Alice% ","language":"DE","persona_name":" Player% "}"#, + ) + .expect("command should parse"); + + assert_eq!( + parsed.command, + CliCommand::StreamInstall { + game_id: "cnctw".to_string(), + account_name: Some(" Alice% ".to_string()), + language: Some("DE".to_string()), + persona_name: Some(" Player% ".to_string()), } ); } @@ -401,18 +527,103 @@ mod tests { } #[test] - fn parses_call_to_play_event_command() { + fn parses_connect_as_an_authenticated_peer_endpoint() { let parsed = parse_command_line( - r#"{"cmd":"publish-call-to-play","event":{"id":"event-1","call_id":"call-1","actor_id":"","actor_name":"Alice","at":1000,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#, + r#"{"cmd":"connect","peer_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","addr":"127.0.0.1:34567"}"#, + ) + .expect("authenticated connect command should parse"); + + assert_eq!( + parsed.command, + CliCommand::Connect { + endpoint: PeerEndpoint::new( + PeerId::from_bytes([0; 32]), + "127.0.0.1:34567" + .parse() + .expect("test socket address should parse"), + ), + } + ); + } + + #[test] + fn rejects_address_only_connect() { + let error = parse_command_line(r#"{"cmd":"connect","addr":"127.0.0.1:34567"}"#) + .expect_err("connect must require an authenticated peer identity"); + + assert!(error.to_string().contains("missing string field peer_id")); + } + + #[test] + fn rejects_noncanonical_connect_peer_id() { + let error = parse_command_line( + r#"{"cmd":"connect","peer_id":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","addr":"127.0.0.1:34567"}"#, + ) + .expect_err("connect must reject noncanonical peer IDs"); + + assert!(error.to_string().contains("canonical peer ID")); + } + + #[test] + fn parses_intent_only_call_to_play_command() { + let parsed = parse_command_line( + r#"{"cmd":"publish-call-to-play","intent":{"call_id":null,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#, ) .expect("command should parse"); - let CliCommand::PublishCallToPlay { event } = parsed.command else { + let CliCommand::PublishCallToPlay { intent } = parsed.command else { panic!("expected PublishCallToPlay"); }; - assert_eq!(event.id, "event-1"); - assert_eq!(event.call_id, "call-1"); - assert_eq!(event.actor_name, "Alice"); + assert!(intent.call_id.is_none()); + assert!(matches!( + intent.action, + lanspread_peer::CallToPlayLocalAction::Create { + game_id, + max_players: 4, + scheduled_for: None, + deadline: 61_000, + } if game_id == "game-1" + )); + } + + #[test] + fn parses_existing_call_rsvp_and_chat_intents_used_by_late_join_scenarios() { + let call_id = format!("{}.{}", "a".repeat(52), "ab".repeat(16)); + let rsvp = parse_command_line(&format!( + r#"{{"cmd":"publish-call-to-play","intent":{{"call_id":"{call_id}","action":"Rsvp"}}}}"#, + )) + .expect("RSVP intent should parse"); + let chat = parse_command_line(&format!( + r#"{{"cmd":"publish-call-to-play","intent":{{"call_id":"{call_id}","action":{{"SendMessage":{{"text":"I am in"}}}}}}}}"#, + )) + .expect("chat intent should parse"); + + let CliCommand::PublishCallToPlay { intent: rsvp } = rsvp.command else { + panic!("expected RSVP PublishCallToPlay"); + }; + let CliCommand::PublishCallToPlay { intent: chat } = chat.command else { + panic!("expected chat PublishCallToPlay"); + }; + assert_eq!(rsvp.call_id, chat.call_id); + assert!(matches!( + rsvp.action, + lanspread_peer::CallToPlayLocalAction::Rsvp + )); + assert!(matches!( + chat.action, + lanspread_peer::CallToPlayLocalAction::SendMessage { text } + if text == "I am in" + )); + } + + #[test] + fn rejects_client_authored_call_to_play_event_fields() { + let error = parse_command_line( + r#"{"cmd":"publish-call-to-play","intent":{"call_id":null,"id":"event-1","at":1000,"action":{"Rsvp":null}}}"#, + ) + .expect_err("client-authored event identity and time must be rejected"); + + assert!(error.to_string().contains("invalid Call to Play intent")); } #[tokio::test] @@ -421,7 +632,7 @@ mod tests { let seed = seed_fixture_game(temp.path(), "fixture-one").expect("fixture should seed"); let dest = temp.path().join("staging"); Arc::new(FixtureUnpacker) - .unpack(&seed.archive, &dest) + .unpack(&seed.archive, &dest, CancellationToken::new()) .await .expect("fixture archive should unpack"); @@ -429,4 +640,126 @@ mod tests { .expect("payload should be written"); assert!(payload.contains("fixture-one")); } + + #[cfg(unix)] + fn controlled_unrar(temp: &TempDir) -> (Arc, PathBuf, PathBuf) { + let program = temp.path().join("controlled-unrar"); + std::fs::write( + &program, + r#"#!/bin/sh +set -eu +[ "$3" = "-p-" ] +dest=$5 +printf '%s' "$$" > "$dest/child.pid" +printf 'started' > "$dest/started" +while [ ! -e "$dest/release" ]; do :; done +printf 'late' > "$dest/late-canary" +"#, + ) + .expect("controlled unrar should be written"); + let mut permissions = std::fs::metadata(&program) + .expect("controlled unrar metadata should be readable") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&program, permissions) + .expect("controlled unrar should be executable"); + + let archive = temp.path().join("archive.eti"); + std::fs::write(&archive, b"fixture").expect("archive should be written"); + let destination = temp.path().join("destination"); + ( + Arc::new(ExternalUnrarUnpacker::new(program)), + archive, + destination, + ) + } + + #[cfg(unix)] + async fn wait_for_path(path: &Path) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while !path.exists() { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("controlled child marker should appear"); + } + + #[cfg(target_os = "linux")] + async fn wait_for_process_exit(pid: u32) { + let process = PathBuf::from(format!("/proc/{pid}")); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while process.exists() { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("controlled child should no longer exist"); + } + + #[cfg(target_os = "linux")] + fn controlled_child_pid(destination: &Path) -> u32 { + std::fs::read_to_string(destination.join("child.pid")) + .expect("controlled child pid should be readable") + .parse() + .expect("controlled child pid should be numeric") + } + + #[cfg(unix)] + #[tokio::test] + async fn external_unrar_cancellation_kills_and_reaps_before_return() { + let temp = TempDir::new("lanspread-peer-cli-unrar-cancel"); + let (unpacker, archive, destination) = controlled_unrar(&temp); + let cancel_token = CancellationToken::new(); + let task_cancel_token = cancel_token.clone(); + let task_destination = destination.clone(); + let task = tokio::spawn(async move { + unpacker + .unpack(&archive, &task_destination, task_cancel_token) + .await + }); + + wait_for_path(&destination.join("started")).await; + #[cfg(target_os = "linux")] + let pid = controlled_child_pid(&destination); + cancel_token.cancel(); + let error = tokio::time::timeout(std::time::Duration::from_secs(2), task) + .await + .expect("cancelled unpack should settle") + .expect("unpack task should not panic") + .expect_err("cancelled unpack should fail"); + assert!(error.to_string().contains("cancelled")); + + #[cfg(target_os = "linux")] + wait_for_process_exit(pid).await; + std::fs::write(destination.join("release"), b"").expect("release marker should be written"); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + assert!(!destination.join("late-canary").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn dropping_external_unrar_future_kills_child() { + let temp = TempDir::new("lanspread-peer-cli-unrar-drop"); + let (unpacker, archive, destination) = controlled_unrar(&temp); + let task_destination = destination.clone(); + let task = tokio::spawn(async move { + unpacker + .unpack(&archive, &task_destination, CancellationToken::new()) + .await + }); + + wait_for_path(&destination.join("started")).await; + #[cfg(target_os = "linux")] + let pid = controlled_child_pid(&destination); + task.abort(); + task.await + .expect_err("aborted unpack task should be cancelled"); + + #[cfg(target_os = "linux")] + wait_for_process_exit(pid).await; + std::fs::write(destination.join("release"), b"").expect("release marker should be written"); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + assert!(!destination.join("late-canary").exists()); + } } diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index aa8582c..21fc5ad 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -1,33 +1,48 @@ //! JSONL command-line harness for running a peer without the Tauri GUI. +#[cfg(unix)] +use std::io::{Cursor, Read as _}; use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, ffi::OsString, - io::Write as _, + future::Future, + io::{self, Write as _}, net::SocketAddr, path::{Path, PathBuf}, + pin::Pin, sync::{Arc, Mutex}, + task::{Context as TaskContext, Poll}, time::{Duration, Instant}, }; use eyre::Context; -use lanspread_compat::eti::get_games; -use lanspread_db::db::{Game, GameCatalog, GameFileDescription}; +use lanspread_compat::catalog_bundle::load_catalog_bundle; +use lanspread_db::{ + content_manifest::CatalogBundle, + db::{Game, GameDB}, +}; use lanspread_peer::{ ActiveOperation, ActiveOperationKind, - CallToPlayEvent, + CallToPlayView, + DownloadAttemptId, + DownloadAttemptKey, + DownloadFailureReason, ExternalUnrarStreamProvider, NoopStreamInstallProvider, OutboundTransfers, PeerCommand, + PeerEndpoint, PeerEvent, PeerGameDB, + PeerIdentity, PeerRuntimeComponent, - PeerRuntimeHandle, PeerSnapshot, PeerStartOptions, + RemoteLibraryView, StreamInstallProvider, + StreamInstallSettings, + load_peer_identity, migrate_legacy_state, start_peer_with_options, }; @@ -46,8 +61,9 @@ use lanspread_peer_cli::{ }; use serde_json::{Value, json}; use tokio::{ - io::{AsyncBufReadExt, BufReader}, + io::{AsyncBufReadExt, AsyncRead, BufReader, ReadBuf}, sync::{Notify, RwLock, mpsc, oneshot}, + task::{JoinError, JoinHandle}, }; #[derive(Debug)] @@ -55,7 +71,9 @@ struct Args { name: String, games_dir: PathBuf, state_dir: PathBuf, - catalog_db: Option, + identity_file: Option, + catalog_db: PathBuf, + manifests_dir: PathBuf, fixtures: Vec, unrar: Option, } @@ -99,10 +117,8 @@ struct CliState { local_games: Vec, remote_games: Vec, active_operations: Vec, - game_files: HashMap>, - unavailable_games: HashSet, downloads: HashMap, - call_to_play_events: Vec, + call_to_play_view: CallToPlayView, } #[derive(Clone, serde::Serialize)] @@ -112,6 +128,7 @@ struct LocalPeer { } struct DownloadMeasurement { + attempt_id: DownloadAttemptId, started_at: Instant, bytes: u64, chunks: u64, @@ -120,26 +137,180 @@ struct DownloadMeasurement { struct SharedState { state: RwLock, peer_game_db: Arc>, - catalog: Arc>, + catalog_game_db: Arc, + catalog_bundle: Option>, + call_to_play_display_name: RwLock, active_outbound_transfers: OutboundTransfers, notify: Notify, - games_dir: PathBuf, + games_dir: RwLock, state_dir: PathBuf, } +#[derive(Debug)] +enum CommandLoopExit { + Eof, + Shutdown { request_id: Option }, + Signal, +} + +enum CommandInput { + #[cfg(unix)] + Pollable(UnixStdin), + #[cfg(unix)] + Buffered(Cursor>), + #[cfg(not(unix))] + Blocking(tokio::io::Stdin), +} + +impl CommandInput { + #[cfg(unix)] + fn open() -> io::Result { + let duplicate = rustix::io::dup(std::io::stdin()).map_err(io::Error::from)?; + Self::from_unix_file(std::fs::File::from(duplicate)) + } + + #[cfg(unix)] + fn from_unix_file(mut file: std::fs::File) -> io::Result { + // Tokio reactors do not support ordinary files on every Unix. Read a + // finite redirected command file before the peer runtime owns any + // resources; a pipe or terminal remains incremental and pollable. + if file.metadata()?.is_file() { + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + return Ok(Self::Buffered(Cursor::new(contents))); + } + + UnixStdin::new(file).map(Self::Pollable) + } + + #[cfg(not(unix))] + #[allow( + clippy::unnecessary_wraps, + reason = "keeps platform-specific stdin construction behind one fallible API" + )] + fn open() -> io::Result { + Ok(Self::Blocking(tokio::io::stdin())) + } +} + +impl AsyncRead for CommandInput { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + #[cfg(unix)] + Self::Pollable(stdin) => Pin::new(stdin).poll_read(cx, buffer), + #[cfg(unix)] + Self::Buffered(cursor) => Pin::new(cursor).poll_read(cx, buffer), + #[cfg(not(unix))] + Self::Blocking(stdin) => Pin::new(stdin).poll_read(cx, buffer), + } + } +} + +#[cfg(unix)] +struct UnixStdin { + inner: tokio::io::unix::AsyncFd, + #[cfg(test)] + poll_started: Option>, +} + +#[cfg(unix)] +impl UnixStdin { + fn new(file: std::fs::File) -> io::Result { + use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl}; + + let flags = fcntl_getfl(&file).map_err(io::Error::from)?; + fcntl_setfl(&file, flags | OFlags::NONBLOCK).map_err(io::Error::from)?; + let inner = tokio::io::unix::AsyncFd::with_interest(file, tokio::io::Interest::READABLE)?; + Ok(Self { + inner, + #[cfg(test)] + poll_started: None, + }) + } + + #[cfg(test)] + fn with_poll_started(mut self, poll_started: Arc) -> Self { + self.poll_started = Some(poll_started); + self + } +} + +#[cfg(unix)] +impl AsyncRead for UnixStdin { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + if buffer.remaining() == 0 { + return Poll::Ready(Ok(())); + } + + #[cfg(test)] + if let Some(poll_started) = &self.poll_started { + poll_started.notify_one(); + } + + loop { + let mut ready = std::task::ready!(self.inner.poll_read_ready_mut(cx))?; + match ready.try_io(|inner| { + rustix::io::read(inner.get_ref(), buffer.initialize_unfilled()) + .map_err(io::Error::from) + }) { + Ok(Ok(read)) => { + buffer.advance(read); + return Poll::Ready(Ok(())); + } + Ok(Err(error)) if error.kind() == io::ErrorKind::Interrupted => {} + Ok(Err(error)) => return Poll::Ready(Err(error)), + Err(_would_block) => {} + } + } + } +} + +#[derive(Debug)] +enum CancelledInputExit { + Signal, + Failure(eyre::Report), +} + +#[derive(Debug)] +enum CliScopeOutcome { + Command(CommandLoopExit), + CancelledInput(CancelledInputExit), +} + +#[cfg(unix)] +const PLATFORM_INPUT_CANCELLATION_IS_QUIESCENT: bool = true; + +// Tokio's non-Unix stdin uses an uncancellable blocking worker. Once its +// command future has been aborted, process termination is the only ownership +// boundary that cannot hang while dropping the runtime. +#[cfg(not(unix))] +const PLATFORM_INPUT_CANCELLATION_IS_QUIESCENT: bool = false; + #[tokio::main] async fn main() -> eyre::Result<()> { let args = parse_args()?; - tokio::fs::create_dir_all(&args.games_dir).await?; - tokio::fs::create_dir_all(&args.state_dir).await?; + let explicit_identity = load_explicit_identity(args.identity_file.as_deref())?; + std::fs::create_dir_all(&args.games_dir)?; + std::fs::create_dir_all(&args.state_dir)?; + let command_input = + CommandInput::open().wrap_err("failed to prepare an interruptible stdin command source")?; + let (catalog_game_db, catalog_bundle) = + load_catalog_authority(&args.catalog_db, &args.manifests_dir, &args.fixtures).await?; let fixture_seeds = seed_fixtures(&args.games_dir, &args.fixtures)?; - let catalog = load_catalog(args.catalog_db.as_deref(), &fixture_seeds).await; let migration = migrate_legacy_state(&args.games_dir, &args.state_dir).await; let (tx_events, rx_events) = mpsc::unbounded_channel(); let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - let catalog = Arc::new(RwLock::new(catalog)); + let catalog_game_db = Arc::new(catalog_game_db); let active_outbound_transfers: OutboundTransfers = Arc::new(RwLock::new(HashMap::new())); let unrar_for_streaming = args.unrar.clone().or_else(default_unrar_program); let unpacker: Arc = match args.unrar.clone() { @@ -151,55 +322,97 @@ async fn main() -> eyre::Result<()> { None => Arc::new(NoopStreamInstallProvider), }; - let mut handle = start_peer_with_options( + let handle = start_peer_with_options( args.games_dir.clone(), tx_events, peer_game_db.clone(), unpacker, - catalog.clone(), + Arc::clone(&catalog_bundle), PeerStartOptions { state_dir: Some(args.state_dir.clone()), + identity: explicit_identity, active_outbound_transfers: Some(active_outbound_transfers.clone()), stream_install_provider: Some(stream_install_provider), + local_network_sharing: true, }, )?; let sender = handle.sender(); + let accepted_game_dir = handle.accepted_game_dir().to_path_buf(); let shared = Arc::new(SharedState { state: RwLock::new(CliState::default()), peer_game_db, - catalog: catalog.clone(), + catalog_game_db, + catalog_bundle: Some(catalog_bundle), + call_to_play_display_name: RwLock::new(args.name.clone()), active_outbound_transfers, notify: Notify::new(), - games_dir: args.games_dir.clone(), + games_dir: RwLock::new(accepted_game_dir.clone()), state_dir: args.state_dir.clone(), }); let writer = JsonlWriter::new(); - tokio::spawn(event_loop(rx_events, shared.clone(), writer.clone())); - writer.emit(event_line( "cli-started", json!({ "name": args.name, - "games_dir": args.games_dir, + "games_dir": accepted_game_dir, "state_dir": args.state_dir, + "catalog_db": args.catalog_db, + "manifests_dir": args.manifests_dir, "migration": migration, "fixtures": fixture_seeds, }), )); - command_loop(&sender, &mut handle, shared, writer).await + let command_task = tokio::spawn(command_loop( + command_input, + sender, + shared.clone(), + writer.clone(), + )); + let event_task = tokio::spawn(event_loop(rx_events, shared, writer.clone())); + let shutdown = async move { + let mut handle = handle; + handle.shutdown(); + handle.wait_stopped().await; + }; + let outcome = join_cli_scope(command_task, event_task, shutdown_signal(), shutdown).await?; + let exit = finish_cli_scope( + outcome, + PLATFORM_INPUT_CANCELLATION_IS_QUIESCENT, + |exit_code, failure| { + if let Some(failure) = failure { + eprintln!("peer CLI stopped exceptionally: {failure:#}"); + } + std::process::exit(exit_code); + }, + )?; + + if let CommandLoopExit::Shutdown { request_id } = exit { + writer.emit(result_line( + &request_id, + "shutdown", + json!({"stopped": true}), + )); + } + Ok(()) +} + +fn load_explicit_identity(path: Option<&Path>) -> eyre::Result>> { + path.map(load_peer_identity) + .transpose() + .wrap_err("failed to load explicitly selected peer identity") + .map(|identity| identity.map(Arc::new)) } async fn command_loop( - sender: &mpsc::UnboundedSender, - handle: &mut PeerRuntimeHandle, + input: CommandInput, + sender: mpsc::UnboundedSender, shared: Arc, writer: JsonlWriter, -) -> eyre::Result<()> { - let stdin = BufReader::new(tokio::io::stdin()); - let mut lines = stdin.lines(); +) -> eyre::Result { + let mut lines = BufReader::new(input).lines(); while let Some(line) = lines.next_line().await? { if line.trim().is_empty() { @@ -214,13 +427,16 @@ async fn command_loop( } }; + if matches!(&envelope.command, CliCommand::Shutdown) { + return Ok(CommandLoopExit::Shutdown { + request_id: envelope.request_id, + }); + } + let command_name = envelope.command.name(); - match handle_command(&envelope, sender, handle, &shared).await { + match handle_command(&envelope, &sender, &shared).await { Ok(data) => { writer.emit(result_line(&envelope.request_id, command_name, data)); - if matches!(envelope.command, CliCommand::Shutdown) { - break; - } } Err(err) => { writer.emit(error_line( @@ -232,13 +448,13 @@ async fn command_loop( } } - Ok(()) + Ok(CommandLoopExit::Eof) } +#[allow(clippy::too_many_lines)] async fn handle_command( envelope: &CommandEnvelope, sender: &mpsc::UnboundedSender, - handle: &mut PeerRuntimeHandle, shared: &Arc, ) -> eyre::Result { match &envelope.command { @@ -247,59 +463,80 @@ async fn handle_command( CliCommand::ListGames => list_games(shared).await, CliCommand::ListCallToPlay => { let (reply, result) = oneshot::channel(); - sender.send(PeerCommand::GetCallToPlayEvents { reply: Some(reply) })?; - let events = tokio::time::timeout(Duration::from_secs(1), result) + sender.send(PeerCommand::GetCallToPlayView { reply: Some(reply) })?; + let view = tokio::time::timeout(Duration::from_secs(1), result) .await - .wrap_err("timed out waiting for Call to Play history")? - .wrap_err("peer stopped before returning Call to Play history")?; - Ok(json!({ "events": events })) + .wrap_err("timed out waiting for Call to Play view")? + .wrap_err("peer stopped before returning Call to Play view")? + .map_err(eyre::Report::msg)?; + Ok(json!({ "view": view })) } - CliCommand::PublishCallToPlay { event } => { + CliCommand::PublishCallToPlay { intent } => { let (reply, result) = oneshot::channel(); - sender.send(PeerCommand::PublishCallToPlay { - event: event.clone(), + sender.send(PeerCommand::ApplyCallToPlayIntent { + intent: intent.clone(), + display_name: shared.call_to_play_display_name.read().await.clone(), reply, })?; - result + let receipt = result .await - .wrap_err("peer stopped before publishing Call to Play event")? + .wrap_err("peer stopped before applying Call to Play intent")? .map_err(eyre::Report::msg)?; - Ok(json!({"published": true, "event_id": event.id})) + Ok(json!({"published": true, "receipt": receipt})) } - CliCommand::SetGameDir { path } => { - sender.send(PeerCommand::SetGameDir(path.clone()))?; - Ok(json!({"queued": true, "path": path})) + CliCommand::SetCallToPlayDisplayName { display_name } => { + let (reply, result) = oneshot::channel(); + sender.send(PeerCommand::SetCallToPlayDisplayName { + display_name: display_name.clone(), + reply, + })?; + let changed = result + .await + .wrap_err("peer stopped before updating the Call to Play display name")? + .map_err(eyre::Report::msg)?; + *shared.call_to_play_display_name.write().await = display_name.clone(); + Ok(json!({"changed": changed, "display_name": display_name})) } + CliCommand::SetGameDir { path } => set_game_dir(sender, shared, path).await, CliCommand::Download { game_id, install_after_download, } => { - ensure_catalog_game(shared, game_id).await?; + ensure_catalog_game(shared, game_id)?; ensure_no_active_operation(shared, game_id).await?; - game_files_for_download(sender, shared, game_id).await?; sender.send(PeerCommand::DownloadGameFilesWithOptions { id: game_id.clone(), install_after_download: *install_after_download, })?; Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download})) } - CliCommand::StreamInstall { game_id } => { - ensure_catalog_game(shared, game_id).await?; + CliCommand::StreamInstall { + game_id, + account_name, + language, + persona_name, + } => { + ensure_catalog_game(shared, game_id)?; ensure_no_active_operation(shared, game_id).await?; sender.send(PeerCommand::StreamInstallGame { id: game_id.clone(), + settings: StreamInstallSettings::sanitized( + account_name.as_deref(), + language.as_deref(), + persona_name.as_deref(), + ), })?; Ok(json!({"queued": true, "game_id": game_id})) } CliCommand::CancelDownload { game_id } => { - ensure_catalog_game(shared, game_id).await?; + ensure_catalog_game(shared, game_id)?; sender.send(PeerCommand::CancelDownload { id: game_id.clone(), })?; Ok(json!({"queued": true, "game_id": game_id})) } CliCommand::Install { game_id } => { - ensure_catalog_game(shared, game_id).await?; + ensure_catalog_game(shared, game_id)?; ensure_no_active_operation(shared, game_id).await?; sender.send(PeerCommand::InstallGame { id: game_id.clone(), @@ -312,7 +549,7 @@ async fn handle_command( language, } => play(shared, game_id, username, language.as_deref()).await, CliCommand::Uninstall { game_id } => { - ensure_catalog_game(shared, game_id).await?; + ensure_catalog_game(shared, game_id)?; ensure_no_active_operation(shared, game_id).await?; sender.send(PeerCommand::UninstallGame { id: game_id.clone(), @@ -320,21 +557,208 @@ async fn handle_command( Ok(json!({"queued": true, "game_id": game_id})) } CliCommand::WaitPeers { count, timeout } => wait_peers(shared, *count, *timeout).await, - CliCommand::Connect { addr } => { - ensure_not_self_connect(shared, *addr).await?; - sender.send(PeerCommand::ConnectPeer(*addr))?; - Ok(json!({"queued": true, "addr": addr.to_string()})) + CliCommand::Connect { endpoint } => { + ensure_not_self_connect(shared, *endpoint).await?; + sender.send(PeerCommand::ConnectPeer(*endpoint))?; + Ok(json!({ + "queued": true, + "peer_id": endpoint.peer_id, + "addr": endpoint.addr.to_string(), + })) } - CliCommand::Shutdown => { - handle.shutdown(); - tokio::time::timeout(Duration::from_secs(5), handle.wait_stopped()) - .await - .wrap_err("timed out waiting for peer runtime shutdown")?; - Ok(json!({"stopped": true})) + CliCommand::Shutdown => eyre::bail!("shutdown must be finalized by the CLI task scope"), + } +} + +async fn shutdown_signal() -> eyre::Result<()> { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + + let mut interrupt = signal(SignalKind::interrupt())?; + let mut terminate = signal(SignalKind::terminate())?; + let received = tokio::select! { + received = interrupt.recv() => received, + received = terminate.recv() => received, + }; + if received.is_none() { + eyre::bail!("CLI shutdown signal stream closed unexpectedly"); + } + Ok(()) + } + + #[cfg(not(unix))] + { + tokio::signal::ctrl_c() + .await + .wrap_err("failed to listen for Ctrl-C") + } +} + +/// Joins the command loop, event loop, and signal listener around one +/// unconditional peer-runtime finalizer. +/// +/// The two loops are spawned only so Tokio can turn their panics and explicit +/// cancellation into `JoinError` values. Their handles remain owned here until +/// both tasks have settled. Peer shutdown always runs before either outcome is +/// returned to `main`. +async fn join_cli_scope( + mut command_task: JoinHandle>, + mut event_task: JoinHandle<()>, + signal: Signal, + shutdown: Shutdown, +) -> eyre::Result +where + Signal: Future>, + Shutdown: Future, +{ + enum FirstTask { + Command(Result, JoinError>), + Events(Result<(), JoinError>), + Signal(eyre::Result<()>), + } + + let first = tokio::select! { + result = &mut command_task => FirstTask::Command(result), + result = &mut event_task => FirstTask::Events(result), + result = signal => FirstTask::Signal(result), + }; + + match first { + FirstTask::Command(command_result) => { + // The peer owns every event sender. Stopping it first lets the + // still-running event loop drain terminal JSONL events and then + // finish naturally when the channel closes. + shutdown.await; + let event_result = event_task.await; + + let exit = command_result.wrap_err("peer command loop task failed to join")??; + event_result.wrap_err("peer event loop failed to join")?; + Ok(CliScopeOutcome::Command(exit)) + } + FirstTask::Events(event_result) => { + // An event-loop exit terminates the CLI scope. Settle the command + // future and peer before deciding whether this platform can unwind + // its stdin implementation or must use the process boundary. + command_task.abort(); + let command_result = command_task.await; + shutdown.await; + + match command_result { + Ok(result) => { + let exit = result?; + event_result.wrap_err("peer event loop failed to join")?; + Ok(CliScopeOutcome::Command(exit)) + } + Err(error) if error.is_cancelled() => { + let failure = match event_result { + Ok(()) => eyre::eyre!("peer event loop stopped before the command loop"), + Err(error) => { + eyre::Report::new(error).wrap_err("peer event loop failed to join") + } + }; + Ok(CliScopeOutcome::CancelledInput( + CancelledInputExit::Failure(failure), + )) + } + Err(error) => { + event_result.wrap_err("peer event loop failed to join")?; + Err(error).wrap_err("peer command loop task failed to join") + } + } + } + FirstTask::Signal(signal_result) => { + // SIGINT/SIGTERM/Ctrl-C are owned termination inputs, not an outer + // race that is allowed to drop this scope. Cancel the command + // future, settle the peer, and drain all terminal events first. + command_task.abort(); + let command_result = command_task.await; + shutdown.await; + let event_result = event_task.await; + + match command_result { + Ok(result) => { + signal_result.wrap_err("CLI shutdown signal listener failed")?; + let exit = result?; + event_result.wrap_err("peer event loop failed to join")?; + Ok(CliScopeOutcome::Command(exit)) + } + Err(error) if error.is_cancelled() => { + let cancellation = if let Err(error) = signal_result { + CancelledInputExit::Failure( + error.wrap_err("CLI shutdown signal listener failed"), + ) + } else if let Err(error) = event_result { + CancelledInputExit::Failure( + eyre::Report::new(error).wrap_err("peer event loop failed to join"), + ) + } else { + CancelledInputExit::Signal + }; + Ok(CliScopeOutcome::CancelledInput(cancellation)) + } + Err(error) => { + signal_result.wrap_err("CLI shutdown signal listener failed")?; + event_result.wrap_err("peer event loop failed to join")?; + Err(error).wrap_err("peer command loop task failed to join") + } + } } } } +/// Resolves the only platform-dependent ownership boundary left by stdin. +/// +/// Unix's `AsyncFd` command source is quiescent once its future is cancelled. +/// Tokio's fallback stdin owns an uncancellable blocking read, so non-Unix must +/// terminate the process *after* peer shutdown and task settlement instead of +/// returning into runtime destruction. EOF and explicit `shutdown` complete as +/// `CliScopeOutcome::Command` and never take this boundary. +fn finish_cli_scope( + outcome: CliScopeOutcome, + input_cancellation_is_quiescent: bool, + terminate_process: Terminate, +) -> eyre::Result +where + Terminate: FnOnce(i32, Option<&eyre::Report>) -> eyre::Result, +{ + match outcome { + CliScopeOutcome::Command(exit) => Ok(exit), + CliScopeOutcome::CancelledInput(CancelledInputExit::Signal) + if input_cancellation_is_quiescent => + { + Ok(CommandLoopExit::Signal) + } + CliScopeOutcome::CancelledInput(CancelledInputExit::Signal) => terminate_process(0, None), + CliScopeOutcome::CancelledInput(CancelledInputExit::Failure(error)) + if input_cancellation_is_quiescent => + { + Err(error) + } + CliScopeOutcome::CancelledInput(CancelledInputExit::Failure(error)) => { + terminate_process(1, Some(&error)) + } + } +} + +async fn set_game_dir( + sender: &mpsc::UnboundedSender, + shared: &SharedState, + path: &Path, +) -> eyre::Result { + let (reply, result) = oneshot::channel(); + sender.send(PeerCommand::SetGameDir { + path: path.to_path_buf(), + reply, + })?; + let accepted_path = result + .await + .wrap_err("peer stopped before acknowledging the game directory")? + .map_err(eyre::Report::msg)?; + *shared.games_dir.write().await = accepted_path.clone(); + Ok(json!({"accepted": true, "path": accepted_path})) +} + async fn status(shared: &SharedState) -> eyre::Result { let state = shared.state.read().await; let peer_count = shared.peer_game_db.read().await.peer_snapshots().len(); @@ -362,11 +786,9 @@ async fn list_peers(shared: &SharedState) -> eyre::Result { async fn list_games(shared: &SharedState) -> eyre::Result { let state = shared.state.read().await; - let catalog = shared.catalog.read().await; - let remote = shared.peer_game_db.read().await.get_catalog_games(&catalog); Ok(json!({ "local": state.local_games.clone(), - "remote": remote, + "remote": state.remote_games.clone(), "active_operations": active_operations_json(&state.active_operations), })) } @@ -377,21 +799,24 @@ async fn play( username: &str, language: Option<&str>, ) -> eyre::Result { - ensure_catalog_game(shared, game_id).await?; - let game_root = shared.games_dir.join(game_id); + ensure_catalog_game(shared, game_id)?; + let game_root = shared.games_dir.read().await.join(game_id); let outcome = lanspread_peer::apply_launch_settings_once( &shared.state_dir, &game_root, game_id, Some(username), language, - ) - .await?; + )?; Ok(json!({ "game_id": game_id, "outcome": outcome })) } -async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> { - if shared.catalog.read().await.contains(game_id) { +fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> { + if shared + .catalog_bundle + .as_ref() + .is_some_and(|catalog| catalog.catalog().contains(game_id)) + { return Ok(()); } @@ -411,14 +836,12 @@ async fn ensure_no_active_operation(shared: &SharedState, game_id: &str) -> eyre Ok(()) } -async fn ensure_not_self_connect(shared: &SharedState, addr: SocketAddr) -> eyre::Result<()> { +async fn ensure_not_self_connect(shared: &SharedState, endpoint: PeerEndpoint) -> eyre::Result<()> { let state = shared.state.read().await; - if state - .local_peer - .as_ref() - .is_some_and(|peer| peer.addr == addr.to_string()) - { - eyre::bail!("cannot connect peer to itself at {addr}"); + if state.local_peer.as_ref().is_some_and(|peer| { + peer.peer_id == endpoint.peer_id.to_string() || peer.addr == endpoint.addr.to_string() + }) { + eyre::bail!("cannot connect peer to itself at {}", endpoint.addr); } Ok(()) @@ -441,39 +864,6 @@ async fn wait_peers(shared: &SharedState, count: usize, timeout: Duration) -> ey Ok(json!({"peer_count": peer_count})) } -async fn game_files_for_download( - sender: &mpsc::UnboundedSender, - shared: &SharedState, - game_id: &str, -) -> eyre::Result> { - { - let mut state = shared.state.write().await; - if let Some(files) = state.game_files.get(game_id).cloned() { - return Ok(files); - } - state.unavailable_games.remove(game_id); - } - - sender.send(PeerCommand::GetGame(game_id.to_string()))?; - let wait = async { - loop { - let state = shared.state.read().await; - if let Some(files) = state.game_files.get(game_id).cloned() { - return Ok(files); - } - if state.unavailable_games.contains(game_id) { - eyre::bail!("no peers have game {game_id}"); - } - drop(state); - shared.notify.notified().await; - } - }; - - tokio::time::timeout(Duration::from_secs(10), wait) - .await - .wrap_err("timed out waiting for game file details")? -} - async fn event_loop( mut rx_events: mpsc::UnboundedReceiver, shared: Arc, @@ -497,21 +887,28 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s shared.state.write().await.local_peer = Some(local_peer.clone()); ("local-peer-ready", json!(local_peer)) } - PeerEvent::ListGames(games) => { - let catalog = shared.catalog.read().await.clone(); - let games = games - .into_iter() - .filter(|game| catalog.contains(&game.id)) - .collect::>(); + PeerEvent::LocalNetworkSharingStateChanged(state) => ( + "local-network-sharing-state-changed", + json!({ "state": state }), + ), + PeerEvent::RemoteLibraryView(view) => { + let games = join_remote_library_view(shared, &view); shared.state.write().await.remote_games = games.clone(); - ("list-games", json!({ "games": games })) + ( + "remote-library-view", + json!({ "view": view, "games": games }), + ) } PeerEvent::LocalLibraryChanged { games } => { let mut state = shared.state.write().await; state.local_games.clone_from(&games); ("local-library-changed", json!({ "games": games })) } - PeerEvent::OutboundTransferCountChanged => ("outbound-transfer-count-changed", json!({})), + PeerEvent::OutboundTransferCountChanged(change) => { + let event = ("outbound-transfer-count-changed", json!({})); + drop(change); + event + } PeerEvent::ActiveOperationsChanged { active_operations } => { let mut state = shared.state.write().await; state.active_operations.clone_from(&active_operations); @@ -520,63 +917,61 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s json!({ "active_operations": active_operations_json(&active_operations) }), ) } - PeerEvent::CallToPlayEvents(events) => { - let mut state = shared.state.write().await; - let mut known = state - .call_to_play_events - .iter() - .map(|event| event.id.clone()) - .collect::>(); - state.call_to_play_events.extend( - events - .iter() - .filter(|event| known.insert(event.id.clone())) - .cloned(), - ); - ("call-to-play-events", json!({ "events": events })) + PeerEvent::CallToPlayView(view) => { + shared.state.write().await.call_to_play_view = view.clone(); + ("call-to-play-view", json!({ "view": view })) } - PeerEvent::GotGameFiles { - id, - file_descriptions, - } => { - shared - .state - .write() - .await - .game_files - .insert(id.clone(), file_descriptions.clone()); - ( - "got-game-files", - json!({"game_id": id, "file_descriptions": file_descriptions}), - ) + PeerEvent::DownloadGameFilesBegin { attempt } => { + download_begin_event(shared, attempt).await } - PeerEvent::DownloadGameFilesBegin { id } => download_begin_event(shared, id).await, PeerEvent::DownloadGameFileChunkFinished { id, + peer_id, peer_addr, + content_id, relative_path, offset, length, } => { - download_chunk_finished_event(shared, id, peer_addr, relative_path, offset, length) - .await + download_chunk_finished_event( + shared, + DownloadChunkFinishedEvent { + id, + peer_id: peer_id.to_string(), + peer_addr, + content_id: content_id.to_string(), + relative_path: relative_path.as_str().to_owned(), + offset, + length, + }, + ) + .await } PeerEvent::DownloadGameFilesProgress(progress) => ( "download-progress", json!({ - "game_id": progress.id, + "game_id": progress.attempt.id, + "attempt_id": progress.attempt.attempt_id.to_string(), "downloaded_bytes": progress.downloaded_bytes, "total_bytes": progress.total_bytes, "bytes_per_second": progress.bytes_per_second, + "active_peer_count": progress.active_peer_count, }), ), - PeerEvent::DownloadGameFilesFinished { id } => { - download_terminal_event(shared, "download-finished", id).await + PeerEvent::DownloadGameFilesActivityChanged { attempt, activity } => ( + "download-activity-changed", + json!({ + "game_id": attempt.id, + "attempt_id": attempt.attempt_id.to_string(), + "activity": activity, + }), + ), + PeerEvent::DownloadGameFilesFinished { attempt } => { + download_terminal_event(shared, "download-finished", attempt, None).await } - PeerEvent::DownloadGameFilesFailed { id } => { - download_terminal_event(shared, "download-failed", id).await + PeerEvent::DownloadGameFilesFailed { attempt, reason } => { + download_terminal_event(shared, "download-failed", attempt, Some(reason)).await } - PeerEvent::DownloadGameFilesAllPeersGone { id } => game_id_event("download-peers-gone", id), PeerEvent::InstallGameFinished { id } => game_id_event("install-finished", id), PeerEvent::InstallGameFailed { id } => game_id_event("install-failed", id), PeerEvent::UninstallGameFinished { id } => game_id_event("uninstall-finished", id), @@ -585,12 +980,13 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s game_id_event("remove-download-finished", id) } PeerEvent::RemoveDownloadedGameFailed { id } => game_id_event("remove-download-failed", id), - PeerEvent::NoPeersHaveGame { id } => no_peers_event(shared, id).await, - PeerEvent::PeerConnected(addr) => ("peer-connected", peer_addr_json(addr)), - PeerEvent::PeerDisconnected(addr) => ("peer-disconnected", peer_addr_json(addr)), - PeerEvent::PeerDiscovered(addr) => ("peer-discovered", peer_addr_json(addr)), - PeerEvent::PeerLost(addr) => ("peer-lost", peer_addr_json(addr)), + PeerEvent::PeerDiscovered(endpoint) => ("peer-discovered", peer_endpoint_json(endpoint)), + PeerEvent::PeerLost(endpoint) => ("peer-lost", peer_endpoint_json(endpoint)), PeerEvent::PeerCountUpdated(count) => ("peer-count-updated", json!({"count": count})), + PeerEvent::IncompatibleProtocolDetected { observed, expected } => ( + "incompatible-protocol-detected", + json!({"observed": observed, "expected": expected}), + ), PeerEvent::RuntimeFailed { component, error } => ( "runtime-failed", json!({"component": runtime_component_name(component), "error": error}), @@ -598,43 +994,102 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s } } +fn join_remote_library_view(shared: &SharedState, view: &RemoteLibraryView) -> Vec { + let mut games = view + .games + .iter() + .filter_map(|availability| { + let catalog_bundle = shared.catalog_bundle.as_ref()?; + let Some(identity) = catalog_bundle.content_identity(&availability.game_id) else { + eprintln!( + "Ignoring remote availability for unknown catalog game {}", + availability.game_id + ); + return None; + }; + if identity.content_id != availability.content_id { + eprintln!( + "Ignoring remote availability for non-catalog content {} ({})", + availability.game_id, availability.content_id + ); + return None; + } + let mut game = shared + .catalog_game_db + .get_game_by_id(&availability.game_id)? + .clone(); + game.peer_count = availability.peer_count; + game.set_downloaded(true); + Some(game) + }) + .collect::>(); + games.sort(); + games +} + fn game_id_event(kind: &'static str, id: String) -> (&'static str, Value) { (kind, json!({"game_id": id})) } -async fn download_begin_event(shared: &SharedState, id: String) -> (&'static str, Value) { - shared.state.write().await.downloads.insert( - id.clone(), - DownloadMeasurement { - started_at: Instant::now(), - bytes: 0, - chunks: 0, - }, - ); - game_id_event("download-begin", id) +async fn download_begin_event( + shared: &SharedState, + attempt: DownloadAttemptKey, +) -> (&'static str, Value) { + let mut state = shared.state.write().await; + let replace = state + .downloads + .get(&attempt.id) + .is_none_or(|measurement| measurement.attempt_id < attempt.attempt_id); + if replace { + state.downloads.insert( + attempt.id.clone(), + DownloadMeasurement { + attempt_id: attempt.attempt_id, + started_at: Instant::now(), + bytes: 0, + chunks: 0, + }, + ); + } + drop(state); + ( + "download-begin", + json!({ + "game_id": attempt.id, + "attempt_id": attempt.attempt_id.to_string(), + }), + ) +} + +struct DownloadChunkFinishedEvent { + id: String, + peer_id: String, + peer_addr: SocketAddr, + content_id: String, + relative_path: String, + offset: u64, + length: u64, } async fn download_chunk_finished_event( shared: &SharedState, - id: String, - peer_addr: SocketAddr, - relative_path: String, - offset: u64, - length: u64, + event: DownloadChunkFinishedEvent, ) -> (&'static str, Value) { - if let Some(measurement) = shared.state.write().await.downloads.get_mut(&id) { - measurement.bytes = measurement.bytes.saturating_add(length); + if let Some(measurement) = shared.state.write().await.downloads.get_mut(&event.id) { + measurement.bytes = measurement.bytes.saturating_add(event.length); measurement.chunks = measurement.chunks.saturating_add(1); } ( "download-chunk-finished", json!({ - "game_id": id, - "peer_addr": peer_addr.to_string(), - "relative_path": relative_path, - "offset": offset, - "length": length, + "game_id": event.id, + "peer_id": event.peer_id, + "peer_addr": event.peer_addr.to_string(), + "content_id": event.content_id, + "relative_path": event.relative_path, + "offset": event.offset, + "length": event.length, }), ) } @@ -642,11 +1097,29 @@ async fn download_chunk_finished_event( async fn download_terminal_event( shared: &SharedState, kind: &'static str, - id: String, + attempt: DownloadAttemptKey, + reason: Option, ) -> (&'static str, Value) { - let measurement = shared.state.write().await.downloads.remove(&id); + let measurement = { + let mut state = shared.state.write().await; + let is_current = state + .downloads + .get(&attempt.id) + .is_some_and(|measurement| measurement.attempt_id == attempt.attempt_id); + is_current + .then(|| state.downloads.remove(&attempt.id)) + .flatten() + }; + + let mut data = json!({ + "game_id": attempt.id, + "attempt_id": attempt.attempt_id.to_string(), + }); + if let Some(reason) = reason { + data["reason"] = json!(reason); + } let Some(measurement) = measurement else { - return game_id_event(kind, id); + return (kind, data); }; let duration = measurement.started_at.elapsed(); @@ -654,33 +1127,21 @@ async fn download_terminal_event( #[allow(clippy::cast_precision_loss)] let bytes = measurement.bytes as f64; - ( - kind, - json!({ - "game_id": id, - "throughput": { - "bytes": measurement.bytes, - "chunks": measurement.chunks, - "duration_ms": duration.as_secs_f64() * 1000.0, - "mib_per_s": bytes / seconds / 1_048_576.0, - "mbit_per_s": bytes * 8.0 / seconds / 1_000_000.0, - }, - }), - ) + data["throughput"] = json!({ + "bytes": measurement.bytes, + "chunks": measurement.chunks, + "duration_ms": duration.as_secs_f64() * 1000.0, + "mib_per_s": bytes / seconds / 1_048_576.0, + "mbit_per_s": bytes * 8.0 / seconds / 1_000_000.0, + }); + (kind, data) } -async fn no_peers_event(shared: &SharedState, id: String) -> (&'static str, Value) { - shared - .state - .write() - .await - .unavailable_games - .insert(id.clone()); - game_id_event("no-peers-have-game", id) -} - -fn peer_addr_json(addr: SocketAddr) -> Value { - json!({"addr": addr.to_string()}) +fn peer_endpoint_json(endpoint: lanspread_peer::PeerEndpoint) -> Value { + json!({ + "peer_id": endpoint.peer_id.to_string(), + "addr": endpoint.addr.to_string(), + }) } fn active_operations_json(active_operations: &[ActiveOperation]) -> Vec { @@ -700,11 +1161,12 @@ fn peer_snapshots_json(peers: &[PeerSnapshot]) -> Vec { .iter() .map(|peer| { json!({ - "peer_id": peer.peer_id.clone(), + "peer_id": peer.peer_id, "addr": peer.addr.to_string(), - "library_rev": peer.library_rev, - "library_digest": peer.library_digest, - "features": peer.features.clone(), + "endpoint_generation": peer.endpoint_generation.get(), + "runtime_session_id": peer.runtime_session_id, + "library_revision": peer.library_revision, + "call_to_play_revision": peer.call_to_play_revision, "game_count": peer.game_count, "games": peer.games.clone(), }) @@ -727,40 +1189,65 @@ fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result, fixtures: &[FixtureSeed]) -> GameCatalog { - let mut catalog = GameCatalog::empty(); - if let Some(path) = catalog_db - && path.exists() - { - match get_games(path).await { - Ok(games) => { - for game in games { - catalog.insert(game.game_id, Some(game.game_version)); - } - } - Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()), - } - } +async fn load_catalog_authority( + catalog_db: &Path, + manifests_dir: &Path, + fixtures: &[String], +) -> eyre::Result<(GameDB, Arc)> { + let loaded = load_catalog_bundle(catalog_db, manifests_dir) + .await + .wrap_err_with(|| { + format!( + "failed to load catalog authority from {} and {}", + catalog_db.display(), + manifests_dir.display() + ) + })?; + let (game_db, bundle) = loaded.into_parts(); + // A selected fixture body can be large and is backed by synchronous, + // bounded filesystem reads. This startup gate runs before the peer owns + // any tasks; keep the work lexical while letting Tokio compensate for the + // blocked CLI executor worker. + tokio::task::block_in_place(|| validate_fixture_authority(&bundle, fixtures))?; + Ok((game_db, bundle)) +} - for seed in fixtures { - catalog.insert( - seed.game_id.clone(), - Some(DEFAULT_FIXTURE_VERSION.to_string()), - ); +fn validate_fixture_authority( + catalog_bundle: &CatalogBundle, + fixtures: &[String], +) -> eyre::Result<()> { + for fixture in fixtures { + let expected_version = catalog_bundle + .catalog() + .expected_version(fixture) + .ok_or_else(|| { + eyre::eyre!("fixture {fixture} is not authorized by the selected catalog profile") + })?; + if expected_version != DEFAULT_FIXTURE_VERSION { + eyre::bail!( + "fixture {fixture} requires catalog version {DEFAULT_FIXTURE_VERSION}, but the selected profile authorizes {expected_version}" + ); + } + catalog_bundle.manifest(fixture).wrap_err_with(|| { + format!("fixture {fixture} has no valid manifest in the selected catalog profile") + })?; } - catalog + Ok(()) } fn parse_args() -> eyre::Result { - let mut args = std::env::args_os().skip(1); - let mut parsed = Args { - name: "peer".to_string(), - games_dir: PathBuf::from("games"), - state_dir: PathBuf::from("state"), - catalog_db: default_catalog_db(), - fixtures: Vec::new(), - unrar: None, - }; + parse_args_from(std::env::args_os().skip(1)) +} + +fn parse_args_from(mut args: impl Iterator) -> eyre::Result { + let mut name = "peer".to_string(); + let mut games_dir = PathBuf::from("games"); + let mut state_dir = PathBuf::from("state"); + let mut identity_file = None; + let mut catalog_db = default_catalog_db(); + let mut manifests_dir = None; + let mut fixtures = Vec::new(); + let mut unrar = None; while let Some(arg) = args.next() { match arg.to_str() { @@ -768,27 +1255,49 @@ fn parse_args() -> eyre::Result { print_help(); std::process::exit(0); } - Some("--name") => parsed.name = next_string(&mut args, "--name")?, - Some("--games-dir") => parsed.games_dir = next_path(&mut args, "--games-dir")?, - Some("--state-dir") => parsed.state_dir = next_path(&mut args, "--state-dir")?, - Some("--catalog-db") => parsed.catalog_db = Some(next_path(&mut args, "--catalog-db")?), - Some("--fixture") => parsed.fixtures.push(next_string(&mut args, "--fixture")?), - Some("--unrar") => parsed.unrar = Some(next_path(&mut args, "--unrar")?), + Some("--name") => name = next_string(&mut args, "--name")?, + Some("--games-dir") => games_dir = next_path(&mut args, "--games-dir")?, + Some("--state-dir") => state_dir = next_path(&mut args, "--state-dir")?, + Some("--identity-file") => { + identity_file = Some(next_path(&mut args, "--identity-file")?); + } + Some("--catalog-db") => catalog_db = next_path(&mut args, "--catalog-db")?, + Some("--manifests-dir") => { + manifests_dir = Some(next_path(&mut args, "--manifests-dir")?); + } + Some("--fixture") => fixtures.push(next_string(&mut args, "--fixture")?), + Some("--unrar") => unrar = Some(next_path(&mut args, "--unrar")?), Some(other) => eyre::bail!("unknown argument: {other}"), None => eyre::bail!("argument is not valid UTF-8: {arg:?}"), } } - Ok(parsed) + let manifests_dir = manifests_dir.unwrap_or_else(|| sibling_manifests_dir(&catalog_db)); + Ok(Args { + name, + games_dir, + state_dir, + identity_file, + catalog_db, + manifests_dir, + fixtures, + unrar, + }) } -fn default_catalog_db() -> Option { - [ - PathBuf::from("/app/game.db"), - PathBuf::from("crates/lanspread-tauri-deno-ts/src-tauri/game.db"), - ] - .into_iter() - .find(|path| path.exists()) +fn default_catalog_db() -> PathBuf { + let container = PathBuf::from("/app/game.db"); + if container.exists() { + return container; + } + PathBuf::from("crates/lanspread-peer-cli/catalogs/default/game.db") +} + +fn sibling_manifests_dir(catalog_db: &Path) -> PathBuf { + catalog_db + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("manifests") } fn default_unrar_program() -> Option { @@ -811,10 +1320,841 @@ fn next_path(args: &mut impl Iterator, flag: &str) -> eyre::Res Ok(PathBuf::from(next_string(args, flag)?)) } +const HELP: &str = "usage: lanspread-peer-cli [--name NAME] [--games-dir PATH] [--state-dir PATH] \ + [--identity-file PATH] \ + [--catalog-db PATH] [--manifests-dir PATH] [--fixture GAME_ID] [--unrar PATH]\n\ + --identity-file loads one existing identity strictly and never repairs or replaces it.\n\ + --manifests-dir defaults to the manifests/ sibling of --catalog-db.\n\ + Every --fixture must be authorized by the selected catalog profile.\n\ + Reads JSONL commands on stdin and writes result/event/error JSONL on stdout."; + fn print_help() { - eprintln!( - "usage: lanspread-peer-cli [--name NAME] [--games-dir PATH] [--state-dir PATH] \\ - [--catalog-db PATH] [--fixture GAME_ID] [--unrar PATH]\n\ - Reads JSONL commands on stdin and writes result/event/error JSONL on stdout." - ); + eprintln!("{HELP}"); +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + }; + #[cfg(unix)] + use std::{os::fd::OwnedFd, process::Stdio}; + + use lanspread_db::content_manifest::{ + Blake3Digest, + CATALOG_CONTENT_INDEX_NAME, + CatalogContentIndex, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + write_canonical_content_index_atomic, + }; + + use super::*; + + static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0); + + struct TestDir(PathBuf); + + impl TestDir { + fn new(label: &str) -> Self { + let path = temp_file_path(label); + std::fs::create_dir(&path).expect("test directory should be created"); + Self(path) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn temp_file_path(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "lanspread-peer-cli-{label}-{}-{}", + std::process::id(), + NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed) + )) + } + + fn fixture_manifest(game_id: &str, version: &str) -> CatalogContentManifest { + let archive = format!("fixture archive for {game_id}\n"); + let archive_hash = Blake3Digest::hash(archive.as_bytes()); + let version_hash = Blake3Digest::hash(version.as_bytes()); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + game_id, + version, + vec![ + CatalogFileEntry::file( + format!("{game_id}.eti"), + u64::try_from(archive.len()).expect("fixture archive length should fit"), + archive_hash, + vec![archive_hash], + ) + .expect("fixture archive entry should validate"), + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("fixture version length should fit"), + version_hash, + vec![version_hash], + ) + .expect("fixture version entry should validate"), + ], + Vec::new(), + ) + .expect("fixture manifest body should validate"), + ) + .expect("fixture manifest should seal") + } + + fn fixture_bundle(game_id: &str, version: &str) -> (TestDir, CatalogBundle) { + let root = TestDir::new("catalog-authority"); + let manifest = fixture_manifest(game_id, version); + let index = CatalogContentIndex::from_manifests([&manifest]) + .expect("fixture content index should validate"); + write_canonical_content_index_atomic(&root.0.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("fixture content index should be written"); + std::fs::write( + root.0.join(format!("{game_id}.json")), + manifest + .to_canonical_json() + .expect("fixture manifest should encode"), + ) + .expect("fixture manifest should be written"); + let bundle = CatalogBundle::new( + &root.0, + BTreeMap::from([(game_id.to_owned(), version.to_owned())]), + ) + .expect("fixture catalog bundle should validate"); + (root, bundle) + } + + fn game_fixture(game_id: &str, version: &str) -> Game { + Game { + id: game_id.to_owned(), + name: format!("Catalog {game_id}"), + description: String::new(), + release_year: String::new(), + publisher: String::new(), + max_players: 4, + version: String::new(), + genre: String::new(), + size: 42, + downloaded: false, + installed: false, + availability: lanspread_db::db::Availability::LocalOnly, + eti_game_version: Some(version.to_owned()), + local_version: None, + peer_count: 0, + } + } + + #[test] + fn catalog_manifest_path_defaults_to_selected_database_sibling() { + let parsed = parse_args_from( + ["--catalog-db", "/profiles/solid/game.db"] + .into_iter() + .map(OsString::from), + ) + .expect("explicit catalog profile should parse"); + + assert_eq!(parsed.catalog_db, Path::new("/profiles/solid/game.db")); + assert_eq!(parsed.manifests_dir, Path::new("/profiles/solid/manifests")); + } + + #[test] + fn help_exposes_catalog_authority_profile_inputs() { + assert!(HELP.contains("--catalog-db PATH")); + assert!(HELP.contains("--manifests-dir PATH")); + assert!(HELP.contains("--identity-file PATH")); + assert!(HELP.contains("never repairs or replaces")); + assert!(HELP.contains("manifests/ sibling")); + assert!(HELP.contains("fixture must be authorized")); + } + + #[test] + fn explicit_identity_path_is_preserved_by_argument_parsing() { + let parsed = parse_args_from( + ["--identity-file", "/identities/alpha.json"] + .into_iter() + .map(OsString::from), + ) + .expect("explicit identity path should parse"); + + assert_eq!( + parsed.identity_file.as_deref(), + Some(Path::new("/identities/alpha.json")) + ); + } + + #[test] + fn malformed_explicit_identity_fails_without_mutating_its_directory() { + let root = TestDir::new("invalid-explicit-identity"); + let identity_path = root.0.join("selected.json"); + let invalid = b"not a peer identity\n"; + std::fs::write(&identity_path, invalid).expect("invalid identity should be written"); + + let error = load_explicit_identity(Some(&identity_path)) + .expect_err("invalid explicit identity must fail closed"); + + assert!( + error + .to_string() + .contains("failed to load explicitly selected peer identity") + ); + assert_eq!( + std::fs::read(&identity_path).expect("identity should remain readable"), + invalid + ); + let entries = std::fs::read_dir(&root.0) + .expect("identity directory should remain readable") + .collect::, _>>() + .expect("identity directory entries should be readable"); + assert_eq!(entries.len(), 1, "strict loading must not create sidecars"); + } + + #[test] + fn explicit_manifest_path_is_independent_of_argument_order() { + let parsed = parse_args_from( + [ + "--manifests-dir", + "/profiles/dynamic/authority", + "--catalog-db", + "/profiles/dynamic/catalog.sqlite", + ] + .into_iter() + .map(OsString::from), + ) + .expect("explicit profile paths should parse"); + + assert_eq!( + parsed.manifests_dir, + Path::new("/profiles/dynamic/authority") + ); + } + + #[test] + fn selected_fixture_must_have_exact_valid_authority() { + let (_root, bundle) = fixture_bundle("fixture-one", DEFAULT_FIXTURE_VERSION); + validate_fixture_authority(&bundle, &["fixture-one".to_owned()]) + .expect("matching fixture authority should be accepted"); + + let error = validate_fixture_authority(&bundle, &["fixture-two".to_owned()]) + .expect_err("an unknown fixture must not be inserted into authority"); + assert!( + error + .to_string() + .contains("is not authorized by the selected catalog profile") + ); + assert!( + !bundle.catalog().contains("fixture-two"), + "fixture validation must never extend catalog authority" + ); + } + + #[test] + fn selected_fixture_rejects_wrong_version_or_malformed_manifest() { + let (_root, wrong_version) = fixture_bundle("fixture-one", "20240101"); + let error = validate_fixture_authority(&wrong_version, &["fixture-one".to_owned()]) + .expect_err("fixture version drift must fail closed"); + assert!(error.to_string().contains(DEFAULT_FIXTURE_VERSION)); + + let root = TestDir::new("malformed-catalog-authority"); + let indexed_manifest = fixture_manifest("fixture-one", DEFAULT_FIXTURE_VERSION); + let index = CatalogContentIndex::from_manifests([&indexed_manifest]) + .expect("fixture content index should validate"); + write_canonical_content_index_atomic(&root.0.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("fixture content index should be written"); + std::fs::write(root.0.join("fixture-one.json"), b"not JSON\n") + .expect("malformed manifest fixture should be written"); + let malformed = CatalogBundle::new( + &root.0, + BTreeMap::from([("fixture-one".to_owned(), DEFAULT_FIXTURE_VERSION.to_owned())]), + ) + .expect("bundle coverage should remain lazy"); + let error = validate_fixture_authority(&malformed, &["fixture-one".to_owned()]) + .expect_err("selected fixture manifest must be eagerly validated"); + assert!( + error + .to_string() + .contains("has no valid manifest in the selected catalog profile") + ); + } + + #[test] + fn remote_view_join_uses_index_without_loading_manifest_body() { + let game_id = "fixture-one"; + let version = DEFAULT_FIXTURE_VERSION; + let (root, bundle) = fixture_bundle(game_id, version); + let content_id = bundle + .content_identity(game_id) + .expect("fixture identity should be indexed") + .content_id; + std::fs::write( + root.0.join(format!("{game_id}.json")), + b"broken after load\n", + ) + .expect("manifest body should become unreadable for the adapter proof"); + + let mut shared = shared_state(PathBuf::from("games")); + shared.catalog_game_db = Arc::new(GameDB::from(vec![game_fixture(game_id, version)])); + shared.catalog_bundle = Some(Arc::new(bundle)); + + let exact = join_remote_library_view( + &shared, + &RemoteLibraryView { + games: vec![lanspread_peer::RemoteGameAvailability { + game_id: game_id.to_owned(), + content_id, + peer_count: 3, + }], + }, + ); + assert_eq!(exact.len(), 1); + assert_eq!(exact[0].id, game_id); + assert_eq!(exact[0].peer_count, 3); + + let wrong = join_remote_library_view( + &shared, + &RemoteLibraryView { + games: vec![lanspread_peer::RemoteGameAvailability { + game_id: game_id.to_owned(), + content_id: lanspread_db::content_manifest::ContentId::from_bytes([7; 32]), + peer_count: 9, + }], + }, + ); + assert!(wrong.is_empty()); + assert!( + shared + .catalog_bundle + .as_ref() + .expect("fixture bundle should remain configured") + .manifest(game_id) + .is_err(), + "the adapter result must not depend on the now-invalid manifest body" + ); + } + + #[tokio::test] + async fn missing_catalog_authority_is_a_startup_error() { + let root = TestDir::new("missing-catalog-authority"); + let error = load_catalog_authority( + &root.0.join("missing-game.db"), + &root.0.join("missing-manifests"), + &[], + ) + .await + .expect_err("missing authority must fail instead of starting with an empty catalog"); + + assert!( + error + .to_string() + .contains("failed to load catalog authority") + ); + } + + fn shared_state(game_dir: PathBuf) -> SharedState { + SharedState { + state: RwLock::new(CliState::default()), + peer_game_db: Arc::new(RwLock::new(PeerGameDB::new())), + catalog_game_db: Arc::new(GameDB::empty()), + catalog_bundle: None, + call_to_play_display_name: RwLock::new("peer-cli".to_owned()), + active_outbound_transfers: Arc::new(RwLock::new(HashMap::new())), + notify: Notify::new(), + games_dir: RwLock::new(game_dir), + state_dir: PathBuf::from("state"), + } + } + + fn download_attempt(game_id: &str, attempt_id: u64) -> DownloadAttemptKey { + serde_json::from_value(json!({ + "id": game_id, + "attempt_id": attempt_id.to_string(), + })) + .expect("test attempt key should use the public wire shape") + } + + #[tokio::test] + async fn stale_download_terminal_cannot_reap_newer_attempt_measurement() { + let shared = shared_state(PathBuf::from("games")); + let older = download_attempt("game", 1); + let newer = download_attempt("game", 2); + + let (_, older_begin) = download_begin_event(&shared, older.clone()).await; + let (_, newer_begin) = download_begin_event(&shared, newer.clone()).await; + assert_eq!(older_begin["attempt_id"], "1"); + assert_eq!(newer_begin["attempt_id"], "2"); + + let (_, stale_terminal) = download_terminal_event( + &shared, + "download-failed", + older, + Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted), + ) + .await; + assert_eq!(stale_terminal["attempt_id"], "1"); + assert_eq!( + stale_terminal["reason"], + "verified-catalog-sources-exhausted" + ); + assert!(stale_terminal.get("throughput").is_none()); + + let (_, _) = download_chunk_finished_event( + &shared, + DownloadChunkFinishedEvent { + id: "game".to_owned(), + peer_id: "peer".to_owned(), + peer_addr: "127.0.0.1:1".parse().expect("test address should parse"), + content_id: "content".to_owned(), + relative_path: "game.eti".to_owned(), + offset: 0, + length: 64, + }, + ) + .await; + let (_, current_terminal) = + download_terminal_event(&shared, "download-finished", newer, None).await; + assert_eq!(current_terminal["attempt_id"], "2"); + assert_eq!(current_terminal["throughput"]["bytes"], 64); + assert_eq!(current_terminal["throughput"]["chunks"], 1); + } + + #[tokio::test] + async fn download_activity_json_is_attempt_keyed_and_typed() { + let shared = shared_state(PathBuf::from("games")); + let attempt = download_attempt("game", 42); + + let (event, data) = update_state_from_event( + &shared, + PeerEvent::DownloadGameFilesActivityChanged { + attempt, + activity: Some(lanspread_peer::DownloadVerificationActivity::RetryingInvalidSource), + }, + ) + .await; + + assert_eq!(event, "download-activity-changed"); + assert_eq!(data["game_id"], "game"); + assert_eq!(data["attempt_id"], "42"); + assert_eq!(data["activity"], "retrying-invalid-source"); + } + + #[tokio::test] + async fn set_game_dir_stores_and_returns_only_the_acknowledged_path() { + let shared = shared_state(PathBuf::from("previous-games")); + let accepted = PathBuf::from("canonical-games"); + let (sender, mut commands) = mpsc::unbounded_channel(); + + let request = set_game_dir(&sender, &shared, Path::new("alias-games")); + let reply = async { + let Some(PeerCommand::SetGameDir { path, reply }) = commands.recv().await else { + panic!("SetGameDir command should carry a reply"); + }; + assert_eq!(path, Path::new("alias-games")); + reply + .send(Ok(accepted.clone())) + .expect("SetGameDir requester should retain its reply receiver"); + }; + let (result, ()) = tokio::join!(request, reply); + + assert_eq!( + result.expect("acknowledged directory should succeed"), + json!({"accepted": true, "path": accepted}) + ); + assert_eq!(*shared.games_dir.read().await, accepted); + } + + #[tokio::test] + async fn set_game_dir_rejection_preserves_the_previous_path() { + let previous = PathBuf::from("previous-games"); + let shared = shared_state(previous.clone()); + let (sender, mut commands) = mpsc::unbounded_channel(); + + let request = set_game_dir(&sender, &shared, Path::new("rejected-games")); + let reply = async { + let Some(PeerCommand::SetGameDir { reply, .. }) = commands.recv().await else { + panic!("SetGameDir command should carry a reply"); + }; + reply + .send(Err("operations are active".to_string())) + .expect("SetGameDir requester should retain its reply receiver"); + }; + let (result, ()) = tokio::join!(request, reply); + + assert!( + result + .expect_err("rejected directory should fail") + .to_string() + .contains("operations are active") + ); + assert_eq!(*shared.games_dir.read().await, previous); + } + + async fn assert_abnormal_command_finalizes( + command_task: JoinHandle>, + ) -> eyre::Report { + let event_stop = tokio_util::sync::CancellationToken::new(); + let shutdown_ran = Arc::new(AtomicBool::new(false)); + let event_finished = Arc::new(AtomicBool::new(false)); + let event_task = tokio::spawn({ + let event_stop = event_stop.clone(); + let shutdown_ran = shutdown_ran.clone(); + let event_finished = event_finished.clone(); + async move { + event_stop.cancelled().await; + assert!( + shutdown_ran.load(Ordering::SeqCst), + "the runtime finalizer must release the event loop" + ); + event_finished.store(true, Ordering::SeqCst); + } + }); + let shutdown = { + let shutdown_ran = shutdown_ran.clone(); + async move { + shutdown_ran.store(true, Ordering::SeqCst); + event_stop.cancel(); + } + }; + + let signal = std::future::pending::>(); + let error = join_cli_scope(command_task, event_task, signal, shutdown) + .await + .expect_err("an abnormal command task must fail the CLI scope"); + + assert!(shutdown_ran.load(Ordering::SeqCst)); + assert!( + event_finished.load(Ordering::SeqCst), + "the CLI scope must join its event task before returning" + ); + error + } + + #[tokio::test] + async fn command_loop_panic_still_finalizes_runtime_and_event_loop() { + async fn panic_command_loop() -> eyre::Result { + tokio::task::yield_now().await; + panic!("injected command-loop panic"); + } + let command_task = tokio::spawn(panic_command_loop()); + + let error = assert_abnormal_command_finalizes(command_task).await; + + assert!( + error + .to_string() + .contains("command loop task failed to join") + ); + } + + #[tokio::test] + async fn command_loop_cancellation_still_finalizes_runtime_and_event_loop() { + let command_task = tokio::spawn(std::future::pending::>()); + command_task.abort(); + + let error = assert_abnormal_command_finalizes(command_task).await; + + assert!( + error + .to_string() + .contains("command loop task failed to join") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn regular_file_commands_are_preloaded_before_runtime_ownership() { + let path = temp_file_path("regular-stdin"); + std::fs::write(&path, b"first\nsecond\n").expect("should create redirected stdin file"); + let file = std::fs::File::open(&path).expect("should reopen redirected stdin file"); + + let input = CommandInput::from_unix_file(file).expect("regular input should preload"); + assert!(matches!(&input, CommandInput::Buffered(_))); + let mut lines = BufReader::new(input).lines(); + assert_eq!( + lines.next_line().await.expect("first line should decode"), + Some("first".to_string()) + ); + assert_eq!( + lines.next_line().await.expect("second line should decode"), + Some("second".to_string()) + ); + assert_eq!(lines.next_line().await.expect("EOF should decode"), None); + + std::fs::remove_file(path).expect("should remove redirected stdin fixture"); + } + + #[cfg(unix)] + #[tokio::test] + async fn pending_pollable_stdin_is_quiescent_before_exceptional_exit_policy() { + let (read_end, _write_end) = + std::os::unix::net::UnixStream::pair().expect("should create pending stdin socket"); + let poll_started = Arc::new(Notify::new()); + let file = std::fs::File::from(OwnedFd::from(read_end)); + let stdin = UnixStdin::new(file) + .expect("socket should be pollable") + .with_poll_started(poll_started.clone()); + let input = CommandInput::Pollable(stdin); + let (sender, _commands) = mpsc::unbounded_channel(); + let command_task = tokio::spawn(command_loop( + input, + sender, + Arc::new(shared_state(PathBuf::from("games"))), + JsonlWriter::new(), + )); + let event_task = tokio::spawn(async move { + poll_started.notified().await; + }); + let shutdown_ran = Arc::new(AtomicBool::new(false)); + let shutdown = { + let shutdown_ran = shutdown_ran.clone(); + async move { + shutdown_ran.store(true, Ordering::SeqCst); + } + }; + + let outcome = tokio::time::timeout( + Duration::from_secs(2), + join_cli_scope( + command_task, + event_task, + std::future::pending::>(), + shutdown, + ), + ) + .await + .expect("pollable stdin cancellation must not hang") + .expect("event-first settlement should produce a scope outcome"); + assert!(shutdown_ran.load(Ordering::SeqCst)); + + let process_boundary_called = Arc::new(AtomicBool::new(false)); + let error = finish_cli_scope(outcome, false, { + let process_boundary_called = process_boundary_called.clone(); + move |exit_code, failure| { + assert!(shutdown_ran.load(Ordering::SeqCst)); + assert_eq!(exit_code, 1); + assert!(failure.is_some()); + process_boundary_called.store(true, Ordering::SeqCst); + eyre::bail!("injected process boundary") + } + }) + .expect_err("the injected process boundary should return its error"); + assert_eq!(error.to_string(), "injected process boundary"); + assert!(process_boundary_called.load(Ordering::SeqCst)); + } + + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn injected_signal_settles_command_peer_and_events_before_returning() { + let command_started = Arc::new(Notify::new()); + let command_dropped = Arc::new(AtomicBool::new(false)); + let command_task = tokio::spawn({ + let command_started = command_started.clone(); + let command_dropped = command_dropped.clone(); + async move { + let _drop_flag = DropFlag(command_dropped); + command_started.notify_one(); + std::future::pending::<()>().await; + Ok(CommandLoopExit::Eof) + } + }); + let event_stop = tokio_util::sync::CancellationToken::new(); + let event_finished = Arc::new(AtomicBool::new(false)); + let event_task = tokio::spawn({ + let event_stop = event_stop.clone(); + let event_finished = event_finished.clone(); + async move { + event_stop.cancelled().await; + event_finished.store(true, Ordering::SeqCst); + } + }); + let shutdown_ran = Arc::new(AtomicBool::new(false)); + let shutdown = { + let shutdown_ran = shutdown_ran.clone(); + async move { + shutdown_ran.store(true, Ordering::SeqCst); + event_stop.cancel(); + } + }; + let (send_signal, receive_signal) = oneshot::channel(); + let signal = async move { + receive_signal + .await + .wrap_err("injected signal sender disappeared") + }; + let scope = join_cli_scope(command_task, event_task, signal, shutdown); + let trigger = async move { + command_started.notified().await; + send_signal + .send(()) + .expect("scope should retain its injected signal receiver"); + }; + let (outcome, ()) = tokio::join!(scope, trigger); + let outcome = outcome.expect("injected signal should settle cleanly"); + + assert!(command_dropped.load(Ordering::SeqCst)); + assert!(shutdown_ran.load(Ordering::SeqCst)); + assert!(event_finished.load(Ordering::SeqCst)); + let process_boundary_called = Arc::new(AtomicBool::new(false)); + let exit = finish_cli_scope(outcome, true, { + let process_boundary_called = process_boundary_called.clone(); + move |_, _| { + process_boundary_called.store(true, Ordering::SeqCst); + eyre::bail!("signal should not require a Unix process boundary") + } + }) + .expect("an injected signal should become a clean CLI exit"); + assert!(matches!(exit, CommandLoopExit::Signal)); + assert!(!process_boundary_called.load(Ordering::SeqCst)); + } + + #[test] + fn normal_command_exits_never_take_the_process_boundary() { + let process_boundary_called = Arc::new(AtomicBool::new(false)); + for exit in [ + CommandLoopExit::Eof, + CommandLoopExit::Shutdown { + request_id: Some(json!(42)), + }, + ] { + finish_cli_scope(CliScopeOutcome::Command(exit), false, { + let process_boundary_called = process_boundary_called.clone(); + move |_, _| { + process_boundary_called.store(true, Ordering::SeqCst); + eyre::bail!("normal command exit must not terminate the process") + } + }) + .expect("normal command exit should remain ordinary"); + } + assert!(!process_boundary_called.load(Ordering::SeqCst)); + } + + #[cfg(unix)] + const SIGNAL_HELPER_ENV: &str = "LANSPREAD_TEST_SIGTERM_SCOPE_HELPER"; + + #[cfg(unix)] + #[tokio::test(flavor = "current_thread")] + async fn unix_sigterm_scope_helper() { + if std::env::var_os(SIGNAL_HELPER_ENV).is_none() { + return; + } + + let input = CommandInput::open().expect("helper stdin should be pollable"); + let (sender, _commands) = mpsc::unbounded_channel(); + let command_task = tokio::spawn(command_loop( + input, + sender, + Arc::new(shared_state(PathBuf::from("games"))), + JsonlWriter::new(), + )); + let event_stop = tokio_util::sync::CancellationToken::new(); + let event_task = tokio::spawn({ + let event_stop = event_stop.clone(); + async move { + event_stop.cancelled().await; + } + }); + let shutdown = async move { + event_stop.cancel(); + }; + let scope_task = tokio::spawn(join_cli_scope( + command_task, + event_task, + shutdown_signal(), + shutdown, + )); + tokio::task::yield_now().await; + println!("SIGNAL-SCOPE-READY"); + std::io::stdout() + .flush() + .expect("helper readiness should flush"); + + let outcome = tokio::time::timeout(Duration::from_secs(5), scope_task) + .await + .expect("SIGTERM scope should not hang") + .expect("SIGTERM scope task should join") + .expect("SIGTERM scope should settle cleanly"); + let exit = finish_cli_scope(outcome, true, |_, _| { + eyre::bail!("Unix SIGTERM must not need process termination") + }) + .expect("SIGTERM should become a clean CLI exit"); + assert!(matches!(exit, CommandLoopExit::Signal)); + println!("SIGNAL-SCOPE-SETTLED"); + std::io::stdout() + .flush() + .expect("helper settlement should flush"); + } + + #[cfg(unix)] + #[tokio::test] + async fn real_sigterm_settles_scope_while_stdin_remains_open() { + let executable = std::env::current_exe().expect("test executable path should exist"); + let mut command = tokio::process::Command::new(executable); + command + .arg("--exact") + .arg("tests::unix_sigterm_scope_helper") + .arg("--nocapture") + .env(SIGNAL_HELPER_ENV, "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = command.spawn().expect("SIGTERM helper should spawn"); + let _open_stdin = child + .stdin + .take() + .expect("SIGTERM helper stdin should remain open"); + let stdout = child + .stdout + .take() + .expect("SIGTERM helper stdout should be piped"); + let mut lines = BufReader::new(stdout).lines(); + + loop { + let line = tokio::time::timeout(Duration::from_secs(5), lines.next_line()) + .await + .expect("SIGTERM helper readiness should not time out") + .expect("SIGTERM helper stdout should be readable") + .expect("SIGTERM helper should announce readiness"); + if line.contains("SIGNAL-SCOPE-READY") { + break; + } + } + + let raw_pid = i32::try_from(child.id().expect("SIGTERM helper should have a PID")) + .expect("SIGTERM helper PID should fit a Unix pid_t"); + let pid = + rustix::process::Pid::from_raw(raw_pid).expect("SIGTERM helper PID should be nonzero"); + rustix::process::kill_process(pid, rustix::process::Signal::TERM) + .expect("SIGTERM should be delivered to helper"); + + let mut settled = false; + while let Some(line) = tokio::time::timeout(Duration::from_secs(5), lines.next_line()) + .await + .expect("SIGTERM helper settlement should not time out") + .expect("SIGTERM helper stdout should remain readable") + { + if line.contains("SIGNAL-SCOPE-SETTLED") { + settled = true; + } + } + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("SIGTERM helper process should not hang") + .expect("SIGTERM helper process should be waitable"); + assert!(status.success(), "SIGTERM helper failed with {status}"); + assert!(settled, "SIGTERM helper must report settled ownership"); + } } diff --git a/crates/lanspread-peer/Cargo.toml b/crates/lanspread-peer/Cargo.toml index f124e0f..d9f2de9 100644 --- a/crates/lanspread-peer/Cargo.toml +++ b/crates/lanspread-peer/Cargo.toml @@ -13,6 +13,8 @@ lanspread-proto = { path = "../lanspread-proto" } lanspread-utils = { path = "../lanspread-utils" } # external +base64 = { workspace = true } +blake3 = { workspace = true } bytes = { workspace = true } cap-fs-ext = { workspace = true } cap-primitives = { workspace = true } @@ -22,17 +24,21 @@ futures = { workspace = true } gethostname = { workspace = true } if-addrs = { workspace = true } log = { workspace = true } -notify = { workspace = true } +rcgen = { workspace = true } +rustls = { workspace = true } s2n-quic = { workspace = true } +s2n-quic-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } strum = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } unicode-normalization = { workspace = true } -uuid = { workspace = true } walkdir = { workspace = true } +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + [lints.clippy] pedantic = { level = "warn", priority = -1 } todo = "warn" diff --git a/crates/lanspread-peer/src/call_to_play.rs b/crates/lanspread-peer/src/call_to_play.rs index 8aebffa..50d5959 100644 --- a/crates/lanspread-peer/src/call_to_play.rs +++ b/crates/lanspread-peer/src/call_to_play.rs @@ -1,913 +1,2518 @@ -//! Replicated event history for Call to Play coordination. +//! Direct, author-owned Call-to-Play state. use std::{ - collections::{BTreeSet, HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, fmt, time::{SystemTime, UNIX_EPOCH}, }; -use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent}; -use tokio::sync::mpsc::UnboundedSender; - -use crate::{ - PeerEvent, - context::Ctx, - events, - network::send_call_to_play_events, - services::{HandshakeCtx, perform_handshake_with_peer}, +use lanspread_proto::{ + CallId, + CallNonce, + CallToPlayAction, + CallToPlayAuthorEvent, + CallToPlayAuthorSnapshot, + ControlValidationError, + EventNonce, + MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, + MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR, + PeerId, + RuntimeSessionId, }; -const MAX_EVENTS: usize = 4_096; -const MAX_ID_CHARS: usize = 128; -const MAX_GAME_ID_CHARS: usize = 256; -const MAX_USERNAME_CHARS: usize = 24; -const MAX_MESSAGE_CHARS: usize = 500; +use crate::peer_db::PeerEndpointGeneration; + +pub(crate) const MAX_CALL_TO_PLAY_AUTHORS: usize = 64; +pub(crate) const LOCAL_TERMINAL_EVENT_RESERVE: usize = 1; +pub(crate) const LOCAL_TERMINAL_BYTE_RESERVE: usize = 512; + const EXPIRED_RETENTION_MS: i64 = 5 * 60_000; const TERMINAL_RETENTION_MS: i64 = 15 * 60_000; -#[derive(Debug, Default)] -pub(crate) struct CallToPlayStore { - events: Vec, +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CallToPlayLocalIntent { + pub(crate) call_id: Option, + pub(crate) action: CallToPlayLocalAction, } -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct BatchMerge { - pub(crate) applied: Vec, - pub(crate) duplicates: usize, - pub(crate) obsolete: usize, - pub(crate) missing_call_ids: Vec, +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CallToPlayLocalAction { + Create { + game_id: String, + max_players: u16, + scheduled_for: Option, + deadline: i64, + }, + Respond { + ready_at: Option, + }, + Rsvp, + SendMessage { + text: String, + }, + Leave, + Cancel, + Start, + AddTime { + deadline: i64, + }, } -impl BatchMerge { - pub(crate) fn needs_history(&self) -> bool { - !self.missing_call_ids.is_empty() +impl CallToPlayLocalAction { + fn into_wire(self) -> CallToPlayAction { + match self { + Self::Create { + game_id, + max_players, + scheduled_for, + deadline, + } => CallToPlayAction::Create { + game_id, + max_players, + scheduled_for, + deadline, + }, + Self::Respond { ready_at } => CallToPlayAction::Respond { ready_at }, + Self::Rsvp => CallToPlayAction::Rsvp, + Self::SendMessage { text } => CallToPlayAction::SendMessage { text }, + Self::Leave => CallToPlayAction::Leave, + Self::Cancel => CallToPlayAction::Cancel, + Self::Start => CallToPlayAction::Start, + Self::AddTime { deadline } => CallToPlayAction::AddTime { deadline }, + } + } + + const fn is_create(&self) -> bool { + matches!(self, Self::Create { .. }) + } + + const fn is_terminal(&self) -> bool { + matches!(self, Self::Cancel | Self::Start) + } + + const fn requires_creator_authority(&self) -> bool { + matches!(self, Self::Cancel | Self::Start | Self::AddTime { .. }) } } -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum MergeError { - Invalid(&'static str), - ConflictingEvent(String), - HistoryFull, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CallToPlayReceipt { + pub(crate) call_id: CallId, + pub(crate) event_id: EventNonce, + pub(crate) revision: u64, } -impl fmt::Display for MergeError { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CallToPlayMutation { + pub(crate) revision: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CallToPlayPublication { + pub(crate) view: CallToPlayView, + pub(crate) local_revision: u64, + pub(crate) local_changed: bool, +} + +/// A fallibility boundary captured before an atomic remote-state commit. +/// +/// Preparation samples the clock and computes any fallible local-pruning +/// candidate without mutating the store. Projection after a remote slice +/// mutation is then infallible and uses this single time boundary. +#[derive(Debug)] +pub(crate) struct PreparedCallToPlayPublication { + now: i64, + base_local_revision: u64, + pruned_local: Option, +} + +impl PreparedCallToPlayPublication { + #[must_use] + pub(crate) const fn local_changed(&self) -> bool { + self.pruned_local.is_some() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CallToPlayView { + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CallToPlayViewEvent { + pub(crate) id: EventNonce, + pub(crate) call_id: CallId, + pub(crate) author_id: PeerId, + pub(crate) author_name: String, + pub(crate) at: i64, + pub(crate) action: CallToPlayAction, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct RemoteAuthorState { + pub(crate) endpoint_generation: PeerEndpointGeneration, + pub(crate) runtime_session_id: RuntimeSessionId, + pub(crate) revision: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CallToPlayValidationError { + Wire(ControlValidationError), + SnapshotTooLarge { + actual: usize, + maximum: usize, + }, + SnapshotEncoding, + DuplicateEventId(EventNonce), + DuplicateCreate(CallId), + InvalidEvent { + event_id: EventNonce, + reason: &'static str, + }, + UnauthorizedAction { + event_id: EventNonce, + call_id: CallId, + }, + MissingCreatorRoot(CallId), + NonMonotonicAuthorHistory, + NonMonotonicCallHistory(CallId), + ActionAfterTerminal(CallId), +} + +impl fmt::Display for CallToPlayValidationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Invalid(reason) => formatter.write_str(reason), - Self::ConflictingEvent(event_id) => { - write!(formatter, "conflicting Call to Play event ID {event_id}") + Self::Wire(error) => write!(formatter, "{error}"), + Self::SnapshotTooLarge { actual, maximum } => write!( + formatter, + "Call-to-Play author snapshot is {actual} bytes; maximum is {maximum}" + ), + Self::SnapshotEncoding => { + formatter.write_str("Call-to-Play author snapshot could not be encoded") + } + Self::DuplicateEventId(event_id) => { + write!(formatter, "duplicate Call-to-Play event ID {event_id}") + } + Self::DuplicateCreate(call_id) => { + write!(formatter, "duplicate Call-to-Play creator root {call_id}") + } + Self::InvalidEvent { event_id, reason } => { + write!(formatter, "invalid Call-to-Play event {event_id}: {reason}") + } + Self::UnauthorizedAction { event_id, call_id } => write!( + formatter, + "Call-to-Play event {event_id} is not authoritative for {call_id}" + ), + Self::MissingCreatorRoot(call_id) => { + write!(formatter, "Call-to-Play call {call_id} has no creator root") + } + Self::NonMonotonicAuthorHistory => { + formatter.write_str("Call-to-Play author events are not timestamp ordered") + } + Self::NonMonotonicCallHistory(call_id) => { + write!( + formatter, + "Call-to-Play call {call_id} has non-monotonic history" + ) + } + Self::ActionAfterTerminal(call_id) => { + write!( + formatter, + "Call-to-Play call {call_id} has an action after termination" + ) } - Self::HistoryFull => formatter.write_str("Call to Play event history is full"), } } } -impl CallToPlayStore { - pub(crate) fn snapshot(&mut self) -> Vec { - self.snapshot_at(now_ms()) - } +impl std::error::Error for CallToPlayValidationError {} - fn snapshot_at(&mut self, now: i64) -> Vec { - compact_history(&mut self.events, now); - self.events.clone() - } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CallToPlayMutationError { + InvalidIntent(&'static str), + UnknownOrExpiredCall(CallId), + CreatorAuthorityRequired(CallId), + CallAlreadyTerminal(CallId), + EventHistoryFull, + RevisionExhausted, + ClockUnavailable, + EntropyUnavailable, + InvalidSnapshot(CallToPlayValidationError), +} - pub(crate) fn merge_batch( - &mut self, - incoming: Vec, - ) -> Result { - self.merge_batch_at(incoming, now_ms()) - } - - fn merge_batch_at( - &mut self, - incoming: Vec, - now: i64, - ) -> Result { - for event in &incoming { - validate_event(event).map_err(MergeError::Invalid)?; - } - - let (incoming, mut duplicates) = deduplicate_batch(incoming)?; - - // Expiry is evaluated before accepting new actions. An action cannot keep - // an already-retired root alive by itself; the sender must provide the - // complete history in the same batch. - let mut retained = self.events.clone(); - compact_history(&mut retained, now); - - let retained_by_id = retained - .iter() - .map(|event| (event.id.as_str(), event)) - .collect::>(); - let mut new_events = Vec::with_capacity(incoming.len()); - for event in incoming { - if let Some(existing) = retained_by_id.get(event.id.as_str()) { - if *existing != &event { - return Err(MergeError::ConflictingEvent(event.id)); - } - duplicates += 1; - } else { - new_events.push(event); +impl fmt::Display for CallToPlayMutationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidIntent(reason) => formatter.write_str(reason), + Self::UnknownOrExpiredCall(call_id) => { + write!( + formatter, + "Call-to-Play call {call_id} is unknown or expired" + ) } - } - drop(retained_by_id); - - let rooted_calls = retained - .iter() - .chain(&new_events) - .filter(|event| matches!(event.action, CallToPlayAction::Create { .. })) - .map(|event| event.call_id.clone()) - .collect::>(); - let retained_tombstones = terminal_tombstone_call_ids(&retained); - let retained_history = HistoryIndex::build(&retained); - let mut applicable = Vec::with_capacity(new_events.len()); - let mut missing_call_ids = BTreeSet::new(); - let mut obsolete = 0; - - for event in new_events { - if retained_tombstones.contains(event.call_id.as_str()) { - obsolete += 1; - continue; + Self::CreatorAuthorityRequired(call_id) => { + write!(formatter, "creator authority is required for {call_id}") } - if let Some(terminal) = retained_history.terminal_events.get(&event.call_id) - && (matches!(event.action, CallToPlayAction::Create { .. }) - || (event.at, event.id.as_str()) > terminal.order_key()) - { - obsolete += 1; - continue; + Self::CallAlreadyTerminal(call_id) => { + write!(formatter, "Call-to-Play call {call_id} is already terminal") } - if !matches!(event.action, CallToPlayAction::Create { .. }) - && !rooted_calls.contains(event.call_id.as_str()) - { - missing_call_ids.insert(event.call_id); - continue; + Self::EventHistoryFull => { + formatter.write_str("Call-to-Play local event history is full") } - applicable.push(event); + Self::RevisionExhausted => { + formatter.write_str("Call-to-Play local revision is exhausted") + } + Self::ClockUnavailable => formatter.write_str("system clock is unavailable"), + Self::EntropyUnavailable => { + formatter.write_str("secure random number generation is unavailable") + } + Self::InvalidSnapshot(error) => write!(formatter, "{error}"), } - - let applicable_ids = applicable - .iter() - .map(|event| event.id.clone()) - .collect::>(); - retained.extend(applicable); - compact_history(&mut retained, now); - - if unresolved_event_count(&retained) > MAX_EVENTS { - return Err(MergeError::HistoryFull); - } - - let retained_ids = retained - .iter() - .map(|event| event.id.clone()) - .collect::>(); - let applied = retained - .iter() - .filter(|event| applicable_ids.contains(&event.id)) - .cloned() - .collect::>(); - obsolete += applicable_ids - .iter() - .filter(|event_id| !retained_ids.contains(*event_id)) - .count(); - - self.events = retained; - Ok(BatchMerge { - applied, - duplicates, - obsolete, - missing_call_ids: missing_call_ids.into_iter().collect(), - }) } } -fn deduplicate_batch( - incoming: Vec, -) -> Result<(Vec, usize), MergeError> { - let mut unique = Vec::::with_capacity(incoming.len()); - let mut indexes = HashMap::::with_capacity(incoming.len()); - let mut duplicates = 0; +impl std::error::Error for CallToPlayMutationError {} - for event in incoming { - if let Some(index) = indexes.get(&event.id).copied() { - if unique[index] != event { - return Err(MergeError::ConflictingEvent(event.id)); - } - duplicates += 1; - } else { - indexes.insert(event.id.clone(), unique.len()); - unique.push(event); - } - } - - Ok((unique, duplicates)) +#[derive(Debug)] +pub(crate) struct PreparedRemoteAuthor { + author_id: PeerId, + endpoint_generation: PeerEndpointGeneration, + runtime_session_id: RuntimeSessionId, + candidate: PreparedRemoteCandidate, } #[derive(Debug)] -struct HistoryIndex { - creators: HashMap, - terminal_events: HashMap, - extensions: HashMap, +enum PreparedRemoteCandidate { + Valid(CallToPlayAuthorSnapshot), + Invalid(CallToPlayValidationError), } -#[derive(Clone, Debug)] -struct CreateRecord { - at: i64, - event_id: String, - actor_id: String, - deadline: i64, +impl PreparedRemoteAuthor { + /// Performs all allocation, encoding, and semantic validation without a + /// store or peer-database lock. The invalid candidate is retained so the + /// locked observation can apply session-clearing rules atomically. + pub(crate) fn prepare( + author_id: PeerId, + endpoint_generation: PeerEndpointGeneration, + runtime_session_id: RuntimeSessionId, + snapshot: CallToPlayAuthorSnapshot, + ) -> Self { + let candidate = match validate_author_snapshot(author_id, &snapshot) { + Ok(()) => PreparedRemoteCandidate::Valid(snapshot), + Err(error) => PreparedRemoteCandidate::Invalid(error), + }; + Self { + author_id, + endpoint_generation, + runtime_session_id, + candidate, + } + } + + #[must_use] + pub(crate) fn validation_error(&self) -> Option<&CallToPlayValidationError> { + match &self.candidate { + PreparedRemoteCandidate::Valid(_) => None, + PreparedRemoteCandidate::Invalid(error) => Some(error), + } + } } -impl CreateRecord { - fn order_key(&self) -> (i64, &str) { - (self.at, &self.event_id) +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ObserveRemoteAuthorOutcome { + Applied { + session_changed: bool, + }, + Unchanged { + generation_rebound: bool, + }, + IgnoredStale { + generation_rebound: bool, + }, + EqualRevisionConflict { + generation_rebound: bool, + }, + InvalidCleared(CallToPlayValidationError), + InvalidPreserved { + error: CallToPlayValidationError, + generation_rebound: bool, + }, + InvalidAbsent(CallToPlayValidationError), + AtCapacity, + RejectedLocalIdentity, +} + +impl ObserveRemoteAuthorOutcome { + #[must_use] + pub(crate) const fn view_changed(&self) -> bool { + matches!(self, Self::Applied { .. } | Self::InvalidCleared(_)) } } #[derive(Clone, Debug)] -struct EventRecord { - at: i64, - event_id: String, +struct RemoteAuthorSlice { + endpoint_generation: PeerEndpointGeneration, + runtime_session_id: RuntimeSessionId, + snapshot: CallToPlayAuthorSnapshot, } -impl EventRecord { - fn order_key(&self) -> (i64, &str) { - (self.at, &self.event_id) +#[derive(Debug)] +pub(crate) struct CallToPlayStore { + local_peer_id: PeerId, + local: CallToPlayAuthorSnapshot, + remote: BTreeMap, + last_publication_at: i64, + #[cfg(test)] + projection_count: usize, +} + +impl CallToPlayStore { + pub(crate) fn new( + local_peer_id: PeerId, + _runtime_session_id: RuntimeSessionId, + display_name: String, + ) -> Result { + let local = CallToPlayAuthorSnapshot { + revision: 0, + display_name, + events: Vec::new(), + }; + validate_author_snapshot(local_peer_id, &local) + .map_err(CallToPlayMutationError::InvalidSnapshot)?; + ensure_local_terminal_reserve(&local, false)?; + Ok(Self { + local_peer_id, + local, + remote: BTreeMap::new(), + last_publication_at: 0, + #[cfg(test)] + projection_count: 0, + }) } -} -#[derive(Clone, Debug)] -struct ExtensionRecord { - event: EventRecord, - deadline: i64, -} + pub(crate) fn publish_local( + &mut self, + intent: CallToPlayLocalIntent, + display_name: String, + ) -> Result<(CallToPlayReceipt, CallToPlayPublication), CallToPlayMutationError> { + let now = now_ms()?.max(self.last_publication_at); + let event_nonce = EventNonce::from_bytes(random_nonce_bytes()?); + let call_nonce = intent + .action + .is_create() + .then(|| random_nonce_bytes().map(CallNonce::from_bytes)) + .transpose()?; + let receipt = self.publish_local_at(intent, display_name, now, call_nonce, event_nonce)?; + Ok((receipt, self.project_publication_at(now, true))) + } -impl HistoryIndex { - fn build(events: &[CallToPlayEvent]) -> Self { - let mut creators = HashMap::::new(); - for event in events { - let CallToPlayAction::Create { deadline, .. } = event.action else { - continue; - }; - let candidate = CreateRecord { - at: event.at, - event_id: event.id.clone(), - actor_id: event.actor_id.clone(), - deadline, - }; - creators - .entry(event.call_id.clone()) - .and_modify(|current| { - if candidate.order_key() < current.order_key() { - current.clone_from(&candidate); - } - }) - .or_insert(candidate); + pub(crate) fn set_local_display_name( + &mut self, + display_name: String, + ) -> Result<(Option, CallToPlayPublication), CallToPlayMutationError> { + let now = now_ms()?.max(self.last_publication_at); + let mutation = self.set_local_display_name_at(display_name, now)?; + let publication = self.project_publication_at(now, mutation.is_some()); + Ok((mutation, publication)) + } + + pub(crate) fn current_publication( + &mut self, + ) -> Result { + let now = now_ms()?.max(self.last_publication_at); + self.publication_at(now) + } + + pub(crate) fn current_responder_state( + &mut self, + ) -> Result<(u64, Option), CallToPlayMutationError> { + let now = now_ms()?.max(self.last_publication_at); + self.responder_state_at(now) + } + + pub(crate) fn local_responder_snapshot( + &mut self, + ) -> Result< + (CallToPlayAuthorSnapshot, u64, Option), + CallToPlayMutationError, + > { + let now = now_ms()?.max(self.last_publication_at); + self.local_responder_snapshot_at(now) + } + + fn local_responder_snapshot_at( + &mut self, + now: i64, + ) -> Result< + (CallToPlayAuthorSnapshot, u64, Option), + CallToPlayMutationError, + > { + let (revision, publication) = self.responder_state_at(now)?; + Ok((self.local.clone(), revision, publication)) + } + + pub(crate) fn prepare_publication( + &self, + ) -> Result { + self.prepare_publication_at(now_ms()?.max(self.last_publication_at)) + } + + fn prepare_publication_at( + &self, + now: i64, + ) -> Result { + Ok(PreparedCallToPlayPublication { + now, + base_local_revision: self.local.revision, + pruned_local: self.pruned_local_candidate_at(now)?, + }) + } + + #[must_use] + pub(crate) fn view_from_prepared( + &mut self, + prepared: PreparedCallToPlayPublication, + ) -> CallToPlayPublication { + let PreparedCallToPlayPublication { + mut now, + base_local_revision, + pruned_local, + } = prepared; + now = now.max(self.last_publication_at); + let local_changed = if self.local.revision == base_local_revision { + if let Some(pruned_local) = pruned_local { + self.local = pruned_local; + true + } else { + false + } + } else { + false + }; + self.project_publication_at(now, local_changed) + } + + /// Commits a prepared candidate while the caller holds the peer-database + /// write lock before this store's write lock and has rechecked the pinned + /// endpoint generation. + pub(crate) fn observe_prepared_remote( + &mut self, + prepared: PreparedRemoteAuthor, + ) -> ObserveRemoteAuthorOutcome { + let PreparedRemoteAuthor { + author_id, + endpoint_generation, + runtime_session_id, + candidate, + } = prepared; + + if author_id == self.local_peer_id { + return ObserveRemoteAuthorOutcome::RejectedLocalIdentity; } - let mut terminal_events = HashMap::::new(); - let mut extensions = HashMap::::new(); - for event in events { - let Some(creator) = creators.get(&event.call_id) else { + let snapshot = match candidate { + PreparedRemoteCandidate::Valid(snapshot) => snapshot, + PreparedRemoteCandidate::Invalid(error) => { + let Some(current) = self.remote.get_mut(&author_id) else { + return ObserveRemoteAuthorOutcome::InvalidAbsent(error); + }; + if current.runtime_session_id != runtime_session_id { + self.remote.remove(&author_id); + return ObserveRemoteAuthorOutcome::InvalidCleared(error); + } + let generation_rebound = current.endpoint_generation != endpoint_generation; + current.endpoint_generation = endpoint_generation; + return ObserveRemoteAuthorOutcome::InvalidPreserved { + error, + generation_rebound, + }; + } + }; + + if let Some(current) = self.remote.get_mut(&author_id) { + let session_changed = current.runtime_session_id != runtime_session_id; + if session_changed || snapshot.revision > current.snapshot.revision { + *current = RemoteAuthorSlice { + endpoint_generation, + runtime_session_id, + snapshot, + }; + return ObserveRemoteAuthorOutcome::Applied { session_changed }; + } + + let generation_rebound = current.endpoint_generation != endpoint_generation; + current.endpoint_generation = endpoint_generation; + if snapshot.revision < current.snapshot.revision { + return ObserveRemoteAuthorOutcome::IgnoredStale { generation_rebound }; + } + return if snapshot == current.snapshot { + ObserveRemoteAuthorOutcome::Unchanged { generation_rebound } + } else { + ObserveRemoteAuthorOutcome::EqualRevisionConflict { generation_rebound } + }; + } + + if self.remote.len() >= MAX_CALL_TO_PLAY_AUTHORS - 1 { + return ObserveRemoteAuthorOutcome::AtCapacity; + } + self.remote.insert( + author_id, + RemoteAuthorSlice { + endpoint_generation, + runtime_session_id, + snapshot, + }, + ); + ObserveRemoteAuthorOutcome::Applied { + session_changed: true, + } + } + + /// Clears every remote author and returns the resulting full, local-only + /// publication. + /// + /// The operation is infallible: a failed clock sample or normal local + /// prune is returned as the optional diagnostic after the remote slices + /// have still been cleared and projected. Such a failure preserves the + /// local author exactly. On success, the local revision changes only when + /// ordinary retention pruning requires it. + #[must_use = "the replacement publication and any maintenance diagnostic must be handled"] + pub(crate) fn clear_remote_authors_and_project( + &mut self, + ) -> (CallToPlayPublication, Option) { + self.clear_remote_authors_and_project_at(now_ms()) + } + + fn clear_remote_authors_and_project_at( + &mut self, + now: Result, + ) -> (CallToPlayPublication, Option) { + let now = match now { + Ok(now) => now.max(self.last_publication_at), + Err(error) => { + self.remote.clear(); + let fallback_now = self.last_publication_at; + let publication = self.project_publication_at(fallback_now, false); + return (publication, Some(error)); + } + }; + let pruned_local = self.pruned_local_candidate_at(now); + self.remote.clear(); + match pruned_local { + Ok(pruned_local) => { + let local_changed = pruned_local.is_some(); + if let Some(pruned_local) = pruned_local { + self.local = pruned_local; + } + (self.project_publication_at(now, local_changed), None) + } + Err(error) => (self.project_publication_at(now, false), Some(error)), + } + } + + pub(crate) fn remove_remote_author_if_generation( + &mut self, + author_id: PeerId, + endpoint_generation: PeerEndpointGeneration, + ) -> bool { + if self + .remote + .get(&author_id) + .is_none_or(|slice| slice.endpoint_generation != endpoint_generation) + { + return false; + } + self.remote.remove(&author_id).is_some() + } + + #[must_use] + pub(crate) fn remote_author_state(&self, author_id: PeerId) -> Option { + self.remote.get(&author_id).map(|slice| RemoteAuthorState { + endpoint_generation: slice.endpoint_generation, + runtime_session_id: slice.runtime_session_id, + revision: slice.snapshot.revision, + }) + } + + #[must_use] + #[cfg(test)] + pub(crate) fn remote_author_count(&self) -> usize { + self.remote.len() + } + + fn publish_local_at( + &mut self, + intent: CallToPlayLocalIntent, + display_name: String, + now: i64, + call_nonce: Option, + event_nonce: EventNonce, + ) -> Result { + if now <= 0 { + return Err(CallToPlayMutationError::ClockUnavailable); + } + + let CallToPlayLocalIntent { call_id, action } = intent; + let is_create = action.is_create(); + let is_terminal = action.is_terminal(); + let requires_creator_authority = action.requires_creator_authority(); + let call_id = if is_create { + if call_id.is_some() { + return Err(CallToPlayMutationError::InvalidIntent( + "Create must not supply a call ID", + )); + } + CallId::new( + self.local_peer_id, + call_nonce.ok_or(CallToPlayMutationError::EntropyUnavailable)?, + ) + } else { + call_id.ok_or(CallToPlayMutationError::InvalidIntent( + "non-Create action requires a call ID", + ))? + }; + + if requires_creator_authority && call_id.creator != self.local_peer_id { + return Err(CallToPlayMutationError::CreatorAuthorityRequired(call_id)); + } + + let retained_events = self.retained_local_events_at(now); + let event_at = retained_events + .last() + .map_or(now, |event| now.max(event.at)); + if !is_create { + let Some(window) = self.call_window_at(call_id, now, &retained_events) else { + return Err(CallToPlayMutationError::UnknownOrExpiredCall(call_id)); + }; + if window.terminal_at.is_some() { + return Err(CallToPlayMutationError::CallAlreadyTerminal(call_id)); + } + } + + let mut candidate = CallToPlayAuthorSnapshot { + revision: self + .local + .revision + .checked_add(1) + .ok_or(CallToPlayMutationError::RevisionExhausted)?, + display_name, + events: retained_events, + }; + candidate.events.push(CallToPlayAuthorEvent { + id: event_nonce, + call_id, + at: event_at, + action: action.into_wire(), + }); + validate_author_snapshot(self.local_peer_id, &candidate) + .map_err(CallToPlayMutationError::InvalidSnapshot)?; + ensure_local_terminal_reserve(&candidate, is_terminal)?; + + self.local = candidate; + Ok(CallToPlayReceipt { + call_id, + event_id: event_nonce, + revision: self.local.revision, + }) + } + + fn set_local_display_name_at( + &mut self, + display_name: String, + now: i64, + ) -> Result, CallToPlayMutationError> { + let retained_events = self.retained_local_events_at(now); + if display_name == self.local.display_name && retained_events == self.local.events { + return Ok(None); + } + let candidate = CallToPlayAuthorSnapshot { + revision: self + .local + .revision + .checked_add(1) + .ok_or(CallToPlayMutationError::RevisionExhausted)?, + display_name, + events: retained_events, + }; + validate_author_snapshot(self.local_peer_id, &candidate) + .map_err(CallToPlayMutationError::InvalidSnapshot)?; + self.local = candidate; + Ok(Some(CallToPlayMutation { + revision: self.local.revision, + })) + } + + fn prune_local_at( + &mut self, + now: i64, + ) -> Result, CallToPlayMutationError> { + let Some(candidate) = self.pruned_local_candidate_at(now)? else { + return Ok(None); + }; + self.local = candidate; + Ok(Some(CallToPlayMutation { + revision: self.local.revision, + })) + } + + fn pruned_local_candidate_at( + &self, + now: i64, + ) -> Result, CallToPlayMutationError> { + let expired = self.expired_local_call_ids_at(now); + if expired.is_empty() { + return Ok(None); + } + let retained_events = self + .local + .events + .iter() + .filter(|event| !expired.contains(&event.call_id)) + .cloned() + .collect(); + let candidate = CallToPlayAuthorSnapshot { + revision: self + .local + .revision + .checked_add(1) + .ok_or(CallToPlayMutationError::RevisionExhausted)?, + display_name: self.local.display_name.clone(), + events: retained_events, + }; + validate_author_snapshot(self.local_peer_id, &candidate) + .map_err(CallToPlayMutationError::InvalidSnapshot)?; + Ok(Some(candidate)) + } + + #[cfg(test)] + fn local_snapshot_at( + &mut self, + now: i64, + ) -> Result { + self.prune_local_at(now)?; + Ok(self.local.clone()) + } + + #[cfg(test)] + fn view_at(&mut self, now: i64) -> Result { + Ok(self.publication_at(now)?.view) + } + + fn publication_at( + &mut self, + now: i64, + ) -> Result { + let local_changed = self.prune_local_at(now)?.is_some(); + Ok(self.project_publication_at(now, local_changed)) + } + + fn responder_state_at( + &mut self, + now: i64, + ) -> Result<(u64, Option), CallToPlayMutationError> { + let local_changed = self.prune_local_at(now)?.is_some(); + if !local_changed { + return Ok((self.local.revision, None)); + } + let publication = self.project_publication_at(now, true); + Ok((publication.local_revision, Some(publication))) + } + + fn project_publication_at(&mut self, now: i64, local_changed: bool) -> CallToPlayPublication { + let now = now.max(self.last_publication_at); + self.last_publication_at = now; + #[cfg(test)] + { + self.projection_count += 1; + } + CallToPlayPublication { + view: self.project_view_at(now), + local_revision: self.local.revision, + local_changed, + } + } + + fn project_view_at(&self, now: i64) -> CallToPlayView { + let windows = self.call_windows(); + let mut events = Vec::new(); + Self::extend_visible_author_events( + self.local_peer_id, + &self.local, + now, + &windows, + &mut events, + ); + for (author_id, slice) in &self.remote { + Self::extend_visible_author_events( + *author_id, + &slice.snapshot, + now, + &windows, + &mut events, + ); + } + events.sort_by_key(|event| (event.at, event.call_id, event.author_id, event.id)); + CallToPlayView { events } + } + + fn extend_visible_author_events( + author_id: PeerId, + snapshot: &CallToPlayAuthorSnapshot, + now: i64, + windows: &HashMap, + output: &mut Vec, + ) { + for event in &snapshot.events { + let Some(window) = windows.get(&event.call_id) else { continue; }; - if event.actor_id != creator.actor_id - || (event.at, event.id.as_str()) <= creator.order_key() + if !window.is_visible_at(now) + || event.at < window.created_at + || window + .terminal_at + .is_some_and(|terminal_at| event.at > terminal_at) { continue; } - - if matches!( - event.action, - CallToPlayAction::Cancel | CallToPlayAction::Start - ) { - let candidate = EventRecord { - at: event.at, - event_id: event.id.clone(), - }; - terminal_events - .entry(event.call_id.clone()) - .and_modify(|current| { - if candidate.order_key() < current.order_key() { - current.clone_from(&candidate); - } - }) - .or_insert(candidate); - } else if let CallToPlayAction::AddTime { deadline } = event.action { - let candidate = ExtensionRecord { - event: EventRecord { - at: event.at, - event_id: event.id.clone(), - }, - deadline, - }; - extensions - .entry(event.call_id.clone()) - .and_modify(|current| { - if candidate.event.order_key() > current.event.order_key() { - current.clone_from(&candidate); - } - }) - .or_insert(candidate); - } - } - - Self { - creators, - terminal_events, - extensions, + output.push(CallToPlayViewEvent { + id: event.id, + call_id: event.call_id, + author_id, + author_name: snapshot.display_name.clone(), + at: event.at, + action: event.action.clone(), + }); } } - fn expired_call_ids(&self, now: i64) -> HashSet<&str> { - self.creators + fn retained_local_events_at(&self, now: i64) -> Vec { + let expired = self.expired_local_call_ids_at(now); + self.local + .events .iter() - .filter_map(|(call_id, creator)| { - if self.terminal_events.contains_key(call_id) { - return None; - } - let deadline = self - .extensions - .get(call_id) - .map_or(creator.deadline, |extension| extension.deadline); - (now - deadline > EXPIRED_RETENTION_MS).then_some(call_id.as_str()) + .filter(|event| !expired.contains(&event.call_id)) + .cloned() + .collect() + } + + fn expired_local_call_ids_at(&self, now: i64) -> HashSet { + let local_call_ids = self + .local + .events + .iter() + .map(|event| event.call_id) + .collect::>(); + if local_call_ids.is_empty() { + return HashSet::new(); + } + self.call_windows() + .into_iter() + .filter_map(|(call_id, window)| { + (local_call_ids.contains(&call_id) && !window.is_visible_at(now)).then_some(call_id) }) .collect() } -} -fn compact_history(events: &mut Vec, now: i64) { - let index = HistoryIndex::build(events); - let expired_calls = index.expired_call_ids(now); - - events.retain(|event| { - if expired_calls.contains(event.call_id.as_str()) { - return false; - } - let Some(terminal) = index.terminal_events.get(&event.call_id) else { - return true; - }; - if (event.at, event.id.as_str()) > terminal.order_key() { - return false; - } - now - terminal.at <= TERMINAL_RETENTION_MS || event.id == terminal.event_id - }); -} - -fn terminal_tombstone_call_ids(events: &[CallToPlayEvent]) -> HashSet { - let rooted_calls = events - .iter() - .filter(|event| matches!(event.action, CallToPlayAction::Create { .. })) - .map(|event| event.call_id.clone()) - .collect::>(); - - events - .iter() - .filter(|event| { - matches!( - event.action, - CallToPlayAction::Cancel | CallToPlayAction::Start - ) && !rooted_calls.contains(event.call_id.as_str()) - }) - .map(|event| event.call_id.clone()) - .collect() -} - -fn unresolved_event_count(events: &[CallToPlayEvent]) -> usize { - let index = HistoryIndex::build(events); - events - .iter() - .filter(|event| { - index.creators.contains_key(&event.call_id) - && !index.terminal_events.contains_key(&event.call_id) - }) - .count() -} - -fn now_ms() -> i64 { - i64::try_from( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - ) - .unwrap_or(i64::MAX) -} - -pub(crate) async fn publish( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - mut event: CallToPlayEvent, -) -> Result<(), String> { - event.actor_id.clone_from(ctx.peer_id.as_ref()); - let merged = ctx - .call_to_play - .write() - .await - .merge_batch(vec![event.clone()]) - .map_err(|err| err.to_string())?; - if merged.needs_history() { - return Err("Call to Play history is missing".to_string()); - } - if merged.applied.is_empty() { - if merged.obsolete > 0 { - return Err("Call to Play event is obsolete".to_string()); - } - return Ok(()); + fn call_window_at( + &self, + call_id: CallId, + now: i64, + local_events: &[CallToPlayAuthorEvent], + ) -> Option { + let window = if call_id.creator == self.local_peer_id { + call_window_from_creator_events(call_id, local_events) + } else { + self.remote + .get(&call_id.creator) + .and_then(|slice| call_window_from_creator_events(call_id, &slice.snapshot.events)) + }?; + window.is_visible_at(now).then_some(window) } - events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied)); + fn call_windows(&self) -> HashMap { + let mut windows = call_windows_from_creator(self.local_peer_id, &self.local.events); + for (author_id, slice) in &self.remote { + windows.extend(call_windows_from_creator( + *author_id, + &slice.snapshot.events, + )); + } + windows + } +} - let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses(); - let peer_id = ctx.peer_id.clone(); - let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui); - ctx.task_tracker.spawn(async move { - let deliveries = peer_addresses.into_iter().map(|peer_addr| { - let event = event.clone(); - let peer_id = peer_id.clone(); - let handshake_ctx = handshake_ctx.clone(); - async move { - deliver_to_peer(handshake_ctx, peer_addr, peer_id.as_ref(), event).await; - } +fn validate_author_snapshot( + author_id: PeerId, + snapshot: &CallToPlayAuthorSnapshot, +) -> Result<(), CallToPlayValidationError> { + snapshot + .validate() + .map_err(CallToPlayValidationError::Wire)?; + let encoded_size = serde_json::to_vec(snapshot) + .map_err(|_| CallToPlayValidationError::SnapshotEncoding)? + .len(); + if encoded_size > MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES { + return Err(CallToPlayValidationError::SnapshotTooLarge { + actual: encoded_size, + maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, }); - futures::future::join_all(deliveries).await; - }); + } + + let mut event_ids = HashSet::with_capacity(snapshot.events.len()); + let mut creator_calls = HashMap::::new(); + if snapshot + .events + .windows(2) + .any(|events| events[0].at > events[1].at) + { + return Err(CallToPlayValidationError::NonMonotonicAuthorHistory); + } + for event in &snapshot.events { + if !event_ids.insert(event.id) { + return Err(CallToPlayValidationError::DuplicateEventId(event.id)); + } + validate_event_fields(event)?; + if is_creator_only_action(&event.action) && event.call_id.creator != author_id { + return Err(CallToPlayValidationError::UnauthorizedAction { + event_id: event.id, + call_id: event.call_id, + }); + } + if let CallToPlayAction::Create { deadline, .. } = event.action + && creator_calls + .insert( + event.call_id, + CreatorValidationState { + created_at: event.at, + last_at: event.at, + deadline, + terminal: false, + }, + ) + .is_some() + { + return Err(CallToPlayValidationError::DuplicateCreate(event.call_id)); + } + } + + let mut seen_roots = HashSet::new(); + for event in &snapshot.events { + if event.call_id.creator != author_id { + continue; + } + if matches!(event.action, CallToPlayAction::Create { .. }) { + seen_roots.insert(event.call_id); + continue; + } + if !seen_roots.contains(&event.call_id) { + return Err(CallToPlayValidationError::MissingCreatorRoot(event.call_id)); + } + let state = creator_calls + .get_mut(&event.call_id) + .ok_or(CallToPlayValidationError::MissingCreatorRoot(event.call_id))?; + if event.at < state.created_at || event.at < state.last_at { + return Err(CallToPlayValidationError::NonMonotonicCallHistory( + event.call_id, + )); + } + if state.terminal { + return Err(CallToPlayValidationError::ActionAfterTerminal( + event.call_id, + )); + } + if let CallToPlayAction::AddTime { deadline } = event.action { + if deadline <= state.deadline { + return Err(CallToPlayValidationError::InvalidEvent { + event_id: event.id, + reason: "AddTime must extend the current deadline", + }); + } + state.deadline = deadline; + } + if matches!( + event.action, + CallToPlayAction::Cancel | CallToPlayAction::Start + ) { + state.terminal = true; + } + state.last_at = event.at; + } Ok(()) } -async fn deliver_to_peer( - handshake_ctx: HandshakeCtx, - peer_addr: std::net::SocketAddr, - peer_id: &str, - event: CallToPlayEvent, -) { - let delivery = send_call_to_play_events(peer_addr, peer_id, vec![event]).await; - match &delivery { - Ok(CallToPlayAck::Rejected { reason }) => { - log::warn!("Peer {peer_addr} rejected a Call to Play event: {reason}"); - } - Ok(CallToPlayAck::Obsolete) => { - log::debug!("Peer {peer_addr} already retired the Call to Play event"); - } - Err(err) => { - log::warn!("Failed to deliver a Call to Play event to {peer_addr}: {err}"); - } - Ok( - CallToPlayAck::Applied - | CallToPlayAck::Duplicate - | CallToPlayAck::NeedHandshake - | CallToPlayAck::NeedHistory, - ) => {} - } - - let Some(reason) = delivery_resync_reason(delivery.as_ref().map_err(|_| ())) else { - return; - }; - if let Err(err) = perform_handshake_with_peer(handshake_ctx, peer_addr, None).await { - log::warn!("Failed to {reason} with {peer_addr}: {err}"); - } -} - -fn delivery_resync_reason(delivery: Result<&CallToPlayAck, ()>) -> Option<&'static str> { - match delivery { - Err(()) => Some("heal a failed Call to Play delivery"), - Ok(CallToPlayAck::NeedHandshake) => Some("complete a requested Call to Play handshake"), - Ok(CallToPlayAck::NeedHistory) => Some("restore missing Call to Play history"), - Ok( - CallToPlayAck::Applied - | CallToPlayAck::Duplicate - | CallToPlayAck::Obsolete - | CallToPlayAck::Rejected { .. }, - ) => None, - } -} - -fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> { - validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?; - validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?; - validate_nonempty(&event.actor_id, MAX_ID_CHARS, "invalid actor id")?; - validate_nonempty(&event.actor_name, MAX_USERNAME_CHARS, "invalid actor name")?; +fn validate_event_fields(event: &CallToPlayAuthorEvent) -> Result<(), CallToPlayValidationError> { + event.validate().map_err(CallToPlayValidationError::Wire)?; if event.at <= 0 { - return Err("invalid event timestamp"); + return Err(CallToPlayValidationError::InvalidEvent { + event_id: event.id, + reason: "event timestamp must be positive", + }); } match &event.action { CallToPlayAction::Create { - game_id, max_players, scheduled_for, deadline, + .. } => { - validate_nonempty(game_id, MAX_GAME_ID_CHARS, "invalid game id")?; if !(2..=64).contains(max_players) { - return Err("max players must be between 2 and 64"); + return Err(CallToPlayValidationError::InvalidEvent { + event_id: event.id, + reason: "max players must be between 2 and 64", + }); } if *deadline <= event.at { - return Err("deadline must be after creation"); + return Err(CallToPlayValidationError::InvalidEvent { + event_id: event.id, + reason: "deadline must be after creation", + }); } if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) { - return Err("scheduled call deadline must match its start time"); + return Err(CallToPlayValidationError::InvalidEvent { + event_id: event.id, + reason: "scheduled call deadline must match its start time", + }); } + checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?; } CallToPlayAction::Respond { ready_at } => { if ready_at.is_some_and(|ready| ready < event.at) { - return Err("ready time cannot be before the response"); + return Err(CallToPlayValidationError::InvalidEvent { + event_id: event.id, + reason: "ready time cannot be before the response", + }); } } - CallToPlayAction::SendMessage { message_id, text } => { - validate_nonempty(message_id, MAX_ID_CHARS, "invalid message id")?; - validate_nonempty(text, MAX_MESSAGE_CHARS, "invalid message")?; - } CallToPlayAction::AddTime { deadline } => { if *deadline <= event.at { - return Err("extended deadline must be in the future"); + return Err(CallToPlayValidationError::InvalidEvent { + event_id: event.id, + reason: "extended deadline must be after the action", + }); } + checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?; + } + CallToPlayAction::Cancel | CallToPlayAction::Start => { + checked_retention_boundary(event.at, TERMINAL_RETENTION_MS, event.id)?; + } + CallToPlayAction::Rsvp | CallToPlayAction::SendMessage { .. } | CallToPlayAction::Leave => { } - CallToPlayAction::Rsvp - | CallToPlayAction::Leave - | CallToPlayAction::Cancel - | CallToPlayAction::Start => {} } - Ok(()) } -fn validate_nonempty( - value: &str, - max_chars: usize, - error: &'static str, -) -> Result<(), &'static str> { - if value.trim().is_empty() || value.chars().count() > max_chars { - return Err(error); +fn checked_retention_boundary( + timestamp: i64, + retention: i64, + event_id: EventNonce, +) -> Result { + timestamp + .checked_add(retention) + .ok_or(CallToPlayValidationError::InvalidEvent { + event_id, + reason: "timestamp overflows its retention boundary", + }) +} + +const fn is_creator_only_action(action: &CallToPlayAction) -> bool { + matches!( + action, + CallToPlayAction::Create { .. } + | CallToPlayAction::Cancel + | CallToPlayAction::Start + | CallToPlayAction::AddTime { .. } + ) +} + +fn ensure_local_terminal_reserve( + snapshot: &CallToPlayAuthorSnapshot, + terminal_action: bool, +) -> Result<(), CallToPlayMutationError> { + let event_limit = if terminal_action { + MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR + } else { + MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - LOCAL_TERMINAL_EVENT_RESERVE + }; + if snapshot.events.len() > event_limit { + return Err(CallToPlayMutationError::EventHistoryFull); + } + + let byte_limit = if terminal_action { + MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES + } else { + MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES - LOCAL_TERMINAL_BYTE_RESERVE + }; + let encoded_size = serde_json::to_vec(snapshot) + .map_err(|_| { + CallToPlayMutationError::InvalidSnapshot(CallToPlayValidationError::SnapshotEncoding) + })? + .len(); + if encoded_size > byte_limit { + return Err(CallToPlayMutationError::EventHistoryFull); } Ok(()) } +#[derive(Clone, Copy, Debug)] +struct CreatorValidationState { + created_at: i64, + last_at: i64, + deadline: i64, + terminal: bool, +} + +#[derive(Clone, Copy, Debug)] +struct CallWindow { + created_at: i64, + deadline: i64, + terminal_at: Option, +} + +impl CallWindow { + fn is_visible_at(self, now: i64) -> bool { + let boundary = self.terminal_at.map_or_else( + || { + self.deadline + .checked_add(EXPIRED_RETENTION_MS) + .expect("validated deadline retention cannot overflow") + }, + |terminal_at| { + terminal_at + .checked_add(TERMINAL_RETENTION_MS) + .expect("validated terminal retention cannot overflow") + }, + ); + now <= boundary + } +} + +fn call_windows_from_creator( + creator: PeerId, + events: &[CallToPlayAuthorEvent], +) -> HashMap { + let mut windows = HashMap::new(); + for event in events { + if event.call_id.creator != creator { + continue; + } + match event.action { + CallToPlayAction::Create { deadline, .. } => { + windows.insert( + event.call_id, + CallWindow { + created_at: event.at, + deadline, + terminal_at: None, + }, + ); + } + CallToPlayAction::AddTime { deadline } => { + if let Some(window) = windows.get_mut(&event.call_id) { + window.deadline = deadline; + } + } + CallToPlayAction::Cancel | CallToPlayAction::Start => { + if let Some(window) = windows.get_mut(&event.call_id) { + window.terminal_at = Some(event.at); + } + } + CallToPlayAction::Respond { .. } + | CallToPlayAction::Rsvp + | CallToPlayAction::SendMessage { .. } + | CallToPlayAction::Leave => {} + } + } + windows +} + +fn call_window_from_creator_events( + call_id: CallId, + events: &[CallToPlayAuthorEvent], +) -> Option { + call_windows_from_creator(call_id.creator, events).remove(&call_id) +} + +fn now_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| CallToPlayMutationError::ClockUnavailable)? + .as_millis(); + i64::try_from(millis).map_err(|_| CallToPlayMutationError::ClockUnavailable) +} + +fn random_nonce_bytes() -> Result<[u8; 16], CallToPlayMutationError> { + let mut bytes = [0_u8; 16]; + rustls::crypto::aws_lc_rs::default_provider() + .secure_random + .fill(&mut bytes) + .map_err(|_| CallToPlayMutationError::EntropyUnavailable)?; + Ok(bytes) +} + #[cfg(test)] mod tests { - use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent}; + use std::net::SocketAddr; - use super::{ - CallToPlayStore, - MAX_EVENTS, - MergeError, - TERMINAL_RETENTION_MS, - delivery_resync_reason, + use lanspread_proto::{ + CallToPlayAuthorSnapshot, + ControlValidationError, + LibrarySnapshot, + MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS, + PeerEndpoint, }; - const TEST_NOW: i64 = 8_000_000_000_000; + use super::*; + use crate::peer_db::PeerGameDB; - fn create_event(id: &str) -> CallToPlayEvent { - create_event_for("call-1", id) + const NOW: i64 = 8_000_000_000_000; + + fn peer(seed: u8) -> PeerId { + PeerId::from_bytes([seed; 32]) } - fn create_event_for(call_id: &str, id: &str) -> CallToPlayEvent { - CallToPlayEvent { - id: id.to_string(), - call_id: call_id.to_string(), - actor_id: "peer-alice".to_string(), - actor_name: "Alice".to_string(), - at: TEST_NOW, + fn session(seed: u8) -> RuntimeSessionId { + RuntimeSessionId::from_bytes([seed; 16]) + } + + fn nonce(value: u128) -> [u8; 16] { + value.to_be_bytes() + } + + fn event_nonce(value: u128) -> EventNonce { + EventNonce::from_bytes(nonce(value)) + } + + fn call_nonce(value: u128) -> CallNonce { + CallNonce::from_bytes(nonce(value)) + } + + fn store(local_peer: PeerId) -> CallToPlayStore { + CallToPlayStore::new(local_peer, session(1), "Local".to_owned()) + .expect("test store should be valid") + } + + fn snapshot( + revision: u64, + display_name: &str, + events: Vec, + ) -> CallToPlayAuthorSnapshot { + CallToPlayAuthorSnapshot { + revision, + display_name: display_name.to_owned(), + events, + } + } + + fn create_event( + creator: PeerId, + call_id: CallId, + id: u128, + at: i64, + deadline: i64, + ) -> CallToPlayAuthorEvent { + assert_eq!(creator, call_id.creator); + CallToPlayAuthorEvent { + id: event_nonce(id), + call_id, + at, action: CallToPlayAction::Create { - game_id: "game-1".to_string(), + game_id: "game".to_owned(), max_players: 4, scheduled_for: None, - deadline: TEST_NOW + 60_000, + deadline, }, } } - fn action_event(id: &str, call_id: &str, action: CallToPlayAction) -> CallToPlayEvent { - CallToPlayEvent { - id: id.to_string(), - call_id: call_id.to_string(), - actor_id: "peer-alice".to_string(), - actor_name: "Alice".to_string(), - at: TEST_NOW + 1_000, + fn action_event( + call_id: CallId, + id: u128, + at: i64, + action: CallToPlayAction, + ) -> CallToPlayAuthorEvent { + CallToPlayAuthorEvent { + id: event_nonce(id), + call_id, + at, + action, + } + } + + fn endpoint_generations(count: usize) -> Vec { + let mut db = PeerGameDB::new(); + let endpoint = PeerEndpoint::new(peer(250), SocketAddr::from(([127, 0, 0, 1], 31_337))); + (0..count) + .map(|index| { + let ticket = db + .begin_candidate_negotiation(endpoint) + .expect("candidate ticket"); + db.commit_authenticated_snapshot( + endpoint, + ticket, + RuntimeSessionId::from_bytes(nonce(index as u128)), + Some(LibrarySnapshot { + revision: index as u64, + games: Vec::new(), + }), + ) + .expect("commit should succeed") + .expect("ticket should remain current") + .endpoint_generation + }) + .collect() + } + + fn prepare( + author: PeerId, + generation: PeerEndpointGeneration, + runtime_session_id: RuntimeSessionId, + snapshot: CallToPlayAuthorSnapshot, + ) -> PreparedRemoteAuthor { + PreparedRemoteAuthor::prepare(author, generation, runtime_session_id, snapshot) + } + + fn create_intent(deadline: i64) -> CallToPlayLocalIntent { + CallToPlayLocalIntent { + call_id: None, + action: CallToPlayLocalAction::Create { + game_id: "game".to_owned(), + max_players: 4, + scheduled_for: None, + deadline, + }, + } + } + + fn intent(call_id: CallId, action: CallToPlayLocalAction) -> CallToPlayLocalIntent { + CallToPlayLocalIntent { + call_id: Some(call_id), action, } } #[test] - fn deduplicates_events_without_reordering_new_history() { - let mut store = CallToPlayStore::default(); - let first = create_event("event-1"); - let second = create_event("event-2"); - let merged = store - .merge_batch_at(vec![first.clone(), first.clone(), second.clone()], TEST_NOW) - .expect("valid batch should merge"); + fn local_publish_generates_typed_ids_and_one_revision() { + let local = peer(1); + let mut store = store(local); + let receipt = store + .publish_local_at( + create_intent(NOW + 60_000), + "Alice".to_owned(), + NOW, + Some(call_nonce(7)), + event_nonce(8), + ) + .expect("valid Create should publish"); - assert_eq!(merged.applied, [first, second]); - assert_eq!(merged.duplicates, 1); - assert_eq!(merged.obsolete, 0); - assert!(!merged.needs_history()); - - let duplicate = store - .merge_batch_at(vec![create_event("event-1")], TEST_NOW) - .expect("stored duplicate should be harmless"); - assert!(duplicate.applied.is_empty()); - assert_eq!(duplicate.duplicates, 1); - - let ids = event_ids(store.snapshot_at(TEST_NOW)); - assert_eq!(ids, ["event-1", "event-2"]); - } - - #[test] - fn invalid_batch_leaves_store_unchanged() { - let mut store = CallToPlayStore::default(); - store - .merge_batch_at(vec![create_event("create")], TEST_NOW) - .expect("create should fit"); - let before = store.snapshot_at(TEST_NOW); - - let mut invalid = action_event( - "invalid", - "call-1", - CallToPlayAction::SendMessage { - message_id: "message-1".to_string(), - text: "valid before corruption".to_string(), - }, - ); - invalid.action = CallToPlayAction::SendMessage { - message_id: "message-1".to_string(), - text: " ".to_string(), - }; + assert_eq!(receipt.call_id, CallId::new(local, call_nonce(7))); + assert_eq!(receipt.event_id, event_nonce(8)); + assert_eq!(receipt.revision, 1); + let local_snapshot = store.local_snapshot_at(NOW).expect("snapshot"); + assert_eq!(local_snapshot.revision, 1); + assert_eq!(local_snapshot.display_name, "Alice"); + assert_eq!(local_snapshot.events.len(), 1); + assert_eq!(local_snapshot.events[0].at, NOW); + let remote_call = CallId::new(peer(2), call_nonce(9)); + let before = store.local_snapshot_at(NOW).expect("snapshot"); assert_eq!( - store.merge_batch_at( - vec![ - action_event("rsvp", "call-1", CallToPlayAction::Rsvp), + store.publish_local_at( + intent(remote_call, CallToPlayLocalAction::Start), + "Alice".to_owned(), + NOW + 1, + None, + event_nonce(10), + ), + Err(CallToPlayMutationError::CreatorAuthorityRequired( + remote_call + )) + ); + assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before); + + for invalid in [ + CallToPlayLocalIntent { + call_id: Some(receipt.call_id), + action: CallToPlayLocalAction::Create { + game_id: "game".to_owned(), + max_players: 4, + scheduled_for: None, + deadline: NOW + 60_000, + }, + }, + CallToPlayLocalIntent { + call_id: None, + action: CallToPlayLocalAction::Rsvp, + }, + ] { + assert!(matches!( + store.publish_local_at( invalid, - ], - TEST_NOW, - ), - Err(MergeError::Invalid("invalid message")) - ); - assert_eq!(store.snapshot_at(TEST_NOW), before); + "Alice".to_owned(), + NOW + 1, + Some(call_nonce(11)), + event_nonce(12), + ), + Err(CallToPlayMutationError::InvalidIntent(_)) + )); + assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before); + } } #[test] - fn conflicting_event_id_leaves_store_unchanged() { - let mut store = CallToPlayStore::default(); - store - .merge_batch_at(vec![create_event("create")], TEST_NOW) - .expect("create should fit"); - let before = store.snapshot_at(TEST_NOW); - let mut conflicting = create_event("create"); - conflicting.actor_name = "Mallory".to_string(); - + fn display_name_only_change_is_bounded_and_published() { + let mut store = store(peer(1)); + assert_eq!(store.local.revision, 0); assert_eq!( - store.merge_batch_at(vec![conflicting], TEST_NOW), - Err(MergeError::ConflictingEvent("create".to_string())) - ); - assert_eq!(store.snapshot_at(TEST_NOW), before); - } - - #[test] - fn create_and_extension_revive_expired_history_in_any_batch_order() { - let extended_deadline = TEST_NOW + 20 * 60_000; - let extension = action_event( - "extend", - "call-1", - CallToPlayAction::AddTime { - deadline: extended_deadline, - }, - ); - let after_recovery_window = TEST_NOW + 6 * 60_000 + 1; - - for history in [ - vec![create_event("create"), extension.clone()], - vec![extension.clone(), create_event("create")], - ] { - let mut store = CallToPlayStore::default(); store - .merge_batch_at(vec![create_event("create")], TEST_NOW) - .expect("initial create should fit"); - - let missing = store - .merge_batch_at(vec![extension.clone()], after_recovery_window) - .expect("missing history is an acknowledged merge outcome"); - assert!(missing.applied.is_empty()); - assert_eq!(missing.missing_call_ids, ["call-1"]); - assert!(store.snapshot_at(after_recovery_window).is_empty()); - - let revived = store - .merge_batch_at(history, after_recovery_window) - .expect("complete history should revive the call atomically"); - assert_eq!(revived.applied.len(), 2); - assert_eq!( - event_ids(store.snapshot_at(after_recovery_window)), - event_ids(revived.applied) - ); - } - } - - #[test] - fn immediately_obsolete_event_is_not_applied() { - let mut store = CallToPlayStore::default(); - let after_recovery_window = TEST_NOW + 6 * 60_000 + 1; - let merged = store - .merge_batch_at(vec![create_event("create")], after_recovery_window) - .expect("obsolete is an acknowledged merge outcome"); - - assert!(merged.applied.is_empty()); - assert_eq!(merged.obsolete, 1); - assert!(store.snapshot_at(after_recovery_window).is_empty()); - } - - #[test] - fn terminal_tombstone_prevents_stale_history_resurrection() { - let mut store = CallToPlayStore::default(); - let start = action_event("start", "call-1", CallToPlayAction::Start); - store - .merge_batch_at(vec![create_event("create"), start.clone()], TEST_NOW) - .expect("terminal history should merge"); - let after_terminal_retention = start.at + TERMINAL_RETENTION_MS + 1; + .set_local_display_name_at("Alice".to_owned(), NOW) + .expect("valid name"), + Some(CallToPlayMutation { revision: 1 }) + ); assert_eq!( - store.snapshot_at(after_terminal_retention).as_slice(), - std::slice::from_ref(&start) + store + .set_local_display_name_at("Alice".to_owned(), NOW) + .expect("same name is a no-op"), + None ); - let stale = store - .merge_batch_at( - vec![ - create_event("create"), - action_event( - "extend", - "call-1", - CallToPlayAction::AddTime { - deadline: TEST_NOW + 20 * 60_000, - }, - ), - ], - after_terminal_retention, - ) - .expect("stale history is an obsolete merge outcome"); - - assert!(stale.applied.is_empty()); - assert_eq!(stale.obsolete, 2); - assert_eq!(store.snapshot_at(after_terminal_retention), [start]); + let before = store.local_snapshot_at(NOW).expect("snapshot"); + assert!( + store + .set_local_display_name_at( + "x".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS + 1), + NOW, + ) + .is_err() + ); + assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before); } #[test] - fn terminal_actions_succeed_at_the_active_history_cap() { - for terminal_action in [CallToPlayAction::Start, CallToPlayAction::Cancel] { - let mut store = full_active_store(); - let terminal = action_event("terminal", "call-1", terminal_action); + fn terminal_reserve_keeps_one_settlement_slot() { + for terminal in [CallToPlayLocalAction::Start, CallToPlayLocalAction::Cancel] { + let local = peer(1); + let call_id = CallId::new(local, call_nonce(1)); + let mut store = store(local); + let mut events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - 1); + events.push(create_event(local, call_id, 1, NOW, NOW + 60_000)); + events.extend((2..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128).map(|id| { + action_event( + call_id, + id, + NOW + i64::try_from(id).expect("event index fits in i64"), + CallToPlayAction::Rsvp, + ) + })); + store.local.events = events; + store.local.revision = 1; + validate_author_snapshot(local, &store.local).expect("full reserved history is valid"); - let merged = store - .merge_batch_at(vec![terminal.clone()], TEST_NOW) - .expect("terminal action should settle the full call"); - assert_eq!(merged.applied.as_slice(), std::slice::from_ref(&terminal)); - assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 1); assert_eq!( - store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1), - [terminal] + store.publish_local_at( + intent(call_id, CallToPlayLocalAction::Rsvp), + "Local".to_owned(), + NOW + 50_000, + None, + event_nonce(10_000), + ), + Err(CallToPlayMutationError::EventHistoryFull) ); + let receipt = store + .publish_local_at( + intent(call_id, terminal), + "Local".to_owned(), + NOW + 50_000, + None, + event_nonce(10_001), + ) + .expect("terminal action must use the reserved slot"); + assert_eq!(receipt.revision, 2); + assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR); + assert_eq!( + store + .set_local_display_name_at("Renamed".to_owned(), NOW + 50_001) + .expect("a non-event mutation does not consume another event slot"), + Some(CallToPlayMutation { revision: 3 }) + ); + assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR); } } #[test] - fn terminal_histories_do_not_consume_active_history_capacity() { - let mut store = full_active_store(); - let mut terminal = action_event("call-1-start", "call-1", CallToPlayAction::Start); - terminal.at = TEST_NOW + 2_000; - store - .merge_batch_at(vec![terminal], TEST_NOW) - .expect("start should settle active history"); + fn pruning_keeps_exact_boundaries_then_removes_without_tombstones_and_bumps() { + let local = peer(1); + let mut unresolved = store(local); + let deadline = NOW + 1_000; + unresolved + .publish_local_at( + create_intent(deadline), + "Local".to_owned(), + NOW, + Some(call_nonce(1)), + event_nonce(1), + ) + .expect("create"); + assert_eq!( + unresolved + .local_snapshot_at(deadline + EXPIRED_RETENTION_MS) + .expect("exact boundary") + .events + .len(), + 1 + ); + let pruned = unresolved + .local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1) + .expect("past boundary"); + assert!(pruned.events.is_empty()); + assert_eq!(pruned.revision, 2); - let created = create_event_for("call-2", "call-2-create"); - let merged = store - .merge_batch_at(vec![created.clone()], TEST_NOW) - .expect("terminal history must not block a new active call"); - assert_eq!(merged.applied, [created]); - assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 2); + let mut terminal = store(local); + let create = terminal + .publish_local_at( + create_intent(NOW + 60_000), + "Local".to_owned(), + NOW, + Some(call_nonce(2)), + event_nonce(2), + ) + .expect("create"); + terminal + .publish_local_at( + intent(create.call_id, CallToPlayLocalAction::Start), + "Local".to_owned(), + NOW + 10, + None, + event_nonce(3), + ) + .expect("start"); + assert_eq!( + terminal + .local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS) + .expect("exact terminal boundary") + .events + .len(), + 2 + ); + let pruned = terminal + .local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS + 1) + .expect("past terminal boundary"); + assert!(pruned.events.is_empty(), "no terminal tombstone remains"); + assert_eq!(pruned.revision, 3); } #[test] - fn full_active_history_returns_an_error() { - let mut store = full_active_store(); - let before = store.snapshot_at(TEST_NOW); + fn responder_reads_project_only_when_local_pruning_changes_state() { + let local = peer(1); + let deadline = NOW + 1_000; + let boundary = deadline + EXPIRED_RETENTION_MS; + let mut store = store(local); + store + .publish_local_at( + create_intent(deadline), + "Local".to_owned(), + NOW, + Some(call_nonce(1)), + event_nonce(1), + ) + .expect("create"); + let (revision, publication) = store + .responder_state_at(boundary) + .expect("exact-boundary Pong state"); + assert_eq!(revision, 1); + assert!(publication.is_none()); + assert_eq!(store.projection_count, 0); + + let (snapshot, revision, publication) = store + .local_responder_snapshot_at(boundary) + .expect("exact-boundary Hello state"); + assert_eq!(snapshot.revision, revision); + assert!(publication.is_none()); + assert_eq!(store.projection_count, 0); + + let (revision, publication) = store + .responder_state_at(boundary + 1) + .expect("expired Pong state"); + let publication = publication.expect("a local prune requires one full publication"); + assert_eq!(revision, 2); + assert_eq!(publication.local_revision, revision); + assert!(publication.local_changed); + assert!(publication.view.events.is_empty()); + assert_eq!(store.projection_count, 1); + + let (snapshot, revision, publication) = store + .local_responder_snapshot_at(boundary + 1) + .expect("already-pruned Hello state"); + assert_eq!(snapshot.revision, revision); + assert_eq!(revision, 2); + assert!(publication.is_none()); + assert_eq!(store.projection_count, 1); + } + + #[test] + fn responder_snapshot_excludes_remote_author_slices_from_full_local_view() { + let local = peer(1); + let participant = peer(2); + let generation = endpoint_generations(1)[0]; + let mut store = store(local); + let create = store + .publish_local_at( + create_intent(NOW + 60_000), + "Alice".to_owned(), + NOW, + Some(call_nonce(1)), + event_nonce(1), + ) + .expect("local Create should publish"); + assert!(matches!( + store.observe_prepared_remote(prepare( + participant, + generation, + session(2), + snapshot( + 1, + "Bob", + vec![action_event( + create.call_id, + 2, + NOW + 1, + CallToPlayAction::Rsvp, + )], + ), + )), + ObserveRemoteAuthorOutcome::Applied { .. } + )); + + let full_view = store.view_at(NOW + 2).expect("full local view"); + assert_eq!(full_view.events.len(), 2); + assert_eq!(full_view.events[0].author_id, local); + assert_eq!(full_view.events[1].author_id, participant); + + let (responder_snapshot, revision, publication) = store + .local_responder_snapshot_at(NOW + 2) + .expect("local responder snapshot"); + assert_eq!(responder_snapshot.revision, revision); + assert!(publication.is_none()); assert_eq!( - store.merge_batch_at( - vec![action_event("overflow", "call-1", CallToPlayAction::Rsvp,)], - TEST_NOW, + responder_snapshot + .events + .iter() + .map(|event| event.id) + .collect::>(), + [event_nonce(1)] + ); + } + + #[test] + fn bulk_remote_clear_preserves_local_author_and_projects_a_local_only_view() { + let creator = peer(1); + let participant = peer(2); + let local = peer(3); + let generations = endpoint_generations(2); + let remote_call = CallId::new(creator, call_nonce(1)); + let mut store = store(local); + store.observe_prepared_remote(prepare( + creator, + generations[0], + session(2), + snapshot( + 1, + "Alice", + vec![create_event(creator, remote_call, 1, NOW, NOW + 60_000)], ), - Err(MergeError::HistoryFull) + )); + let local_call = store + .publish_local_at( + create_intent(NOW + 60_000), + "Local".to_owned(), + NOW + 1, + Some(call_nonce(2)), + event_nonce(2), + ) + .expect("local Create") + .call_id; + store + .publish_local_at( + intent(remote_call, CallToPlayLocalAction::Rsvp), + "Local".to_owned(), + NOW + 2, + None, + event_nonce(3), + ) + .expect("local RSVP to the remote call"); + store.observe_prepared_remote(prepare( + participant, + generations[1], + session(3), + snapshot( + 1, + "Bob", + vec![action_event( + remote_call, + 4, + NOW + 3, + CallToPlayAction::Rsvp, + )], + ), + )); + assert_eq!(store.remote_author_count(), 2); + assert_eq!( + store.view_at(NOW + 4).expect("combined view").events.len(), + 4 + ); + + let local_before = store.local.clone(); + let projections_before = store.projection_count; + let (publication, diagnostic) = store.clear_remote_authors_and_project_at(Ok(NOW + 4)); + + assert_eq!(diagnostic, None); + assert_eq!(store.local, local_before); + assert_eq!(publication.local_revision, local_before.revision); + assert!(!publication.local_changed); + assert_eq!(store.remote_author_count(), 0); + assert!(store.remote_author_state(creator).is_none()); + assert!(store.remote_author_state(participant).is_none()); + assert_eq!(store.projection_count, projections_before + 1); + assert_eq!(publication.view.events.len(), 1); + assert_eq!(publication.view.events[0].call_id, local_call); + assert_eq!(publication.view.events[0].author_id, local); + assert_eq!( + store + .local + .events + .iter() + .map(|event| event.id) + .collect::>(), + [event_nonce(2), event_nonce(3)], + "the root-gated view must not erase the local author slice" ); - assert_eq!(store.snapshot_at(TEST_NOW), before); } #[test] - fn visible_call_retains_complete_chat_history() { - let mut store = CallToPlayStore::default(); - let message = action_event( - "message-event", - "call-1", - CallToPlayAction::SendMessage { - message_id: "message-1".to_string(), - text: "Ready when you are".to_string(), - }, + fn bulk_remote_clear_falls_back_after_clock_or_prune_failure() { + let generation = endpoint_generations(1)[0]; + let remote = peer(1); + let local = peer(2); + let other_remote = peer(3); + let remote_call = CallId::new(remote, call_nonce(1)); + + let mut clock_failure = store(local); + let local_call = clock_failure + .publish_local_at( + create_intent(NOW + 60_000), + "Local".to_owned(), + NOW, + Some(call_nonce(2)), + event_nonce(1), + ) + .expect("local Create") + .call_id; + clock_failure.observe_prepared_remote(prepare( + remote, + generation, + session(2), + snapshot( + 1, + "Remote", + vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)], + ), + )); + clock_failure.observe_prepared_remote(prepare( + other_remote, + generation, + session(3), + snapshot(0, "Other", Vec::new()), + )); + assert_eq!(clock_failure.remote_author_count(), 2); + let local_before = clock_failure.local.clone(); + let (publication, diagnostic) = clock_failure + .clear_remote_authors_and_project_at(Err(CallToPlayMutationError::ClockUnavailable)); + assert_eq!(diagnostic, Some(CallToPlayMutationError::ClockUnavailable)); + assert_eq!(clock_failure.local, local_before); + assert_eq!(clock_failure.remote_author_count(), 0); + assert_eq!(publication.local_revision, local_before.revision); + assert!(!publication.local_changed); + assert_eq!(publication.view.events.len(), 1); + assert_eq!(publication.view.events[0].call_id, local_call); + + let deadline = NOW + 100; + let mut prune_failure = store(local); + prune_failure + .publish_local_at( + create_intent(deadline), + "Local".to_owned(), + NOW, + Some(call_nonce(3)), + event_nonce(3), + ) + .expect("expiring local Create"); + prune_failure.local.revision = u64::MAX; + prune_failure.observe_prepared_remote(prepare( + remote, + generation, + session(2), + snapshot( + 1, + "Remote", + vec![create_event(remote, remote_call, 4, NOW, NOW + 60_000)], + ), + )); + prune_failure.observe_prepared_remote(prepare( + other_remote, + generation, + session(3), + snapshot(0, "Other", Vec::new()), + )); + assert_eq!(prune_failure.remote_author_count(), 2); + let local_before = prune_failure.local.clone(); + let (publication, diagnostic) = prune_failure + .clear_remote_authors_and_project_at(Ok(deadline + EXPIRED_RETENTION_MS + 1)); + assert_eq!(diagnostic, Some(CallToPlayMutationError::RevisionExhausted)); + assert_eq!(prune_failure.local, local_before); + assert_eq!(prune_failure.remote_author_count(), 0); + assert_eq!(publication.local_revision, u64::MAX); + assert!(!publication.local_changed); + assert!(publication.view.events.is_empty()); + } + + #[test] + fn bulk_remote_clear_is_idempotent_after_one_normal_local_prune() { + let generation = endpoint_generations(1)[0]; + let remote = peer(1); + let local = peer(2); + let deadline = NOW + 100; + let mut store = store(local); + store + .publish_local_at( + create_intent(deadline), + "Local".to_owned(), + NOW, + Some(call_nonce(1)), + event_nonce(1), + ) + .expect("expiring local Create"); + let remote_call = CallId::new(remote, call_nonce(2)); + store.observe_prepared_remote(prepare( + remote, + generation, + session(2), + snapshot( + 1, + "Remote", + vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)], + ), + )); + let clear_at = deadline + EXPIRED_RETENTION_MS + 1; + + let (first, first_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at)); + assert_eq!(first_diagnostic, None); + assert!(first.local_changed); + assert_eq!(first.local_revision, 2); + assert!(first.view.events.is_empty()); + assert_eq!(store.remote_author_count(), 0); + let local_after_first = store.local.clone(); + + let (second, second_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at)); + assert_eq!(second_diagnostic, None); + assert!(!second.local_changed); + assert_eq!(second.local_revision, 2); + assert_eq!(second.view, first.view); + assert_eq!(store.local, local_after_first); + assert_eq!(store.remote_author_count(), 0); + } + + #[test] + fn prepared_publication_freezes_one_boundary_and_fails_before_remote_commit() { + let local = peer(1); + let deadline = NOW + 1_000; + let mut pruning_store = store(local); + pruning_store + .publish_local_at( + create_intent(deadline), + "Local".to_owned(), + NOW, + Some(call_nonce(1)), + event_nonce(1), + ) + .expect("create"); + + let exact = pruning_store + .prepare_publication_at(deadline + EXPIRED_RETENTION_MS) + .expect("exact boundary preparation"); + assert!(!exact.local_changed()); + let publication = pruning_store.view_from_prepared(exact); + assert_eq!(publication.view.events.len(), 1); + assert_eq!(publication.local_revision, 1); + + let expired = pruning_store + .prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1) + .expect("past-boundary preparation"); + assert!(expired.local_changed()); + assert_eq!( + pruning_store.local.revision, 1, + "preparation is transactional" ); - let merged = store - .merge_batch_at( + drop(expired); + assert_eq!( + pruning_store.local.revision, 1, + "dropping a token is a no-op" + ); + assert_eq!(pruning_store.local.events.len(), 1); + let expired = pruning_store + .prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1) + .expect("repeat past-boundary preparation"); + let publication = pruning_store.view_from_prepared(expired); + assert!(publication.view.events.is_empty()); + assert_eq!(publication.local_revision, 2); + assert!(publication.local_changed); + + let mut exhausted = store(local); + exhausted + .publish_local_at( + create_intent(deadline), + "Local".to_owned(), + NOW, + Some(call_nonce(2)), + event_nonce(2), + ) + .expect("create"); + exhausted.local.revision = u64::MAX; + let before = exhausted.local.clone(); + assert!(matches!( + exhausted.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1), + Err(CallToPlayMutationError::RevisionExhausted) + )); + assert_eq!(exhausted.local, before); + } + + #[test] + fn an_old_prepared_projection_cannot_overwrite_a_newer_timer_view() { + let author = peer(1); + let generation = endpoint_generations(1)[0]; + let call_id = CallId::new(author, call_nonce(1)); + let deadline = NOW + 100; + let mut store = store(peer(2)); + store.observe_prepared_remote(prepare( + author, + generation, + session(2), + snapshot( + 1, + "Alice", + vec![create_event(author, call_id, 1, NOW, deadline)], + ), + )); + + let old = store + .prepare_publication_at(deadline + EXPIRED_RETENTION_MS) + .expect("old projection"); + let newer = store + .publication_at(deadline + EXPIRED_RETENTION_MS + 1) + .expect("newer projection"); + assert!(newer.view.events.is_empty()); + let replayed = store.view_from_prepared(old); + assert!(replayed.view.events.is_empty()); + } + + #[test] + fn add_time_recovers_during_the_five_minute_window() { + let local = peer(1); + let mut store = store(local); + let deadline = NOW + 100; + let created = store + .publish_local_at( + create_intent(deadline), + "Local".to_owned(), + NOW, + Some(call_nonce(1)), + event_nonce(1), + ) + .expect("create"); + let recovery_time = deadline + EXPIRED_RETENTION_MS; + store + .publish_local_at( + intent( + created.call_id, + CallToPlayLocalAction::AddTime { + deadline: recovery_time + 60_000, + }, + ), + "Local".to_owned(), + recovery_time, + None, + event_nonce(2), + ) + .expect("AddTime at the exact recovery boundary should revive the call"); + assert_eq!( + store + .local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1) + .expect("snapshot") + .events + .len(), + 2 + ); + } + + #[test] + fn participant_slice_is_retained_hidden_and_creator_departure_hides_call() { + let local = peer(3); + let creator = peer(1); + let participant = peer(2); + let call_id = CallId::new(creator, call_nonce(1)); + let generations = endpoint_generations(3); + let mut store = store(local); + + let participant_event = action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp); + assert!(matches!( + store.observe_prepared_remote(prepare( + participant, + generations[0], + session(2), + snapshot(1, "Same name", vec![participant_event.clone()]), + )), + ObserveRemoteAuthorOutcome::Applied { .. } + )); + assert!(store.view_at(NOW + 2).expect("view").events.is_empty()); + assert!(store.remote_author_state(participant).is_some()); + + let root = create_event(creator, call_id, 1, NOW, NOW + 60_000); + store.observe_prepared_remote(prepare( + creator, + generations[1], + session(3), + snapshot(1, "Same name", vec![root.clone()]), + )); + let view = store.view_at(NOW + 2).expect("view"); + assert_eq!(view.events.len(), 2); + assert_eq!(view.events[0].author_id, creator); + assert_eq!(view.events[1].author_id, participant); + assert_eq!(view.events[0].author_name, view.events[1].author_name); + + assert!(store.remove_remote_author_if_generation(participant, generations[0])); + let view = store.view_at(NOW + 2).expect("participant departure view"); + assert_eq!(view.events.len(), 1); + assert_eq!(view.events[0].author_id, creator); + assert!(matches!( + store.observe_prepared_remote(prepare( + participant, + generations[0], + session(2), + snapshot(1, "Same name", vec![participant_event]), + )), + ObserveRemoteAuthorOutcome::Applied { .. } + )); + + assert!(!store.remove_remote_author_if_generation(creator, generations[0])); + assert!(store.remove_remote_author_if_generation(creator, generations[1])); + assert!(store.view_at(NOW + 2).expect("view").events.is_empty()); + assert!(store.remote_author_state(participant).is_some()); + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "one state-transition matrix keeps its shared setup and assertions together" + )] + fn same_session_stale_and_invalid_preserve_and_rebind_but_new_invalid_clears() { + let local = peer(9); + let author = peer(1); + let call_id = CallId::new(author, call_nonce(1)); + let generations = endpoint_generations(6); + let mut store = store(local); + let root = create_event(author, call_id, 1, NOW, NOW + 60_000); + + assert_eq!( + store.observe_prepared_remote(prepare( + author, + generations[0], + session(2), + snapshot(5, "Alice", vec![root.clone()]), + )), + ObserveRemoteAuthorOutcome::Applied { + session_changed: true + } + ); + assert_eq!( + store.observe_prepared_remote(prepare( + author, + generations[1], + session(2), + snapshot(4, "Lower", vec![root.clone()]), + )), + ObserveRemoteAuthorOutcome::IgnoredStale { + generation_rebound: true + } + ); + assert_eq!( + store + .remote_author_state(author) + .expect("remote author should remain") + .revision, + 5 + ); + + assert_eq!( + store.observe_prepared_remote(prepare( + author, + generations[1], + session(2), + snapshot(5, "Alice", vec![root.clone()]), + )), + ObserveRemoteAuthorOutcome::Unchanged { + generation_rebound: false + } + ); + + assert!(matches!( + store.observe_prepared_remote(prepare( + author, + generations[2], + session(2), + snapshot(5, "Equal conflict", vec![root]), + )), + ObserveRemoteAuthorOutcome::EqualRevisionConflict { + generation_rebound: true + } + )); + assert_eq!( + store.view_at(NOW).expect("view").events[0].author_name, + "Alice" + ); + + assert!(matches!( + store.observe_prepared_remote(prepare( + author, + generations[3], + session(2), + snapshot(6, " ", Vec::new()), + )), + ObserveRemoteAuthorOutcome::InvalidPreserved { + generation_rebound: true, + .. + } + )); + assert_eq!( + store + .remote_author_state(author) + .expect("preserved") + .endpoint_generation, + generations[3] + ); + assert!(!store.remove_remote_author_if_generation(author, generations[2])); + + assert!(matches!( + store.observe_prepared_remote(prepare( + author, + generations[4], + session(3), + snapshot(0, " ", Vec::new()), + )), + ObserveRemoteAuthorOutcome::InvalidCleared(_) + )); + assert!(store.remote_author_state(author).is_none()); + + assert!(matches!( + store.observe_prepared_remote(prepare( + author, + generations[5], + session(3), + snapshot(0, "Restarted", Vec::new()), + )), + ObserveRemoteAuthorOutcome::Applied { + session_changed: true + } + )); + assert_eq!( + store + .remote_author_state(author) + .expect("restarted remote author should exist") + .revision, + 0 + ); + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "one author-isolation matrix keeps its shared fixtures and assertions together" + )] + fn invalid_duplicates_order_authority_and_bytes_are_author_isolated() { + let generations = endpoint_generations(2); + let author = peer(1); + let other_creator = peer(2); + let own_call = CallId::new(author, call_nonce(1)); + let other_call = CallId::new(other_creator, call_nonce(2)); + + let duplicate = action_event(other_call, 1, NOW, CallToPlayAction::Rsvp); + let prepared = prepare( + author, + generations[0], + session(2), + snapshot(1, "Alice", vec![duplicate.clone(), duplicate]), + ); + assert!(matches!( + prepared.validation_error(), + Some(CallToPlayValidationError::DuplicateEventId(_)) + )); + + let prepared = prepare( + author, + generations[0], + session(2), + snapshot( + 1, + "Alice", vec![ - create_event("create"), - action_event("rsvp", "call-1", CallToPlayAction::Rsvp), - message.clone(), + action_event(other_call, 1, NOW + 1, CallToPlayAction::Rsvp), + action_event(other_call, 2, NOW, CallToPlayAction::Leave), ], - TEST_NOW, - ) - .expect("visible history should merge"); - - assert_eq!(merged.applied.len(), 3); - assert!(store.snapshot_at(TEST_NOW).contains(&message)); - } - - #[test] - fn terminal_history_preserves_roster_and_chat_for_display_window() { - let mut store = CallToPlayStore::default(); - let message = action_event( - "message", - "call-1", - CallToPlayAction::SendMessage { - message_id: "message-1".to_string(), - text: "Launching now".to_string(), - }, - ); - let terminal = action_event("terminal", "call-1", CallToPlayAction::Start); - let history = vec![ - create_event("create"), - action_event("rsvp", "call-1", CallToPlayAction::Rsvp), - message, - terminal.clone(), - ]; - store - .merge_batch_at(history.clone(), TEST_NOW) - .expect("terminal history should merge"); - - let mut post_terminal = action_event( - "post-terminal", - "call-1", - CallToPlayAction::SendMessage { - message_id: "message-2".to_string(), - text: "Too late".to_string(), - }, - ); - post_terminal.at = terminal.at + 1; - let obsolete = store - .merge_batch_at( - vec![create_event("different-create"), post_terminal], - TEST_NOW, - ) - .expect("terminal updates should be obsolete"); - assert!(obsolete.applied.is_empty()); - assert_eq!(obsolete.obsolete, 2); - assert_eq!( - store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS), - history + ), ); assert_eq!( - store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1), - [terminal] + prepared.validation_error(), + Some(&CallToPlayValidationError::NonMonotonicAuthorHistory) ); - } - #[test] - fn only_failed_or_incomplete_deliveries_request_resync() { - assert!(delivery_resync_reason(Err(())).is_some()); - assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHandshake)).is_some()); - assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHistory)).is_some()); + let prepared = prepare( + author, + generations[0], + session(2), + snapshot( + 1, + "Alice", + vec![create_event(other_creator, other_call, 1, NOW, NOW + 1_000)], + ), + ); + assert!(matches!( + prepared.validation_error(), + Some(CallToPlayValidationError::UnauthorizedAction { .. }) + )); - for ack in [ - CallToPlayAck::Applied, - CallToPlayAck::Duplicate, - CallToPlayAck::Obsolete, - CallToPlayAck::Rejected { - reason: "invalid".to_string(), - }, - ] { - assert!(delivery_resync_reason(Ok(&ack)).is_none()); + let overflow = prepare( + author, + generations[0], + session(2), + snapshot( + 1, + "Alice", + vec![create_event(author, own_call, 9, NOW, i64::MAX)], + ), + ); + assert!(matches!( + overflow.validation_error(), + Some(CallToPlayValidationError::InvalidEvent { + reason: "timestamp overflows its retention boundary", + .. + }) + )); + + let mut oversized_events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR); + let text = "😀".repeat(250); + for id in 0..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128 { + oversized_events.push(action_event( + other_call, + id, + NOW + i64::try_from(id).expect("event index fits in i64"), + CallToPlayAction::SendMessage { text: text.clone() }, + )); } + let oversized = prepare( + author, + generations[0], + session(2), + snapshot(1, "Alice", oversized_events), + ); + assert!(matches!( + oversized.validation_error(), + Some(CallToPlayValidationError::Wire( + ControlValidationError::EncodedTooLarge { .. } + )) + )); + + let mut store = store(peer(9)); + let valid_root = create_event(author, own_call, 3, NOW, NOW + 60_000); + store.observe_prepared_remote(prepare( + author, + generations[0], + session(2), + snapshot(1, "Alice", vec![valid_root]), + )); + assert!(matches!( + store.observe_prepared_remote(oversized), + ObserveRemoteAuthorOutcome::InvalidPreserved { .. } + )); + assert_eq!( + store + .remote_author_state(author) + .expect("valid remote author should remain") + .revision, + 1 + ); } - fn full_active_store() -> CallToPlayStore { - let mut history = Vec::with_capacity(MAX_EVENTS); - history.push(create_event("create")); - history.extend((1..MAX_EVENTS).map(|index| { - action_event(&format!("event-{index}"), "call-1", CallToPlayAction::Rsvp) - })); - - let mut store = CallToPlayStore::default(); - store - .merge_batch_at(history, TEST_NOW) - .expect("active history should fit through the cap"); - store + #[test] + fn remote_expiry_hides_without_mutating_the_accepted_revision() { + let generation = endpoint_generations(1)[0]; + let author = peer(1); + let call_id = CallId::new(author, call_nonce(1)); + let deadline = NOW + 100; + let mut store = store(peer(2)); + store.observe_prepared_remote(prepare( + author, + generation, + session(2), + snapshot( + 7, + "Alice", + vec![create_event(author, call_id, 1, NOW, deadline)], + ), + )); + assert_eq!( + store + .view_at(deadline + EXPIRED_RETENTION_MS) + .expect("exact boundary") + .events + .len(), + 1 + ); + assert!( + store + .view_at(deadline + EXPIRED_RETENTION_MS + 1) + .expect("expired view") + .events + .is_empty() + ); + assert_eq!( + store + .remote_author_state(author) + .expect("expired remote author should remain cached") + .revision, + 7 + ); } - fn event_ids(events: Vec) -> Vec { - events.into_iter().map(|event| event.id).collect() + #[test] + fn remote_author_capacity_does_not_consume_local_capacity() { + let generation = endpoint_generations(1)[0]; + let local = peer(200); + let mut store = store(local); + for seed in + 1..u8::try_from(MAX_CALL_TO_PLAY_AUTHORS).expect("author limit fits in one byte") + { + assert!(matches!( + store.observe_prepared_remote(prepare( + peer(seed), + generation, + session(seed), + snapshot(0, "Peer", Vec::new()), + )), + ObserveRemoteAuthorOutcome::Applied { .. } + )); + } + assert_eq!( + store.remote_author_count() + 1, + MAX_CALL_TO_PLAY_AUTHORS, + "the local author counts toward the total-author cap" + ); + assert_eq!( + store.observe_prepared_remote(prepare( + peer(100), + generation, + session(100), + snapshot(0, "Extra", Vec::new()), + )), + ObserveRemoteAuthorOutcome::AtCapacity + ); + + let receipt = store + .publish_local_at( + create_intent(NOW + 60_000), + "Local".to_owned(), + NOW, + Some(call_nonce(1)), + event_nonce(1), + ) + .expect("remote capacity must not block local publication"); + assert_eq!(receipt.revision, 1); + } + + #[test] + fn full_view_is_deterministic_and_wholly_recomputed() { + let local = peer(3); + let creator = peer(1); + let participant = peer(2); + let call_id = CallId::new(creator, call_nonce(1)); + let generations = endpoint_generations(2); + let mut store = store(local); + store.observe_prepared_remote(prepare( + participant, + generations[0], + session(2), + snapshot( + 1, + "Bob", + vec![ + action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave), + action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp), + ], + ), + )); + assert!(store.remote_author_state(participant).is_none()); + + store.observe_prepared_remote(prepare( + participant, + generations[0], + session(2), + snapshot( + 2, + "Bob", + vec![ + action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp), + action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave), + ], + ), + )); + store.observe_prepared_remote(prepare( + creator, + generations[1], + session(3), + snapshot( + 1, + "Alice", + vec![create_event(creator, call_id, 1, NOW, NOW + 60_000)], + ), + )); + let first = store.view_at(NOW + 3).expect("view"); + let second = store.view_at(NOW + 3).expect("view"); + assert_eq!(first, second); + assert_eq!( + first + .events + .iter() + .map(|event| event.id) + .collect::>(), + [event_nonce(1), event_nonce(2), event_nonce(3)] + ); + + assert!(matches!( + store.observe_prepared_remote(prepare( + participant, + generations[0], + session(2), + snapshot(3, "Bob", Vec::new()), + )), + ObserveRemoteAuthorOutcome::Applied { + session_changed: false + } + )); + let replaced = store.view_at(NOW + 3).expect("replacement view"); + assert_eq!(replaced.events.len(), 1); + assert_eq!(replaced.events[0].author_id, creator); + + assert!(store.remove_remote_author_if_generation(participant, generations[0])); + let replaced = store.view_at(NOW + 3).expect("view"); + assert_eq!(replaced.events.len(), 1); + assert_eq!(replaced.events[0].author_id, creator); } } diff --git a/crates/lanspread-peer/src/config.rs b/crates/lanspread-peer/src/config.rs index deb14af..6be3dcb 100644 --- a/crates/lanspread-peer/src/config.rs +++ b/crates/lanspread-peer/src/config.rs @@ -23,9 +23,6 @@ pub const PEER_DOWNLOAD_STREAM_WINDOW: usize = 4; /// Application-level read buffer used when sending file bytes over QUIC (1 MiB). pub const FILE_TRANSFER_BUFFER_SIZE: usize = 1024 * 1024; -/// Maximum number of retry attempts for failed chunk downloads. -pub const MAX_RETRY_COUNT: usize = 3; - /// QUIC connection-level receive window for bulk LAN transfers (256 MiB). pub const QUIC_CONNECTION_DATA_WINDOW: u64 = 256 * 1024 * 1024; @@ -41,15 +38,28 @@ pub const QUIC_INITIAL_CONGESTION_WINDOW: u32 = 4 * 1024 * 1024; /// Requested OS UDP send and receive buffer size for QUIC sockets (4 MiB). pub const QUIC_SOCKET_BUFFER_SIZE: usize = 4 * 1024 * 1024; -/// Fallback interval for reconciling missed filesystem watcher events (seconds). +/// Maximum time allowed for establishing a QUIC connection. +pub const QUIC_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(3); + +/// Maximum period without QUIC traffic before a connection is closed. +pub const QUIC_IDLE_TIMEOUT: Duration = Duration::from_secs(3); + +/// Absolute deadline for short control-plane requests. +/// +/// This bounds peers that keep a response stream alive by slowly sending data, +/// which the transport idle timeout alone cannot detect. +pub const QUIC_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +/// Grace period for a QUIC endpoint to finish after all application owners +/// have been drained. The endpoint task is aborted and still joined afterward. +pub const QUIC_ENDPOINT_SHUTDOWN_GRACE: Duration = Duration::from_secs(3); + +/// Interval for checking direct game-root metadata (seconds). +pub const LOCAL_GAME_POLL_INTERVAL_SECS: u64 = 1; + +/// Fallback interval for a full local-library reconciliation (seconds). pub const LOCAL_GAME_FALLBACK_SCAN_SECS: u64 = 300; -/// TLS certificate for QUIC connections. -pub static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../cert.pem")); - -/// TLS private key for QUIC connections. -pub static KEY_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../key.pem")); - /// Returns the peer stale timeout as a Duration. #[must_use] pub fn peer_stale_timeout() -> Duration { diff --git a/crates/lanspread-peer/src/content_quarantine.rs b/crates/lanspread-peer/src/content_quarantine.rs new file mode 100644 index 0000000..39ed6ac --- /dev/null +++ b/crates/lanspread-peer/src/content_quarantine.rs @@ -0,0 +1,119 @@ +//! Runtime-local quarantine for peers that served invalid catalog content. + +use std::{ + collections::HashSet, + sync::{Arc, RwLock}, +}; + +use lanspread_db::content_manifest::ContentId; +use lanspread_proto::{PeerEndpoint, PeerId}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct QuarantineKey { + peer_id: PeerId, + content_id: ContentId, +} + +/// In-memory quarantine shared by every transfer in one peer runtime. +/// +/// Clones share the same set. Constructing a new value starts empty; quarantine +/// is intentionally not durable trust state. +#[derive(Clone, Debug, Default)] +pub(crate) struct ContentQuarantine { + quarantined: Arc>>, +} + +impl ContentQuarantine { + /// Records a typed integrity failure for this peer and exact catalog + /// content. Callers must not use this for transport or local I/O failures. + pub(crate) fn record_integrity_failure( + &self, + source: &PeerEndpoint, + content_id: ContentId, + ) -> bool { + self.quarantined + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(QuarantineKey { + peer_id: source.peer_id, + content_id, + }) + } + + #[must_use] + pub(crate) fn is_quarantined(&self, source: &PeerEndpoint, content_id: ContentId) -> bool { + self.quarantined + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&QuarantineKey { + peer_id: source.peer_id, + content_id, + }) + } +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + + use super::*; + + fn source(peer_id: &str, port: u16) -> PeerEndpoint { + PeerEndpoint::new( + PeerId::from_bytes(*blake3::hash(peer_id.as_bytes()).as_bytes()), + SocketAddr::from(([127, 0, 0, 1], port)), + ) + } + + fn content(seed: u8) -> ContentId { + ContentId::from_bytes([seed; 32]) + } + + #[test] + fn bad_source_is_quarantined_without_blocking_good_source() { + let quarantine = ContentQuarantine::default(); + let bad = source("bad", 12000); + let good = source("good", 12001); + let content_id = content(1); + + assert!(quarantine.record_integrity_failure(&bad, content_id)); + assert!(quarantine.is_quarantined(&bad, content_id)); + assert!(!quarantine.is_quarantined(&good, content_id)); + } + + #[test] + fn address_rotation_does_not_escape_peer_content_quarantine() { + let quarantine = ContentQuarantine::default(); + let original = source("peer", 12000); + let rotated = source("peer", 22000); + let content_id = content(2); + + quarantine.record_integrity_failure(&original, content_id); + + assert!(quarantine.is_quarantined(&rotated, content_id)); + } + + #[test] + fn quarantine_for_one_content_id_does_not_block_another() { + let quarantine = ContentQuarantine::default(); + let source = source("peer", 12000); + + quarantine.record_integrity_failure(&source, content(3)); + + assert!(quarantine.is_quarantined(&source, content(3))); + assert!(!quarantine.is_quarantined(&source, content(4))); + } + + #[test] + fn clones_share_runtime_state_but_a_new_runtime_starts_empty() { + let runtime = ContentQuarantine::default(); + let runtime_clone = runtime.clone(); + let source = source("peer", 12000); + let content_id = content(5); + + runtime.record_integrity_failure(&source, content_id); + + assert!(runtime_clone.is_quarantined(&source, content_id)); + assert!(!ContentQuarantine::default().is_quarantined(&source, content_id)); + } +} diff --git a/crates/lanspread-peer/src/context.rs b/crates/lanspread-peer/src/context.rs index 2d2c8b1..b0ac058 100644 --- a/crates/lanspread-peer/src/context.rs +++ b/crates/lanspread-peer/src/context.rs @@ -1,9 +1,19 @@ //! Shared context types for the peer system. -use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc}; +use std::{ + collections::HashMap, + net::SocketAddr, + ops::Deref, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU8, Ordering}, + }, +}; -use lanspread_db::db::{GameCatalog, GameDB}; -use tokio::sync::{RwLock, mpsc::UnboundedSender}; +use lanspread_db::{content_manifest::CatalogBundle, db::GameDB}; +use lanspread_proto::{MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS, PeerId, RuntimeSessionId}; +use tokio::sync::{Mutex, RwLock}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; use crate::{ @@ -11,14 +21,158 @@ use crate::{ StreamInstallProvider, Unpacker, call_to_play::CallToPlayStore, - events, + content_quarantine::ContentQuarantine, + identity::PeerIdentity, library::LocalLibraryState, + network_generation::NetworkControl, peer_db::PeerGameDB, + quic_runtime::QuicConnector, + recovery_quarantine::RecoveryQuarantine, + services::StateSyncHandle, + transfer_status::ActiveDownloadSignal, }; /// Thread-safe map of active outbound file transfers grouped by game ID. pub type OutboundTransfers = Arc>>>; +const OUTBOUND_CHANGE_IDLE: u8 = 0; +const OUTBOUND_CHANGE_QUEUED: u8 = 1; +const OUTBOUND_CHANGE_DIRTY: u8 = 2; + +/// Opaque edge-trigger token for one coalesced outbound-transfer projection. +/// Dropping it acknowledges the observation and requeues at most one latest +/// notification if producers marked the state dirty while it was pending. +#[derive(Debug)] +pub struct OutboundTransferChange { + state: Arc, + tx_notify_ui: tokio::sync::mpsc::UnboundedSender, +} + +impl Drop for OutboundTransferChange { + fn drop(&mut self) { + if self.tx_notify_ui.is_closed() { + self.state.store(OUTBOUND_CHANGE_IDLE, Ordering::Release); + return; + } + loop { + match self.state.load(Ordering::Acquire) { + OUTBOUND_CHANGE_QUEUED => { + if self + .state + .compare_exchange( + OUTBOUND_CHANGE_QUEUED, + OUTBOUND_CHANGE_IDLE, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + return; + } + } + OUTBOUND_CHANGE_DIRTY => { + if self + .state + .compare_exchange( + OUTBOUND_CHANGE_DIRTY, + OUTBOUND_CHANGE_QUEUED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + let next = Self { + state: Arc::clone(&self.state), + tx_notify_ui: self.tx_notify_ui.clone(), + }; + if self + .tx_notify_ui + .send(PeerEvent::OutboundTransferCountChanged(next)) + .is_err() + { + self.state.store(OUTBOUND_CHANGE_IDLE, Ordering::Release); + } + return; + } + } + OUTBOUND_CHANGE_IDLE => return, + _ => unreachable!("outbound transfer change state is internal"), + } + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct OutboundTransferNotifier { + state: Arc, + tx_notify_ui: tokio::sync::mpsc::UnboundedSender, +} + +impl OutboundTransferNotifier { + fn new( + state: Arc, + tx_notify_ui: tokio::sync::mpsc::UnboundedSender, + ) -> Self { + Self { + state, + tx_notify_ui, + } + } + + pub(crate) fn notify(&self) { + loop { + match self.state.load(Ordering::Acquire) { + OUTBOUND_CHANGE_IDLE => { + if self + .state + .compare_exchange( + OUTBOUND_CHANGE_IDLE, + OUTBOUND_CHANGE_QUEUED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + continue; + } + if self.tx_notify_ui.is_closed() { + self.state.store(OUTBOUND_CHANGE_IDLE, Ordering::Release); + return; + } + let change = OutboundTransferChange { + state: Arc::clone(&self.state), + tx_notify_ui: self.tx_notify_ui.clone(), + }; + if self + .tx_notify_ui + .send(PeerEvent::OutboundTransferCountChanged(change)) + .is_err() + { + self.state.store(OUTBOUND_CHANGE_IDLE, Ordering::Release); + } + return; + } + OUTBOUND_CHANGE_QUEUED => { + if self + .state + .compare_exchange( + OUTBOUND_CHANGE_QUEUED, + OUTBOUND_CHANGE_DIRTY, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + return; + } + } + OUTBOUND_CHANGE_DIRTY => return, + _ => unreachable!("outbound transfer change state is internal"), + } + } + } +} + /// Mutating filesystem operation currently in flight for a game root. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OperationKind { @@ -44,33 +198,84 @@ pub struct Ctx { pub peer_game_db: Arc>, pub local_peer_addr: Arc>>, pub active_operations: Arc>>, - pub active_downloads: Arc>>, + /// Serializes game-directory recovery with filesystem and outbound-transfer admission. + pub operation_admission: Arc>, + pub recovery_quarantine: RecoveryQuarantine, + pub(crate) content_quarantine: ContentQuarantine, + pub(crate) active_downloads: Arc>>, pub unpacker: Arc, pub stream_install_provider: Arc, - pub catalog: Arc>, - pub peer_id: Arc, + pub catalog: Arc, + pub(crate) peer_identity: Arc, + pub peer_id: PeerId, + pub(crate) runtime_session_id: RuntimeSessionId, + pub(crate) state_sync: StateSyncHandle, pub shutdown: CancellationToken, pub task_tracker: TaskTracker, + pub(crate) network: NetworkControl, pub active_outbound_transfers: OutboundTransfers, + outbound_transfer_change_state: Arc, pub call_to_play: Arc>, } +/// Generation-bound context for every service that can touch the LAN. +/// +/// The embedded core context survives sharing toggles. The connector and +/// cancellation token do not: both belong to exactly one network generation. +/// This keeps local monitoring and filesystem commands alive while a disabled +/// generation is being drained. +#[derive(Clone)] +pub(crate) struct NetworkServiceCtx { + core: Ctx, + pub(crate) quic: QuicConnector, + pub(crate) shutdown: CancellationToken, +} + +impl NetworkServiceCtx { + pub(crate) fn new(core: Ctx, quic: QuicConnector, shutdown: CancellationToken) -> Self { + Self { + core, + quic, + shutdown, + } + } + + pub(crate) fn to_peer_ctx( + &self, + tx_notify_ui: tokio::sync::mpsc::UnboundedSender, + ) -> PeerCtx { + self.core.to_peer_ctx(tx_notify_ui, self.shutdown.clone()) + } +} + +impl Deref for NetworkServiceCtx { + type Target = Ctx; + + fn deref(&self) -> &Self::Target { + &self.core + } +} + /// Context for peer connection handling. #[derive(Clone)] pub struct PeerCtx { pub game_dir: Arc>, - pub local_game_db: Arc>>, pub local_library: Arc>, pub local_peer_addr: Arc>>, pub active_operations: Arc>>, - pub peer_game_db: Arc>, - pub catalog: Arc>, - pub peer_id: Arc, + /// Serializes outbound-transfer admission with game-directory recovery. + pub operation_admission: Arc>, + pub recovery_quarantine: RecoveryQuarantine, + pub catalog: Arc, + pub(crate) peer_identity: Arc, + pub peer_id: PeerId, + pub(crate) runtime_session_id: RuntimeSessionId, + pub(crate) state_sync: StateSyncHandle, pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender, pub stream_install_provider: Arc, pub shutdown: CancellationToken, - pub task_tracker: TaskTracker, pub active_outbound_transfers: OutboundTransfers, + pub(crate) outbound_transfer_notifier: OutboundTransferNotifier, pub call_to_play: Arc>, } @@ -88,100 +293,140 @@ impl std::fmt::Debug for PeerCtx { impl Ctx { /// Creates a new context with the given peer game database. #[allow(clippy::too_many_arguments)] - pub fn new( + pub(crate) fn new( peer_game_db: Arc>, - peer_id: String, + peer_identity: Arc, game_dir: PathBuf, state_dir: PathBuf, unpacker: Arc, shutdown: CancellationToken, task_tracker: TaskTracker, - catalog: Arc>, + catalog: Arc, active_outbound_transfers: OutboundTransfers, stream_install_provider: Arc, - ) -> Self { - Self { - game_dir: Arc::new(RwLock::new(game_dir)), + network: NetworkControl, + ) -> eyre::Result { + let peer_id = peer_identity.peer_id(); + let runtime_session_id = new_runtime_session_id()?; + let state_sync = StateSyncHandle::new(peer_id); + let call_to_play = CallToPlayStore::new( + peer_id, + runtime_session_id, + default_call_to_play_display_name(), + )?; + Ok(Self { + game_dir: Arc::new(RwLock::new(game_dir.clone())), state_dir: Arc::new(state_dir), local_game_db: Arc::new(RwLock::new(None)), local_library: Arc::new(RwLock::new(LocalLibraryState::empty())), peer_game_db, local_peer_addr: Arc::new(RwLock::new(None)), active_operations: Arc::new(RwLock::new(HashMap::new())), + operation_admission: Arc::new(Mutex::new(())), + recovery_quarantine: RecoveryQuarantine::recovering(game_dir), + content_quarantine: ContentQuarantine::default(), active_downloads: Arc::new(RwLock::new(HashMap::new())), unpacker, stream_install_provider, catalog, - peer_id: Arc::new(peer_id), + peer_identity, + peer_id, + runtime_session_id, + state_sync, shutdown, task_tracker, + network, active_outbound_transfers, - call_to_play: Arc::new(RwLock::new(CallToPlayStore::default())), - } + outbound_transfer_change_state: Arc::new(AtomicU8::new(OUTBOUND_CHANGE_IDLE)), + call_to_play: Arc::new(RwLock::new(call_to_play)), + }) } /// Creates a `PeerCtx` from this context. pub fn to_peer_ctx( &self, tx_notify_ui: tokio::sync::mpsc::UnboundedSender, + shutdown: CancellationToken, ) -> PeerCtx { + let outbound_transfer_notifier = OutboundTransferNotifier::new( + Arc::clone(&self.outbound_transfer_change_state), + tx_notify_ui.clone(), + ); PeerCtx { game_dir: self.game_dir.clone(), - local_game_db: self.local_game_db.clone(), local_library: self.local_library.clone(), local_peer_addr: self.local_peer_addr.clone(), active_operations: self.active_operations.clone(), - peer_game_db: self.peer_game_db.clone(), + operation_admission: self.operation_admission.clone(), + recovery_quarantine: self.recovery_quarantine.clone(), catalog: self.catalog.clone(), - peer_id: self.peer_id.clone(), + peer_identity: self.peer_identity.clone(), + peer_id: self.peer_id, + runtime_session_id: self.runtime_session_id, + state_sync: self.state_sync.clone(), tx_notify_ui, stream_install_provider: self.stream_install_provider.clone(), - shutdown: self.shutdown.clone(), - task_tracker: self.task_tracker.clone(), + shutdown, active_outbound_transfers: self.active_outbound_transfers.clone(), + outbound_transfer_notifier, call_to_play: self.call_to_play.clone(), } } } -/// Removes operation tracking no matter how a task exits. +fn new_runtime_session_id() -> eyre::Result { + let provider = rustls::crypto::aws_lc_rs::default_provider(); + let mut bytes = [0_u8; 16]; + provider + .secure_random + .fill(&mut bytes) + .map_err(|_| eyre::eyre!("failed to generate runtime session ID"))?; + Ok(RuntimeSessionId::from_bytes(bytes)) +} + +fn default_call_to_play_display_name() -> String { + let hostname = gethostname::gethostname().to_string_lossy().into_owned(); + let display_name = hostname + .trim() + .chars() + .take(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS) + .collect::(); + if display_name.is_empty() { + "Peer".to_owned() + } else { + display_name + } +} + +/// Keeps an admitted filesystem operation fail-closed until explicit settlement. +/// +/// Normal completion disarms this guard only after recovery and refreshed state +/// have been published. If a task panics, is aborted, or otherwise drops the +/// guard while armed, the operation deliberately remains admitted until process +/// restart recovery. pub(crate) struct OperationGuard { id: String, - active_operations: Arc>>, - active_downloads: Arc>>, - tx_notify_ui: UnboundedSender, - clears_download: bool, + operation_cancel: Option, armed: bool, } impl OperationGuard { - pub(crate) fn new( - id: String, - active_operations: Arc>>, - tx_notify_ui: UnboundedSender, - ) -> Self { + pub(crate) fn new(id: String) -> Self { Self { id, - active_operations, - active_downloads: Arc::new(RwLock::new(HashMap::new())), - tx_notify_ui, - clears_download: false, + operation_cancel: None, armed: true, } } - pub(crate) fn download( - id: String, - active_operations: Arc>>, - active_downloads: Arc>>, - tx_notify_ui: UnboundedSender, - ) -> Self { + pub(crate) fn download(id: String, download_cancel: CancellationToken) -> Self { + Self::cancellable(id, download_cancel) + } + + pub(crate) fn cancellable(id: String, operation_cancel: CancellationToken) -> Self { Self { id, - active_operations, - active_downloads, - tx_notify_ui, - clears_download: true, + operation_cancel: Some(operation_cancel), armed: true, } } @@ -197,60 +442,56 @@ impl Drop for OperationGuard { return; } - let id = self.id.clone(); + let id = &self.id; log::error!( - "Operation guard is cleaning up {id}; operation ended without explicit state cleanup" + "Operation guard for {id} ended unexpectedly; retaining operation tracking until process restart" ); - - if let Ok(mut guard) = self.active_operations.try_write() { - if guard.remove(&id).is_some() { - events::send_active_operations_snapshot(&self.tx_notify_ui, &guard); - } - } else if let Ok(handle) = tokio::runtime::Handle::try_current() { - let active_operations = self.active_operations.clone(); - let tx_notify_ui = self.tx_notify_ui.clone(); - handle.spawn({ - let id = id.clone(); - async move { - let mut active_operations = active_operations.write().await; - if active_operations.remove(&id).is_some() { - events::send_active_operations_snapshot(&tx_notify_ui, &active_operations); - } - } - }); - } else { - log::error!("Failed to clean operation state for {id}: no Tokio runtime"); - } - - if !self.clears_download { - return; - } - - if let Ok(mut guard) = self.active_downloads.try_write() { - guard.remove(&id); - } else if let Ok(handle) = tokio::runtime::Handle::try_current() { - let active_downloads = self.active_downloads.clone(); - handle.spawn({ - let id = id.clone(); - async move { - active_downloads.write().await.remove(&id); - } - }); - } else { - log::error!("Failed to clean active download state for {id}: no Tokio runtime"); + if let Some(token) = &self.operation_cancel { + token.cancel(); } } } #[cfg(test)] mod tests { - use std::{collections::HashMap, sync::Arc, time::Duration}; + use std::{ + collections::HashMap, + sync::{Arc, atomic::AtomicU8}, + }; use tokio::sync::{RwLock, mpsc}; use tokio_util::sync::CancellationToken; - use super::{OperationGuard, OperationKind}; - use crate::{ActiveOperation, ActiveOperationKind, PeerEvent}; + use super::{OUTBOUND_CHANGE_IDLE, OperationGuard, OperationKind, OutboundTransferNotifier}; + use crate::PeerEvent; + + #[test] + fn outbound_transfer_churn_keeps_at_most_one_edge_queued() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let notifier = + OutboundTransferNotifier::new(Arc::new(AtomicU8::new(OUTBOUND_CHANGE_IDLE)), tx); + + for _ in 0..100 { + notifier.notify(); + } + let PeerEvent::OutboundTransferCountChanged(first) = + rx.try_recv().expect("one coalesced edge should be queued") + else { + panic!("expected outbound transfer change edge"); + }; + assert!(rx.try_recv().is_err()); + + notifier.notify(); + drop(first); + let PeerEvent::OutboundTransferCountChanged(second) = + rx.try_recv().expect("one dirty follow-up should be queued") + else { + panic!("expected outbound transfer change follow-up"); + }; + assert!(rx.try_recv().is_err()); + drop(second); + assert!(rx.try_recv().is_err()); + } type OperationTracking = ( Arc>>, @@ -258,25 +499,6 @@ mod tests { CancellationToken, ); - async fn wait_for_tracking_clear( - id: &str, - active_operations: &Arc>>, - active_downloads: &Arc>>, - ) { - tokio::time::timeout(Duration::from_secs(1), async { - loop { - let operation_contains = active_operations.read().await.contains_key(id); - let active_contains = active_downloads.read().await.contains_key(id); - if !operation_contains && !active_contains { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("download tracking should be cleared"); - } - fn tracked_download_state(id: &str) -> OperationTracking { let active_operations = Arc::new(RwLock::new(HashMap::from([( id.to_string(), @@ -290,90 +512,47 @@ mod tests { (active_operations, active_downloads, cancel) } - async fn recv_active_operations( - rx: &mut mpsc::UnboundedReceiver, - ) -> Vec { - let event = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("active operation event should arrive") - .expect("event channel should remain open"); - let PeerEvent::ActiveOperationsChanged { active_operations } = event else { - panic!("expected ActiveOperationsChanged"); - }; - active_operations - } - #[tokio::test] - async fn operation_guard_cleans_tracking_when_not_disarmed() { + async fn download_guard_retains_tracking_when_not_disarmed() { let id = "game-complete"; - let (active_operations, active_downloads, _) = tracked_download_state(id); - let (tx, mut rx) = mpsc::unbounded_channel(); + let (active_operations, active_downloads, cancel) = tracked_download_state(id); + drop(OperationGuard::download(id.to_string(), cancel.clone())); - drop(OperationGuard::download( - id.to_string(), - active_operations.clone(), - active_downloads.clone(), - tx, - )); - - wait_for_tracking_clear(id, &active_operations, &active_downloads).await; - assert!(recv_active_operations(&mut rx).await.is_empty()); + assert!(active_operations.read().await.contains_key(id)); + assert!(active_downloads.read().await.contains_key(id)); + assert!(cancel.is_cancelled()); } #[tokio::test] - async fn operation_guard_cleans_tracking_after_cancellation() { + async fn download_guard_retains_tracking_after_cancellation() { let id = "game-cancelled"; let (active_operations, active_downloads, cancel) = tracked_download_state(id); cancel.cancel(); - let (tx, mut rx) = mpsc::unbounded_channel(); - - drop(OperationGuard::download( - id.to_string(), - active_operations.clone(), - active_downloads.clone(), - tx, - )); - - wait_for_tracking_clear(id, &active_operations, &active_downloads).await; - assert!(recv_active_operations(&mut rx).await.is_empty()); - } - - #[tokio::test] - async fn disarmed_operation_guard_does_not_clean_tracking() { - let id = "game-finished"; - let (active_operations, active_downloads, _) = tracked_download_state(id); - let (tx, _rx) = mpsc::unbounded_channel(); - - OperationGuard::download( - id.to_string(), - active_operations.clone(), - active_downloads.clone(), - tx, - ) - .disarm(); + drop(OperationGuard::download(id.to_string(), cancel.clone())); assert!(active_operations.read().await.contains_key(id)); assert!(active_downloads.read().await.contains_key(id)); } #[tokio::test] - async fn operation_guard_cleans_tracking_when_task_is_dropped() { - let id = "game-aborted"; - let (active_operations, active_downloads, _) = tracked_download_state(id); - let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); - let (tx, mut rx) = mpsc::unbounded_channel(); + async fn disarmed_operation_guard_does_not_clean_tracking() { + let id = "game-finished"; + let (active_operations, active_downloads, cancel) = tracked_download_state(id); + OperationGuard::download(id.to_string(), cancel.clone()).disarm(); + assert!(active_operations.read().await.contains_key(id)); + assert!(active_downloads.read().await.contains_key(id)); + } + + #[tokio::test] + async fn download_guard_retains_tracking_when_task_is_dropped() { + let id = "game-aborted"; + let (active_operations, active_downloads, cancel) = tracked_download_state(id); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); let handle = tokio::spawn({ - let active_operations = active_operations.clone(); - let active_downloads = active_downloads.clone(); - let tx = tx.clone(); + let cancel = cancel.clone(); async move { - let _guard = OperationGuard::download( - id.to_string(), - active_operations, - active_downloads, - tx, - ); + let _guard = OperationGuard::download(id.to_string(), cancel); let _ = ready_tx.send(()); std::future::pending::<()>().await; } @@ -383,35 +562,21 @@ mod tests { handle.abort(); let _ = handle.await; - wait_for_tracking_clear(id, &active_operations, &active_downloads).await; - assert_eq!( - recv_active_operations(&mut rx).await, - Vec::::new() - ); + assert!(active_operations.read().await.contains_key(id)); + assert!(active_downloads.read().await.contains_key(id)); } #[tokio::test] - async fn operation_guard_cleanup_snapshot_keeps_other_operations() { + async fn unexpected_non_download_guard_retains_tracking() { let active_operations = Arc::new(RwLock::new(HashMap::from([ - ("aborted".to_string(), OperationKind::Downloading), + ("aborted".to_string(), OperationKind::Installing), ("other".to_string(), OperationKind::Installing), ]))); - let active_downloads = Arc::new(RwLock::new(HashMap::new())); - let (tx, mut rx) = mpsc::unbounded_channel(); - drop(OperationGuard::download( - "aborted".to_string(), - active_operations, - active_downloads, - tx, - )); + drop(OperationGuard::new("aborted".to_string())); - assert_eq!( - recv_active_operations(&mut rx).await, - vec![ActiveOperation { - id: "other".to_string(), - operation: ActiveOperationKind::Installing, - }] - ); + let operations = active_operations.read().await; + assert_eq!(operations.get("aborted"), Some(&OperationKind::Installing)); + assert_eq!(operations.get("other"), Some(&OperationKind::Installing)); } } diff --git a/crates/lanspread-peer/src/download/confined_fs.rs b/crates/lanspread-peer/src/download/confined_fs.rs index eec6604..7e2ae52 100644 --- a/crates/lanspread-peer/src/download/confined_fs.rs +++ b/crates/lanspread-peer/src/download/confined_fs.rs @@ -19,8 +19,10 @@ use cap_primitives::{ ambient_authority, fs::{self, DirOptions, OpenOptions}, }; +use lanspread_db::content_manifest::CanonicalCatalogPath; use super::manifest::{ValidatedDownloadEntry, ValidatedDownloadPath}; +use crate::scoped_blocking::scoped_blocking; #[derive(Clone)] pub(super) struct ConfinedGameRoot { @@ -42,33 +44,26 @@ impl fmt::Debug for ConfinedGameRoot { } impl ConfinedGameRoot { - pub(super) async fn open_or_create(games_folder: &Path, game_id: &str) -> eyre::Result { + pub(super) fn open_or_create(games_folder: &Path, game_id: &str) -> eyre::Result { let games_folder = games_folder.to_path_buf(); let game_id = game_id.to_owned(); - tokio::task::spawn_blocking(move || Self::open_blocking(&games_folder, &game_id, true)) - .await? + scoped_blocking(move || Self::open_blocking(&games_folder, &game_id, true)) } - pub(super) async fn open_existing( - games_folder: &Path, - game_id: &str, - ) -> eyre::Result> { + pub(super) fn open_existing(games_folder: &Path, game_id: &str) -> eyre::Result> { let games_folder = games_folder.to_path_buf(); let game_id = game_id.to_owned(); - tokio::task::spawn_blocking(move || Self::open_blocking(&games_folder, &game_id, false)) - .await - .map_err(Into::into) - .and_then(|result| match result { - Ok(root) => Ok(Some(root)), - Err(error) - if error - .downcast_ref::() - .is_some_and(|error| error.kind() == ErrorKind::NotFound) => - { - Ok(None) - } - Err(error) => Err(error), - }) + match scoped_blocking(move || Self::open_blocking(&games_folder, &game_id, false)) { + Ok(root) => Ok(Some(root)), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == ErrorKind::NotFound) => + { + Ok(None) + } + Err(error) => Err(error), + } } fn open_blocking(games_folder: &Path, game_id: &str, create: bool) -> eyre::Result { @@ -100,12 +95,9 @@ impl ConfinedGameRoot { &self.inner.display_path } - pub(super) async fn prepare_entries( - &self, - entries: Vec, - ) -> eyre::Result<()> { + pub(super) fn prepare_entries(&self, entries: Vec) -> eyre::Result<()> { let root = self.clone(); - tokio::task::spawn_blocking(move || { + scoped_blocking(move || { for entry in &entries { if entry.is_dir() { root.open_directory_blocking(entry.destination(), true)?; @@ -115,21 +107,39 @@ impl ConfinedGameRoot { } Ok(()) }) - .await? } - pub(super) async fn open_chunk_file(&self, path: &ValidatedDownloadPath) -> eyre::Result { + pub(super) fn open_chunk_file(&self, path: &ValidatedDownloadPath) -> eyre::Result { let root = self.clone(); let path = path.clone(); - tokio::task::spawn_blocking(move || root.open_regular_file_blocking(&path, false)).await? + scoped_blocking(move || root.open_regular_file_blocking(&path, false)) } - pub(super) async fn sync_entries( + fn open_catalog_file_for_read( &self, - entries: Vec, - ) -> eyre::Result<()> { + path: &CanonicalCatalogPath, + expected_size: u64, + ) -> eyre::Result { let root = self.clone(); - tokio::task::spawn_blocking(move || { + let path = path.clone(); + scoped_blocking(move || { + let (parent, leaf) = root.open_parent_from_canonical_blocking(path.as_str(), false)?; + let file = open_regular_file_for_read_at(&parent, leaf)?; + let actual_size = file.metadata()?.len(); + if actual_size != expected_size { + eyre::bail!( + "catalog file size mismatch at {}/{}: expected {expected_size}, found {actual_size}", + root.inner.display_path.display(), + path.as_str() + ); + } + Ok(file) + }) + } + + pub(super) fn sync_entries(&self, entries: Vec) -> eyre::Result<()> { + let root = self.clone(); + scoped_blocking(move || { let mut parent_directories = BTreeSet::new(); for entry in &entries { if !entry.is_dir() { @@ -148,40 +158,37 @@ impl ConfinedGameRoot { sync_directory_handle(&root.inner.game_root)?; Ok(()) }) - .await? } - pub(super) async fn remove_owned_regular_files( + pub(super) fn remove_owned_regular_files( &self, paths: Vec, ) -> eyre::Result<()> { let root = self.clone(); - tokio::task::spawn_blocking(move || { + scoped_blocking(move || { for path in &paths { root.remove_owned_regular_file_blocking(path)?; } Ok(()) }) - .await? } - pub(super) async fn reject_existing_unowned_files( + pub(super) fn reject_existing_unowned_files( &self, paths: Vec, ) -> eyre::Result<()> { let root = self.clone(); - tokio::task::spawn_blocking(move || { + scoped_blocking(move || { for path in &paths { root.reject_existing_unowned_file_blocking(path)?; } Ok(()) }) - .await? } - pub(super) async fn root_regular_file_exists(&self, name: &'static str) -> eyre::Result { + pub(super) fn root_regular_file_exists(&self, name: &'static str) -> eyre::Result { let root = self.clone(); - tokio::task::spawn_blocking( + scoped_blocking( move || match root.inspect_root_regular_file_blocking(name) { Ok(_) => Ok(true), Err(error) @@ -194,29 +201,35 @@ impl ConfinedGameRoot { Err(error) => Err(error), }, ) - .await? } - pub(super) async fn root_entry_exists(&self, name: &'static str) -> eyre::Result { + pub(super) fn root_entry_exists(&self, name: &'static str) -> eyre::Result { let root = self.clone(); - tokio::task::spawn_blocking(move || { + scoped_blocking(move || { match fs::stat(&root.inner.game_root, Path::new(name), FollowSymlinks::No) { Ok(_) => Ok(true), Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), Err(error) => Err(error.into()), } }) - .await? } - pub(super) async fn create_new_root_file(&self, name: &'static str) -> eyre::Result { + pub(super) fn create_new_root_file(&self, name: &'static str) -> eyre::Result { let root = self.clone(); - tokio::task::spawn_blocking(move || root.create_new_root_file_blocking(name)).await? + scoped_blocking(move || root.create_new_root_file_blocking(name)) } - pub(super) async fn remove_root_file_if_exists(&self, name: &'static str) -> eyre::Result<()> { + pub(super) fn remove_root_file_if_exists(&self, name: &'static str) -> eyre::Result<()> { let root = self.clone(); - tokio::task::spawn_blocking(move || { + scoped_blocking(move || { + // An unlink attempt against a missing entry on a read-only mount + // fails with EROFS rather than NotFound. Inspect first so passive + // startup recovery can leave a complete read-only package alone. + match fs::stat(&root.inner.game_root, Path::new(name), FollowSymlinks::No) { + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } #[cfg(windows)] match root.inspect_root_regular_file_blocking(name) { Ok(file) => { @@ -240,16 +253,15 @@ impl ConfinedGameRoot { Err(error) => Err(error.into()), } }) - .await? } - pub(super) async fn rename_root_file( + pub(super) fn rename_root_file( &self, source: &'static str, destination: &'static str, ) -> eyre::Result<()> { let root = self.clone(); - tokio::task::spawn_blocking(move || { + scoped_blocking(move || { fs::rename( &root.inner.game_root, Path::new(source), @@ -258,14 +270,11 @@ impl ConfinedGameRoot { )?; Ok(()) }) - .await? } - pub(super) async fn sync_root(&self) -> std::io::Result<()> { + pub(super) fn sync_root(&self) -> std::io::Result<()> { let root = self.clone(); - tokio::task::spawn_blocking(move || sync_directory_handle(&root.inner.game_root)) - .await - .map_err(std::io::Error::other)? + scoped_blocking(move || sync_directory_handle(&root.inner.game_root)) } fn open_directory_blocking( @@ -316,7 +325,15 @@ impl ConfinedGameRoot { path: &'a ValidatedDownloadPath, create: bool, ) -> eyre::Result<(File, &'a str)> { - let mut components = path.components().peekable(); + self.open_parent_from_canonical_blocking(path.canonical(), create) + } + + fn open_parent_from_canonical_blocking<'a>( + &self, + canonical_path: &'a str, + create: bool, + ) -> eyre::Result<(File, &'a str)> { + let mut components = canonical_path.split('/').peekable(); let mut current = self.inner.game_root.try_clone()?; while let Some(component) = components.next() { if components.peek().is_none() { @@ -445,6 +462,20 @@ impl ConfinedGameRoot { } } +/// Opens one catalog-authorized ordinary file through retained, no-follow +/// directory handles and verifies that the opened object still has the exact +/// catalog size. +pub(crate) fn open_catalog_file_for_read( + games_folder: &Path, + game_id: &str, + path: &CanonicalCatalogPath, + expected_size: u64, +) -> eyre::Result { + let root = ConfinedGameRoot::open_existing(games_folder, game_id)? + .ok_or_else(|| eyre::eyre!("catalog game root does not exist: {game_id}"))?; + root.open_catalog_file_for_read(path, expected_size) +} + fn open_ambient_directory_nofollow(path: &Path) -> eyre::Result { let mut options = OpenOptions::new(); options.read(true); @@ -483,6 +514,12 @@ fn open_regular_file_at(parent: &File, leaf: &str, create: bool) -> eyre::Result Ok(file) } +fn open_regular_file_for_read_at(parent: &File, leaf: &str) -> eyre::Result { + let file = fs::open(parent, Path::new(leaf), &inspection_file_options())?; + validate_regular_file_handle(&file, leaf)?; + Ok(file) +} + #[cfg(windows)] fn inspect_regular_file_at(parent: &File, leaf: &str) -> eyre::Result { let options = inspection_file_options(); @@ -602,7 +639,7 @@ const fn sync_directory_handle(_directory: &File) -> std::io::Result<()> { #[cfg(test)] mod tests { - use std::io::{Seek, SeekFrom, Write}; + use std::io::{Read, Seek, SeekFrom, Write}; use super::*; use crate::test_support::TempDir; @@ -611,19 +648,17 @@ mod tests { ValidatedDownloadPath::from_ownership(value).expect("test path should validate") } - #[tokio::test] - async fn prepares_and_reopens_nested_regular_file() { + #[test] + fn prepares_and_reopens_nested_regular_file() { let games = TempDir::new("lanspread-confined-basic"); - let root = ConfinedGameRoot::open_or_create(games.path(), "game") - .await - .expect("game root should open"); + let root = + ConfinedGameRoot::open_or_create(games.path(), "game").expect("game root should open"); let destination = path("nested/payload.bin"); root.prepare_file_blocking(&destination, 4) .expect("file should prepare"); let mut file = root .open_chunk_file(&destination) - .await .expect("file should reopen"); file.seek(SeekFrom::Start(0)).expect("seek should succeed"); file.write_all(b"data").expect("write should succeed"); @@ -636,16 +671,102 @@ mod tests { ); } + #[test] + fn catalog_read_opens_only_the_exact_sized_regular_file() { + let games = TempDir::new("lanspread-confined-catalog-read"); + std::fs::create_dir_all(games.game_root()).expect("game root should be created"); + std::fs::write(games.game_root().join("version.ini"), b"20250101") + .expect("version sentinel should be written"); + let path = + CanonicalCatalogPath::new("version.ini").expect("catalog path should be canonical"); + + let mut file = open_catalog_file_for_read(games.path(), "game", &path, 8) + .expect("exact catalog file should open"); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .expect("opened catalog file should be readable"); + assert_eq!(bytes, b"20250101"); + assert!( + file.write_all(b"mutation").is_err(), + "sender capability must be read-only" + ); + assert!(open_catalog_file_for_read(games.path(), "game", &path, 7).is_err()); + } + #[cfg(unix)] - #[tokio::test] - async fn intermediate_and_final_symlinks_never_redirect_preparation() { + #[test] + fn catalog_read_rejects_intermediate_and_final_symlinks() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-confined-catalog-read-links"); + let outside = TempDir::new("lanspread-confined-catalog-read-outside"); + std::fs::create_dir_all(games.game_root()).expect("game root should be created"); + std::fs::write(outside.path().join("canary"), b"outside") + .expect("outside file should be written"); + symlink(outside.path(), games.game_root().join("linked")) + .expect("intermediate link should be created"); + symlink( + outside.path().join("canary"), + games.game_root().join("leaf"), + ) + .expect("final link should be created"); + + let intermediate = + CanonicalCatalogPath::new("linked/canary").expect("catalog path should be canonical"); + let final_link = + CanonicalCatalogPath::new("leaf").expect("catalog path should be canonical"); + assert!(open_catalog_file_for_read(games.path(), "game", &intermediate, 7).is_err()); + assert!(open_catalog_file_for_read(games.path(), "game", &final_link, 7).is_err()); + } + + #[cfg(unix)] + #[test] + fn catalog_read_retains_the_opened_file_after_a_path_swap() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-confined-catalog-read-swap"); + let outside = TempDir::new("lanspread-confined-catalog-read-swap-outside"); + std::fs::create_dir_all(games.game_root()).expect("game root should be created"); + std::fs::write(games.game_root().join("payload.bin"), b"catalog") + .expect("catalog payload should be written"); + std::fs::write(outside.path().join("canary"), b"outside") + .expect("outside file should be written"); + let path = + CanonicalCatalogPath::new("payload.bin").expect("catalog path should be canonical"); + let mut file = open_catalog_file_for_read(games.path(), "game", &path, 7) + .expect("catalog file should open"); + + std::fs::rename( + games.game_root().join("payload.bin"), + games.game_root().join("original.bin"), + ) + .expect("catalog payload should move"); + symlink( + outside.path().join("canary"), + games.game_root().join("payload.bin"), + ) + .expect("replacement link should be created"); + + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .expect("retained handle should remain readable"); + assert_eq!(bytes, b"catalog"); + assert_eq!( + std::fs::read(outside.path().join("canary")) + .expect("outside canary should remain readable"), + b"outside" + ); + } + + #[cfg(unix)] + #[test] + fn intermediate_and_final_symlinks_never_redirect_preparation() { use std::os::unix::fs::symlink; let games = TempDir::new("lanspread-confined-links"); let outside = TempDir::new("lanspread-confined-outside"); - let root = ConfinedGameRoot::open_or_create(games.path(), "game") - .await - .expect("game root should open"); + let root = + ConfinedGameRoot::open_or_create(games.path(), "game").expect("game root should open"); std::fs::write(outside.path().join("canary"), b"outside") .expect("canary should be written"); symlink(outside.path(), games.game_root().join("linked")) @@ -669,21 +790,19 @@ mod tests { } #[cfg(unix)] - #[tokio::test] - async fn writes_remain_on_open_handle_after_path_swap() { + #[test] + fn writes_remain_on_open_handle_after_path_swap() { use std::os::unix::fs::symlink; let games = TempDir::new("lanspread-confined-swap"); let outside = TempDir::new("lanspread-confined-swap-outside"); - let root = ConfinedGameRoot::open_or_create(games.path(), "game") - .await - .expect("game root should open"); + let root = + ConfinedGameRoot::open_or_create(games.path(), "game").expect("game root should open"); let destination = path("payload.bin"); root.prepare_file_blocking(&destination, 4) .expect("file should prepare"); let mut open_file = root .open_chunk_file(&destination) - .await .expect("file should open"); std::fs::write(outside.path().join("canary"), b"safe").expect("canary should be written"); std::fs::rename( @@ -715,15 +834,14 @@ mod tests { } #[cfg(unix)] - #[tokio::test] - async fn retained_root_handle_survives_ambient_path_swap() { + #[test] + fn retained_root_handle_survives_ambient_path_swap() { use std::os::unix::fs::symlink; let games = TempDir::new("lanspread-confined-root-swap"); let outside = TempDir::new("lanspread-confined-root-swap-outside"); - let root = ConfinedGameRoot::open_or_create(games.path(), "game") - .await - .expect("game root should open"); + let root = + ConfinedGameRoot::open_or_create(games.path(), "game").expect("game root should open"); std::fs::write(outside.path().join("canary"), b"safe").expect("canary should be written"); std::fs::rename(games.game_root(), games.path().join("held-root")) .expect("game root path should move"); @@ -741,42 +859,38 @@ mod tests { } #[cfg(unix)] - #[tokio::test] - async fn cleanup_can_inspect_and_remove_read_only_owned_files() { + #[test] + fn cleanup_can_inspect_and_remove_read_only_owned_files() { use std::os::unix::fs::PermissionsExt as _; let games = TempDir::new("lanspread-confined-read-only-cleanup"); - let root = ConfinedGameRoot::open_or_create(games.path(), "game") - .await - .expect("game root should open"); + let root = + ConfinedGameRoot::open_or_create(games.path(), "game").expect("game root should open"); let payload = games.game_root().join("payload.bin"); std::fs::write(&payload, b"owned").expect("payload should be written"); std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o444)) .expect("payload should become read-only"); root.remove_owned_regular_files(vec![path("payload.bin")]) - .await .expect("read-only owned file should be removable"); assert!(!payload.exists()); } #[cfg(unix)] - #[tokio::test] - async fn cleanup_does_not_require_read_access_to_owned_files() { + #[test] + fn cleanup_does_not_require_read_access_to_owned_files() { use std::os::unix::fs::PermissionsExt as _; let games = TempDir::new("lanspread-confined-mode-zero-cleanup"); - let root = ConfinedGameRoot::open_or_create(games.path(), "game") - .await - .expect("game root should open"); + let root = + ConfinedGameRoot::open_or_create(games.path(), "game").expect("game root should open"); let payload = games.game_root().join("payload.bin"); std::fs::write(&payload, b"owned").expect("payload should be written"); std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o000)) .expect("payload permissions should be removed"); root.remove_owned_regular_files(vec![path("payload.bin")]) - .await .expect("mode-zero owned file should be removable by its parent owner"); assert!(!payload.exists()); diff --git a/crates/lanspread-peer/src/download/manifest.rs b/crates/lanspread-peer/src/download/manifest.rs index f8db50d..9c8c718 100644 --- a/crates/lanspread-peer/src/download/manifest.rs +++ b/crates/lanspread-peer/src/download/manifest.rs @@ -1,23 +1,24 @@ use std::{ - collections::BTreeMap, + fmt, fs::Metadata, path::{Path, PathBuf}, + sync::Arc, }; use eyre::WrapErr; -use lanspread_db::db::{GameCatalog, GameFileDescription}; +use lanspread_db::content_manifest::{ + Blake3Digest, + CanonicalCatalogPath, + CatalogContentManifest, + CatalogEntryKind, + ContentId, +}; use unicode_normalization::is_nfc; use crate::game_paths::{VERSION_INI, is_download_protected_root_name, portable_name_key}; /// A remote manifest may describe at most this many filesystem entries. pub(crate) const MAX_DOWNLOAD_MANIFEST_ENTRIES: usize = 100_000; -/// A single remotely described file may be at most one tebibyte. -pub(crate) const MAX_DOWNLOAD_FILE_BYTES: u64 = 1024 * 1024 * 1024 * 1024; -/// A complete remotely described game may be at most sixteen tebibytes. -pub(crate) const MAX_DOWNLOAD_MANIFEST_BYTES: u64 = 16 * MAX_DOWNLOAD_FILE_BYTES; -/// The root sentinel is parsed in memory and should contain only a version value. -pub(crate) const MAX_VERSION_INI_BYTES: u64 = 64 * 1024; /// Portable filesystems support at least 255 bytes per ordinary component. pub(crate) const MAX_DOWNLOAD_COMPONENT_BYTES: usize = 255; /// Leave room for the configured game root under conservative 1,024-unit paths. @@ -28,9 +29,9 @@ const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000; #[derive(Clone, Debug)] pub(crate) struct ValidatedDownloadEntry { destination: ValidatedDownloadPath, - protocol_path: String, is_dir: bool, size: u64, + catalog_file_index: usize, } impl ValidatedDownloadEntry { @@ -38,10 +39,6 @@ impl ValidatedDownloadEntry { &self.destination } - pub(super) fn protocol_path(&self) -> &str { - &self.protocol_path - } - pub(crate) const fn is_dir(&self) -> bool { self.is_dir } @@ -53,50 +50,79 @@ impl ValidatedDownloadEntry { pub(crate) fn is_version_ini(&self) -> bool { self.destination.canonical() == VERSION_INI } +} - pub(crate) fn protocol_description(&self, game_id: &str) -> GameFileDescription { - GameFileDescription { - game_id: game_id.to_owned(), - relative_path: self.protocol_path.clone(), - is_dir: self.is_dir, - size: self.size, +#[derive(Clone, Copy, Debug)] +enum CatalogDigestSource { + File, + Chunk(usize), +} + +/// A constant-size reference to one digest retained by the catalog authority. +/// +/// Planning clones only the manifest `Arc` and these indices, not the catalog's +/// potentially millions of 32-byte digest values. +#[derive(Clone)] +pub(super) struct ExpectedCatalogBlake3 { + manifest: Arc, + file_index: usize, + source: CatalogDigestSource, +} + +impl ExpectedCatalogBlake3 { + pub(super) fn digest(&self) -> Blake3Digest { + let file = self + .manifest + .files() + .get(self.file_index) + .expect("validated catalog digest references retain their file entry"); + match self.source { + CatalogDigestSource::File => file + .file_blake3() + .expect("validated catalog regular files retain their file digest"), + CatalogDigestSource::Chunk(index) => file.chunk_blake3()[index], } } +} - #[cfg(test)] - pub(super) fn test_file(protocol_path: &str, canonical_path: &str, size: u64) -> Self { - validate_canonical_path(canonical_path).expect("test path should be canonical"); - Self { - destination: ValidatedDownloadPath::new(canonical_path.to_owned()), - protocol_path: protocol_path.to_owned(), - is_dir: false, - size, - } +impl fmt::Debug for ExpectedCatalogBlake3 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let file = &self.manifest.files()[self.file_index]; + formatter + .debug_struct("ExpectedCatalogBlake3") + .field("content_id", &self.manifest.content_id()) + .field("path", &file.canonical_path().as_str()) + .field("source", &self.source) + .finish() } } /// A canonical root-relative path that passed the complete download policy. -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(super) struct ValidatedDownloadPath { - canonical: String, + canonical: CanonicalCatalogPath, } impl ValidatedDownloadPath { - fn new(canonical: String) -> Self { + fn new(canonical: CanonicalCatalogPath) -> Self { Self { canonical } } pub(super) fn from_ownership(path: &str) -> eyre::Result { validate_owned_file_path(path)?; - Ok(Self::new(path.to_owned())) + Ok(Self::new(CanonicalCatalogPath::new(path)?)) } pub(super) fn canonical(&self) -> &str { + self.canonical.as_str() + } + + pub(super) const fn catalog_path(&self) -> &CanonicalCatalogPath { &self.canonical } pub(super) fn components(&self) -> impl DoubleEndedIterator { - self.canonical.split('/') + self.canonical.as_str().split('/') } } @@ -106,41 +132,55 @@ pub(crate) struct ValidatedDownloadManifest { game_id: String, games_folder: PathBuf, entries: Vec, + catalog: Arc, } impl ValidatedDownloadManifest { - /// Contains current protocol-7 descriptions within one known catalog game root. - pub(crate) fn from_protocol_v7( + /// Builds the complete ordinary-download plan exclusively from the local + /// catalog authority. + pub(crate) fn from_catalog( games_folder: &Path, - game_id: &str, - descriptions: Vec, - catalog: &GameCatalog, + catalog: Arc, ) -> eyre::Result { - if !catalog.contains(game_id) { - eyre::bail!("cannot download unknown catalog game {game_id}"); - } - if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES { + let game_id = catalog.game_id().to_owned(); + validate_game_id(&game_id)?; + let games_folder = canonical_games_folder(games_folder)?; + let game_root = games_folder.join(&game_id); + validate_game_root(&game_root)?; + if catalog.files().len() > MAX_DOWNLOAD_MANIFEST_ENTRIES { eyre::bail!( - "download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}", - descriptions.len() + "catalog manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}", + catalog.files().len() ); } - validate_game_id(game_id)?; - let games_folder = canonical_games_folder(games_folder)?; - let game_root = games_folder.join(game_id); - validate_game_root(&game_root)?; - let mut builder = - ProtocolV7ManifestBuilder::new(game_id, Some(&game_root), descriptions.len())?; - for description in descriptions { - builder.push(description)?; + let mut entries = Vec::with_capacity(catalog.files().len()); + for (catalog_file_index, catalog_entry) in catalog.files().iter().enumerate() { + let canonical_catalog_path = catalog_entry.canonical_path().clone(); + let canonical_path = canonical_catalog_path.as_str(); + let (_, components) = validate_canonical_path(canonical_path)?; + let root_component = components + .first() + .expect("validated canonical paths have one component"); + if is_download_protected_root_name(root_component) { + eyre::bail!("catalog path targets install or recovery state: {canonical_path}"); + } + + let is_dir = catalog_entry.kind() == CatalogEntryKind::Directory; + validate_existing_destination(&game_root, &components, is_dir, canonical_path)?; + entries.push(ValidatedDownloadEntry { + destination: ValidatedDownloadPath::new(canonical_catalog_path), + is_dir, + size: catalog_entry.size(), + catalog_file_index, + }); } - let entries = builder.finish()?; Ok(Self { - game_id: game_id.to_owned(), + game_id, games_folder, entries, + catalog, }) } @@ -152,10 +192,47 @@ impl ValidatedDownloadManifest { &self.games_folder } + pub(crate) fn content_id(&self) -> ContentId { + self.catalog.content_id() + } + pub(super) fn entries(&self) -> &[ValidatedDownloadEntry] { &self.entries } + pub(super) fn expected_blake3( + &self, + entry: &ValidatedDownloadEntry, + chunk_index: Option, + ) -> eyre::Result { + let manifest = &self.catalog; + let file_index = entry.catalog_file_index; + let catalog_entry = manifest + .files() + .get(file_index) + .ok_or_else(|| eyre::eyre!("catalog file index is out of bounds"))?; + let source = if let Some(index) = chunk_index { + if catalog_entry.chunk_blake3().get(index).is_none() { + eyre::bail!("catalog chunk digest index is out of bounds"); + } + CatalogDigestSource::Chunk(index) + } else { + if entry.size != 0 || catalog_entry.file_blake3().is_none() { + eyre::bail!("only an empty catalog file may use its whole-file digest"); + } + CatalogDigestSource::File + }; + Ok(ExpectedCatalogBlake3 { + manifest: Arc::clone(manifest), + file_index, + source, + }) + } + + pub(crate) const fn catalog_manifest(&self) -> &Arc { + &self.catalog + } + pub(crate) fn transfer_entries(&self) -> impl Iterator { self.entries.iter().filter(|entry| !entry.is_version_ini()) } @@ -170,7 +247,7 @@ impl ValidatedDownloadManifest { pub(super) fn owned_file_paths(&self) -> Vec { self.transfer_entries() .filter(|entry| !entry.is_dir()) - .map(|entry| entry.destination.canonical.clone()) + .map(|entry| entry.destination.canonical().to_owned()) .collect() } } @@ -190,199 +267,6 @@ pub(super) fn validate_owned_file_path(path: &str) -> eyre::Result { Ok(alias) } -/// Validates one peer's complete current-wire description before aggregation. -pub(crate) fn validate_protocol_v7_descriptions( - game_id: &str, - descriptions: Vec, -) -> eyre::Result> { - if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES { - eyre::bail!( - "download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}", - descriptions.len() - ); - } - validate_game_id(game_id)?; - let mut builder = ProtocolV7ManifestBuilder::new(game_id, None, descriptions.len())?; - for description in descriptions { - builder.push(description)?; - } - Ok(builder - .finish()? - .into_iter() - .map(|entry| entry.protocol_description(game_id)) - .collect()) -} - -struct ProtocolV7ManifestBuilder<'a> { - game_id: &'a str, - game_root: Option<&'a Path>, - prefix: String, - game_alias: String, - entries: Vec, - shapes: BTreeMap, - total_bytes: u64, - saw_protocol_root: bool, -} - -impl<'a> ProtocolV7ManifestBuilder<'a> { - fn new(game_id: &'a str, game_root: Option<&'a Path>, capacity: usize) -> eyre::Result { - Ok(Self { - game_id, - game_root, - prefix: format!("{game_id}/"), - game_alias: windows_alias_component(game_id)?, - entries: Vec::with_capacity(capacity), - shapes: BTreeMap::new(), - total_bytes: 0, - saw_protocol_root: false, - }) - } - - fn push(&mut self, description: GameFileDescription) -> eyre::Result<()> { - validate_protocol_game_id(self.game_id, &description)?; - if self.take_redundant_root(&description)? { - return Ok(()); - } - if description.relative_path.contains('\\') { - eyre::bail!( - "download path must use forward slashes: {}", - description.relative_path - ); - } - - let canonical_path = description - .relative_path - .strip_prefix(&self.prefix) - .ok_or_else(|| { - eyre::eyre!( - "download path must start with exactly {}: {}", - self.prefix, - description.relative_path - ) - })?; - let (alias_path, components) = validate_canonical_path(canonical_path)?; - self.validate_root_component(&components, &description.relative_path)?; - validate_entry_shape( - &mut self.shapes, - &alias_path, - description.is_dir, - &description.relative_path, - )?; - self.account_size(canonical_path, &description)?; - if let Some(game_root) = self.game_root { - validate_existing_destination( - game_root, - &components, - description.is_dir, - &description.relative_path, - )?; - } - self.entries.push(ValidatedDownloadEntry { - destination: ValidatedDownloadPath::new(canonical_path.to_owned()), - protocol_path: description.relative_path, - is_dir: description.is_dir, - size: description.size, - }); - Ok(()) - } - - fn take_redundant_root(&mut self, description: &GameFileDescription) -> eyre::Result { - if description.relative_path != self.game_id { - return Ok(false); - } - if description.is_dir && description.size == 0 { - if self.saw_protocol_root { - eyre::bail!("duplicate protocol game-root entry for {}", self.game_id); - } - self.saw_protocol_root = true; - return Ok(true); - } - eyre::bail!( - "the protocol game-root entry for {} must be a zero-sized directory", - self.game_id - ) - } - - fn validate_root_component(&self, components: &[&str], display_path: &str) -> eyre::Result<()> { - let root_component = components - .first() - .expect("validated canonical paths have one component"); - if windows_alias_component(root_component)? == self.game_alias { - eyre::bail!("download path contains a doubled game prefix: {display_path}"); - } - if is_download_protected_root_name(root_component) { - eyre::bail!("download path targets install or recovery state: {display_path}"); - } - Ok(()) - } - - fn account_size( - &mut self, - canonical_path: &str, - description: &GameFileDescription, - ) -> eyre::Result<()> { - if description.is_dir { - if description.size != 0 { - eyre::bail!( - "directory entry has a non-zero size: {}", - description.relative_path - ); - } - return Ok(()); - } - if description.size > MAX_DOWNLOAD_FILE_BYTES { - eyre::bail!( - "download file exceeds the {MAX_DOWNLOAD_FILE_BYTES}-byte limit: {}", - description.relative_path - ); - } - if canonical_path == VERSION_INI && description.size > MAX_VERSION_INI_BYTES { - eyre::bail!( - "root version.ini exceeds the {MAX_VERSION_INI_BYTES}-byte limit: {}", - description.relative_path - ); - } - self.total_bytes = self - .total_bytes - .checked_add(description.size) - .ok_or_else(|| eyre::eyre!("download manifest byte count overflow"))?; - if self.total_bytes > MAX_DOWNLOAD_MANIFEST_BYTES { - eyre::bail!("download manifest exceeds the {MAX_DOWNLOAD_MANIFEST_BYTES}-byte limit"); - } - Ok(()) - } - - fn finish(mut self) -> eyre::Result> { - self.entries - .sort_by(|left, right| left.destination.cmp(&right.destination)); - let versions = self - .entries - .iter() - .filter(|entry| entry.is_version_ini()) - .collect::>(); - let [version_ini] = versions.as_slice() else { - eyre::bail!( - "expected exactly one regular root version.ini for {}, found {}", - self.game_id, - versions.len() - ); - }; - if version_ini.is_dir { - eyre::bail!( - "root version.ini for {} must be a regular file", - self.game_id - ); - } - Ok(self.entries) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum EntryShape { - File, - Directory, -} - pub(super) fn validate_game_id(game_id: &str) -> eyre::Result<()> { if game_id.contains('/') || game_id.contains('\\') { eyre::bail!("catalog game ID must be one path component: {game_id}"); @@ -397,19 +281,6 @@ pub(super) fn validate_game_id(game_id: &str) -> eyre::Result<()> { Ok(()) } -fn validate_protocol_game_id( - requested_game_id: &str, - description: &GameFileDescription, -) -> eyre::Result<()> { - if description.game_id != requested_game_id { - eyre::bail!( - "description for {} cannot be used to download {requested_game_id}", - description.game_id - ); - } - Ok(()) -} - pub(super) fn canonical_games_folder(games_folder: &Path) -> eyre::Result { if !games_folder.is_absolute() { eyre::bail!( @@ -532,42 +403,6 @@ fn looks_like_dos_short_name(component: &str) -> bool { }) } -fn validate_entry_shape( - shapes: &mut BTreeMap, - alias_path: &str, - is_dir: bool, - display_path: &str, -) -> eyre::Result<()> { - let shape = if is_dir { - EntryShape::Directory - } else { - EntryShape::File - }; - if shapes.insert(alias_path.to_owned(), shape).is_some() { - eyre::bail!("duplicate or platform-alias download path: {display_path}"); - } - - let mut parent = alias_path; - while let Some((prefix, _)) = parent.rsplit_once('/') { - if shapes.get(prefix) == Some(&EntryShape::File) { - eyre::bail!("download path descends through a file: {display_path}"); - } - parent = prefix; - } - - if shape == EntryShape::File { - let descendant_prefix = format!("{alias_path}/"); - if shapes - .range(descendant_prefix.clone()..) - .next() - .is_some_and(|(candidate, _)| candidate.starts_with(&descendant_prefix)) - { - eyre::bail!("download file conflicts with a described child: {display_path}"); - } - } - Ok(()) -} - fn validate_existing_destination( game_root: &Path, components: &[&str], @@ -650,486 +485,151 @@ const fn is_windows_reparse_point(_metadata: &Metadata) -> bool { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::sync::Arc; + + use lanspread_db::content_manifest::{ + Blake3Digest, + CatalogContentManifestBody, + CatalogFileEntry, + }; use super::*; use crate::test_support::TempDir; - #[derive(Debug, PartialEq, Eq)] - enum TreeEntry { - Directory, - File(Vec), - Symlink(PathBuf), - Other, - } - - fn catalog() -> GameCatalog { - GameCatalog::from_ids(["game".to_owned()]) - } - - fn file(path: &str, size: u64) -> GameFileDescription { - GameFileDescription { - game_id: "game".to_owned(), - relative_path: path.to_owned(), - is_dir: false, - size, - } - } - - fn directory(path: &str) -> GameFileDescription { - GameFileDescription { - game_id: "game".to_owned(), - relative_path: path.to_owned(), - is_dir: true, - size: 0, - } - } - - fn valid_descriptions() -> Vec { - vec![ - directory("game"), - file("game/archive.eti", 10), - file("game/version.ini", 8), - ] - } - - fn validate( - temp: &TempDir, - descriptions: Vec, - ) -> eyre::Result { - ValidatedDownloadManifest::from_protocol_v7(temp.path(), "game", descriptions, &catalog()) - } - - fn snapshot_tree(root: &Path) -> BTreeMap { - walkdir::WalkDir::new(root) - .follow_links(false) - .into_iter() - .map(|entry| entry.expect("test tree should be readable")) - .filter(|entry| entry.path() != root) - .map(|entry| { - let relative = entry - .path() - .strip_prefix(root) - .expect("entry should be below root") - .to_path_buf(); - let file_type = entry.file_type(); - let value = if file_type.is_dir() { - TreeEntry::Directory - } else if file_type.is_file() { - TreeEntry::File( - std::fs::read(entry.path()).expect("test file should be readable"), - ) - } else if file_type.is_symlink() { - TreeEntry::Symlink( - std::fs::read_link(entry.path()).expect("test link should be readable"), - ) - } else { - TreeEntry::Other - }; - (relative, value) - }) - .collect() - } - - fn write_file(path: &Path, bytes: &[u8]) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("parent should be created"); - } - std::fs::write(path, bytes).expect("test file should be written"); - } - - fn assert_rejected_without_mutation(descriptions: Vec) { - let temp = TempDir::new("lanspread-manifest-unchanged"); - write_file(&temp.game_root().join("archive.eti"), b"original"); - write_file(&temp.game_root().join("version.ini"), b"20250101"); - write_file(&temp.game_root().join("local/save.dat"), b"save"); - write_file(&temp.path().join("sibling/local/save.dat"), b"sibling"); - let before = snapshot_tree(temp.path()); - - assert!(validate(&temp, descriptions).is_err()); - assert_eq!(snapshot_tree(temp.path()), before); - } - - #[test] - fn protocol_v7_adapter_strips_exact_game_prefix() { - let temp = TempDir::new("lanspread-manifest-valid"); - let manifest = validate(&temp, valid_descriptions()).expect("manifest should validate"); - - let paths = manifest - .entries() - .iter() - .map(|entry| entry.destination().canonical()) - .collect::>(); - assert_eq!(paths, ["archive.eti", "version.ini"]); - let version_ini = manifest - .entries() - .iter() - .find(|entry| entry.is_version_ini()) - .expect("version.ini should exist"); - assert_eq!(version_ini.protocol_path(), "game/version.ini"); - } - - #[test] - fn accepts_nfc_catalog_game_id_and_path_components() { - let temp = TempDir::new("lanspread-manifest-nfc-valid"); - let game_id = "g\u{e1}me"; - let catalog = GameCatalog::from_ids([game_id.to_owned()]); - let descriptions = vec![ - GameFileDescription { - game_id: game_id.to_owned(), - relative_path: game_id.to_owned(), - is_dir: true, - size: 0, - }, - GameFileDescription { - game_id: game_id.to_owned(), - relative_path: format!("{game_id}/caf\u{e9}/archive.eti"), - is_dir: false, - size: 10, - }, - GameFileDescription { - game_id: game_id.to_owned(), - relative_path: format!("{game_id}/version.ini"), - is_dir: false, - size: 8, - }, - ]; - - let manifest = ValidatedDownloadManifest::from_protocol_v7( - temp.path(), - game_id, - descriptions, - &catalog, + fn catalog_manifest() -> Arc { + let archive_digest = Blake3Digest::hash(b"abc"); + let version_digest = Blake3Digest::hash(b"1"); + Arc::new( + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "game", + "1", + vec![ + CatalogFileEntry::directory("data") + .expect("catalog directory should validate"), + CatalogFileEntry::file( + "data/archive.eti", + 3, + archive_digest, + vec![archive_digest], + ) + .expect("catalog archive should validate"), + CatalogFileEntry::file( + "version.ini", + 1, + version_digest, + vec![version_digest], + ) + .expect("catalog version should validate"), + ], + Vec::new(), + ) + .expect("catalog body should validate"), + ) + .expect("catalog should seal"), ) - .expect("NFC-normalized names should validate"); + } - assert_eq!(manifest.game_id(), game_id); + #[test] + fn catalog_authority_and_typed_root_relative_paths_are_retained() { + let catalog = catalog_manifest(); + let expected_content_id = catalog.content_id(); + let temp = TempDir::new("lanspread-catalog-manifest"); + + let manifest = ValidatedDownloadManifest::from_catalog(temp.path(), Arc::clone(&catalog)) + .expect("catalog manifest should become download authority"); + + assert!(Arc::ptr_eq(manifest.catalog_manifest(), &catalog)); + assert_eq!(manifest.content_id(), expected_content_id); + assert_eq!( + manifest + .entries() + .iter() + .map(|entry| entry.destination().catalog_path().as_str()) + .collect::>(), + ["data", "data/archive.eti", "version.ini"] + ); assert!( manifest .entries() .iter() - .any(|entry| entry.destination().canonical() == "caf\u{e9}/archive.eti") + .all(|entry| !entry.destination().canonical().starts_with("game/")), + "wire paths must remain canonical game-root-relative catalog paths" ); } #[test] - fn rejects_non_nfc_catalog_game_id_without_mutation() { - let temp = TempDir::new("lanspread-manifest-nfc-game-id"); - write_file(&temp.path().join("existing/file.bin"), b"unchanged"); - let before = snapshot_tree(temp.path()); - let game_id = "ga\u{301}me"; - let catalog = GameCatalog::from_ids([game_id.to_owned()]); - let descriptions = vec![GameFileDescription { - game_id: game_id.to_owned(), - relative_path: format!("{game_id}/version.ini"), - is_dir: false, - size: 8, - }]; - - let error = ValidatedDownloadManifest::from_protocol_v7( - temp.path(), - game_id, - descriptions, - &catalog, - ) - .expect_err("non-NFC catalog game ID should fail"); - - assert!(error.to_string().contains("Unicode NFC normalization")); - assert_eq!(snapshot_tree(temp.path()), before); - } - - #[test] - fn rejects_non_nfc_download_component_without_mutation() { - assert_rejected_without_mutation(vec![ - file("game/cafe\u{301}/archive.eti", 10), - file("game/version.ini", 8), - ]); - } - - #[test] - fn rejects_unknown_catalog_game() { - let temp = TempDir::new("lanspread-manifest-unknown"); - let error = ValidatedDownloadManifest::from_protocol_v7( - temp.path(), - "unknown", - Vec::new(), - &catalog(), - ) - .expect_err("unknown game should fail"); - assert!(error.to_string().contains("unknown catalog game")); - } - - #[test] - fn rejects_missing_different_and_doubled_game_prefixes() { - let temp = TempDir::new("lanspread-manifest-prefix"); - for path in ["version.ini", "other/version.ini", "game/game/version.ini"] { - let descriptions = vec![file("game/archive.eti", 10), file(path, 8)]; - assert!(validate(&temp, descriptions).is_err(), "accepted {path}"); - } - } - - #[test] - fn rejects_cross_game_description_identity() { - let temp = TempDir::new("lanspread-manifest-cross-game"); - let mut descriptions = valid_descriptions(); - descriptions[1].game_id = "other".to_owned(); - assert!(validate(&temp, descriptions).is_err()); - } - - #[test] - fn rejects_noncanonical_and_nonportable_paths() { - let temp = TempDir::new("lanspread-manifest-noncanonical"); + fn ownership_paths_reuse_typed_catalog_validation_and_reject_reserved_roots() { + assert_eq!( + ValidatedDownloadPath::from_ownership("data/archive.eti") + .expect("catalog path should validate") + .catalog_path() + .as_str(), + "data/archive.eti" + ); for path in [ - "game/a\\b.eti", - "game//archive.eti", - "game/./archive.eti", - "game/../archive.eti", - "game/archive.eti/", - "game/C:/archive.eti", - "game/archive?.eti", - "game/archive.eti\0suffix", + "version.ini", + "local/save.dat", + ".sync/state", + "../escape", + "/absolute", + "back\\slash", + "NUL.txt", ] { - let descriptions = vec![file(path, 10), file("game/version.ini", 8)]; - assert!(validate(&temp, descriptions).is_err(), "accepted {path:?}"); - } - } - - #[test] - fn rejects_portability_aliases_and_path_length_overflows() { - let temp = TempDir::new("lanspread-manifest-portability"); - for path in [ - "game/.Å¿ync/state", - "game/COM0", - "game/LPT0.txt", - "game/COM¹.txt", - "game/LPT³.log", - "game/CON .txt", - "game/CON\u{00a0}", - "game/LOCAL~1/save.dat", - ] { - let descriptions = vec![file(path, 1), file("game/version.ini", 8)]; - assert!(validate(&temp, descriptions).is_err(), "accepted {path}"); - } - - let long_component = format!("game/{}", "a".repeat(MAX_DOWNLOAD_COMPONENT_BYTES + 1)); - assert!( - validate( - &temp, - vec![file(&long_component, 1), file("game/version.ini", 8)] - ) - .is_err() - ); - - let long_relative = std::iter::repeat_n("a".repeat(200), 5) - .collect::>() - .join("/"); - let long_path = format!("game/{long_relative}"); - assert!( - validate( - &temp, - vec![file(&long_path, 1), file("game/version.ini", 8)] - ) - .is_err() - ); - } - - #[test] - fn rejects_install_and_recovery_owned_roots() { - let temp = TempDir::new("lanspread-manifest-reserved"); - for component in [ - "local", - "LOCAL", - ".local.installing", - ".local.backup", - ".sync", - ".lanspread", - ".lanspread.json", - ".lanspread.json.tmp", - ".lanspread_owned", - ".softlan_first_start_done", - ".softlan_game_installed", - ".version.ini.tmp", - ".version.ini.discarded", - "install_intent.json", - "install_intent.json.tmp", - ] { - let path = format!("game/{component}/payload.bin"); - let descriptions = vec![file(&path, 10), file("game/version.ini", 8)]; assert!( - validate(&temp, descriptions).is_err(), - "accepted protected path {path}" + ValidatedDownloadPath::from_ownership(path).is_err(), + "accepted invalid ownership path {path:?}" ); } } #[test] - fn rejects_duplicates_platform_aliases_and_shape_conflicts() { - let temp = TempDir::new("lanspread-manifest-alias"); - let cases = [ - vec![file("game/A.eti", 1), file("game/a.eti", 1)], - vec![file("game/archive.eti", 1), file("game/archive.eti", 1)], - vec![file("game/con.txt", 1)], - vec![file("game/archive.eti.", 1)], - vec![file("game/archive.eti ", 1)], - vec![file("game/dir", 1), file("game/dir/child", 1)], - vec![file("game/dir/child", 1), file("game/dir", 1)], - vec![directory("game/dir"), file("game/dir", 1)], - vec![directory("game"), directory("game")], - ]; - for mut descriptions in cases { - descriptions.push(file("game/version.ini", 8)); - assert!(validate(&temp, descriptions).is_err()); - } - } - - #[test] - fn raw_peer_validation_rejects_duplicates_before_consensus() { - let descriptions = vec![ - file("game/version.ini", 8), - file("game/archive.eti", 1), - file("game/archive.eti", 1), - ]; - assert!(validate_protocol_v7_descriptions("game", descriptions).is_err()); - } - - #[test] - fn requires_exactly_one_regular_root_version_ini() { - let temp = TempDir::new("lanspread-manifest-version"); - assert!(validate(&temp, vec![file("game/archive.eti", 1)]).is_err()); - assert!( - validate( - &temp, - vec![file("game/version.ini", 8), file("game/version.ini", 8)] - ) - .is_err() - ); - assert!(validate(&temp, vec![directory("game/version.ini")]).is_err()); - } - - #[test] - fn rejects_nonzero_directory_and_size_limits() { - let temp = TempDir::new("lanspread-manifest-limits"); - let mut bad_dir = directory("game/data"); - bad_dir.size = 1; - assert!(validate(&temp, vec![bad_dir, file("game/version.ini", 8)]).is_err()); - assert!( - validate( - &temp, - vec![ - file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1), - file("game/version.ini", 8), - ] - ) - .is_err() - ); - assert!( - validate( - &temp, - vec![ - file("game/version.ini", MAX_VERSION_INI_BYTES + 1), - file("game/archive.eti", 1), - ] - ) - .is_err() - ); - - let descriptions = vec![file("game/version.ini", 8); MAX_DOWNLOAD_MANIFEST_ENTRIES + 1]; - assert!(validate(&temp, descriptions).is_err()); - } - - #[test] - fn hostile_late_descriptor_leaves_filesystem_unchanged() { - let temp = TempDir::new("lanspread-manifest-zero-mutation"); + fn catalog_validation_rejects_existing_destination_shape_conflicts_without_mutation() { + let temp = TempDir::new("lanspread-catalog-shape-conflict"); let game_root = temp.game_root(); std::fs::create_dir_all(&game_root).expect("game root should be created"); - std::fs::write(game_root.join("archive.eti"), b"original") - .expect("existing archive should be written"); - std::fs::write(game_root.join("version.ini"), b"20250101") - .expect("existing version should be written"); - - let descriptions = vec![ - file("game/archive.eti", 1), - file("game/version.ini", 8), - file("game/local/save.dat", 1), - ]; - assert!(validate(&temp, descriptions).is_err()); + std::fs::write(game_root.join("data"), b"existing file") + .expect("conflicting file should be created"); + let before = std::fs::read(game_root.join("data")).expect("file should be readable"); + assert!(ValidatedDownloadManifest::from_catalog(temp.path(), catalog_manifest()).is_err()); assert_eq!( - std::fs::read(game_root.join("archive.eti")).expect("archive should remain"), - b"original" + std::fs::read(game_root.join("data")).expect("file should remain readable"), + before ); - assert_eq!( - std::fs::read(game_root.join("version.ini")).expect("version should remain"), - b"20250101" - ); - assert_eq!( - std::fs::read_dir(&game_root) - .expect("game root should remain readable") - .count(), - 2 - ); - } - - #[test] - fn rejection_categories_leave_the_complete_tree_unchanged() { - let cases = [ - vec![file("game/version.ini", 8), file("other/local/save.dat", 1)], - vec![file("game/version.ini", 8), file("game/local/save.dat", 1)], - vec![file("game/version.ini", 8), file("game/.sync/state", 1)], - vec![file("game/version.ini", 8), file("game/COM0", 1)], - vec![file("game/version.ini", 8), file("game/CON\u{00a0}", 1)], - vec![file("game/version.ini", 8), file("game/../escape", 1)], - vec![ - file("game/version.ini", 8), - file("game/A.eti", 1), - file("game/a.eti", 1), - ], - vec![ - file("game/version.ini", 8), - file("game/dir", 1), - file("game/dir/child", 1), - ], - vec![file("game/archive.eti", 1)], - vec![directory("game/version.ini")], - vec![ - file("game/version.ini", 8), - file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1), - ], - vec![ - directory("game"), - directory("game"), - file("game/version.ini", 8), - ], - ]; - for descriptions in cases { - assert_rejected_without_mutation(descriptions); - } - - assert_rejected_without_mutation(vec![ - file("game/version.ini", 8); - MAX_DOWNLOAD_MANIFEST_ENTRIES + 1 - ]); } #[cfg(unix)] #[test] - fn rejects_symlink_game_roots_and_destination_components() { + fn catalog_validation_rejects_symlink_game_roots_and_destination_components() { use std::os::unix::fs::symlink; - let root_link = TempDir::new("lanspread-manifest-root-link"); - let outside = TempDir::new("lanspread-manifest-outside"); + let root_link = TempDir::new("lanspread-catalog-root-link"); + let outside = TempDir::new("lanspread-catalog-outside"); + std::fs::write(outside.path().join("canary"), b"outside") + .expect("outside canary should be created"); symlink(outside.path(), root_link.path().join("game")) .expect("game root symlink should be created"); - assert!(validate(&root_link, valid_descriptions()).is_err()); - let child_link = TempDir::new("lanspread-manifest-child-link"); + assert!( + ValidatedDownloadManifest::from_catalog(root_link.path(), catalog_manifest()).is_err() + ); + assert_eq!( + std::fs::read(outside.path().join("canary")).expect("canary should remain"), + b"outside" + ); + + let child_link = TempDir::new("lanspread-catalog-child-link"); std::fs::create_dir_all(child_link.game_root()).expect("game root should be created"); - symlink(outside.path(), child_link.game_root().join("payload")) + symlink(outside.path(), child_link.game_root().join("data")) .expect("child symlink should be created"); - let descriptions = vec![ - file("game/payload/file.bin", 1), - file("game/version.ini", 8), - ]; - assert!(validate(&child_link, descriptions).is_err()); + + assert!( + ValidatedDownloadManifest::from_catalog(child_link.path(), catalog_manifest()).is_err() + ); + assert_eq!( + std::fs::read(outside.path().join("canary")).expect("canary should remain"), + b"outside" + ); } } diff --git a/crates/lanspread-peer/src/download/mod.rs b/crates/lanspread-peer/src/download/mod.rs index 486defe..50db7d8 100644 --- a/crates/lanspread-peer/src/download/mod.rs +++ b/crates/lanspread-peer/src/download/mod.rs @@ -9,11 +9,24 @@ mod progress; mod retry; mod storage; mod task_drain; +mod transfer_error; mod transport; mod version_ini; -pub(crate) use manifest::{ValidatedDownloadManifest, validate_protocol_v7_descriptions}; -pub(crate) use orchestrator::download_game_files; +pub(crate) use confined_fs::open_catalog_file_for_read; +pub(crate) use manifest::ValidatedDownloadManifest; +pub(crate) use orchestrator::{DownloadCompletion, DownloadGameRequest, download_game_files}; +pub(crate) use ownership::{ + DownloadOwnershipReadiness, + download_ownership_matches_content, + download_ownership_readiness, + recover_incomplete_download, + remove_downloaded_payload, + scan_download_ownership_recovery_ids, +}; #[cfg(test)] -pub(crate) use ownership::seed_download_ownership_for_test; -pub(crate) use ownership::{recover_incomplete_download, remove_downloaded_payload}; +pub(crate) use ownership::{ + seed_download_ownership_for_test, + seed_pending_download_ownership_for_test, +}; +pub(crate) use transfer_error::{DownloadTransferError, DownloadTransferErrorKind}; diff --git a/crates/lanspread-peer/src/download/orchestrator.rs b/crates/lanspread-peer/src/download/orchestrator.rs index 514cf47..6e7157a 100644 --- a/crates/lanspread-peer/src/download/orchestrator.rs +++ b/crates/lanspread-peer/src/download/orchestrator.rs @@ -1,19 +1,35 @@ -use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + fmt, + net::SocketAddr, + path::Path, + sync::Arc, +}; use futures::stream::FuturesUnordered; +use lanspread_db::content_manifest::ContentId; +use lanspread_proto::PeerEndpoint; use tokio::sync::mpsc::UnboundedSender; use tokio_util::sync::CancellationToken; use super::{ + DownloadTransferError, + DownloadTransferErrorKind, confined_fs::ConfinedGameRoot, manifest::{ValidatedDownloadEntry, ValidatedDownloadManifest}, ownership::{DownloadOwnershipTransaction, OwnershipJournalPublication}, - planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans}, + planning::{ + ChunkDownloadResult, + DownloadChunk, + PeerDownloadPlan, + build_peer_plans, + reconcile_chunk_results, + }, progress::{DownloadProgressTracker, sample_download_progress}, - retry::{RetryContext, retry_failed_chunks}, + retry::{RetryChunk, RetryContext, quarantine_if_integrity_failure, retry_failed_chunks}, storage::{prepare_game_storage, sync_game_storage}, task_drain::collect_or_drain_on_cancel, - transport::download_from_peer, + transport::{PeerDownloadRequest, download_from_peer}, version_ini::{ VersionIniBuffer, VersionIniCommit, @@ -22,48 +38,187 @@ use super::{ restore_unjournaled_version_ini_transaction, }, }; -use crate::{PeerEvent, config::MAX_RETRY_COUNT}; +use crate::{ + DownloadFailureReason, + DownloadVerificationActivity, + PeerEvent, + content_quarantine::ContentQuarantine, + peer_db::PeerId, + quic_runtime::QuicConnector, + transfer_status::{DownloadAttemptReporter, DownloadAttemptStatus}, +}; + +/// Terminal state of a complete payload transfer. +#[derive(Debug)] +pub(crate) enum DownloadCompletion { + /// Payload, sentinel, and ownership state are durably settled. + Durable, + /// The committed payload is visible, but recovery must settle its metadata + /// before it can be advertised, served, or installed. + RecoveryRequired(eyre::Report), +} + +/// Typed owner-facing failure from one complete ordinary download operation. +#[derive(Debug)] +pub(crate) struct DownloadOperationError { + reason: Option, + error: eyre::Report, +} + +impl DownloadOperationError { + pub(super) fn cancelled(error: impl Into) -> Self { + Self { + reason: None, + error: error.into(), + } + } + + pub(super) fn sources_exhausted(error: impl Into) -> Self { + Self { + reason: Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted), + error: error.into(), + } + } + + pub(super) fn operation_failed(error: impl Into) -> Self { + Self { + reason: Some(DownloadFailureReason::OperationFailed), + error: error.into(), + } + } + + pub(crate) const fn reason(&self) -> Option { + self.reason + } + + pub(crate) fn into_report(self) -> eyre::Report { + self.error + } +} + +/// Aggregates independent chunk failures without letting a later retryable +/// source failure downgrade an already-observed local operation failure. +#[derive(Default)] +struct TransferFailureAggregate { + operation_failed: Option, + cancelled: Option, + sources_exhausted: Option, +} + +impl TransferFailureAggregate { + fn record(&mut self, error: DownloadTransferError) { + match error.kind() { + DownloadTransferErrorKind::LocalIo => { + self.operation_failed.get_or_insert(error); + } + DownloadTransferErrorKind::Cancelled => { + self.cancelled.get_or_insert(error); + } + DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => { + self.sources_exhausted.get_or_insert(error); + } + } + } + + fn into_operation_error(self) -> Option { + if let Some(error) = self.operation_failed { + return Some(DownloadOperationError::operation_failed(error)); + } + if let Some(error) = self.cancelled { + return Some(DownloadOperationError::cancelled(error)); + } + self.sources_exhausted + .map(DownloadOperationError::sources_exhausted) + } + + fn cancellation_error(&mut self, game_id: &str) -> DownloadOperationError { + self.operation_failed.take().map_or_else( + || cancelled_download(game_id), + DownloadOperationError::operation_failed, + ) + } +} + +impl fmt::Display for DownloadOperationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.error.fmt(formatter) + } +} + +/// Complete authority and runtime input for one ordinary catalog download. +pub(crate) struct DownloadGameRequest<'a> { + pub(crate) attempt: &'a DownloadAttemptStatus, + pub(crate) manifest: ValidatedDownloadManifest, + pub(crate) state_dir: &'a Path, + pub(crate) sources: &'a [PeerEndpoint], + pub(crate) content_id: ContentId, + pub(crate) quarantine: &'a ContentQuarantine, + pub(crate) tx_notify_ui: UnboundedSender, + pub(crate) cancel_token: CancellationToken, + pub(crate) quic: QuicConnector, +} /// Downloads all game files from available peers. #[allow(clippy::too_many_lines)] pub(crate) async fn download_game_files( - manifest: ValidatedDownloadManifest, - state_dir: &Path, - peers: Vec, - file_peer_map: HashMap>, - tx_notify_ui: UnboundedSender, - cancel_token: CancellationToken, -) -> eyre::Result<()> { + request: DownloadGameRequest<'_>, +) -> Result { + let DownloadGameRequest { + attempt, + manifest, + state_dir, + sources, + content_id, + quarantine, + tx_notify_ui, + cancel_token, + quic, + } = request; let game_id = manifest.game_id().to_owned(); - if peers.is_empty() { - eyre::bail!("no peers available for game {game_id}"); + let manifest_content_id = manifest.catalog_manifest().content_id(); + if manifest_content_id != content_id { + return Err(DownloadOperationError::operation_failed(eyre::eyre!( + "download content ID does not match catalog authority for game {game_id}: requested {content_id}, catalog {manifest_content_id}" + ))); + } + let sources = eligible_content_sources(sources, content_id, quarantine) + .map_err(DownloadOperationError::operation_failed)?; + if sources.is_empty() { + return Err(DownloadOperationError::sources_exhausted(eyre::eyre!( + "no peers available for game {game_id}" + ))); } if cancel_token.is_cancelled() { - eyre::bail!("download cancelled for game {game_id}"); + return Err(cancelled_download(&game_id)); } let version_entry = manifest.version_entry(); - let version_buffer = - match VersionIniBuffer::new(version_entry.protocol_path(), version_entry.size()) { - Ok(buffer) => Arc::new(buffer), - Err(err) => return Err(err), - }; - let confined_root = ConfinedGameRoot::open_or_create(manifest.games_folder(), &game_id).await?; - let ownership = - DownloadOwnershipTransaction::prepare(state_dir, &manifest, &confined_root).await?; + let version_buffer = match VersionIniBuffer::new( + version_entry.destination().canonical(), + version_entry.size(), + ) { + Ok(buffer) => Arc::new(buffer), + Err(err) => return Err(DownloadOperationError::operation_failed(err)), + }; + let confined_root = ConfinedGameRoot::open_or_create(manifest.games_folder(), &game_id) + .map_err(DownloadOperationError::operation_failed)?; + let ownership = DownloadOwnershipTransaction::prepare(state_dir, &manifest, &confined_root) + .await + .map_err(DownloadOperationError::operation_failed)?; - if let Err(error) = begin_version_ini_transaction(&confined_root).await { - if let Err(restore_error) = restore_before_ownership_journal(&confined_root).await { - return Err(error.wrap_err(format!( - "sentinel parking failed and rollback also failed: {restore_error}" + if let Err(error) = begin_version_ini_transaction(&confined_root) { + if let Err(restore_error) = restore_before_ownership_journal(&confined_root) { + return Err(DownloadOperationError::operation_failed(error.wrap_err( + format!("sentinel parking failed and rollback also failed: {restore_error}"), ))); } - return Err(error); + return Err(DownloadOperationError::operation_failed(error)); } if cancel_token.is_cancelled() { - restore_before_ownership_journal(&confined_root).await?; - eyre::bail!("download cancelled for game {game_id}"); + restore_before_ownership_journal(&confined_root) + .map_err(DownloadOperationError::operation_failed)?; + return Err(cancelled_download(&game_id)); } match ownership.journal_pending().await { Ok(OwnershipJournalPublication::Durable) => {} @@ -71,83 +226,92 @@ pub(crate) async fn download_game_files( // The pending record is visible, so restoring the old sentinel // would make recovery mistake it for a landed new commit. Stop // before payload mutation and leave the phase unambiguous. - return Err(eyre::eyre!( + return Err(DownloadOperationError::operation_failed(eyre::eyre!( "pending download ownership was renamed but its durability could not be established: {error}" - )); + ))); } Err(error) => { - if let Err(restore_error) = restore_before_ownership_journal(&confined_root).await { - return Err(error.wrap_err(format!( - "ownership journal failed and sentinel restore also failed: {restore_error}" + if let Err(restore_error) = restore_before_ownership_journal(&confined_root) { + return Err(DownloadOperationError::operation_failed(error.wrap_err( + format!( + "ownership journal failed and sentinel restore also failed: {restore_error}" + ), ))); } - return Err(error); + return Err(DownloadOperationError::operation_failed(error)); } } - if let Err(err) = prepare_game_storage(&manifest, &confined_root).await { - abort_download_best_effort(&ownership, &game_id).await; - if cancel_token.is_cancelled() { - eyre::bail!("download cancelled for game {game_id}"); - } - return Err(err); + if let Err(err) = prepare_game_storage(&manifest, &confined_root) { + let failure = DownloadOperationError::operation_failed(err); + return Err(abort_download(&ownership, &game_id, failure).await); } if cancel_token.is_cancelled() { - abort_download_best_effort(&ownership, &game_id).await; - eyre::bail!("download cancelled for game {game_id}"); - } - - if let Err(error) = tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { - id: game_id.clone(), - }) { - abort_download_best_effort(&ownership, &game_id).await; - return Err(error.into()); + let failure = cancelled_download(&game_id); + return Err(abort_download(&ownership, &game_id, failure).await); } let progress_tracker = DownloadProgressTracker::new(total_download_bytes(manifest.entries())); + let attempt_reporter = attempt.reporter(); let transfer_ctx = TransferContext { game_id: &game_id, game_root: &confined_root, - peers: &peers, - file_peer_map: &file_peer_map, + sources: &sources, + content_id, + quarantine, tx_notify_ui: &tx_notify_ui, cancel_token: &cancel_token, + quic: &quic, version_buffer: version_buffer.clone(), progress_tracker: progress_tracker.clone(), + attempt: attempt_reporter.clone(), }; + let plans = match build_initial_transfer_plans(&transfer_ctx, &manifest) { + Ok(plans) => plans, + Err(error) => { + attempt.close_source_admission(); + return Err(abort_download(&ownership, &game_id, error).await); + } + }; + attempt.emit_begin(); + attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks); let transfer_result = sample_download_progress( - &game_id, + attempt_reporter, progress_tracker, - tx_notify_ui.clone(), - download_transfer_chunks(&transfer_ctx, manifest.entries()), + download_transfer_chunks(&transfer_ctx, plans), ) .await; + attempt.close_source_admission(); + attempt.clear_activity(); if let Err(err) = transfer_result { - abort_download_best_effort(&ownership, &game_id).await; - return Err(err); + return Err(abort_download(&ownership, &game_id, err).await); } if cancel_token.is_cancelled() { - abort_download_best_effort(&ownership, &game_id).await; - eyre::bail!("download cancelled for game {game_id}"); + let failure = cancelled_download(&game_id); + return Err(abort_download(&ownership, &game_id, failure).await); } - if let Err(error) = sync_game_storage(&manifest, &confined_root).await { - abort_download_best_effort(&ownership, &game_id).await; - return Err(error.wrap_err("failed to make downloaded payload durable")); + if let Err(error) = sync_game_storage(&manifest, &confined_root) { + let failure = DownloadOperationError::operation_failed( + error.wrap_err("failed to make downloaded payload durable"), + ); + return Err(abort_download(&ownership, &game_id, failure).await); } if cancel_token.is_cancelled() { - abort_download_best_effort(&ownership, &game_id).await; - eyre::bail!("download cancelled for game {game_id}"); + let failure = cancelled_download(&game_id); + return Err(abort_download(&ownership, &game_id, failure).await); } - if let Err(error) = ownership.remove_stale().await { - abort_download_best_effort(&ownership, &game_id).await; - return Err(error.wrap_err("failed to remove stale download-owned files")); + if let Err(error) = ownership.remove_stale() { + let failure = DownloadOperationError::operation_failed( + error.wrap_err("failed to remove stale download-owned files"), + ); + return Err(abort_download(&ownership, &game_id, failure).await); } if cancel_token.is_cancelled() { - abort_download_best_effort(&ownership, &game_id).await; - eyre::bail!("download cancelled for game {game_id}"); + let failure = cancelled_download(&game_id); + return Err(abort_download(&ownership, &game_id, failure).await); } match commit_version_ini_buffer(&confined_root, &version_buffer).await { @@ -155,202 +319,322 @@ pub(crate) async fn download_game_files( Ok(VersionIniCommit::NeedsRecovery(error)) => { // The visible sentinel makes rollback unsafe. Keep pending ownership // so startup recovery can decide from the durable filesystem state. - return Err(eyre::eyre!( + return Ok(DownloadCompletion::RecoveryRequired(eyre::eyre!( "version.ini was renamed but its durability could not be established: {error}" - )); + ))); } Err(error) => { - abort_download_best_effort(&ownership, &game_id).await; - return Err(error); + let failure = DownloadOperationError::operation_failed(error); + return Err(abort_download(&ownership, &game_id, failure).await); } } - if let Err(error) = ownership.finalize().await { - // The sentinel rename is the commit point. Pending ownership lets the - // next recovery or download finish this idempotently. - log::error!("Downloaded {game_id}, but ownership finalization must be recovered: {error}"); + match ownership.finalize().await { + Ok(OwnershipJournalPublication::Durable) => {} + Ok(OwnershipJournalPublication::NeedsRecovery(error)) => { + return Ok(DownloadCompletion::RecoveryRequired(error.wrap_err( + "downloaded payload is visible, but ownership durability must be recovered", + ))); + } + Err(error) => { + return Ok(DownloadCompletion::RecoveryRequired(error.wrap_err( + "downloaded payload is visible, but ownership finalization must be recovered", + ))); + } } log::info!("all files downloaded for game: {game_id}"); - Ok(()) + Ok(DownloadCompletion::Durable) } -async fn restore_before_ownership_journal(game_root: &ConfinedGameRoot) -> eyre::Result<()> { - restore_unjournaled_version_ini_transaction(game_root).await +fn restore_before_ownership_journal(game_root: &ConfinedGameRoot) -> eyre::Result<()> { + restore_unjournaled_version_ini_transaction(game_root) } -async fn abort_download_best_effort(ownership: &DownloadOwnershipTransaction, game_id: &str) { - if let Err(err) = ownership.abort().await { - log::warn!("Failed to abort download-owned payload for {game_id}: {err}"); +fn cancelled_download(game_id: &str) -> DownloadOperationError { + DownloadOperationError::cancelled(eyre::eyre!("download cancelled for game {game_id}")) +} + +async fn abort_download( + ownership: &DownloadOwnershipTransaction, + game_id: &str, + failure: DownloadOperationError, +) -> DownloadOperationError { + match ownership.abort().await { + Ok(()) => failure, + Err(abort_error) => DownloadOperationError::operation_failed(eyre::eyre!( + "download failed for {game_id}: {failure}; ownership rollback also failed: {abort_error}" + )), } } struct TransferContext<'a> { game_id: &'a str, game_root: &'a ConfinedGameRoot, - peers: &'a [SocketAddr], - file_peer_map: &'a HashMap>, + sources: &'a [PeerEndpoint], + content_id: ContentId, + quarantine: &'a ContentQuarantine, tx_notify_ui: &'a UnboundedSender, cancel_token: &'a CancellationToken, + quic: &'a QuicConnector, version_buffer: Arc, progress_tracker: Arc, + attempt: DownloadAttemptReporter, +} + +struct InitialAttempt { + source: PeerEndpoint, + planned_chunks: Vec, + result: Result, DownloadTransferError>, +} + +fn eligible_content_sources( + sources: &[PeerEndpoint], + content_id: ContentId, + quarantine: &ContentQuarantine, +) -> eyre::Result> { + let mut seen_peer_ids = HashSet::::new(); + let mut peer_by_addr = HashMap::::new(); + let mut eligible = Vec::with_capacity(sources.len()); + + for source in sources { + if !seen_peer_ids.insert(source.peer_id) { + continue; + } + if let Some(previous_peer_id) = peer_by_addr.insert(source.addr, source.peer_id) { + eyre::bail!( + "content source address {} is ambiguously assigned to peers {previous_peer_id} and {}", + source.addr, + source.peer_id + ); + } + if !quarantine.is_quarantined(source, content_id) { + eligible.push(*source); + } + } + + Ok(eligible) } async fn download_transfer_chunks( ctx: &TransferContext<'_>, - transfer_descs: &[ValidatedDownloadEntry], -) -> eyre::Result<()> { - let plans = build_peer_plans(ctx.peers, transfer_descs, ctx.file_peer_map); - + plans: HashMap, +) -> Result<(), DownloadOperationError> { let tasks = FuturesUnordered::new(); - for (peer_addr, plan) in plans { + for (endpoint, plan) in plans { + let source = endpoint; + let planned_chunks = plan.chunks.clone(); let game_root = ctx.game_root.clone(); let game_id = ctx.game_id.to_string(); let cancel_token = ctx.cancel_token.clone(); let version_buffer = ctx.version_buffer.clone(); let progress_tracker = ctx.progress_tracker.clone(); + let quic = ctx.quic.clone(); tasks.push(async move { - download_from_peer( - peer_addr, - &game_id, + let result = download_from_peer(PeerDownloadRequest { + quic, + endpoint, + game_id, plan, game_root, - &cancel_token, - Some(version_buffer), + cancel_token, + version_buffer, progress_tracker, - ) - .await + }) + .await; + InitialAttempt { + source, + planned_chunks, + result, + } }); } - let mut failed_chunks: Vec = Vec::new(); - let mut last_err: Option = None; + let mut failed_chunks = Vec::new(); + let mut failures = TransferFailureAggregate::default(); - for result in collect_or_drain_on_cancel(tasks, ctx.cancel_token, ctx.game_id).await? { + let attempts = collect_or_drain_on_cancel(tasks, ctx.cancel_token, ctx.game_id) + .await + .map_err(DownloadOperationError::cancelled)?; + for attempt in attempts { if ctx.cancel_token.is_cancelled() { - eyre::bail!("download cancelled for game {}", ctx.game_id); - } - - match result { - Ok(results) => { - collect_chunk_results( - ctx.game_id, - ctx.tx_notify_ui, - results, - &mut failed_chunks, - &mut last_err, - ); - } - Err(_) if ctx.cancel_token.is_cancelled() => { - eyre::bail!("download cancelled for game {}", ctx.game_id); - } - Err(e) => last_err = Some(e), + return Err(failures.cancellation_error(ctx.game_id)); } + collect_initial_attempt(ctx, attempt, &mut failed_chunks, &mut failures) + .map_err(DownloadOperationError::operation_failed)?; } - if !failed_chunks.is_empty() && !ctx.peers.is_empty() { - retry_chunks(ctx, failed_chunks, &mut last_err).await?; + if !failed_chunks.is_empty() { + retry_chunks(ctx, failed_chunks, &mut failures).await?; } if ctx.cancel_token.is_cancelled() { - eyre::bail!("download cancelled for game {}", ctx.game_id); + return Err(failures.cancellation_error(ctx.game_id)); } - if let Some(err) = last_err { - return Err(err); + if let Some(error) = failures.into_operation_error() { + return Err(error); } Ok(()) } -fn collect_chunk_results( - game_id: &str, - tx_notify_ui: &UnboundedSender, - results: Vec, - failed_chunks: &mut Vec, - last_err: &mut Option, -) { - for chunk_result in results { - match chunk_result.result { - Ok(()) => { - let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished { - id: game_id.to_string(), - peer_addr: chunk_result.peer_addr, - relative_path: chunk_result.chunk.request_path, - offset: chunk_result.chunk.offset, - length: chunk_result.chunk.length, - }); +fn build_initial_transfer_plans( + ctx: &TransferContext<'_>, + manifest: &ValidatedDownloadManifest, +) -> Result, DownloadOperationError> { + let sources = eligible_content_sources(ctx.sources, ctx.content_id, ctx.quarantine) + .map_err(DownloadOperationError::operation_failed)?; + if sources.is_empty() { + return Err(DownloadOperationError::sources_exhausted(eyre::eyre!( + "no nonquarantined sources remain for game {}", + ctx.game_id + ))); + } + + // Local catalog identity defines the exact content and canonical path carried + // by every request. Planning only distributes those immutable chunks over + // authenticated, exact-content, quarantine-filtered endpoints. + let plans = + build_peer_plans(&sources, manifest).map_err(DownloadOperationError::operation_failed)?; + if plans.is_empty() { + return Err(DownloadOperationError::operation_failed(eyre::eyre!( + "catalog download plan contains no chunks for game {}", + ctx.game_id + ))); + } + Ok(plans) +} + +fn collect_initial_attempt( + ctx: &TransferContext<'_>, + attempt: InitialAttempt, + failed_chunks: &mut Vec, + failures: &mut TransferFailureAggregate, +) -> eyre::Result<()> { + let InitialAttempt { + source, + planned_chunks, + result, + } = attempt; + match result { + Ok(results) => { + let expected_endpoint = source; + for (planned_chunk, mut result) in reconcile_chunk_results( + planned_chunks, + results, + expected_endpoint, + |chunk| chunk, + "initial download", + )? { + result.chunk = planned_chunk; + collect_initial_chunk_result(ctx, &source, result, failed_chunks, failures); } - Err(e) => { - log::warn!( - "Failed to download chunk from {}: {e}", - chunk_result.peer_addr + } + Err(error) => { + for chunk in planned_chunks { + collect_initial_chunk_result( + ctx, + &source, + ChunkDownloadResult { + chunk, + result: Err(error.clone()), + peer_endpoint: source, + }, + failed_chunks, + failures, ); - if chunk_result.chunk.retry_count < MAX_RETRY_COUNT { - let mut retry_chunk = chunk_result.chunk; - retry_chunk.retry_count += 1; - retry_chunk.last_peer = Some(chunk_result.peer_addr); - failed_chunks.push(retry_chunk); - } else { - *last_err = Some(eyre::eyre!( - "Max retries exceeded for chunk: {}", - chunk_result.chunk.request_path - )); + } + } + } + Ok(()) +} + +fn collect_initial_chunk_result( + ctx: &TransferContext<'_>, + source: &PeerEndpoint, + result: ChunkDownloadResult, + failed_chunks: &mut Vec, + failures: &mut TransferFailureAggregate, +) { + match result.result { + Ok(()) => notify_chunk_finished(ctx, &result.chunk, result.peer_endpoint), + Err(error) => { + log::warn!("Failed to download chunk from {}: {error}", source.addr); + quarantine_if_integrity_failure(ctx.quarantine, source, ctx.content_id, &error); + match error.kind() { + DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => { + failed_chunks.push(RetryChunk::after_failure(result.chunk, *source, error)); + } + DownloadTransferErrorKind::LocalIo | DownloadTransferErrorKind::Cancelled => { + failures.record(error); } } } } } +fn notify_chunk_finished( + ctx: &TransferContext<'_>, + chunk: &DownloadChunk, + peer_endpoint: PeerEndpoint, +) { + let _ = ctx + .tx_notify_ui + .send(PeerEvent::DownloadGameFileChunkFinished { + id: ctx.game_id.to_string(), + peer_id: peer_endpoint.peer_id, + peer_addr: peer_endpoint.addr, + content_id: chunk.content_id, + relative_path: chunk.canonical_path().clone(), + offset: chunk.offset, + length: chunk.length, + }); +} + async fn retry_chunks( ctx: &TransferContext<'_>, - failed_chunks: Vec, - last_err: &mut Option, -) -> eyre::Result<()> { + failed_chunks: Vec, + failures: &mut TransferFailureAggregate, +) -> Result<(), DownloadOperationError> { if ctx.cancel_token.is_cancelled() { - eyre::bail!("download cancelled for game {}", ctx.game_id); + return Err(failures.cancellation_error(ctx.game_id)); } log::info!("Retrying {} failed chunks", failed_chunks.len()); let retry_ctx = RetryContext { - peers: ctx.peers, + sources: ctx.sources, + content_id: ctx.content_id, + quarantine: ctx.quarantine, game_root: ctx.game_root, game_id: ctx.game_id, - file_peer_map: ctx.file_peer_map, cancel_token: ctx.cancel_token, - version_buffer: Some(ctx.version_buffer.clone()), + quic: ctx.quic, + version_buffer: ctx.version_buffer.clone(), progress_tracker: ctx.progress_tracker.clone(), + attempt: ctx.attempt.clone(), }; let retry_results = match retry_failed_chunks(failed_chunks, &retry_ctx).await { Ok(results) => results, Err(_) if ctx.cancel_token.is_cancelled() => { - eyre::bail!("download cancelled for game {}", ctx.game_id); + return Err(failures.cancellation_error(ctx.game_id)); } Err(err) => { - *last_err = Some(err); - Vec::new() + return Err(DownloadOperationError::operation_failed(err)); } }; for chunk_result in retry_results { if ctx.cancel_token.is_cancelled() { - eyre::bail!("download cancelled for game {}", ctx.game_id); + return Err(failures.cancellation_error(ctx.game_id)); } match chunk_result.result { Ok(()) => { - let _ = ctx - .tx_notify_ui - .send(PeerEvent::DownloadGameFileChunkFinished { - id: ctx.game_id.to_string(), - peer_addr: chunk_result.peer_addr, - relative_path: chunk_result.chunk.request_path, - offset: chunk_result.chunk.offset, - length: chunk_result.chunk.length, - }); + notify_chunk_finished(ctx, &chunk_result.chunk, chunk_result.peer_endpoint); } Err(e) => { log::error!("Retry failed for chunk: {e}"); - *last_err = Some(e); + failures.record(e); } } } @@ -364,3 +648,71 @@ fn total_download_bytes(file_descs: &[ValidatedDownloadEntry]) -> u64 { .filter(|entry| !entry.is_dir()) .fold(0u64, |total, entry| total.saturating_add(entry.size())) } + +#[cfg(test)] +mod tests { + use super::*; + + fn source(peer_id: &str, port: u16) -> PeerEndpoint { + PeerEndpoint::new( + PeerId::from_bytes(*blake3::hash(peer_id.as_bytes()).as_bytes()), + SocketAddr::from(([127, 0, 0, 1], port)), + ) + } + + fn content(seed: u8) -> ContentId { + ContentId::from_bytes([seed; 32]) + } + + #[test] + fn initial_source_filter_skips_bad_source_and_keeps_good_source() { + let bad = source("bad", 12000); + let good = source("good", 12001); + let sources = vec![bad, good]; + let quarantine = ContentQuarantine::default(); + let content_id = content(1); + quarantine.record_integrity_failure(&bad, content_id); + + let eligible = eligible_content_sources(&sources, content_id, &quarantine) + .expect("unambiguous content sources should validate"); + + assert_eq!(eligible, vec![good]); + } + + #[test] + fn initial_source_filter_rejects_ambiguous_address_identity() { + let sources = vec![source("first", 12000), source("second", 12000)]; + + let error = eligible_content_sources(&sources, content(2), &ContentQuarantine::default()) + .expect_err("one address must not represent two authenticated identities"); + + assert!(error.to_string().contains("ambiguously assigned")); + } + + #[test] + fn local_failure_is_not_downgraded_by_later_retryable_exhaustion() { + let mut failures = TransferFailureAggregate::default(); + failures.record(DownloadTransferError::local_io("destination write failed")); + failures.record(DownloadTransferError::transport( + "retry source disconnected", + )); + + let error = failures + .into_operation_error() + .expect("recorded failures should produce an operation error"); + + assert_eq!(error.reason(), Some(DownloadFailureReason::OperationFailed)); + assert_eq!(error.to_string(), "destination write failed"); + } + + #[test] + fn local_failure_is_not_downgraded_by_later_cancellation() { + let mut failures = TransferFailureAggregate::default(); + failures.record(DownloadTransferError::local_io("destination write failed")); + + let error = failures.cancellation_error("game"); + + assert_eq!(error.reason(), Some(DownloadFailureReason::OperationFailed)); + assert_eq!(error.to_string(), "destination write failed"); + } +} diff --git a/crates/lanspread-peer/src/download/ownership.rs b/crates/lanspread-peer/src/download/ownership.rs index f04d2e1..44b3d07 100644 --- a/crates/lanspread-peer/src/download/ownership.rs +++ b/crates/lanspread-peer/src/download/ownership.rs @@ -1,14 +1,24 @@ //! Crash-consistent provenance for files created by peer downloads. use std::{ - collections::BTreeSet, - io::ErrorKind, + collections::{BTreeSet, HashMap, HashSet}, + io::{ErrorKind, Read as _, Write as _}, path::{Path, PathBuf}, }; +use cap_fs_ext::{ + FollowSymlinks, + OpenOptionsFollowExt, + OpenOptionsMaybeDirExt, + OpenOptionsSyncExt, +}; +use cap_primitives::{ + ambient_authority, + fs::{self as cap_fs, OpenOptions as CapOpenOptions}, +}; use eyre::WrapErr as _; +use lanspread_db::content_manifest::ContentId; use serde::{Deserialize, Serialize}; -use tokio::io::AsyncWriteExt; use super::{ confined_fs::ConfinedGameRoot, @@ -28,12 +38,49 @@ use super::{ }, }; use crate::{ - game_paths::{BACKUP_DIR, INSTALLING_DIR, LOCAL_DIR, VERSION_INI}, - state_paths::{download_ownership_path, download_ownership_tmp_path}, + game_paths::{BACKUP_DIR, INSTALLING_DIR, LOCAL_DIR, VERSION_INI, portable_name_key}, + scoped_blocking::scoped_blocking, + state_paths::{ + DOWNLOAD_OWNERSHIP_DIR, + DOWNLOAD_OWNERSHIP_RECORD_FILE, + DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE, + DOWNLOAD_OWNERSHIP_TMP_FILE, + LEGACY_DOWNLOAD_OWNERSHIP_FILE, + LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE, + LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE, + download_ownership_namespace_component, + download_ownership_namespace_dir, + download_ownership_path, + download_ownership_recovery_required_path, + download_ownership_tmp_path, + games_folder_key, + games_state_dir, + legacy_download_ownership_path, + legacy_download_ownership_recovery_required_path, + legacy_download_ownership_tmp_path, + }, }; -const OWNERSHIP_SCHEMA_VERSION: u32 = 1; +const OWNERSHIP_SCHEMA_VERSION: u32 = 2; const MAX_OWNERSHIP_RECORD_BYTES: u64 = 128 * 1024 * 1024; +const MAX_DOWNLOAD_OWNERSHIP_GAME_DIRS: usize = 100_000; +const MAX_DOWNLOAD_OWNERSHIP_GAME_STATE_ENTRIES: usize = 100_000; +const MAX_DOWNLOAD_OWNERSHIP_NAMESPACES: usize = 100_000; +const MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES: usize = 3; +const RECOVERY_MARKER_BYTES: &[u8] = b"recovery required\n"; + +/// Establishes a cancellation point before entering finite ownership I/O. +/// +/// Once the closure starts, it runs to completion in the calling task's +/// lexical scope. Aborting that task therefore cannot detach an in-progress +/// filesystem mutation or let the caller observe half of an atomic sequence. +async fn scoped_ownership_fs(work: F) -> R +where + F: FnOnce() -> R, +{ + tokio::task::yield_now().await; + scoped_blocking(work) +} #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -41,7 +88,9 @@ struct DownloadOwnershipRecord { schema_version: u32, game_id: String, games_folder_key: String, + committed_content_id: Option, committed_files: Vec, + pending_content_id: Option, pending_files: Option>, } @@ -51,7 +100,9 @@ impl DownloadOwnershipRecord { schema_version: OWNERSHIP_SCHEMA_VERSION, game_id: game_id.to_owned(), games_folder_key: games_folder_key.to_owned(), + committed_content_id: None, committed_files: Vec::new(), + pending_content_id: None, pending_files: None, } } @@ -77,12 +128,20 @@ impl DownloadOwnershipRecord { eyre::bail!("download ownership belongs to a different games directory"); } validate_file_set(&self.committed_files)?; + if self.committed_content_id.is_none() && !self.committed_files.is_empty() { + eyre::bail!("download ownership contains unverified committed files"); + } if let Some(pending) = &self.pending_files { validate_file_set(pending)?; + if self.pending_content_id.is_none() && !pending.is_empty() { + eyre::bail!("download ownership contains unverified pending files"); + } validate_generation_aliases( &self.committed_files.iter().cloned().collect(), &pending.iter().cloned().collect(), )?; + } else if self.pending_content_id.is_some() { + eyre::bail!("download ownership contains a pending content ID without pending files"); } Ok(self) } @@ -90,15 +149,762 @@ impl DownloadOwnershipRecord { enum LoadedOwnership { Missing, + Foreign, Invalid, Valid(DownloadOwnershipRecord), } +#[derive(Clone, Copy, Debug, Default)] +struct OwnershipNamespaceArtifacts { + marker_exists: bool, +} + +#[derive(Debug, Default)] +struct LegacyOwnershipState { + record: Option, + marker_exists: bool, + tmp_exists: bool, +} + +#[derive(Debug, Default)] +struct ScannedOwnershipNamespace { + record: Option, + tmp_exists: bool, +} + +/// Finds ownership state bound to one configured games directory. +/// +/// The scan validates the selected ownership namespace before returning any +/// IDs. It never mutates state and never follows a link or Windows reparse +/// point. Namespaces for other roots remain inert and uninspected. +pub(crate) fn scan_download_ownership_recovery_ids( + state_dir: &Path, + games_folder: &Path, +) -> eyre::Result> { + scoped_blocking(|| { + let games_folder = canonical_games_folder(games_folder)?; + open_ambient_directory_nofollow(&games_folder).wrap_err_with(|| { + format!( + "configured games path is not a safe directory: {}", + games_folder.display() + ) + })?; + scan_download_ownership_recovery_ids_blocking( + &games_state_dir(state_dir), + &games_folder_key(&games_folder), + ) + }) +} + +fn scan_download_ownership_recovery_ids_blocking( + state_games_dir: &Path, + expected_games_folder_key: &str, +) -> eyre::Result> { + let state_games = match open_ambient_directory_nofollow(state_games_dir) { + Ok(directory) => directory, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(HashSet::new()), + Err(error) => return Err(error.into()), + }; + + let mut recovery_ids = HashSet::new(); + let mut portable_ids = HashMap::new(); + let mut game_count = 0_usize; + let mut game_state_entry_count = 0_usize; + let mut namespace_count = 0_usize; + for entry in cap_fs::read_base_dir(&state_games)? { + game_count += 1; + if game_count > MAX_DOWNLOAD_OWNERSHIP_GAME_DIRS { + eyre::bail!( + "download ownership state contains more than {MAX_DOWNLOAD_OWNERSHIP_GAME_DIRS} game directories" + ); + } + + let entry = entry.wrap_err("failed to enumerate download ownership state")?; + let name = entry.file_name(); + let display_path = state_games_dir.join(&name); + let game_state = open_directory_at(&state_games, Path::new(&name)).wrap_err_with(|| { + format!( + "unsafe download ownership game-state directory {}", + display_path.display() + ) + })?; + + let legacy_present = legacy_ownership_artifacts_exist(&game_state)?; + let namespaces = + find_ownership_namespaces_dir(&game_state, &display_path, &mut game_state_entry_count)?; + if !legacy_present && namespaces.is_none() { + continue; + } + + let id = name.to_str().ok_or_else(|| { + eyre::eyre!( + "download ownership game-state directory is not valid UTF-8: {}", + display_path.display() + ) + })?; + validate_game_id(id).wrap_err_with(|| { + format!( + "invalid game ID for download ownership {}", + display_path.display() + ) + })?; + let portable_id = portable_name_key(id); + if let Some(previous) = portable_ids.insert(portable_id, id.to_owned()) { + eyre::bail!("download ownership IDs {previous:?} and {id:?} are portable aliases"); + } + + let legacy = read_legacy_ownership_state_at(&game_state, id)?; + if legacy.record.is_some() || legacy.tmp_exists { + recovery_ids.insert(id.to_owned()); + } + + let Some(namespaces) = namespaces else { + continue; + }; + let expected_namespace = download_ownership_namespace_component(expected_games_folder_key); + if let Some(namespace_dir) = find_selected_namespace( + &namespaces, + &display_path.join(DOWNLOAD_OWNERSHIP_DIR), + &expected_namespace, + &mut namespace_count, + )? { + let scanned = + scan_download_ownership_namespace(&namespace_dir, id, &expected_namespace)?; + if scanned.tmp_exists { + recovery_ids.insert(id.to_owned()); + } + let Some(record) = scanned.record else { + continue; + }; + if record.games_folder_key != expected_games_folder_key { + eyre::bail!( + "download ownership namespace for {id} contains a record bound to a different games directory" + ); + } + if let Some(legacy_record) = legacy.record + && legacy_record.games_folder_key == expected_games_folder_key + && legacy_record != record + { + eyre::bail!("legacy and namespaced download ownership records conflict for {id}"); + } + recovery_ids.insert(id.to_owned()); + } + } + + Ok(recovery_ids) +} + +fn find_ownership_namespaces_dir( + game_state: &std::fs::File, + display_path: &Path, + entry_count: &mut usize, +) -> eyre::Result> { + let expected_alias = portable_name_key(DOWNLOAD_OWNERSHIP_DIR); + let mut found = false; + for entry in cap_fs::read_base_dir(game_state)? { + *entry_count += 1; + if *entry_count > MAX_DOWNLOAD_OWNERSHIP_GAME_STATE_ENTRIES { + eyre::bail!( + "download ownership state contains more than {MAX_DOWNLOAD_OWNERSHIP_GAME_STATE_ENTRIES} per-game state entries" + ); + } + let entry = entry.wrap_err("failed to enumerate game ownership state")?; + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + for legacy_name in [ + LEGACY_DOWNLOAD_OWNERSHIP_FILE, + LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE, + LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE, + ] { + if name_str != legacy_name + && portable_name_key(name_str) == portable_name_key(legacy_name) + { + eyre::bail!( + "legacy download ownership entry {} is a portable alias of {legacy_name:?}", + display_path.join(&name).display() + ); + } + } + if portable_name_key(name_str) == expected_alias { + if name_str != DOWNLOAD_OWNERSHIP_DIR { + eyre::bail!( + "download ownership namespace directory {} is a portable alias of {DOWNLOAD_OWNERSHIP_DIR:?}", + display_path.join(&name).display() + ); + } + found = true; + } + } + if !found { + return Ok(None); + } + open_directory_at(game_state, Path::new(DOWNLOAD_OWNERSHIP_DIR)) + .map(Some) + .wrap_err_with(|| { + format!( + "unsafe download ownership namespace directory {}", + display_path.join(DOWNLOAD_OWNERSHIP_DIR).display() + ) + }) +} + +fn find_selected_namespace( + namespaces: &std::fs::File, + display_path: &Path, + expected_namespace: &str, + namespace_count: &mut usize, +) -> eyre::Result> { + let expected_alias = portable_name_key(expected_namespace); + let mut found = false; + for entry in cap_fs::read_base_dir(namespaces)? { + *namespace_count += 1; + if *namespace_count > MAX_DOWNLOAD_OWNERSHIP_NAMESPACES { + eyre::bail!( + "download ownership state contains more than {MAX_DOWNLOAD_OWNERSHIP_NAMESPACES} root namespaces" + ); + } + let entry = entry.wrap_err("failed to enumerate download ownership root namespaces")?; + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + if name_str == expected_namespace { + found = true; + } else if portable_name_key(name_str) == expected_alias { + eyre::bail!( + "download ownership namespace {} is a portable alias of {expected_namespace:?}", + display_path.join(&name).display() + ); + } + } + if !found { + return Ok(None); + } + open_directory_at(namespaces, Path::new(expected_namespace)) + .map(Some) + .wrap_err_with(|| { + format!( + "unsafe download ownership namespace {}", + display_path.join(expected_namespace).display() + ) + }) +} + +fn scan_download_ownership_namespace( + namespace_dir: &std::fs::File, + expected_game_id: &str, + namespace: &str, +) -> eyre::Result { + let mut record_file = None; + let mut marker_exists = false; + let mut tmp_exists = false; + let mut entry_count = 0_usize; + for entry in cap_fs::read_base_dir(namespace_dir)? { + entry_count += 1; + if entry_count > MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES { + eyre::bail!( + "download ownership namespace {namespace} contains more than {MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES} entries" + ); + } + + let entry = entry.wrap_err("failed to enumerate a download ownership namespace")?; + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + eyre::bail!("download ownership namespace {namespace} contains a non-UTF-8 entry"); + }; + let file = open_regular_file_at(namespace_dir, Path::new(&name)).wrap_err_with(|| { + format!("unsafe download ownership namespace entry {namespace}/{name_str}") + })?; + match name_str { + DOWNLOAD_OWNERSHIP_RECORD_FILE => record_file = Some(file), + DOWNLOAD_OWNERSHIP_TMP_FILE => { + validate_file_size( + &file, + MAX_OWNERSHIP_RECORD_BYTES, + "ownership temporary file", + )?; + tmp_exists = true; + } + DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE => { + let marker = read_bounded_file(file, u64::try_from(RECOVERY_MARKER_BYTES.len())?)?; + if marker != RECOVERY_MARKER_BYTES { + eyre::bail!("download ownership recovery marker has invalid contents"); + } + marker_exists = true; + } + _ => eyre::bail!( + "download ownership namespace {namespace} contains unexpected entry {name_str:?}" + ), + } + } + + let Some(record_file) = record_file else { + if marker_exists { + eyre::bail!( + "download ownership namespace {namespace} has a recovery marker but no ownership record" + ); + } + return Ok(ScannedOwnershipNamespace { + record: None, + tmp_exists, + }); + }; + let record = load_scanned_ownership_record(record_file, expected_game_id)?; + let expected_namespace = download_ownership_namespace_component(&record.games_folder_key); + if namespace != expected_namespace { + eyre::bail!( + "download ownership namespace {namespace} does not match its bound games directory" + ); + } + Ok(ScannedOwnershipNamespace { + record: Some(record), + tmp_exists, + }) +} + +fn load_scanned_ownership_record( + file: std::fs::File, + expected_game_id: &str, +) -> eyre::Result { + let bytes = read_bounded_file(file, MAX_OWNERSHIP_RECORD_BYTES)?; + let record: DownloadOwnershipRecord = serde_json::from_slice(&bytes)?; + let record_games_folder_key = record.games_folder_key.clone(); + let record = record.validate(expected_game_id, &record_games_folder_key)?; + validate_persisted_games_folder_key(&record_games_folder_key)?; + Ok(record) +} + +fn legacy_ownership_artifacts_exist(game_state: &std::fs::File) -> eyre::Result { + for name in [ + LEGACY_DOWNLOAD_OWNERSHIP_FILE, + LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE, + LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE, + ] { + match open_regular_file_at(game_state, Path::new(name)) { + Ok(_) => return Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(false) +} + +fn read_legacy_ownership_state_at( + game_state: &std::fs::File, + game_id: &str, +) -> eyre::Result { + let record_file = + match open_regular_file_at(game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_FILE)) { + Ok(file) => Some(file), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + let tmp_file = + match open_regular_file_at(game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE)) { + Ok(file) => Some(file), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + if let Some(file) = &tmp_file { + validate_file_size( + file, + MAX_OWNERSHIP_RECORD_BYTES, + "legacy ownership temporary file", + )?; + } + let tmp_exists = tmp_file.is_some(); + let marker_file = match open_regular_file_at( + game_state, + Path::new(LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE), + ) { + Ok(file) => Some(file), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + let marker_exists = marker_file.is_some(); + if let Some(file) = marker_file { + let marker = read_bounded_file(file, u64::try_from(RECOVERY_MARKER_BYTES.len())?)?; + if marker != RECOVERY_MARKER_BYTES { + eyre::bail!("legacy download ownership recovery marker has invalid contents"); + } + } + let Some(record_file) = record_file else { + if marker_exists { + eyre::bail!( + "legacy download ownership recovery marker exists without an ownership record" + ); + } + return Ok(LegacyOwnershipState { + record: None, + marker_exists: false, + tmp_exists, + }); + }; + Ok(LegacyOwnershipState { + record: Some(load_scanned_ownership_record(record_file, game_id)?), + marker_exists, + tmp_exists, + }) +} + +fn inspect_ownership_namespace(path: &Path) -> eyre::Result { + let namespace_name = path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| eyre::eyre!("download ownership namespace path has no UTF-8 name"))?; + let namespaces_path = path + .parent() + .ok_or_else(|| eyre::eyre!("download ownership namespace path has no parent"))?; + let game_state_path = namespaces_path + .parent() + .ok_or_else(|| eyre::eyre!("download ownership namespace path has no game state"))?; + let state_games_path = game_state_path + .parent() + .ok_or_else(|| eyre::eyre!("download ownership namespace path has no state root"))?; + let game_name = game_state_path + .file_name() + .ok_or_else(|| eyre::eyre!("download ownership game-state path has no name"))?; + let state_games = match open_ambient_directory_nofollow(state_games_path) { + Ok(directory) => directory, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(OwnershipNamespaceArtifacts::default()); + } + Err(error) => { + return Err(error).wrap_err_with(|| { + format!("unsafe download ownership namespace {}", path.display()) + }); + } + }; + let game_state = match open_directory_at(&state_games, Path::new(game_name)) { + Ok(directory) => directory, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(OwnershipNamespaceArtifacts::default()); + } + Err(error) => return Err(error.into()), + }; + let mut game_state_entry_count = 0; + let Some(namespaces) = + find_ownership_namespaces_dir(&game_state, game_state_path, &mut game_state_entry_count)? + else { + return Ok(OwnershipNamespaceArtifacts::default()); + }; + let mut namespace_count = 0; + let Some(namespace_dir) = find_selected_namespace( + &namespaces, + namespaces_path, + namespace_name, + &mut namespace_count, + )? + else { + return Ok(OwnershipNamespaceArtifacts::default()); + }; + + let mut artifacts = OwnershipNamespaceArtifacts::default(); + let mut entry_count = 0_usize; + for entry in cap_fs::read_base_dir(&namespace_dir)? { + entry_count += 1; + if entry_count > MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES { + eyre::bail!( + "download ownership namespace {} contains more than {MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES} entries", + path.display() + ); + } + let entry = entry.wrap_err("failed to enumerate download ownership namespace")?; + let name = entry.file_name(); + let name_str = name.to_str().ok_or_else(|| { + eyre::eyre!( + "download ownership namespace {} contains a non-UTF-8 entry", + path.display() + ) + })?; + let file = open_regular_file_at(&namespace_dir, Path::new(&name)).wrap_err_with(|| { + format!( + "unsafe download ownership namespace entry {}", + path.join(&name).display() + ) + })?; + match name_str { + DOWNLOAD_OWNERSHIP_RECORD_FILE => { + validate_file_size(&file, MAX_OWNERSHIP_RECORD_BYTES, "ownership record")?; + } + DOWNLOAD_OWNERSHIP_TMP_FILE => { + validate_file_size( + &file, + MAX_OWNERSHIP_RECORD_BYTES, + "ownership temporary file", + )?; + } + DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE => { + let marker = read_bounded_file(file, u64::try_from(RECOVERY_MARKER_BYTES.len())?)?; + if marker != RECOVERY_MARKER_BYTES { + eyre::bail!("download ownership recovery marker has invalid contents"); + } + artifacts.marker_exists = true; + } + _ => eyre::bail!( + "download ownership namespace {} contains unexpected entry {name_str:?}", + path.display() + ), + } + } + Ok(artifacts) +} + +fn load_legacy_ownership_state( + state_dir: &Path, + game_id: &str, +) -> eyre::Result { + let state_games = match open_ambient_directory_nofollow(&games_state_dir(state_dir)) { + Ok(directory) => directory, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(LegacyOwnershipState::default()); + } + Err(error) => return Err(error.into()), + }; + let game_state = match open_directory_at(&state_games, Path::new(game_id)) { + Ok(directory) => directory, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(LegacyOwnershipState::default()); + } + Err(error) => return Err(error.into()), + }; + let mut entry_count = 0; + let _ = find_ownership_namespaces_dir( + &game_state, + &games_state_dir(state_dir).join(game_id), + &mut entry_count, + )?; + read_legacy_ownership_state_at(&game_state, game_id).wrap_err_with(|| { + format!( + "invalid legacy download ownership state at {}, {}, or {}", + legacy_download_ownership_path(state_dir, game_id).display(), + legacy_download_ownership_tmp_path(state_dir, game_id).display(), + legacy_download_ownership_recovery_required_path(state_dir, game_id).display() + ) + }) +} + +fn migrate_current_legacy_ownership(state_dir: &Path, game_id: &str) -> eyre::Result<()> { + let legacy = load_legacy_ownership_state(state_dir, game_id)?; + let Some(record) = legacy.record else { + if legacy.tmp_exists { + remove_legacy_ownership_tmp(state_dir, game_id)?; + } + return Ok(()); + }; + let games_folder_key = &record.games_folder_key; + + let namespace_path = download_ownership_namespace_dir(state_dir, game_id, games_folder_key); + let record_path = download_ownership_path(state_dir, game_id, games_folder_key); + let tmp_path = download_ownership_tmp_path(state_dir, game_id, games_folder_key); + let marker_path = + download_ownership_recovery_required_path(state_dir, game_id, games_folder_key); + let artifacts = inspect_ownership_namespace(&namespace_path)?; + let needs_marker = legacy.marker_exists || record.pending_files.is_some(); + match load_record(&record_path, game_id, games_folder_key) { + LoadedOwnership::Missing if artifacts.marker_exists => { + eyre::bail!( + "cannot migrate legacy ownership for {game_id}: destination has a marker without a record" + ); + } + LoadedOwnership::Missing => { + sweep_tmp_file(&tmp_path); + require_durable_record( + write_record(&record_path, &tmp_path, &record)?, + "migrated download ownership", + )?; + } + LoadedOwnership::Valid(destination) if destination == record => {} + LoadedOwnership::Valid(_) => { + eyre::bail!( + "cannot migrate legacy ownership for {game_id}: destination record conflicts" + ); + } + LoadedOwnership::Foreign => { + eyre::bail!( + "cannot migrate legacy ownership for {game_id}: destination record belongs to another games directory" + ); + } + LoadedOwnership::Invalid => { + eyre::bail!( + "cannot migrate legacy ownership for {game_id}: destination record is invalid" + ); + } + } + + let destination = inspect_ownership_namespace(&namespace_path)?; + if needs_marker && !destination.marker_exists { + create_recovery_marker(&marker_path)?; + } + sweep_tmp_file(&tmp_path); + remove_legacy_ownership_after_migration(state_dir, game_id) +} + +fn remove_legacy_ownership_tmp(state_dir: &Path, game_id: &str) -> eyre::Result<()> { + let state_games = open_ambient_directory_nofollow(&games_state_dir(state_dir))?; + let game_state = open_directory_at(&state_games, Path::new(game_id))?; + if remove_file_at_if_exists(&game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE))? { + sync_directory_handle(&game_state)?; + } + Ok(()) +} + +fn remove_legacy_ownership_after_migration(state_dir: &Path, game_id: &str) -> eyre::Result<()> { + let state_games = open_ambient_directory_nofollow(&games_state_dir(state_dir))?; + let game_state = open_directory_at(&state_games, Path::new(game_id))?; + let removed_scratch = + remove_file_at_if_exists(&game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE))? + | remove_file_at_if_exists( + &game_state, + Path::new(LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE), + )?; + if removed_scratch { + sync_directory_handle(&game_state)?; + } + if remove_file_at_if_exists(&game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_FILE))? { + sync_directory_handle(&game_state)?; + } + Ok(()) +} + +fn remove_file_at_if_exists(parent: &std::fs::File, path: &Path) -> std::io::Result { + match cap_fs::remove_file(parent, path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +#[cfg(unix)] +fn sync_directory_handle(directory: &std::fs::File) -> std::io::Result<()> { + directory.sync_all() +} + +#[cfg(not(unix))] +const fn sync_directory_handle(_directory: &std::fs::File) -> std::io::Result<()> { + Ok(()) +} + +fn validate_file_size(file: &std::fs::File, limit: u64, label: &str) -> eyre::Result<()> { + let metadata = file.metadata()?; + if metadata.len() > limit { + eyre::bail!("{label} exceeds {limit} bytes"); + } + Ok(()) +} + +fn read_bounded_file(file: std::fs::File, limit: u64) -> eyre::Result> { + validate_file_size(&file, limit, "download ownership file")?; + let mut bytes = Vec::with_capacity(usize::try_from(file.metadata()?.len())?); + file.take(limit + 1).read_to_end(&mut bytes)?; + if u64::try_from(bytes.len())? > limit { + eyre::bail!("download ownership file exceeds {limit} bytes"); + } + Ok(bytes) +} + +#[cfg(unix)] +fn validate_persisted_games_folder_key(key: &str) -> eyre::Result<()> { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _}; + + let bytes = decode_lower_hex_key(key, "unix:")?; + if bytes.contains(&0) || !Path::new(OsStr::from_bytes(&bytes)).is_absolute() { + eyre::bail!("download ownership contains an invalid games-directory key"); + } + Ok(()) +} + +#[cfg(windows)] +fn validate_persisted_games_folder_key(key: &str) -> eyre::Result<()> { + use std::os::windows::ffi::OsStringExt as _; + + let encoded = key + .strip_prefix("windows:") + .ok_or_else(|| eyre::eyre!("download ownership contains an invalid games-directory key"))?; + if encoded.is_empty() || encoded.len() % 4 != 0 || !is_lower_hex(encoded.as_bytes()) { + eyre::bail!("download ownership contains an invalid games-directory key"); + } + let units = encoded + .as_bytes() + .chunks_exact(4) + .map(|chunk| { + let digits = std::str::from_utf8(chunk)?; + Ok(u16::from_str_radix(digits, 16)?) + }) + .collect::>>()?; + if units.contains(&0) || !PathBuf::from(std::ffi::OsString::from_wide(&units)).is_absolute() { + eyre::bail!("download ownership contains an invalid games-directory key"); + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn validate_persisted_games_folder_key(key: &str) -> eyre::Result<()> { + let _ = decode_lower_hex_key(key, "native:")?; + Ok(()) +} + +#[cfg(not(windows))] +fn decode_lower_hex_key(key: &str, prefix: &str) -> eyre::Result> { + let encoded = key + .strip_prefix(prefix) + .ok_or_else(|| eyre::eyre!("download ownership contains an invalid games-directory key"))?; + if encoded.is_empty() || encoded.len() % 2 != 0 || !is_lower_hex(encoded.as_bytes()) { + eyre::bail!("download ownership contains an invalid games-directory key"); + } + encoded + .as_bytes() + .chunks_exact(2) + .map(|chunk| { + let digits = std::str::from_utf8(chunk)?; + Ok(u8::from_str_radix(digits, 16)?) + }) + .collect() +} + +fn is_lower_hex(bytes: &[u8]) -> bool { + bytes + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DownloadOwnershipReadiness { + Untracked, + Settled, + RecoveryRequired, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DownloadOwnershipStatus { + readiness: DownloadOwnershipReadiness, + committed_content_id: Option, +} + +impl DownloadOwnershipStatus { + const fn without_content(readiness: DownloadOwnershipReadiness) -> Self { + Self { + readiness, + committed_content_id: None, + } + } + + const fn settled(committed_content_id: Option) -> Self { + Self { + readiness: DownloadOwnershipReadiness::Settled, + committed_content_id, + } + } +} + +#[derive(Debug)] pub(super) enum OwnershipJournalPublication { Durable, /// The new record is visible, but its directory entry may not survive a /// power loss. Callers must not treat this as a pre-publication failure. - NeedsRecovery(std::io::Error), + NeedsRecovery(eyre::Report), } /// One download attempt whose previous and proposed ownership sets are durable. @@ -106,13 +912,271 @@ pub(super) enum OwnershipJournalPublication { pub(super) struct DownloadOwnershipTransaction { record_path: PathBuf, tmp_path: PathBuf, + recovery_required_path: PathBuf, game_id: String, games_folder_key: String, game_root: ConfinedGameRoot, + previous_content_id: Option, previous: BTreeSet, + current_content_id: Option, current: BTreeSet, } +struct DownloadRemovalPreparation { + game_root: ConfinedGameRoot, + game_id: String, + games_folder_key: String, + record_path: PathBuf, + tmp_path: PathBuf, + recovery_required_path: PathBuf, +} + +/// Reports whether ownership metadata allows the current game root to be used. +/// +/// Only a completely absent root namespace is untracked. Any structurally +/// selected but unsettled, misplaced, legacy, or unreadable state fails closed +/// until recovery has repaired it. +pub(crate) async fn download_ownership_readiness( + games_folder: &Path, + state_dir: &Path, + game_id: &str, +) -> DownloadOwnershipReadiness { + download_ownership_status(games_folder, state_dir, game_id) + .await + .readiness +} + +/// Returns whether settled ownership proves the exact expected catalog content. +/// +/// Missing, legacy, corrupt, pending, recovery-marked, and differently bound +/// records all fail closed. Callers may use this for local-download shortcuts; +/// a version sentinel alone is not catalog-content proof. +pub(crate) async fn download_ownership_matches_content( + games_folder: &Path, + state_dir: &Path, + game_id: &str, + expected_content_id: ContentId, +) -> bool { + let status = download_ownership_status(games_folder, state_dir, game_id).await; + status.readiness == DownloadOwnershipReadiness::Settled + && status.committed_content_id == Some(expected_content_id) +} + +async fn download_ownership_status( + games_folder: &Path, + state_dir: &Path, + game_id: &str, +) -> DownloadOwnershipStatus { + if validate_game_id(game_id).is_err() { + return DownloadOwnershipStatus::without_content( + DownloadOwnershipReadiness::RecoveryRequired, + ); + } + + scoped_ownership_fs(|| { + let games_folder = match canonical_games_folder(games_folder) { + Ok(games_folder) => games_folder, + Err(error) => { + log::warn!("Cannot resolve games directory for ownership readiness: {error}"); + return DownloadOwnershipStatus::without_content( + DownloadOwnershipReadiness::RecoveryRequired, + ); + } + }; + + let games_folder_key = games_folder_key(&games_folder); + match load_legacy_ownership_state(state_dir, game_id) { + Ok(legacy) + if legacy + .record + .as_ref() + .is_some_and(|record| record.games_folder_key == games_folder_key) => + { + return DownloadOwnershipStatus::without_content( + DownloadOwnershipReadiness::RecoveryRequired, + ); + } + Ok(_) => {} + Err(error) => { + log::warn!("Cannot inspect legacy download ownership state: {error}"); + return DownloadOwnershipStatus::without_content( + DownloadOwnershipReadiness::RecoveryRequired, + ); + } + } + let namespace_path = + download_ownership_namespace_dir(state_dir, game_id, &games_folder_key); + let artifacts = match inspect_ownership_namespace(&namespace_path) { + Ok(artifacts) => artifacts, + Err(error) => { + log::warn!("Cannot inspect download ownership namespace: {error}"); + return DownloadOwnershipStatus::without_content( + DownloadOwnershipReadiness::RecoveryRequired, + ); + } + }; + let record_path = download_ownership_path(state_dir, game_id, &games_folder_key); + let record = load_record(&record_path, game_id, &games_folder_key); + match record { + LoadedOwnership::Missing if !artifacts.marker_exists => { + DownloadOwnershipStatus::without_content(DownloadOwnershipReadiness::Untracked) + } + LoadedOwnership::Missing | LoadedOwnership::Foreign | LoadedOwnership::Invalid => { + DownloadOwnershipStatus::without_content( + DownloadOwnershipReadiness::RecoveryRequired, + ) + } + LoadedOwnership::Valid(DownloadOwnershipRecord { + pending_files: Some(_), + .. + }) => DownloadOwnershipStatus::without_content( + DownloadOwnershipReadiness::RecoveryRequired, + ), + LoadedOwnership::Valid(_) if artifacts.marker_exists => { + DownloadOwnershipStatus::without_content( + DownloadOwnershipReadiness::RecoveryRequired, + ) + } + LoadedOwnership::Valid(record) => { + DownloadOwnershipStatus::settled(record.committed_content_id) + } + } + }) + .await +} + +impl DownloadRemovalPreparation { + fn new( + game_root: ConfinedGameRoot, + state_dir: &Path, + game_id: &str, + games_folder_key: String, + ) -> Self { + let record_path = download_ownership_path(state_dir, game_id, &games_folder_key); + let tmp_path = download_ownership_tmp_path(state_dir, game_id, &games_folder_key); + let recovery_required_path = + download_ownership_recovery_required_path(state_dir, game_id, &games_folder_key); + Self { + game_root, + game_id: game_id.to_owned(), + games_folder_key, + record_path, + tmp_path, + recovery_required_path, + } + } + + fn into_transaction_blocking(self) -> eyre::Result> { + let record = match load_record(&self.record_path, &self.game_id, &self.games_folder_key) { + LoadedOwnership::Valid(record) => record, + LoadedOwnership::Missing => { + eyre::bail!( + "cannot safely remove downloaded files for {}: the ownership record is missing; move or delete the legacy game folder manually", + self.game_id + ); + } + LoadedOwnership::Foreign => { + eyre::bail!( + "cannot safely remove downloaded files for {}: the ownership record belongs to a different games directory; move or delete the game folder manually", + self.game_id + ); + } + LoadedOwnership::Invalid => { + eyre::bail!( + "cannot safely remove downloaded files for {}: the ownership record is invalid; move or delete the game folder manually", + self.game_id + ); + } + }; + if record.pending_files.is_some() { + eyre::bail!( + "download ownership recovery did not settle for {}", + self.game_id + ); + } + if !self.game_root.root_regular_file_exists(VERSION_INI)? { + if !record.committed_files.is_empty() { + eyre::bail!("download sentinel is missing for {}", self.game_id); + } + if record.committed_content_id.is_none() { + return Ok(None); + } + // A catalog generation may own only version.ini. If that sentinel + // disappeared externally, removal still has to clear its + // exact-content binding instead of leaving a false local shortcut + // behind. + } + for (name, label) in [ + (LOCAL_DIR, "local install"), + (INSTALLING_DIR, "install staging"), + (BACKUP_DIR, "install backup"), + ] { + if self.game_root.root_entry_exists(name)? { + eyre::bail!( + "refusing to remove downloaded files for {} with {label}", + self.game_id + ); + } + } + + Ok(Some(DownloadOwnershipTransaction { + record_path: self.record_path, + tmp_path: self.tmp_path, + recovery_required_path: self.recovery_required_path, + game_id: self.game_id, + games_folder_key: self.games_folder_key, + game_root: self.game_root, + previous_content_id: record.committed_content_id, + previous: record.committed_files.into_iter().collect(), + current_content_id: None, + current: BTreeSet::new(), + })) + } +} + +fn clear_absent_download_ownership( + state_dir: &Path, + game_id: &str, + games_folder_key: &str, +) -> eyre::Result<()> { + let namespace_path = download_ownership_namespace_dir(state_dir, game_id, games_folder_key); + let artifacts = inspect_ownership_namespace(&namespace_path)?; + let record_path = download_ownership_path(state_dir, game_id, games_folder_key); + let tmp_path = download_ownership_tmp_path(state_dir, game_id, games_folder_key); + let recovery_required_path = + download_ownership_recovery_required_path(state_dir, game_id, games_folder_key); + match load_record(&record_path, game_id, games_folder_key) { + LoadedOwnership::Missing if !artifacts.marker_exists => { + sweep_tmp_file(&tmp_path); + Ok(()) + } + LoadedOwnership::Missing => { + eyre::bail!("download ownership namespace for {game_id} has no valid record") + } + LoadedOwnership::Foreign => eyre::bail!( + "download ownership namespace for {game_id} contains a record bound to a different games directory" + ), + LoadedOwnership::Invalid => { + eyre::bail!("download ownership namespace for {game_id} contains an invalid record") + } + LoadedOwnership::Valid(record) if record.pending_files.is_some() => { + eyre::bail!("download ownership recovery did not settle for absent game {game_id}") + } + LoadedOwnership::Valid(record) + if record.committed_files.is_empty() && record.committed_content_id.is_none() => + { + Ok(()) + } + LoadedOwnership::Valid(_) => { + let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key); + require_durable_record( + publish_settled_record(&record_path, &tmp_path, &recovery_required_path, &empty)?, + "absent downloaded-game ownership", + ) + } + } +} + impl DownloadOwnershipTransaction { /// Recovers an earlier attempt and loads the last trustworthy ownership set. pub(super) async fn prepare( @@ -120,50 +1184,93 @@ impl DownloadOwnershipTransaction { manifest: &ValidatedDownloadManifest, game_root: &ConfinedGameRoot, ) -> eyre::Result { + let current_content_id = manifest.catalog_manifest().content_id(); let games_folder_key = games_folder_key(manifest.games_folder()); recover_incomplete_download_with_root( - game_root, + Some(game_root), state_dir, manifest.game_id(), &games_folder_key, ) .await?; - let record_path = download_ownership_path(state_dir, manifest.game_id()); - let tmp_path = download_ownership_tmp_path(state_dir, manifest.game_id()); - let (previous, needs_baseline) = - match load_record(&record_path, manifest.game_id(), &games_folder_key).await { - LoadedOwnership::Valid(record) => { - (record.committed_files.into_iter().collect(), false) - } - LoadedOwnership::Missing | LoadedOwnership::Invalid => (BTreeSet::new(), true), - }; - let current = manifest.owned_file_paths().into_iter().collect(); - validate_generation_aliases(&previous, ¤t)?; - let untracked_targets = current - .difference(&previous) - .map(|path| ValidatedDownloadPath::from_ownership(path)) - .collect::>>()?; - game_root - .reject_existing_unowned_files(untracked_targets) - .await?; - if needs_baseline { - let baseline = DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key); - require_durable_record( - write_record(&record_path, &tmp_path, &baseline).await?, - "download ownership baseline", - )?; - } + scoped_ownership_fs(|| { + let record_path = + download_ownership_path(state_dir, manifest.game_id(), &games_folder_key); + let tmp_path = + download_ownership_tmp_path(state_dir, manifest.game_id(), &games_folder_key); + let recovery_required_path = download_ownership_recovery_required_path( + state_dir, + manifest.game_id(), + &games_folder_key, + ); + let namespace_path = + download_ownership_namespace_dir(state_dir, manifest.game_id(), &games_folder_key); + let artifacts = inspect_ownership_namespace(&namespace_path)?; + let (previous_content_id, previous, needs_baseline) = + match load_record(&record_path, manifest.game_id(), &games_folder_key) { + LoadedOwnership::Valid(record) => ( + record.committed_content_id, + record.committed_files.into_iter().collect(), + false, + ), + LoadedOwnership::Missing if !artifacts.marker_exists => { + (None, BTreeSet::new(), true) + } + LoadedOwnership::Missing => { + eyre::bail!( + "download ownership namespace for {} exists without a valid record", + manifest.game_id() + ); + } + LoadedOwnership::Foreign => { + eyre::bail!( + "download ownership namespace for {} contains a record bound to a different games directory", + manifest.game_id() + ); + } + LoadedOwnership::Invalid => { + eyre::bail!( + "download ownership namespace for {} contains an invalid record", + manifest.game_id() + ); + } + }; + let current = manifest.owned_file_paths().into_iter().collect(); + validate_generation_aliases(&previous, ¤t)?; + let untracked_targets = current + .difference(&previous) + .map(|path| ValidatedDownloadPath::from_ownership(path)) + .collect::>>()?; + game_root.reject_existing_unowned_files(untracked_targets)?; + if needs_baseline { + let baseline = + DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key); + require_durable_record( + publish_settled_record( + &record_path, + &tmp_path, + &recovery_required_path, + &baseline, + )?, + "download ownership baseline", + )?; + } - Ok(Self { - record_path, - tmp_path, - game_id: manifest.game_id().to_owned(), - games_folder_key, - game_root: game_root.clone(), - previous, - current, + Ok(Self { + record_path, + tmp_path, + recovery_required_path, + game_id: manifest.game_id().to_owned(), + games_folder_key, + game_root: game_root.clone(), + previous_content_id, + previous, + current_content_id: Some(current_content_id), + current, + }) }) + .await } async fn prepare_removal( @@ -172,118 +1279,125 @@ impl DownloadOwnershipTransaction { game_id: &str, ) -> eyre::Result> { validate_game_id(game_id)?; - let games_folder_path = games_folder.to_path_buf(); - let games_folder = - tokio::task::spawn_blocking(move || canonical_games_folder(&games_folder_path)) - .await??; - let games_folder_key = games_folder_key(&games_folder); - let record_path = download_ownership_path(state_dir, game_id); - let tmp_path = download_ownership_tmp_path(state_dir, game_id); - let Some(game_root) = ConfinedGameRoot::open_existing(&games_folder, game_id).await? else { - let empty = DownloadOwnershipRecord::empty(game_id, &games_folder_key); - require_durable_record( - write_record(&record_path, &tmp_path, &empty).await?, - "absent downloaded-game ownership", - )?; + let (game_root, games_folder_key) = scoped_ownership_fs(|| { + let games_folder = canonical_games_folder(games_folder)?; + let game_root = ConfinedGameRoot::open_existing(&games_folder, game_id)?; + Ok::<_, eyre::Report>((game_root, games_folder_key(&games_folder))) + }) + .await?; + + recover_incomplete_download_with_root( + game_root.as_ref(), + state_dir, + game_id, + &games_folder_key, + ) + .await?; + let Some(game_root) = game_root else { + scoped_ownership_fs(|| { + clear_absent_download_ownership(state_dir, game_id, &games_folder_key) + }) + .await?; return Ok(None); }; - - recover_incomplete_download_with_root(&game_root, state_dir, game_id, &games_folder_key) - .await?; - let record = match load_record(&record_path, game_id, &games_folder_key).await { - LoadedOwnership::Valid(record) => record, - LoadedOwnership::Missing => { - eyre::bail!( - "cannot safely remove downloaded files for {game_id}: the ownership record is missing; move or delete the legacy game folder manually" - ); - } - LoadedOwnership::Invalid => { - eyre::bail!( - "cannot safely remove downloaded files for {game_id}: the ownership record is invalid; move or delete the game folder manually" - ); - } - }; - if record.pending_files.is_some() { - eyre::bail!("download ownership recovery did not settle for {game_id}"); - } - if !game_root.root_regular_file_exists(VERSION_INI).await? { - if record.committed_files.is_empty() { - return Ok(None); - } - eyre::bail!("download sentinel is missing for {game_id}"); - } - for (name, label) in [ - (LOCAL_DIR, "local install"), - (INSTALLING_DIR, "install staging"), - (BACKUP_DIR, "install backup"), - ] { - if game_root.root_entry_exists(name).await? { - eyre::bail!("refusing to remove downloaded files for {game_id} with {label}"); - } - } - - Ok(Some(Self { - record_path, - tmp_path, - game_id: game_id.to_owned(), - games_folder_key, - game_root, - previous: record.committed_files.into_iter().collect(), - current: BTreeSet::new(), - })) + let preparation = + DownloadRemovalPreparation::new(game_root, state_dir, game_id, games_folder_key); + scoped_ownership_fs(|| preparation.into_transaction_blocking()).await } /// Publishes the proposed set after the old sentinel has been parked. pub(super) async fn journal_pending(&self) -> eyre::Result { - let record = DownloadOwnershipRecord { - schema_version: OWNERSHIP_SCHEMA_VERSION, - game_id: self.game_id.clone(), - games_folder_key: self.games_folder_key.clone(), - committed_files: self.previous.iter().cloned().collect(), - pending_files: Some(self.current.iter().cloned().collect()), - }; - write_record(&self.record_path, &self.tmp_path, &record).await + scoped_ownership_fs(|| { + let record = DownloadOwnershipRecord { + schema_version: OWNERSHIP_SCHEMA_VERSION, + game_id: self.game_id.clone(), + games_folder_key: self.games_folder_key.clone(), + committed_content_id: self.previous_content_id, + committed_files: self.previous.iter().cloned().collect(), + pending_content_id: self.current_content_id, + pending_files: Some(self.current.iter().cloned().collect()), + }; + match write_record(&self.record_path, &self.tmp_path, &record)? { + OwnershipJournalPublication::NeedsRecovery(error) => { + Ok(OwnershipJournalPublication::NeedsRecovery(error)) + } + OwnershipJournalPublication::Durable => { + match create_recovery_marker(&self.recovery_required_path) { + Ok(()) => Ok(OwnershipJournalPublication::Durable), + Err(error) => Ok(OwnershipJournalPublication::NeedsRecovery( + error.wrap_err( + "pending ownership is durable, but its recovery quarantine is uncertain", + ), + )), + } + } + } + }) + .await } /// Removes only previously owned regular files absent from this manifest. - pub(super) async fn remove_stale(&self) -> eyre::Result<()> { + pub(super) fn remove_stale(&self) -> eyre::Result<()> { let stale = self .previous .difference(&self.current) .cloned() .collect::>(); - remove_owned_files(&self.game_root, &stale).await + remove_owned_files(&self.game_root, &stale) } /// Aborts a journaled attempt without touching paths outside either owned set. pub(super) async fn abort(&self) -> eyre::Result<()> { - let removable = self - .previous - .union(&self.current) - .cloned() - .collect::>(); - remove_owned_files(&self.game_root, &removable).await?; - discard_version_ini_transaction(&self.game_root).await?; - let empty = DownloadOwnershipRecord::empty(&self.game_id, &self.games_folder_key); - require_durable_record( - write_record(&self.record_path, &self.tmp_path, &empty).await?, - "aborted download ownership", - ) + scoped_ownership_fs(|| { + let removable = self + .previous + .union(&self.current) + .cloned() + .collect::>(); + remove_owned_files(&self.game_root, &removable)?; + discard_version_ini_transaction(&self.game_root)?; + let empty = DownloadOwnershipRecord::empty(&self.game_id, &self.games_folder_key); + require_durable_record( + publish_settled_record( + &self.record_path, + &self.tmp_path, + &self.recovery_required_path, + &empty, + )?, + "aborted download ownership", + ) + }) + .await } /// Finalizes ownership after the new `version.ini` commit point has landed. - pub(super) async fn finalize(&self) -> eyre::Result<()> { - let record = DownloadOwnershipRecord { - schema_version: OWNERSHIP_SCHEMA_VERSION, - game_id: self.game_id.clone(), - games_folder_key: self.games_folder_key.clone(), - committed_files: self.current.iter().cloned().collect(), - pending_files: None, - }; - require_durable_record( - write_record(&self.record_path, &self.tmp_path, &record).await?, - "finalized download ownership", - ) + pub(super) async fn finalize(&self) -> eyre::Result { + self.finalize_with_parent_sync(sync_parent_dir).await + } + + async fn finalize_with_parent_sync( + &self, + sync_record_parent: impl FnOnce(&Path) -> std::io::Result<()>, + ) -> eyre::Result { + scoped_ownership_fs(|| { + let record = DownloadOwnershipRecord { + schema_version: OWNERSHIP_SCHEMA_VERSION, + game_id: self.game_id.clone(), + games_folder_key: self.games_folder_key.clone(), + committed_content_id: self.current_content_id, + committed_files: self.current.iter().cloned().collect(), + pending_content_id: None, + pending_files: None, + }; + publish_settled_record_with_parent_sync( + &self.record_path, + &self.tmp_path, + &self.recovery_required_path, + &record, + sync_record_parent, + ) + }) + .await } } @@ -303,11 +1417,9 @@ pub(crate) async fn remove_downloaded_payload( return Ok(()); }; - if let Err(error) = - super::version_ini::begin_version_ini_transaction(&transaction.game_root).await - { + if let Err(error) = super::version_ini::begin_version_ini_transaction(&transaction.game_root) { if let Err(restore_error) = - restore_unjournaled_version_ini_transaction(&transaction.game_root).await + restore_unjournaled_version_ini_transaction(&transaction.game_root) { return Err(error.wrap_err(format!( "sentinel parking failed and rollback also failed: {restore_error}" @@ -324,7 +1436,7 @@ pub(crate) async fn remove_downloaded_payload( } Err(error) => { if let Err(restore_error) = - restore_unjournaled_version_ini_transaction(&transaction.game_root).await + restore_unjournaled_version_ini_transaction(&transaction.game_root) { return Err(error.wrap_err(format!( "removal ownership journal failed and sentinel restore also failed: {restore_error}" @@ -336,9 +1448,12 @@ pub(crate) async fn remove_downloaded_payload( // From the durable empty pending generation onward, recovery must roll the // removal forward. Never restore the sentinel on a later failure. - transaction.remove_stale().await?; - discard_version_ini_transaction(&transaction.game_root).await?; - transaction.finalize().await + transaction.remove_stale()?; + discard_version_ini_transaction(&transaction.game_root)?; + require_durable_record( + transaction.finalize().await?, + "finalized download removal ownership", + ) } /// Recovers the ownership/version transaction for one inactive game root. @@ -353,87 +1468,150 @@ pub(crate) async fn recover_incomplete_download( game_root.display() ); }; - let games_folder = match tokio::fs::canonicalize(games_folder).await { - Ok(path) => path, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(error.into()), - }; if game_root.file_name() != Some(std::ffi::OsStr::new(game_id)) { eyre::bail!( "game root is not the requested direct catalog child: {}", game_root.display() ); } - let Some(game_root) = ConfinedGameRoot::open_existing(&games_folder, game_id).await? else { - return Ok(()); - }; - let key = games_folder_key(&games_folder); - recover_incomplete_download_with_root(&game_root, state_dir, game_id, &key).await + let (game_root, key) = scoped_ownership_fs(|| { + let games_folder = canonical_games_folder(games_folder)?; + let game_root = ConfinedGameRoot::open_existing(&games_folder, game_id)?; + let key = games_folder_key(&games_folder); + Ok::<_, eyre::Report>((game_root, key)) + }) + .await?; + recover_incomplete_download_with_root(game_root.as_ref(), state_dir, game_id, &key).await } async fn recover_incomplete_download_with_root( - game_root: &ConfinedGameRoot, + game_root: Option<&ConfinedGameRoot>, state_dir: &Path, game_id: &str, games_folder_key: &str, ) -> eyre::Result<()> { - let path = download_ownership_path(state_dir, game_id); - let tmp_path = download_ownership_tmp_path(state_dir, game_id); + scoped_ownership_fs(|| { + migrate_current_legacy_ownership(state_dir, game_id)?; + let path = download_ownership_path(state_dir, game_id, games_folder_key); + let tmp_path = download_ownership_tmp_path(state_dir, game_id, games_folder_key); + let recovery_required_path = + download_ownership_recovery_required_path(state_dir, game_id, games_folder_key); + let namespace_path = + download_ownership_namespace_dir(state_dir, game_id, games_folder_key); + let artifacts = inspect_ownership_namespace(&namespace_path)?; - match load_record(&path, game_id, games_folder_key).await { - LoadedOwnership::Missing => { - // Pre-journal versions used the same scratch name after payload - // mutation. Without a new-format baseline, restoring it could - // advertise partially overwritten bytes as a complete download. - discard_version_ini_transaction(game_root).await?; - } - LoadedOwnership::Invalid => { - // With no trustworthy journal, never make potentially partial bytes ready. - discard_version_ini_transaction(game_root).await?; - } - LoadedOwnership::Valid(mut record) => { - let Some(pending) = record.pending_files.clone() else { - restore_unjournaled_version_ini_transaction(game_root).await?; - sweep_tmp_file(&tmp_path).await; - return Ok(()); - }; - - let committed = record - .committed_files - .iter() - .cloned() - .collect::>(); - let pending = pending.into_iter().collect::>(); - if game_root - .root_regular_file_exists(crate::game_paths::VERSION_INI) - .await? - { - let stale = committed - .difference(&pending) - .cloned() - .collect::>(); - remove_owned_files(game_root, &stale).await?; - finish_recovered_version_ini_transaction(game_root).await?; - record.committed_files = pending.into_iter().collect(); - record.pending_files = None; - require_durable_record( - write_record(&path, &tmp_path, &record).await?, - "recovered committed download ownership", - )?; - } else { - let removable = committed.union(&pending).cloned().collect::>(); - remove_owned_files(game_root, &removable).await?; - discard_version_ini_transaction(game_root).await?; - let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key); - require_durable_record( - write_record(&path, &tmp_path, &empty).await?, - "recovered aborted download ownership", - )?; + match load_record(&path, game_id, games_folder_key) { + LoadedOwnership::Missing if !artifacts.marker_exists => { + // Pre-journal versions used the same scratch name after payload + // mutation. Without a new-format baseline, restoring it could + // advertise partially overwritten bytes as a complete download. + if let Some(game_root) = game_root { + discard_version_ini_transaction(game_root)?; + } } + LoadedOwnership::Missing => eyre::bail!( + "download ownership namespace for {game_id} exists without a valid record" + ), + LoadedOwnership::Foreign => { + eyre::bail!( + "download ownership namespace for {game_id} contains a record bound to a different games directory" + ); + } + LoadedOwnership::Invalid => { + eyre::bail!( + "download ownership namespace for {game_id} contains an invalid record" + ); + } + LoadedOwnership::Valid(record) => recover_valid_ownership( + game_root, + game_id, + games_folder_key, + &path, + &tmp_path, + &recovery_required_path, + artifacts.marker_exists, + record, + )?, } + sweep_tmp_file(&tmp_path); + Ok(()) + }) + .await +} + +#[allow(clippy::too_many_arguments)] +fn recover_valid_ownership( + game_root: Option<&ConfinedGameRoot>, + game_id: &str, + games_folder_key: &str, + path: &Path, + tmp_path: &Path, + recovery_required_path: &Path, + recovery_required: bool, + mut record: DownloadOwnershipRecord, +) -> eyre::Result<()> { + let Some(pending) = record.pending_files.clone() else { + if recovery_required { + // Finalization may have made the settled record visible before + // losing certainty about its rename. Re-publish the same record + // durably before clearing the quarantine. + if let Some(game_root) = game_root { + discard_version_ini_transaction(game_root)?; + } + require_durable_record( + publish_settled_record(path, tmp_path, recovery_required_path, &record)?, + "recovered settled download ownership", + )?; + } else if let Some(game_root) = game_root { + restore_unjournaled_version_ini_transaction(game_root)?; + } + return Ok(()); + }; + + let committed = record + .committed_files + .iter() + .cloned() + .collect::>(); + let pending = pending.into_iter().collect::>(); + let Some(game_root) = game_root else { + let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key); + return require_durable_record( + publish_settled_record(path, tmp_path, recovery_required_path, &empty)?, + "recovered absent-root download ownership", + ); + }; + + if game_root.root_regular_file_exists(crate::game_paths::VERSION_INI)? { + let pending_content_id = record.pending_content_id.ok_or_else(|| { + eyre::eyre!( + "download removal intent for {game_id} unexpectedly has a committed sentinel" + ) + })?; + let stale = committed + .difference(&pending) + .cloned() + .collect::>(); + remove_owned_files(game_root, &stale)?; + finish_recovered_version_ini_transaction(game_root)?; + record.committed_content_id = Some(pending_content_id); + record.committed_files = pending.into_iter().collect(); + record.pending_content_id = None; + record.pending_files = None; + require_durable_record( + publish_settled_record(path, tmp_path, recovery_required_path, &record)?, + "recovered committed download ownership", + ) + } else { + let removable = committed.union(&pending).cloned().collect::>(); + remove_owned_files(game_root, &removable)?; + discard_version_ini_transaction(game_root)?; + let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key); + require_durable_record( + publish_settled_record(path, tmp_path, recovery_required_path, &empty)?, + "recovered aborted download ownership", + ) } - sweep_tmp_file(&tmp_path).await; - Ok(()) } fn validate_file_set(paths: &[String]) -> eyre::Result<()> { @@ -480,13 +1658,95 @@ fn validate_generation_aliases( Ok(()) } -async fn load_record( +fn open_ambient_directory_nofollow(path: &Path) -> std::io::Result { + let directory = cap_fs::open_ambient(path, &directory_options(), ambient_authority())?; + validate_directory_handle(&directory, path)?; + Ok(directory) +} + +fn open_directory_at(parent: &std::fs::File, path: &Path) -> std::io::Result { + let directory = cap_fs::open(parent, path, &directory_options())?; + validate_directory_handle(&directory, path)?; + Ok(directory) +} + +fn open_regular_file_at(parent: &std::fs::File, path: &Path) -> std::io::Result { + let file = cap_fs::open(parent, path, ®ular_file_options())?; + validate_regular_file_handle(&file, path)?; + Ok(file) +} + +fn open_ambient_regular_file_nofollow(path: &Path) -> std::io::Result { + let file = cap_fs::open_ambient(path, ®ular_file_options(), ambient_authority())?; + validate_regular_file_handle(&file, path)?; + Ok(file) +} + +fn directory_options() -> CapOpenOptions { + let mut options = CapOpenOptions::new(); + options.read(true); + options + .maybe_dir(true) + .follow(FollowSymlinks::No) + .nonblock(true); + options +} + +fn regular_file_options() -> CapOpenOptions { + let mut options = CapOpenOptions::new(); + options.read(true); + options.follow(FollowSymlinks::No).nonblock(true); + options +} + +fn validate_directory_handle(file: &std::fs::File, display: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_dir() || is_windows_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "download ownership state is not a non-reparse directory: {}", + display.display() + ), + )); + } + Ok(()) +} + +fn validate_regular_file_handle(file: &std::fs::File, display: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_file() || is_windows_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "download ownership state is not a regular non-reparse file: {}", + display.display() + ), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn is_windows_reparse(metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +const fn is_windows_reparse(_metadata: &std::fs::Metadata) -> bool { + false +} + +fn load_record( path: &Path, expected_game_id: &str, expected_games_folder_key: &str, ) -> LoadedOwnership { - let metadata = match tokio::fs::metadata(path).await { - Ok(metadata) => metadata, + let file = match open_ambient_regular_file_nofollow(path) { + Ok(file) => file, Err(error) if error.kind() == ErrorKind::NotFound => return LoadedOwnership::Missing, Err(error) => { log::warn!( @@ -496,15 +1756,7 @@ async fn load_record( return LoadedOwnership::Invalid; } }; - if !metadata.is_file() || metadata.len() > MAX_OWNERSHIP_RECORD_BYTES { - log::warn!( - "Ignoring invalid download ownership file {}", - path.display() - ); - return LoadedOwnership::Invalid; - } - - let bytes = match tokio::fs::read(path).await { + let bytes = match read_bounded_file(file, MAX_OWNERSHIP_RECORD_BYTES) { Ok(bytes) => bytes, Err(error) => { log::warn!( @@ -514,11 +1766,23 @@ async fn load_record( return LoadedOwnership::Invalid; } }; - match serde_json::from_slice::(&bytes) - .map_err(eyre::Report::from) - .and_then(|record| record.validate(expected_game_id, expected_games_folder_key)) - { - Ok(record) => LoadedOwnership::Valid(record), + match serde_json::from_slice::(&bytes).map_err(eyre::Report::from) { + Ok(record) => { + let record_games_folder_key = record.games_folder_key.clone(); + match record.validate(expected_game_id, &record_games_folder_key) { + Ok(_) if record_games_folder_key != expected_games_folder_key => { + LoadedOwnership::Foreign + } + Ok(record) => LoadedOwnership::Valid(record), + Err(error) => { + log::warn!( + "Ignoring invalid download ownership {}: {error}", + path.display() + ); + LoadedOwnership::Invalid + } + } + } Err(error) => { log::warn!( "Ignoring invalid download ownership {}: {error}", @@ -529,15 +1793,124 @@ async fn load_record( } } -async fn write_record( +fn create_recovery_marker(path: &Path) -> eyre::Result<()> { + create_recovery_marker_with_parent_sync(path, sync_parent_dir) +} + +fn create_recovery_marker_with_parent_sync( + path: &Path, + sync_parent: impl FnOnce(&Path) -> std::io::Result<()>, +) -> eyre::Result<()> { + let parent = path + .parent() + .ok_or_else(|| eyre::eyre!("download ownership recovery marker has no parent"))?; + create_state_parent_durably(parent)?; + + let mut options = CapOpenOptions::new(); + options.read(true).write(true).create(true); + options.follow(FollowSymlinks::No).nonblock(true); + let marker = cap_fs::open_ambient(path, &options, ambient_authority()).wrap_err_with(|| { + format!( + "failed to open download ownership recovery marker without following links at {}", + path.display() + ) + })?; + validate_recovery_marker_handle(&marker, path)?; + + let mut marker = marker; + marker.set_len(0)?; + marker.write_all(b"recovery required\n")?; + marker.sync_all()?; + drop(marker); + sync_parent(path).wrap_err_with(|| { + format!( + "failed to make download ownership recovery marker durable at {}", + path.display() + ) + }) +} + +fn validate_recovery_marker_handle(marker: &std::fs::File, path: &Path) -> std::io::Result<()> { + validate_regular_file_handle(marker, path) +} + +fn clear_recovery_marker(path: &Path) -> eyre::Result<()> { + clear_recovery_marker_with_parent_sync(path, sync_parent_dir) +} + +fn clear_recovery_marker_with_parent_sync( + path: &Path, + sync_parent: impl FnOnce(&Path) -> std::io::Result<()>, +) -> eyre::Result<()> { + remove_file_if_exists(path).wrap_err_with(|| { + format!( + "failed to clear download ownership recovery marker {}", + path.display() + ) + })?; + + // The settled record was already made durable before the marker was + // removed. A directory-sync failure here can only resurrect the marker + // after a crash, which is a conservative false positive. The marker is + // visibly absent now, so returning recovery-required would itself permit a + // contradictory readiness observation. + if let Err(error) = sync_parent(path) { + log::warn!( + "Cleared download ownership recovery marker {}, but its removal may not survive a power loss: {error}", + path.display() + ); + } + Ok(()) +} + +fn publish_settled_record( + path: &Path, + tmp_path: &Path, + recovery_required_path: &Path, + record: &DownloadOwnershipRecord, +) -> eyre::Result { + publish_settled_record_with_parent_sync( + path, + tmp_path, + recovery_required_path, + record, + sync_parent_dir, + ) +} + +fn publish_settled_record_with_parent_sync( + path: &Path, + tmp_path: &Path, + recovery_required_path: &Path, + record: &DownloadOwnershipRecord, + sync_record_parent: impl FnOnce(&Path) -> std::io::Result<()>, +) -> eyre::Result { + debug_assert!(record.pending_files.is_none()); + debug_assert!(record.pending_content_id.is_none()); + create_recovery_marker(recovery_required_path)?; + + match write_record_with_parent_sync(path, tmp_path, record, sync_record_parent)? { + OwnershipJournalPublication::NeedsRecovery(error) => { + Ok(OwnershipJournalPublication::NeedsRecovery(error)) + } + OwnershipJournalPublication::Durable => { + match clear_recovery_marker(recovery_required_path) { + Ok(()) => Ok(OwnershipJournalPublication::Durable), + Err(error) => Ok(OwnershipJournalPublication::NeedsRecovery(error)), + } + } + } +} + +fn write_record( path: &Path, tmp_path: &Path, record: &DownloadOwnershipRecord, ) -> eyre::Result { - write_record_with_parent_sync(path, tmp_path, record, sync_parent_dir).await + write_record_with_parent_sync(path, tmp_path, record, sync_parent_dir) } -async fn write_record_with_parent_sync( +fn write_record_with_parent_sync( path: &Path, tmp_path: &Path, record: &DownloadOwnershipRecord, @@ -546,19 +1919,30 @@ async fn write_record_with_parent_sync( let parent = path .parent() .ok_or_else(|| eyre::eyre!("download ownership path has no parent"))?; - create_state_parent_durably(parent).await?; + create_state_parent_durably(parent)?; let bytes = serde_json::to_vec_pretty(record)?; if u64::try_from(bytes.len())? > MAX_OWNERSHIP_RECORD_BYTES { eyre::bail!("download ownership record exceeds its size limit"); } - let mut file = tokio::fs::File::create(tmp_path).await?; - file.write_all(&bytes).await?; - file.sync_all().await?; + let mut options = CapOpenOptions::new(); + options.read(true).write(true).create(true); + options.follow(FollowSymlinks::No).nonblock(true); + let mut file = + cap_fs::open_ambient(tmp_path, &options, ambient_authority()).wrap_err_with(|| { + format!( + "failed to open download ownership temporary file without following links at {}", + tmp_path.display() + ) + })?; + validate_regular_file_handle(&file, tmp_path)?; + file.set_len(0)?; + file.write_all(&bytes)?; + file.sync_all()?; drop(file); - tokio::fs::rename(tmp_path, path).await?; + std::fs::rename(tmp_path, path)?; if let Err(error) = sync_parent(path) { - return Ok(OwnershipJournalPublication::NeedsRecovery(error)); + return Ok(OwnershipJournalPublication::NeedsRecovery(error.into())); } Ok(OwnershipJournalPublication::Durable) } @@ -575,24 +1959,21 @@ fn require_durable_record( } } -async fn remove_owned_files( - game_root: &ConfinedGameRoot, - paths: &BTreeSet, -) -> eyre::Result<()> { +fn remove_owned_files(game_root: &ConfinedGameRoot, paths: &BTreeSet) -> eyre::Result<()> { let paths = paths .iter() .map(|path| ValidatedDownloadPath::from_ownership(path)) .collect::>>()?; - game_root.remove_owned_regular_files(paths).await + game_root.remove_owned_regular_files(paths) } -async fn create_state_parent_durably(path: &Path) -> eyre::Result<()> { +fn create_state_parent_durably(path: &Path) -> eyre::Result<()> { let mut missing = Vec::new(); let mut cursor = Some(path); while let Some(candidate) = cursor { - match tokio::fs::symlink_metadata(candidate).await { + match std::fs::symlink_metadata(candidate) { Ok(metadata) => { - if !metadata.is_dir() { + if !metadata.is_dir() || is_windows_reparse(&metadata) { eyre::bail!( "download ownership parent is not a directory: {}", candidate.display() @@ -608,23 +1989,29 @@ async fn create_state_parent_durably(path: &Path) -> eyre::Result<()> { } } - tokio::fs::create_dir_all(path).await?; + std::fs::create_dir_all(path)?; + open_ambient_directory_nofollow(path).wrap_err_with(|| { + format!( + "download ownership parent is not a safe directory: {}", + path.display() + ) + })?; for created in missing.iter().rev() { sync_parent_dir(created)?; } Ok(()) } -async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> { - match tokio::fs::remove_file(path).await { +fn remove_file_if_exists(path: &Path) -> eyre::Result<()> { + match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), Err(error) => Err(error.into()), } } -async fn sweep_tmp_file(path: &Path) { - if let Err(error) = remove_file_if_exists(path).await { +fn sweep_tmp_file(path: &Path) { + if let Err(error) = remove_file_if_exists(path) { log::warn!( "Failed to sweep ownership scratch {}: {error}", path.display() @@ -639,58 +2026,82 @@ pub(crate) async fn seed_download_ownership_for_test( game_id: &str, committed_files: &[&str], ) { - let games_folder = canonical_games_folder(games_folder).expect("games folder should resolve"); - let record = DownloadOwnershipRecord { - schema_version: OWNERSHIP_SCHEMA_VERSION, - game_id: game_id.to_owned(), - games_folder_key: games_folder_key(&games_folder), - committed_files: committed_files.iter().map(ToString::to_string).collect(), - pending_files: None, - }; - require_durable_record( - write_record( - &download_ownership_path(state_dir, game_id), - &download_ownership_tmp_path(state_dir, game_id), - &record, - ) - .await - .expect("test ownership should publish"), - "test ownership", + seed_download_ownership_generation_for_test( + state_dir, + games_folder, + game_id, + committed_files, + None, ) - .expect("test ownership should be durable"); + .await; } -#[cfg(unix)] -fn games_folder_key(path: &Path) -> String { - use std::os::unix::ffi::OsStrExt; - - format!("unix:{}", hex_encode(path.as_os_str().as_bytes())) +#[cfg(test)] +const fn test_content_id() -> ContentId { + ContentId::from_bytes([0x42; 32]) } -#[cfg(windows)] -fn games_folder_key(path: &Path) -> String { - use std::{fmt::Write as _, os::windows::ffi::OsStrExt}; - - let mut encoded = String::from("windows:"); - for unit in path.as_os_str().encode_wide() { - let _ = write!(encoded, "{unit:04x}"); - } - encoded +#[cfg(test)] +pub(crate) async fn seed_pending_download_ownership_for_test( + state_dir: &Path, + games_folder: &Path, + game_id: &str, + committed_files: &[&str], + pending_files: &[&str], +) { + seed_download_ownership_generation_for_test( + state_dir, + games_folder, + game_id, + committed_files, + Some(pending_files), + ) + .await; } -#[cfg(not(any(unix, windows)))] -fn games_folder_key(path: &Path) -> String { - format!("native:{}", hex_encode(path.as_os_str().as_encoded_bytes())) -} - -fn hex_encode(bytes: &[u8]) -> String { - use std::fmt::Write as _; - - let mut encoded = String::with_capacity(bytes.len() * 2); - for byte in bytes { - let _ = write!(encoded, "{byte:02x}"); - } - encoded +#[cfg(test)] +async fn seed_download_ownership_generation_for_test( + state_dir: &Path, + games_folder: &Path, + game_id: &str, + committed_files: &[&str], + pending_files: Option<&[&str]>, +) { + scoped_ownership_fs(|| { + let games_folder = + canonical_games_folder(games_folder).expect("games folder should resolve"); + let is_pending = pending_files.is_some(); + let record = DownloadOwnershipRecord { + schema_version: OWNERSHIP_SCHEMA_VERSION, + game_id: game_id.to_owned(), + games_folder_key: games_folder_key(&games_folder), + committed_content_id: (!committed_files.is_empty()).then_some(test_content_id()), + committed_files: committed_files.iter().map(ToString::to_string).collect(), + pending_content_id: pending_files + .filter(|paths| !paths.is_empty()) + .map(|_| test_content_id()), + pending_files: pending_files + .map(|paths| paths.iter().map(ToString::to_string).collect()), + }; + let games_folder_key = games_folder_key(&games_folder); + let record_path = download_ownership_path(state_dir, game_id, &games_folder_key); + let tmp_path = download_ownership_tmp_path(state_dir, game_id, &games_folder_key); + let recovery_required_path = + download_ownership_recovery_required_path(state_dir, game_id, &games_folder_key); + let publication = if is_pending { + write_record(&record_path, &tmp_path, &record).expect("test ownership should publish") + } else { + publish_settled_record(&record_path, &tmp_path, &recovery_required_path, &record) + .expect("test ownership should publish") + }; + require_durable_record(publication, "test ownership") + .expect("test ownership should be durable"); + if is_pending { + create_recovery_marker(&recovery_required_path) + .expect("test recovery marker should be durable"); + } + }) + .await; } #[cfg(unix)] @@ -708,7 +2119,14 @@ const fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { #[cfg(test)] mod tests { - use lanspread_db::db::{GameCatalog, GameFileDescription}; + use std::sync::Arc; + + use lanspread_db::content_manifest::{ + Blake3Digest, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + }; use super::*; use crate::{ @@ -717,25 +2135,46 @@ mod tests { }; fn manifest(games_folder: &Path, files: &[&str]) -> ValidatedDownloadManifest { - let mut descriptions = vec![GameFileDescription { - game_id: "game".to_owned(), - relative_path: "game/version.ini".to_owned(), - is_dir: false, - size: 8, - }]; - descriptions.extend(files.iter().map(|path| GameFileDescription { - game_id: "game".to_owned(), - relative_path: format!("game/{path}"), - is_dir: false, - size: 4, + manifest_with_version(games_folder, files, "20250101") + } + + fn manifest_with_version( + games_folder: &Path, + files: &[&str], + game_version: &str, + ) -> ValidatedDownloadManifest { + let version_digest = Blake3Digest::hash(game_version.as_bytes()); + let file_digest = Blake3Digest::hash(b"data"); + let mut entries = vec![ + CatalogFileEntry::file( + VERSION_INI, + u64::try_from(game_version.len()).expect("version length should fit"), + version_digest, + vec![version_digest], + ) + .expect("version entry should validate"), + ]; + let mut directories = BTreeSet::new(); + for path in files { + let components = path.split('/').collect::>(); + for component_count in 1..components.len() { + directories.insert(components[..component_count].join("/")); + } + } + entries.extend(directories.into_iter().map(|path| { + CatalogFileEntry::directory(path).expect("directory entry should validate") })); - ValidatedDownloadManifest::from_protocol_v7( - games_folder, - "game", - descriptions, - &GameCatalog::from_ids(["game".to_owned()]), - ) - .expect("manifest should validate") + entries.extend(files.iter().map(|path| { + CatalogFileEntry::file(*path, 4, file_digest, vec![file_digest]) + .expect("file entry should validate") + })); + entries.sort_by(|left, right| left.canonical_path().cmp(right.canonical_path())); + let body = CatalogContentManifestBody::new("game", game_version, entries, Vec::new()) + .expect("manifest body should validate"); + let catalog = + Arc::new(CatalogContentManifest::seal(body).expect("catalog manifest should validate")); + ValidatedDownloadManifest::from_catalog(games_folder, catalog) + .expect("download manifest should validate") } fn write_file(path: &Path, bytes: &[u8]) { @@ -745,87 +2184,691 @@ mod tests { std::fs::write(path, bytes).expect("file should be written"); } + fn ownership_record_path(state_dir: &Path, games_folder: &Path) -> PathBuf { + download_ownership_path(state_dir, "game", &games_folder_key(games_folder)) + } + + fn ownership_tmp_path(state_dir: &Path, games_folder: &Path) -> PathBuf { + download_ownership_tmp_path(state_dir, "game", &games_folder_key(games_folder)) + } + + fn ownership_marker_path(state_dir: &Path, games_folder: &Path) -> PathBuf { + download_ownership_recovery_required_path( + state_dir, + "game", + &games_folder_key(games_folder), + ) + } + async fn seed_record( state_dir: &Path, games_folder: &Path, committed: &[&str], pending: Option<&[&str]>, + ) { + seed_record_with_content_ids( + state_dir, + games_folder, + committed, + (!committed.is_empty()).then_some(test_content_id()), + pending, + pending + .filter(|paths| !paths.is_empty()) + .map(|_| test_content_id()), + ) + .await; + } + + async fn seed_record_with_content_ids( + state_dir: &Path, + games_folder: &Path, + committed: &[&str], + committed_content_id: Option, + pending: Option<&[&str]>, + pending_content_id: Option, + ) { + scoped_ownership_fs(|| { + let games_folder_key = games_folder_key(games_folder); + let record = DownloadOwnershipRecord { + schema_version: OWNERSHIP_SCHEMA_VERSION, + game_id: "game".to_owned(), + games_folder_key: games_folder_key.clone(), + committed_content_id, + committed_files: committed.iter().map(ToString::to_string).collect(), + pending_content_id, + pending_files: pending.map(|paths| paths.iter().map(ToString::to_string).collect()), + }; + require_durable_record( + write_record( + &download_ownership_path(state_dir, "game", &games_folder_key), + &download_ownership_tmp_path(state_dir, "game", &games_folder_key), + &record, + ) + .expect("record should be published"), + "test ownership", + ) + .expect("record should be durable"); + }) + .await; + } + + fn seed_legacy_record( + state_dir: &Path, + games_folder: &Path, + committed: &[&str], + pending: Option<&[&str]>, + marker: bool, ) { let record = DownloadOwnershipRecord { schema_version: OWNERSHIP_SCHEMA_VERSION, game_id: "game".to_owned(), games_folder_key: games_folder_key(games_folder), + committed_content_id: (!committed.is_empty()).then_some(test_content_id()), committed_files: committed.iter().map(ToString::to_string).collect(), + pending_content_id: pending + .filter(|paths| !paths.is_empty()) + .map(|_| test_content_id()), pending_files: pending.map(|paths| paths.iter().map(ToString::to_string).collect()), }; - require_durable_record( - write_record( - &download_ownership_path(state_dir, "game"), - &download_ownership_tmp_path(state_dir, "game"), - &record, - ) - .await - .expect("record should be published"), - "test ownership", - ) - .expect("record should be durable"); - } - - async fn read_valid_record(state_dir: &Path, games_folder: &Path) -> DownloadOwnershipRecord { - match load_record( - &download_ownership_path(state_dir, "game"), - "game", - &games_folder_key(games_folder), - ) - .await - { - LoadedOwnership::Valid(record) => record, - LoadedOwnership::Missing => panic!("record should exist"), - LoadedOwnership::Invalid => panic!("record should be valid"), + write_file( + &legacy_download_ownership_path(state_dir, "game"), + &serde_json::to_vec_pretty(&record).expect("legacy record should encode"), + ); + if marker { + write_file( + &legacy_download_ownership_recovery_required_path(state_dir, "game"), + RECOVERY_MARKER_BYTES, + ); } } - async fn confined_root(manifest: &ValidatedDownloadManifest) -> ConfinedGameRoot { + async fn read_valid_record(state_dir: &Path, games_folder: &Path) -> DownloadOwnershipRecord { + scoped_ownership_fs(|| { + let games_folder_key = games_folder_key(games_folder); + match load_record( + &download_ownership_path(state_dir, "game", &games_folder_key), + "game", + &games_folder_key, + ) { + LoadedOwnership::Valid(record) => record, + LoadedOwnership::Missing => panic!("record should exist"), + LoadedOwnership::Foreign => { + panic!("record should belong to this games directory") + } + LoadedOwnership::Invalid => panic!("record should be valid"), + } + }) + .await + } + + fn confined_root(manifest: &ValidatedDownloadManifest) -> ConfinedGameRoot { ConfinedGameRoot::open_or_create(manifest.games_folder(), manifest.game_id()) - .await .expect("confined game root should open") } + async fn readiness(games_folder: &Path, state_dir: &Path) -> DownloadOwnershipReadiness { + download_ownership_readiness(games_folder, state_dir, "game").await + } + + #[tokio::test] + async fn readiness_classifies_every_ownership_state_and_binding() { + let games = TempDir::new("lanspread-ownership-readiness-games"); + let foreign_games = TempDir::new("lanspread-ownership-readiness-foreign-games"); + let state = TempDir::new("lanspread-ownership-readiness-state"); + let record_path = ownership_record_path(state.path(), games.path()); + let marker_path = ownership_marker_path(state.path(), games.path()); + + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::Untracked + ); + + // A marker inside the selected root namespace is enough to fail + // closed: it may be the only durable evidence of an interrupted + // first publication. + create_recovery_marker(&marker_path).expect("marker should publish"); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + remove_file_if_exists(&marker_path).expect("marker should clear"); + + seed_record(state.path(), games.path(), &["archive.eti"], None).await; + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::Settled + ); + + create_recovery_marker(&marker_path).expect("marker should publish"); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + remove_file_if_exists(&marker_path).expect("marker should clear"); + + seed_record( + state.path(), + games.path(), + &["archive.eti"], + Some(&["archive.eti"]), + ) + .await; + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + + std::fs::remove_dir_all(record_path.parent().expect("record should have a parent")) + .expect("current namespace should be removed for the foreign-state check"); + + seed_record( + state.path(), + foreign_games.path(), + &["archive.eti"], + Some(&["archive.eti"]), + ) + .await; + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::Untracked + ); + assert_eq!( + readiness(foreign_games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + + write_file(&record_path, b"not json"); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + + let invalid = DownloadOwnershipRecord { + schema_version: OWNERSHIP_SCHEMA_VERSION + 1, + game_id: "game".to_owned(), + games_folder_key: games_folder_key(games.path()), + committed_content_id: Some(test_content_id()), + committed_files: vec!["archive.eti".to_owned()], + pending_content_id: None, + pending_files: None, + }; + write_file( + &record_path, + &serde_json::to_vec(&invalid).expect("invalid fixture should encode"), + ); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + + std::fs::remove_file(&record_path).expect("record should be removed"); + std::fs::create_dir(&record_path).expect("non-file record should be created"); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + } + + #[tokio::test] + async fn pre_content_id_ownership_record_is_never_catalog_verified() { + let games = TempDir::new("lanspread-ownership-old-schema-games"); + let state = TempDir::new("lanspread-ownership-old-schema-state"); + let old_record = serde_json::json!({ + "schema_version": 1, + "game_id": "game", + "games_folder_key": games_folder_key(games.path()), + "committed_files": ["archive.eti"], + "pending_files": null, + }); + write_file( + &ownership_record_path(state.path(), games.path()), + &serde_json::to_vec_pretty(&old_record).expect("old record should encode"), + ); + + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + assert!( + !download_ownership_matches_content( + games.path(), + state.path(), + "game", + test_content_id(), + ) + .await + ); + } + + #[tokio::test] + async fn pending_root_namespace_survives_another_root_and_recovers_when_selected_again() { + let old_games = TempDir::new("lanspread-ownership-old-marker-root"); + let new_games = TempDir::new("lanspread-ownership-new-marker-root"); + let state = TempDir::new("lanspread-ownership-foreign-marker-state"); + seed_record( + state.path(), + old_games.path(), + &["old.eti"], + Some(&["pending.eti"]), + ) + .await; + let marker_path = ownership_marker_path(state.path(), old_games.path()); + create_recovery_marker(&marker_path).expect("foreign recovery marker should publish"); + let old_record_path = ownership_record_path(state.path(), old_games.path()); + let old_record_before = std::fs::read(&old_record_path).expect("old record should exist"); + let old_marker_before = std::fs::read(&marker_path).expect("old marker should exist"); + + let manifest = manifest(new_games.path(), &[]); + let game_root = confined_root(&manifest); + DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root) + .await + .expect("new-root baseline should not inspect foreign ownership contents"); + + assert_eq!( + std::fs::read(&old_record_path).expect("old record should survive"), + old_record_before + ); + assert_eq!( + std::fs::read(&marker_path).expect("old marker should survive"), + old_marker_before + ); + assert_eq!( + readiness(new_games.path(), state.path()).await, + DownloadOwnershipReadiness::Settled + ); + let record = read_valid_record(state.path(), new_games.path()).await; + assert!(record.committed_files.is_empty()); + assert!(record.pending_files.is_none()); + + recover_incomplete_download(&old_games.game_root(), state.path(), "game") + .await + .expect("selecting the old absent root should recover its pending state"); + let recovered = read_valid_record(state.path(), old_games.path()).await; + assert!(recovered.committed_files.is_empty()); + assert!(recovered.pending_files.is_none()); + assert!(!marker_path.exists()); + assert_eq!( + readiness(new_games.path(), state.path()).await, + DownloadOwnershipReadiness::Settled + ); + } + + #[tokio::test] + async fn settled_roots_retain_independent_removal_authority() { + let games_a = TempDir::new("lanspread-ownership-removal-root-a"); + let games_b = TempDir::new("lanspread-ownership-removal-root-b"); + let state = TempDir::new("lanspread-ownership-removal-roots-state"); + for games in [&games_a, &games_b] { + write_file(&games.game_root().join(VERSION_INI), b"20240101"); + write_file(&games.game_root().join("archive.eti"), b"owned"); + seed_record(state.path(), games.path(), &["archive.eti"], None).await; + } + + remove_downloaded_payload(games_a.path(), state.path(), "game") + .await + .expect("root A ownership should authorize only root A removal"); + assert!(!games_a.game_root().join("archive.eti").exists()); + assert_eq!( + std::fs::read(games_b.game_root().join("archive.eti")) + .expect("root B payload should remain"), + b"owned" + ); + assert_eq!( + read_valid_record(state.path(), games_b.path()) + .await + .committed_files, + ["archive.eti"] + ); + + remove_downloaded_payload(games_b.path(), state.path(), "game") + .await + .expect("root B ownership should remain independently removable"); + assert!(!games_b.game_root().join("archive.eti").exists()); + assert!( + read_valid_record(state.path(), games_a.path()) + .await + .committed_files + .is_empty() + ); + assert!( + read_valid_record(state.path(), games_b.path()) + .await + .committed_files + .is_empty() + ); + } + + #[tokio::test] + async fn selected_namespace_rejects_a_record_from_another_root_without_mutation() { + let games_a = TempDir::new("lanspread-ownership-misplaced-root-a"); + let games_b = TempDir::new("lanspread-ownership-misplaced-root-b"); + let state = TempDir::new("lanspread-ownership-misplaced-state"); + seed_record(state.path(), games_a.path(), &["archive.eti"], None).await; + let record_a = ownership_record_path(state.path(), games_a.path()); + let bytes_a = std::fs::read(&record_a).expect("root A record should be readable"); + let record_b = ownership_record_path(state.path(), games_b.path()); + write_file(&record_b, &bytes_a); + let marker_b = ownership_marker_path(state.path(), games_b.path()); + create_recovery_marker(&marker_b).expect("root B marker should publish"); + let marker_before = std::fs::read(&marker_b).expect("root B marker should be readable"); + + assert!( + scan_download_ownership_recovery_ids(state.path(), games_b.path()).is_err(), + "the digest is only an index; the full root key remains authority" + ); + assert_eq!( + readiness(games_b.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + let manifest = manifest(games_b.path(), &[]); + let game_root = confined_root(&manifest); + let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root) + .await + .expect_err("a misplaced record must never become a fresh baseline"); + assert!(error.to_string().contains("different games directory")); + + assert_eq!( + std::fs::read(&record_a).expect("root A record should remain"), + bytes_a + ); + assert_eq!( + std::fs::read(&record_b).expect("misplaced record should remain as evidence"), + bytes_a + ); + assert_eq!( + std::fs::read(&marker_b).expect("marker should remain as evidence"), + marker_before + ); + } + + #[test] + fn selected_namespace_portable_alias_fails_closed_without_mutation() { + let games = TempDir::new("lanspread-ownership-namespace-alias-games"); + let state = TempDir::new("lanspread-ownership-namespace-alias-state"); + let namespace = + download_ownership_namespace_dir(state.path(), "game", &games_folder_key(games.path())); + let alias = namespace + .file_name() + .and_then(std::ffi::OsStr::to_str) + .expect("namespace should have a UTF-8 name") + .to_ascii_uppercase(); + let alias_path = namespace + .parent() + .expect("namespace should have a parent") + .join(alias); + write_file(&alias_path.join("canary"), b"preserve"); + + assert!( + scan_download_ownership_recovery_ids(state.path(), games.path()).is_err(), + "an alias of the selected namespace must be rejected before recovery" + ); + assert_eq!( + std::fs::read(alias_path.join("canary")).expect("canary should remain"), + b"preserve" + ); + } + + #[cfg(unix)] + #[test] + fn selected_namespace_symlink_is_rejected_without_following_it() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-ownership-namespace-link-games"); + let state = TempDir::new("lanspread-ownership-namespace-link-state"); + let outside = TempDir::new("lanspread-ownership-namespace-link-outside"); + let namespace = + download_ownership_namespace_dir(state.path(), "game", &games_folder_key(games.path())); + std::fs::create_dir_all(namespace.parent().expect("namespace should have a parent")) + .expect("namespace parent should be created"); + write_file(&outside.path().join("canary"), b"outside"); + symlink(outside.path(), &namespace).expect("selected namespace link should be created"); + + assert!(scan_download_ownership_recovery_ids(state.path(), games.path()).is_err()); + assert_eq!( + std::fs::read(outside.path().join("canary")).expect("outside canary should remain"), + b"outside" + ); + } + + #[tokio::test] + async fn legacy_pending_state_migrates_to_its_bound_root_while_another_root_is_selected() { + let games_a = TempDir::new("lanspread-ownership-legacy-root-a"); + let games_b = TempDir::new("lanspread-ownership-legacy-root-b"); + let state = TempDir::new("lanspread-ownership-legacy-state"); + seed_legacy_record( + state.path(), + games_a.path(), + &["old.eti"], + Some(&["pending.eti"]), + true, + ); + let legacy_path = legacy_download_ownership_path(state.path(), "game"); + let legacy_before = std::fs::read(&legacy_path).expect("legacy record should exist"); + + assert_eq!( + scan_download_ownership_recovery_ids(state.path(), games_b.path()) + .expect("valid legacy state should be scheduled for migration"), + HashSet::from(["game".to_owned()]) + ); + recover_incomplete_download(&games_b.game_root(), state.path(), "game") + .await + .expect("migration should use the legacy record's own root binding"); + + assert!(!legacy_path.exists()); + assert!(!legacy_download_ownership_tmp_path(state.path(), "game").exists()); + assert!(!legacy_download_ownership_recovery_required_path(state.path(), "game").exists()); + assert_eq!( + std::fs::read(ownership_record_path(state.path(), games_a.path())) + .expect("namespaced record should exist"), + legacy_before + ); + assert!(ownership_marker_path(state.path(), games_a.path()).is_file()); + assert_eq!( + readiness(games_b.path(), state.path()).await, + DownloadOwnershipReadiness::Untracked + ); + } + + #[tokio::test] + async fn exact_legacy_migration_duplicate_resumes_after_marker_cleanup_crash() { + let games = TempDir::new("lanspread-ownership-legacy-split-games"); + let state = TempDir::new("lanspread-ownership-legacy-split-state"); + seed_legacy_record(state.path(), games.path(), &["archive.eti"], None, true); + seed_record(state.path(), games.path(), &["archive.eti"], None).await; + create_recovery_marker(&ownership_marker_path(state.path(), games.path())) + .expect("destination marker should represent the split migration"); + std::fs::remove_file(legacy_download_ownership_recovery_required_path( + state.path(), + "game", + )) + .expect("legacy marker cleanup should be represented"); + + recover_incomplete_download(&games.game_root(), state.path(), "game") + .await + .expect("an exact duplicate should finish migration and selected-root recovery"); + + assert!(!legacy_download_ownership_path(state.path(), "game").exists()); + assert!(!ownership_marker_path(state.path(), games.path()).exists()); + assert_eq!( + read_valid_record(state.path(), games.path()) + .await + .committed_files, + ["archive.eti"] + ); + } + + #[test] + fn ambiguous_legacy_state_fails_closed_without_mutation() { + let games = TempDir::new("lanspread-ownership-legacy-invalid-games"); + let state = TempDir::new("lanspread-ownership-legacy-invalid-state"); + let marker = legacy_download_ownership_recovery_required_path(state.path(), "game"); + write_file(&marker, RECOVERY_MARKER_BYTES); + let before = std::fs::read(&marker).expect("legacy marker should be readable"); + + assert!(scan_download_ownership_recovery_ids(state.path(), games.path()).is_err()); + assert_eq!( + std::fs::read(&marker).expect("ambiguous marker should be preserved"), + before + ); + } + + #[tokio::test] + async fn legacy_and_namespaced_conflict_is_zero_mutation() { + let games = TempDir::new("lanspread-ownership-legacy-conflict-games"); + let state = TempDir::new("lanspread-ownership-legacy-conflict-state"); + seed_legacy_record(state.path(), games.path(), &["legacy.eti"], None, false); + seed_record(state.path(), games.path(), &["namespaced.eti"], None).await; + let legacy_path = legacy_download_ownership_path(state.path(), "game"); + let namespaced_path = ownership_record_path(state.path(), games.path()); + let legacy_before = std::fs::read(&legacy_path).expect("legacy record should exist"); + let namespaced_before = + std::fs::read(&namespaced_path).expect("namespaced record should exist"); + + assert!(scan_download_ownership_recovery_ids(state.path(), games.path()).is_err()); + assert!( + recover_incomplete_download(&games.game_root(), state.path(), "game") + .await + .is_err() + ); + assert_eq!( + std::fs::read(&legacy_path).expect("legacy evidence should remain"), + legacy_before + ); + assert_eq!( + std::fs::read(&namespaced_path).expect("destination evidence should remain"), + namespaced_before + ); + } + + #[tokio::test] + async fn prepublication_tmp_only_state_is_discovered_and_swept() { + let games = TempDir::new("lanspread-ownership-tmp-only-games"); + let state = TempDir::new("lanspread-ownership-tmp-only-state"); + let tmp = ownership_tmp_path(state.path(), games.path()); + let legacy_tmp = legacy_download_ownership_tmp_path(state.path(), "game"); + write_file(&tmp, b"partial"); + write_file(&legacy_tmp, b"legacy-partial"); + + assert_eq!( + scan_download_ownership_recovery_ids(state.path(), games.path()) + .expect("safe temporary state should scan"), + HashSet::from(["game".to_owned()]) + ); + recover_incomplete_download(&games.game_root(), state.path(), "game") + .await + .expect("prepublication scratch should be safely swept"); + assert!(!tmp.exists()); + assert!(!legacy_tmp.exists()); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::Untracked + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn unreadable_current_record_requires_recovery() { + use std::os::unix::fs::PermissionsExt as _; + + let games = TempDir::new("lanspread-ownership-unreadable-games"); + let state = TempDir::new("lanspread-ownership-unreadable-state"); + seed_record(state.path(), games.path(), &["archive.eti"], None).await; + let record_path = ownership_record_path(state.path(), games.path()); + std::fs::set_permissions(&record_path, std::fs::Permissions::from_mode(0o000)) + .expect("record should become unreadable"); + + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + + std::fs::set_permissions(&record_path, std::fs::Permissions::from_mode(0o600)) + .expect("test cleanup should restore record permissions"); + } + #[tokio::test] async fn journal_is_sorted_bound_and_ignores_stray_tmp() { let games = TempDir::new("lanspread-ownership-games"); let state = TempDir::new("lanspread-ownership-state"); let manifest = manifest(games.path(), &["z.eti", "nested/a.bin"]); - let game_root = confined_root(&manifest).await; + let expected_content_id = manifest.catalog_manifest().content_id(); + let game_root = confined_root(&manifest); let transaction = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root) .await .expect("transaction should prepare"); - transaction + let publication = transaction .journal_pending() .await .expect("pending set should be durable"); + assert!(matches!(publication, OwnershipJournalPublication::Durable)); + assert!( + ownership_marker_path(state.path(), games.path()).is_file(), + "the quarantine must span all payload mutation" + ); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + assert!( + !download_ownership_matches_content( + games.path(), + state.path(), + "game", + expected_content_id, + ) + .await + ); let record = read_valid_record(state.path(), games.path()).await; assert_eq!( record.pending_files, Some(vec!["nested/a.bin".to_owned(), "z.eti".to_owned()]) ); + assert_eq!(record.pending_content_id, Some(expected_content_id)); + assert!(record.committed_content_id.is_none()); assert!(record.committed_files.is_empty()); assert_eq!(record.game_id, "game"); assert_eq!(record.games_folder_key, games_folder_key(games.path())); - transaction + let publication = transaction .finalize() .await .expect("record should finalize"); - write_file( - &download_ownership_tmp_path(state.path(), "game"), - b"not json", + assert!(matches!(publication, OwnershipJournalPublication::Durable)); + assert!(!ownership_marker_path(state.path(), games.path()).exists()); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::Settled ); + write_file(&ownership_tmp_path(state.path(), games.path()), b"not json"); let stable = read_valid_record(state.path(), games.path()).await; + assert_eq!(stable.committed_content_id, Some(expected_content_id)); assert_eq!(stable.committed_files, ["nested/a.bin", "z.eti"]); + assert!(stable.pending_content_id.is_none()); assert!(stable.pending_files.is_none()); + assert!( + download_ownership_matches_content( + games.path(), + state.path(), + "game", + expected_content_id, + ) + .await + ); + + let different_content_id = manifest_with_version(games.path(), &[], "20250102") + .catalog_manifest() + .content_id(); + assert!( + !download_ownership_matches_content( + games.path(), + state.path(), + "game", + different_content_id, + ) + .await + ); } #[test] @@ -834,7 +2877,9 @@ mod tests { schema_version: OWNERSHIP_SCHEMA_VERSION, game_id: "game".to_owned(), games_folder_key: "root".to_owned(), + committed_content_id: Some(test_content_id()), committed_files: vec!["archive.eti".to_owned()], + pending_content_id: None, pending_files: None, }; assert!(valid.clone().validate("game", "root").is_ok()); @@ -859,9 +2904,35 @@ mod tests { let mut cross_generation_alias = valid.clone(); cross_generation_alias.committed_files = vec!["Archive.eti".to_owned()]; + cross_generation_alias.pending_content_id = Some(ContentId::from_bytes([0x24; 32])); cross_generation_alias.pending_files = Some(vec!["archive.eti".to_owned()]); assert!(cross_generation_alias.validate("game", "root").is_err()); + let mut unverified_committed = valid.clone(); + unverified_committed.committed_content_id = None; + assert!(unverified_committed.validate("game", "root").is_err()); + + let mut unverified_pending = valid.clone(); + unverified_pending.pending_files = Some(vec!["new.eti".to_owned()]); + assert!(unverified_pending.validate("game", "root").is_err()); + + let mut detached_pending_id = valid.clone(); + detached_pending_id.pending_content_id = Some(ContentId::from_bytes([0x24; 32])); + assert!(detached_pending_id.validate("game", "root").is_err()); + + let mut removal_intent = valid.clone(); + removal_intent.pending_files = Some(Vec::new()); + assert!(removal_intent.validate("game", "root").is_ok()); + + let mut version_only_committed = valid.clone(); + version_only_committed.committed_files.clear(); + assert!(version_only_committed.validate("game", "root").is_ok()); + + let mut version_only_pending = valid.clone(); + version_only_pending.pending_content_id = Some(ContentId::from_bytes([0x24; 32])); + version_only_pending.pending_files = Some(Vec::new()); + assert!(version_only_pending.validate("game", "root").is_ok()); + assert!(valid.clone().validate("other", "root").is_err()); assert!(valid.validate("game", "other-root").is_err()); } @@ -875,7 +2946,7 @@ mod tests { write_file(&root.join("archive.eti"), b"user-bytes"); write_file(&root.join("notes.txt"), b"user-note"); let manifest = manifest(games.path(), &["archive.eti"]); - let confined_root = confined_root(&manifest).await; + let confined_root = confined_root(&manifest); let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) .await @@ -895,7 +2966,7 @@ mod tests { b"user-note" ); assert!(!root.join(VERSION_DISCARDED_FILE).exists()); - assert!(!download_ownership_path(state.path(), "game").exists()); + assert!(!ownership_record_path(state.path(), games.path()).exists()); } #[tokio::test] @@ -922,13 +2993,12 @@ mod tests { .await; let manifest = manifest(games.path(), &["keep.eti", "new.eti"]); - let confined_root = confined_root(&manifest).await; + let confined_root = confined_root(&manifest); let transaction = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) .await .expect("transaction should prepare"); super::super::version_ini::begin_version_ini_transaction(&confined_root) - .await .expect("sentinel should park"); transaction .journal_pending() @@ -936,7 +3006,6 @@ mod tests { .expect("pending should journal"); transaction .remove_stale() - .await .expect("stale cleanup should succeed"); assert!(root.join("keep.eti").is_file()); @@ -966,7 +3035,9 @@ mod tests { b"save" ); let record = read_valid_record(state.path(), games.path()).await; + assert!(record.committed_content_id.is_none()); assert!(record.committed_files.is_empty()); + assert!(record.pending_content_id.is_none()); assert!(record.pending_files.is_none()); } @@ -1009,7 +3080,9 @@ mod tests { b"user" ); let record = read_valid_record(state.path(), games.path()).await; + assert!(record.committed_content_id.is_none()); assert!(record.committed_files.is_empty()); + assert!(record.pending_content_id.is_none()); assert!(record.pending_files.is_none()); remove_downloaded_payload(games.path(), state.path(), "game") @@ -1021,6 +3094,185 @@ mod tests { ); } + #[tokio::test] + async fn removal_clears_a_version_only_content_binding_without_a_sentinel() { + let games = TempDir::new("lanspread-ownership-remove-version-only-games"); + let state = TempDir::new("lanspread-ownership-remove-version-only-state"); + std::fs::create_dir(games.game_root()).expect("empty game root should be created"); + let content_id = test_content_id(); + seed_record_with_content_ids( + state.path(), + games.path(), + &[], + Some(content_id), + None, + None, + ) + .await; + + assert!( + download_ownership_matches_content(games.path(), state.path(), "game", content_id,) + .await + ); + + remove_downloaded_payload(games.path(), state.path(), "game") + .await + .expect("version-only ownership should still be removable"); + + let record = read_valid_record(state.path(), games.path()).await; + assert!(record.committed_content_id.is_none()); + assert!(record.committed_files.is_empty()); + assert!(record.pending_content_id.is_none()); + assert!(record.pending_files.is_none()); + assert!( + !download_ownership_matches_content(games.path(), state.path(), "game", content_id,) + .await + ); + } + + #[tokio::test] + async fn absent_root_clears_a_version_only_content_binding() { + let games = TempDir::new("lanspread-ownership-remove-absent-version-only-games"); + let state = TempDir::new("lanspread-ownership-remove-absent-version-only-state"); + let content_id = test_content_id(); + seed_record_with_content_ids( + state.path(), + games.path(), + &[], + Some(content_id), + None, + None, + ) + .await; + + remove_downloaded_payload(games.path(), state.path(), "game") + .await + .expect("an absent version-only root should settle ownership"); + + let record = read_valid_record(state.path(), games.path()).await; + assert!(record.committed_content_id.is_none()); + assert!(record.committed_files.is_empty()); + assert!(record.pending_content_id.is_none()); + assert!(record.pending_files.is_none()); + assert!( + !download_ownership_matches_content(games.path(), state.path(), "game", content_id,) + .await + ); + } + + #[tokio::test] + async fn absent_root_quarantines_a_marker_without_a_record() { + let games = TempDir::new("lanspread-ownership-remove-absent-missing-games"); + let state = TempDir::new("lanspread-ownership-remove-absent-missing-state"); + let record_path = ownership_record_path(state.path(), games.path()); + let marker_path = ownership_marker_path(state.path(), games.path()); + create_recovery_marker(&marker_path).expect("marker should publish"); + let marker_before = std::fs::read(&marker_path).expect("marker should be readable"); + + let error = remove_downloaded_payload(games.path(), state.path(), "game") + .await + .expect_err("marker-only state must stay quarantined"); + + assert!(error.to_string().contains("without a valid record")); + assert!(!record_path.exists()); + assert_eq!( + std::fs::read(&marker_path).expect("marker should be preserved"), + marker_before + ); + } + + #[tokio::test] + async fn absent_root_preserves_a_foreign_record_and_marker() { + let games = TempDir::new("lanspread-ownership-remove-absent-current-games"); + let foreign_games = TempDir::new("lanspread-ownership-remove-absent-foreign-games"); + let state = TempDir::new("lanspread-ownership-remove-absent-foreign-state"); + seed_record( + state.path(), + foreign_games.path(), + &["archive.eti"], + Some(&["partial.eti"]), + ) + .await; + let record_path = ownership_record_path(state.path(), foreign_games.path()); + let marker_path = ownership_marker_path(state.path(), foreign_games.path()); + create_recovery_marker(&marker_path).expect("foreign marker should publish"); + let record_before = std::fs::read(&record_path).expect("record should be readable"); + let marker_before = std::fs::read(&marker_path).expect("marker should be readable"); + + remove_downloaded_payload(games.path(), state.path(), "game") + .await + .expect("an absent root must not settle foreign state"); + + assert_eq!( + std::fs::read(&record_path).expect("foreign record should be preserved"), + record_before + ); + assert_eq!( + std::fs::read(&marker_path).expect("foreign marker should be preserved"), + marker_before + ); + assert!(matches!( + load_record(&record_path, "game", &games_folder_key(games.path())), + LoadedOwnership::Foreign + )); + } + + #[tokio::test] + async fn absent_root_rejects_invalid_state_without_clearing_its_marker() { + let games = TempDir::new("lanspread-ownership-remove-absent-invalid-games"); + let state = TempDir::new("lanspread-ownership-remove-absent-invalid-state"); + let record_path = ownership_record_path(state.path(), games.path()); + let marker_path = ownership_marker_path(state.path(), games.path()); + write_file(&record_path, b"not-json"); + create_recovery_marker(&marker_path).expect("marker should publish"); + let marker_before = std::fs::read(&marker_path).expect("marker should be readable"); + + let error = remove_downloaded_payload(games.path(), state.path(), "game") + .await + .expect_err("invalid state must not be silently settled"); + + assert!(error.to_string().contains("invalid record")); + assert_eq!( + std::fs::read(&record_path).expect("invalid record should be preserved"), + b"not-json" + ); + assert_eq!( + std::fs::read(&marker_path).expect("marker should be preserved"), + marker_before + ); + } + + #[tokio::test] + async fn absent_root_settles_only_current_bound_valid_state() { + let games = TempDir::new("lanspread-ownership-remove-absent-valid-games"); + let state = TempDir::new("lanspread-ownership-remove-absent-valid-state"); + seed_record( + state.path(), + games.path(), + &["archive.eti"], + Some(&["partial.eti"]), + ) + .await; + let marker_path = ownership_marker_path(state.path(), games.path()); + create_recovery_marker(&marker_path).expect("current marker should publish"); + + assert_eq!( + scan_download_ownership_recovery_ids(state.path(), games.path()) + .expect("ownership-only state should scan"), + HashSet::from(["game".to_owned()]), + "startup recovery must discover pending state without a game directory" + ); + + remove_downloaded_payload(games.path(), state.path(), "game") + .await + .expect("current-bound state should settle when its root is absent"); + + let record = read_valid_record(state.path(), games.path()).await; + assert!(record.committed_files.is_empty()); + assert!(record.pending_files.is_none()); + assert!(!marker_path.exists()); + } + #[tokio::test] async fn removal_without_trustworthy_ownership_is_zero_mutation() { for invalid_record in [None, Some(b"not-json".as_slice())] { @@ -1031,14 +3283,17 @@ mod tests { write_file(&root.join("archive.eti"), b"ambiguous"); write_file(&root.join("notes.txt"), b"user"); if let Some(bytes) = invalid_record { - write_file(&download_ownership_path(state.path(), "game"), bytes); + write_file(&ownership_record_path(state.path(), games.path()), bytes); } let error = remove_downloaded_payload(games.path(), state.path(), "game") .await .expect_err("ambiguous payload must not be removed"); - assert!(error.to_string().contains("cannot safely remove")); + assert!( + error.to_string().contains("cannot safely remove") + || error.to_string().contains("invalid record") + ); assert_eq!( std::fs::read(root.join(VERSION_INI)).expect("sentinel should remain"), b"20240101" @@ -1104,102 +3359,132 @@ mod tests { b"user" ); let record = read_valid_record(state.path(), games.path()).await; + assert!(record.committed_content_id.is_none()); assert!(record.committed_files.is_empty()); + assert!(record.pending_content_id.is_none()); assert!(record.pending_files.is_none()); } #[tokio::test] - async fn recovery_handles_every_durable_journal_state() { - // Crash after parking the old sentinel but before publishing pending. - { - let games = TempDir::new("lanspread-ownership-unrecorded-games"); - let state = TempDir::new("lanspread-ownership-unrecorded-state"); - let root = games.game_root(); - write_file(&root.join(VERSION_INI), b"20240101"); - let manifest = manifest(games.path(), &["archive.eti"]); - let confined_root = confined_root(&manifest).await; - let _transaction = - DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) - .await - .expect("baseline ownership should be durable"); - super::super::version_ini::begin_version_ini_transaction(&confined_root) + async fn recovery_restores_an_unjournaled_parked_sentinel() { + let games = TempDir::new("lanspread-ownership-unrecorded-games"); + let state = TempDir::new("lanspread-ownership-unrecorded-state"); + let root = games.game_root(); + write_file(&root.join(VERSION_INI), b"20240101"); + let manifest = manifest(games.path(), &["archive.eti"]); + let confined_root = confined_root(&manifest); + let _transaction = + DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) .await - .expect("sentinel should park"); - recover_incomplete_download(&root, state.path(), "game") - .await - .expect("unjournaled park should recover"); - assert_eq!( - std::fs::read(root.join(VERSION_INI)).expect("sentinel should be readable"), - b"20240101" - ); - assert!(!root.join(VERSION_DISCARDED_FILE).exists()); - } + .expect("baseline ownership should be durable"); + super::super::version_ini::begin_version_ini_transaction(&confined_root) + .expect("sentinel should park"); - // Pending without a sentinel means the transaction never committed. - { - let games = TempDir::new("lanspread-ownership-abort-games"); - let state = TempDir::new("lanspread-ownership-abort-state"); - let root = games.game_root(); - write_file(&root.join("old.eti"), b"old"); - write_file(&root.join("new.eti"), b"partial"); - write_file(&root.join("notes.txt"), b"user"); - write_file(&root.join(LOCAL_DIR).join("save.dat"), b"save"); - write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101"); - seed_record(state.path(), games.path(), &["old.eti"], Some(&["new.eti"])).await; - recover_incomplete_download(&root, state.path(), "game") - .await - .expect("pending abort should recover"); - assert!(!root.join("old.eti").exists()); - assert!(!root.join("new.eti").exists()); - assert!(!root.join(VERSION_INI).exists()); - assert!(!root.join(VERSION_DISCARDED_FILE).exists()); - assert_eq!( - std::fs::read(root.join("notes.txt")).expect("user file should remain readable"), - b"user" - ); - assert_eq!( - std::fs::read(root.join(LOCAL_DIR).join("save.dat")) - .expect("local save should remain readable"), - b"save" - ); - let record = read_valid_record(state.path(), games.path()).await; - assert!(record.committed_files.is_empty()); - assert!(record.pending_files.is_none()); - } + recover_incomplete_download(&root, state.path(), "game") + .await + .expect("unjournaled park should recover"); - // Pending with a sentinel means commit landed before ledger finalization. - { - let games = TempDir::new("lanspread-ownership-commit-games"); - let state = TempDir::new("lanspread-ownership-commit-state"); - let root = games.game_root(); - for path in ["keep.eti", "new.eti", "stale.eti", "notes.txt"] { - write_file(&root.join(path), path.as_bytes()); - } - write_file(&root.join(VERSION_INI), b"20250101"); - write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101"); - seed_record( - state.path(), + assert_eq!( + std::fs::read(root.join(VERSION_INI)).expect("sentinel should be readable"), + b"20240101" + ); + assert!(!root.join(VERSION_DISCARDED_FILE).exists()); + } + + #[tokio::test] + async fn recovery_aborts_a_pending_generation_without_a_sentinel() { + let games = TempDir::new("lanspread-ownership-abort-games"); + let state = TempDir::new("lanspread-ownership-abort-state"); + let root = games.game_root(); + write_file(&root.join("old.eti"), b"old"); + write_file(&root.join("new.eti"), b"partial"); + write_file(&root.join("notes.txt"), b"user"); + write_file(&root.join(LOCAL_DIR).join("save.dat"), b"save"); + write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101"); + seed_record(state.path(), games.path(), &["old.eti"], Some(&["new.eti"])).await; + + recover_incomplete_download(&root, state.path(), "game") + .await + .expect("pending abort should recover"); + + assert!(!root.join("old.eti").exists()); + assert!(!root.join("new.eti").exists()); + assert!(!root.join(VERSION_INI).exists()); + assert!(!root.join(VERSION_DISCARDED_FILE).exists()); + assert_eq!( + std::fs::read(root.join("notes.txt")).expect("user file should remain readable"), + b"user" + ); + assert_eq!( + std::fs::read(root.join(LOCAL_DIR).join("save.dat")) + .expect("local save should remain readable"), + b"save" + ); + let record = read_valid_record(state.path(), games.path()).await; + assert!(record.committed_content_id.is_none()); + assert!(record.committed_files.is_empty()); + assert!(record.pending_content_id.is_none()); + assert!(record.pending_files.is_none()); + } + + #[tokio::test] + async fn recovery_promotes_the_pending_content_id_after_sentinel_commit() { + let games = TempDir::new("lanspread-ownership-commit-games"); + let state = TempDir::new("lanspread-ownership-commit-state"); + let root = games.game_root(); + for path in ["keep.eti", "new.eti", "stale.eti", "notes.txt"] { + write_file(&root.join(path), path.as_bytes()); + } + write_file(&root.join(VERSION_INI), b"20250101"); + write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101"); + let committed_content_id = ContentId::from_bytes([0x11; 32]); + let pending_content_id = ContentId::from_bytes([0x22; 32]); + seed_record_with_content_ids( + state.path(), + games.path(), + &["keep.eti", "stale.eti"], + Some(committed_content_id), + Some(&["keep.eti", "new.eti"]), + Some(pending_content_id), + ) + .await; + + recover_incomplete_download(&root, state.path(), "game") + .await + .expect("landed commit should finalize"); + + assert!(root.join("keep.eti").is_file()); + assert!(root.join("new.eti").is_file()); + assert!(!root.join("stale.eti").exists()); + assert!(root.join("notes.txt").is_file()); + assert_eq!( + std::fs::read(root.join(VERSION_INI)).expect("sentinel should be readable"), + b"20250101" + ); + assert!(!root.join(VERSION_DISCARDED_FILE).exists()); + let record = read_valid_record(state.path(), games.path()).await; + assert_eq!(record.committed_content_id, Some(pending_content_id)); + assert_eq!(record.committed_files, ["keep.eti", "new.eti"]); + assert!(record.pending_content_id.is_none()); + assert!(record.pending_files.is_none()); + assert!( + download_ownership_matches_content( games.path(), - &["keep.eti", "stale.eti"], - Some(&["keep.eti", "new.eti"]), + state.path(), + "game", + pending_content_id, ) - .await; - recover_incomplete_download(&root, state.path(), "game") - .await - .expect("landed commit should finalize"); - assert!(root.join("keep.eti").is_file()); - assert!(root.join("new.eti").is_file()); - assert!(!root.join("stale.eti").exists()); - assert!(root.join("notes.txt").is_file()); - assert_eq!( - std::fs::read(root.join(VERSION_INI)).expect("sentinel should be readable"), - b"20250101" - ); - assert!(!root.join(VERSION_DISCARDED_FILE).exists()); - let record = read_valid_record(state.path(), games.path()).await; - assert_eq!(record.committed_files, ["keep.eti", "new.eti"]); - assert!(record.pending_files.is_none()); - } + .await + ); + assert!( + !download_ownership_matches_content( + games.path(), + state.path(), + "game", + committed_content_id, + ) + .await + ); } #[tokio::test] @@ -1230,16 +3515,17 @@ mod tests { schema_version: OWNERSHIP_SCHEMA_VERSION, game_id: "game".to_owned(), games_folder_key: games_folder_key(games.path()), + committed_content_id: None, committed_files: Vec::new(), + pending_content_id: Some(test_content_id()), pending_files: Some(vec!["archive.eti".to_owned()]), }; - let record_path = download_ownership_path(state.path(), "game"); - let tmp_path = download_ownership_tmp_path(state.path(), "game"); + let record_path = ownership_record_path(state.path(), games.path()); + let tmp_path = ownership_tmp_path(state.path(), games.path()); let publication = write_record_with_parent_sync(&record_path, &tmp_path, &record, |_| { Err(std::io::Error::other("injected parent sync failure")) }) - .await .expect("post-publication sync failure must remain phase-aware"); assert!(matches!( @@ -1249,6 +3535,269 @@ mod tests { assert_eq!(read_valid_record(state.path(), games.path()).await, record); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn aborted_finalize_waits_for_the_atomic_publication_scope() { + use std::{ + sync::{Arc, Condvar, Mutex, mpsc}, + thread, + time::Duration, + }; + + let games = TempDir::new("lanspread-ownership-scoped-finalize-games"); + let state = TempDir::new("lanspread-ownership-scoped-finalize-state"); + let manifest = manifest(games.path(), &["archive.eti"]); + let game_root = confined_root(&manifest); + let transaction = + DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root) + .await + .expect("transaction should prepare"); + let marker_path = ownership_marker_path(state.path(), games.path()); + + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let gate_for_publication = Arc::clone(&gate); + let gate_for_release = Arc::clone(&gate); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (request_release, release_requested) = mpsc::channel(); + let release_thread = thread::spawn(move || { + let _ = release_requested.recv_timeout(Duration::from_secs(2)); + let (gate_open, wake) = &*gate_for_release; + let mut gate_open = gate_open.lock().expect("release gate must not be poisoned"); + *gate_open = true; + wake.notify_one(); + }); + + let mut finalize_task = tokio::spawn(async move { + transaction + .finalize_with_parent_sync(move |_| { + entered_tx + .send(()) + .expect("publication must report reaching its sync point"); + let (gate_open, wake) = &*gate_for_publication; + let gate_open = gate_open + .lock() + .expect("publication gate must not be poisoned"); + let _gate_open = wake + .wait_while(gate_open, |gate_open| !*gate_open) + .expect("publication gate must not be poisoned"); + Ok(()) + }) + .await + }); + + tokio::time::timeout(Duration::from_secs(2), entered_rx) + .await + .expect("publication must reach the injected sync point") + .expect("publication must retain its entry sender"); + assert!( + marker_path.is_file(), + "finalization must publish quarantine first" + ); + + finalize_task.abort(); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut finalize_task) + .await + .is_err(), + "task abort must wait for the in-progress publication scope" + ); + + request_release + .send(()) + .expect("release thread must remain available"); + release_thread + .join() + .expect("release thread must not panic"); + let completion = tokio::time::timeout(Duration::from_secs(2), &mut finalize_task) + .await + .expect("finalization must stop after its publication scope completes"); + match completion { + Ok(Ok(OwnershipJournalPublication::Durable)) => {} + Ok(Ok(OwnershipJournalPublication::NeedsRecovery(error))) => { + panic!("publication unexpectedly required recovery: {error}") + } + Ok(Err(error)) => panic!("publication failed unexpectedly: {error}"), + Err(error) if error.is_cancelled() => {} + Err(error) => panic!("finalization task failed unexpectedly: {error}"), + } + + assert!( + !marker_path.exists(), + "the atomic publication scope must clear quarantine before task completion" + ); + let record = read_valid_record(state.path(), games.path()).await; + assert_eq!(record.committed_files, ["archive.eti"]); + assert!(record.pending_files.is_none()); + } + + #[tokio::test] + async fn pending_publication_never_allows_mutation_without_a_quarantine_marker() { + let games = TempDir::new("lanspread-ownership-marker-failure-games"); + let state = TempDir::new("lanspread-ownership-marker-failure-state"); + let manifest = manifest(games.path(), &["archive.eti"]); + let game_root = confined_root(&manifest); + let transaction = + DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root) + .await + .expect("transaction should prepare"); + let marker_path = ownership_marker_path(state.path(), games.path()); + std::fs::create_dir(&marker_path).expect("invalid marker entry should be created"); + + let publication = transaction + .journal_pending() + .await + .expect("durable pending state should retain phase information"); + + assert!(matches!( + publication, + OwnershipJournalPublication::NeedsRecovery(_) + )); + let record = read_valid_record(state.path(), games.path()).await; + assert_eq!(record.pending_files, Some(vec!["archive.eti".to_owned()])); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + } + + #[tokio::test] + async fn uncertain_final_record_stays_quarantined_until_recovery_republishes_it() { + let games = TempDir::new("lanspread-ownership-finalize-recovery-games"); + let state = TempDir::new("lanspread-ownership-finalize-recovery-state"); + let manifest = manifest(games.path(), &["archive.eti"]); + let expected_content_id = manifest.catalog_manifest().content_id(); + write_file(&games.game_root().join(VERSION_INI), b"20250101"); + let game_root = confined_root(&manifest); + let transaction = + DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root) + .await + .expect("transaction should prepare"); + assert!(matches!( + transaction + .journal_pending() + .await + .expect("pending ownership should publish"), + OwnershipJournalPublication::Durable + )); + + let publication = transaction + .finalize_with_parent_sync(|_| { + Err(std::io::Error::other("injected final-record sync failure")) + }) + .await + .expect("post-rename uncertainty should stay phase-aware"); + + assert!(matches!( + publication, + OwnershipJournalPublication::NeedsRecovery(_) + )); + let marker_path = ownership_marker_path(state.path(), games.path()); + assert!(marker_path.is_file()); + let visible_record = read_valid_record(state.path(), games.path()).await; + assert_eq!( + visible_record.committed_content_id, + Some(expected_content_id) + ); + assert_eq!(visible_record.committed_files, ["archive.eti"]); + assert!(visible_record.pending_content_id.is_none()); + assert!(visible_record.pending_files.is_none()); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::RecoveryRequired + ); + assert!( + !download_ownership_matches_content( + games.path(), + state.path(), + "game", + expected_content_id, + ) + .await + ); + + recover_incomplete_download(&games.game_root(), state.path(), "game") + .await + .expect("recovery should re-publish visible settled ownership"); + + assert!(!marker_path.exists()); + assert_eq!( + readiness(games.path(), state.path()).await, + DownloadOwnershipReadiness::Settled + ); + assert_eq!( + read_valid_record(state.path(), games.path()).await, + visible_record + ); + assert!( + download_ownership_matches_content( + games.path(), + state.path(), + "game", + expected_content_id, + ) + .await + ); + } + + #[tokio::test] + async fn marker_clear_reports_only_visible_quarantine_failures() { + let games = TempDir::new("lanspread-ownership-marker-clear-games"); + let state = TempDir::new("lanspread-ownership-marker-clear-state"); + let marker_path = ownership_marker_path(state.path(), games.path()); + create_recovery_marker(&marker_path).expect("marker should publish"); + + clear_recovery_marker_with_parent_sync(&marker_path, |_| { + Err(std::io::Error::other( + "injected marker removal sync failure", + )) + }) + .expect("an unlinked marker is visibly settled despite conservative crash uncertainty"); + assert!(!marker_path.exists()); + + std::fs::create_dir(&marker_path).expect("invalid marker entry should be created"); + assert!(clear_recovery_marker(&marker_path).is_err()); + assert!(marker_path.is_dir()); + + std::fs::remove_dir(&marker_path).expect("invalid marker should be removed"); + assert!( + create_recovery_marker_with_parent_sync(&marker_path, |_| { + Err(std::io::Error::other("injected marker sync failure")) + }) + .is_err() + ); + assert!( + marker_path.is_file(), + "a visible but uncertain marker must remain conservative" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn marker_creation_rejects_links_and_non_regular_entries_without_mutation() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-ownership-marker-nofollow-games"); + let state = TempDir::new("lanspread-ownership-marker-nofollow-state"); + let outside = TempDir::new("lanspread-ownership-marker-nofollow-outside"); + let marker_path = ownership_marker_path(state.path(), games.path()); + std::fs::create_dir_all(marker_path.parent().expect("marker should have a parent")) + .expect("marker parent should be created"); + let canary_path = outside.path().join("canary"); + write_file(&canary_path, b"outside"); + symlink(&canary_path, &marker_path).expect("marker symlink should be created"); + + assert!(create_recovery_marker(&marker_path).is_err()); + assert!(marker_path.is_symlink()); + assert_eq!( + std::fs::read(&canary_path).expect("outside canary should remain readable"), + b"outside" + ); + + std::fs::remove_file(&marker_path).expect("marker symlink should be removed"); + std::fs::create_dir(&marker_path).expect("non-regular marker should be created"); + assert!(create_recovery_marker(&marker_path).is_err()); + assert!(marker_path.is_dir()); + } + #[tokio::test] async fn cross_generation_portable_alias_is_rejected_before_payload_mutation() { let games = TempDir::new("lanspread-ownership-alias-games"); @@ -1259,7 +3808,7 @@ mod tests { seed_record(state.path(), games.path(), &["Archive.eti"], None).await; let manifest = manifest(games.path(), &["archive.eti"]); - let confined_root = confined_root(&manifest).await; + let confined_root = confined_root(&manifest); let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) .await .expect_err("case-only ownership changes must fail closed"); @@ -1288,9 +3837,10 @@ mod tests { ) .await; - recover_incomplete_download(&root, state.path(), "game") + let error = recover_incomplete_download(&root, state.path(), "game") .await - .expect("invalid ownership must fail closed"); + .expect_err("invalid ownership must fail closed"); + assert!(error.to_string().contains("invalid record")); assert_eq!( std::fs::read(root.join("archive.eti")).expect("payload must be preserved"), @@ -1313,7 +3863,7 @@ mod tests { write_file(&root.join("archive.eti"), b"replacement"); let manifest = manifest(games.path(), &["archive.eti"]); - let confined_root = confined_root(&manifest).await; + let confined_root = confined_root(&manifest); DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) .await .expect("path ownership deliberately survives local root replacement"); @@ -1353,11 +3903,15 @@ mod tests { ); assert!(!games_b.game_root().join(VERSION_INI).exists()); - write_file(&download_ownership_path(state.path(), "game"), b"corrupt"); + write_file( + &ownership_record_path(state.path(), games_b.path()), + b"corrupt", + ); write_file(&games_b.game_root().join("partial.eti"), b"unknown"); - recover_incomplete_download(&games_b.game_root(), state.path(), "game") + let error = recover_incomplete_download(&games_b.game_root(), state.path(), "game") .await - .expect("corrupt state should preserve unknown payload"); + .expect_err("corrupt state should fail closed"); + assert!(error.to_string().contains("invalid record")); assert_eq!( std::fs::read(games_b.game_root().join("partial.eti")) .expect("unknown payload should remain readable"), diff --git a/crates/lanspread-peer/src/download/planning.rs b/crates/lanspread-peer/src/download/planning.rs index bfeabdd..463ae78 100644 --- a/crates/lanspread-peer/src/download/planning.rs +++ b/crates/lanspread-peer/src/download/planning.rs @@ -1,17 +1,36 @@ -use std::{collections::HashMap, net::SocketAddr}; +use std::collections::HashMap; -use super::manifest::{ValidatedDownloadEntry, ValidatedDownloadPath}; -use crate::config::CHUNK_SIZE; +use lanspread_db::content_manifest::{Blake3Digest, CanonicalCatalogPath, ContentId}; +use lanspread_proto::PeerEndpoint; + +use super::{ + manifest::{ExpectedCatalogBlake3, ValidatedDownloadManifest, ValidatedDownloadPath}, + transfer_error::DownloadTransferResult, +}; +use crate::game_paths::VERSION_INI; /// Represents a chunk of a file to be downloaded. #[derive(Debug, Clone)] pub(super) struct DownloadChunk { - pub(super) request_path: String, + pub(super) content_id: ContentId, pub(super) destination: ValidatedDownloadPath, pub(super) offset: u64, pub(super) length: u64, - pub(super) retry_count: usize, - pub(super) last_peer: Option, + pub(super) expected_blake3: ExpectedCatalogBlake3, +} + +impl DownloadChunk { + pub(super) fn expected_blake3(&self) -> Blake3Digest { + self.expected_blake3.digest() + } + + pub(super) fn is_version_ini(&self) -> bool { + self.destination.canonical() == VERSION_INI + } + + pub(super) const fn canonical_path(&self) -> &CanonicalCatalogPath { + self.destination.catalog_path() + } } /// Download plan for a single peer. @@ -24,85 +43,132 @@ pub(super) struct PeerDownloadPlan { #[derive(Debug)] pub(super) struct ChunkDownloadResult { pub(super) chunk: DownloadChunk, - pub(super) result: eyre::Result<()>, - pub(super) peer_addr: SocketAddr, + pub(super) result: DownloadTransferResult<()>, + pub(super) peer_endpoint: PeerEndpoint, } -/// Resolves which peers have a specific file. -pub(super) fn resolve_file_peers<'a>( - relative_path: &str, - file_peer_map: &'a HashMap>, - fallback: &'a [SocketAddr], -) -> &'a [SocketAddr] { - if let Some(peers) = file_peer_map.get(relative_path) - && !peers.is_empty() - { - return peers; +#[derive(Debug, Eq, Hash, PartialEq)] +struct DownloadChunkKey { + content_id: ContentId, + canonical_path: CanonicalCatalogPath, + offset: u64, + length: u64, +} + +impl From<&DownloadChunk> for DownloadChunkKey { + fn from(chunk: &DownloadChunk) -> Self { + Self { + content_id: chunk.content_id, + canonical_path: chunk.canonical_path().clone(), + offset: chunk.offset, + length: chunk.length, + } + } +} + +/// Reconciles one transport response against the exact planned chunk-key set. +/// +/// Returning successfully proves that every planned key has exactly one result +/// and that the transport did not introduce an unplanned key. +pub(super) fn reconcile_chunk_results( + planned: Vec, + results: Vec, + expected_endpoint: PeerEndpoint, + chunk_for: F, + phase: &str, +) -> eyre::Result> +where + F: for<'a> Fn(&'a T) -> &'a DownloadChunk, +{ + let mut planned_by_key = HashMap::with_capacity(planned.len()); + for planned_item in planned { + let key = DownloadChunkKey::from(chunk_for(&planned_item)); + if planned_by_key.insert(key, planned_item).is_some() { + eyre::bail!("{phase} plan contains a duplicate chunk key"); + } } - fallback + let mut reconciled = Vec::with_capacity(results.len()); + for result in results { + if result.peer_endpoint != expected_endpoint { + eyre::bail!( + "{phase} transport attributed a chunk result to unexpected endpoint {} at {} instead of {} at {}", + result.peer_endpoint.peer_id, + result.peer_endpoint.addr, + expected_endpoint.peer_id, + expected_endpoint.addr, + ); + } + let key = DownloadChunkKey::from(&result.chunk); + let planned_item = planned_by_key.remove(&key).ok_or_else(|| { + eyre::eyre!("{phase} transport returned an unplanned or duplicate chunk result") + })?; + reconciled.push((planned_item, result)); + } + + if !planned_by_key.is_empty() { + eyre::bail!("{phase} transport omitted one or more planned chunk results"); + } + Ok(reconciled) } -/// Builds download plans distributing files across peers. +/// Builds a catalog-owned plan across the caller's eligible source set. pub(super) fn build_peer_plans( - peers: &[SocketAddr], - file_descs: &[ValidatedDownloadEntry], - file_peer_map: &HashMap>, -) -> HashMap { - let mut plans: HashMap = HashMap::new(); + peers: &[PeerEndpoint], + manifest: &ValidatedDownloadManifest, +) -> eyre::Result> { + let mut plans: HashMap = HashMap::new(); + let chunk_size = manifest.catalog_manifest().chunk_size(); + let content_id = manifest.content_id(); if peers.is_empty() { - return plans; + return Ok(plans); } - let mut planned_bytes: HashMap = HashMap::new(); + let mut planned_bytes: HashMap = HashMap::new(); let mut tie_breaker = 0usize; - for desc in file_descs.iter().filter(|entry| !entry.is_dir()) { + for desc in manifest.entries().iter().filter(|entry| !entry.is_dir()) { let size = desc.size(); - let eligible_peers = resolve_file_peers(desc.protocol_path(), file_peer_map, peers); - if eligible_peers.is_empty() { - continue; - } if size == 0 { - let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker); + let peer = select_least_loaded_peer(peers, &planned_bytes, &mut tie_breaker); *planned_bytes.entry(peer).or_default() += 1; plans.entry(peer).or_default().chunks.push(DownloadChunk { - request_path: desc.protocol_path().to_owned(), + content_id, destination: desc.destination().clone(), offset: 0, length: 0, - retry_count: 0, - last_peer: Some(peer), + expected_blake3: manifest.expected_blake3(desc, None)?, }); continue; } let mut offset = 0u64; + let mut chunk_index = 0usize; while offset < size { - let length = std::cmp::min(CHUNK_SIZE, size - offset); - let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker); + let length = std::cmp::min(chunk_size, size - offset); + let peer = select_least_loaded_peer(peers, &planned_bytes, &mut tie_breaker); *planned_bytes.entry(peer).or_default() += length; plans.entry(peer).or_default().chunks.push(DownloadChunk { - request_path: desc.protocol_path().to_owned(), + content_id, destination: desc.destination().clone(), offset, length, - retry_count: 0, - last_peer: Some(peer), + expected_blake3: manifest.expected_blake3(desc, Some(chunk_index))?, }); offset += length; + chunk_index += 1; } } - plans + Ok(plans) } fn select_least_loaded_peer( - eligible_peers: &[SocketAddr], - planned_bytes: &HashMap, + eligible_peers: &[PeerEndpoint], + planned_bytes: &HashMap, tie_breaker: &mut usize, -) -> SocketAddr { +) -> PeerEndpoint { let start = *tie_breaker % eligible_peers.len(); *tie_breaker = (*tie_breaker).wrapping_add(1); @@ -123,60 +189,128 @@ fn select_least_loaded_peer( #[cfg(test)] mod tests { - use super::*; + use std::{net::SocketAddr, sync::Arc}; - fn file(protocol_path: &str, size: u64) -> ValidatedDownloadEntry { - let canonical_path = protocol_path.strip_prefix("game/").unwrap_or(protocol_path); - ValidatedDownloadEntry::test_file(protocol_path, canonical_path, size) + use lanspread_db::content_manifest::{ + Blake3Digest, + CATALOG_CHUNK_SIZE, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + }; + + use super::*; + use crate::test_support::TempDir; + + fn catalog_file( + canonical_path: &str, + size: u64, + chunk_blake3: Vec, + ) -> CatalogFileEntry { + let file_blake3 = match chunk_blake3.as_slice() { + [] => Blake3Digest::hash(&[]), + [only] => *only, + _ => Blake3Digest::from_bytes([0xf0; 32]), + }; + CatalogFileEntry::file(canonical_path, size, file_blake3, chunk_blake3) + .expect("catalog test file should validate") } - fn loopback_addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn validated_manifest( + mut files: Vec, + ) -> (TempDir, ValidatedDownloadManifest) { + let version_digest = Blake3Digest::hash(b"1"); + files.push( + CatalogFileEntry::file("version.ini", 1, version_digest, vec![version_digest]) + .expect("version.ini should validate"), + ); + files.sort_by(|left, right| { + left.canonical_path() + .as_str() + .cmp(right.canonical_path().as_str()) + }); + let catalog = Arc::new( + CatalogContentManifest::seal( + CatalogContentManifestBody::new("game", "1", files, Vec::new()) + .expect("catalog body should validate"), + ) + .expect("catalog manifest should seal"), + ); + let temp = TempDir::new("lanspread-planning"); + let manifest = ValidatedDownloadManifest::from_catalog(temp.path(), catalog) + .expect("catalog download manifest should validate"); + (temp, manifest) + } + + fn peer_endpoint(port: u16) -> PeerEndpoint { + PeerEndpoint::new( + lanspread_proto::PeerId::from_bytes(*blake3::hash(&port.to_le_bytes()).as_bytes()), + SocketAddr::from(([127, 0, 0, 1], port)), + ) } #[test] fn build_peer_plans_handles_partial_final_chunk() { - let peers = vec![loopback_addr(12000), loopback_addr(12001)]; - let file_size = CHUNK_SIZE * 2 + CHUNK_SIZE / 4; - let mut file_peer_map = HashMap::new(); - file_peer_map.insert("game/file.dat".to_string(), peers.clone()); - let file_descs = vec![file("game/file.dat", file_size)]; + let peers = vec![peer_endpoint(12000), peer_endpoint(12001)]; + let file_size = CATALOG_CHUNK_SIZE * 2 + CATALOG_CHUNK_SIZE / 4; + let expected = [ + Blake3Digest::from_bytes([1; 32]), + Blake3Digest::from_bytes([2; 32]), + Blake3Digest::from_bytes([3; 32]), + ]; + let (_temp, manifest) = + validated_manifest(vec![catalog_file("file.dat", file_size, expected.to_vec())]); - let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); - let mut chunks: Vec<_> = plans.values().flat_map(|plan| plan.chunks.iter()).collect(); + let plans = build_peer_plans(&peers, &manifest).expect("catalog plan should build"); + let mut chunks: Vec<_> = plans + .values() + .flat_map(|plan| plan.chunks.iter()) + .filter(|chunk| chunk.canonical_path().as_str() == "file.dat") + .collect(); assert_eq!(chunks.len(), 3, "expected three chunks for 2.25 blocks"); chunks.sort_by_key(|chunk| chunk.offset); let last_chunk = chunks.last().expect("last chunk exists"); - assert_eq!(last_chunk.offset, CHUNK_SIZE * 2); + assert_eq!(last_chunk.offset, CATALOG_CHUNK_SIZE * 2); assert_eq!(last_chunk.length, file_size - last_chunk.offset); - assert_eq!(last_chunk.length, CHUNK_SIZE / 4); + assert_eq!(last_chunk.length, CATALOG_CHUNK_SIZE / 4); assert_eq!( last_chunk.offset + last_chunk.length, file_size, "last chunk should finish the file" ); + assert_eq!( + chunks + .iter() + .map(|chunk| chunk.expected_blake3()) + .collect::>(), + expected, + "each chunk should resolve the digest at its retained catalog index" + ); } #[test] fn build_peer_plans_spreads_large_file_chunks_across_shared_peers() { - let peers = vec![loopback_addr(12000), loopback_addr(12001)]; - let large_file = "game/large.eti"; - let file_size = CHUNK_SIZE * 3 + CHUNK_SIZE / 2; - let mut file_peer_map = HashMap::new(); - file_peer_map.insert("game/version.ini".to_string(), peers.clone()); - file_peer_map.insert(large_file.to_string(), peers.clone()); - let file_descs = vec![file("game/version.ini", 9), file(large_file, file_size)]; + let peers = vec![peer_endpoint(12000), peer_endpoint(12001)]; + let large_file = "large.eti"; + let file_size = CATALOG_CHUNK_SIZE * 3 + CATALOG_CHUNK_SIZE / 2; + let (_temp, manifest) = validated_manifest(vec![catalog_file( + "large.eti", + file_size, + (1_u8..=4) + .map(|seed| Blake3Digest::from_bytes([seed; 32])) + .collect(), + )]); - let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); + let plans = build_peer_plans(&peers, &manifest).expect("catalog plan should build"); let mut chunk_counts = HashMap::new(); let mut byte_counts = HashMap::new(); for (peer, plan) in plans { for chunk in plan.chunks { - if chunk.request_path == large_file { + if chunk.canonical_path().as_str() == large_file { *chunk_counts.entry(peer).or_insert(0usize) += 1; *byte_counts.entry(peer).or_insert(0u64) += chunk.length; } @@ -192,7 +326,7 @@ mod tests { ]; assert_eq!(assigned_bytes.iter().sum::(), file_size); assert!( - assigned_bytes[0].abs_diff(assigned_bytes[1]) <= CHUNK_SIZE, + assigned_bytes[0].abs_diff(assigned_bytes[1]) <= CATALOG_CHUNK_SIZE, "large file bytes should be balanced within one chunk: {} vs {}", assigned_bytes[0], assigned_bytes[1] @@ -200,47 +334,172 @@ mod tests { } #[test] - fn build_peer_plans_respects_file_peer_map() { - let shared_a = loopback_addr(12010); - let shared_b = loopback_addr(12011); - let exclusive = loopback_addr(12012); - let peers = vec![shared_a, shared_b, exclusive]; - - let mut file_peer_map = HashMap::new(); - file_peer_map.insert("shared.bin".to_string(), vec![shared_a, shared_b]); - file_peer_map.insert("exclusive.bin".to_string(), vec![exclusive]); - - let file_descs = vec![ - file("shared.bin", CHUNK_SIZE * 2), - file("exclusive.bin", CHUNK_SIZE), + fn build_peer_plans_uses_the_complete_exact_content_source_set() { + let peers = vec![ + peer_endpoint(12010), + peer_endpoint(12011), + peer_endpoint(12012), ]; - let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); - let exclusive_plan = plans - .get(&exclusive) - .expect("exclusive peer should have a plan"); - assert!( - exclusive_plan - .chunks - .iter() - .all(|chunk| chunk.request_path == "exclusive.bin"), - "exclusive peer should only receive exclusive.bin chunks" - ); + let (_temp, manifest) = validated_manifest(vec![ + catalog_file( + "first.bin", + CATALOG_CHUNK_SIZE, + vec![Blake3Digest::from_bytes([1; 32])], + ), + catalog_file( + "second.bin", + CATALOG_CHUNK_SIZE * 2, + vec![ + Blake3Digest::from_bytes([2; 32]), + Blake3Digest::from_bytes([3; 32]), + ], + ), + ]); - for (peer, plan) in plans { - for chunk in plan.chunks { - match chunk.request_path.as_str() { - "exclusive.bin" => assert_eq!( - peer, exclusive, - "exclusive.bin chunks should only be assigned to the exclusive peer" - ), - "shared.bin" => assert!( - peer == shared_a || peer == shared_b, - "shared.bin chunks must stay within shared peers" - ), + let plans = build_peer_plans(&peers, &manifest).expect("catalog plan should build"); + + for (peer, plan) in &plans { + assert!(peers.contains(peer), "only an eligible source may be used"); + for chunk in &plan.chunks { + match chunk.canonical_path().as_str() { + "first.bin" | "second.bin" | "version.ini" => {} other => panic!("unexpected file in plan: {other}"), } } } + assert_eq!( + plans.values().map(|plan| plan.chunks.len()).sum::(), + 4, + "every catalog file and chunk should be planned exactly once" + ); + assert!( + peers.iter().all(|peer| plans.contains_key(peer)), + "load balancing should use every exact-content source" + ); + } + + #[test] + fn zero_byte_file_is_planned_with_its_catalog_file_digest() { + let peer = peer_endpoint(12020); + let empty_digest = Blake3Digest::hash(&[]); + let (_temp, manifest) = validated_manifest(vec![catalog_file("empty.bin", 0, Vec::new())]); + + let plans = build_peer_plans(&[peer], &manifest).expect("catalog plan should build"); + let empty = plans[&peer] + .chunks + .iter() + .find(|chunk| chunk.canonical_path().as_str() == "empty.bin") + .expect("empty file should still have one verification chunk"); + + assert_eq!(empty.length, 0); + assert_eq!(empty.expected_blake3(), empty_digest); + } + + #[test] + fn planned_digest_references_outlive_the_download_manifest() { + let peer = peer_endpoint(12021); + let expected = Blake3Digest::from_bytes([9; 32]); + let (temp, manifest) = + validated_manifest(vec![catalog_file("archive.eti", 1, vec![expected])]); + let plans = build_peer_plans(&[peer], &manifest).expect("catalog plan should build"); + + drop(manifest); + drop(temp); + + let archive = plans[&peer] + .chunks + .iter() + .find(|chunk| chunk.canonical_path().as_str() == "archive.eti") + .expect("archive chunk should remain planned"); + assert_eq!(archive.expected_blake3(), expected); + } + + #[test] + fn result_reconciliation_rejects_an_omitted_initial_chunk() { + let peer = peer_endpoint(12023); + let (_temp, manifest) = validated_manifest(vec![catalog_file( + "archive.eti", + 1, + vec![Blake3Digest::from_bytes([7; 32])], + )]); + let mut plans = build_peer_plans(&[peer], &manifest).expect("catalog plan should build"); + let planned = plans.remove(&peer).expect("peer should have a plan").chunks; + + let error = + reconcile_chunk_results(planned, Vec::new(), peer, |chunk| chunk, "initial download") + .expect_err("an omitted result must fail closed"); + + assert!(error.to_string().contains("omitted")); + } + + #[test] + fn result_reconciliation_rejects_a_duplicate_initial_chunk() { + let peer = peer_endpoint(12024); + let (_temp, manifest) = validated_manifest(vec![catalog_file( + "archive.eti", + 1, + vec![Blake3Digest::from_bytes([8; 32])], + )]); + let mut plans = build_peer_plans(&[peer], &manifest).expect("catalog plan should build"); + let planned = plans.remove(&peer).expect("peer should have a plan").chunks; + let duplicate = planned + .first() + .expect("plan should contain a chunk") + .clone(); + let results = vec![ + ChunkDownloadResult { + chunk: duplicate.clone(), + result: Ok(()), + peer_endpoint: peer, + }, + ChunkDownloadResult { + chunk: duplicate, + result: Ok(()), + peer_endpoint: peer, + }, + ]; + + let error = + reconcile_chunk_results(planned, results, peer, |chunk| chunk, "initial download") + .expect_err("a duplicate result must fail closed"); + + assert!(error.to_string().contains("duplicate")); + } + + #[test] + fn result_reconciliation_rejects_endpoint_misattribution() { + let expected = peer_endpoint(12025); + let unexpected = peer_endpoint(12026); + let (_temp, manifest) = validated_manifest(vec![catalog_file( + "archive.eti", + 1, + vec![Blake3Digest::from_bytes([9; 32])], + )]); + let mut plans = + build_peer_plans(&[expected], &manifest).expect("catalog plan should build"); + let planned = plans + .remove(&expected) + .expect("peer should have a plan") + .chunks; + let result_chunk = planned + .first() + .expect("plan should contain a chunk") + .clone(); + + let error = reconcile_chunk_results( + planned, + vec![ChunkDownloadResult { + chunk: result_chunk, + result: Ok(()), + peer_endpoint: unexpected, + }], + expected, + |chunk| chunk, + "initial download", + ) + .expect_err("endpoint mismatch must fail closed"); + + assert!(error.to_string().contains("unexpected endpoint")); } } diff --git a/crates/lanspread-peer/src/download/progress.rs b/crates/lanspread-peer/src/download/progress.rs index 43d4c05..cf7525f 100644 --- a/crates/lanspread-peer/src/download/progress.rs +++ b/crates/lanspread-peer/src/download/progress.rs @@ -1,7 +1,6 @@ use std::{ collections::HashMap, future::Future, - net::SocketAddr, sync::{ Arc, Mutex, @@ -10,18 +9,18 @@ use std::{ time::{Duration, Instant}, }; -use tokio::{ - sync::mpsc::UnboundedSender, - time::{self, MissedTickBehavior}, -}; +use lanspread_db::content_manifest::{CanonicalCatalogPath, ContentId}; +use lanspread_proto::{PeerEndpoint, PeerId}; +use tokio::time::{self, MissedTickBehavior}; -use crate::{DownloadProgress, PeerEvent, events}; +use crate::{DownloadAttemptKey, DownloadProgress, transfer_status::DownloadAttemptReporter}; const DOWNLOAD_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(500); #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct ChunkProgressKey { - relative_path: String, + content_id: ContentId, + canonical_path: CanonicalCatalogPath, offset: u64, } @@ -30,7 +29,7 @@ pub(super) struct DownloadProgressTracker { downloaded_bytes: AtomicU64, transferred_bytes: AtomicU64, chunks: Mutex>, - active_peers: Mutex>, + active_peers: Mutex>, } impl DownloadProgressTracker { @@ -46,18 +45,20 @@ impl DownloadProgressTracker { pub(super) fn track_chunk( self: &Arc, - peer_addr: SocketAddr, - relative_path: &str, + peer_endpoint: PeerEndpoint, + content_id: ContentId, + canonical_path: &CanonicalCatalogPath, offset: u64, expected_bytes: u64, ) -> ChunkProgress { ChunkProgress { tracker: self.clone(), key: ChunkProgressKey { - relative_path: relative_path.to_string(), + content_id, + canonical_path: canonical_path.clone(), offset, }, - _peer_activity: self.track_active_peer(peer_addr), + _peer_activity: self.track_active_peer(peer_endpoint.peer_id), expected_bytes, received_bytes: 0, } @@ -109,32 +110,32 @@ impl DownloadProgressTracker { } } - fn track_active_peer(self: &Arc, peer_addr: SocketAddr) -> ActivePeerDownload { + fn track_active_peer(self: &Arc, peer_id: PeerId) -> ActivePeerDownload { { let mut active_peers = self .active_peers .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - *active_peers.entry(peer_addr).or_default() += 1; + *active_peers.entry(peer_id).or_default() += 1; } ActivePeerDownload { tracker: self.clone(), - peer_addr, + peer_id, } } - fn finish_active_peer(&self, peer_addr: SocketAddr) { + fn finish_active_peer(&self, peer_id: PeerId) { let mut active_peers = self .active_peers .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let Some(count) = active_peers.get_mut(&peer_addr) else { + let Some(count) = active_peers.get_mut(&peer_id) else { return; }; if *count <= 1 { - active_peers.remove(&peer_addr); + active_peers.remove(&peer_id); } else { *count -= 1; } @@ -147,9 +148,9 @@ impl DownloadProgressTracker { .len() } - fn snapshot(&self, id: &str, bytes_per_second: u64) -> DownloadProgress { + fn snapshot(&self, attempt: &DownloadAttemptKey, bytes_per_second: u64) -> DownloadProgress { DownloadProgress { - id: id.to_string(), + attempt: attempt.clone(), downloaded_bytes: self.reported_downloaded_bytes(), total_bytes: self.total_bytes, bytes_per_second, @@ -183,12 +184,12 @@ impl ChunkProgress { struct ActivePeerDownload { tracker: Arc, - peer_addr: SocketAddr, + peer_id: PeerId, } impl Drop for ActivePeerDownload { fn drop(&mut self) { - self.tracker.finish_active_peer(self.peer_addr); + self.tracker.finish_active_peer(self.peer_id); } } @@ -199,23 +200,17 @@ fn add_saturating(counter: &AtomicU64, delta: u64) { } struct ProgressSampler { - id: String, + attempt: DownloadAttemptReporter, tracker: Arc, - tx_notify_ui: UnboundedSender, last_bytes: u64, last_at: Instant, } impl ProgressSampler { - fn new( - id: String, - tracker: Arc, - tx_notify_ui: UnboundedSender, - ) -> Self { + fn new(attempt: DownloadAttemptReporter, tracker: Arc) -> Self { Self { - id, + attempt, tracker, - tx_notify_ui, last_bytes: 0, last_at: Instant::now(), } @@ -241,10 +236,8 @@ impl ProgressSampler { } fn emit(&self, bytes_per_second: u64) { - events::send( - &self.tx_notify_ui, - PeerEvent::DownloadGameFilesProgress(self.tracker.snapshot(&self.id, bytes_per_second)), - ); + self.attempt + .emit_progress(self.tracker.snapshot(self.attempt.key(), bytes_per_second)); } } @@ -255,15 +248,14 @@ fn bytes_per_second(bytes: u64, elapsed: Duration) -> u64 { } pub(super) async fn sample_download_progress( - id: &str, + attempt: DownloadAttemptReporter, tracker: Arc, - tx_notify_ui: UnboundedSender, future: F, ) -> T where F: Future, { - let mut sampler = ProgressSampler::new(id.to_string(), tracker, tx_notify_ui); + let mut sampler = ProgressSampler::new(attempt, tracker); sampler.emit_initial(); let mut interval = time::interval(DOWNLOAD_PROGRESS_UPDATE_INTERVAL); @@ -284,21 +276,32 @@ where #[cfg(test)] mod tests { + use std::net::SocketAddr; + use super::*; - fn loopback_addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn endpoint(seed: u8, port: u16) -> PeerEndpoint { + PeerEndpoint::new( + PeerId::from_bytes([seed; 32]), + SocketAddr::from(([127, 0, 0, 1], port)), + ) + } + + fn path(value: &str) -> CanonicalCatalogPath { + CanonicalCatalogPath::new(value).expect("test path should be canonical") } #[test] fn tracker_counts_only_new_bytes_for_a_retried_chunk() { let tracker = DownloadProgressTracker::new(100); - let peer = loopback_addr(12000); - let mut first_attempt = tracker.track_chunk(peer, "game/file.bin", 0, 100); + let peer = endpoint(1, 12000); + let content_id = ContentId::from_bytes([2; 32]); + let path = path("file.bin"); + let mut first_attempt = tracker.track_chunk(peer, content_id, &path, 0, 100); first_attempt.record_bytes(40); first_attempt.record_bytes(10); - let mut retry = tracker.track_chunk(peer, "game/file.bin", 0, 100); + let mut retry = tracker.track_chunk(peer, content_id, &path, 0, 100); retry.record_bytes(25); retry.record_bytes(50); @@ -306,10 +309,38 @@ mod tests { assert_eq!(tracker.raw_transferred_bytes(), 125); } + #[test] + fn tracker_keys_progress_by_exact_content_and_canonical_path() { + let tracker = DownloadProgressTracker::new(100); + let peer = endpoint(1, 12000); + let first_content = ContentId::from_bytes([2; 32]); + let second_content = ContentId::from_bytes([3; 32]); + let first_path = path("first.bin"); + let second_path = path("second.bin"); + + tracker + .track_chunk(peer, first_content, &first_path, 0, 100) + .record_bytes(10); + tracker + .track_chunk(peer, second_content, &first_path, 0, 100) + .record_bytes(20); + tracker + .track_chunk(peer, first_content, &second_path, 0, 100) + .record_bytes(30); + + assert_eq!(tracker.reported_downloaded_bytes(), 60); + } + #[test] fn tracker_clamps_reported_bytes_to_total() { let tracker = DownloadProgressTracker::new(10); - let mut chunk = tracker.track_chunk(loopback_addr(12000), "game/file.bin", 0, 0); + let mut chunk = tracker.track_chunk( + endpoint(1, 12000), + ContentId::from_bytes([2; 32]), + &path("file.bin"), + 0, + 0, + ); chunk.record_bytes(25); assert_eq!(tracker.raw_downloaded_bytes(), 25); @@ -319,17 +350,22 @@ mod tests { #[test] fn tracker_reports_unique_active_peer_count() { let tracker = DownloadProgressTracker::new(100); - let first_peer = loopback_addr(12000); - let second_peer = loopback_addr(12001); + let first_peer = endpoint(1, 12000); + let moved_first_peer = endpoint(1, 12002); + let second_peer = endpoint(2, 12001); + let content_id = ContentId::from_bytes([3; 32]); + let file = path("file.bin"); + let other = path("other.bin"); + let attempt = DownloadAttemptKey::next("game".to_owned()); { - let _first_chunk = tracker.track_chunk(first_peer, "game/file.bin", 0, 50); - let _second_chunk = tracker.track_chunk(first_peer, "game/file.bin", 50, 50); - let _third_chunk = tracker.track_chunk(second_peer, "game/other.bin", 0, 10); + let _first_chunk = tracker.track_chunk(first_peer, content_id, &file, 0, 50); + let _second_chunk = tracker.track_chunk(moved_first_peer, content_id, &file, 50, 50); + let _third_chunk = tracker.track_chunk(second_peer, content_id, &other, 0, 10); - assert_eq!(tracker.snapshot("game", 0).active_peer_count, 2); + assert_eq!(tracker.snapshot(&attempt, 0).active_peer_count, 2); } - assert_eq!(tracker.snapshot("game", 0).active_peer_count, 0); + assert_eq!(tracker.snapshot(&attempt, 0).active_peer_count, 0); } } diff --git a/crates/lanspread-peer/src/download/retry.rs b/crates/lanspread-peer/src/download/retry.rs index cf68c82..9138cf5 100644 --- a/crates/lanspread-peer/src/download/retry.rs +++ b/crates/lanspread-peer/src/download/retry.rs @@ -1,44 +1,75 @@ use std::{ - collections::{HashMap, VecDeque}, - net::SocketAddr, + collections::{HashMap, HashSet, VecDeque}, sync::Arc, }; use futures::stream::FuturesUnordered; +use lanspread_db::content_manifest::ContentId; +use lanspread_proto::PeerEndpoint; use tokio_util::sync::CancellationToken; use super::{ + DownloadTransferError, + DownloadTransferErrorKind, confined_fs::ConfinedGameRoot, - planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan, resolve_file_peers}, + planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan, reconcile_chunk_results}, progress::DownloadProgressTracker, task_drain::collect_or_drain_on_cancel, - transport::download_from_peer, + transport::{PeerDownloadRequest, download_from_peer}, version_ini::VersionIniBuffer, }; -use crate::config::MAX_RETRY_COUNT; +use crate::{ + DownloadVerificationActivity, + content_quarantine::ContentQuarantine, + peer_db::PeerId, + quic_runtime::QuicConnector, + transfer_status::DownloadAttemptReporter, +}; -/// Selects a peer for retrying a failed chunk. -fn select_retry_peer(peers: &[SocketAddr], last_peer: Option) -> Option { - if peers.is_empty() { - return None; - } - - if peers.len() > 1 - && let Some(last) = last_peer - && let Some(pos) = peers.iter().position(|addr| *addr == last) - { - let next_index = (pos + 1) % peers.len(); - return Some(peers[next_index]); - } - - peers.first().copied() +/// One failed chunk plus the exact authenticated sources already attempted. +/// +/// Transport addresses deliberately do not participate in retry identity. A +/// peer that rotates its address is still one attempted source for this chunk. +#[derive(Debug)] +pub(super) struct RetryChunk { + chunk: DownloadChunk, + attempted_peer_ids: HashSet, + last_source: PeerEndpoint, + last_error: DownloadTransferError, } -/// Returns a fallback peer address for error reporting. -fn fallback_peer_addr(peers: &[SocketAddr], last_peer: Option) -> SocketAddr { - last_peer - .or_else(|| peers.first().copied()) - .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))) +impl RetryChunk { + pub(super) fn after_failure( + chunk: DownloadChunk, + source: PeerEndpoint, + error: DownloadTransferError, + ) -> Self { + Self { + chunk, + attempted_peer_ids: HashSet::from([source.peer_id]), + last_source: source, + last_error: error, + } + } +} + +pub(super) struct RetryContext<'a> { + pub(super) sources: &'a [PeerEndpoint], + pub(super) content_id: ContentId, + pub(super) quarantine: &'a ContentQuarantine, + pub(super) game_root: &'a ConfinedGameRoot, + pub(super) game_id: &'a str, + pub(super) cancel_token: &'a CancellationToken, + pub(super) quic: &'a QuicConnector, + pub(super) version_buffer: Arc, + pub(super) progress_tracker: Arc, + pub(super) attempt: DownloadAttemptReporter, +} + +struct RetryAttempt { + source: PeerEndpoint, + chunks: Vec, + result: Result, DownloadTransferError>, } fn ensure_not_cancelled(cancel_token: &CancellationToken, game_id: &str) -> eyre::Result<()> { @@ -48,96 +79,92 @@ fn ensure_not_cancelled(cancel_token: &CancellationToken, game_id: &str) -> eyre Ok(()) } -struct RetryAttempt { - peer_addr: SocketAddr, - chunks: Vec, - result: eyre::Result>, -} - -pub(super) struct RetryContext<'a> { - pub(super) peers: &'a [SocketAddr], - pub(super) game_root: &'a ConfinedGameRoot, - pub(super) game_id: &'a str, - pub(super) file_peer_map: &'a HashMap>, - pub(super) cancel_token: &'a CancellationToken, - pub(super) version_buffer: Option>, - pub(super) progress_tracker: Arc, +fn select_retry_source<'a>( + sources: &'a [PeerEndpoint], + attempted_peer_ids: &HashSet, + content_id: ContentId, + quarantine: &ContentQuarantine, +) -> Option<&'a PeerEndpoint> { + let mut seen_peer_ids = HashSet::new(); + sources.iter().find(|source| { + seen_peer_ids.insert(source.peer_id) + && !attempted_peer_ids.contains(&source.peer_id) + && !quarantine.is_quarantined(source, content_id) + }) } fn plan_retry_batch( - queue: &mut VecDeque, - peers: &[SocketAddr], - file_peer_map: &HashMap>, + queue: &mut VecDeque, + ctx: &RetryContext<'_>, final_results: &mut Vec, -) -> HashMap { - let mut retry_plans: HashMap = HashMap::new(); +) -> HashMap> { + let mut retry_plans: HashMap> = HashMap::new(); - while let Some(mut chunk) = queue.pop_front() { - let eligible_peers = resolve_file_peers(&chunk.request_path, file_peer_map, peers); - - if chunk.retry_count >= MAX_RETRY_COUNT { + while let Some(mut retry) = queue.pop_front() { + let Some(source) = select_retry_source( + ctx.sources, + &retry.attempted_peer_ids, + ctx.content_id, + ctx.quarantine, + ) else { final_results.push(ChunkDownloadResult { - chunk: chunk.clone(), - result: Err(eyre::eyre!( - "Retry budget exhausted for chunk: {}", - chunk.request_path - )), - peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), - }); - continue; - } - - let Some(peer_addr) = select_retry_peer(eligible_peers, chunk.last_peer) else { - final_results.push(ChunkDownloadResult { - chunk: chunk.clone(), - result: Err(eyre::eyre!( - "No peers available to retry chunk: {}", - chunk.request_path - )), - peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), + chunk: retry.chunk, + result: Err(retry.last_error), + peer_endpoint: retry.last_source, }); continue; }; - chunk.last_peer = Some(peer_addr); - retry_plans.entry(peer_addr).or_default().chunks.push(chunk); + if retry.last_error.kind() == DownloadTransferErrorKind::Integrity { + ctx.attempt + .set_activity(DownloadVerificationActivity::RetryingInvalidSource); + } + + retry.attempted_peer_ids.insert(source.peer_id); + retry.last_source = *source; + retry_plans.entry(*source).or_default().push(retry); } retry_plans } async fn run_retry_batch( - retry_plans: HashMap, + retry_plans: HashMap>, ctx: &RetryContext<'_>, ) -> eyre::Result> { let attempts = FuturesUnordered::new(); - for (peer_addr, plan) in retry_plans { + for (source, chunks) in retry_plans { if ctx.cancel_token.is_cancelled() { break; } - let retry_chunks = plan.chunks.clone(); + let plan = PeerDownloadPlan { + chunks: chunks.iter().map(|retry| retry.chunk.clone()).collect(), + }; let game_root = ctx.game_root.clone(); let game_id = ctx.game_id.to_string(); let cancel_token = ctx.cancel_token.clone(); let version_buffer = ctx.version_buffer.clone(); let progress_tracker = ctx.progress_tracker.clone(); + let quic = ctx.quic.clone(); + let endpoint = source; attempts.push(async move { - let result = download_from_peer( - peer_addr, - &game_id, + let result = download_from_peer(PeerDownloadRequest { + quic, + endpoint, + game_id, plan, game_root, - &cancel_token, + cancel_token, version_buffer, progress_tracker, - ) + }) .await; RetryAttempt { - peer_addr, - chunks: retry_chunks, + source, + chunks, result, } }); @@ -146,111 +173,154 @@ async fn run_retry_batch( collect_or_drain_on_cancel(attempts, ctx.cancel_token, ctx.game_id).await } -fn handle_retry_chunk_result( - result: ChunkDownloadResult, - queue: &mut VecDeque, +pub(super) fn quarantine_if_integrity_failure( + quarantine: &ContentQuarantine, + source: &PeerEndpoint, + content_id: ContentId, + error: &DownloadTransferError, +) { + if error.kind() == DownloadTransferErrorKind::Integrity { + quarantine.record_integrity_failure(source, content_id); + } +} + +struct RetryFailurePolicy<'a> { + content_id: ContentId, + quarantine: &'a ContentQuarantine, +} + +fn handle_retry_attempt_error( + source: &PeerEndpoint, + chunks: Vec, + error: &DownloadTransferError, + policy: &RetryFailurePolicy<'_>, + queue: &mut VecDeque, final_results: &mut Vec, ) { - let ChunkDownloadResult { - mut chunk, - result, - peer_addr, - } = result; + let kind = error.kind(); + quarantine_if_integrity_failure(policy.quarantine, source, policy.content_id, error); - match result { - Ok(()) => final_results.push(ChunkDownloadResult { - chunk, - result: Ok(()), - peer_addr, - }), - Err(err) => { - chunk.retry_count += 1; - chunk.last_peer = Some(peer_addr); - - if chunk.retry_count >= MAX_RETRY_COUNT { - let context = format!("Retry budget exhausted for chunk: {}", chunk.request_path); + for mut retry in chunks { + let error = error.clone(); + retry.last_source = *source; + match kind { + DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => { + retry.last_error = error; + queue.push_back(retry); + } + DownloadTransferErrorKind::LocalIo | DownloadTransferErrorKind::Cancelled => { final_results.push(ChunkDownloadResult { - chunk, - result: Err(err.wrap_err(context)), - peer_addr, + chunk: retry.chunk, + result: Err(error), + peer_endpoint: *source, }); - } else { - queue.push_back(chunk); } } } } -fn handle_retry_attempt_error( - peer_addr: SocketAddr, - chunks: Vec, - err: &eyre::Report, - queue: &mut VecDeque, +fn handle_retry_chunk_result( + mut retry: RetryChunk, + result: ChunkDownloadResult, + source: &PeerEndpoint, + ctx: &RetryContext<'_>, + queue: &mut VecDeque, final_results: &mut Vec, ) { - let error = err.to_string(); + let peer_endpoint = result.peer_endpoint; + match result.result { + Ok(()) => final_results.push(ChunkDownloadResult { + chunk: retry.chunk, + result: Ok(()), + peer_endpoint, + }), + Err(error) => { + quarantine_if_integrity_failure(ctx.quarantine, source, ctx.content_id, &error); + retry.last_source = *source; - for mut chunk in chunks { - chunk.retry_count += 1; - chunk.last_peer = Some(peer_addr); - - if chunk.retry_count >= MAX_RETRY_COUNT { - final_results.push(ChunkDownloadResult { - chunk: chunk.clone(), - result: Err(eyre::eyre!( - "Retry budget exhausted for chunk after connection failure: {}: {error}", - chunk.request_path - )), - peer_addr, - }); - } else { - queue.push_back(chunk); + match error.kind() { + DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => { + retry.last_error = error; + queue.push_back(retry); + } + DownloadTransferErrorKind::LocalIo | DownloadTransferErrorKind::Cancelled => { + final_results.push(ChunkDownloadResult { + chunk: retry.chunk, + result: Err(error), + peer_endpoint, + }); + } + } } } } -/// Retries downloading failed chunks. +fn handle_retry_attempt( + attempt: RetryAttempt, + ctx: &RetryContext<'_>, + queue: &mut VecDeque, + final_results: &mut Vec, +) -> eyre::Result<()> { + let RetryAttempt { + source, + chunks, + result, + } = attempt; + let results = match result { + Ok(results) => results, + Err(error) => { + let policy = RetryFailurePolicy { + content_id: ctx.content_id, + quarantine: ctx.quarantine, + }; + handle_retry_attempt_error(&source, chunks, &error, &policy, queue, final_results); + return Ok(()); + } + }; + + let expected_endpoint = source; + for (retry, result) in reconcile_chunk_results( + chunks, + results, + expected_endpoint, + |retry| &retry.chunk, + "retry", + )? { + handle_retry_chunk_result(retry, result, &source, ctx, queue, final_results); + } + Ok(()) +} + +/// Retries failed chunks against every eligible, nonquarantined peer identity. +/// +/// Each source is attempted at most once per chunk. There is no numeric retry +/// cap: terminal failure means the complete eligible source set was exhausted. pub(super) async fn retry_failed_chunks( - failed_chunks: Vec, + failed_chunks: Vec, ctx: &RetryContext<'_>, ) -> eyre::Result> { + if failed_chunks + .iter() + .any(|retry| retry.chunk.content_id != ctx.content_id) + { + eyre::bail!( + "retry plan content ID does not match requested catalog authority for game {}", + ctx.game_id + ); + } let mut final_results = Vec::new(); - let mut queue: VecDeque = failed_chunks.into_iter().collect(); + let mut queue: VecDeque = failed_chunks.into_iter().collect(); while !queue.is_empty() { ensure_not_cancelled(ctx.cancel_token, ctx.game_id)?; - let retry_plans = - plan_retry_batch(&mut queue, ctx.peers, ctx.file_peer_map, &mut final_results); + let retry_plans = plan_retry_batch(&mut queue, ctx, &mut final_results); if retry_plans.is_empty() { continue; } - let attempts = run_retry_batch(retry_plans, ctx).await?; - - for attempt in attempts { - let RetryAttempt { - peer_addr, - chunks, - result, - } = attempt; - - match result { - Ok(results) => { - for result in results { - handle_retry_chunk_result(result, &mut queue, &mut final_results); - } - } - Err(err) => { - handle_retry_attempt_error( - peer_addr, - chunks, - &err, - &mut queue, - &mut final_results, - ); - } - } + for attempt in run_retry_batch(retry_plans, ctx).await? { + handle_retry_attempt(attempt, ctx, &mut queue, &mut final_results)?; } } @@ -259,37 +329,206 @@ pub(super) async fn retry_failed_chunks( #[cfg(test)] mod tests { + use std::{net::SocketAddr, sync::Arc}; + + use lanspread_db::content_manifest::{ + Blake3Digest, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + }; + use super::*; + use crate::test_support::TempDir; - fn loopback_addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn source(peer_id: &str, port: u16) -> PeerEndpoint { + PeerEndpoint::new( + PeerId::from_bytes(*blake3::hash(peer_id.as_bytes()).as_bytes()), + SocketAddr::from(([127, 0, 0, 1], port)), + ) + } + + fn content(seed: u8) -> ContentId { + ContentId::from_bytes([seed; 32]) + } + + fn chunk() -> DownloadChunk { + let version_digest = Blake3Digest::hash(b"1"); + let catalog = Arc::new( + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "game", + "1", + vec![ + CatalogFileEntry::file( + "version.ini", + 1, + version_digest, + vec![version_digest], + ) + .expect("test catalog entry should validate"), + ], + Vec::new(), + ) + .expect("test catalog body should validate"), + ) + .expect("test catalog should seal"), + ); + let temp = TempDir::new("lanspread-retry-chunk"); + let manifest = + super::super::manifest::ValidatedDownloadManifest::from_catalog(temp.path(), catalog) + .expect("test download manifest should validate"); + let entry = manifest.version_entry(); + DownloadChunk { + content_id: manifest.content_id(), + destination: entry.destination().clone(), + offset: 0, + length: entry.size(), + expected_blake3: manifest + .expected_blake3(entry, Some(0)) + .expect("test chunk digest should exist"), + } } #[test] - fn retry_peer_selection_cycles_after_last_failed_peer() { - let peers = vec![ - loopback_addr(12000), - loopback_addr(12001), - loopback_addr(12002), - ]; + fn source_selection_exhausts_more_than_three_unique_peer_ids() { + let sources = (0_u16..5) + .map(|index| source(&format!("peer-{index}"), 12000 + index)) + .collect::>(); + let quarantine = ContentQuarantine::default(); + let content_id = content(1); + let mut attempted = HashSet::new(); + let mut selected = Vec::new(); - assert_eq!(select_retry_peer(&peers, Some(peers[0])), Some(peers[1])); - assert_eq!(select_retry_peer(&peers, Some(peers[1])), Some(peers[2])); - assert_eq!(select_retry_peer(&peers, Some(peers[2])), Some(peers[0])); + while let Some(source) = select_retry_source(&sources, &attempted, content_id, &quarantine) + { + attempted.insert(source.peer_id); + selected.push(source.peer_id); + } + + assert_eq!(selected.len(), 5); + assert_eq!(selected.first().copied(), Some(source("peer-0", 0).peer_id)); + assert_eq!(selected.last().copied(), Some(source("peer-4", 0).peer_id)); } #[test] - fn retry_peer_selection_uses_first_peer_without_prior_failure() { - let peers = vec![loopback_addr(12000), loopback_addr(12001)]; + fn newly_quarantined_source_is_skipped_before_the_next_selection() { + let bad = source("bad", 12000); + let good = source("good", 12001); + let sources = vec![bad, good]; + let quarantine = ContentQuarantine::default(); + let content_id = content(2); - assert_eq!(select_retry_peer(&peers, None), Some(peers[0])); + assert_eq!( + select_retry_source(&sources, &HashSet::new(), content_id, &quarantine), + Some(&bad) + ); + quarantine.record_integrity_failure(&bad, content_id); + + assert_eq!( + select_retry_source(&sources, &HashSet::new(), content_id, &quarantine), + Some(&good) + ); } #[test] - fn retry_peer_selection_wraps_between_two_peers() { - let peers = vec![loopback_addr(12000), loopback_addr(12001)]; + fn transport_and_local_errors_never_quarantine_content() { + let source = source("peer", 12000); + let content_id = content(3); - assert_eq!(select_retry_peer(&peers, Some(peers[0])), Some(peers[1])); - assert_eq!(select_retry_peer(&peers, Some(peers[1])), Some(peers[0])); + for error in [ + DownloadTransferError::transport("offline"), + DownloadTransferError::local_io("disk full"), + DownloadTransferError::cancelled("game"), + ] { + let quarantine = ContentQuarantine::default(); + quarantine_if_integrity_failure(&quarantine, &source, content_id, &error); + assert!(!quarantine.is_quarantined(&source, content_id)); + } + } + + #[test] + fn only_typed_integrity_error_quarantines_exact_peer_content_pair() { + let source = source("peer", 12000); + let content_id = content(4); + let quarantine = ContentQuarantine::default(); + let error = DownloadTransferError::integrity("bad hash"); + + quarantine_if_integrity_failure(&quarantine, &source, content_id, &error); + + assert!(quarantine.is_quarantined(&source, content_id)); + assert!(!quarantine.is_quarantined(&source, content(5))); + } + + #[test] + fn whole_attempt_transport_failure_requeues_every_planned_chunk() { + let initial = source("initial", 12000); + let retry_source = source("retry", 12001); + let content_id = content(6); + let quarantine = ContentQuarantine::default(); + let policy = RetryFailurePolicy { + content_id, + quarantine: &quarantine, + }; + let mut first = RetryChunk::after_failure( + chunk(), + initial, + DownloadTransferError::transport("initial failed"), + ); + first.attempted_peer_ids.insert(retry_source.peer_id); + let mut second = RetryChunk::after_failure( + chunk(), + initial, + DownloadTransferError::transport("initial failed"), + ); + second.attempted_peer_ids.insert(retry_source.peer_id); + let mut queue = VecDeque::new(); + let mut final_results = Vec::new(); + + handle_retry_attempt_error( + &retry_source, + vec![first, second], + &DownloadTransferError::transport("connection failed"), + &policy, + &mut queue, + &mut final_results, + ); + + assert_eq!(queue.len(), 2); + assert!(final_results.is_empty()); + assert!(queue.iter().all(|retry| retry.last_error.kind() + == DownloadTransferErrorKind::Transport + && retry.last_source == retry_source + && retry.attempted_peer_ids.contains(&retry_source.peer_id))); + assert!(!quarantine.is_quarantined(&retry_source, content_id)); + } + + #[test] + fn retry_reconciliation_rejects_endpoint_misattribution() { + let initial = source("initial", 12010); + let expected = source("expected", 12011); + let unexpected = source("unexpected", 12012); + let planned_chunk = chunk(); + let retry = RetryChunk::after_failure( + planned_chunk.clone(), + initial, + DownloadTransferError::transport("initial failed"), + ); + let expected_endpoint = PeerEndpoint::new(expected.peer_id, expected.addr); + + let error = reconcile_chunk_results( + vec![retry], + vec![ChunkDownloadResult { + chunk: planned_chunk, + result: Ok(()), + peer_endpoint: PeerEndpoint::new(unexpected.peer_id, unexpected.addr), + }], + expected_endpoint, + |retry| &retry.chunk, + "retry", + ) + .expect_err("retry endpoint mismatch must fail closed"); + + assert!(error.to_string().contains("unexpected endpoint")); } } diff --git a/crates/lanspread-peer/src/download/storage.rs b/crates/lanspread-peer/src/download/storage.rs index 6792e8b..67bfc1c 100644 --- a/crates/lanspread-peer/src/download/storage.rs +++ b/crates/lanspread-peer/src/download/storage.rs @@ -1,55 +1,59 @@ use super::{confined_fs::ConfinedGameRoot, manifest::ValidatedDownloadManifest}; /// Prepares storage for game files by creating directories and pre-allocating files. -pub(super) async fn prepare_game_storage( +pub(super) fn prepare_game_storage( manifest: &ValidatedDownloadManifest, game_root: &ConfinedGameRoot, ) -> eyre::Result<()> { - game_root - .prepare_entries(manifest.transfer_entries().cloned().collect()) - .await + game_root.prepare_entries(manifest.transfer_entries().cloned().collect()) } /// Makes payload bytes and directory entries durable before the sentinel commit. -pub(super) async fn sync_game_storage( +pub(super) fn sync_game_storage( manifest: &ValidatedDownloadManifest, game_root: &ConfinedGameRoot, ) -> eyre::Result<()> { - game_root - .sync_entries(manifest.transfer_entries().cloned().collect()) - .await + game_root.sync_entries(manifest.transfer_entries().cloned().collect()) } #[cfg(test)] mod tests { - use lanspread_db::db::{GameCatalog, GameFileDescription}; + use std::sync::Arc; + + use lanspread_db::content_manifest::{ + Blake3Digest, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + }; use super::*; use crate::test_support::TempDir; - #[tokio::test] - async fn prepare_game_storage_skips_version_ini_sentinel() { + #[test] + fn prepare_game_storage_skips_version_ini_sentinel() { let temp = TempDir::new("lanspread-download"); - let descs = vec![GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/version.ini".to_string(), - is_dir: false, - size: 8, - }]; - let manifest = ValidatedDownloadManifest::from_protocol_v7( - temp.path(), - "game", - descs, - &GameCatalog::from_ids(["game".to_owned()]), + let version = b"20250101"; + let digest = Blake3Digest::hash(version); + let catalog = CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "game", + "20250101", + vec![ + CatalogFileEntry::file("version.ini", 8, digest, vec![digest]) + .expect("version.ini entry should validate"), + ], + Vec::new(), + ) + .expect("catalog body should validate"), ) - .expect("manifest should validate"); + .expect("catalog manifest should seal"); + let manifest = ValidatedDownloadManifest::from_catalog(temp.path(), Arc::new(catalog)) + .expect("manifest should validate"); let game_root = ConfinedGameRoot::open_or_create(temp.path(), "game") - .await .expect("confined game root should open"); - prepare_game_storage(&manifest, &game_root) - .await - .expect("storage preparation should succeed"); + prepare_game_storage(&manifest, &game_root).expect("storage preparation should succeed"); assert!(!temp.path().join("game").join("version.ini").exists()); } diff --git a/crates/lanspread-peer/src/download/transfer_error.rs b/crates/lanspread-peer/src/download/transfer_error.rs new file mode 100644 index 0000000..f147d74 --- /dev/null +++ b/crates/lanspread-peer/src/download/transfer_error.rs @@ -0,0 +1,104 @@ +//! Typed failure boundary for ordinary catalog-content transfers. + +use std::fmt; + +/// Stable classification used by retry and source-quarantine policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DownloadTransferErrorKind { + /// The source supplied bytes or a byte count that disagrees with the local + /// catalog authority. + Integrity, + /// QUIC connection, request, or receive failure. + Transport, + /// Local filesystem or destination-buffer failure. + LocalIo, + /// The owning download operation was cancelled. + Cancelled, +} + +/// One ordinary-transfer failure with a policy-safe classification. +/// +/// Callers must branch on [`Self::kind`], never parse the diagnostic text. +#[derive(Clone, Debug)] +pub(crate) struct DownloadTransferError { + kind: DownloadTransferErrorKind, + message: String, +} + +impl DownloadTransferError { + #[must_use] + pub(crate) fn integrity(message: impl Into) -> Self { + Self::new(DownloadTransferErrorKind::Integrity, message) + } + + #[must_use] + pub(crate) fn transport(message: impl Into) -> Self { + Self::new(DownloadTransferErrorKind::Transport, message) + } + + #[must_use] + pub(crate) fn local_io(message: impl Into) -> Self { + Self::new(DownloadTransferErrorKind::LocalIo, message) + } + + #[must_use] + pub(crate) fn cancelled(game_id: &str) -> Self { + Self::new( + DownloadTransferErrorKind::Cancelled, + format!("download cancelled for game {game_id}"), + ) + } + + #[must_use] + pub(crate) const fn kind(&self) -> DownloadTransferErrorKind { + self.kind + } + + fn new(kind: DownloadTransferErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl fmt::Display for DownloadTransferError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for DownloadTransferError {} + +pub(crate) type DownloadTransferResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifications_are_explicit_and_text_independent() { + let cases = [ + ( + DownloadTransferError::integrity("same diagnostic"), + DownloadTransferErrorKind::Integrity, + ), + ( + DownloadTransferError::transport("same diagnostic"), + DownloadTransferErrorKind::Transport, + ), + ( + DownloadTransferError::local_io("same diagnostic"), + DownloadTransferErrorKind::LocalIo, + ), + ( + DownloadTransferError::cancelled("game"), + DownloadTransferErrorKind::Cancelled, + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.kind(), expected); + } + } +} diff --git a/crates/lanspread-peer/src/download/transport.rs b/crates/lanspread-peer/src/download/transport.rs index 9c063ac..43ee685 100644 --- a/crates/lanspread-peer/src/download/transport.rs +++ b/crates/lanspread-peer/src/download/transport.rs @@ -1,8 +1,17 @@ -use std::{collections::VecDeque, net::SocketAddr, sync::Arc}; +use std::{ + collections::VecDeque, + fs::File, + future::Future, + io::{Seek as _, SeekFrom}, + sync::Arc, + time::Duration, +}; use futures::{SinkExt, StreamExt, stream::FuturesUnordered}; +use lanspread_db::content_manifest::Blake3Digest; +use lanspread_proto::{ControlMessage, PeerEndpoint, Request}; use s2n_quic::{Connection, stream::ReceiveStream}; -use tokio::io::{AsyncSeekExt, AsyncWrite, AsyncWriteExt}; +use tokio::time::{self, Instant}; use tokio_util::{ codec::{FramedWrite, LengthDelimitedCodec}, sync::CancellationToken, @@ -12,20 +21,96 @@ use super::{ confined_fs::ConfinedGameRoot, planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan}, progress::DownloadProgressTracker, + transfer_error::{DownloadTransferError, DownloadTransferErrorKind, DownloadTransferResult}, version_ini::VersionIniBuffer, }; -use crate::{config::PEER_DOWNLOAD_STREAM_WINDOW, network::connect_to_peer}; +use crate::{ + config::PEER_DOWNLOAD_STREAM_WINDOW, + network::connect_to_peer, + quic_runtime::QuicConnector, + scoped_blocking::scoped_blocking, +}; + +const ORDINARY_CHUNK_TRANSFER_TIMEOUT: Duration = Duration::from_mins(10); fn ensure_download_not_cancelled( cancel_token: &CancellationToken, game_id: &str, -) -> eyre::Result<()> { +) -> DownloadTransferResult<()> { if cancel_token.is_cancelled() { - eyre::bail!("download cancelled for game {game_id}"); + return Err(DownloadTransferError::cancelled(game_id)); } Ok(()) } +/// An application-owned absolute deadline for one catalog chunk. +/// +/// QUIC keep-alive traffic is intentionally irrelevant here: only completing +/// this chunk before the deadline succeeds. `run` checks again after polling +/// the operation so finite synchronous work cannot outlive a reported timeout. +#[derive(Clone, Copy, Debug)] +struct ChunkDeadline { + expires_at: Instant, + timeout: Duration, +} + +impl ChunkDeadline { + fn ordinary() -> Self { + Self::after(ORDINARY_CHUNK_TRANSFER_TIMEOUT) + } + + fn after(timeout: Duration) -> Self { + Self { + expires_at: Instant::now() + timeout, + timeout, + } + } + + fn timeout_error(self, canonical_path: &str, offset: u64) -> DownloadTransferError { + let timeout = self.timeout; + DownloadTransferError::transport(format!( + "catalog chunk transfer timed out after {timeout:?} for {canonical_path} at offset {offset}" + )) + } + + fn ensure_active( + self, + cancel_token: &CancellationToken, + game_id: &str, + canonical_path: &str, + offset: u64, + ) -> DownloadTransferResult<()> { + ensure_download_not_cancelled(cancel_token, game_id)?; + if Instant::now() >= self.expires_at { + return Err(self.timeout_error(canonical_path, offset)); + } + Ok(()) + } + + async fn run( + self, + cancel_token: &CancellationToken, + game_id: &str, + canonical_path: &str, + offset: u64, + operation: impl Future, + ) -> DownloadTransferResult { + self.ensure_active(cancel_token, game_id, canonical_path, offset)?; + let result = tokio::select! { + biased; + () = cancel_token.cancelled() => { + return Err(DownloadTransferError::cancelled(game_id)); + } + () = time::sleep_until(self.expires_at) => { + return Err(self.timeout_error(canonical_path, offset)); + } + result = operation => result, + }; + self.ensure_active(cancel_token, game_id, canonical_path, offset)?; + Ok(result) + } +} + #[derive(Clone, Copy, Debug)] struct ReceiveBudget { expected: u64, @@ -40,29 +125,66 @@ impl ReceiveBudget { } } - fn accept(&mut self, byte_count: usize) -> eyre::Result<()> { - let byte_count = u64::try_from(byte_count)?; - self.received = self - .received - .checked_add(byte_count) - .ok_or_else(|| eyre::eyre!("received chunk byte count overflow"))?; + fn accept(&mut self, byte_count: usize) -> DownloadTransferResult<()> { + let byte_count = u64::try_from(byte_count).map_err(|error| { + DownloadTransferError::integrity(format!( + "received chunk frame length does not fit u64: {error}" + )) + })?; + self.received = self.received.checked_add(byte_count).ok_or_else(|| { + DownloadTransferError::integrity("received chunk byte count overflow") + })?; if self.received > self.expected { - eyre::bail!( + return Err(DownloadTransferError::integrity(format!( "peer sent too many chunk bytes: expected {}, received at least {}", - self.expected, - self.received - ); + self.expected, self.received + ))); } Ok(()) } - fn finish(self) -> eyre::Result<()> { + fn finish(self) -> DownloadTransferResult<()> { if self.received != self.expected { - eyre::bail!( + return Err(DownloadTransferError::integrity(format!( "incomplete chunk download: expected {} bytes, received {}", - self.expected, - self.received - ); + self.expected, self.received + ))); + } + Ok(()) + } +} + +#[derive(Debug)] +struct ChunkVerifier { + budget: ReceiveBudget, + hasher: blake3::Hasher, + expected_blake3: Blake3Digest, +} + +impl ChunkVerifier { + fn new(expected_length: u64, expected_blake3: Blake3Digest) -> Self { + Self { + budget: ReceiveBudget::new(expected_length), + hasher: blake3::Hasher::new(), + expected_blake3, + } + } + + fn accept(&mut self, bytes: &[u8]) -> DownloadTransferResult<()> { + // Hash the complete peer frame even when it proves to exceed the + // catalog-owned byte budget. No received byte bypasses verification. + self.hasher.update(bytes); + self.budget.accept(bytes.len()) + } + + fn finish(self, path: &str, offset: u64) -> DownloadTransferResult<()> { + self.budget.finish()?; + let actual = Blake3Digest::from_bytes(*self.hasher.finalize().as_bytes()); + if actual != self.expected_blake3 { + return Err(DownloadTransferError::integrity(format!( + "catalog BLAKE3 mismatch for {path} at offset {offset}: expected {}, received {actual}", + self.expected_blake3 + ))); } Ok(()) } @@ -73,59 +195,124 @@ async fn open_chunk_stream( game_id: &str, chunk: &DownloadChunk, cancel_token: &CancellationToken, -) -> eyre::Result { - use lanspread_proto::{Message, Request}; + deadline: ChunkDeadline, +) -> DownloadTransferResult { + let canonical_path = chunk.canonical_path(); - let stream = tokio::select! { - biased; - () = cancel_token.cancelled() => { - eyre::bail!("download cancelled for game {game_id}"); - } - result = conn.open_bidirectional_stream() => result?, - }; + let stream = deadline + .run( + cancel_token, + game_id, + canonical_path.as_str(), + chunk.offset, + conn.open_bidirectional_stream(), + ) + .await? + .map_err(|error| { + DownloadTransferError::transport(format!("failed to open chunk stream: {error}")) + })?; let (rx, tx) = stream.split(); let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); let request = Request::GetGameFileChunk { game_id: game_id.to_string(), - relative_path: chunk.request_path.clone(), + content_id: chunk.content_id, + relative_path: canonical_path.clone(), offset: chunk.offset, length: chunk.length, }; - tokio::select! { - biased; - () = cancel_token.cancelled() => { - eyre::bail!("download cancelled for game {game_id}"); - } - result = framed_tx.send(request.encode()) => result?, - } + let encoded_request = request.encode().map_err(|error| { + DownloadTransferError::transport(format!("failed to encode chunk request: {error}")) + })?; + deadline + .run( + cancel_token, + game_id, + canonical_path.as_str(), + chunk.offset, + framed_tx.send(encoded_request), + ) + .await? + .map_err(|error| { + DownloadTransferError::transport(format!("failed to send chunk request: {error}")) + })?; - tokio::select! { - biased; - () = cancel_token.cancelled() => { - eyre::bail!("download cancelled for game {game_id}"); - } - result = framed_tx.close() => result?, - } + deadline + .run( + cancel_token, + game_id, + canonical_path.as_str(), + chunk.offset, + framed_tx.close(), + ) + .await? + .map_err(|error| { + DownloadTransferError::transport(format!("failed to finish chunk request: {error}")) + })?; Ok(rx) } /// Receives one requested chunk from a peer stream. #[derive(Clone)] struct ChunkReceiveContext { - peer_addr: SocketAddr, + peer_endpoint: PeerEndpoint, game_root: ConfinedGameRoot, game_id: String, cancel_token: CancellationToken, - version_buffer: Option>, + version_buffer: Arc, progress_tracker: Arc, } -async fn flush_before_propagating( - writer: &mut (impl AsyncWrite + Unpin), - operation_result: eyre::Result, -) -> eyre::Result { - let flush_result = writer.flush().await; +fn ensure_chunk_active( + deadline: ChunkDeadline, + chunk: &DownloadChunk, + ctx: &ChunkReceiveContext, +) -> DownloadTransferResult<()> { + deadline.ensure_active( + &ctx.cancel_token, + &ctx.game_id, + chunk.canonical_path().as_str(), + chunk.offset, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ChunkSink { + RegularFile, + VersionBuffer, +} + +fn select_chunk_sink( + is_version_ini: bool, + canonical_path: &str, + version_buffer: &VersionIniBuffer, +) -> DownloadTransferResult { + match (is_version_ini, version_buffer.matches(canonical_path)) { + (false, false) => Ok(ChunkSink::RegularFile), + (true, true) => Ok(ChunkSink::VersionBuffer), + _ => Err(DownloadTransferError::local_io(format!( + "download plan and version.ini buffer disagree for {canonical_path}" + ))), + } +} + +fn seek_chunk_file(file: &mut File, offset: u64) -> std::io::Result<()> { + scoped_blocking(|| file.seek(SeekFrom::Start(offset)).map(|_| ())) +} + +/// Completes one bounded write in the caller's task so cancellation cannot +/// detach filesystem work onto Tokio's blocking pool. +fn write_chunk_bytes(writer: &mut impl std::io::Write, bytes: &[u8]) -> std::io::Result<()> { + scoped_blocking(|| writer.write_all(bytes)) +} + +fn flush_before_propagating( + writer: &mut impl std::io::Write, + operation_result: DownloadTransferResult, +) -> DownloadTransferResult { + let flush_result = scoped_blocking(|| writer.flush()).map_err(|error| { + DownloadTransferError::local_io(format!("failed to flush downloaded chunk: {error}")) + }); let value = operation_result?; flush_result?; Ok(value) @@ -135,51 +322,91 @@ async fn receive_chunk( mut rx: ReceiveStream, chunk: &DownloadChunk, ctx: &ChunkReceiveContext, -) -> eyre::Result<()> { - if let Some(buffer) = &ctx.version_buffer - && buffer.matches(&chunk.request_path) - { - return download_version_ini_chunk(rx, chunk, buffer, ctx).await; + deadline: ChunkDeadline, +) -> DownloadTransferResult<()> { + ensure_chunk_active(deadline, chunk, ctx)?; + match select_chunk_sink( + chunk.is_version_ini(), + chunk.canonical_path().as_str(), + &ctx.version_buffer, + )? { + ChunkSink::VersionBuffer => { + return download_version_ini_chunk(rx, chunk, &ctx.version_buffer, ctx, deadline).await; + } + ChunkSink::RegularFile => {} } - ensure_download_not_cancelled(&ctx.cancel_token, &ctx.game_id)?; - let mut file = - tokio::fs::File::from_std(ctx.game_root.open_chunk_file(&chunk.destination).await?); - file.seek(std::io::SeekFrom::Start(chunk.offset)).await?; - ensure_download_not_cancelled(&ctx.cancel_token, &ctx.game_id)?; + let mut file = ctx + .game_root + .open_chunk_file(&chunk.destination) + .map_err(|error| { + DownloadTransferError::local_io(format!( + "failed to open chunk destination {}: {error}", + chunk.destination.canonical() + )) + })?; + ensure_chunk_active(deadline, chunk, ctx)?; + seek_chunk_file(&mut file, chunk.offset).map_err(|error| { + DownloadTransferError::local_io(format!( + "failed to seek chunk destination {} to offset {}: {error}", + chunk.destination.canonical(), + chunk.offset + )) + })?; + ensure_chunk_active(deadline, chunk, ctx)?; - let mut receive_budget = ReceiveBudget::new(chunk.length); + let mut verifier = ChunkVerifier::new(chunk.length, chunk.expected_blake3()); let mut progress = ctx.progress_tracker.track_chunk( - ctx.peer_addr, - &chunk.request_path, + ctx.peer_endpoint, + chunk.content_id, + chunk.canonical_path(), chunk.offset, chunk.length, ); - let receive_result: eyre::Result<()> = async { + let receive_result: DownloadTransferResult<()> = async { loop { - let bytes = tokio::select! { - biased; - () = ctx.cancel_token.cancelled() => { - eyre::bail!("download cancelled for game {}", ctx.game_id); - } - result = rx.receive() => result?, - }; + let bytes = deadline + .run( + &ctx.cancel_token, + &ctx.game_id, + chunk.canonical_path().as_str(), + chunk.offset, + rx.receive(), + ) + .await? + .map_err(|error| { + DownloadTransferError::transport(format!( + "failed to receive chunk {} at offset {}: {error}", + chunk.canonical_path().as_str(), + chunk.offset + )) + })?; let Some(bytes) = bytes else { break; }; - receive_budget.accept(bytes.len())?; - file.write_all(&bytes).await?; + verifier.accept(&bytes)?; + ensure_chunk_active(deadline, chunk, ctx)?; + write_chunk_bytes(&mut file, &bytes).map_err(|error| { + DownloadTransferError::local_io(format!( + "failed to write chunk destination {} at offset {}: {error}", + chunk.destination.canonical(), + chunk.offset + )) + })?; progress.record_bytes(bytes.len()); + ensure_chunk_active(deadline, chunk, ctx)?; } - receive_budget.finish() + verifier.finish(chunk.canonical_path().as_str(), chunk.offset) } .await; - flush_before_propagating(&mut file, receive_result).await?; + flush_before_propagating(&mut file, receive_result)?; + ensure_chunk_active(deadline, chunk, ctx)?; // Verify file integrity by checking the file size - verify_chunk_integrity(&file, chunk.offset, chunk.length).await?; + verify_chunk_integrity(&file, chunk.offset, chunk.length)?; + ensure_chunk_active(deadline, chunk, ctx)?; Ok(()) } @@ -188,12 +415,13 @@ async fn receive_chunk_result( chunk: DownloadChunk, rx: ReceiveStream, ctx: ChunkReceiveContext, + deadline: ChunkDeadline, ) -> ChunkDownloadResult { - let result = receive_chunk(rx, &chunk, &ctx).await; + let result = receive_chunk(rx, &chunk, &ctx, deadline).await; ChunkDownloadResult { chunk, result, - peer_addr: ctx.peer_addr, + peer_endpoint: ctx.peer_endpoint, } } @@ -202,55 +430,88 @@ async fn download_version_ini_chunk( chunk: &DownloadChunk, buffer: &VersionIniBuffer, ctx: &ChunkReceiveContext, -) -> eyre::Result<()> { - let mut received = Vec::with_capacity(usize::try_from(chunk.length)?); - let mut receive_budget = ReceiveBudget::new(chunk.length); + deadline: ChunkDeadline, +) -> DownloadTransferResult<()> { + ensure_chunk_active(deadline, chunk, ctx)?; + let capacity = usize::try_from(chunk.length).map_err(|error| { + DownloadTransferError::local_io(format!( + "version.ini chunk length does not fit local memory: {error}" + )) + })?; + let mut received = Vec::with_capacity(capacity); + let mut verifier = ChunkVerifier::new(chunk.length, chunk.expected_blake3()); let mut progress = ctx.progress_tracker.track_chunk( - ctx.peer_addr, - &chunk.request_path, + ctx.peer_endpoint, + chunk.content_id, + chunk.canonical_path(), chunk.offset, chunk.length, ); loop { - let bytes = tokio::select! { - biased; - () = ctx.cancel_token.cancelled() => { - eyre::bail!("download cancelled for game {}", ctx.game_id); - } - result = rx.receive() => result?, - }; + let bytes = deadline + .run( + &ctx.cancel_token, + &ctx.game_id, + chunk.canonical_path().as_str(), + chunk.offset, + rx.receive(), + ) + .await? + .map_err(|error| { + DownloadTransferError::transport(format!( + "failed to receive buffered version.ini chunk at offset {}: {error}", + chunk.offset + )) + })?; let Some(bytes) = bytes else { break; }; - receive_budget.accept(bytes.len())?; + verifier.accept(&bytes)?; progress.record_bytes(bytes.len()); received.extend_from_slice(&bytes); } - receive_budget.finish()?; - - buffer.write_at(chunk.offset, &received).await + verifier.finish(chunk.canonical_path().as_str(), chunk.offset)?; + deadline + .run( + &ctx.cancel_token, + &ctx.game_id, + chunk.canonical_path().as_str(), + chunk.offset, + buffer.write_at(chunk.offset, &received), + ) + .await? + .map_err(|error| { + DownloadTransferError::local_io(format!( + "failed to buffer version.ini chunk at offset {offset}: {error}", + offset = chunk.offset + )) + }) } /// Verifies that a chunk was written correctly. -async fn verify_chunk_integrity( - file: &tokio::fs::File, +fn verify_chunk_integrity( + file: &File, offset: u64, expected_length: u64, -) -> eyre::Result<()> { +) -> DownloadTransferResult<()> { if expected_length == 0 { return Ok(()); // Skip verification for whole files or zero-length chunks } - let metadata = file.metadata().await?; + let metadata = scoped_blocking(|| file.metadata()).map_err(|error| { + DownloadTransferError::local_io(format!( + "failed to inspect downloaded chunk destination: {error}" + )) + })?; let file_size = metadata.len(); + let expected_end = offset + .checked_add(expected_length) + .ok_or_else(|| DownloadTransferError::local_io("chunk end offset overflow"))?; - if file_size < offset + expected_length { - eyre::bail!( - "File integrity check failed: file size {} is less than expected {} (offset: {})", - file_size, - offset + expected_length, - offset - ); + if file_size < expected_end { + return Err(DownloadTransferError::local_io(format!( + "file integrity check failed: file size {file_size} is less than expected {expected_end} (offset: {offset})" + ))); } Ok(()) @@ -258,34 +519,79 @@ async fn verify_chunk_integrity( fn failed_chunk_result( chunk: DownloadChunk, - peer_addr: SocketAddr, - reason: impl Into, + peer_endpoint: PeerEndpoint, + error: DownloadTransferError, ) -> ChunkDownloadResult { ChunkDownloadResult { chunk, - result: Err(eyre::Report::msg(reason.into())), - peer_addr, + result: Err(error), + peer_endpoint, } } fn failed_plan_results( plan: PeerDownloadPlan, - peer_addr: SocketAddr, + peer_endpoint: PeerEndpoint, reason: impl std::fmt::Display, ) -> Vec { let reason = format!("peer connection failed: {reason}"); plan.chunks .into_iter() - .map(|chunk| failed_chunk_result(chunk, peer_addr, reason.clone())) + .map(|chunk| { + failed_chunk_result( + chunk, + peer_endpoint, + DownloadTransferError::transport(reason.clone()), + ) + }) .collect() } +fn record_completed_chunk( + completed: ChunkDownloadResult, + pending: &mut VecDeque, + results: &mut Vec, + peer_endpoint: PeerEndpoint, +) { + let stop_reason = completed.result.as_ref().err().and_then(|error| { + matches!( + error.kind(), + DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport + ) + .then(|| error.to_string()) + }); + results.push(completed); + + let Some(stop_reason) = stop_reason else { + return; + }; + while let Some(chunk) = pending.pop_front() { + results.push(failed_chunk_result( + chunk, + peer_endpoint, + DownloadTransferError::transport(format!( + "source unavailable after earlier chunk failure: {stop_reason}" + )), + )); + } +} + +fn take_pending_chunk_for_window( + pending: &mut VecDeque, + in_flight: usize, +) -> Option { + if in_flight >= PEER_DOWNLOAD_STREAM_WINDOW.max(1) { + return None; + } + pending.pop_front() +} + struct ChunkPlanContext<'a> { - peer_addr: SocketAddr, + peer_endpoint: PeerEndpoint, game_id: &'a str, game_root: &'a ConfinedGameRoot, cancel_token: &'a CancellationToken, - version_buffer: Option>, + version_buffer: Arc, progress_tracker: Arc, } @@ -293,13 +599,12 @@ async fn download_chunk_plan( conn: &mut Connection, chunks: Vec, ctx: &ChunkPlanContext<'_>, -) -> eyre::Result> { +) -> DownloadTransferResult> { let mut pending: VecDeque = chunks.into(); let mut in_flight = FuturesUnordered::new(); let mut results = Vec::new(); - let window = PEER_DOWNLOAD_STREAM_WINDOW.max(1); let receive_ctx = ChunkReceiveContext { - peer_addr: ctx.peer_addr, + peer_endpoint: ctx.peer_endpoint, game_root: ctx.game_root.clone(), game_id: ctx.game_id.to_owned(), cancel_token: ctx.cancel_token.clone(), @@ -314,36 +619,44 @@ async fn download_chunk_plan( results.clear(); } - while !cancelled && in_flight.len() < window { - let Some(chunk) = pending.pop_front() else { + while !cancelled { + let Some(chunk) = take_pending_chunk_for_window(&mut pending, in_flight.len()) else { break; }; log::info!( "Downloading chunk {} (offset {}, length {}) from {}", - chunk.request_path, + chunk.canonical_path().as_str(), chunk.offset, chunk.length, - ctx.peer_addr + ctx.peer_endpoint.addr ); - match open_chunk_stream(conn, ctx.game_id, &chunk, ctx.cancel_token).await { + let deadline = ChunkDeadline::ordinary(); + match open_chunk_stream(conn, ctx.game_id, &chunk, ctx.cancel_token, deadline).await { Ok(rx) => { - in_flight.push(receive_chunk_result(chunk, rx, receive_ctx.clone())); + in_flight.push(receive_chunk_result( + chunk, + rx, + receive_ctx.clone(), + deadline, + )); } - Err(_) if ctx.cancel_token.is_cancelled() => { + Err(error) if error.kind() == DownloadTransferErrorKind::Cancelled => { cancelled = true; results.clear(); break; } Err(err) => { - let reason = format!("failed to open chunk stream: {err}"); - results.push(failed_chunk_result(chunk, ctx.peer_addr, reason.clone())); + let reason = err.to_string(); + results.push(failed_chunk_result(chunk, ctx.peer_endpoint, err)); while let Some(chunk) = pending.pop_front() { results.push(failed_chunk_result( chunk, - ctx.peer_addr, - format!("peer stream unavailable after earlier open failure: {reason}"), + ctx.peer_endpoint, + DownloadTransferError::transport(format!( + "peer stream unavailable after earlier open failure: {reason}" + )), )); } break; @@ -373,53 +686,71 @@ async fn download_chunk_plan( results.clear(); } result = in_flight.next() => { - results.push(result.expect("in-flight chunk stream should exist")); + record_completed_chunk( + result.expect("in-flight chunk stream should exist"), + &mut pending, + &mut results, + ctx.peer_endpoint, + ); } }; } if cancelled { - eyre::bail!("download cancelled for game {}", ctx.game_id); + return Err(DownloadTransferError::cancelled(ctx.game_id)); } Ok(results) } /// Downloads all assigned chunks and files from a single peer. +pub(super) struct PeerDownloadRequest { + pub(super) quic: QuicConnector, + pub(super) endpoint: PeerEndpoint, + pub(super) game_id: String, + pub(super) plan: PeerDownloadPlan, + pub(super) game_root: ConfinedGameRoot, + pub(super) cancel_token: CancellationToken, + pub(super) version_buffer: Arc, + pub(super) progress_tracker: Arc, +} + pub(super) async fn download_from_peer( - peer_addr: SocketAddr, - game_id: &str, - plan: PeerDownloadPlan, - game_root: ConfinedGameRoot, - cancel_token: &CancellationToken, - version_buffer: Option>, - progress_tracker: Arc, -) -> eyre::Result> { + request: PeerDownloadRequest, +) -> DownloadTransferResult> { + let PeerDownloadRequest { + quic, + endpoint, + game_id, + plan, + game_root, + cancel_token, + version_buffer, + progress_tracker, + } = request; if plan.chunks.is_empty() { return Ok(Vec::new()); } - ensure_download_not_cancelled(cancel_token, game_id)?; + ensure_download_not_cancelled(&cancel_token, &game_id)?; - let mut conn = match tokio::select! { - () = cancel_token.cancelled() => { - eyre::bail!("download cancelled for game {game_id}"); - } - result = connect_to_peer(peer_addr) => result, - } { + let mut conn = match connect_to_peer(&quic, &endpoint, &cancel_token).await { Ok(conn) => conn, - Err(err) => return Ok(failed_plan_results(plan, peer_addr, err)), + Err(_) if cancel_token.is_cancelled() => { + return Err(DownloadTransferError::cancelled(&game_id)); + } + Err(err) => return Ok(failed_plan_results(plan, endpoint, err)), }; if let Err(err) = conn.keep_alive(true) { - return Ok(failed_plan_results(plan, peer_addr, err)); + return Ok(failed_plan_results(plan, endpoint, err)); } let chunk_ctx = ChunkPlanContext { - peer_addr, - game_id, + peer_endpoint: endpoint, + game_id: &game_id, game_root: &game_root, - cancel_token, + cancel_token: &cancel_token, version_buffer, progress_tracker, }; @@ -432,39 +763,144 @@ pub(super) async fn download_from_peer( #[cfg(test)] mod tests { use std::{ - io, - pin::Pin, - task::{Context, Poll}, + collections::VecDeque, + future, + io::{self, Write}, + net::SocketAddr, + sync::{Arc, mpsc}, + time::Duration, }; - use tokio::io::AsyncWrite; + use lanspread_db::content_manifest::{ + Blake3Digest, + CATALOG_CHUNK_SIZE, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + }; + use lanspread_proto::{PeerEndpoint, PeerId}; - use super::{ReceiveBudget, flush_before_propagating}; + use super::{ + CancellationToken, + ChunkDeadline, + ChunkDownloadResult, + ChunkSink, + ChunkVerifier, + DownloadChunk, + DownloadTransferError, + DownloadTransferErrorKind, + DownloadTransferResult, + ReceiveBudget, + VersionIniBuffer, + flush_before_propagating, + record_completed_chunk, + select_chunk_sink, + take_pending_chunk_for_window, + write_chunk_bytes, + }; + use crate::{ + config::PEER_DOWNLOAD_STREAM_WINDOW, + download::{ValidatedDownloadManifest, planning::build_peer_plans}, + test_support::TempDir, + }; #[derive(Default)] struct FlushProbe { flushed: bool, } - impl AsyncWrite for FlushProbe { - fn poll_write( - self: Pin<&mut Self>, - _context: &mut Context<'_>, - bytes: &[u8], - ) -> Poll> { - Poll::Ready(Ok(bytes.len())) + impl Write for FlushProbe { + fn write(&mut self, bytes: &[u8]) -> io::Result { + Ok(bytes.len()) } - fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { - self.get_mut().flushed = true; - Poll::Ready(Ok(())) + fn flush(&mut self) -> io::Result<()> { + self.flushed = true; + Ok(()) + } + } + + struct DelayedWriter { + started: Option>, + release: mpsc::Receiver<()>, + } + + impl Write for DelayedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if let Some(started) = self.started.take() { + let _ = started.send(()); + } + self.release + .recv_timeout(Duration::from_secs(2)) + .map_err(io::Error::other)?; + Ok(bytes.len()) } - fn poll_shutdown(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) + fn flush(&mut self) -> io::Result<()> { + Ok(()) } } + fn loopback_addr(port: u16) -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], port)) + } + + fn endpoint_for_addr(peer: SocketAddr) -> PeerEndpoint { + PeerEndpoint::new( + PeerId::from_bytes(*blake3::hash(peer.to_string().as_bytes()).as_bytes()), + peer, + ) + } + + fn catalog_planned_chunks(peer: SocketAddr) -> Vec { + let endpoint = endpoint_for_addr(peer); + let archive_chunk_count = PEER_DOWNLOAD_STREAM_WINDOW.max(1) + 2; + let archive_digests = (0..archive_chunk_count) + .map(|index| { + let byte = u8::try_from(index + 1).expect("test chunk count should fit in u8"); + Blake3Digest::from_bytes([byte; 32]) + }) + .collect(); + let version_digest = Blake3Digest::hash(b"1"); + let catalog = Arc::new( + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "game", + "1", + vec![ + CatalogFileEntry::file( + "archive.eti", + CATALOG_CHUNK_SIZE + * u64::try_from(archive_chunk_count) + .expect("test chunk count should fit in u64"), + Blake3Digest::from_bytes([0xf0; 32]), + archive_digests, + ) + .expect("multi-chunk archive should validate"), + CatalogFileEntry::file( + "version.ini", + 1, + version_digest, + vec![version_digest], + ) + .expect("version.ini should validate"), + ], + Vec::new(), + ) + .expect("catalog body should validate"), + ) + .expect("catalog should seal"), + ); + let temp = TempDir::new("lanspread-transport-plan"); + let manifest = ValidatedDownloadManifest::from_catalog(temp.path(), catalog) + .expect("catalog download manifest should validate"); + build_peer_plans(&[endpoint], &manifest) + .expect("catalog plan should build") + .remove(&endpoint) + .expect("peer should receive a plan") + .chunks + } + #[test] fn receive_budget_accepts_exactly_the_requested_bytes() { let mut budget = ReceiveBudget::new(5); @@ -490,16 +926,306 @@ mod tests { .expect("empty zero-length stream should finish"); } + #[test] + fn chunk_verifier_hashes_split_frames_against_catalog_digest() { + let expected = Blake3Digest::hash(b"catalog bytes"); + let mut verifier = ChunkVerifier::new(13, expected); + + verifier + .accept(b"catalog ") + .expect("first frame should fit"); + verifier.accept(b"bytes").expect("second frame should fit"); + + verifier + .finish("archive.eti", 0) + .expect("byte count and streaming digest should match"); + } + + #[test] + fn chunk_verifier_classifies_wrong_short_and_extra_bytes_as_integrity() { + let expected = Blake3Digest::hash(b"right"); + + let mut wrong = ChunkVerifier::new(5, expected); + wrong + .accept(b"wrong") + .expect("wrong bytes have exact length"); + assert_eq!( + wrong + .finish("archive.eti", 0) + .expect_err("wrong digest must fail") + .kind(), + DownloadTransferErrorKind::Integrity + ); + + let mut short = ChunkVerifier::new(5, expected); + short.accept(b"righ").expect("short frame should fit"); + assert_eq!( + short + .finish("archive.eti", 0) + .expect_err("short payload must fail") + .kind(), + DownloadTransferErrorKind::Integrity + ); + + let mut extra = ChunkVerifier::new(5, expected); + assert_eq!( + extra + .accept(b"right!") + .expect_err("extra payload must fail immediately") + .kind(), + DownloadTransferErrorKind::Integrity + ); + } + + #[test] + fn zero_byte_chunk_still_verifies_the_catalog_file_digest() { + ChunkVerifier::new(0, Blake3Digest::hash(&[])) + .finish("empty.bin", 0) + .expect("empty catalog file should verify"); + + assert_eq!( + ChunkVerifier::new(0, Blake3Digest::from_bytes([7; 32])) + .finish("empty.bin", 0) + .expect_err("incorrect empty-file digest must fail") + .kind(), + DownloadTransferErrorKind::Integrity + ); + } + + #[test] + fn version_ini_can_never_fall_through_to_the_regular_file_sink() { + let buffer = + VersionIniBuffer::new("version.ini", 8).expect("version.ini buffer should validate"); + + assert_eq!( + select_chunk_sink(true, "version.ini", &buffer) + .expect("matching sentinel chunk should route"), + ChunkSink::VersionBuffer + ); + assert_eq!( + select_chunk_sink(false, "archive.eti", &buffer).expect("ordinary chunk should route"), + ChunkSink::RegularFile + ); + for (is_version_ini, path) in [(true, "archive.eti"), (false, "version.ini")] { + assert_eq!( + select_chunk_sink(is_version_ini, path, &buffer) + .expect_err("mismatched local routing state must fail closed") + .kind(), + DownloadTransferErrorKind::LocalIo + ); + } + } + #[tokio::test] - async fn receive_errors_are_propagated_only_after_flushing() { + async fn injected_app_deadline_expires_while_the_transport_future_stays_live() { + let cancellation = CancellationToken::new(); + let deadline = ChunkDeadline::after(Duration::from_millis(25)); + let transfer = deadline.run( + &cancellation, + "game", + "archive.eti", + 0, + future::pending::<()>(), + ); + + let error = tokio::time::timeout(Duration::from_secs(2), transfer) + .await + .expect("injected application deadline should be bounded") + .expect_err("the application deadline must defeat a live QUIC transport"); + assert_eq!(error.kind(), DownloadTransferErrorKind::Transport); + assert!( + error.to_string().contains("timed out after 25ms"), + "timeout diagnostic should preserve the injected deadline" + ); + } + + #[test] + fn integrity_failure_stops_pending_work_but_drains_the_controlled_window() { + let peer = loopback_addr(12030); + let endpoint = endpoint_for_addr(peer); + let mut pending = VecDeque::from(catalog_planned_chunks(peer)); + let total_chunks = pending.len(); + let mut in_flight = Vec::new(); + while let Some(chunk) = take_pending_chunk_for_window(&mut pending, in_flight.len()) { + in_flight.push(chunk); + } + assert_eq!( + in_flight.len(), + PEER_DOWNLOAD_STREAM_WINDOW.max(1), + "the production scheduler must open only one controlled window" + ); + assert!( + !pending.is_empty(), + "the test plan must contain work beyond the controlled window" + ); + + let never_started = pending.len(); + let bad = in_flight.remove(0); + let mut results = Vec::new(); + + record_completed_chunk( + ChunkDownloadResult { + chunk: bad, + result: Err(DownloadTransferError::integrity("wrong catalog bytes")), + peer_endpoint: endpoint, + }, + &mut pending, + &mut results, + endpoint, + ); + + assert!(pending.is_empty(), "no new stream may open after bad bytes"); + assert!( + take_pending_chunk_for_window(&mut pending, in_flight.len()).is_none(), + "the production scheduler must not open another stream after bad bytes" + ); + assert_eq!( + results.len(), + never_started + 1, + "bad and never-started chunks are returned" + ); + assert_eq!( + results + .iter() + .filter(|result| { + result + .result + .as_ref() + .is_err_and(|error| error.kind() == DownloadTransferErrorKind::Integrity) + }) + .count(), + 1, + "only the chunk that supplied invalid bytes may prove integrity failure" + ); + assert_eq!( + results + .iter() + .filter(|result| { + result + .result + .as_ref() + .is_err_and(|error| error.kind() == DownloadTransferErrorKind::Transport) + }) + .count(), + never_started, + "never-started chunks must stay retryable without false quarantine evidence" + ); + + let already_in_flight = in_flight.len(); + for chunk in in_flight { + record_completed_chunk( + ChunkDownloadResult { + chunk, + result: Ok(()), + peer_endpoint: endpoint, + }, + &mut pending, + &mut results, + endpoint, + ); + } + assert_eq!(results.len(), total_chunks); + assert_eq!( + results + .iter() + .filter(|result| result.result.is_ok()) + .count(), + already_in_flight, + "every already-open stream must be drained and retained" + ); + } + + #[test] + fn receive_errors_are_propagated_only_after_flushing() { let mut probe = FlushProbe::default(); - let receive_error: eyre::Result<()> = Err(eyre::eyre!("peer receive failed")); + let receive_error: DownloadTransferResult<()> = + Err(DownloadTransferError::transport("peer receive failed")); let error = flush_before_propagating(&mut probe, receive_error) - .await .expect_err("receive error should be preserved"); assert!(probe.flushed); assert_eq!(error.to_string(), "peer receive failed"); + assert_eq!(error.kind(), DownloadTransferErrorKind::Transport); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn abort_waits_for_started_chunk_write_to_settle() { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let mut task = tokio::spawn(async move { + let mut writer = DelayedWriter { + started: Some(started_tx), + release: release_rx, + }; + write_chunk_bytes(&mut writer, b"payload") + }); + + tokio::time::timeout(Duration::from_secs(2), started_rx) + .await + .expect("chunk write should start") + .expect("chunk writer should retain the start signal"); + + task.abort(); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut task) + .await + .is_err(), + "aborting must not complete the task while its file write is still running" + ); + + release_tx + .send(()) + .expect("delayed chunk write should still be waiting"); + let completion = tokio::time::timeout(Duration::from_secs(2), &mut task) + .await + .expect("chunk task should finish after the write settles"); + match completion { + Ok(Ok(())) => {} + Ok(Err(error)) => panic!("chunk write failed unexpectedly: {error}"), + Err(error) if error.is_cancelled() => {} + Err(error) => panic!("chunk task failed unexpectedly: {error}"), + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn deadline_waits_for_started_scoped_file_work_to_settle() { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let mut task = tokio::spawn(async move { + let cancellation = CancellationToken::new(); + let deadline = ChunkDeadline::after(Duration::from_millis(25)); + let mut writer = DelayedWriter { + started: Some(started_tx), + release: release_rx, + }; + deadline + .run(&cancellation, "game", "archive.eti", 0, async { + write_chunk_bytes(&mut writer, b"payload") + }) + .await + }); + + tokio::time::timeout(Duration::from_secs(2), started_rx) + .await + .expect("chunk write should start") + .expect("chunk writer should retain the start signal"); + tokio::time::sleep(Duration::from_millis(75)).await; + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut task) + .await + .is_err(), + "deadline must not report completion while scoped file work is running" + ); + + release_tx + .send(()) + .expect("delayed chunk write should still be waiting"); + let error = tokio::time::timeout(Duration::from_secs(2), &mut task) + .await + .expect("deadline task should finish after file work settles") + .expect("deadline task should not panic") + .expect_err("elapsed deadline should fail after file work settles"); + assert_eq!(error.kind(), DownloadTransferErrorKind::Transport); } } diff --git a/crates/lanspread-peer/src/download/version_ini.rs b/crates/lanspread-peer/src/download/version_ini.rs index 0d7042c..9f909c2 100644 --- a/crates/lanspread-peer/src/download/version_ini.rs +++ b/crates/lanspread-peer/src/download/version_ini.rs @@ -1,7 +1,12 @@ -use tokio::{io::AsyncWriteExt, sync::Mutex}; +use std::{fs::File, io::Write as _}; + +use tokio::sync::Mutex; use super::confined_fs::ConfinedGameRoot; -use crate::game_paths::{VERSION_DISCARDED_FILE, VERSION_INI, VERSION_TMP_FILE}; +use crate::{ + game_paths::{VERSION_DISCARDED_FILE, VERSION_INI, VERSION_TMP_FILE}, + scoped_blocking::scoped_blocking, +}; pub(super) enum VersionIniCommit { Durable, @@ -50,28 +55,20 @@ impl VersionIniBuffer { } } -pub(super) async fn begin_version_ini_transaction( - game_root: &ConfinedGameRoot, -) -> eyre::Result<()> { - game_root - .remove_root_file_if_exists(VERSION_TMP_FILE) - .await?; - game_root - .remove_root_file_if_exists(VERSION_DISCARDED_FILE) - .await?; +pub(super) fn begin_version_ini_transaction(game_root: &ConfinedGameRoot) -> eyre::Result<()> { + game_root.remove_root_file_if_exists(VERSION_TMP_FILE)?; + game_root.remove_root_file_if_exists(VERSION_DISCARDED_FILE)?; - if game_root.root_regular_file_exists(VERSION_INI).await? { - game_root - .rename_root_file(VERSION_INI, VERSION_DISCARDED_FILE) - .await?; - game_root.sync_root().await?; + if game_root.root_regular_file_exists(VERSION_INI)? { + game_root.rename_root_file(VERSION_INI, VERSION_DISCARDED_FILE)?; + game_root.sync_root()?; } Ok(()) } #[cfg(test)] -pub(super) async fn rollback_version_ini_transaction(game_root: &ConfinedGameRoot) { - if let Err(err) = discard_version_ini_transaction(game_root).await { +pub(super) fn rollback_version_ini_transaction(game_root: &ConfinedGameRoot) { + if let Err(err) = discard_version_ini_transaction(game_root) { log::warn!( "Failed to discard version.ini transaction in {}: {err}", game_root.display_path().display() @@ -80,49 +77,34 @@ pub(super) async fn rollback_version_ini_transaction(game_root: &ConfinedGameRoo } /// Restores the old sentinel after a crash or failure before ownership journaling. -pub(super) async fn restore_unjournaled_version_ini_transaction( +pub(super) fn restore_unjournaled_version_ini_transaction( game_root: &ConfinedGameRoot, ) -> eyre::Result<()> { - game_root - .remove_root_file_if_exists(VERSION_TMP_FILE) - .await?; - if game_root.root_regular_file_exists(VERSION_INI).await? { - game_root - .remove_root_file_if_exists(VERSION_DISCARDED_FILE) - .await?; + game_root.remove_root_file_if_exists(VERSION_TMP_FILE)?; + if game_root.root_regular_file_exists(VERSION_INI)? { + game_root.remove_root_file_if_exists(VERSION_DISCARDED_FILE)?; return Ok(()); } - if game_root - .root_regular_file_exists(VERSION_DISCARDED_FILE) - .await? - { - game_root - .rename_root_file(VERSION_DISCARDED_FILE, VERSION_INI) - .await?; - game_root.sync_root().await?; + if game_root.root_regular_file_exists(VERSION_DISCARDED_FILE)? { + game_root.rename_root_file(VERSION_DISCARDED_FILE, VERSION_INI)?; + game_root.sync_root()?; } Ok(()) } /// Removes all sentinel scratch after an aborted journaled download. -pub(super) async fn discard_version_ini_transaction( - game_root: &ConfinedGameRoot, -) -> eyre::Result<()> { - game_root - .remove_root_file_if_exists(VERSION_TMP_FILE) - .await?; - game_root - .remove_root_file_if_exists(VERSION_DISCARDED_FILE) - .await?; - game_root.sync_root().await?; +pub(super) fn discard_version_ini_transaction(game_root: &ConfinedGameRoot) -> eyre::Result<()> { + game_root.remove_root_file_if_exists(VERSION_TMP_FILE)?; + game_root.remove_root_file_if_exists(VERSION_DISCARDED_FILE)?; + game_root.sync_root()?; Ok(()) } /// Sweeps scratch left after a committed sentinel was recovered. -pub(super) async fn finish_recovered_version_ini_transaction( +pub(super) fn finish_recovered_version_ini_transaction( game_root: &ConfinedGameRoot, ) -> eyre::Result<()> { - discard_version_ini_transaction(game_root).await + discard_version_ini_transaction(game_root) } pub(super) async fn commit_version_ini_buffer( @@ -132,6 +114,16 @@ pub(super) async fn commit_version_ini_buffer( commit_version_ini_buffer_with_sync(game_root, buffer, None).await } +fn write_and_sync_version_file(file: File, bytes: &[u8]) -> std::io::Result<()> { + // The file is moved into this scope so write, durability, and close all + // settle before the sentinel rename can begin. + scoped_blocking(move || { + let mut file = file; + file.write_all(bytes)?; + file.sync_all() + }) +} + async fn commit_version_ini_buffer_with_sync( game_root: &ConfinedGameRoot, buffer: &VersionIniBuffer, @@ -139,25 +131,17 @@ async fn commit_version_ini_buffer_with_sync( ) -> eyre::Result { let bytes = buffer.snapshot().await; - let mut file = - tokio::fs::File::from_std(game_root.create_new_root_file(VERSION_TMP_FILE).await?); - file.write_all(&bytes).await?; - file.sync_all().await?; - drop(file); + let file = game_root.create_new_root_file(VERSION_TMP_FILE)?; + write_and_sync_version_file(file, &bytes)?; - game_root - .rename_root_file(VERSION_TMP_FILE, VERSION_INI) - .await?; + game_root.rename_root_file(VERSION_TMP_FILE, VERSION_INI)?; if let Some(error) = injected_sync_error { return Ok(VersionIniCommit::NeedsRecovery(error)); } - if let Err(error) = game_root.sync_root().await { + if let Err(error) = game_root.sync_root() { return Ok(VersionIniCommit::NeedsRecovery(error)); } - if let Err(error) = game_root - .remove_root_file_if_exists(VERSION_DISCARDED_FILE) - .await - { + if let Err(error) = game_root.remove_root_file_if_exists(VERSION_DISCARDED_FILE) { log::warn!( "Committed {} but failed to sweep the parked sentinel: {error}", game_root.display_path().join(VERSION_INI).display() @@ -171,9 +155,8 @@ mod tests { use super::*; use crate::test_support::TempDir; - async fn open_game_root(temp: &TempDir) -> ConfinedGameRoot { + fn open_game_root(temp: &TempDir) -> ConfinedGameRoot { ConfinedGameRoot::open_or_create(temp.path(), "game") - .await .expect("confined game root should open") } @@ -197,9 +180,8 @@ mod tests { #[tokio::test] async fn commit_version_ini_writes_sentinel_last_and_sweeps_discarded() { let temp = TempDir::new("lanspread-download"); - let game_root = open_game_root(&temp).await; - tokio::fs::write(temp.game_root().join(".version.ini.discarded"), b"old") - .await + let game_root = open_game_root(&temp); + std::fs::write(temp.game_root().join(".version.ini.discarded"), b"old") .expect("discarded sentinel should be written"); let buffer = @@ -224,9 +206,8 @@ mod tests { #[tokio::test] async fn landed_rename_with_failed_parent_sync_keeps_recovery_state() { let temp = TempDir::new("lanspread-version-durability"); - let game_root = open_game_root(&temp).await; - tokio::fs::write(temp.game_root().join(VERSION_DISCARDED_FILE), b"20240101") - .await + let game_root = open_game_root(&temp); + std::fs::write(temp.game_root().join(VERSION_DISCARDED_FILE), b"20240101") .expect("old sentinel should be parked"); let buffer = VersionIniBuffer::new("game/version.ini", 8).expect("buffer should be created"); @@ -245,28 +226,23 @@ mod tests { assert!(matches!(outcome, VersionIniCommit::NeedsRecovery(_))); assert_eq!( - tokio::fs::read(temp.game_root().join(VERSION_INI)) - .await + std::fs::read(temp.game_root().join(VERSION_INI)) .expect("new sentinel should be visible"), b"20250101" ); assert!(temp.game_root().join(VERSION_DISCARDED_FILE).is_file()); } - #[tokio::test] - async fn begin_version_ini_transaction_parks_existing_sentinel() { + #[test] + fn begin_version_ini_transaction_parks_existing_sentinel() { let temp = TempDir::new("lanspread-download"); - let game_root = open_game_root(&temp).await; - tokio::fs::write(temp.game_root().join("version.ini"), b"20240101") - .await + let game_root = open_game_root(&temp); + std::fs::write(temp.game_root().join("version.ini"), b"20240101") .expect("version sentinel should be written"); - tokio::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial") - .await + std::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial") .expect("tmp sentinel should be written"); - begin_version_ini_transaction(&game_root) - .await - .expect("transaction should begin"); + begin_version_ini_transaction(&game_root).expect("transaction should begin"); assert!(!temp.game_root().join("version.ini").exists()); assert!(!temp.game_root().join(".version.ini.tmp").exists()); @@ -278,39 +254,33 @@ mod tests { } #[cfg(unix)] - #[tokio::test] - async fn begin_can_inspect_and_park_read_only_sentinel() { + #[test] + fn begin_can_inspect_and_park_read_only_sentinel() { use std::os::unix::fs::PermissionsExt as _; let temp = TempDir::new("lanspread-read-only-version"); - let game_root = open_game_root(&temp).await; + let game_root = open_game_root(&temp); let version_path = temp.game_root().join(VERSION_INI); - tokio::fs::write(&version_path, b"20240101") - .await - .expect("version sentinel should be written"); + std::fs::write(&version_path, b"20240101").expect("version sentinel should be written"); std::fs::set_permissions(&version_path, std::fs::Permissions::from_mode(0o444)) .expect("version sentinel should become read-only"); - begin_version_ini_transaction(&game_root) - .await - .expect("read-only sentinel should park"); + begin_version_ini_transaction(&game_root).expect("read-only sentinel should park"); assert!(!version_path.exists()); assert!(temp.game_root().join(VERSION_DISCARDED_FILE).is_file()); } - #[tokio::test] - async fn rollback_version_ini_transaction_sweeps_transients() { + #[test] + fn rollback_version_ini_transaction_sweeps_transients() { let temp = TempDir::new("lanspread-download"); - let game_root = open_game_root(&temp).await; - tokio::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial") - .await + let game_root = open_game_root(&temp); + std::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial") .expect("tmp sentinel should be written"); - tokio::fs::write(temp.game_root().join(".version.ini.discarded"), b"old") - .await + std::fs::write(temp.game_root().join(".version.ini.discarded"), b"old") .expect("discarded sentinel should be written"); - rollback_version_ini_transaction(&game_root).await; + rollback_version_ini_transaction(&game_root); assert!(!temp.game_root().join(".version.ini.tmp").exists()); assert!(!temp.game_root().join(".version.ini.discarded").exists()); diff --git a/crates/lanspread-peer/src/events.rs b/crates/lanspread-peer/src/events.rs index 6cb475e..f4207e4 100644 --- a/crates/lanspread-peer/src/events.rs +++ b/crates/lanspread-peer/src/events.rs @@ -1,14 +1,19 @@ //! UI event helpers used by peer command and service code. -use std::{collections::HashMap, net::SocketAddr, sync::Arc}; +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, +}; -use lanspread_db::db::GameCatalog; +use lanspread_db::content_manifest::ContentId; use tokio::sync::{RwLock, mpsc::UnboundedSender}; use crate::{ ActiveOperation, ActiveOperationKind, PeerEvent, + RemoteGameAvailability, + RemoteLibraryView, context::OperationKind, peer_db::PeerGameDB, }; @@ -66,38 +71,85 @@ fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind { pub async fn emit_peer_game_list( peer_game_db: &Arc>, - catalog: &Arc>, tx_notify_ui: &UnboundedSender, ) { - let games = { - let catalog = catalog.read().await; - peer_game_db.read().await.get_catalog_games(&catalog) - }; - send(tx_notify_ui, PeerEvent::ListGames(games)); + let db = peer_game_db.read().await; + send_remote_library_view_locked(&db, tx_notify_ui); +} + +fn send_remote_library_view_locked( + peer_game_db: &PeerGameDB, + tx_notify_ui: &UnboundedSender, +) { + send( + tx_notify_ui, + PeerEvent::RemoteLibraryView(remote_library_view(peer_game_db)), + ); +} + +pub(crate) fn remote_library_view(peer_game_db: &PeerGameDB) -> RemoteLibraryView { + let mut counts = BTreeMap::<(String, ContentId), u32>::new(); + for game in peer_game_db + .peer_snapshots() + .into_iter() + .flat_map(|peer| peer.games) + { + let count = counts.entry((game.game_id, game.content_id)).or_default(); + *count = count.saturating_add(1); + } + RemoteLibraryView { + games: counts + .into_iter() + .map( + |((game_id, content_id), peer_count)| RemoteGameAvailability { + game_id, + content_id, + peer_count, + }, + ) + .collect(), + } } pub async fn emit_peer_count( peer_game_db: &Arc>, tx_notify_ui: &UnboundedSender, ) { - let peer_count = { peer_game_db.read().await.get_peer_addresses().len() }; + let db = peer_game_db.read().await; + let peer_count = db.peer_endpoints().len(); send(tx_notify_ui, PeerEvent::PeerCountUpdated(peer_count)); } -pub async fn emit_peer_discovered( - peer_game_db: &Arc>, - tx_notify_ui: &UnboundedSender, - peer_addr: SocketAddr, -) { - send(tx_notify_ui, PeerEvent::PeerDiscovered(peer_addr)); - emit_peer_count(peer_game_db, tx_notify_ui).await; -} +#[cfg(test)] +mod tests { + use super::*; -pub async fn emit_peer_lost( - peer_game_db: &Arc>, - tx_notify_ui: &UnboundedSender, - peer_addr: SocketAddr, -) { - send(tx_notify_ui, PeerEvent::PeerLost(peer_addr)); - emit_peer_count(peer_game_db, tx_notify_ui).await; + #[tokio::test] + async fn remote_library_view_is_enqueued_before_a_waiting_writer_can_commit() { + let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); + let guard = peer_game_db.read().await; + let writer_db = Arc::clone(&peer_game_db); + let writer = tokio::spawn(async move { + let _guard = writer_db.write().await; + }); + tokio::task::yield_now().await; + assert!(!writer.is_finished(), "writer must wait for the read guard"); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + send_remote_library_view_locked(&guard, &tx); + assert!(matches!( + rx.try_recv(), + Ok(PeerEvent::RemoteLibraryView(RemoteLibraryView { games })) if games.is_empty() + )); + assert!( + !writer.is_finished(), + "the view must be enqueued while the read guard still orders writers" + ); + + drop(guard); + tokio::time::timeout(std::time::Duration::from_secs(1), writer) + .await + .expect("writer should acquire after the view is enqueued") + .expect("writer task should finish"); + } } diff --git a/crates/lanspread-peer/src/game_paths.rs b/crates/lanspread-peer/src/game_paths.rs index 7ec1b8c..b0511b2 100644 --- a/crates/lanspread-peer/src/game_paths.rs +++ b/crates/lanspread-peer/src/game_paths.rs @@ -20,18 +20,6 @@ pub(crate) fn portable_name_key(name: &str) -> String { name.to_uppercase() } -/// Matches the committed install directory according to the host filesystem. -#[cfg(target_os = "windows")] -pub(crate) fn is_local_dir_name(name: &str) -> bool { - name.eq_ignore_ascii_case(LOCAL_DIR) -} - -/// Matches the committed install directory according to the host filesystem. -#[cfg(not(target_os = "windows"))] -pub(crate) fn is_local_dir_name(name: &str) -> bool { - name == LOCAL_DIR -} - /// Returns whether a top-level entry belongs to install, recovery, or legacy state. /// /// This deliberately uses a conservative platform-independent comparison because diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index 24b7f32..9206cfe 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -1,45 +1,67 @@ //! Command handlers for peer commands. use std::{ - collections::{HashSet, hash_map::Entry}, + collections::{HashMap, HashSet}, + fmt, future::Future, - net::SocketAddr, path::{Path, PathBuf}, sync::Arc, time::Duration, }; -use lanspread_db::db::{GameDB, GameFileDescription}; -use tokio::sync::{RwLock, mpsc::UnboundedSender}; +use lanspread_db::{ + content_manifest::{CatalogContentManifest, ContentId}, + db::GameDB, +}; +use lanspread_proto::PeerEndpoint; +use tokio::sync::mpsc::UnboundedSender; use tokio_util::sync::CancellationToken; +#[cfg(test)] +use crate::local_games::{rescan_local_game, scan_local_library}; use crate::{ + DownloadAttemptKey, + DownloadFailureReason, + DownloadVerificationActivity, InstallOperation, PeerEvent, + StreamInstallSettings, + apply_launch_settings_to_verified_tree, + content_quarantine::ContentQuarantine, context::{Ctx, OperationGuard, OperationKind}, download::{ + DownloadCompletion, + DownloadGameRequest, + DownloadOwnershipReadiness, ValidatedDownloadManifest, download_game_files, + download_ownership_matches_content, + download_ownership_readiness, remove_downloaded_payload, - validate_protocol_v7_descriptions, }, events, install, local_games::{ LocalLibraryScan, game_from_summary, - get_game_file_descriptions, local_dir_is_directory, local_download_matches_catalog, - rescan_local_game, - scan_local_library, + rescan_local_game_with_recovery_failures, + scan_local_library_with_recovery_failures, version_ini_is_regular_file, }, - network::{request_game_details_from_peer, send_library_delta}, + mark_launch_settings_applied, peer_db::PeerGameDB, - remote_peer::ensure_peer_id_for_addr, - services::{HandshakeCtx, perform_handshake_with_peer}, - stream_install::receive_streamed_install, + quic_runtime::QuicConnector, + scoped_blocking::scoped_blocking, + services::{HandshakeCtx, ReservedCandidateHandshake}, + stream_install::{ + ReceiveStreamedInstallRequest, + StreamInstallReceiveError, + StreamInstallReceiveErrorKind, + receive_streamed_install, + }, + transfer_status::{DownloadAttemptReporter, DownloadAttemptStatus}, }; // ============================================================================= @@ -49,171 +71,140 @@ use crate::{ const OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10); const OUTBOUND_TRANSFER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); +async fn retain_guard_until_operation_completes(guard: G, operation: F) -> F::Output +where + F: Future, +{ + let output = operation.await; + drop(guard); + output +} + +async fn register_download_attempt( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + key: DownloadAttemptKey, + cancellation: CancellationToken, +) -> DownloadAttemptStatus { + let status = DownloadAttemptStatus::new(key, cancellation, tx_notify_ui.clone()); + ctx.active_downloads + .write() + .await + .insert(status.key().id.clone(), status.signal()); + status +} + +/// Immutable filesystem target selected when a command enters the peer core. +/// +/// The configured games directory may change while a spawned operation waits to +/// run. Admission compares this captured target with the configured directory, +/// and every later filesystem step uses these paths rather than rereading +/// `Ctx::game_dir`. +#[derive(Clone, Debug, Eq, PartialEq)] +struct OperationTarget { + games_folder: PathBuf, + game_id: String, +} + +impl OperationTarget { + fn new(games_folder: PathBuf, game_id: String) -> Self { + Self { + games_folder, + game_id, + } + } + + fn game_id(&self) -> &str { + &self.game_id + } + + fn game_root(&self) -> PathBuf { + self.games_folder.join(&self.game_id) + } +} + +fn select_content_sources( + peer_game_db: &PeerGameDB, + game_id: &str, + content_id: ContentId, + quarantine: &ContentQuarantine, +) -> Vec { + let mut endpoints = peer_game_db.peer_endpoints_with_content(game_id, content_id); + endpoints.sort_unstable_by_key(|endpoint| (endpoint.peer_id, endpoint.addr)); + endpoints.dedup(); + + endpoints + .into_iter() + .filter(|endpoint| !quarantine.is_quarantined(endpoint, content_id)) + .collect() +} + +fn load_catalog_manifest(ctx: &Ctx, game_id: &str) -> eyre::Result> { + let catalog = Arc::clone(&ctx.catalog); + let game_id = game_id.to_owned(); + scoped_blocking(move || catalog.manifest(&game_id)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StreamInstallFailureDisposition { + RetryAndQuarantine, + Retry, + Stop, +} + +const fn stream_install_failure_disposition( + kind: StreamInstallReceiveErrorKind, +) -> StreamInstallFailureDisposition { + match kind { + StreamInstallReceiveErrorKind::Integrity => { + StreamInstallFailureDisposition::RetryAndQuarantine + } + StreamInstallReceiveErrorKind::Transport => StreamInstallFailureDisposition::Retry, + StreamInstallReceiveErrorKind::Cancelled | StreamInstallReceiveErrorKind::Setup => { + StreamInstallFailureDisposition::Stop + } + } +} + +#[derive(Debug)] +struct StreamDownloadError { + reason: Option, + error: eyre::Report, +} + +impl StreamDownloadError { + fn cancelled(error: impl Into) -> Self { + Self { + reason: None, + error: error.into(), + } + } + + fn sources_exhausted(error: impl Into) -> Self { + Self { + reason: Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted), + error: error.into(), + } + } + + fn operation_failed(error: impl Into) -> Self { + Self { + reason: Some(DownloadFailureReason::OperationFailed), + error: error.into(), + } + } +} + +impl fmt::Display for StreamDownloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.error.fmt(formatter) + } +} + /// Handles the `ListGames` command. pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { log::info!("ListGames command received"); - events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, tx_notify_ui).await; -} - -/// Tries to serve a game from local files. -async fn try_serve_local_game( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - id: &str, -) -> bool { - let game_dir = { ctx.game_dir.read().await.clone() }; - - let active_operations = ctx.active_operations.read().await; - let catalog = ctx.catalog.read().await; - if !local_download_matches_catalog(&game_dir, id, &active_operations, &catalog).await { - return false; - } - drop(active_operations); - drop(catalog); - - match get_game_file_descriptions(id, &game_dir).await { - Ok(file_descriptions) => { - log::info!("Serving game {id} from local files"); - if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles { - id: id.to_string(), - file_descriptions, - }) { - log::error!("Failed to send GotGameFiles event: {e}"); - } - true - } - Err(e) => { - log::error!("Failed to enumerate local file descriptions for {id}: {e}"); - false - } - } -} - -/// Handles the `GetGame` command. -pub(crate) async fn handle_get_game_command( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - id: String, - source: GameDetailSource, -) { - if source.allows_local() && try_serve_local_game(ctx, tx_notify_ui, &id).await { - return; - } - - log::info!("Requesting game from peers: {id}"); - let expected_version = catalog_expected_version(ctx, &id).await; - let peers = { - let peer_game_db = ctx.peer_game_db.read().await; - source.select_peers(&peer_game_db, &id, expected_version.as_deref()) - }; - if peers.is_empty() { - log::warn!("No peers have game {id}"); - if let Err(e) = tx_notify_ui.send(PeerEvent::NoPeersHaveGame { id: id.clone() }) { - log::error!("Failed to send NoPeersHaveGame event: {e}"); - } - return; - } - - let peer_game_db = ctx.peer_game_db.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - ctx.task_tracker.spawn(fetch_game_details_from_peers( - peers, - id, - expected_version, - peer_game_db, - tx_notify_ui, - |peer_addr, game_id, peer_game_db| async move { - request_game_details_and_update(peer_addr, &game_id, peer_game_db).await - }, - )); -} - -#[derive(Clone, Copy, Debug)] -pub(crate) enum GameDetailSource { - LocalOrPeers, - LatestPeersOnly, -} - -impl GameDetailSource { - fn allows_local(self) -> bool { - matches!(self, Self::LocalOrPeers) - } - - fn select_peers( - self, - peer_game_db: &PeerGameDB, - id: &str, - expected_version: Option<&str>, - ) -> Vec { - match self { - Self::LocalOrPeers | Self::LatestPeersOnly => { - peer_game_db.peers_with_expected_version(id, expected_version) - } - } - } -} - -/// Requests game details from a peer and updates the peer game database. -async fn request_game_details_and_update( - peer_addr: SocketAddr, - game_id: &str, - peer_game_db: Arc>, -) -> eyre::Result> { - let (file_descriptions, _) = request_game_details_from_peer(peer_addr, game_id).await?; - let peer_id = ensure_peer_id_for_addr(&peer_game_db, peer_addr).await; - - { - let mut db = peer_game_db.write().await; - db.update_peer_game_files(&peer_id, game_id, file_descriptions.clone()); - } - - Ok(file_descriptions) -} - -async fn fetch_game_details_from_peers( - peers: Vec, - id: String, - expected_version: Option, - peer_game_db: Arc>, - tx_notify_ui: UnboundedSender, - mut fetch_details: F, -) where - F: FnMut(SocketAddr, String, Arc>) -> Fut + Send + 'static, - Fut: Future>> + Send, -{ - let mut fetched_any = false; - for peer_addr in peers { - match fetch_details(peer_addr, id.clone(), peer_game_db.clone()).await { - Ok(_) => { - log::info!("Fetched game file list for {id} from peer {peer_addr}"); - fetched_any = true; - } - Err(e) => { - log::error!("Failed to fetch game files for {id} from {peer_addr}: {e}"); - } - } - } - - if fetched_any { - let aggregated_files = { - peer_game_db - .read() - .await - .aggregated_game_files(&id, expected_version.as_deref()) - }; - - if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles { - id: id.clone(), - file_descriptions: aggregated_files, - }) { - log::error!("Failed to send GotGameFiles event: {e}"); - } - } else { - log::warn!("Failed to retrieve game files for {id} from any peer"); - if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) { - log::error!("Failed to send DownloadGameFilesFailed event: {e}"); - } - } + events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await; } /// Handles the `DownloadGameFiles` command. @@ -225,144 +216,112 @@ pub async fn handle_download_game_files_command( install_after_download: bool, ) { log::info!("Got PeerCommand::DownloadGameFiles"); - if !catalog_contains(ctx, &id).await { + let attempt = DownloadAttemptKey::next(id.clone()); + if !catalog_contains(ctx, &id) { log::warn!("Ignoring download command for non-catalog game {id}"); - if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) { - log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); - } + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); return; } let games_folder = { ctx.game_dir.read().await.clone() }; - let expected_version = catalog_expected_version(ctx, &id).await; - - let raw_peer_manifests = ctx - .peer_game_db - .read() - .await - .expected_version_game_files_for(&id, expected_version.as_deref()); - let validation_game_id = id.clone(); - let raw_validation = tokio::task::spawn_blocking(move || { - let mut valid = Vec::new(); - let mut rejected = Vec::new(); - for (peer_addr, descriptions) in raw_peer_manifests { - match validate_protocol_v7_descriptions(&validation_game_id, descriptions) { - Ok(descriptions) => valid.push((peer_addr, descriptions)), - Err(error) => rejected.push((peer_addr, error.to_string())), - } - } - (valid, rejected) - }) - .await; - let (peer_manifests, rejected_manifests) = match raw_validation { - Ok(result) => result, + let target = OperationTarget::new(games_folder.clone(), id.clone()); + let catalog_manifest = match load_catalog_manifest(ctx, &id) { + Ok(manifest) => manifest, Err(error) => { - log::error!("Peer manifest validation task failed for {id}: {error}"); - send_download_failed(tx_notify_ui, &id); + log::error!("Failed to load catalog content manifest for {id}: {error}"); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); return; } }; - for (peer_addr, error) in rejected_manifests { - log::warn!("Ignoring invalid download manifest from {peer_addr} for {id}: {error}"); - } - - // Use only complete, individually valid peer manifests for size consensus. - let (validated_descriptions, peer_whitelist, file_peer_map) = { - match ctx - .peer_game_db - .read() - .await - .validate_file_sizes_majority_from(&id, &peer_manifests) - { - Ok((files, peers, file_peer_map)) => { - log::info!( - "Majority validation: {} validated files, {} trusted peers for game {id}", - files.len(), - peers.len() - ); - (files, peers, file_peer_map) - } - Err(e) => { - log::error!("File size majority validation failed for {id}: {e}"); - if let Err(send_err) = - tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) - { - log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); - } - return; - } + let content_id = catalog_manifest.content_id(); + let manifest_games_folder = games_folder.clone(); + let manifest = scoped_blocking(move || { + ValidatedDownloadManifest::from_catalog(&manifest_games_folder, catalog_manifest) + }); + let manifest = match manifest { + Ok(manifest) => manifest, + Err(error) => { + log::error!("Rejected catalog download manifest for {id}: {error}"); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); + return; } }; let local_dl_available = { - let active_operations = ctx.active_operations.read().await; - let catalog = ctx.catalog.read().await; - local_download_matches_catalog(&games_folder, &id, &active_operations, &catalog).await + if ctx.recovery_quarantine.is_blocked(&games_folder, &id) { + false + } else { + let active_operations = ctx.active_operations.read().await; + local_download_matches_catalog( + &games_folder, + ctx.state_dir.as_ref(), + &id, + &active_operations, + ctx.catalog.catalog(), + ) + .await + && download_ownership_matches_content( + &games_folder, + ctx.state_dir.as_ref(), + &id, + content_id, + ) + .await + } }; - if peer_whitelist.is_empty() { - if local_dl_available { - log::info!("Using locally downloaded files for game {id}; skipping peer transfer"); - if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { id: id.clone() }) - { - log::error!("Failed to send DownloadGameFilesBegin event: {e}"); - } - if let Err(e) = - tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.clone() }) - { - log::error!("Failed to send DownloadGameFilesFinished event: {e}"); - } - if install_after_download { - spawn_install_operation(ctx, tx_notify_ui, id.clone()); - } - } else { - log::error!("No trusted peers available after majority validation for game {id}"); - if let Err(send_err) = - tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) - { - log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); - } - } - return; - } - - if validated_descriptions.is_empty() { - log::error!( - "No validated file descriptions available to download game {id}; request metadata first" - ); - if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) { - log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); - } - return; - } - - let catalog = ctx.catalog.read().await.clone(); - let manifest_games_folder = games_folder.clone(); - let manifest_game_id = id.clone(); - let manifest = tokio::task::spawn_blocking(move || { - ValidatedDownloadManifest::from_protocol_v7( - &manifest_games_folder, - &manifest_game_id, - validated_descriptions, - &catalog, - ) - }) - .await; - let manifest = match manifest { - Ok(Ok(manifest)) => manifest, - Ok(Err(error)) => { - log::error!("Rejected download manifest for {id}: {error}"); - send_download_failed(tx_notify_ui, &id); + let network_permit = match ctx.network.try_acquire() { + Ok(permit) => permit, + Err(error) if local_dl_available => { + log::info!( + "Using locally downloaded files for game {id} while Local network sharing is unavailable: {error}" + ); + finish_cached_download(ctx, tx_notify_ui, &attempt, install_after_download, target); return; } Err(error) => { - log::error!("Download manifest validation task failed for {id}: {error}"); - send_download_failed(tx_notify_ui, &id); + log::warn!("Cannot download {id} while Local network sharing is unavailable: {error}"); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); return; } }; - match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await { + let sources = { + let peer_game_db = ctx.peer_game_db.read().await; + select_content_sources(&peer_game_db, &id, content_id, &ctx.content_quarantine) + }; + + if sources.is_empty() { + if local_dl_available { + finish_cached_download(ctx, tx_notify_ui, &attempt, install_after_download, target); + } else { + log::error!("No eligible exact catalog content source available for game {id}"); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::VerifiedCatalogSourcesExhausted, + ); + } + return; + } + + match begin_operation(ctx, tx_notify_ui, &target, OperationKind::Downloading).await { BeginOperationResult::Started => {} BeginOperationResult::AlreadyActive => { log::warn!("Operation for {id} already in progress; ignoring new download request"); @@ -370,128 +329,219 @@ pub async fn handle_download_game_files_command( } BeginOperationResult::DrainTimedOut => { log::error!("Timed out waiting for outbound transfers before downloading {id}"); - send_download_failed(tx_notify_ui, &id); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); + return; + } + BeginOperationResult::PublicationFailed => { + log::error!("Failed to withdraw published availability before downloading {id}"); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); + return; + } + BeginOperationResult::RecoveryBlocked => { + log::warn!("Ignoring download for recovery-blocked game {id}"); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); + return; + } + BeginOperationResult::GameDirChanged => { + log::warn!( + "Game directory changed while preparing download for {id}; retry the request against the current library" + ); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); return; } } - let active_operations = ctx.active_operations.clone(); - let active_downloads = ctx.active_downloads.clone(); let tx_notify_ui_clone = tx_notify_ui.clone(); let download_id = id.clone(); - let cancel_token = ctx.shutdown.child_token(); + let cancel_token = network_permit.child_token(); + let quic = network_permit.connector().clone(); let ctx_clone = ctx.clone(); + let operation_target = target.clone(); + let download_attempt = + register_download_attempt(ctx, tx_notify_ui, attempt, cancel_token.clone()).await; - ctx.active_downloads - .write() - .await - .insert(id, cancel_token.clone()); + ctx.task_tracker + .spawn(retain_guard_until_operation_completes( + network_permit, + async move { + let download_state_guard = + OperationGuard::download(download_id.clone(), cancel_token.clone()); - ctx.task_tracker.spawn(async move { - let download_state_guard = OperationGuard::download( - download_id.clone(), - active_operations, - active_downloads, - tx_notify_ui_clone.clone(), - ); - - let result = download_game_files( - manifest, - ctx_clone.state_dir.as_ref(), - peer_whitelist, - file_peer_map, - tx_notify_ui_clone.clone(), - cancel_token.clone(), - ) - .await; - - match result { - Ok(()) => { - let Some(prepared) = - prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await - else { - if let Err(err) = refresh_local_game_for_ending_operation( - &ctx_clone, - &tx_notify_ui_clone, - &download_id, - ) - .await - { - log::error!("Failed to refresh local library after download: {err}"); + let result = download_game_files(DownloadGameRequest { + attempt: &download_attempt, + manifest, + state_dir: ctx_clone.state_dir.as_ref(), + sources: &sources, + content_id, + quarantine: &ctx_clone.content_quarantine, + tx_notify_ui: tx_notify_ui_clone.clone(), + cancel_token: cancel_token.clone(), + quic, + }) + .await; + download_attempt.close_source_admission(); + download_attempt.clear_activity(); + let result = match result { + Ok(completion) => { + settle_download_completion(&ctx_clone, &operation_target, completion) + .await + .map_err(|error| (error, Some(DownloadFailureReason::OperationFailed))) + } + Err(error) => { + let reason = error.reason(); + Err((error.into_report(), reason)) } - end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await; - download_state_guard.disarm(); - send_download_finished(&tx_notify_ui_clone, &download_id); - return; }; - if install_after_download { - if transition_download_to_install( - &ctx_clone, - &tx_notify_ui_clone, - &download_id, - prepared.operation_kind, - ) - .await - { - clear_active_download(&ctx_clone, &download_id).await; - send_download_finished(&tx_notify_ui_clone, &download_id); - download_state_guard.disarm(); - run_started_install_operation( + match result { + Ok(()) => { + let Some(prepared) = prepare_install_operation( &ctx_clone, &tx_notify_ui_clone, - download_id, - prepared, + &operation_target, + ) + .await + else { + finish_successful_download_after_refresh( + &ctx_clone, + &tx_notify_ui_clone, + &operation_target, + download_state_guard, + &download_attempt, + ) + .await; + return; + }; + + if install_after_download { + if transition_download_to_install( + &ctx_clone, + &tx_notify_ui_clone, + &download_id, + prepared.operation_kind, + ) + .await + { + clear_active_download(&ctx_clone, download_attempt.key()).await; + download_attempt.emit_finished(); + run_started_install_operation( + &ctx_clone, + &tx_notify_ui_clone, + operation_target.clone(), + prepared, + download_state_guard, + cancel_token.clone(), + ) + .await; + } else { + finish_successful_download_after_refresh( + &ctx_clone, + &tx_notify_ui_clone, + &operation_target, + download_state_guard, + &download_attempt, + ) + .await; + } + } else { + finish_successful_download_after_refresh( + &ctx_clone, + &tx_notify_ui_clone, + &operation_target, + download_state_guard, + &download_attempt, + ) + .await; + } + } + Err((e, reason)) => { + let download_was_cancelled = cancel_token.is_cancelled(); + if download_was_cancelled { + log::info!("Download cancelled for {download_id}: {e}"); + } else { + log::error!("Download failed for {download_id}: {e}"); + } + finish_failed_download_after_refresh( + &ctx_clone, + &tx_notify_ui_clone, + &operation_target, + download_state_guard, + &download_attempt, + reason, ) .await; - } else { - end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await; - download_state_guard.disarm(); - send_download_finished(&tx_notify_ui_clone, &download_id); } - } else { - if let Err(err) = refresh_local_game_for_ending_operation( - &ctx_clone, - &tx_notify_ui_clone, - &download_id, - ) - .await - { - log::error!("Failed to refresh local library after download: {err}"); - } - end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await; - download_state_guard.disarm(); - send_download_finished(&tx_notify_ui_clone, &download_id); } - } - Err(e) => { - if let Err(refresh_err) = refresh_local_game_for_ending_operation( - &ctx_clone, - &tx_notify_ui_clone, - &download_id, - ) - .await - { - log::error!( - "Failed to refresh local library after download failure: {refresh_err}" - ); - } - end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await; - download_state_guard.disarm(); - let download_was_cancelled = cancel_token.is_cancelled(); - if download_was_cancelled { - log::info!("Download cancelled for {download_id}: {e}"); - } else { - log::error!("Download failed for {download_id}: {e}"); - } - send_download_failed_unless_cancelled( - &tx_notify_ui_clone, - &download_id, - download_was_cancelled, - ); - } + }, + )); +} + +fn finish_cached_download( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + attempt: &DownloadAttemptKey, + install_after_download: bool, + target: OperationTarget, +) { + let id = &attempt.id; + log::info!("Using locally downloaded files for game {id}; skipping peer transfer"); + let status = DownloadAttemptStatus::new( + attempt.clone(), + CancellationToken::new(), + tx_notify_ui.clone(), + ); + status.emit_begin(); + status.close_source_admission(); + status.emit_finished(); + if install_after_download { + spawn_install_operation(ctx, tx_notify_ui, target); + } +} + +async fn settle_download_completion( + ctx: &Ctx, + target: &OperationTarget, + completion: DownloadCompletion, +) -> eyre::Result<()> { + let game_id = target.game_id(); + let uncertain_commit = match completion { + DownloadCompletion::Durable => None, + DownloadCompletion::RecoveryRequired(cause) => { + log::warn!( + "Download committed for {game_id}, but readiness is quarantined pending recovery: {cause}" + ); + Some(cause) } - }); + }; + + install::recover_game_root(&target.game_root(), ctx.state_dir.as_ref(), game_id) + .await + .map_err(|recovery_error| match uncertain_commit { + Some(cause) => recovery_error.wrap_err(format!( + "download recovery did not settle for {game_id}; original completion error: {cause}" + )), + None => recovery_error.wrap_err(format!( + "download completion recovery did not settle for {game_id}" + )), + })?; + log::info!("Settled committed download state for {game_id} before publication"); + Ok(()) } /// Handles the `InstallGame` command. @@ -500,61 +550,249 @@ pub async fn handle_install_game_command( tx_notify_ui: &UnboundedSender, id: String, ) { - spawn_install_operation(ctx, tx_notify_ui, id); + let games_folder = ctx.game_dir.read().await.clone(); + spawn_install_operation(ctx, tx_notify_ui, OperationTarget::new(games_folder, id)); +} + +async fn stream_install_target_is_ready(ctx: &Ctx, target: &OperationTarget) -> bool { + let id = target.game_id(); + if ctx.recovery_quarantine.is_blocked(&target.games_folder, id) { + log::warn!("Ignoring streamed install command for recovery-blocked game {id}"); + return false; + } + if download_ownership_readiness(&target.games_folder, ctx.state_dir.as_ref(), id).await + == DownloadOwnershipReadiness::RecoveryRequired + { + log::warn!("Ignoring streamed install command for recovery-quarantined game {id}"); + return false; + } + if local_dir_is_directory(&target.game_root()).await { + log::warn!("Ignoring streamed install command for already-installed game {id}"); + return false; + } + true +} + +async fn begin_stream_install_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: &OperationTarget, + attempt: &DownloadAttemptKey, +) -> bool { + let id = target.game_id(); + match begin_operation(ctx, tx_notify_ui, target, OperationKind::Downloading).await { + BeginOperationResult::Started => true, + BeginOperationResult::AlreadyActive => { + log::warn!("Operation for {id} already in progress; ignoring streamed install request"); + false + } + BeginOperationResult::DrainTimedOut => { + log::error!("Timed out waiting for outbound transfers before streamed install of {id}"); + send_download_failed( + tx_notify_ui, + attempt, + DownloadFailureReason::OperationFailed, + ); + false + } + BeginOperationResult::PublicationFailed => { + log::error!( + "Failed to withdraw published availability before streamed install of {id}" + ); + send_download_failed( + tx_notify_ui, + attempt, + DownloadFailureReason::OperationFailed, + ); + false + } + BeginOperationResult::RecoveryBlocked => { + log::warn!("Ignoring streamed install for recovery-blocked game {id}"); + send_download_failed( + tx_notify_ui, + attempt, + DownloadFailureReason::OperationFailed, + ); + false + } + BeginOperationResult::GameDirChanged => { + log::warn!( + "Game directory changed while preparing streamed install for {id}; retry the request against the current library" + ); + send_download_failed( + tx_notify_ui, + attempt, + DownloadFailureReason::OperationFailed, + ); + false + } + } } pub async fn handle_stream_install_game_command( ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String, + settings: StreamInstallSettings, ) { - if !catalog_contains(ctx, &id).await { + let attempt = DownloadAttemptKey::next(id.clone()); + if !catalog_contains(ctx, &id) { log::warn!("Ignoring streamed install command for non-catalog game {id}"); - send_download_failed(tx_notify_ui, &id); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); + return; + } + + let manifest = match load_catalog_manifest(ctx, &id) { + Ok(manifest) => manifest, + Err(error) => { + log::error!("Failed to load catalog content manifest for {id}: {error}"); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); + return; + } + }; + if !manifest.supports_streamed_install() { + log::warn!("Ignoring streamed install for {id}: catalog has no extracted-file manifest"); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); return; } let games_folder = { ctx.game_dir.read().await.clone() }; - let game_root = games_folder.join(&id); - if local_dir_is_directory(&game_root).await { - log::warn!("Ignoring streamed install command for already-installed game {id}"); - send_download_failed(tx_notify_ui, &id); + let target = OperationTarget::new(games_folder, id.clone()); + if !stream_install_target_is_ready(ctx, &target).await { + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); return; } - match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await { - BeginOperationResult::Started => {} - BeginOperationResult::AlreadyActive => { - log::warn!("Operation for {id} already in progress; ignoring streamed install request"); - return; - } - BeginOperationResult::DrainTimedOut => { - log::error!("Timed out waiting for outbound transfers before streamed install of {id}"); - send_download_failed(tx_notify_ui, &id); + let network_permit = match ctx.network.try_acquire() { + Ok(permit) => permit, + Err(error) => { + log::warn!( + "Cannot stream install {id} while Local network sharing is unavailable: {error}" + ); + send_download_failed( + tx_notify_ui, + &attempt, + DownloadFailureReason::OperationFailed, + ); return; } + }; + + if !begin_stream_install_operation(ctx, tx_notify_ui, &target, &attempt).await { + return; } - let expected_version = catalog_expected_version(ctx, &id).await; - let cancel_token = ctx.shutdown.child_token(); - ctx.active_downloads - .write() - .await - .insert(id.clone(), cancel_token.clone()); + let cancel_token = network_permit.child_token(); + let quic = network_permit.connector().clone(); + let download_attempt = + register_download_attempt(ctx, tx_notify_ui, attempt, cancel_token.clone()).await; + let download_guard = OperationGuard::download(id.clone(), cancel_token.clone()); + + // Root-dependent preflight is repeated after admission. A preceding + // same-game operation may have changed ownership or install state after the + // optimistic check above but before this operation acquired the gate. + if !stream_install_target_is_ready(ctx, &target).await { + finish_failed_stream_download( + ctx, + tx_notify_ui, + &target, + download_guard, + &download_attempt, + Some(DownloadFailureReason::OperationFailed), + ) + .await; + return; + } let ctx_clone = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); - ctx.task_tracker.spawn(async move { - run_stream_install_operation( - ctx_clone, - tx_notify_ui, - id, - game_root, - expected_version, - cancel_token, - ) - .await; - }); + let operation = StreamInstallOperation { + ctx: ctx_clone, + tx_notify_ui, + target, + manifest, + settings, + quic, + cancel_token, + download_guard, + download_attempt, + }; + ctx.task_tracker + .spawn(retain_guard_until_operation_completes( + network_permit, + run_stream_install_operation(operation), + )); +} + +struct StreamInstallOperation { + ctx: Ctx, + tx_notify_ui: UnboundedSender, + target: OperationTarget, + manifest: Arc, + settings: StreamInstallSettings, + quic: QuicConnector, + cancel_token: CancellationToken, + download_guard: OperationGuard, + download_attempt: DownloadAttemptStatus, +} + +async fn stream_install_sources( + ctx: &Ctx, + target: &OperationTarget, + manifest: &CatalogContentManifest, +) -> Vec { + let peer_game_db = ctx.peer_game_db.read().await; + select_content_sources( + &peer_game_db, + target.game_id(), + manifest.content_id(), + &ctx.content_quarantine, + ) +} + +async fn select_stream_install_sources_or_finish( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: &OperationTarget, + manifest: &CatalogContentManifest, + download_guard: OperationGuard, + download_attempt: &DownloadAttemptStatus, +) -> Option<(Vec, OperationGuard)> { + let sources = stream_install_sources(ctx, target, manifest).await; + if !sources.is_empty() { + return Some((sources, download_guard)); + } + + log::error!( + "No eligible exact catalog content source available for streamed install of {}", + target.game_id() + ); + finish_failed_stream_download( + ctx, + tx_notify_ui, + target, + download_guard, + download_attempt, + Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted), + ) + .await; + None } /// Handles the `UninstallGame` command. @@ -563,10 +801,12 @@ pub async fn handle_uninstall_game_command( tx_notify_ui: &UnboundedSender, id: String, ) { + let games_folder = ctx.game_dir.read().await.clone(); + let target = OperationTarget::new(games_folder, id); let ctx = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); ctx.task_tracker.clone().spawn(async move { - run_uninstall_operation(&ctx, &tx_notify_ui, id).await; + run_uninstall_operation(&ctx, &tx_notify_ui, target).await; }); } @@ -575,10 +815,12 @@ pub async fn handle_remove_downloaded_game_command( tx_notify_ui: &UnboundedSender, id: String, ) { + let games_folder = ctx.game_dir.read().await.clone(); + let target = OperationTarget::new(games_folder, id); let ctx = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); ctx.task_tracker.clone().spawn(async move { - run_remove_downloaded_operation(&ctx, &tx_notify_ui, id).await; + run_remove_downloaded_operation(&ctx, &tx_notify_ui, target).await; }); } @@ -587,301 +829,544 @@ pub async fn handle_cancel_download_command( _tx_notify_ui: &UnboundedSender, id: String, ) { - let cancel_token = ctx.active_downloads.read().await.get(&id).cloned(); - let Some(cancel_token) = cancel_token else { + let signal = ctx.active_downloads.read().await.get(&id).cloned(); + let Some(signal) = signal else { log::warn!("Ignoring cancel request for inactive download {id}"); return; }; log::info!("Cancelling download for game {id}"); - cancel_token.cancel(); + signal.cancel_silently(); } -async fn run_stream_install_operation( - ctx: Ctx, - tx_notify_ui: UnboundedSender, - id: String, - game_root: PathBuf, - expected_version: Option, - cancel_token: CancellationToken, -) { - let download_guard = OperationGuard::download( - id.clone(), - ctx.active_operations.clone(), - ctx.active_downloads.clone(), - tx_notify_ui.clone(), - ); +async fn run_stream_install_operation(operation: StreamInstallOperation) { + let StreamInstallOperation { + ctx, + tx_notify_ui, + target, + manifest, + settings, + quic, + cancel_token, + download_guard, + download_attempt, + } = operation; + let id = target.game_id.clone(); + download_attempt.emit_begin(); - events::send( - &tx_notify_ui, - PeerEvent::DownloadGameFilesBegin { id: id.clone() }, - ); - - let peer_addrs = - match select_stream_install_peers(&ctx, &id, expected_version.as_deref(), &cancel_token) - .await - { - Ok(peers) => peers, - Err(err) => { - let download_was_cancelled = cancel_token.is_cancelled(); - if download_was_cancelled { - log::info!("Streamed install preflight cancelled for {id}: {err}"); - } else { - log::error!("Streamed install preflight failed for {id}: {err}"); - } - finish_failed_stream_download( - &ctx, - &tx_notify_ui, - &id, - download_guard, - download_was_cancelled, - ) - .await; - return; - } - }; - - match receive_streamed_install_from_peers( + let Some((sources, download_guard)) = select_stream_install_sources_or_finish( &ctx, &tx_notify_ui, - &id, - &game_root, - &peer_addrs, - &cancel_token, + &target, + &manifest, + download_guard, + &download_attempt, ) .await - { + else { + return; + }; + + let receive_result = receive_streamed_install_from_peers(StreamInstallReceiveRequest { + ctx: &ctx, + tx_notify_ui: &tx_notify_ui, + target: &target, + manifest: &manifest, + sources: &sources, + quic: &quic, + cancel_token: &cancel_token, + attempt: download_attempt.reporter(), + }) + .await; + download_attempt.close_source_admission(); + download_attempt.clear_activity(); + + match receive_result { Ok(transaction) => { + let promotion = StreamInstallPromotionPreparation { + ctx: &ctx, + tx_notify_ui: &tx_notify_ui, + target: &target, + transaction, + settings: &settings, + cancel_token: &cancel_token, + download_guard, + download_attempt: &download_attempt, + }; + let Some((transaction, download_guard)) = + prepare_streamed_install_for_promotion(promotion).await + else { + return; + }; + if transition_download_to_install(&ctx, &tx_notify_ui, &id, OperationKind::Installing) .await { - clear_active_download(&ctx, &id).await; - send_download_finished(&tx_notify_ui, &id); - download_guard.disarm(); - commit_streamed_install(&ctx, &tx_notify_ui, id, transaction).await; + commit_streamed_install(StreamInstallCommit { + ctx, + tx_notify_ui, + target, + transaction, + manifest, + cancel_token, + operation_guard: download_guard, + download_attempt, + }) + .await; return; } - if let Err(err) = transaction.rollback().await { + if let Err(err) = transaction.rollback() { log::error!("Failed to roll back streamed install for {id}: {err}"); } - finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false).await; - } - Err(err) => { - let download_was_cancelled = cancel_token.is_cancelled(); - if download_was_cancelled { - log::info!("Streamed install download cancelled for {id}: {err}"); - } else { - log::error!("Streamed install download failed for {id}: {err}"); - } finish_failed_stream_download( &ctx, &tx_notify_ui, - &id, + &target, download_guard, - download_was_cancelled, + &download_attempt, + Some(DownloadFailureReason::OperationFailed), + ) + .await; + } + Err(err) => { + finish_stream_receive_error( + &ctx, + &tx_notify_ui, + &target, + download_guard, + &download_attempt, + err, ) .await; } } } -async fn receive_streamed_install_from_peers( +async fn finish_stream_receive_error( ctx: &Ctx, tx_notify_ui: &UnboundedSender, + target: &OperationTarget, + download_guard: OperationGuard, + download_attempt: &DownloadAttemptStatus, + error: StreamDownloadError, +) { + if error.reason.is_none() { + log::info!( + "Streamed install download cancelled for {}: {error}", + target.game_id() + ); + } else { + log::error!( + "Streamed install download failed for {}: {error}", + target.game_id() + ); + } + finish_failed_stream_download( + ctx, + tx_notify_ui, + target, + download_guard, + download_attempt, + error.reason, + ) + .await; +} + +struct StreamInstallPromotionPreparation<'a> { + ctx: &'a Ctx, + tx_notify_ui: &'a UnboundedSender, + target: &'a OperationTarget, + transaction: install::StreamedInstallTransaction, + settings: &'a StreamInstallSettings, + cancel_token: &'a CancellationToken, + download_guard: OperationGuard, + download_attempt: &'a DownloadAttemptStatus, +} + +async fn prepare_streamed_install_for_promotion( + preparation: StreamInstallPromotionPreparation<'_>, +) -> Option<(install::StreamedInstallTransaction, OperationGuard)> { + let StreamInstallPromotionPreparation { + ctx, + tx_notify_ui, + target, + transaction, + settings, + cancel_token, + download_guard, + download_attempt, + } = preparation; + let id = target.game_id(); + if let Err(error) = apply_launch_settings_to_verified_tree( + transaction.staging_dir(), + Some(settings.account_name()), + Some(settings.language()), + Some(settings.persona_name()), + ) { + log::error!( + "Failed to apply launch settings to verified streamed install for {id}: {error}" + ); + if let Err(rollback_error) = transaction.rollback() { + log::error!( + "Failed to roll back streamed install for {id} after launch-settings failure: {rollback_error}" + ); + } + finish_failed_stream_download( + ctx, + tx_notify_ui, + target, + download_guard, + download_attempt, + Some(DownloadFailureReason::OperationFailed), + ) + .await; + return None; + } + + // Launch-settings mutation is intentionally finite and non-detachable. + // Honor cancellation after it reaches a stable tree boundary, before the + // verified staging directory can be promoted. + if cancel_token.is_cancelled() { + log::info!("Streamed install for {id} was cancelled after applying launch settings"); + let reason = match transaction.rollback() { + Ok(()) => None, + Err(rollback_error) => { + log::error!( + "Failed to roll back cancelled streamed install for {id}: {rollback_error}" + ); + Some(DownloadFailureReason::OperationFailed) + } + }; + finish_failed_stream_download( + ctx, + tx_notify_ui, + target, + download_guard, + download_attempt, + reason, + ) + .await; + return None; + } + + Some((transaction, download_guard)) +} + +struct StreamInstallReceiveRequest<'a> { + ctx: &'a Ctx, + tx_notify_ui: &'a UnboundedSender, + target: &'a OperationTarget, + manifest: &'a Arc, + sources: &'a [PeerEndpoint], + quic: &'a QuicConnector, + cancel_token: &'a CancellationToken, + attempt: DownloadAttemptReporter, +} + +fn settle_failed_stream_receive( + ctx: &Ctx, + source: &PeerEndpoint, + content_id: ContentId, id: &str, + transaction: install::StreamedInstallTransaction, + error: StreamInstallReceiveError, +) -> Result<(StreamInstallFailureDisposition, StreamInstallReceiveError), StreamDownloadError> { + let disposition = stream_install_failure_disposition(error.kind()); + if disposition == StreamInstallFailureDisposition::RetryAndQuarantine { + ctx.content_quarantine + .record_integrity_failure(source, content_id); + } + transaction.rollback().map_err(|rollback_error| { + StreamDownloadError::operation_failed(eyre::eyre!( + "streamed install attempt from {} at {} failed for {id}: {error}; rollback also failed: {rollback_error}", + source.peer_id, + source.addr + )) + })?; + Ok((disposition, error)) +} + +fn exhausted_stream_receive_error( + id: &str, + last_receive_error: Option, +) -> StreamDownloadError { + match last_receive_error { + Some(error) => StreamDownloadError::sources_exhausted(eyre::Report::new(error)), + None => StreamDownloadError::sources_exhausted(eyre::eyre!( + "streamed install download failed for {id}: no peer attempts were made" + )), + } +} + +fn begin_stream_receive_attempt( game_root: &Path, - peer_addrs: &[SocketAddr], - cancel_token: &CancellationToken, -) -> eyre::Result { + state_dir: &Path, + id: &str, + retry_invalid_source: &mut bool, + attempt: &DownloadAttemptReporter, +) -> Result { + let transaction = install::begin_streamed_install(game_root, state_dir, id) + .map_err(StreamDownloadError::operation_failed)?; + if std::mem::take(retry_invalid_source) { + attempt.set_activity(DownloadVerificationActivity::RetryingInvalidSource); + } else { + attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks); + } + Ok(transaction) +} + +async fn receive_streamed_install_from_peers( + request: StreamInstallReceiveRequest<'_>, +) -> Result { + let StreamInstallReceiveRequest { + ctx, + tx_notify_ui, + target, + manifest, + sources, + quic, + cancel_token, + attempt, + } = request; + let id = target.game_id(); + let game_root = target.game_root(); let mut last_receive_error = None; - for &peer_addr in peer_addrs { + let mut retry_invalid_source = false; + let content_id = manifest.content_id(); + for source in sources { if cancel_token.is_cancelled() { - eyre::bail!("streamed install for {id} was cancelled"); + return Err(StreamDownloadError::cancelled(eyre::eyre!( + "streamed install for {id} was cancelled" + ))); + } + if ctx.content_quarantine.is_quarantined(source, content_id) { + log::debug!( + "Skipping quarantined streamed-install source {} at {} for {id}", + source.peer_id, + source.addr + ); + continue; } - let transaction = - install::begin_streamed_install(game_root, ctx.state_dir.as_ref(), id).await?; - let receive_result = receive_streamed_install( - peer_addr, + let transaction = begin_stream_receive_attempt( + &game_root, + ctx.state_dir.as_ref(), id, - transaction.staging_dir(), - tx_notify_ui.clone(), - cancel_token.clone(), - ) + &mut retry_invalid_source, + &attempt, + )?; + let receive_result = receive_streamed_install(ReceiveStreamedInstallRequest { + endpoint: *source, + game_id: id, + manifest: Arc::clone(manifest), + staging_dir: transaction.staging_dir(), + attempt: attempt.clone(), + tx_notify_ui: tx_notify_ui.clone(), + quic, + cancel_token: cancel_token.clone(), + }) .await; match receive_result { Ok(()) => return Ok(transaction), Err(err) => { - if let Err(rollback_err) = transaction.rollback().await { - log::error!("Failed to roll back streamed install for {id}: {rollback_err}"); + let error_kind = err.kind(); + let (disposition, err) = + settle_failed_stream_receive(ctx, source, content_id, id, transaction, err)?; + + match disposition { + StreamInstallFailureDisposition::RetryAndQuarantine => { + log::warn!( + "Streamed install attempt from {} at {} failed for {id}; trying another peer if available: {err}", + source.peer_id, + source.addr + ); + last_receive_error = Some(err); + retry_invalid_source = true; + } + StreamInstallFailureDisposition::Retry => { + log::warn!( + "Streamed install attempt from {} at {} failed for {id}; trying another peer if available: {err}", + source.peer_id, + source.addr + ); + last_receive_error = Some(err); + } + StreamInstallFailureDisposition::Stop => { + let error = eyre::Report::new(err); + return Err(match error_kind { + StreamInstallReceiveErrorKind::Cancelled => { + StreamDownloadError::cancelled(error) + } + StreamInstallReceiveErrorKind::Setup => { + StreamDownloadError::operation_failed(error) + } + StreamInstallReceiveErrorKind::Integrity + | StreamInstallReceiveErrorKind::Transport => { + unreachable!("retryable stream failures cannot stop immediately") + } + }); + } } - if cancel_token.is_cancelled() { - return Err(err); - } - - log::warn!( - "Streamed install attempt from {peer_addr} failed for {id}; trying another peer if available: {err}" - ); - last_receive_error = Some(err); } } } - Err(last_receive_error.unwrap_or_else(|| { - eyre::eyre!("streamed install download failed for {id}: no peer attempts were made") - })) -} - -async fn select_stream_install_peers( - ctx: &Ctx, - id: &str, - expected_version: Option<&str>, - cancel_token: &CancellationToken, -) -> eyre::Result> { - let mut metadata_peers = { - ctx.peer_game_db - .read() - .await - .peers_with_expected_version(id, expected_version) - }; - metadata_peers.sort(); - if metadata_peers.is_empty() { - eyre::bail!("no peers have game {id}"); - } - - refresh_stream_install_file_details(ctx, id, &metadata_peers, cancel_token).await?; - - let mut peers = match ctx - .peer_game_db - .read() - .await - .validate_file_sizes_majority(id, expected_version) - { - Ok((validated_files, peer_whitelist, _)) if !validated_files.is_empty() => peer_whitelist, - Ok(_) => { - eyre::bail!("no trusted peers available for streamed install of {id}"); - } - Err(err) => { - return Err(err.wrap_err(format!( - "file size majority validation failed for streamed install {id}" - ))); - } - }; - peers.sort(); - if peers.is_empty() { - eyre::bail!("no peer selected for streamed install of {id}"); - } - - Ok(peers) -} - -async fn refresh_stream_install_file_details( - ctx: &Ctx, - id: &str, - peers: &[SocketAddr], - cancel_token: &CancellationToken, -) -> eyre::Result<()> { - let mut fetched_any = false; - for &peer_addr in peers { - if cancel_token.is_cancelled() { - eyre::bail!("streamed install for {id} was cancelled"); - } - - match request_game_details_and_update(peer_addr, id, ctx.peer_game_db.clone()).await { - Ok(_) => { - log::info!("Fetched streamed-install file list for {id} from peer {peer_addr}"); - fetched_any = true; - } - Err(err) => { - log::error!( - "Failed to fetch streamed-install files for {id} from {peer_addr}: {err}" - ); - } - } - } - - if !fetched_any { - eyre::bail!("failed to retrieve game files for {id} from any peer"); - } - - Ok(()) + Err(exhausted_stream_receive_error(id, last_receive_error)) } async fn finish_failed_stream_download( ctx: &Ctx, tx_notify_ui: &UnboundedSender, - id: &str, + target: &OperationTarget, guard: OperationGuard, - cancelled: bool, + status: &DownloadAttemptStatus, + direct_reason: Option, ) { - if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, id).await { - log::error!("Failed to refresh local library after streamed install failure: {err}"); + status.close_source_admission(); + status.clear_activity(); + if settle_and_end_download( + ctx, + tx_notify_ui, + target, + guard, + status.key(), + "streamed install failure", + ) + .await + && let Some(reason) = status.resolve_failure(direct_reason) + { + status.emit_failed(reason); } - end_download_operation(ctx, tx_notify_ui, id).await; - guard.disarm(); - send_download_failed_unless_cancelled(tx_notify_ui, id, cancelled); } -async fn commit_streamed_install( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - id: String, +struct StreamInstallCommit { + ctx: Ctx, + tx_notify_ui: UnboundedSender, + target: OperationTarget, transaction: install::StreamedInstallTransaction, -) { - let operation_guard = OperationGuard::new( - id.clone(), - ctx.active_operations.clone(), - tx_notify_ui.clone(), - ); + manifest: Arc, + cancel_token: CancellationToken, + operation_guard: OperationGuard, + download_attempt: DownloadAttemptStatus, +} - match transaction.commit().await { +async fn commit_streamed_install(commit: StreamInstallCommit) { + let StreamInstallCommit { + ctx, + tx_notify_ui, + target, + transaction, + manifest, + cancel_token, + operation_guard, + download_attempt, + } = commit; + let id = target.game_id().to_owned(); + + match promote_streamed_install( + ctx.state_dir.as_ref(), + &id, + transaction, + &manifest, + &cancel_token, + ) { Ok(()) => { - if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await + // Promotion is now past its durable rename boundary. Only publish + // download success after cancellation can no longer veto it. + clear_active_download(&ctx, download_attempt.key()).await; + download_attempt.emit_finished(); + if settle_and_end_operation( + &ctx, + &tx_notify_ui, + &target, + operation_guard, + "streamed install completion", + ) + .await { - log::error!("Failed to refresh local library after streamed install: {err}"); + events::send( + &tx_notify_ui, + PeerEvent::InstallGameFinished { id: id.clone() }, + ); + } else { + events::send( + &tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.clone() }, + ); } - end_operation(ctx, tx_notify_ui, &id).await; - operation_guard.disarm(); - events::send( - tx_notify_ui, - PeerEvent::InstallGameFinished { id: id.clone() }, - ); } - Err(err) => { - log::error!("Streamed install commit failed for {id}: {err}"); - if let Err(refresh_err) = - refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await - { - log::error!( - "Failed to refresh local library after streamed install commit failure: {refresh_err}" + Err(error) => { + let direct_reason = match error { + install::StreamedInstallCommitError::Cancelled(error) => { + log::info!("Streamed install was cancelled before promotion for {id}: {error}"); + None + } + install::StreamedInstallCommitError::OperationFailed(error) => { + log::error!("Streamed install commit failed for {id}: {error}"); + Some(DownloadFailureReason::OperationFailed) + } + }; + let settled = settle_and_end_operation( + &ctx, + &tx_notify_ui, + &target, + operation_guard, + "streamed install commit failure", + ) + .await; + clear_active_download(&ctx, download_attempt.key()).await; + if settled && let Some(reason) = download_attempt.resolve_failure(direct_reason) { + download_attempt.emit_failed(reason); + events::send( + &tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.clone() }, ); } - end_operation(ctx, tx_notify_ui, &id).await; - operation_guard.disarm(); - events::send( - tx_notify_ui, - PeerEvent::InstallGameFailed { id: id.clone() }, - ); } } } -fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { +fn promote_streamed_install( + state_dir: &Path, + id: &str, + transaction: install::StreamedInstallTransaction, + manifest: &CatalogContentManifest, + cancel_token: &CancellationToken, +) -> Result<(), install::StreamedInstallCommitError> { + transaction.commit_classified(manifest, cancel_token)?; + if let Err(error) = mark_launch_settings_applied(state_dir, id) { + log::warn!( + "Streamed install for {id} was promoted, but its launch-settings marker could not be written; first play will retry the rewrite: {error}" + ); + } + Ok(()) +} + +fn spawn_install_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: OperationTarget, +) { let ctx = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); ctx.task_tracker.clone().spawn(async move { - run_install_operation(&ctx, &tx_notify_ui, id).await; + run_install_operation(&ctx, &tx_notify_ui, target).await; }); } -async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { - let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else { +async fn run_install_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: OperationTarget, +) { + let id = target.game_id().to_owned(); + let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &target).await else { return; }; - match begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await { + match begin_operation(ctx, tx_notify_ui, &target, prepared.operation_kind).await { BeginOperationResult::Started => {} BeginOperationResult::AlreadyActive => { log::warn!("Operation for {id} already in progress; ignoring install command"); @@ -895,13 +1380,62 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender { + log::error!("Failed to withdraw published availability before install/update of {id}"); + events::send( + tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.clone() }, + ); + return; + } + BeginOperationResult::RecoveryBlocked => { + log::warn!("Ignoring install for recovery-blocked game {id}"); + events::send( + tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.clone() }, + ); + return; + } + BeginOperationResult::GameDirChanged => { + log::warn!( + "Game directory changed while preparing install for {id}; retry the request against the current library" + ); + events::send( + tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.clone() }, + ); + return; + } } - run_started_install_operation(ctx, tx_notify_ui, id, prepared).await; + let cancel_token = ctx.shutdown.child_token(); + let operation_guard = OperationGuard::cancellable(id.clone(), cancel_token.clone()); + let Some(revalidated) = + revalidate_install_operation(ctx, tx_notify_ui, &target, prepared.operation_kind).await + else { + let _ = settle_and_end_operation( + ctx, + tx_notify_ui, + &target, + operation_guard, + "install preflight rejection", + ) + .await; + return; + }; + + run_started_install_operation( + ctx, + tx_notify_ui, + target, + revalidated, + operation_guard, + cancel_token, + ) + .await; } struct PreparedInstallOperation { - game_root: PathBuf, operation: InstallOperation, operation_kind: OperationKind, } @@ -909,14 +1443,34 @@ struct PreparedInstallOperation { async fn prepare_install_operation( ctx: &Ctx, tx_notify_ui: &UnboundedSender, - id: &str, + target: &OperationTarget, ) -> Option { - if !catalog_contains(ctx, id).await { + let id = target.game_id(); + if !catalog_contains(ctx, id) { log::warn!("Ignoring install command for non-catalog game {id}"); return None; } - let game_root = { ctx.game_dir.read().await.join(id) }; + if ctx.recovery_quarantine.is_blocked(&target.games_folder, id) { + log::warn!("Ignoring install command for recovery-blocked game {id}"); + events::send( + tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.to_string() }, + ); + return None; + } + if download_ownership_readiness(&target.games_folder, ctx.state_dir.as_ref(), id).await + == DownloadOwnershipReadiness::RecoveryRequired + { + log::warn!("Ignoring install command for recovery-quarantined game {id}"); + events::send( + tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.to_string() }, + ); + return None; + } + + let game_root = target.game_root(); if !version_ini_is_regular_file(&game_root).await { log::warn!("Ignoring install command for {id}: version.ini sentinel is absent"); events::send( @@ -938,63 +1492,103 @@ async fn prepare_install_operation( }; Some(PreparedInstallOperation { - game_root, operation, operation_kind, }) } +async fn revalidate_install_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: &OperationTarget, + expected_kind: OperationKind, +) -> Option { + let revalidated = prepare_install_operation(ctx, tx_notify_ui, target).await?; + if revalidated.operation_kind == expected_kind { + return Some(revalidated); + } + + let id = target.game_id(); + log::warn!( + "Install state changed while admitting {id}; refusing stale {expected_kind:?} operation as {:?}", + revalidated.operation_kind + ); + events::send( + tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.to_string() }, + ); + None +} + async fn run_started_install_operation( ctx: &Ctx, tx_notify_ui: &UnboundedSender, - id: String, + target: OperationTarget, prepared: PreparedInstallOperation, + operation_guard: OperationGuard, + cancel_token: CancellationToken, ) { - let PreparedInstallOperation { - game_root, - operation, - .. - } = prepared; - - let operation_guard = OperationGuard::new( - id.clone(), - ctx.active_operations.clone(), - tx_notify_ui.clone(), - ); + let id = target.game_id().to_owned(); + let game_root = target.game_root(); + let operation = prepared.operation; let result = { let state_dir = ctx.state_dir.as_ref(); match operation { InstallOperation::Installing => { - install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await + install::install( + &game_root, + state_dir, + &id, + ctx.unpacker.clone(), + cancel_token.clone(), + ) + .await } InstallOperation::Updating => { - install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await + install::update( + &game_root, + state_dir, + &id, + ctx.unpacker.clone(), + cancel_token.clone(), + ) + .await } } }; match result { Ok(()) => { - if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await - { - log::error!("Failed to refresh local library after install: {err}"); - } - end_operation(ctx, tx_notify_ui, &id).await; - operation_guard.disarm(); - events::send( + if settle_and_end_operation( + ctx, tx_notify_ui, - PeerEvent::InstallGameFinished { id: id.clone() }, - ); + &target, + operation_guard, + "install completion", + ) + .await + { + events::send( + tx_notify_ui, + PeerEvent::InstallGameFinished { id: id.clone() }, + ); + } else { + events::send( + tx_notify_ui, + PeerEvent::InstallGameFailed { id: id.clone() }, + ); + } } Err(err) => { log::error!("Install operation failed for {id}: {err}"); - if let Err(refresh_err) = - refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await - { - log::error!("Failed to refresh local library after install failure: {refresh_err}"); - } - end_operation(ctx, tx_notify_ui, &id).await; - operation_guard.disarm(); + let _ = settle_and_end_operation( + ctx, + tx_notify_ui, + &target, + operation_guard, + "install failure", + ) + .await; events::send( tx_notify_ui, PeerEvent::InstallGameFailed { id: id.clone() }, @@ -1003,13 +1597,18 @@ async fn run_started_install_operation( } } -async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { - if !catalog_contains(ctx, &id).await { +async fn run_uninstall_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: OperationTarget, +) { + let id = target.game_id().to_owned(); + if !catalog_contains(ctx, &id) { log::warn!("Ignoring uninstall command for non-catalog game {id}"); return; } - match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await { + match begin_operation(ctx, tx_notify_ui, &target, OperationKind::Uninstalling).await { BeginOperationResult::Started => {} BeginOperationResult::AlreadyActive => { log::warn!("Operation for {id} already in progress; ignoring uninstall command"); @@ -1023,40 +1622,70 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender { + log::error!("Failed to withdraw published availability before uninstall of {id}"); + events::send( + tx_notify_ui, + PeerEvent::UninstallGameFailed { id: id.clone() }, + ); + return; + } + BeginOperationResult::RecoveryBlocked => { + log::warn!("Ignoring uninstall for recovery-blocked game {id}"); + events::send( + tx_notify_ui, + PeerEvent::UninstallGameFailed { id: id.clone() }, + ); + return; + } + BeginOperationResult::GameDirChanged => { + log::warn!( + "Game directory changed before uninstall admission for {id}; refusing to retarget the command" + ); + events::send( + tx_notify_ui, + PeerEvent::UninstallGameFailed { id: id.clone() }, + ); + return; + } } - let game_root = { ctx.game_dir.read().await.join(&id) }; - let operation_guard = OperationGuard::new( - id.clone(), - ctx.active_operations.clone(), - tx_notify_ui.clone(), - ); - let result = install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await; + let game_root = target.game_root(); + let operation_guard = OperationGuard::new(id.clone()); + let result = install::uninstall(&game_root, ctx.state_dir.as_ref(), &id); match result { Ok(()) => { - if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await - { - log::error!("Failed to refresh local library after uninstall: {err}"); - } - end_operation(ctx, tx_notify_ui, &id).await; - operation_guard.disarm(); - events::send( + if settle_and_end_operation( + ctx, tx_notify_ui, - PeerEvent::UninstallGameFinished { id: id.clone() }, - ); + &target, + operation_guard, + "uninstall completion", + ) + .await + { + events::send( + tx_notify_ui, + PeerEvent::UninstallGameFinished { id: id.clone() }, + ); + } else { + events::send( + tx_notify_ui, + PeerEvent::UninstallGameFailed { id: id.clone() }, + ); + } } Err(err) => { log::error!("Uninstall operation failed for {id}: {err}"); - if let Err(refresh_err) = - refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await - { - log::error!( - "Failed to refresh local library after uninstall failure: {refresh_err}" - ); - } - end_operation(ctx, tx_notify_ui, &id).await; - operation_guard.disarm(); + let _ = settle_and_end_operation( + ctx, + tx_notify_ui, + &target, + operation_guard, + "uninstall failure", + ) + .await; events::send( tx_notify_ui, PeerEvent::UninstallGameFailed { id: id.clone() }, @@ -1068,14 +1697,15 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, - id: String, + target: OperationTarget, ) { - if !catalog_contains(ctx, &id).await { + let id = target.game_id().to_owned(); + if !catalog_contains(ctx, &id) { log::warn!("Ignoring downloaded-file removal for non-catalog game {id}"); return; } - match begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await { + match begin_operation(ctx, tx_notify_ui, &target, OperationKind::RemovingDownload).await { BeginOperationResult::Started => {} BeginOperationResult::AlreadyActive => { log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal"); @@ -1089,40 +1719,71 @@ async fn run_remove_downloaded_operation( ); return; } + BeginOperationResult::PublicationFailed => { + log::error!( + "Failed to withdraw published availability before downloaded-file removal of {id}" + ); + events::send( + tx_notify_ui, + PeerEvent::RemoveDownloadedGameFailed { id: id.clone() }, + ); + return; + } + BeginOperationResult::RecoveryBlocked => { + log::warn!("Ignoring downloaded-file removal for recovery-blocked game {id}"); + events::send( + tx_notify_ui, + PeerEvent::RemoveDownloadedGameFailed { id: id.clone() }, + ); + return; + } + BeginOperationResult::GameDirChanged => { + log::warn!( + "Game directory changed before downloaded-file removal admission for {id}; refusing to retarget the command" + ); + events::send( + tx_notify_ui, + PeerEvent::RemoveDownloadedGameFailed { id: id.clone() }, + ); + return; + } } - let game_dir = { ctx.game_dir.read().await.clone() }; - let operation_guard = OperationGuard::new( - id.clone(), - ctx.active_operations.clone(), - tx_notify_ui.clone(), - ); - let result = remove_downloaded_payload(&game_dir, ctx.state_dir.as_ref(), &id).await; + let operation_guard = OperationGuard::new(id.clone()); + let result = remove_downloaded_payload(&target.games_folder, ctx.state_dir.as_ref(), &id).await; match result { Ok(()) => { - if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await - { - log::error!("Failed to refresh local library after downloaded-file removal: {err}"); - } - end_operation(ctx, tx_notify_ui, &id).await; - operation_guard.disarm(); - events::send( + if settle_and_end_operation( + ctx, tx_notify_ui, - PeerEvent::RemoveDownloadedGameFinished { id: id.clone() }, - ); + &target, + operation_guard, + "downloaded-file removal completion", + ) + .await + { + events::send( + tx_notify_ui, + PeerEvent::RemoveDownloadedGameFinished { id: id.clone() }, + ); + } else { + events::send( + tx_notify_ui, + PeerEvent::RemoveDownloadedGameFailed { id: id.clone() }, + ); + } } Err(err) => { log::error!("Downloaded-file removal failed for {id}: {err}"); - if let Err(refresh_err) = - refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await - { - log::error!( - "Failed to refresh local library after downloaded-file removal failure: {refresh_err}" - ); - } - end_operation(ctx, tx_notify_ui, &id).await; - operation_guard.disarm(); + let _ = settle_and_end_operation( + ctx, + tx_notify_ui, + &target, + operation_guard, + "downloaded-file removal failure", + ) + .await; events::send( tx_notify_ui, PeerEvent::RemoveDownloadedGameFailed { id: id.clone() }, @@ -1135,19 +1796,22 @@ async fn run_remove_downloaded_operation( enum BeginOperationResult { Started, AlreadyActive, + GameDirChanged, DrainTimedOut, + RecoveryBlocked, + PublicationFailed, } async fn begin_operation( ctx: &Ctx, tx_notify_ui: &UnboundedSender, - id: &str, + target: &OperationTarget, operation: OperationKind, ) -> BeginOperationResult { begin_operation_with_drain_timeout( ctx, tx_notify_ui, - id, + target, operation, OUTBOUND_TRANSFER_DRAIN_TIMEOUT, ) @@ -1157,18 +1821,62 @@ async fn begin_operation( async fn begin_operation_with_drain_timeout( ctx: &Ctx, tx_notify_ui: &UnboundedSender, - id: &str, + target: &OperationTarget, operation: OperationKind, drain_timeout: Duration, ) -> BeginOperationResult { + let admission = ctx.operation_admission.lock().await; + let game_dir = ctx.game_dir.read().await; + if *game_dir != target.games_folder { + return BeginOperationResult::GameDirChanged; + } + if ctx + .recovery_quarantine + .is_blocked(&target.games_folder, target.game_id()) + { + return BeginOperationResult::RecoveryBlocked; + } + drop(game_dir); let started = { let mut active_operations = ctx.active_operations.write().await; - match active_operations.entry(id.to_string()) { - Entry::Vacant(entry) => { - entry.insert(operation); - true + if active_operations.contains_key(target.game_id()) { + false + } else { + // Acquire both guards before making the operation visible. A fresh + // Hello can therefore observe either the pre-operation projection + // or the withdrawn one, never an active operation that is still + // advertised as available. + let mut library = ctx.local_library.write().await; + let withdrawn_revision = match library.withdraw_for_operation(target.game_id()) { + Ok(revision) => revision, + Err(error) => { + log::error!( + "Cannot admit {operation:?} for {}: {error}", + target.game_id() + ); + return BeginOperationResult::PublicationFailed; + } + }; + active_operations.insert(target.game_id.clone(), operation); + + if let Some(revision) = withdrawn_revision { + let game_db = GameDB::from( + library + .games + .values() + .map(game_from_summary) + .collect::>(), + ); + *ctx.local_game_db.write().await = Some(game_db.clone()); + events::send( + tx_notify_ui, + PeerEvent::LocalLibraryChanged { + games: game_db.all_games().into_iter().cloned().collect(), + }, + ); + ctx.state_sync.publish_library_revision(revision); } - Entry::Occupied(_) => false, + true } }; @@ -1176,12 +1884,30 @@ async fn begin_operation_with_drain_timeout( return BeginOperationResult::AlreadyActive; } + // Once admitted, a directory change observes the active operation and is + // rejected. Release the admission barrier before emitting or draining. + drop(admission); + events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await; if operation_requires_outbound_drain(operation) - && !cancel_and_wait_for_outbound_transfers(ctx, id, drain_timeout).await + && !cancel_and_wait_for_outbound_transfers( + ctx, + OutboundTransferScope::Game(target.game_id()), + drain_timeout, + ) + .await { - end_operation(ctx, tx_notify_ui, id).await; + if let Err(error) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, target).await + { + // The withdrawn projection is safe to retain. Do not restore stale + // availability when the authoritative rescan cannot complete. + log::error!( + "Failed to restore local availability after outbound drain timeout for {}: {error}", + target.game_id() + ); + } + end_operation(ctx, tx_notify_ui, target.game_id()).await; return BeginOperationResult::DrainTimedOut; } @@ -1189,23 +1915,65 @@ async fn begin_operation_with_drain_timeout( } fn operation_requires_outbound_drain(operation: OperationKind) -> bool { - operation == OperationKind::Updating || operation == OperationKind::RemovingDownload + matches!( + operation, + OperationKind::Downloading | OperationKind::Updating | OperationKind::RemovingDownload + ) +} + +#[derive(Clone, Copy)] +enum OutboundTransferScope<'a> { + Game(&'a str), + All, +} + +impl OutboundTransferScope<'_> { + fn matches(self, game_id: &str) -> bool { + match self { + Self::Game(target) => target == game_id, + Self::All => true, + } + } + + fn description(self) -> String { + match self { + Self::Game(game_id) => format!("for {game_id}"), + Self::All => "across all games".to_string(), + } + } +} + +fn outbound_transfer_tokens( + active: &HashMap>, + scope: OutboundTransferScope<'_>, +) -> Vec { + active + .iter() + .filter(|(game_id, _)| scope.matches(game_id)) + .flat_map(|(_, transfers)| transfers.iter().map(|(_, token)| token.clone())) + .collect() +} + +fn outbound_transfer_count( + active: &HashMap>, + scope: OutboundTransferScope<'_>, +) -> usize { + active + .iter() + .filter(|(game_id, _)| scope.matches(game_id)) + .map(|(_, transfers)| transfers.len()) + .sum() } async fn cancel_and_wait_for_outbound_transfers( ctx: &Ctx, - id: &str, + scope: OutboundTransferScope<'_>, drain_timeout: Duration, ) -> bool { - let mut tokens_to_cancel = Vec::new(); - { + let tokens_to_cancel = { let active = ctx.active_outbound_transfers.read().await; - if let Some(transfers) = active.get(id) { - for (_, token) in transfers { - tokens_to_cancel.push(token.clone()); - } - } - } + outbound_transfer_tokens(&active, scope) + }; for token in tokens_to_cancel { token.cancel(); } @@ -1214,7 +1982,7 @@ async fn cancel_and_wait_for_outbound_transfers( loop { let count = { let active = ctx.active_outbound_transfers.read().await; - active.get(id).map_or(0, Vec::len) + outbound_transfer_count(&active, scope) }; if count == 0 { break; @@ -1228,10 +1996,11 @@ async fn cancel_and_wait_for_outbound_transfers( if !drained { let count = { let active = ctx.active_outbound_transfers.read().await; - active.get(id).map_or(0, Vec::len) + outbound_transfer_count(&active, scope) }; + let scope = scope.description(); log::error!( - "Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain for {id}" + "Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain {scope}" ); } @@ -1279,104 +2048,297 @@ async fn end_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: } } -async fn clear_active_download(ctx: &Ctx, id: &str) { - ctx.active_downloads.write().await.remove(id); -} - -fn send_download_finished(tx_notify_ui: &UnboundedSender, id: &str) { - if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.into() }) { - log::error!("Failed to send DownloadGameFilesFinished event: {err}"); +async fn clear_active_download(ctx: &Ctx, attempt: &DownloadAttemptKey) { + let mut active_downloads = ctx.active_downloads.write().await; + if active_downloads + .get(&attempt.id) + .is_some_and(|active| active.key() == attempt) + { + active_downloads.remove(&attempt.id); } } -fn send_download_failed(tx_notify_ui: &UnboundedSender, id: &str) { - if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.into() }) { - log::error!("Failed to send DownloadGameFilesFailed event: {err}"); - } -} - -fn send_download_failed_unless_cancelled( +fn send_download_failed( tx_notify_ui: &UnboundedSender, - id: &str, - cancelled: bool, + attempt: &DownloadAttemptKey, + reason: DownloadFailureReason, +) { + let status = DownloadAttemptStatus::new( + attempt.clone(), + CancellationToken::new(), + tx_notify_ui.clone(), + ); + status.close_source_admission(); + status.emit_failed(reason); +} + +async fn end_download_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + attempt: &DownloadAttemptKey, +) { + clear_active_download(ctx, attempt).await; + end_operation(ctx, tx_notify_ui, &attempt.id).await; +} + +async fn settle_target_state( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: &OperationTarget, +) -> eyre::Result<()> { + install::recover_game_root( + &target.game_root(), + ctx.state_dir.as_ref(), + target.game_id(), + ) + .await?; + refresh_local_game_for_ending_operation(ctx, tx_notify_ui, target).await +} + +async fn settle_and_end_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: &OperationTarget, + guard: OperationGuard, + label: &str, ) -> bool { - if cancelled { + if let Err(error) = settle_target_state(ctx, tx_notify_ui, target).await { + log::error!( + "Failed to settle {label} for {}: {error}; retaining the operation gate until process restart", + target.game_id() + ); return false; } - send_download_failed(tx_notify_ui, id); + // Settlement is complete before the guard is disarmed. If this task is + // cancelled during the async map cleanup, the still-present operation entry + // remains fail-closed; if removal already landed, recovery/publication is + // known complete. + guard.disarm(); + end_operation(ctx, tx_notify_ui, target.game_id()).await; true } -async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: &str) { - end_operation(ctx, tx_notify_ui, id).await; - clear_active_download(ctx, id).await; +async fn settle_and_end_download( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: &OperationTarget, + guard: OperationGuard, + attempt: &DownloadAttemptKey, + label: &str, +) -> bool { + if let Err(error) = settle_target_state(ctx, tx_notify_ui, target).await { + log::error!( + "Failed to settle {label} for {}: {error}; retaining the operation gate until process restart", + target.game_id() + ); + return false; + } + + guard.disarm(); + end_download_operation(ctx, tx_notify_ui, attempt).await; + true } -async fn catalog_contains(ctx: &Ctx, id: &str) -> bool { - ctx.catalog.read().await.contains(id) +async fn finish_successful_download_after_refresh( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: &OperationTarget, + guard: OperationGuard, + status: &DownloadAttemptStatus, +) { + status.clear_activity(); + if settle_and_end_download( + ctx, + tx_notify_ui, + target, + guard, + status.key(), + "download completion", + ) + .await + { + status.emit_finished(); + } } -async fn catalog_expected_version(ctx: &Ctx, id: &str) -> Option { - ctx.catalog - .read() - .await - .expected_version(id) - .map(ToOwned::to_owned) +async fn finish_failed_download_after_refresh( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + target: &OperationTarget, + guard: OperationGuard, + status: &DownloadAttemptStatus, + direct_reason: Option, +) { + status.close_source_admission(); + status.clear_activity(); + if settle_and_end_download( + ctx, + tx_notify_ui, + target, + guard, + status.key(), + "download failure", + ) + .await + && let Some(reason) = status.resolve_failure(direct_reason) + { + status.emit_failed(reason); + } +} + +fn catalog_contains(ctx: &Ctx, id: &str) -> bool { + ctx.catalog.catalog().contains(id) +} + +async fn begin_local_recovery( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + game_dir: &Path, + force_empty_snapshot: bool, +) -> eyre::Result<()> { + let (had_cached_games, revision) = { + let mut library = ctx.local_library.write().await; + let had_cached_games = !library.games.is_empty(); + let revision = library.clear_for_recovery(game_dir)?; + (had_cached_games, revision) + }; + ctx.recovery_quarantine.begin(game_dir.to_path_buf()); + *ctx.local_game_db.write().await = None; + if force_empty_snapshot || had_cached_games { + events::send( + tx_notify_ui, + PeerEvent::LocalLibraryChanged { games: Vec::new() }, + ); + } + if let Some(revision) = revision { + ctx.state_sync.publish_library_revision(revision); + } + Ok(()) } /// Handles the `SetGameDir` command. +/// +/// Recovery, scanning, quarantine settlement, and library-delta delivery +/// attempts all complete before this function returns. `Ok` means the returned +/// canonical root is bound, even when a recovery failure left one or more games +/// quarantined. `Err` is returned only before changing the configured root. pub async fn handle_set_game_dir_command( ctx: &Ctx, tx_notify_ui: &UnboundedSender, game_dir: PathBuf, -) { - let current_game_dir = ctx.game_dir.read().await.clone(); - if current_game_dir == game_dir { - log::info!( - "Game directory {} unchanged; refreshing without recovery", +) -> Result { + handle_set_game_dir_command_with_drain_timeout( + ctx, + tx_notify_ui, + game_dir, + OUTBOUND_TRANSFER_DRAIN_TIMEOUT, + ) + .await +} + +async fn handle_set_game_dir_command_with_drain_timeout( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + requested_game_dir: PathBuf, + drain_timeout: Duration, +) -> Result { + let _admission = ctx.operation_admission.lock().await; + let game_dir = match scoped_blocking(|| crate::canonicalize_game_dir(&requested_game_dir)) { + Ok(game_dir) => game_dir, + Err(error) => { + let error = error.to_string(); + log::warn!( + "Rejecting invalid game directory {}: {error}", + requested_game_dir.display() + ); + return Err(error); + } + }; + if let Err(error) = + install::intent::scan_active_install_intents(ctx.state_dir.as_ref(), &game_dir) + { + let error = format!( + "cannot set game directory to {} while install intent state is unresolved: {error}", game_dir.display() ); - let tx_notify_ui = tx_notify_ui.clone(); - let ctx_clone = ctx.clone(); - ctx.task_tracker.spawn(async move { - if let Err(err) = refresh_local_library(&ctx_clone, &tx_notify_ui).await { - log::error!("Failed to refresh local game database: {err}"); - } - }); - return; + log::warn!("{error}"); + return Err(error); } - + let current_game_dir = ctx.game_dir.read().await.clone(); let active_ids = active_operation_ids(ctx).await; if !active_ids.is_empty() { - log::warn!( - "Rejecting game directory change to {} while operations are active for: {}", - game_dir.display(), - active_ids.into_iter().collect::>().join(", ") + let mut active_ids = active_ids.into_iter().collect::>(); + active_ids.sort(); + let error = format!( + "cannot set game directory while operations are active for: {}", + active_ids.join(", ") ); - return; + log::warn!( + "Rejecting game directory refresh/change to {} while operations are active for: {}", + game_dir.display(), + active_ids.join(", ") + ); + return Err(error); } + if !cancel_and_wait_for_outbound_transfers(ctx, OutboundTransferScope::All, drain_timeout).await + { + log::error!( + "Keeping game directory {} unchanged because outbound transfers did not drain", + current_game_dir.display() + ); + return Err(format!( + "outbound transfers did not drain; game directory remains {}", + current_game_dir.display() + )); + } + + if current_game_dir == game_dir { + log::info!( + "Game directory {} unchanged; retrying inactive recovery before refresh", + game_dir.display() + ); + if let Err(error) = begin_local_recovery(ctx, tx_notify_ui, &game_dir, true).await { + log::error!("Failed to begin local recovery: {error}"); + return Err(format!("failed to begin local recovery: {error}")); + } + match load_local_library(ctx, tx_notify_ui).await { + Ok(()) => log::info!("Local game database refreshed successfully"), + Err(error) => log::error!( + "Game directory {} remains accepted but its library refresh failed: {error}", + game_dir.display() + ), + } + return Ok(game_dir); + } + + // Begin the target recovery epoch before changing the configured root. + // Requests that captured the old root now observe a root mismatch, while + // requests that later capture the new root observe Recovering. + if let Err(error) = begin_local_recovery(ctx, tx_notify_ui, &game_dir, true).await { + log::error!( + "Failed to begin recovery for {}: {error}", + game_dir.display() + ); + return Err(format!( + "failed to begin recovery for {}: {error}", + game_dir.display() + )); + } *ctx.game_dir.write().await = game_dir.clone(); log::info!("Game directory set to: {}", game_dir.display()); - let tx_notify_ui = tx_notify_ui.clone(); - let ctx_clone = ctx.clone(); - - ctx.task_tracker.spawn(async move { - match load_local_library_with_policy( - &ctx_clone, - &tx_notify_ui, - LocalLibraryEventPolicy::ForceSnapshot, - ) + match load_local_library_with_policy(ctx, tx_notify_ui, LocalLibraryEventPolicy::ForceSnapshot) .await - { - Ok(()) => log::info!("Local game database loaded successfully"), - Err(e) => { - log::error!("Failed to load local game database: {e}"); - } - } - }); + { + Ok(()) => log::info!("Local game database loaded successfully"), + Err(error) => log::error!( + "Game directory {} remains accepted but its library load failed: {error}", + game_dir.display() + ), + } + Ok(game_dir) } /// Loads the configured local library and announces the result. @@ -1393,25 +2355,32 @@ async fn load_local_library_with_policy( event_policy: LocalLibraryEventPolicy, ) -> eyre::Result<()> { let game_dir = { ctx.game_dir.read().await.clone() }; + begin_local_recovery(ctx, tx_notify_ui, &game_dir, false).await?; + let active_operations = ctx.active_operations.read().await; let active_ids = active_operations.keys().cloned().collect(); - install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?; + let recovery_report = + install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?; drop(active_operations); - scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await -} -async fn refresh_local_library( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, -) -> eyre::Result<()> { - let game_dir = { ctx.game_dir.read().await.clone() }; - scan_and_announce_local_library( - ctx, - tx_notify_ui, - &game_dir, - LocalLibraryEventPolicy::OnChange, - ) - .await + for (id, error) in recovery_report.failures() { + log::error!("Keeping game {id} quarantined after recovery failure: {error}"); + } + let recovery_error = recovery_report.summary_error(); + let failed_ids = recovery_report.failed_ids(); + scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy, &failed_ids) + .await?; + + if !ctx.recovery_quarantine.settle(&game_dir, failed_ids) { + eyre::bail!( + "local recovery result for {} was superseded before settlement", + game_dir.display() + ); + } + match recovery_error { + Some(error) => Err(error), + None => Ok(()), + } } async fn scan_and_announce_local_library( @@ -1419,11 +2388,22 @@ async fn scan_and_announce_local_library( tx_notify_ui: &UnboundedSender, game_dir: &Path, event_policy: LocalLibraryEventPolicy, + recovery_failed_ids: &HashSet, ) -> eyre::Result<()> { - let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(game_dir, ctx.state_dir.as_ref(), &catalog).await?; - update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await; - Ok(()) + let catalog = ctx.catalog.catalog(); + let scan = scan_local_library_with_recovery_failures( + game_dir, + ctx.state_dir.as_ref(), + catalog, + recovery_failed_ids, + ) + .await?; + match update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await { + LocalLibraryPublication::Published => Ok(()), + LocalLibraryPublication::Rejected => { + eyre::bail!("local library scan became obsolete before publication") + } + } } /// Refreshes the game whose operation has completed before clearing its @@ -1431,20 +2411,32 @@ async fn scan_and_announce_local_library( async fn refresh_local_game_for_ending_operation( ctx: &Ctx, tx_notify_ui: &UnboundedSender, - id: &str, + target: &OperationTarget, ) -> eyre::Result<()> { - let game_dir = { ctx.game_dir.read().await.clone() }; - let catalog = ctx.catalog.read().await.clone(); - let scan = rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, id).await?; - update_and_announce_games_with_policy( + let catalog = ctx.catalog.catalog(); + let failed_ids = ctx.recovery_quarantine.failed_ids(&target.games_folder); + let scan = rescan_local_game_with_recovery_failures( + &target.games_folder, + ctx.state_dir.as_ref(), + catalog, + target.game_id(), + &failed_ids, + ) + .await?; + match update_and_announce_games_with_policy( ctx, tx_notify_ui, scan, LocalLibraryEventPolicy::OnChange, - Some(id), + Some(target.game_id()), ) - .await; - Ok(()) + .await + { + LocalLibraryPublication::Published => Ok(()), + LocalLibraryPublication::Rejected => { + eyre::bail!("local game refresh became obsolete before publication") + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1453,6 +2445,12 @@ enum LocalLibraryEventPolicy { ForceSnapshot, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LocalLibraryPublication { + Published, + Rejected, +} + async fn active_operation_ids(ctx: &Ctx) -> HashSet { ctx.active_operations.read().await.keys().cloned().collect() } @@ -1467,16 +2465,41 @@ pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSe pub async fn handle_connect_peer_command( ctx: &Ctx, tx_notify_ui: &UnboundedSender, - addr: SocketAddr, + endpoint: PeerEndpoint, ) { - log::info!("Direct connect command received for {addr}"); - let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui); - - ctx.task_tracker.spawn(async move { - if let Err(err) = perform_handshake_with_peer(handshake_ctx, addr, None).await { - log::warn!("Failed direct connect to {addr}: {err}"); + log::info!("Direct connect command received for {}", endpoint.addr); + let network_permit = match ctx.network.try_acquire() { + Ok(permit) => permit, + Err(error) => { + log::warn!( + "Cannot directly connect to {} while Local network sharing is unavailable: {error}", + endpoint.addr + ); + return; } - }); + }; + let network_ctx = network_permit.service_context(ctx.clone()); + let handshake_ctx = HandshakeCtx::from_network(&network_ctx, tx_notify_ui); + let handshake = match ReservedCandidateHandshake::reserve(handshake_ctx, endpoint).await { + Ok(handshake) => handshake, + Err(err) => { + log::warn!( + "Failed to reserve direct connect to {}: {err}", + endpoint.addr + ); + return; + } + }; + + ctx.task_tracker + .spawn(retain_guard_until_operation_completes( + network_permit, + async move { + if let Err(err) = handshake.run().await { + log::warn!("Failed direct connect to {}: {err}", endpoint.addr); + } + }, + )); } // ============================================================================= @@ -1489,7 +2512,7 @@ pub async fn update_and_announce_games( tx_notify_ui: &UnboundedSender, scan: LocalLibraryScan, ) { - update_and_announce_games_with_policy( + let _ = update_and_announce_games_with_policy( ctx, tx_notify_ui, scan, @@ -1505,13 +2528,27 @@ async fn update_and_announce_games_with_policy( scan: LocalLibraryScan, event_policy: LocalLibraryEventPolicy, ending_operation_id: Option<&str>, -) { +) -> LocalLibraryPublication { let LocalLibraryScan { + source_game_dir, mut game_db, mut summaries, revision, } = scan; + // Hold the configured-root read guard through publication. A directory + // switch either happens first (and this scan is rejected) or waits until + // both cached library representations have been updated. + let current_game_dir = ctx.game_dir.read().await; + if *current_game_dir != source_game_dir { + log::debug!( + "Discarding local library scan from {} because the configured directory is now {}", + source_game_dir.display(), + current_game_dir.display() + ); + return LocalLibraryPublication::Rejected; + } + let mut active_operation_ids = active_operation_ids(ctx).await; if let Some(id) = ending_operation_id { active_operation_ids.remove(id); @@ -1523,19 +2560,51 @@ async fn update_and_announce_games_with_policy( game_db = GameDB::from(summaries.values().map(game_from_summary).collect()); } - let delta = { - let mut library_guard = ctx.local_library.write().await; - library_guard.update_from_scan(summaries, revision) + // Resolve every manifest needed by the prospective wire publication before + // making its revision visible. Hello/Pong responders are cache-only, so a + // malformed or unreadable artifact must reject the whole scan without + // partially mutating either local cache or emitting UI/state-sync updates. + let eligible = crate::library::catalog_eligible_game_ids(&summaries, ctx.catalog.catalog()); + let catalog = Arc::clone(&ctx.catalog); + let summaries = match scoped_blocking(move || { + crate::library::prime_library_manifests(&eligible, &catalog)?; + Ok::<_, eyre::Report>(summaries) + }) { + Ok(summaries) => summaries, + Err(error) => { + log::error!("Rejecting local library publication: {error}"); + return LocalLibraryPublication::Rejected; + } }; + let mut library_guard = ctx.local_library.write().await; + if let Some(published_revision) = library_guard.source_revision_for(&source_game_dir) + && revision < published_revision + { + log::debug!( + "Discarding local library scan at source revision {revision} because source revision {published_revision} is already published" + ); + return LocalLibraryPublication::Rejected; + } + let published_revision = + match library_guard.update_from_scan(&source_game_dir, summaries, revision) { + Ok(published_revision) => published_revision, + Err(error) => { + log::error!("Rejecting local library publication: {error}"); + return LocalLibraryPublication::Rejected; + } + }; + { let mut db_guard = ctx.local_game_db.write().await; *db_guard = Some(game_db.clone()); } + drop(library_guard); + drop(current_game_dir); let all_games = game_db.all_games().into_iter().cloned().collect::>(); - if delta.is_some() || event_policy == LocalLibraryEventPolicy::ForceSnapshot { + if published_revision.is_some() || event_policy == LocalLibraryEventPolicy::ForceSnapshot { events::send( tx_notify_ui, PeerEvent::LocalLibraryChanged { @@ -1546,57 +2615,85 @@ async fn update_and_announce_games_with_policy( log::debug!("Skipping unchanged local library event"); } - let Some(delta) = delta else { - return; + let Some(published_revision) = published_revision else { + return LocalLibraryPublication::Published; }; - let peer_targets = { - let db = ctx.peer_game_db.read().await; - db.peer_identities() - .into_iter() - .map(|(_peer_id, addr)| addr) - .collect::>() - }; + ctx.state_sync.publish_library_revision(published_revision); - for peer_addr in peer_targets { - let delta = delta.clone(); - let peer_id = ctx.peer_id.as_ref().clone(); - ctx.task_tracker.spawn(async move { - if let Err(e) = send_library_delta(peer_addr, &peer_id, delta).await { - log::warn!("Failed to send library delta to {peer_addr}: {e}"); - } - }); - } + LocalLibraryPublication::Published } #[cfg(test)] mod tests { use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, net::SocketAddr, path::{Path, PathBuf}, - sync::{Arc, Mutex}, - time::Duration, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, }; - use lanspread_db::db::GameCatalog; - use lanspread_proto::{Availability, GameSummary}; - use tokio::sync::mpsc; + use lanspread_db::{ + content_manifest::{ + Blake3Digest, + CATALOG_CONTENT_INDEX_NAME, + CatalogContentIdentity, + CatalogContentIndex, + CatalogContentIndexEntry, + CatalogContentManifestBody, + CatalogExtractedEntry, + CatalogFileEntry, + ContentId, + write_canonical_content_index_atomic, + }, + db::Availability, + }; + use lanspread_proto::{ + GameAvailability, + LibrarySnapshot, + PeerEndpoint, + PeerId, + RuntimeSessionId, + }; + use tokio::sync::{RwLock, mpsc, oneshot}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; use super::*; use crate::{ ActiveOperation, ActiveOperationKind, + CallToPlayLocalAction, + CallToPlayLocalIntent, UnpackFuture, Unpacker, - test_support::TempDir, + download::seed_pending_download_ownership_for_test, + identity::PeerIdentity, + install::intent::{InstallIntent, InstallIntentState, intent_path, write_intent}, + network_generation::NetworkControl, + test_support::{TempDir, catalog_bundle}, }; struct FakeUnpacker; + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + impl Unpacker for FakeUnpacker { - fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { + fn unpack<'a>( + &'a self, + _archive: &'a Path, + dest: &'a Path, + _cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { Box::pin(async move { tokio::fs::write(dest.join("payload.txt"), b"installed").await?; Ok(()) @@ -1611,45 +2708,817 @@ mod tests { std::fs::write(path, bytes).expect("file should be written"); } + fn operation_target(game_dir: &Path) -> OperationTarget { + OperationTarget::new(game_dir.to_path_buf(), "game".to_string()) + } + + fn streamed_manifest(entries: Vec) -> CatalogContentManifest { + let version = b"20250101"; + let version_digest = Blake3Digest::hash(version); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "game", + "20250101", + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("test version length should fit u64"), + version_digest, + vec![version_digest], + ) + .expect("test version entry should validate"), + ], + entries, + ) + .expect("test streamed manifest body should validate"), + ) + .expect("test streamed manifest should seal") + } + + fn downloadable_manifest() -> CatalogContentManifest { + let version = b"20250101"; + let archive = b"archive"; + let version_digest = Blake3Digest::hash(version); + let archive_digest = Blake3Digest::hash(archive); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "game", + "20250101", + vec![ + CatalogFileEntry::file( + "game.eti", + u64::try_from(archive.len()).expect("test archive length should fit u64"), + archive_digest, + vec![archive_digest], + ) + .expect("test archive entry should validate"), + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("test version length should fit u64"), + version_digest, + vec![version_digest], + ) + .expect("test version entry should validate"), + ], + Vec::new(), + ) + .expect("test download manifest body should validate"), + ) + .expect("test download manifest should seal") + } + + async fn seed_exact_download_ownership(ctx: &Ctx, games_folder: &Path, content_id: ContentId) { + crate::download::seed_download_ownership_for_test( + ctx.state_dir.as_ref(), + games_folder, + "game", + &["game.eti"], + ) + .await; + let games_folder_key = crate::state_paths::games_folder_key(games_folder); + let record_path = crate::state_paths::download_ownership_path( + ctx.state_dir.as_ref(), + "game", + &games_folder_key, + ); + let mut record: serde_json::Value = serde_json::from_slice( + &std::fs::read(&record_path).expect("seeded ownership should be readable"), + ) + .expect("seeded ownership should be valid JSON"); + record["committed_content_id"] = serde_json::Value::String(content_id.to_string()); + std::fs::write( + record_path, + serde_json::to_vec(&record).expect("updated ownership should serialize"), + ) + .expect("updated ownership should be written"); + } + fn test_ctx(game_dir: PathBuf) -> Ctx { + test_ctx_with_catalog(game_dir, catalog_bundle([("game", "20250101")])) + } + + fn test_ctx_with_catalog( + game_dir: PathBuf, + catalog: Arc, + ) -> Ctx { + test_ctx_with_catalog_and_network(game_dir, catalog, NetworkControl::enabled_for_test()) + } + + fn test_ctx_with_catalog_and_network( + game_dir: PathBuf, + catalog: Arc, + network: NetworkControl, + ) -> Ctx { let state_dir = game_dir.join(".test-state"); - Ctx::new( + let recovery_root = game_dir.clone(); + let ctx = Ctx::new( Arc::new(RwLock::new(PeerGameDB::new())), - "peer".to_string(), + Arc::new(PeerIdentity::generate().expect("test identity should generate")), game_dir, state_dir, Arc::new(FakeUnpacker), CancellationToken::new(), TaskTracker::new(), - Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))), + catalog, Arc::new(RwLock::new(HashMap::new())), Arc::new(crate::NoopStreamInstallProvider), + network, ) + .expect("test context should initialize"); + assert!( + ctx.recovery_quarantine + .settle(&recovery_root, HashSet::new()) + ); + ctx + } + + async fn register_test_download( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + ) -> (DownloadAttemptStatus, CancellationToken) { + let cancellation = CancellationToken::new(); + let status = register_download_attempt( + ctx, + tx_notify_ui, + DownloadAttemptKey::next("game".to_owned()), + cancellation.clone(), + ) + .await; + (status, cancellation) + } + + fn create_call_to_play_intent() -> CallToPlayLocalIntent { + let now = i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should follow the Unix epoch") + .as_millis(), + ) + .expect("current Unix time should fit i64"); + CallToPlayLocalIntent { + call_id: None, + action: CallToPlayLocalAction::Create { + game_id: "game".to_owned(), + max_players: 4, + scheduled_for: None, + deadline: now + 60_000, + }, + } } #[test] - fn cancelled_download_error_does_not_emit_failed_event() { + fn cancelled_download_owner_does_not_emit_failed_event() { let (tx, mut rx) = mpsc::unbounded_channel(); + let status = DownloadAttemptStatus::new( + DownloadAttemptKey::next("game".to_owned()), + CancellationToken::new(), + tx, + ); - let emitted = send_download_failed_unless_cancelled(&tx, "game", true); + assert!(status.signal().cancel_silently()); + status.close_source_admission(); + status.clear_activity(); - assert!(!emitted); + assert_eq!(status.resolve_failure(None), None); assert!(rx.try_recv().is_err()); } #[test] fn uncancelled_download_error_emits_failed_event() { let (tx, mut rx) = mpsc::unbounded_channel(); + let attempt = DownloadAttemptKey::next("game".to_owned()); - let emitted = send_download_failed_unless_cancelled(&tx, "game", false); + send_download_failed(&tx, &attempt, DownloadFailureReason::OperationFailed); - assert!(emitted); assert!(matches!( rx.try_recv(), - Ok(PeerEvent::DownloadGameFilesFailed { id }) if id == "game" + Ok(PeerEvent::DownloadGameFilesFailed { + attempt: emitted_attempt, + reason: DownloadFailureReason::OperationFailed, + }) if emitted_attempt == attempt )); } + #[tokio::test] + async fn retained_guard_outlives_cancelled_operation_cleanup() { + let dropped = Arc::new(AtomicBool::new(false)); + let cancel_token = CancellationToken::new(); + let task_cancel_token = cancel_token.clone(); + let (started_tx, started_rx) = oneshot::channel(); + let (cleanup_tx, cleanup_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + + let task = tokio::spawn(retain_guard_until_operation_completes( + DropFlag(Arc::clone(&dropped)), + async move { + started_tx + .send(()) + .expect("operation-start signal should be observed"); + task_cancel_token.cancelled().await; + cleanup_tx + .send(()) + .expect("cleanup-start signal should be observed"); + release_rx + .await + .expect("operation cleanup should be released"); + }, + )); + + started_rx + .await + .expect("retained operation should report startup"); + cancel_token.cancel(); + cleanup_rx + .await + .expect("retained operation should enter cleanup"); + assert!(!dropped.load(Ordering::SeqCst)); + assert!(!task.is_finished()); + + release_tx + .send(()) + .expect("cleanup release should reach retained operation"); + task.await.expect("retained operation should not panic"); + assert!(dropped.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn disabled_network_uses_exact_cached_download_without_peer_db_access() { + let games = TempDir::new("lanspread-handler-disabled-cached-download"); + write_file(&games.game_root().join("version.ini"), b"20250101"); + write_file(&games.game_root().join("game.eti"), b"archive"); + let manifest = downloadable_manifest(); + let content_id = manifest.content_id(); + let catalog = Arc::new( + lanspread_db::content_manifest::CatalogBundle::from_manifests([manifest]) + .expect("test catalog should validate"), + ); + let ctx = test_ctx_with_catalog_and_network( + games.path().to_path_buf(), + catalog, + NetworkControl::disabled_for_test(), + ); + seed_exact_download_ownership(&ctx, games.path(), content_id).await; + let peer_db = ctx.peer_game_db.write().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + + tokio::time::timeout( + Duration::from_secs(1), + handle_download_game_files_command(&ctx, &tx, "game".to_owned(), false), + ) + .await + .expect("cached download must not wait for PeerDB while sharing is disabled"); + drop(peer_db); + + let begin_attempt = match recv_event(&mut rx).await { + PeerEvent::DownloadGameFilesBegin { attempt } => attempt, + event => panic!("expected download begin, got {event:?}"), + }; + let finished_attempt = match recv_event(&mut rx).await { + PeerEvent::DownloadGameFilesFinished { attempt } => attempt, + event => panic!("expected download finish, got {event:?}"), + }; + assert_eq!(begin_attempt, finished_attempt); + assert_eq!(begin_attempt.id, "game"); + assert!(ctx.active_operations.read().await.is_empty()); + assert!(ctx.active_downloads.read().await.is_empty()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn disabled_network_rejects_nonlocal_download_before_peer_db_or_mutation() { + let games = TempDir::new("lanspread-handler-disabled-download"); + let ctx = test_ctx_with_catalog_and_network( + games.path().to_path_buf(), + catalog_bundle([("game", "20250101")]), + NetworkControl::disabled_for_test(), + ); + let peer_db = ctx.peer_game_db.write().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + + tokio::time::timeout( + Duration::from_secs(1), + handle_download_game_files_command(&ctx, &tx, "game".to_owned(), false), + ) + .await + .expect("disabled download must not wait for PeerDB"); + drop(peer_db); + + assert!(matches!( + recv_event(&mut rx).await, + PeerEvent::DownloadGameFilesFailed { + attempt, + reason: DownloadFailureReason::OperationFailed, + } if attempt.id == "game" + )); + assert!(!games.game_root().exists()); + assert!(ctx.active_operations.read().await.is_empty()); + assert!(ctx.active_downloads.read().await.is_empty()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn disabled_network_rejects_stream_install_before_peer_db_or_staging() { + let games = TempDir::new("lanspread-handler-disabled-stream-install"); + let manifest = streamed_manifest(vec![ + CatalogExtractedEntry::file("payload.txt", 7, Blake3Digest::hash(b"payload")) + .expect("test streamed entry should validate"), + ]); + let catalog = Arc::new( + lanspread_db::content_manifest::CatalogBundle::from_manifests([manifest]) + .expect("test catalog should validate"), + ); + let ctx = test_ctx_with_catalog_and_network( + games.path().to_path_buf(), + catalog, + NetworkControl::disabled_for_test(), + ); + let peer_db = ctx.peer_game_db.write().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + + tokio::time::timeout( + Duration::from_secs(1), + handle_stream_install_game_command( + &ctx, + &tx, + "game".to_owned(), + StreamInstallSettings::default(), + ), + ) + .await + .expect("disabled streamed install must not wait for PeerDB"); + drop(peer_db); + + assert!(matches!( + recv_event(&mut rx).await, + PeerEvent::DownloadGameFilesFailed { + attempt, + reason: DownloadFailureReason::OperationFailed, + } if attempt.id == "game" + )); + assert!(!games.game_root().exists()); + assert!(ctx.active_operations.read().await.is_empty()); + assert!(ctx.active_downloads.read().await.is_empty()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn disabled_network_rejects_direct_connect_before_ticket_reservation() { + let games = TempDir::new("lanspread-handler-disabled-direct-connect"); + let ctx = test_ctx_with_catalog_and_network( + games.path().to_path_buf(), + catalog_bundle([("game", "20250101")]), + NetworkControl::disabled_for_test(), + ); + let peer_db = ctx.peer_game_db.write().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + + tokio::time::timeout( + Duration::from_secs(1), + handle_connect_peer_command(&ctx, &tx, endpoint(7, 12_007)), + ) + .await + .expect("disabled direct connect must not wait for PeerDB"); + drop(peer_db); + + assert_eq!( + ctx.peer_game_db.read().await.negotiation_claim_counts(), + (0, 0) + ); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn disabled_network_rejects_call_to_play_before_store_mutation() { + let games = TempDir::new("lanspread-handler-disabled-call-to-play"); + let ctx = test_ctx_with_catalog_and_network( + games.path().to_path_buf(), + catalog_bundle([("game", "20250101")]), + NetworkControl::disabled_for_test(), + ); + let mut store = ctx.call_to_play.write().await; + let before = store + .current_publication() + .expect("initial Call-to-Play projection should load") + .view; + let (tx, mut rx) = mpsc::unbounded_channel(); + let (reply_tx, reply_rx) = oneshot::channel(); + + tokio::time::timeout( + Duration::from_secs(1), + crate::handle_apply_call_to_play_intent( + &ctx, + &tx, + create_call_to_play_intent(), + "Player".to_owned(), + reply_tx, + ), + ) + .await + .expect("closed admission must reject without waiting for the held store lock"); + assert!( + reply_rx + .await + .expect("handler should reply") + .expect_err("disabled admission must reject") + .contains("disabled or changing state") + ); + let after = store + .current_publication() + .expect("unchanged Call-to-Play projection should load") + .view; + assert_eq!(after, before); + drop(store); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn enabled_call_to_play_admission_waits_then_mutates_once() { + let games = TempDir::new("lanspread-handler-enabled-call-to-play"); + let ctx = test_ctx(games.path().to_path_buf()); + let store = ctx.call_to_play.write().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let (reply_tx, mut reply_rx) = oneshot::channel(); + let task = tokio::spawn({ + let ctx = ctx.clone(); + let tx = tx.clone(); + async move { + crate::handle_apply_call_to_play_intent( + &ctx, + &tx, + create_call_to_play_intent(), + "Player".to_owned(), + reply_tx, + ) + .await; + } + }); + + tokio::task::yield_now().await; + assert!(matches!( + reply_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + drop(store); + task.await + .expect("admitted Call-to-Play task should finish"); + let receipt = reply_rx + .await + .expect("handler should reply") + .expect("enabled admission should accept the intent"); + assert_eq!(receipt.revision, 1); + assert!(matches!( + recv_event(&mut rx).await, + PeerEvent::CallToPlayView(view) if view.events.len() == 1 + )); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn streamed_install_without_extracted_catalog_manifest_fails_before_admission() { + let games = TempDir::new("lanspread-handler-stream-capability"); + let ctx = test_ctx(games.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + handle_stream_install_game_command( + &ctx, + &tx, + "game".to_string(), + StreamInstallSettings::default(), + ) + .await; + + assert!(matches!( + recv_event(&mut rx).await, + PeerEvent::DownloadGameFilesFailed { + attempt, + reason: DownloadFailureReason::OperationFailed, + } if attempt.id == "game" + )); + assert!(ctx.active_operations.read().await.is_empty()); + assert!(ctx.active_downloads.read().await.is_empty()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn version_only_local_download_never_shortcuts_exact_catalog_ownership() { + let games = TempDir::new("lanspread-handler-local-content-ownership"); + write_file(&games.game_root().join("version.ini"), b"20250101"); + write_file(&games.game_root().join("game.eti"), b"archive"); + let ctx = test_ctx(games.path().to_path_buf()); + crate::download::seed_download_ownership_for_test( + ctx.state_dir.as_ref(), + games.path(), + "game", + &["game.eti"], + ) + .await; + let catalog_content_id = ctx + .catalog + .manifest("game") + .expect("test manifest should load") + .content_id(); + assert!( + !download_ownership_matches_content( + games.path(), + ctx.state_dir.as_ref(), + "game", + catalog_content_id, + ) + .await + ); + let (tx, mut rx) = mpsc::unbounded_channel(); + + handle_download_game_files_command(&ctx, &tx, "game".to_string(), false).await; + + assert!(matches!( + recv_event(&mut rx).await, + PeerEvent::DownloadGameFilesFailed { + attempt, + reason: DownloadFailureReason::VerifiedCatalogSourcesExhausted, + } if attempt.id == "game" + )); + assert!(ctx.active_operations.read().await.is_empty()); + assert!(ctx.active_downloads.read().await.is_empty()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn already_active_download_emits_no_status_for_the_new_attempt() { + let games = TempDir::new("lanspread-handler-download-already-active"); + let manifest = downloadable_manifest(); + let content_id = manifest.content_id(); + let catalog = Arc::new( + lanspread_db::content_manifest::CatalogBundle::from_manifests([manifest]) + .expect("test catalog should validate"), + ); + let ctx = test_ctx_with_catalog(games.path().to_path_buf(), catalog); + { + let mut peer_game_db = ctx.peer_game_db.write().await; + upsert( + &mut peer_game_db, + endpoint(1, 12_001), + Some(("game", content_id)), + ); + } + ctx.active_operations + .write() + .await + .insert("game".to_owned(), OperationKind::Downloading); + let (tx, mut rx) = mpsc::unbounded_channel(); + + handle_download_game_files_command(&ctx, &tx, "game".to_owned(), false).await; + + assert_eq!( + ctx.active_operations.read().await.get("game"), + Some(&OperationKind::Downloading) + ); + assert!(ctx.active_downloads.read().await.is_empty()); + assert_no_event(&mut rx).await; + } + + #[test] + fn streamed_install_marks_settings_only_after_successful_promotion() { + let games = TempDir::new("lanspread-handler-stream-promotion"); + let state = TempDir::new("lanspread-handler-stream-promotion-state"); + let marker = crate::launch_settings_applied_path(state.path(), "game"); + let transaction = install::begin_streamed_install(&games.game_root(), state.path(), "game") + .expect("streamed install transaction should begin"); + write_file(&transaction.staging_dir().join("payload.txt"), b"installed"); + + assert!(!marker.exists()); + let manifest = streamed_manifest(vec![ + CatalogExtractedEntry::file("payload.txt", 9, Blake3Digest::hash(b"installed")) + .expect("test streamed entry should validate"), + ]); + promote_streamed_install( + state.path(), + "game", + transaction, + &manifest, + &CancellationToken::new(), + ) + .expect("verified staging should be promoted"); + + assert_eq!( + std::fs::read(games.game_root().join("local/payload.txt")) + .expect("promoted payload should be readable"), + b"installed" + ); + assert!(marker.is_file()); + } + + #[tokio::test] + async fn recovery_required_completion_settles_before_success() { + let games = TempDir::new("lanspread-handler-download-recovery"); + let root = games.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("archive.eti"), b"archive"); + let ctx = test_ctx(games.path().to_path_buf()); + seed_pending_download_ownership_for_test( + ctx.state_dir.as_ref(), + games.path(), + "game", + &["archive.eti"], + &["archive.eti"], + ) + .await; + + settle_download_completion( + &ctx, + &operation_target(games.path()), + DownloadCompletion::RecoveryRequired(eyre::eyre!("injected uncertainty")), + ) + .await + .expect("committed download recovery should settle"); + + assert_eq!( + download_ownership_readiness(games.path(), ctx.state_dir.as_ref(), "game").await, + DownloadOwnershipReadiness::Settled + ); + } + + #[tokio::test] + async fn failed_completion_recovery_remains_quarantined() { + let games = TempDir::new("lanspread-handler-download-recovery-failure"); + let root = games.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("archive.eti"), b"archive"); + std::fs::create_dir_all(root.join(".version.ini.tmp")) + .expect("invalid scratch directory should be created"); + let ctx = test_ctx(games.path().to_path_buf()); + seed_pending_download_ownership_for_test( + ctx.state_dir.as_ref(), + games.path(), + "game", + &["archive.eti"], + &["archive.eti"], + ) + .await; + + let error = settle_download_completion( + &ctx, + &operation_target(games.path()), + DownloadCompletion::RecoveryRequired(eyre::eyre!("injected uncertainty")), + ) + .await + .expect_err("invalid scratch shape should keep recovery quarantined"); + + assert!( + error + .to_string() + .contains("download recovery did not settle") + ); + assert_eq!( + download_ownership_readiness(games.path(), ctx.state_dir.as_ref(), "game").await, + DownloadOwnershipReadiness::RecoveryRequired + ); + let scan = scan_local_library(games.path(), ctx.state_dir.as_ref(), ctx.catalog.catalog()) + .await + .expect("quarantined library should still scan"); + let game = scan + .summaries + .get("game") + .expect("quarantined game should remain visible"); + assert!(!game.downloaded); + assert_eq!(game.availability, Availability::LocalOnly); + } + + #[tokio::test] + async fn failed_root_stays_quarantined_while_healthy_root_loads_and_retry_clears() { + let games = TempDir::new("lanspread-handler-runtime-recovery"); + let broken = games.path().join("broken"); + let healthy = games.path().join("healthy"); + for root in [&broken, &healthy] { + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("archive.eti"), b"archive"); + write_file(&root.join("local/payload.txt"), b"installed"); + } + std::fs::create_dir_all(broken.join(".version.ini.tmp")) + .expect("invalid scratch directory should be created"); + write_file(&healthy.join(".version.ini.tmp"), b"stale scratch"); + + let ctx = test_ctx_with_catalog( + games.path().to_path_buf(), + catalog_bundle([("broken", "20250101"), ("healthy", "20250101")]), + ); + let (tx, _rx) = mpsc::unbounded_channel(); + + let error = load_local_library(&ctx, &tx) + .await + .expect_err("per-game recovery failure should remain visible to the caller"); + assert!(error.to_string().contains("broken")); + + assert!(ctx.recovery_quarantine.is_blocked(games.path(), "broken")); + assert!(!ctx.recovery_quarantine.is_blocked(games.path(), "healthy")); + let library = ctx.local_library.read().await; + assert!(!library.games["broken"].downloaded); + assert!(!library.games["broken"].installed); + assert!(library.games["healthy"].downloaded); + assert!(library.games["healthy"].installed); + drop(library); + assert!(!healthy.join(".version.ini.tmp").exists()); + + std::fs::remove_dir_all(broken.join(".version.ini.tmp")) + .expect("invalid scratch directory should be removable"); + load_local_library(&ctx, &tx) + .await + .expect("corrected recovery should settle"); + + assert!(!ctx.recovery_quarantine.is_blocked(games.path(), "broken")); + let library = ctx.local_library.read().await; + assert!(library.games["broken"].downloaded); + assert!(library.games["broken"].installed); + } + + #[cfg(unix)] + #[tokio::test] + async fn startup_recovery_quarantines_symlink_game_root_without_scanning_outside() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-handler-symlink-recovery-games"); + let outside = TempDir::new("lanspread-handler-symlink-recovery-outside"); + write_file(&outside.path().join("version.ini"), b"20250101"); + write_file(&outside.path().join("archive.eti"), b"archive"); + write_file(&outside.path().join("local/payload.txt"), b"installed"); + write_file(&outside.path().join("canary.txt"), b"outside"); + symlink(outside.path(), games.path().join("game")) + .expect("game-root symlink should be created"); + + let ctx = test_ctx(games.path().to_path_buf()); + let (tx, _rx) = mpsc::unbounded_channel(); + + let error = load_local_library(&ctx, &tx) + .await + .expect_err("unsafe game root should remain a visible recovery failure"); + + assert!(error.to_string().contains("game")); + assert!(ctx.recovery_quarantine.is_blocked(games.path(), "game")); + assert!(!ctx.local_library.read().await.games.contains_key("game")); + assert_eq!( + std::fs::read(outside.path().join("canary.txt")) + .expect("canary should remain readable"), + b"outside" + ); + assert_eq!( + std::fs::read(outside.path().join("local/payload.txt")) + .expect("outside install should remain readable"), + b"installed" + ); + } + + #[tokio::test] + async fn operation_admission_rejects_recovering_and_failed_games() { + let games = TempDir::new("lanspread-handler-recovery-admission"); + let ctx = test_ctx(games.path().to_path_buf()); + let (tx, _rx) = mpsc::unbounded_channel(); + + ctx.recovery_quarantine.begin(games.path().to_path_buf()); + let target = operation_target(games.path()); + assert_eq!( + begin_operation(&ctx, &tx, &target, OperationKind::Downloading).await, + BeginOperationResult::RecoveryBlocked + ); + assert!(ctx.active_operations.read().await.is_empty()); + + assert!( + ctx.recovery_quarantine + .settle(games.path(), HashSet::from(["game".to_string()])) + ); + assert_eq!( + begin_operation(&ctx, &tx, &target, OperationKind::Downloading).await, + BeginOperationResult::RecoveryBlocked + ); + assert!(ctx.active_operations.read().await.is_empty()); + } + + #[tokio::test] + async fn final_download_refresh_failure_keeps_fail_closed_operation_gate() { + let games = TempDir::new("lanspread-handler-download-refresh-failure"); + write_file(&games.game_root(), b"not a directory"); + let ctx = test_ctx(games.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let (status, cancel) = register_test_download(&ctx, &tx).await; + ctx.active_operations + .write() + .await + .insert("game".to_string(), OperationKind::Downloading); + let guard = OperationGuard::download("game".to_string(), cancel.clone()); + + finish_successful_download_after_refresh( + &ctx, + &tx, + &operation_target(games.path()), + guard, + &status, + ) + .await; + + assert!(ctx.active_operations.read().await.contains_key("game")); + assert!(ctx.active_downloads.read().await.contains_key("game")); + assert!(cancel.is_cancelled()); + assert_no_event(&mut rx).await; + } + async fn recv_event(rx: &mut mpsc::UnboundedReceiver) -> PeerEvent { tokio::time::timeout(Duration::from_secs(1), rx.recv()) .await @@ -1666,32 +3535,65 @@ mod tests { ); } + fn drain_events(rx: &mut mpsc::UnboundedReceiver) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + events.push(event); + } + events + } + fn addr(port: u16) -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], port)) } - fn summary(id: &str, version: &str, availability: Availability) -> GameSummary { - GameSummary { + fn peer_id(seed: u8) -> PeerId { + PeerId::from_bytes([seed; 32]) + } + + fn endpoint(seed: u8, port: u16) -> PeerEndpoint { + PeerEndpoint::new(peer_id(seed), addr(port)) + } + + fn upsert(db: &mut PeerGameDB, endpoint: PeerEndpoint, game: Option<(&str, ContentId)>) { + let ticket = db + .begin_candidate_negotiation(endpoint) + .expect("authenticated test peer should reserve"); + db.commit_authenticated_snapshot( + endpoint, + ticket, + RuntimeSessionId::from_bytes([1; 16]), + Some(LibrarySnapshot { + revision: 1, + games: game + .into_iter() + .map(|(game_id, content_id)| GameAvailability { + game_id: game_id.to_owned(), + content_id, + }) + .collect(), + }), + ) + .expect("authenticated snapshot should commit") + .expect("candidate ticket should be current"); + } + + fn summary( + id: &str, + version: &str, + availability: Availability, + ) -> crate::library::LocalGameSummary { + crate::library::LocalGameSummary { id: id.to_string(), name: id.to_string(), size: 42, downloaded: availability == Availability::Ready, installed: true, eti_version: Some(version.to_string()), - manifest_hash: 7, availability, } } - fn file_desc(game_id: &str, relative_path: &str, size: u64) -> GameFileDescription { - GameFileDescription { - game_id: game_id.to_string(), - relative_path: relative_path.to_string(), - is_dir: false, - size, - } - } - fn assert_local_update(event: PeerEvent, installed: bool, downloaded: bool) { let _ = local_update_game(event, installed, downloaded); } @@ -1728,180 +3630,89 @@ mod tests { } #[test] - fn update_source_selects_expected_ready_peer_manifest() { - let old_addr = addr(12_000); - let new_addr = addr(12_001); - let local_only_addr = addr(12_002); + fn content_sources_require_exact_content_and_authenticated_identity() { + let matching_a = endpoint(20, 12_100); + let matching_b = endpoint(21, 12_101); + let wrong_content = endpoint(22, 12_102); + let no_game = endpoint(23, 12_103); + let content_id = ContentId::from_bytes([1; 32]); + let other_content_id = ContentId::from_bytes([2; 32]); let mut db = PeerGameDB::new(); - db.upsert_peer("old".to_string(), old_addr); - db.upsert_peer("new".to_string(), new_addr); - db.upsert_peer("local-only".to_string(), local_only_addr); - db.update_peer_games( - &"old".to_string(), - vec![summary("game", "20240101", Availability::Ready)], - ); - db.update_peer_games( - &"new".to_string(), - vec![summary("game", "20250101", Availability::Ready)], - ); - db.update_peer_games( - &"local-only".to_string(), - vec![summary("game", "20990101", Availability::LocalOnly)], - ); + upsert(&mut db, matching_a, Some(("game", content_id))); + upsert(&mut db, matching_b, Some(("game", content_id))); + upsert(&mut db, wrong_content, Some(("game", other_content_id))); + upsert(&mut db, no_game, None); + + let quarantine = ContentQuarantine::default(); + quarantine.record_integrity_failure(&matching_a, content_id); assert_eq!( - GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")), - vec![new_addr] + select_content_sources(&db, "game", content_id, &quarantine), + vec![matching_b] + ); + assert_eq!( + select_content_sources(&db, "game", other_content_id, &quarantine), + vec![wrong_content] ); } - #[tokio::test] - async fn update_fetch_emits_fresh_manifest_from_expected_peer() { - let old_addr = addr(12_010); - let new_addr = addr(12_011); - let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - { - let mut db = peer_game_db.write().await; - db.upsert_peer("old".to_string(), old_addr); - db.upsert_peer("new".to_string(), new_addr); - db.update_peer_games( - &"old".to_string(), - vec![summary("game", "20240101", Availability::Ready)], - ); - db.update_peer_games( - &"new".to_string(), - vec![summary("game", "20250101", Availability::Ready)], - ); - } - let peers = { - let db = peer_game_db.read().await; - GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")) - }; - let (tx, mut rx) = mpsc::unbounded_channel(); - let fetched_peers = Arc::new(Mutex::new(Vec::new())); + #[test] + fn streamed_install_retries_and_quarantines_only_typed_integrity_failures() { + assert_eq!( + stream_install_failure_disposition(StreamInstallReceiveErrorKind::Integrity), + StreamInstallFailureDisposition::RetryAndQuarantine + ); + assert_eq!( + stream_install_failure_disposition(StreamInstallReceiveErrorKind::Transport), + StreamInstallFailureDisposition::Retry + ); + assert_eq!( + stream_install_failure_disposition(StreamInstallReceiveErrorKind::Cancelled), + StreamInstallFailureDisposition::Stop + ); + assert_eq!( + stream_install_failure_disposition(StreamInstallReceiveErrorKind::Setup), + StreamInstallFailureDisposition::Stop + ); + } - fetch_game_details_from_peers( - peers, - "game".to_string(), - Some("20250101".to_string()), - peer_game_db.clone(), + #[test] + fn alternate_stream_setup_failure_does_not_emit_retry_activity() { + let games = TempDir::new("lanspread-handler-stream-retry-setup-failure"); + let state = TempDir::new("lanspread-handler-stream-retry-setup-state"); + std::fs::create_dir_all(games.game_root().join("local")) + .expect("installed tree should be created"); + let (tx, mut rx) = mpsc::unbounded_channel(); + let status = DownloadAttemptStatus::new( + DownloadAttemptKey::next("game".to_owned()), + CancellationToken::new(), tx, - { - let fetched_peers = fetched_peers.clone(); - move |peer_addr, game_id, peer_game_db| { - let fetched_peers = fetched_peers.clone(); - async move { - fetched_peers - .lock() - .expect("fetched peer list should not be poisoned") - .push(peer_addr); - let files = vec![ - file_desc(&game_id, "game/version.ini", 8), - file_desc(&game_id, "game/new.eti", 11), - ]; - peer_game_db.write().await.update_peer_game_files( - &"new".to_string(), - &game_id, - files.clone(), - ); - Ok(files) - } - } - }, - ) - .await; - - assert_eq!( - *fetched_peers - .lock() - .expect("fetched peer list should not be poisoned"), - vec![new_addr] ); - let PeerEvent::GotGameFiles { - id, - file_descriptions, - } = recv_event(&mut rx).await - else { - panic!("expected GotGameFiles"); + let mut retry_invalid_source = true; + + let error = match begin_stream_receive_attempt( + &games.game_root(), + state.path(), + "game", + &mut retry_invalid_source, + &status.reporter(), + ) { + Ok(transaction) => { + transaction + .rollback() + .expect("unexpected transaction should roll back"); + panic!("already-installed staging setup must fail"); + } + Err(error) => error, }; - assert_eq!(id, "game"); - assert!( - file_descriptions - .iter() - .any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11), - "expected-version peer manifest should be emitted to the download path" - ); + + assert_eq!(error.reason, Some(DownloadFailureReason::OperationFailed)); + assert!(retry_invalid_source); + assert!(rx.try_recv().is_err()); } #[tokio::test] - async fn failed_peer_detail_fetch_emits_terminal_download_failure() { - let first_addr = addr(12_020); - let second_addr = addr(12_021); - let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - let (tx, mut rx) = mpsc::unbounded_channel(); - let fetched_peers = Arc::new(Mutex::new(Vec::new())); - - fetch_game_details_from_peers( - vec![first_addr, second_addr], - "game".to_string(), - Some("20250101".to_string()), - peer_game_db, - tx.clone(), - { - let fetched_peers = fetched_peers.clone(); - move |peer_addr, _game_id, _peer_game_db| { - let fetched_peers = fetched_peers.clone(); - async move { - fetched_peers - .lock() - .expect("fetched peer list should not be poisoned") - .push(peer_addr); - Err::, _>(eyre::eyre!("detail fetch failed")) - } - } - }, - ) - .await; - - assert_eq!( - *fetched_peers - .lock() - .expect("fetched peer list should not be poisoned"), - vec![first_addr, second_addr] - ); - assert!(matches!( - recv_event(&mut rx).await, - PeerEvent::DownloadGameFilesFailed { id } if id == "game" - )); - assert_no_event(&mut rx).await; - } - - #[tokio::test] - async fn update_request_skips_local_manifest_even_when_download_exists() { - let temp = TempDir::new("lanspread-handler-expected-peer"); - let root = temp.game_root(); - write_file(&root.join("version.ini"), b"20240101"); - write_file(&root.join("game.eti"), b"old archive"); - - let ctx = test_ctx(temp.path().to_path_buf()); - let (tx, mut rx) = mpsc::unbounded_channel(); - - handle_get_game_command( - &ctx, - &tx, - "game".to_string(), - GameDetailSource::LatestPeersOnly, - ) - .await; - - assert!(matches!( - recv_event(&mut rx).await, - PeerEvent::NoPeersHaveGame { id } if id == "game" - )); - } - - #[tokio::test] - async fn local_library_scan_hides_active_game_state() { + async fn active_game_projection_uses_peer_acceptable_revisions() { let temp = TempDir::new("lanspread-handler-active-hide"); let root = temp.game_root(); write_file(&root.join("version.ini"), b"20250101"); @@ -1909,12 +3720,13 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - let catalog = ctx.catalog.read().await.clone(); + let catalog = ctx.catalog.catalog(); // 1. Initial scan: the game is ready and announced - let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog) .await .expect("scan should succeed"); + let source_revision = scan.revision; update_and_announce_games(&ctx, &tx, scan).await; let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { @@ -1923,15 +3735,27 @@ mod tests { assert_eq!(games.len(), 1); assert_eq!(games[0].id, "game"); + let initial_snapshot = { + let library = ctx.local_library.read().await; + crate::library::build_library_snapshot( + library.publication(ctx.catalog.catalog()), + &ctx.catalog, + ) + .expect("catalog snapshot should build") + }; + let initial_revision = initial_snapshot.revision; + assert_eq!(initial_snapshot.games.len(), 1); + // 2. Set the game as active/in-progress and scan again ctx.active_operations .write() .await .insert("game".to_string(), OperationKind::Installing); - let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog) .await .expect("scan should succeed"); + assert_eq!(scan.revision, source_revision); update_and_announce_games(&ctx, &tx, scan).await; let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { @@ -1941,6 +3765,204 @@ mod tests { games.is_empty(), "active game should be hidden/unannounced during operations" ); + let hidden_snapshot = { + let library = ctx.local_library.read().await; + crate::library::build_library_snapshot( + library.publication(ctx.catalog.catalog()), + &ctx.catalog, + ) + .expect("hidden catalog snapshot should build") + }; + assert!(hidden_snapshot.revision > initial_revision); + assert!(hidden_snapshot.games.is_empty()); + let hidden_revision = hidden_snapshot.revision; + + // 3. Operation completion republishes the game from the same unchanged + // disk revision. The operation remains registered until publication, + // so the ending ID is explicitly excluded from the hidden projection. + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog) + .await + .expect("ending-operation scan should succeed"); + assert_eq!(scan.revision, source_revision); + assert_eq!( + update_and_announce_games_with_policy( + &ctx, + &tx, + scan, + LocalLibraryEventPolicy::OnChange, + Some("game"), + ) + .await, + LocalLibraryPublication::Published + ); + + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("expected LocalLibraryChanged"); + }; + assert_eq!(games.len(), 1); + assert_eq!(games[0].id, "game"); + let restored_snapshot = { + let library = ctx.local_library.read().await; + crate::library::build_library_snapshot( + library.publication(ctx.catalog.catalog()), + &ctx.catalog, + ) + .expect("restored catalog snapshot should build") + }; + assert!(restored_snapshot.revision > hidden_revision); + assert_eq!(restored_snapshot.games.len(), 1); + assert_eq!(restored_snapshot.games[0].game_id, "game"); + } + + #[tokio::test] + async fn invalid_prospective_manifest_rejects_publication_without_visible_mutation() { + let games = TempDir::new("lanspread-handler-invalid-publication-game-root"); + let root = games.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + + let manifests = TempDir::new("lanspread-handler-invalid-publication-manifests"); + write_file(&manifests.path().join("game.json"), b"not JSON"); + let content_index = CatalogContentIndex::from_entries([CatalogContentIndexEntry { + game_id: "game".to_owned(), + game_version: "20250101".to_owned(), + identity: CatalogContentIdentity { + content_id: ContentId::from_bytes([1; 32]), + supports_streamed_install: false, + }, + }]) + .expect("test content index should validate"); + write_canonical_content_index_atomic( + &manifests.path().join(CATALOG_CONTENT_INDEX_NAME), + &content_index, + ) + .expect("test content index should publish"); + let catalog = Arc::new( + lanspread_db::content_manifest::CatalogBundle::new( + manifests.path(), + BTreeMap::from([("game".to_owned(), "20250101".to_owned())]), + ) + .expect("catalog construction should defer manifest body parsing"), + ); + let ctx = test_ctx_with_catalog(games.path().to_path_buf(), catalog); + let (tx, mut rx) = mpsc::unbounded_channel(); + let scan = scan_local_library(games.path(), ctx.state_dir.as_ref(), ctx.catalog.catalog()) + .await + .expect("ready game should scan before manifest priming"); + + assert_eq!( + update_and_announce_games_with_policy( + &ctx, + &tx, + scan, + LocalLibraryEventPolicy::OnChange, + None, + ) + .await, + LocalLibraryPublication::Rejected + ); + + let library = ctx.local_library.read().await; + assert_eq!(library.revision, 0); + assert!(library.games.is_empty()); + drop(library); + assert!(ctx.local_game_db.read().await.is_none()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn local_library_rejects_scan_from_previous_game_directory() { + let current = TempDir::new("lanspread-handler-current-scan-root"); + let previous = TempDir::new("lanspread-handler-previous-scan-root"); + write_file(¤t.game_root().join("version.ini"), b"20250101"); + write_file(¤t.game_root().join("game.eti"), b"archive"); + write_file(&previous.game_root().join("version.ini"), b"20250101"); + write_file(&previous.game_root().join("game.eti"), b"archive"); + write_file( + &previous.game_root().join("local/payload.txt"), + b"installed", + ); + + let ctx = test_ctx(current.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let catalog = ctx.catalog.catalog(); + + let current_scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), catalog) + .await + .expect("current root should scan"); + update_and_announce_games(&ctx, &tx, current_scan).await; + assert_local_update(recv_event(&mut rx).await, false, true); + + let obsolete_scan = scan_local_library(previous.path(), ctx.state_dir.as_ref(), catalog) + .await + .expect("previous root should scan"); + update_and_announce_games(&ctx, &tx, obsolete_scan).await; + + assert_no_event(&mut rx).await; + let library = ctx.local_library.read().await; + assert!(!library.games["game"].installed); + drop(library); + let game_db = ctx.local_game_db.read().await; + assert!( + !game_db + .as_ref() + .expect("current database should remain published") + .get_game_by_id("game") + .expect("current game should remain published") + .installed + ); + } + + #[tokio::test] + async fn local_library_rejects_scan_older_than_published_revision() { + let temp = TempDir::new("lanspread-handler-stale-scan"); + let root = temp.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + + let ctx = test_ctx(temp.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let catalog = ctx.catalog.catalog(); + let older_scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog) + .await + .expect("older snapshot should scan"); + + seed_pending_download_ownership_for_test( + ctx.state_dir.as_ref(), + temp.path(), + "game", + &["game.eti"], + &["game.eti"], + ) + .await; + let newer_scan = rescan_local_game(temp.path(), ctx.state_dir.as_ref(), catalog, "game") + .await + .expect("quarantined snapshot should scan"); + assert!(newer_scan.revision > older_scan.revision); + let newer_revision = newer_scan.revision; + + update_and_announce_games(&ctx, &tx, newer_scan).await; + assert_local_update(recv_event(&mut rx).await, false, false); + update_and_announce_games(&ctx, &tx, older_scan).await; + + assert_no_event(&mut rx).await; + let library = ctx.local_library.read().await; + assert_eq!( + library.source_revision_for(temp.path()), + Some(newer_revision) + ); + assert!(!library.games["game"].downloaded); + assert_eq!(library.games["game"].availability, Availability::LocalOnly); + drop(library); + let game_db = ctx.local_game_db.read().await; + assert!( + !game_db + .as_ref() + .expect("newer database should remain published") + .get_game_by_id("game") + .expect("newer game should remain published") + .downloaded + ); } #[tokio::test] @@ -1952,9 +3974,10 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); + let target = operation_target(temp.path()); assert_eq!( - begin_operation(&ctx, &tx, "game", OperationKind::Updating).await, + begin_operation(&ctx, &tx, &target, OperationKind::Updating).await, BeginOperationResult::Started ); assert_active_update( @@ -1966,6 +3989,66 @@ mod tests { ); } + #[tokio::test] + async fn begin_operation_withdraws_fresh_library_snapshot_before_mutation() { + let temp = TempDir::new("lanspread-handler-active-withdrawal"); + let root = temp.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + + let ctx = test_ctx(temp.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), ctx.catalog.catalog()) + .await + .expect("initial scan should succeed"); + update_and_announce_games(&ctx, &tx, scan).await; + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("expected initial LocalLibraryChanged"); + }; + assert_eq!(games.len(), 1); + let initial_revision = ctx.local_library.read().await.revision; + + assert_eq!( + begin_operation( + &ctx, + &tx, + &operation_target(temp.path()), + OperationKind::Installing, + ) + .await, + BeginOperationResult::Started + ); + + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("availability withdrawal must precede the active-operation event"); + }; + assert!(games.is_empty()); + assert_active_update( + recv_event(&mut rx).await, + &active_update("game", ActiveOperationKind::Installing), + ); + + let snapshot = { + let library = ctx.local_library.read().await; + assert!(library.revision > initial_revision); + crate::library::build_library_snapshot( + library.publication(ctx.catalog.catalog()), + &ctx.catalog, + ) + .expect("withdrawn snapshot should build") + }; + assert!(snapshot.games.is_empty()); + assert!( + ctx.local_game_db + .read() + .await + .as_ref() + .expect("local database should remain published") + .all_games() + .is_empty() + ); + } + #[tokio::test] async fn begin_operation_timeout_clears_active_operation_snapshot() { let temp = TempDir::new("lanspread-handler-active-drain-timeout"); @@ -1975,6 +4058,13 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); + let target = operation_target(temp.path()); + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), ctx.catalog.catalog()) + .await + .expect("initial scan should succeed"); + update_and_announce_games(&ctx, &tx, scan).await; + assert_local_update(recv_event(&mut rx).await, false, true); + let initial_revision = ctx.local_library.read().await.revision; let token = CancellationToken::new(); ctx.active_outbound_transfers .write() @@ -1985,7 +4075,7 @@ mod tests { begin_operation_with_drain_timeout( &ctx, &tx, - "game", + &target, OperationKind::Updating, Duration::from_millis(1), ) @@ -1994,6 +4084,10 @@ mod tests { ); assert!(token.is_cancelled()); + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("operation admission should withdraw availability first"); + }; + assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &[ActiveOperation { @@ -2001,11 +4095,96 @@ mod tests { operation: ActiveOperationKind::Updating, }], ); + assert_local_update(recv_event(&mut rx).await, false, true); assert_active_update(recv_event(&mut rx).await, &[]); assert!( !ctx.active_operations.read().await.contains_key("game"), "timed-out drain should not leave the operation stuck active" ); + let snapshot = { + let library = ctx.local_library.read().await; + assert_eq!(library.revision, initial_revision + 2); + crate::library::build_library_snapshot( + library.publication(ctx.catalog.catalog()), + &ctx.catalog, + ) + .expect("restored snapshot should build") + }; + assert_eq!(snapshot.games.len(), 1); + assert_eq!(snapshot.games[0].game_id, "game"); + } + + #[tokio::test] + async fn begin_operation_revision_exhaustion_is_fail_closed() { + let temp = TempDir::new("lanspread-handler-active-revision-exhaustion"); + let root = temp.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + + let ctx = test_ctx(temp.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), ctx.catalog.catalog()) + .await + .expect("initial scan should succeed"); + update_and_announce_games(&ctx, &tx, scan).await; + assert_local_update(recv_event(&mut rx).await, false, true); + + let before_games = { + let mut library = ctx.local_library.write().await; + library.revision = u64::MAX; + library.games.clone() + }; + let before_db = ctx + .local_game_db + .read() + .await + .as_ref() + .expect("initial database should be published") + .clone(); + + assert_eq!( + begin_operation( + &ctx, + &tx, + &operation_target(temp.path()), + OperationKind::Installing, + ) + .await, + BeginOperationResult::PublicationFailed + ); + + assert!(ctx.active_operations.read().await.is_empty()); + let library = ctx.local_library.read().await; + assert_eq!(library.revision, u64::MAX); + assert_eq!(library.games, before_games); + drop(library); + assert_eq!( + ctx.local_game_db + .read() + .await + .as_ref() + .expect("database should remain published") + .all_games(), + before_db.all_games(), + ); + assert_no_event(&mut rx).await; + } + + #[test] + fn payload_replacing_operations_require_outbound_transfer_drain() { + assert!(operation_requires_outbound_drain( + OperationKind::Downloading + )); + assert!(operation_requires_outbound_drain(OperationKind::Updating)); + assert!(operation_requires_outbound_drain( + OperationKind::RemovingDownload + )); + assert!(!operation_requires_outbound_drain( + OperationKind::Installing + )); + assert!(!operation_requires_outbound_drain( + OperationKind::Uninstalling + )); } #[tokio::test] @@ -2017,15 +4196,15 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - let catalog = ctx.catalog.read().await.clone(); + let catalog = ctx.catalog.catalog(); - let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog) .await .expect("first scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; assert_local_update(recv_event(&mut rx).await, false, true); - let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog) .await .expect("second scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; @@ -2043,19 +4222,24 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) + let catalog = ctx.catalog.catalog(); + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog) .await .expect("initial scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; assert_local_update(recv_event(&mut rx).await, true, true); - run_install_operation(&ctx, &tx, "game".to_string()).await; + run_install_operation(&ctx, &tx, operation_target(temp.path())).await; + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("operation admission should withdraw the ready game"); + }; + assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::Updating), ); + assert_local_update(recv_event(&mut rx).await, true, true); assert_active_update(recv_event(&mut rx).await, &[]); assert!(matches!( recv_event(&mut rx).await, @@ -2074,7 +4258,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - run_install_operation(&ctx, &tx, "game".to_string()).await; + run_install_operation(&ctx, &tx, operation_target(temp.path())).await; assert_active_update( recv_event(&mut rx).await, @@ -2089,6 +4273,108 @@ mod tests { assert!(ctx.active_operations.read().await.is_empty()); } + #[tokio::test] + async fn install_rechecks_new_ownership_quarantine_after_admission() { + let temp = TempDir::new("lanspread-handler-install-stale-preflight"); + let root = temp.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + let ctx = test_ctx(temp.path().to_path_buf()); + let target = operation_target(temp.path()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + let prepared = prepare_install_operation(&ctx, &tx, &target) + .await + .expect("initial preflight should accept the settled download"); + assert_eq!( + begin_operation(&ctx, &tx, &target, prepared.operation_kind).await, + BeginOperationResult::Started + ); + let operation_guard = OperationGuard::new("game".to_string()); + seed_pending_download_ownership_for_test( + ctx.state_dir.as_ref(), + temp.path(), + "game", + &["game.eti"], + &["game.eti"], + ) + .await; + + assert!( + revalidate_install_operation(&ctx, &tx, &target, prepared.operation_kind) + .await + .is_none(), + "post-admission ownership quarantine must invalidate the stale preflight" + ); + assert!( + settle_and_end_operation( + &ctx, + &tx, + &target, + operation_guard, + "test stale install preflight", + ) + .await + ); + + assert!(!root.join("local").exists()); + assert!(ctx.active_operations.read().await.is_empty()); + assert!( + drain_events(&mut rx) + .iter() + .any(|event| matches!(event, PeerEvent::InstallGameFailed { id } if id == "game")) + ); + } + + #[tokio::test] + async fn streamed_install_rechecks_new_ownership_quarantine_after_admission() { + let temp = TempDir::new("lanspread-handler-stream-stale-preflight"); + let root = temp.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + let ctx = test_ctx(temp.path().to_path_buf()); + let target = operation_target(temp.path()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + assert!(stream_install_target_is_ready(&ctx, &target).await); + assert_eq!( + begin_operation(&ctx, &tx, &target, OperationKind::Downloading).await, + BeginOperationResult::Started + ); + let (status, cancel) = register_test_download(&ctx, &tx).await; + let guard = OperationGuard::download("game".to_string(), cancel); + seed_pending_download_ownership_for_test( + ctx.state_dir.as_ref(), + temp.path(), + "game", + &["game.eti"], + &["game.eti"], + ) + .await; + + assert!(!stream_install_target_is_ready(&ctx, &target).await); + finish_failed_stream_download( + &ctx, + &tx, + &target, + guard, + &status, + Some(DownloadFailureReason::OperationFailed), + ) + .await; + + assert!(!root.join("local").exists()); + assert!(ctx.active_operations.read().await.is_empty()); + assert!(ctx.active_downloads.read().await.is_empty()); + assert!(drain_events(&mut rx).iter().any(|event| matches!( + event, + PeerEvent::DownloadGameFilesFailed { + attempt, + reason: DownloadFailureReason::OperationFailed, + } if attempt.id == "game" + ))); + } + #[tokio::test] async fn download_handoff_waits_for_readers_and_auto_installs() { let temp = TempDir::new("lanspread-handler-download-handoff"); @@ -2097,16 +4383,14 @@ mod tests { write_file(&root.join("game.eti"), b"archive"); let ctx = test_ctx(temp.path().to_path_buf()); + let (prepare_tx, _prepare_rx) = mpsc::unbounded_channel(); + let (download_status, _download_cancel) = register_test_download(&ctx, &prepare_tx).await; ctx.active_operations .write() .await .insert("game".to_string(), OperationKind::Downloading); - ctx.active_downloads - .write() - .await - .insert("game".to_string(), CancellationToken::new()); - let (prepare_tx, _prepare_rx) = mpsc::unbounded_channel(); - let prepared = prepare_install_operation(&ctx, &prepare_tx, "game") + let target = operation_target(temp.path()); + let prepared = prepare_install_operation(&ctx, &prepare_tx, &target) .await .expect("downloaded game should be installable"); let read_guard = ctx.active_operations.read().await; @@ -2115,13 +4399,26 @@ mod tests { let install_task = tokio::spawn({ let ctx = ctx.clone(); let tx = tx.clone(); + let target = target.clone(); + let attempt = download_status.key().clone(); async move { assert!( transition_download_to_install(&ctx, &tx, "game", prepared.operation_kind) .await ); - clear_active_download(&ctx, "game").await; - run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await; + clear_active_download(&ctx, &attempt).await; + let cancel_token = CancellationToken::new(); + let operation_guard = + OperationGuard::cancellable("game".to_string(), cancel_token.clone()); + run_started_install_operation( + &ctx, + &tx, + target, + prepared, + operation_guard, + cancel_token, + ) + .await; } }); @@ -2148,16 +4445,12 @@ mod tests { async fn cancel_download_command_only_cancels_active_token() { let temp = TempDir::new("lanspread-handler-cancel-download"); let ctx = test_ctx(temp.path().to_path_buf()); - let cancel = CancellationToken::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let (_status, cancel) = register_test_download(&ctx, &tx).await; ctx.active_operations .write() .await .insert("game".to_string(), OperationKind::Downloading); - ctx.active_downloads - .write() - .await - .insert("game".to_string(), cancel.clone()); - let (tx, mut rx) = mpsc::unbounded_channel(); handle_cancel_download_command(&ctx, &tx, "game".to_string()).await; @@ -2181,7 +4474,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - run_install_operation(&ctx, &tx, "game".to_string()).await; + run_install_operation(&ctx, &tx, operation_target(temp.path())).await; assert_active_update( recv_event(&mut rx).await, @@ -2206,7 +4499,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - run_install_operation(&ctx, &tx, "game".to_string()).await; + run_install_operation(&ctx, &tx, operation_target(temp.path())).await; assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::Installing), @@ -2222,7 +4515,11 @@ mod tests { write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("game.eti"), b"new archive"); - run_install_operation(&ctx, &tx, "game".to_string()).await; + run_install_operation(&ctx, &tx, operation_target(temp.path())).await; + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("update admission should withdraw the ready game"); + }; + assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::Updating), @@ -2235,7 +4532,11 @@ mod tests { PeerEvent::InstallGameFinished { id } if id == "game" )); - run_uninstall_operation(&ctx, &tx, "game".to_string()).await; + run_uninstall_operation(&ctx, &tx, operation_target(temp.path())).await; + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("uninstall admission should withdraw the ready game"); + }; + assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::Uninstalling), @@ -2261,7 +4562,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - run_uninstall_operation(&ctx, &tx, "game".to_string()).await; + run_uninstall_operation(&ctx, &tx, operation_target(temp.path())).await; assert_active_update( recv_event(&mut rx).await, @@ -2292,23 +4593,23 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::unbounded_channel(); - let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) + let catalog = ctx.catalog.catalog(); + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), catalog) .await .expect("initial scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; assert_local_update(recv_event(&mut rx).await, false, true); - run_remove_downloaded_operation(&ctx, &tx, "game".to_string()).await; + run_remove_downloaded_operation(&ctx, &tx, operation_target(temp.path())).await; + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("download removal admission should withdraw the ready game"); + }; + assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::RemovingDownload), ); - let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { - panic!("expected LocalLibraryChanged"); - }; - assert!(games.is_empty()); assert_active_update(recv_event(&mut rx).await, &[]); assert!(matches!( recv_event(&mut rx).await, @@ -2321,7 +4622,134 @@ mod tests { } #[tokio::test] - async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() { + async fn install_command_does_not_retarget_after_set_game_dir() { + let current = TempDir::new("lanspread-handler-install-captured-root"); + let next = TempDir::new("lanspread-handler-install-next-root"); + for root in [current.game_root(), next.game_root()] { + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + } + let ctx = test_ctx(current.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + // On the current-thread test runtime, this ready lock acquisition does + // not yield to the newly spawned command. Move the configured root + // after capture so its operation must reject the stale target. + handle_install_game_command(&ctx, &tx, "game".to_string()).await; + *ctx.game_dir.write().await = next.path().to_path_buf(); + + assert_eq!(*ctx.game_dir.read().await, next.path()); + assert!(!current.game_root().join("local").exists()); + assert!(!next.game_root().join("local").exists()); + let terminal_event = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let event = recv_event(&mut rx).await; + if matches!(event, PeerEvent::InstallGameFailed { ref id } if id == "game") { + break event; + } + assert!(!matches!( + event, + PeerEvent::InstallGameFinished { ref id } if id == "game" + )); + } + }) + .await + .expect("captured-root install should report failure"); + assert!(matches!( + terminal_event, + PeerEvent::InstallGameFailed { id } if id == "game" + )); + } + + #[tokio::test] + async fn uninstall_command_does_not_retarget_after_set_game_dir() { + let current = TempDir::new("lanspread-handler-uninstall-captured-root"); + let next = TempDir::new("lanspread-handler-uninstall-next-root"); + for root in [current.game_root(), next.game_root()] { + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + write_file(&root.join("local/payload.txt"), b"installed"); + } + let ctx = test_ctx(current.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + handle_uninstall_game_command(&ctx, &tx, "game".to_string()).await; + *ctx.game_dir.write().await = next.path().to_path_buf(); + + assert_eq!(*ctx.game_dir.read().await, next.path()); + assert!(current.game_root().join("local/payload.txt").is_file()); + assert!(next.game_root().join("local/payload.txt").is_file()); + let terminal_event = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let event = recv_event(&mut rx).await; + if matches!(event, PeerEvent::UninstallGameFailed { ref id } if id == "game") { + break event; + } + assert!(!matches!( + event, + PeerEvent::UninstallGameFinished { ref id } if id == "game" + )); + } + }) + .await + .expect("captured-root uninstall should report failure"); + assert!(matches!( + terminal_event, + PeerEvent::UninstallGameFailed { id } if id == "game" + )); + } + + #[tokio::test] + async fn remove_downloaded_command_does_not_retarget_after_set_game_dir() { + let current = TempDir::new("lanspread-handler-remove-captured-root"); + let next = TempDir::new("lanspread-handler-remove-next-root"); + for root in [current.game_root(), next.game_root()] { + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + } + let ctx = test_ctx(current.path().to_path_buf()); + crate::download::seed_download_ownership_for_test( + ctx.state_dir.as_ref(), + current.path(), + "game", + &["game.eti"], + ) + .await; + let (tx, mut rx) = mpsc::unbounded_channel(); + + handle_remove_downloaded_game_command(&ctx, &tx, "game".to_string()).await; + *ctx.game_dir.write().await = next.path().to_path_buf(); + + assert_eq!(*ctx.game_dir.read().await, next.path()); + assert!(current.game_root().join("version.ini").is_file()); + assert!(current.game_root().join("game.eti").is_file()); + assert!(next.game_root().join("version.ini").is_file()); + assert!(next.game_root().join("game.eti").is_file()); + let terminal_event = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let event = recv_event(&mut rx).await; + if matches!( + event, + PeerEvent::RemoveDownloadedGameFailed { ref id } if id == "game" + ) { + break event; + } + assert!(!matches!( + event, + PeerEvent::RemoveDownloadedGameFinished { ref id } if id == "game" + )); + } + }) + .await + .expect("captured-root removal should report failure"); + assert!(matches!( + terminal_event, + PeerEvent::RemoveDownloadedGameFailed { id } if id == "game" + )); + } + + #[tokio::test] + async fn active_operation_rejection_is_returned_in_set_game_dir_reply() { let current = TempDir::new("lanspread-handler-current-dir"); let next = TempDir::new("lanspread-handler-next-dir"); let ctx = test_ctx(current.path().to_path_buf()); @@ -2331,40 +4759,435 @@ mod tests { .insert("game".to_string(), OperationKind::Downloading); let (tx, _rx) = mpsc::unbounded_channel(); - handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await; + let error = handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()) + .await + .expect_err("active operation should reject SetGameDir"); + assert!(error.contains("operations are active")); assert_eq!(*ctx.game_dir.read().await, current.path()); } + #[cfg(unix)] #[tokio::test] - async fn same_path_set_game_dir_refreshes_without_recovery() { + async fn same_directory_alias_reply_uses_existing_canonical_root() { + use std::os::unix::fs::symlink; + + let current = TempDir::new("lanspread-handler-same-alias-target"); + let aliases = TempDir::new("lanspread-handler-same-alias-parent"); + let alias = aliases.path().join("games"); + symlink(current.path(), &alias).expect("same-root alias should be created"); + let current = std::fs::canonicalize(current.path()).expect("root should canonicalize"); + let ctx = test_ctx(current.clone()); + let (tx, _rx) = mpsc::unbounded_channel(); + + let accepted = handle_set_game_dir_command(&ctx, &tx, alias) + .await + .expect("same-root alias should be accepted"); + + assert_eq!(accepted, current); + assert_eq!(*ctx.game_dir.read().await, current); + } + + #[cfg(unix)] + #[tokio::test] + async fn path_change_alias_reply_and_storage_use_canonical_target() { + use std::os::unix::fs::symlink; + + let current = TempDir::new("lanspread-handler-change-alias-current"); + let next = TempDir::new("lanspread-handler-change-alias-target"); + let aliases = TempDir::new("lanspread-handler-change-alias-parent"); + let alias = aliases.path().join("games"); + symlink(next.path(), &alias).expect("new-root alias should be created"); + let current = std::fs::canonicalize(current.path()).expect("root should canonicalize"); + let next = std::fs::canonicalize(next.path()).expect("new root should canonicalize"); + let ctx = test_ctx(current); + let (tx, _rx) = mpsc::unbounded_channel(); + + let accepted = handle_set_game_dir_command(&ctx, &tx, alias.clone()) + .await + .expect("new-root alias should be accepted"); + + assert_eq!(accepted, next); + assert_ne!(accepted, alias); + assert_eq!(*ctx.game_dir.read().await, next); + } + + #[tokio::test] + async fn invalid_directory_rejection_reply_preserves_current_root() { + let current = TempDir::new("lanspread-handler-invalid-dir-current"); + let missing = current.path().join("missing"); + let ctx = test_ctx(current.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + let error = handle_set_game_dir_command(&ctx, &tx, missing) + .await + .expect_err("missing root should be rejected"); + + assert!(error.contains("failed to canonicalize game directory")); + assert_eq!(*ctx.game_dir.read().await, current.path()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn foreign_intent_for_absent_candidate_game_rejects_before_root_mutation() { + let current = TempDir::new("lanspread-handler-foreign-intent-current"); + let candidate = TempDir::new("lanspread-handler-foreign-intent-candidate"); + let current_path = + std::fs::canonicalize(current.path()).expect("current root should canonicalize"); + let candidate_path = + std::fs::canonicalize(candidate.path()).expect("candidate root should canonicalize"); + let ctx = test_ctx(current_path.clone()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let published = summary("game", "20250101", Availability::Ready); + ctx.local_library + .write() + .await + .update_from_scan( + ¤t_path, + HashMap::from([("game".to_string(), published.clone())]), + 1, + ) + .expect("initial library revision should be available"); + *ctx.local_game_db.write().await = Some(GameDB::from(vec![game_from_summary(&published)])); + let before = ctx.local_library.read().await.clone(); + + let intent = InstallIntent::new( + ¤t_path.join("orphan"), + "orphan", + InstallIntentState::Updating, + None, + ) + .expect("old-root intent should be valid"); + write_intent(ctx.state_dir.as_ref(), "orphan", &intent) + .expect("old-root intent should be persisted"); + + let error = handle_set_game_dir_command(&ctx, &tx, candidate_path.clone()) + .await + .expect_err("foreign intent must reject the root switch"); + + assert!(error.contains("install intent state is unresolved")); + assert!(error.contains("different configured games")); + assert_eq!(*ctx.game_dir.read().await, current_path); + assert!(!candidate_path.join("orphan").exists()); + assert!(intent_path(ctx.state_dir.as_ref(), "orphan").is_file()); + assert!(!ctx.recovery_quarantine.is_blocked(¤t_path, "game")); + assert!(ctx.local_game_db.read().await.is_some()); + let after = ctx.local_library.read().await; + assert_eq!(after.revision, before.revision); + assert_eq!(after.games, before.games); + drop(after); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn same_path_set_game_dir_drains_all_transfers_before_recovery() { let temp = TempDir::new("lanspread-handler-same-dir"); write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp"); let ctx = test_ctx(temp.path().to_path_buf()); let (tx, _rx) = mpsc::unbounded_channel(); + let first = CancellationToken::new(); + let second = CancellationToken::new(); + *ctx.active_outbound_transfers.write().await = HashMap::from([ + ("game".to_string(), vec![(1, first.clone())]), + ("other".to_string(), vec![(2, second.clone())]), + ]); - handle_set_game_dir_command(&ctx, &tx, temp.path().to_path_buf()).await; - ctx.task_tracker.close(); - ctx.task_tracker.wait().await; + let command_ctx = ctx.clone(); + let command_tx = tx.clone(); + let game_dir = temp.path().to_path_buf(); + let command = tokio::spawn(async move { + handle_set_game_dir_command_with_drain_timeout( + &command_ctx, + &command_tx, + game_dir, + Duration::from_secs(1), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), async { + first.cancelled().await; + second.cancelled().await; + }) + .await + .expect("all outbound transfers should be cancelled"); + assert!(temp.game_root().join(".version.ini.tmp").is_file()); + + ctx.active_outbound_transfers.write().await.remove("game"); + tokio::task::yield_now().await; + assert!(!command.is_finished()); + assert!(temp.game_root().join(".version.ini.tmp").is_file()); + + ctx.active_outbound_transfers.write().await.remove("other"); + assert_eq!( + command + .await + .expect("SetGameDir task should not fail") + .expect("same root should be accepted"), + temp.path().to_path_buf() + ); + + assert!(ctx.active_outbound_transfers.read().await.is_empty()); + assert!(!temp.game_root().join(".version.ini.tmp").exists()); + } + + #[tokio::test] + async fn same_path_drain_timeout_rejection_is_returned_in_reply() { + let temp = TempDir::new("lanspread-handler-same-dir-timeout"); + write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp"); + let ctx = test_ctx(temp.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let token = CancellationToken::new(); + ctx.active_outbound_transfers + .write() + .await + .insert("game".to_string(), vec![(1, token.clone())]); + + let error = handle_set_game_dir_command_with_drain_timeout( + &ctx, + &tx, + temp.path().to_path_buf(), + Duration::from_millis(1), + ) + .await + .expect_err("drain timeout should reject SetGameDir"); + + assert!(error.contains("did not drain")); + assert!(token.is_cancelled()); + assert_eq!(*ctx.game_dir.read().await, temp.path()); + assert!(temp.game_root().join(".version.ini.tmp").is_file()); + assert!(!ctx.recovery_quarantine.is_blocked(temp.path(), "game")); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn same_path_set_game_dir_skips_recovery_for_active_game() { + let temp = TempDir::new("lanspread-handler-same-dir-active"); + write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp"); + let ctx = test_ctx(temp.path().to_path_buf()); + ctx.active_operations + .write() + .await + .insert("game".to_string(), OperationKind::Downloading); + let (tx, _rx) = mpsc::unbounded_channel(); + + let error = handle_set_game_dir_command(&ctx, &tx, temp.path().to_path_buf()) + .await + .expect_err("active game should reject SetGameDir refresh"); + + assert!(error.contains("operations are active")); assert!(temp.game_root().join(".version.ini.tmp").is_file()); } #[tokio::test] - async fn path_changing_set_game_dir_runs_recovery() { + async fn path_changing_set_game_dir_drains_all_transfers_before_switch() { let current = TempDir::new("lanspread-handler-old-dir"); let next = TempDir::new("lanspread-handler-new-dir"); write_file(&next.game_root().join(".version.ini.tmp"), b"tmp"); let ctx = test_ctx(current.path().to_path_buf()); let (tx, _rx) = mpsc::unbounded_channel(); + let first = CancellationToken::new(); + let second = CancellationToken::new(); + *ctx.active_outbound_transfers.write().await = HashMap::from([ + ("game".to_string(), vec![(1, first.clone())]), + ("other".to_string(), vec![(2, second.clone())]), + ]); - handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await; - ctx.task_tracker.close(); - ctx.task_tracker.wait().await; + let command_ctx = ctx.clone(); + let command_tx = tx.clone(); + let next_path = next.path().to_path_buf(); + let command = tokio::spawn(async move { + handle_set_game_dir_command_with_drain_timeout( + &command_ctx, + &command_tx, + next_path, + Duration::from_secs(1), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), async { + first.cancelled().await; + second.cancelled().await; + }) + .await + .expect("all outbound transfers should be cancelled"); + assert_eq!(*ctx.game_dir.read().await, current.path()); + assert!(next.game_root().join(".version.ini.tmp").is_file()); + + ctx.active_outbound_transfers.write().await.remove("game"); + tokio::task::yield_now().await; + assert!(!command.is_finished()); + assert_eq!(*ctx.game_dir.read().await, current.path()); + + ctx.active_outbound_transfers.write().await.remove("other"); + assert_eq!( + command + .await + .expect("SetGameDir task should not fail") + .expect("new root should be accepted"), + next.path().to_path_buf() + ); + + assert_eq!(*ctx.game_dir.read().await, next.path()); assert!(!next.game_root().join(".version.ini.tmp").exists()); } + #[tokio::test] + async fn path_change_drain_timeout_rejection_preserves_current_state() { + let current = TempDir::new("lanspread-handler-old-dir-timeout"); + let next = TempDir::new("lanspread-handler-new-dir-timeout"); + let root = current.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join("game.eti"), b"archive"); + let ctx = test_ctx(current.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + let catalog = ctx.catalog.catalog(); + let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), catalog) + .await + .expect("initial scan should succeed"); + update_and_announce_games(&ctx, &tx, scan).await; + let _ = recv_event(&mut rx).await; + assert!( + ctx.recovery_quarantine + .settle(current.path(), HashSet::from(["game".to_string()])) + ); + let library_before = ctx.local_library.read().await.clone(); + assert!(ctx.local_game_db.read().await.is_some()); + + write_file(&root.join(".version.ini.tmp"), b"old tmp"); + write_file(&next.game_root().join(".version.ini.tmp"), b"new tmp"); + let token = CancellationToken::new(); + ctx.active_outbound_transfers + .write() + .await + .insert("game".to_string(), vec![(1, token.clone())]); + + let error = handle_set_game_dir_command_with_drain_timeout( + &ctx, + &tx, + next.path().to_path_buf(), + Duration::from_millis(1), + ) + .await + .expect_err("drain timeout should reject SetGameDir"); + + assert!(error.contains("did not drain")); + assert!(token.is_cancelled()); + assert_eq!(*ctx.game_dir.read().await, current.path()); + assert!(root.join(".version.ini.tmp").is_file()); + assert!(next.game_root().join(".version.ini.tmp").is_file()); + assert_eq!( + ctx.recovery_quarantine.failed_ids(current.path()), + HashSet::from(["game".to_string()]) + ); + let library_after = ctx.local_library.read().await; + assert_eq!(library_after.revision, library_before.revision); + assert_eq!(library_after.games, library_before.games); + drop(library_after); + assert!(ctx.local_game_db.read().await.is_some()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn path_change_fails_before_mutation_when_publication_revision_is_exhausted() { + let current = TempDir::new("lanspread-handler-revision-exhausted-current"); + let next = TempDir::new("lanspread-handler-revision-exhausted-next"); + let ctx = test_ctx(current.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let published = summary("game", "20250101", Availability::Ready); + ctx.local_library + .write() + .await + .update_from_scan( + current.path(), + HashMap::from([("game".to_string(), published.clone())]), + u64::MAX, + ) + .expect("maximum source revision may be published once"); + ctx.local_library.write().await.revision = u64::MAX; + *ctx.local_game_db.write().await = Some(GameDB::from(vec![game_from_summary(&published)])); + + let error = handle_set_game_dir_command_with_drain_timeout( + &ctx, + &tx, + next.path().to_path_buf(), + Duration::from_secs(1), + ) + .await + .expect_err("pre-mutation recovery failure should reject SetGameDir"); + + assert!(error.contains("failed to begin recovery")); + assert_eq!(*ctx.game_dir.read().await, current.path()); + let library = ctx.local_library.read().await; + assert_eq!(library.revision, u64::MAX); + assert_eq!(library.games["game"], published); + drop(library); + assert!(ctx.local_game_db.read().await.is_some()); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn accepted_quarantined_root_is_returned_in_canonical_reply() { + let current = TempDir::new("lanspread-handler-old-dir-recovery-failure"); + let next = TempDir::new("lanspread-handler-new-dir-recovery-failure"); + let next_root = next.game_root(); + write_file(&next_root.join("version.ini"), b"20250101"); + write_file(&next_root.join("game.eti"), b"archive"); + std::fs::create_dir_all(next_root.join(".version.ini.tmp")) + .expect("invalid recovery scratch directory should be created"); + let ctx = test_ctx(current.path().to_path_buf()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + assert_eq!( + handle_set_game_dir_command_with_drain_timeout( + &ctx, + &tx, + next.path().to_path_buf(), + Duration::from_secs(1), + ) + .await + .expect("accepted root should be acknowledged despite quarantine"), + next.path().to_path_buf() + ); + + assert_eq!(*ctx.game_dir.read().await, next.path()); + assert!(ctx.recovery_quarantine.is_blocked(next.path(), "game")); + let library = ctx.local_library.read().await; + let game = library + .games + .get("game") + .expect("failed game should be published in quarantined state"); + assert!(!game.downloaded); + assert!(!game.installed); + drop(library); + + assert_eq!( + begin_operation( + &ctx, + &tx, + &operation_target(next.path()), + OperationKind::Downloading, + ) + .await, + BeginOperationResult::RecoveryBlocked + ); + assert!(ctx.active_operations.read().await.is_empty()); + + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("expected cleared local library snapshot"); + }; + assert!(games.is_empty()); + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("expected quarantined local library snapshot"); + }; + assert_eq!(games.len(), 1); + assert_eq!(games[0].id, "game"); + assert!(!games[0].downloaded); + assert_eq!(games[0].availability, Availability::LocalOnly); + } + #[tokio::test] async fn path_changing_set_game_dir_emits_equivalent_snapshot() { let current = TempDir::new("lanspread-handler-old-equivalent-dir"); @@ -2376,17 +5199,47 @@ mod tests { let ctx = test_ctx(current.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), &catalog) + let catalog = ctx.catalog.catalog(); + let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), catalog) .await .expect("initial scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; assert_local_update(recv_event(&mut rx).await, false, true); - handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await; - ctx.task_tracker.close(); - ctx.task_tracker.wait().await; + let initial_snapshot = { + let library = ctx.local_library.read().await; + crate::library::build_library_snapshot( + library.publication(ctx.catalog.catalog()), + &ctx.catalog, + ) + .expect("initial catalog snapshot should build") + }; + let initial_revision = initial_snapshot.revision; + assert_eq!(initial_snapshot.games.len(), 1); + assert_eq!( + handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()) + .await + .expect("equivalent new root should be accepted"), + next.path().to_path_buf() + ); + + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("expected cleared local library snapshot"); + }; + assert!(games.is_empty()); assert_local_update(recv_event(&mut rx).await, false, true); + + let restored_snapshot = { + let library = ctx.local_library.read().await; + crate::library::build_library_snapshot( + library.publication(ctx.catalog.catalog()), + &ctx.catalog, + ) + .expect("restored catalog snapshot should build") + }; + assert!(restored_snapshot.revision >= initial_revision + 2); + assert_eq!(restored_snapshot.games.len(), 1); + assert_eq!(restored_snapshot.games[0].game_id, "game"); } } diff --git a/crates/lanspread-peer/src/identity.rs b/crates/lanspread-peer/src/identity.rs index 5a14522..efee2eb 100644 --- a/crates/lanspread-peer/src/identity.rs +++ b/crates/lanspread-peer/src/identity.rs @@ -1,34 +1,1103 @@ -use std::path::Path; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; +use std::{ + fmt, + fs::{self, File}, + io::{ErrorKind, Read as _, Write as _}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; -use uuid::Uuid; +use base64::prelude::{BASE64_STANDARD_NO_PAD, Engine as _}; +use cap_fs_ext::{ + FollowSymlinks, + OpenOptionsFollowExt as _, + OpenOptionsMaybeDirExt as _, + OpenOptionsSyncExt as _, +}; +#[cfg(unix)] +use cap_primitives::fs::OpenOptionsExt as _; +use cap_primitives::{ + ambient_authority, + fs::{self as cap_fs, OpenOptions as CapOpenOptions}, +}; +use eyre::{WrapErr as _, bail, ensure}; +use lanspread_proto::PeerId; +use rcgen::{ + CertificateParams, + DistinguishedName, + DnType, + ExtendedKeyUsagePurpose, + KeyPair, + KeyUsagePurpose, + PKCS_ED25519, + PublicKeyData as _, +}; +use rustls::{ + crypto::aws_lc_rs, + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}, + server::ParsedCertificate, + sign::CertifiedKey, +}; +use serde::{Deserialize, Serialize}; -use crate::state_paths::peer_id_path; +use crate::state_paths::peer_identity_path; -pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1"; -pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1"; -pub const FEATURE_CALL_TO_PLAY: &str = "call-to-play-v1"; +pub(crate) const PEER_SNI_SUFFIX: &str = ".peer.lanspread.invalid"; -pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result { - let path = peer_id_path(state_dir); - if let Ok(existing) = std::fs::read_to_string(&path) { - let trimmed = existing.trim(); - if !trimmed.is_empty() { - return Ok(trimmed.to_string()); +const IDENTITY_RECORD_VERSION: u32 = 1; +const IDENTITY_ALGORITHM: &str = "ed25519"; +const MAX_IDENTITY_RECORD_BYTES: usize = 64 * 1024; +const MAX_CERTIFICATE_BYTES: usize = 16 * 1024; +const MAX_PRIVATE_KEY_BYTES: usize = 4 * 1024; +const ED25519_SPKI_PREFIX: &[u8; 12] = &[ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, +]; +const ED25519_PUBLIC_KEY_BYTES: usize = 32; +const UNIQUE_PATH_ATTEMPTS: usize = 64; + +static NEXT_IDENTITY_SIDECAR: AtomicU64 = AtomicU64::new(0); + +/// The private responder identity retained for the lifetime of one peer runtime. +pub struct PeerIdentity { + peer_id: PeerId, + certificate: CertificateDer<'static>, + private_key: PrivatePkcs8KeyDer<'static>, +} + +impl fmt::Debug for PeerIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PeerIdentity") + .field("peer_id", &self.peer_id) + .field("certificate_der_len", &self.certificate.as_ref().len()) + .field("private_key", &"") + .finish() + } +} + +impl PeerIdentity { + pub(crate) fn generate() -> eyre::Result { + let key_pair = KeyPair::generate_for(&PKCS_ED25519) + .wrap_err("failed to generate Ed25519 peer identity key")?; + let key_spki = key_pair.subject_public_key_info(); + let peer_id = peer_id_from_spki(&key_spki)?; + let server_name = server_name_for_peer(peer_id) + .wrap_err("derived PeerId did not form a valid TLS server name")?; + + let mut params = CertificateParams::new(vec![server_name]) + .wrap_err("failed to construct peer identity certificate parameters")?; + let mut distinguished_name = DistinguishedName::new(); + distinguished_name.push(DnType::CommonName, "lanspread peer"); + params.distinguished_name = distinguished_name; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + + let certificate = params + .self_signed(&key_pair) + .wrap_err("failed to issue self-signed peer identity certificate")? + .der() + .clone(); + let private_key = PrivatePkcs8KeyDer::from(key_pair.serialize_der()); + Self::from_der(certificate, private_key) + .wrap_err("generated peer identity did not pass strict validation") + } + + fn from_der( + certificate: CertificateDer<'static>, + private_key: PrivatePkcs8KeyDer<'static>, + ) -> eyre::Result { + ensure!( + certificate.as_ref().len() <= MAX_CERTIFICATE_BYTES, + "peer identity certificate exceeds the size limit" + ); + ensure!( + private_key.secret_pkcs8_der().len() <= MAX_PRIVATE_KEY_BYTES, + "peer identity private key exceeds the size limit" + ); + + let parsed = ParsedCertificate::try_from(&certificate) + .wrap_err("peer identity certificate is not valid DER X.509")?; + let certificate_spki = parsed.subject_public_key_info(); + let peer_id = peer_id_from_spki(certificate_spki.as_ref())?; + + let key_pair = KeyPair::from_pkcs8_der_and_sign_algo(&private_key, &PKCS_ED25519) + .wrap_err("peer identity private key is not Ed25519 PKCS#8")?; + ensure!( + key_pair.subject_public_key_info() == certificate_spki.as_ref(), + "peer identity certificate and private key have different public keys" + ); + + let server_name = ServerName::try_from(server_name_for_peer(peer_id)?) + .wrap_err("peer identity server name is invalid")?; + rustls::client::verify_server_name(&parsed, &server_name) + .wrap_err("peer identity certificate SAN does not match its PeerId")?; + + let provider = aws_lc_rs::default_provider(); + let certified_key = CertifiedKey::from_der( + vec![certificate.clone()], + PrivateKeyDer::Pkcs8(private_key.clone_key()), + &provider, + ) + .wrap_err("rustls rejected the peer identity certificate or key")?; + certified_key + .keys_match() + .wrap_err("rustls could not prove peer identity certificate/key consistency")?; + + Ok(Self { + peer_id, + certificate, + private_key, + }) + } + + #[must_use] + pub const fn peer_id(&self) -> PeerId { + self.peer_id + } + + pub(crate) fn certificate(&self) -> CertificateDer<'static> { + self.certificate.clone() + } + + pub(crate) fn private_key(&self) -> PrivateKeyDer<'static> { + PrivateKeyDer::Pkcs8(self.private_key.clone_key()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PeerIdentityPersistenceFailureKind { + Read, + Permissions, + Quarantine, + Write, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerIdentityPersistenceFailure { + pub kind: PeerIdentityPersistenceFailureKind, + pub path: PathBuf, + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PeerIdentityPersistence { + Loaded, + Created, + ReplacedCorrupt { quarantine_path: PathBuf }, + Ephemeral(PeerIdentityPersistenceFailure), +} + +#[derive(Debug)] +pub struct LoadedPeerIdentity { + pub identity: PeerIdentity, + pub persistence: PeerIdentityPersistence, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct IdentityRecord { + version: u32, + algorithm: String, + certificate_der: String, + private_key_pkcs8_der: String, +} + +struct IdentityLocation { + parent: File, + path: PathBuf, + file_name: PathBuf, +} + +struct OpenedIdentityRecord { + location: IdentityLocation, + file: File, + bytes: Vec, +} + +struct UniqueIdentitySidecar { + file_name: PathBuf, + file: File, +} + +/// Loads the installation identity, or generates one identity for this runtime if persistence fails. +pub fn load_or_generate_peer_identity(state_dir: &Path) -> eyre::Result { + load_or_generate_peer_identity_with_persist(state_dir, persist_identity) +} + +fn load_or_generate_peer_identity_with_persist( + state_dir: &Path, + persist: impl FnOnce(&Path, &PeerIdentity) -> eyre::Result<()>, +) -> eyre::Result { + let path = peer_identity_path(state_dir); + match read_record_bytes(&path) { + Ok(None) => { + let identity = PeerIdentity::generate()?; + match persist(&path, &identity) { + Ok(()) => Ok(LoadedPeerIdentity { + identity, + persistence: PeerIdentityPersistence::Created, + }), + Err(error) => Ok(ephemeral_identity( + identity, + PeerIdentityPersistenceFailureKind::Write, + path, + &error, + )), + } + } + Ok(Some(opened)) => match decode_identity(&opened.bytes) { + Ok(identity) => match tighten_private_permissions(&opened.file) { + Ok(()) => Ok(LoadedPeerIdentity { + identity, + persistence: PeerIdentityPersistence::Loaded, + }), + Err(error) => Ok(ephemeral_identity( + PeerIdentity::generate()?, + PeerIdentityPersistenceFailureKind::Permissions, + path, + &error, + )), + }, + Err(_) => replace_corrupt_identity(&path, &opened), + }, + Err(error) => Ok(ephemeral_identity( + PeerIdentity::generate()?, + PeerIdentityPersistenceFailureKind::Read, + path, + &error, + )), + } +} + +#[cfg(test)] +pub(crate) fn load_or_generate_peer_identity_with_write_failure( + state_dir: &Path, +) -> eyre::Result { + load_or_generate_peer_identity_with_persist(state_dir, |_path, _identity| { + Err(eyre::eyre!("injected peer identity persist write failure")) + }) +} + +/// Loads an explicitly selected identity without mutation or recovery. +/// +/// This is the deterministic peer-CLI seam: malformed or missing input fails closed. +pub fn load_peer_identity(path: &Path) -> eyre::Result { + let opened = read_record_bytes(path) + .wrap_err_with(|| format!("failed to read peer identity {}", path.display()))? + .ok_or_else(|| eyre::eyre!("peer identity does not exist: {}", path.display()))?; + decode_identity(&opened.bytes) + .wrap_err_with(|| format!("peer identity is invalid: {}", path.display())) +} + +fn replace_corrupt_identity( + path: &Path, + opened: &OpenedIdentityRecord, +) -> eyre::Result { + let identity = PeerIdentity::generate()?; + let quarantine_path = match quarantine_corrupt_identity(opened) { + Ok(path) => path, + Err(error) => { + return Ok(ephemeral_identity( + identity, + PeerIdentityPersistenceFailureKind::Quarantine, + path.to_path_buf(), + &error, + )); + } + }; + + match persist_identity_at(&opened.location, &identity) { + Ok(()) => Ok(LoadedPeerIdentity { + identity, + persistence: PeerIdentityPersistence::ReplacedCorrupt { quarantine_path }, + }), + Err(error) => Ok(ephemeral_identity( + identity, + PeerIdentityPersistenceFailureKind::Write, + path.to_path_buf(), + &error, + )), + } +} + +fn ephemeral_identity( + identity: PeerIdentity, + kind: PeerIdentityPersistenceFailureKind, + path: PathBuf, + error: &eyre::Report, +) -> LoadedPeerIdentity { + LoadedPeerIdentity { + identity, + persistence: PeerIdentityPersistence::Ephemeral(PeerIdentityPersistenceFailure { + kind, + path, + message: format!("{error:#}"), + }), + } +} + +fn read_record_bytes(path: &Path) -> eyre::Result> { + let location = match open_identity_location(path) { + Ok(location) => location, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).wrap_err("failed to open peer identity parent directory"), + }; + let mut file = match open_regular_file_at(&location, &location.file_name) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error).wrap_err("failed to open peer identity without following links"); + } + }; + let read_limit = u64::try_from(MAX_IDENTITY_RECORD_BYTES + 1) + .wrap_err("identity read bound does not fit u64")?; + let mut bytes = Vec::new(); + std::io::Read::by_ref(&mut file) + .take(read_limit) + .read_to_end(&mut bytes) + .wrap_err("failed to read peer identity")?; + Ok(Some(OpenedIdentityRecord { + location, + file, + bytes, + })) +} + +fn decode_identity(bytes: &[u8]) -> eyre::Result { + ensure!( + bytes.len() <= MAX_IDENTITY_RECORD_BYTES, + "peer identity record exceeds the size limit" + ); + let record: IdentityRecord = + serde_json::from_slice(bytes).wrap_err("peer identity record is not strict JSON")?; + ensure!( + record.version == IDENTITY_RECORD_VERSION, + "unsupported peer identity record version {}", + record.version + ); + ensure!( + record.algorithm == IDENTITY_ALGORITHM, + "unsupported peer identity algorithm" + ); + + let certificate = decode_canonical_base64( + &record.certificate_der, + MAX_CERTIFICATE_BYTES, + "certificate", + )?; + let private_key = decode_canonical_base64( + &record.private_key_pkcs8_der, + MAX_PRIVATE_KEY_BYTES, + "private key", + )?; + PeerIdentity::from_der( + CertificateDer::from(certificate), + PrivatePkcs8KeyDer::from(private_key), + ) +} + +fn decode_canonical_base64(value: &str, max_len: usize, label: &str) -> eyre::Result> { + let decoded = BASE64_STANDARD_NO_PAD + .decode(value) + .wrap_err_with(|| format!("peer identity {label} is not unpadded base64"))?; + ensure!( + decoded.len() <= max_len, + "peer identity {label} exceeds the size limit" + ); + ensure!( + BASE64_STANDARD_NO_PAD.encode(&decoded) == value, + "peer identity {label} base64 is not canonical" + ); + Ok(decoded) +} + +fn encode_identity(identity: &PeerIdentity) -> eyre::Result> { + let record = IdentityRecord { + version: IDENTITY_RECORD_VERSION, + algorithm: IDENTITY_ALGORITHM.to_owned(), + certificate_der: BASE64_STANDARD_NO_PAD.encode(identity.certificate.as_ref()), + private_key_pkcs8_der: BASE64_STANDARD_NO_PAD + .encode(identity.private_key.secret_pkcs8_der()), + }; + let mut bytes = serde_json::to_vec(&record).wrap_err("failed to encode peer identity")?; + bytes.push(b'\n'); + ensure!( + bytes.len() <= MAX_IDENTITY_RECORD_BYTES, + "encoded peer identity exceeds the size limit" + ); + Ok(bytes) +} + +fn persist_identity(path: &Path, identity: &PeerIdentity) -> eyre::Result<()> { + let parent = identity_parent_path(path)?; + fs::create_dir_all(parent).wrap_err("failed to create peer identity directory")?; + let location = open_identity_location(path) + .wrap_err("failed to retain peer identity parent directory without following links")?; + persist_identity_at(&location, identity) +} + +fn persist_identity_at(location: &IdentityLocation, identity: &PeerIdentity) -> eyre::Result<()> { + let bytes = encode_identity(identity)?; + let mut temporary = create_unique_file(location, "tmp")?; + let write_result = (|| -> eyre::Result<()> { + tighten_private_permissions(&temporary.file)?; + temporary + .file + .write_all(&bytes) + .wrap_err("failed to write temporary peer identity")?; + temporary + .file + .sync_all() + .wrap_err("failed to sync temporary peer identity")?; + cap_fs::hard_link( + &location.parent, + &temporary.file_name, + &location.parent, + &location.file_name, + ) + .wrap_err("failed to publish peer identity without clobbering")?; + cap_fs::remove_file(&location.parent, &temporary.file_name) + .wrap_err("failed to remove published identity temporary link")?; + sync_parent_directory(&location.parent)?; + Ok(()) + })(); + if write_result.is_err() { + let _ = cap_fs::remove_file(&location.parent, &temporary.file_name); + } + write_result +} + +fn create_unique_file( + location: &IdentityLocation, + label: &str, +) -> eyre::Result { + for _ in 0..UNIQUE_PATH_ATTEMPTS { + let candidate = sidecar_path(&location.path, label); + let file_name: PathBuf = candidate + .file_name() + .ok_or_else(|| eyre::eyre!("peer identity sidecar path has no file name"))? + .into(); + let mut options = CapOpenOptions::new(); + options.read(true).write(true).create_new(true); + options.follow(FollowSymlinks::No).nonblock(true); + #[cfg(unix)] + options.mode(0o600); + match cap_fs::open(&location.parent, &file_name, &options) { + Ok(file) => { + validate_regular_file_handle(&file, &candidate)?; + return Ok(UniqueIdentitySidecar { file_name, file }); + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => return Err(error).wrap_err("failed to create temporary peer identity"), } } + bail!("could not allocate a unique peer identity sidecar path") +} - let peer_id = Uuid::now_v7().simple().to_string(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; +fn quarantine_corrupt_identity(opened: &OpenedIdentityRecord) -> eyre::Result { + tighten_private_permissions(&opened.file)?; + let location = &opened.location; + + for _ in 0..UNIQUE_PATH_ATTEMPTS { + let current = open_regular_file_at(location, &location.file_name) + .wrap_err("failed to re-open corrupt peer identity before quarantine")?; + ensure!( + same_identity_file(&opened.file, ¤t)?, + "peer identity changed before quarantine" + ); + + let candidate_path = sidecar_path(&location.path, "corrupt"); + let candidate_name: PathBuf = candidate_path + .file_name() + .ok_or_else(|| eyre::eyre!("peer identity quarantine path has no file name"))? + .into(); + match cap_fs::hard_link( + &location.parent, + &location.file_name, + &location.parent, + &candidate_name, + ) { + Ok(()) => { + let verification = (|| -> eyre::Result<()> { + let candidate = open_regular_file_at(location, &candidate_name) + .wrap_err("failed to verify corrupt identity quarantine link")?; + ensure!( + same_identity_file(&opened.file, &candidate)?, + "quarantine link does not name the retained corrupt identity" + ); + + let current = open_regular_file_at(location, &location.file_name) + .wrap_err("failed to re-open corrupt peer identity before removal")?; + ensure!( + same_identity_file(&opened.file, ¤t)?, + "peer identity changed while quarantine was being published" + ); + Ok(()) + })(); + if let Err(error) = verification { + cap_fs::remove_file(&location.parent, &candidate_name) + .wrap_err("failed to discard an unverified identity quarantine link")?; + sync_parent_directory(&location.parent)?; + return Err(error); + } + + sync_parent_directory(&location.parent)?; + cap_fs::remove_file(&location.parent, &location.file_name) + .wrap_err("failed to remove corrupt identity after quarantine publication")?; + sync_parent_directory(&location.parent)?; + return Ok(candidate_path); + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).wrap_err("failed to quarantine corrupt peer identity"); + } + } } - std::fs::write(&path, peer_id.as_bytes())?; - Ok(peer_id) + bail!("could not allocate a unique corrupt identity quarantine path") } -pub fn default_features() -> Vec { - vec![ - FEATURE_LIBRARY_DELTA.to_string(), - FEATURE_LIBRARY_SNAPSHOT.to_string(), - FEATURE_CALL_TO_PLAY.to_string(), - ] +fn identity_parent_path(path: &Path) -> eyre::Result<&Path> { + let parent = path + .parent() + .ok_or_else(|| eyre::eyre!("peer identity path has no parent"))?; + if parent.as_os_str().is_empty() { + Ok(Path::new(".")) + } else { + Ok(parent) + } +} + +fn open_identity_location(path: &Path) -> std::io::Result { + let parent_path = identity_parent_path(path) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidInput, error.to_string()))?; + let file_name = path.file_name().ok_or_else(|| { + std::io::Error::new( + ErrorKind::InvalidInput, + "peer identity path has no file name", + ) + })?; + let parent = cap_fs::open_ambient(parent_path, &directory_options(), ambient_authority())?; + validate_directory_handle(&parent, parent_path)?; + Ok(IdentityLocation { + parent, + path: path.to_path_buf(), + file_name: file_name.into(), + }) +} + +fn open_regular_file_at(location: &IdentityLocation, file_name: &Path) -> std::io::Result { + let file = cap_fs::open(&location.parent, file_name, ®ular_file_options())?; + validate_regular_file_handle(&file, &location.path)?; + Ok(file) +} + +fn directory_options() -> CapOpenOptions { + let mut options = CapOpenOptions::new(); + options.read(true); + options + .maybe_dir(true) + .follow(FollowSymlinks::No) + .nonblock(true); + options +} + +fn regular_file_options() -> CapOpenOptions { + let mut options = CapOpenOptions::new(); + options.read(true); + options.follow(FollowSymlinks::No).nonblock(true); + options +} + +fn validate_directory_handle(file: &File, display: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_dir() || is_windows_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "peer identity parent is not a non-reparse directory: {}", + display.display() + ), + )); + } + Ok(()) +} + +fn validate_regular_file_handle(file: &File, display: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_file() || is_windows_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "peer identity is not a non-reparse regular file: {}", + display.display() + ), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn is_windows_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +const fn is_windows_reparse(_metadata: &fs::Metadata) -> bool { + false +} + +#[cfg(unix)] +fn same_identity_file(left: &File, right: &File) -> eyre::Result { + use std::os::unix::fs::MetadataExt as _; + + let left = left + .metadata() + .wrap_err("failed to identify retained peer identity")?; + let right = right + .metadata() + .wrap_err("failed to identify re-opened peer identity")?; + Ok(left.dev() == right.dev() && left.ino() == right.ino()) +} + +#[cfg(windows)] +fn same_identity_file(left: &File, right: &File) -> eyre::Result { + use std::os::windows::fs::MetadataExt as _; + + let left = left + .metadata() + .wrap_err("failed to identify retained peer identity")?; + let right = right + .metadata() + .wrap_err("failed to identify re-opened peer identity")?; + let left_volume = left + .volume_serial_number() + .ok_or_else(|| eyre::eyre!("retained peer identity has no volume serial number"))?; + let right_volume = right + .volume_serial_number() + .ok_or_else(|| eyre::eyre!("re-opened peer identity has no volume serial number"))?; + let left_index = left + .file_index() + .ok_or_else(|| eyre::eyre!("retained peer identity has no file index"))?; + let right_index = right + .file_index() + .ok_or_else(|| eyre::eyre!("re-opened peer identity has no file index"))?; + Ok(left_volume == right_volume && left_index == right_index) +} + +#[cfg(not(any(unix, windows)))] +fn same_identity_file(_left: &File, _right: &File) -> eyre::Result { + bail!("platform cannot prove peer identity file continuity") +} + +fn sidecar_path(path: &Path, label: &str) -> PathBuf { + let sequence = NEXT_IDENTITY_SIDECAR.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("peer-identity-v1.json"); + path.with_file_name(format!( + "{file_name}.{label}-{}-{sequence}", + std::process::id() + )) +} + +#[cfg(unix)] +fn tighten_private_permissions(file: &File) -> eyre::Result<()> { + let metadata = file + .metadata() + .wrap_err("failed to inspect peer identity permissions")?; + if metadata.permissions().mode() & 0o777 != 0o600 { + file.set_permissions(fs::Permissions::from_mode(0o600)) + .wrap_err("failed to restrict peer identity permissions")?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn tighten_private_permissions(_file: &File) -> eyre::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &File) -> eyre::Result<()> { + parent + .sync_all() + .wrap_err("failed to sync peer identity parent directory") +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &File) -> eyre::Result<()> { + Ok(()) +} + +pub(crate) fn peer_id_from_spki(spki_der: &[u8]) -> eyre::Result { + ensure!( + spki_der.len() == ED25519_SPKI_PREFIX.len() + ED25519_PUBLIC_KEY_BYTES + && spki_der.starts_with(ED25519_SPKI_PREFIX), + "certificate does not contain canonical Ed25519 SPKI" + ); + Ok(PeerId::from_bytes(*blake3::hash(spki_der).as_bytes())) +} + +pub(crate) fn server_name_for_peer( + peer_id: PeerId, +) -> Result { + let server_name = format!("{peer_id}{PEER_SNI_SUFFIX}"); + let _ = ServerName::try_from(server_name.clone())?; + Ok(server_name) +} + +#[cfg(test)] +mod tests { + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + + use base64::prelude::{BASE64_STANDARD_NO_PAD, Engine as _}; + use eyre::ensure; + use rcgen::{CertificateParams, KeyPair, PKCS_ED25519}; + use rustls::pki_types::PrivatePkcs8KeyDer; + + use super::{ + IdentityRecord, + MAX_IDENTITY_RECORD_BYTES, + PeerIdentity, + PeerIdentityPersistence, + PeerIdentityPersistenceFailureKind, + decode_identity, + encode_identity, + load_or_generate_peer_identity, + load_or_generate_peer_identity_with_write_failure, + load_peer_identity, + persist_identity, + quarantine_corrupt_identity, + read_record_bytes, + replace_corrupt_identity, + }; + use crate::{state_paths::peer_identity_path, test_support::TempDir}; + + #[test] + fn generated_identity_is_persisted_and_reused() -> eyre::Result<()> { + let state = TempDir::new("lanspread-peer-identity-reuse"); + let first = load_or_generate_peer_identity(state.path())?; + ensure!(first.persistence == PeerIdentityPersistence::Created); + let first_peer_id = first.identity.peer_id(); + let first_certificate = first.identity.certificate().as_ref().to_vec(); + + let second = load_or_generate_peer_identity(state.path())?; + ensure!(second.persistence == PeerIdentityPersistence::Loaded); + ensure!(second.identity.peer_id() == first_peer_id); + ensure!(second.identity.certificate().as_ref() == first_certificate); + Ok(()) + } + + #[test] + fn atomic_publication_never_clobbers_an_existing_identity() -> eyre::Result<()> { + let state = TempDir::new("lanspread-peer-identity-no-clobber"); + let path = peer_identity_path(state.path()); + let winner = PeerIdentity::generate()?; + let contender = PeerIdentity::generate()?; + persist_identity(&path, &winner)?; + let winner_bytes = fs::read(&path)?; + + ensure!(persist_identity(&path, &contender).is_err()); + ensure!(fs::read(&path)? == winner_bytes); + ensure!(load_peer_identity(&path)?.peer_id() == winner.peer_id()); + Ok(()) + } + + #[test] + fn corrupt_identity_is_quarantined_byte_for_byte_and_replaced() -> eyre::Result<()> { + let state = TempDir::new("lanspread-peer-identity-corrupt"); + let path = peer_identity_path(state.path()); + let corrupt = b"not an identity\n"; + fs::write(&path, corrupt)?; + + let loaded = load_or_generate_peer_identity(state.path())?; + let PeerIdentityPersistence::ReplacedCorrupt { quarantine_path } = loaded.persistence + else { + return Err(eyre::eyre!("corrupt identity was not quarantined")); + }; + ensure!(fs::read(&quarantine_path)? == corrupt); + ensure!(load_peer_identity(&path)?.peer_id() == loaded.identity.peer_id()); + Ok(()) + } + + #[test] + fn mismatched_certificate_and_key_are_replaced() -> eyre::Result<()> { + let state = TempDir::new("lanspread-peer-identity-mismatch"); + let path = peer_identity_path(state.path()); + let identity_a = PeerIdentity::generate()?; + let identity_b = PeerIdentity::generate()?; + let mut record: IdentityRecord = serde_json::from_slice(&encode_identity(&identity_a)?)?; + let other: IdentityRecord = serde_json::from_slice(&encode_identity(&identity_b)?)?; + record.private_key_pkcs8_der = other.private_key_pkcs8_der; + let original = serde_json::to_vec(&record)?; + fs::write(&path, &original)?; + + let loaded = load_or_generate_peer_identity(state.path())?; + let PeerIdentityPersistence::ReplacedCorrupt { quarantine_path } = loaded.persistence + else { + return Err(eyre::eyre!("mismatched identity was not quarantined")); + }; + ensure!(fs::read(&quarantine_path)? == original); + ensure!(loaded.identity.peer_id() != identity_a.peer_id()); + ensure!(loaded.identity.peer_id() != identity_b.peer_id()); + Ok(()) + } + + #[test] + fn explicit_identity_load_is_fail_closed_and_non_mutating() -> eyre::Result<()> { + let state = TempDir::new("lanspread-peer-identity-explicit"); + let path = peer_identity_path(state.path()); + let invalid = br#"{"version":2,"algorithm":"ed25519","certificate_der":"","private_key_pkcs8_der":""}"#; + fs::write(&path, invalid)?; + + ensure!(load_peer_identity(&path).is_err()); + ensure!(fs::read(&path)? == invalid); + ensure!( + fs::read_dir(state.path())?.count() == 1, + "explicit loading must not create a quarantine or replacement" + ); + Ok(()) + } + + #[test] + fn injected_write_failure_returns_one_in_memory_identity() -> eyre::Result<()> { + let temp = TempDir::new("lanspread-peer-identity-ephemeral"); + + let loaded = load_or_generate_peer_identity_with_write_failure(temp.path())?; + let PeerIdentityPersistence::Ephemeral(failure) = loaded.persistence else { + return Err(eyre::eyre!( + "persistence failure did not use an ephemeral identity" + )); + }; + ensure!(failure.kind == PeerIdentityPersistenceFailureKind::Write); + ensure!(failure.path == peer_identity_path(temp.path())); + ensure!( + failure + .message + .contains("injected peer identity persist write failure") + ); + ensure!(!peer_identity_path(temp.path()).exists()); + ensure!(!loaded.identity.certificate().as_ref().is_empty()); + Ok(()) + } + + #[test] + fn debug_output_redacts_private_key() -> eyre::Result<()> { + let identity = PeerIdentity::generate()?; + let encoded = encode_identity(&identity)?; + let record: IdentityRecord = serde_json::from_slice(&encoded)?; + let debug = format!("{identity:?}"); + ensure!(debug.contains("")); + ensure!(!debug.contains(&record.private_key_pkcs8_der)); + Ok(()) + } + + #[test] + fn record_decoder_rejects_unknown_fields_and_noncanonical_base64() -> eyre::Result<()> { + let identity = PeerIdentity::generate()?; + let encoded = encode_identity(&identity)?; + let mut record: serde_json::Value = serde_json::from_slice(&encoded)?; + record["extra"] = serde_json::json!(true); + ensure!(decode_identity(&serde_json::to_vec(&record)?).is_err()); + + let mut record: serde_json::Value = serde_json::from_slice(&encoded)?; + let certificate = record["certificate_der"] + .as_str() + .ok_or_else(|| eyre::eyre!("certificate field was not a string"))?; + record["certificate_der"] = serde_json::json!(format!("{certificate}=")); + ensure!(decode_identity(&serde_json::to_vec(&record)?).is_err()); + Ok(()) + } + + #[test] + fn record_decoder_rejects_unknown_version_and_algorithm() -> eyre::Result<()> { + let identity = PeerIdentity::generate()?; + let encoded = encode_identity(&identity)?; + + let mut record: serde_json::Value = serde_json::from_slice(&encoded)?; + record["version"] = serde_json::json!(2); + ensure!(decode_identity(&serde_json::to_vec(&record)?).is_err()); + + let mut record: serde_json::Value = serde_json::from_slice(&encoded)?; + record["algorithm"] = serde_json::json!("ecdsa-p256"); + ensure!(decode_identity(&serde_json::to_vec(&record)?).is_err()); + Ok(()) + } + + #[test] + fn identity_certificate_san_must_match_its_spki_peer_id() -> eyre::Result<()> { + let key_pair = KeyPair::generate_for(&PKCS_ED25519)?; + let params = CertificateParams::new(vec!["wrong.peer.lanspread.invalid".to_owned()])?; + let certificate = params.self_signed(&key_pair)?.der().clone(); + let private_key = PrivatePkcs8KeyDer::from(key_pair.serialize_der()); + + let validation = PeerIdentity::from_der(certificate, private_key); + ensure!(validation.is_err()); + Ok(()) + } + + #[test] + fn oversized_record_is_quarantined_without_truncation() -> eyre::Result<()> { + let state = TempDir::new("lanspread-peer-identity-oversized"); + let path = peer_identity_path(state.path()); + let oversized = vec![b'x'; MAX_IDENTITY_RECORD_BYTES + 1]; + fs::write(&path, &oversized)?; + + let loaded = load_or_generate_peer_identity(state.path())?; + let PeerIdentityPersistence::ReplacedCorrupt { quarantine_path } = loaded.persistence + else { + return Err(eyre::eyre!("oversized identity was not quarantined")); + }; + ensure!(fs::read(&quarantine_path)? == oversized); + Ok(()) + } + + #[test] + fn encoded_record_does_not_appear_in_debug_output() -> eyre::Result<()> { + let identity = PeerIdentity::generate()?; + let encoded = encode_identity(&identity)?; + let record: IdentityRecord = serde_json::from_slice(&encoded)?; + let secret = BASE64_STANDARD_NO_PAD.decode(record.private_key_pkcs8_der)?; + let debug = format!("{identity:?}"); + ensure!( + !debug + .as_bytes() + .windows(secret.len()) + .any(|window| window == secret) + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn persisted_identity_is_mode_0600() -> eyre::Result<()> { + let state = TempDir::new("lanspread-peer-identity-mode"); + load_or_generate_peer_identity(state.path())?; + let mode = fs::metadata(peer_identity_path(state.path()))? + .permissions() + .mode() + & 0o777; + ensure!(mode == 0o600, "identity mode was {mode:o}"); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn identity_symlink_is_not_followed_or_replaced() -> eyre::Result<()> { + use std::os::unix::fs::symlink; + + let state = TempDir::new("lanspread-peer-identity-symlink"); + let external = TempDir::new("lanspread-peer-identity-symlink-target"); + let external_path = external.path().join("external-identity"); + let external_bytes = b"external identity must remain untouched\n"; + fs::write(&external_path, external_bytes)?; + fs::set_permissions(&external_path, fs::Permissions::from_mode(0o640))?; + let external_mode = fs::metadata(&external_path)?.permissions().mode() & 0o777; + + let identity_path = peer_identity_path(state.path()); + symlink(&external_path, &identity_path)?; + + let loaded = load_or_generate_peer_identity(state.path())?; + let PeerIdentityPersistence::Ephemeral(failure) = loaded.persistence else { + return Err(eyre::eyre!("identity symlink was followed or replaced")); + }; + ensure!(failure.kind == PeerIdentityPersistenceFailureKind::Read); + ensure!( + fs::symlink_metadata(&identity_path)? + .file_type() + .is_symlink() + ); + ensure!(fs::read(&external_path)? == external_bytes); + ensure!( + fs::metadata(&external_path)?.permissions().mode() & 0o777 == external_mode, + "external identity permissions changed" + ); + ensure!( + fs::read_dir(state.path())?.count() == 1, + "symlink rejection created a quarantine or replacement" + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn identity_parent_symlink_is_not_followed() -> eyre::Result<()> { + use std::os::unix::fs::symlink; + + let alias_parent = TempDir::new("lanspread-peer-identity-parent-symlink"); + let external = TempDir::new("lanspread-peer-identity-parent-target"); + let external_identity = peer_identity_path(external.path()); + let external_bytes = b"external parent identity must remain untouched\n"; + fs::write(&external_identity, external_bytes)?; + fs::set_permissions(&external_identity, fs::Permissions::from_mode(0o640))?; + let external_mode = fs::metadata(&external_identity)?.permissions().mode() & 0o777; + let alias = alias_parent.path().join("state-link"); + symlink(external.path(), &alias)?; + + let loaded = load_or_generate_peer_identity(&alias)?; + let PeerIdentityPersistence::Ephemeral(failure) = loaded.persistence else { + return Err(eyre::eyre!("identity parent symlink was followed")); + }; + ensure!(failure.kind == PeerIdentityPersistenceFailureKind::Read); + ensure!(fs::read(&external_identity)? == external_bytes); + ensure!( + fs::metadata(&external_identity)?.permissions().mode() & 0o777 == external_mode, + "external parent identity permissions changed" + ); + ensure!( + fs::read_dir(external.path())?.count() == 1, + "parent symlink rejection created a quarantine or replacement" + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn quarantine_rejects_a_swapped_external_file() -> eyre::Result<()> { + let state = TempDir::new("lanspread-peer-identity-swap"); + let external = TempDir::new("lanspread-peer-identity-swap-target"); + let identity_path = peer_identity_path(state.path()); + fs::write(&identity_path, b"retained corrupt identity\n")?; + let opened = read_record_bytes(&identity_path)? + .ok_or_else(|| eyre::eyre!("test corrupt identity disappeared"))?; + + let retained_path = state.path().join("retained-corrupt-identity"); + fs::rename(&identity_path, &retained_path)?; + let external_path = external.path().join("external-file"); + let external_bytes = b"external file must not be quarantined\n"; + fs::write(&external_path, external_bytes)?; + fs::set_permissions(&external_path, fs::Permissions::from_mode(0o640))?; + let external_mode = fs::metadata(&external_path)?.permissions().mode() & 0o777; + fs::hard_link(&external_path, &identity_path)?; + + let loaded = replace_corrupt_identity(&identity_path, &opened)?; + let PeerIdentityPersistence::Ephemeral(failure) = loaded.persistence else { + return Err(eyre::eyre!("swapped identity was quarantined or replaced")); + }; + ensure!(failure.kind == PeerIdentityPersistenceFailureKind::Quarantine); + ensure!(fs::read(&external_path)? == external_bytes); + ensure!(fs::read(&identity_path)? == external_bytes); + ensure!( + fs::metadata(&external_path)?.permissions().mode() & 0o777 == external_mode, + "swapped external file permissions changed" + ); + ensure!( + fs::read_dir(state.path())?.all(|entry| { + entry.is_ok_and(|entry| !entry.file_name().to_string_lossy().contains(".corrupt-")) + }), + "swapped external file acquired a quarantine link" + ); + + // The direct helper remains separately covered so future refactors cannot + // accidentally bypass the retained-handle guard in the replacement path. + ensure!(quarantine_corrupt_identity(&opened).is_err()); + Ok(()) + } } diff --git a/crates/lanspread-peer/src/install/intent.rs b/crates/lanspread-peer/src/install/intent.rs index cfabfc7..229f748 100644 --- a/crates/lanspread-peer/src/install/intent.rs +++ b/crates/lanspread-peer/src/install/intent.rs @@ -1,17 +1,33 @@ use std::{ + collections::{HashMap, HashSet}, + io::{ErrorKind, Read as _, Write as _}, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; +use cap_fs_ext::{ + FollowSymlinks, + OpenOptionsFollowExt as _, + OpenOptionsMaybeDirExt as _, + OpenOptionsSyncExt as _, +}; +use cap_primitives::{ + ambient_authority, + fs::{self as cap_fs, OpenOptions as CapOpenOptions}, +}; +use eyre::WrapErr as _; use serde::{Deserialize, Serialize}; -use tokio::io::AsyncWriteExt; +use super::mutation_root::validate_game_id; use crate::game_paths::{ INSTALL_INTENT_FILE as INTENT_FILE, INSTALL_INTENT_TMP_FILE as INTENT_TMP_FILE, + portable_name_key, }; -const INTENT_SCHEMA_VERSION: u32 = 1; +const INTENT_SCHEMA_VERSION: u32 = 2; +const MAX_INSTALL_INTENT_BYTES: u64 = 64 * 1024; +const MAX_INSTALL_INTENT_STATE_DIRS: usize = 100_000; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum InstallIntentState { @@ -22,32 +38,45 @@ pub enum InstallIntentState { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] pub struct InstallIntent { pub schema_version: u32, pub id: String, + pub games_folder_key: String, pub recorded_at: u64, pub state: InstallIntentState, pub eti_version: Option, } impl InstallIntent { - pub fn new(id: &str, state: InstallIntentState, eti_version: Option) -> Self { - Self { + pub fn new( + game_root: &Path, + id: &str, + state: InstallIntentState, + eti_version: Option, + ) -> eyre::Result { + Ok(Self { schema_version: INTENT_SCHEMA_VERSION, id: id.to_string(), + games_folder_key: expected_games_folder_key(game_root)?, recorded_at: now_unix_secs(), state, eti_version, - } + }) } - pub fn none(id: &str, eti_version: Option) -> Self { - Self::new(id, InstallIntentState::None, eti_version) + #[cfg(test)] + pub fn none(game_root: &Path, id: &str, eti_version: Option) -> eyre::Result { + Self::new(game_root, id, InstallIntentState::None, eti_version) } +} - pub fn is_current_for(&self, id: &str) -> bool { - self.schema_version == INTENT_SCHEMA_VERSION && self.id == id - } +#[derive(Debug)] +pub enum LoadedInstallIntent { + Missing, + Valid(InstallIntent), + ForeignRoot, + Invalid(String), } pub fn intent_path(state_dir: &Path, id: &str) -> PathBuf { @@ -58,51 +87,228 @@ pub fn intent_tmp_path(state_dir: &Path, id: &str) -> PathBuf { crate::state_paths::game_state_dir(state_dir, id).join(INTENT_TMP_FILE) } -pub async fn read_intent(state_dir: &Path, id: &str) -> InstallIntent { +pub fn read_intent(state_dir: &Path, game_root: &Path, id: &str) -> LoadedInstallIntent { let path = intent_path(state_dir, id); - let data = match tokio::fs::read_to_string(&path).await { + let data = match crate::scoped_blocking::scoped_blocking(|| std::fs::read_to_string(&path)) { Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return LoadedInstallIntent::Missing; + } Err(err) => { - if err.kind() != std::io::ErrorKind::NotFound { - log::warn!("Failed to read install intent {}: {err}", path.display()); - } - return InstallIntent::none(id, None); + return LoadedInstallIntent::Invalid(format!( + "failed to read install intent {}: {err}", + path.display() + )); } }; match serde_json::from_str::(&data) { - Ok(intent) if intent.is_current_for(id) => intent, - Ok(intent) => { - log::warn!( - "Ignoring install intent {} with schema {} for id {}", + Ok(intent) if intent.schema_version != INTENT_SCHEMA_VERSION => { + LoadedInstallIntent::Invalid(format!( + "install intent {} uses unsupported schema {}", path.display(), - intent.schema_version, - intent.id - ); - InstallIntent::none(id, None) - } - Err(err) => { - log::warn!("Ignoring corrupt install intent {}: {err}", path.display()); - InstallIntent::none(id, None) + intent.schema_version + )) } + Ok(intent) if intent.id != id => LoadedInstallIntent::Invalid(format!( + "install intent {} belongs to game {}, expected {id}", + path.display(), + intent.id + )), + Ok(intent) => match expected_games_folder_key(game_root) { + Ok(expected_key) if intent.games_folder_key == expected_key => { + LoadedInstallIntent::Valid(intent) + } + Ok(_) => LoadedInstallIntent::ForeignRoot, + Err(err) => LoadedInstallIntent::Invalid(err.to_string()), + }, + Err(err) => LoadedInstallIntent::Invalid(format!( + "install intent {} is corrupt: {err}", + path.display() + )), } } -pub async fn write_intent(state_dir: &Path, id: &str, intent: &InstallIntent) -> eyre::Result<()> { +/// Validate every persisted install intent before changing configured roots. +/// +/// The returned IDs have active, current-schema intents bound to +/// `games_folder`. Any foreign, legacy, corrupt, unreadable, aliased, or unsafe +/// entry fails the whole read-only scan so no root transition can overwrite or +/// strand recovery state. +pub(crate) fn scan_active_install_intents( + state_dir: &Path, + games_folder: &Path, +) -> eyre::Result> { + let expected_games_folder_key = canonical_games_folder_key(games_folder)?; + let state_games_dir = crate::state_paths::games_state_dir(state_dir); + crate::scoped_blocking::scoped_blocking(|| { + scan_active_install_intents_blocking(&state_games_dir, &expected_games_folder_key) + }) +} + +fn scan_active_install_intents_blocking( + state_games_dir: &Path, + expected_games_folder_key: &str, +) -> eyre::Result> { + let state_games = match open_ambient_directory_nofollow(state_games_dir) { + Ok(directory) => directory, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(HashSet::new()); + } + Err(error) => return Err(error.into()), + }; + + let mut active_ids = HashSet::new(); + let mut portable_ids = HashMap::new(); + let mut errors = Vec::new(); + let mut entry_count = 0_usize; + for entry in cap_fs::read_base_dir(&state_games)? { + entry_count += 1; + if entry_count > MAX_INSTALL_INTENT_STATE_DIRS { + errors.push(format!( + "install intent state contains more than {MAX_INSTALL_INTENT_STATE_DIRS} game directories" + )); + break; + } + + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + errors.push(format!("failed to enumerate install intent state: {error}")); + continue; + } + }; + let name = entry.file_name(); + let display_path = state_games_dir.join(&name); + let game_state = match open_directory_at(&state_games, Path::new(&name)) { + Ok(directory) => directory, + Err(error) => { + errors.push(format!( + "unsafe install intent state directory {}: {error}", + display_path.display() + )); + continue; + } + }; + let intent = match open_regular_file_at(&game_state, Path::new(INTENT_FILE)) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => continue, + Err(error) => { + errors.push(format!( + "unsafe install intent {}: {error}", + display_path.join(INTENT_FILE).display() + )); + continue; + } + }; + let Some(id) = name.to_str() else { + errors.push(format!( + "install intent state directory is not valid UTF-8: {}", + display_path.display() + )); + continue; + }; + if let Err(error) = validate_game_id(id) { + errors.push(format!( + "invalid game ID for install intent {}: {error}", + display_path.join(INTENT_FILE).display() + )); + continue; + } + let portable_id = portable_name_key(id); + if let Some(previous) = portable_ids.insert(portable_id, id.to_owned()) { + errors.push(format!( + "install intent IDs {previous:?} and {id:?} are portable aliases" + )); + } + match load_scanned_intent(intent, id, expected_games_folder_key) { + Ok(active_id) => { + active_ids.insert(active_id); + } + Err(error) => errors.push(format!( + "invalid install intent {}: {error}", + display_path.join(INTENT_FILE).display() + )), + } + } + + if errors.is_empty() { + Ok(active_ids) + } else { + eyre::bail!("install intent scan failed: {}", errors.join("; ")) + } +} + +fn load_scanned_intent( + file: std::fs::File, + expected_id: &str, + expected_games_folder_key: &str, +) -> eyre::Result { + let metadata = file.metadata()?; + if !metadata.is_file() || is_windows_reparse(&metadata) { + eyre::bail!("intent is not a regular non-reparse file"); + } + if metadata.len() > MAX_INSTALL_INTENT_BYTES { + eyre::bail!("intent exceeds {MAX_INSTALL_INTENT_BYTES} bytes"); + } + + let mut bytes = Vec::with_capacity(usize::try_from(metadata.len())?); + let mut limited = file.take(MAX_INSTALL_INTENT_BYTES + 1); + limited.read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_INSTALL_INTENT_BYTES { + eyre::bail!("intent exceeds {MAX_INSTALL_INTENT_BYTES} bytes"); + } + let intent: InstallIntent = serde_json::from_slice(&bytes)?; + if intent.schema_version != INTENT_SCHEMA_VERSION { + eyre::bail!("unsupported schema {}", intent.schema_version); + } + if intent.id != expected_id { + eyre::bail!( + "intent belongs to game {}, expected {expected_id}", + intent.id + ); + } + if intent.games_folder_key != expected_games_folder_key { + eyre::bail!("intent belongs to a different configured games directory"); + } + if intent.state == InstallIntentState::None { + eyre::bail!("settled install intent must be absent, not state None"); + } + Ok(intent.id) +} + +/// Remove a settled intent durably. Missing is the only steady state. +pub fn clear_intent(state_dir: &Path, id: &str) -> eyre::Result<()> { + let path = intent_path(state_dir, id); + let tmp_path = intent_tmp_path(state_dir, id); + crate::scoped_blocking::scoped_blocking(|| { + let tmp_removed = remove_file_if_exists(&tmp_path)?; + let intent_removed = remove_file_if_exists(&path)?; + if tmp_removed || intent_removed { + sync_parent_dir(&path)?; + } + Ok(()) + }) +} + +pub fn write_intent(state_dir: &Path, id: &str, intent: &InstallIntent) -> eyre::Result<()> { let game_state_dir = crate::state_paths::game_state_dir(state_dir, id); - tokio::fs::create_dir_all(&game_state_dir).await?; let path = intent_path(state_dir, id); let tmp_path = intent_tmp_path(state_dir, id); let data = serde_json::to_vec_pretty(intent)?; - let mut file = tokio::fs::File::create(&tmp_path).await?; - file.write_all(&data).await?; - file.sync_all().await?; - drop(file); + crate::scoped_blocking::scoped_blocking(|| { + std::fs::create_dir_all(&game_state_dir)?; - tokio::fs::rename(&tmp_path, &path).await?; - sync_parent_dir(&path)?; - Ok(()) + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(&data)?; + file.sync_all()?; + drop(file); + + std::fs::rename(&tmp_path, &path)?; + sync_parent_dir(&path)?; + Ok(()) + }) } fn now_unix_secs() -> u64 { @@ -112,6 +318,119 @@ fn now_unix_secs() -> u64 { .as_secs() } +fn expected_games_folder_key(game_root: &Path) -> eyre::Result { + let games_folder = game_root.parent().ok_or_else(|| { + eyre::eyre!( + "game root has no configured-games-directory parent: {}", + game_root.display() + ) + })?; + canonical_games_folder_key(games_folder) +} + +fn canonical_games_folder_key(games_folder: &Path) -> eyre::Result { + let canonical = crate::scoped_blocking::scoped_blocking(|| { + let canonical = std::fs::canonicalize(games_folder).wrap_err_with(|| { + format!( + "failed to resolve configured games directory {} for install intent", + games_folder.display() + ) + })?; + open_ambient_directory_nofollow(&canonical).wrap_err_with(|| { + format!( + "configured games path is not a safe directory: {}", + canonical.display() + ) + })?; + Ok::<_, eyre::Report>(canonical) + })?; + Ok(crate::state_paths::games_folder_key(&canonical)) +} + +fn open_ambient_directory_nofollow(path: &Path) -> std::io::Result { + let directory = cap_fs::open_ambient(path, &directory_options(), ambient_authority())?; + validate_directory_handle(&directory, path)?; + Ok(directory) +} + +fn open_directory_at(parent: &std::fs::File, path: &Path) -> std::io::Result { + let directory = cap_fs::open(parent, path, &directory_options())?; + validate_directory_handle(&directory, path)?; + Ok(directory) +} + +fn open_regular_file_at(parent: &std::fs::File, path: &Path) -> std::io::Result { + let file = cap_fs::open(parent, path, ®ular_file_options())?; + validate_regular_file_handle(&file, path)?; + Ok(file) +} + +fn directory_options() -> CapOpenOptions { + let mut options = CapOpenOptions::new(); + options.read(true); + options + .maybe_dir(true) + .follow(FollowSymlinks::No) + .nonblock(true); + options +} + +fn regular_file_options() -> CapOpenOptions { + let mut options = CapOpenOptions::new(); + options.read(true); + options.follow(FollowSymlinks::No).nonblock(true); + options +} + +fn validate_directory_handle(file: &std::fs::File, display: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_dir() || is_windows_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "install intent state is not a non-reparse directory: {}", + display.display() + ), + )); + } + Ok(()) +} + +fn validate_regular_file_handle(file: &std::fs::File, display: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_file() || is_windows_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "install intent is not a regular non-reparse file: {}", + display.display() + ), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn is_windows_reparse(metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +const fn is_windows_reparse(_metadata: &std::fs::Metadata) -> bool { + false +} + +fn remove_file_if_exists(path: &Path) -> std::io::Result { + match std::fs::remove_file(path) { + Ok(()) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + #[cfg(unix)] fn sync_parent_dir(path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { @@ -130,109 +449,272 @@ mod tests { use super::*; use crate::test_support::TempDir; - async fn write_raw_intent(state_dir: &Path, id: &str, bytes: impl AsRef<[u8]>) { + fn write_raw_intent(state_dir: &Path, id: &str, bytes: impl AsRef<[u8]>) { let path = intent_path(state_dir, id); if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent) - .await - .expect("intent parent should be created"); + std::fs::create_dir_all(parent).expect("intent parent should be created"); } - tokio::fs::write(path, bytes) - .await - .expect("intent should be written"); + std::fs::write(path, bytes).expect("intent should be written"); } - #[tokio::test] - async fn tmp_write_without_rename_leaves_previous_intent_intact() { - let temp = TempDir::new("lanspread-intent"); + #[test] + fn tmp_write_without_rename_leaves_previous_intent_intact() { + let games = TempDir::new("lanspread-intent-games"); + let state = TempDir::new("lanspread-intent-state"); + let root = games.path().join("game"); let previous = InstallIntent::new( + &root, "game", InstallIntentState::Updating, Some("20240101".to_string()), - ); - write_intent(temp.path(), "game", &previous) - .await - .expect("previous intent should be written"); + ) + .expect("intent root should resolve"); + write_intent(state.path(), "game", &previous).expect("previous intent should be written"); - tokio::fs::write( - intent_tmp_path(temp.path(), "game"), - serde_json::to_vec(&InstallIntent::new( - "game", - InstallIntentState::Installing, - Some("20250101".to_string()), - )) + std::fs::write( + intent_tmp_path(state.path(), "game"), + serde_json::to_vec( + &InstallIntent::new( + &root, + "game", + InstallIntentState::Installing, + Some("20250101".to_string()), + ) + .expect("intent root should resolve"), + ) .expect("intent should serialize"), ) - .await .expect("tmp intent should be written"); - let recovered = read_intent(temp.path(), "game").await; + let LoadedInstallIntent::Valid(recovered) = read_intent(state.path(), &root, "game") else { + panic!("previous intent should remain valid"); + }; assert_eq!(recovered.state, InstallIntentState::Updating); assert_eq!(recovered.eti_version.as_deref(), Some("20240101")); } - #[tokio::test] - async fn schema_mismatch_is_treated_as_missing() { - let temp = TempDir::new("lanspread-intent"); + #[test] + fn schema_mismatch_is_invalid_not_missing() { + let games = TempDir::new("lanspread-intent-games"); + let state = TempDir::new("lanspread-intent-state"); + let root = games.path().join("game"); write_raw_intent( - temp.path(), + state.path(), "game", - r#"{"schema_version":2,"id":"game","recorded_at":0,"state":"Updating"}"#, - ) - .await; + r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":null}"#, + ); - let recovered = read_intent(temp.path(), "game").await; - assert_eq!(recovered.state, InstallIntentState::None); + assert!(matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Invalid(_) + )); } - #[tokio::test] - async fn mismatched_id_is_treated_as_missing() { - let temp = TempDir::new("lanspread-intent"); + #[test] + fn mismatched_id_is_invalid_not_missing() { + let games = TempDir::new("lanspread-intent-games"); + let state = TempDir::new("lanspread-intent-state"); + let root = games.path().join("game"); + let mut intent = InstallIntent::new(&root, "game", InstallIntentState::Updating, None) + .expect("intent root should resolve"); + intent.id = "other".to_owned(); write_raw_intent( - temp.path(), + state.path(), "game", - r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#, - ) - .await; + serde_json::to_vec(&intent).expect("intent should serialize"), + ); - let recovered = read_intent(temp.path(), "game").await; - assert_eq!(recovered.state, InstallIntentState::None); + assert!(matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Invalid(_) + )); } - #[tokio::test] - async fn corrupt_intent_is_treated_as_missing() { - let temp = TempDir::new("lanspread-intent"); - write_raw_intent(temp.path(), "game", b"not json").await; + #[test] + fn corrupt_intent_is_invalid_not_missing() { + let games = TempDir::new("lanspread-intent-games"); + let state = TempDir::new("lanspread-intent-state"); + let root = games.path().join("game"); + write_raw_intent(state.path(), "game", b"not json"); - let recovered = read_intent(temp.path(), "game").await; - assert_eq!(recovered.state, InstallIntentState::None); + assert!(matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Invalid(_) + )); } - #[tokio::test] - async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() { - let temp = TempDir::new("lanspread-intent"); - write_raw_intent( - temp.path(), - "game", - r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#, - ) - .await; + #[test] + fn foreign_root_is_distinct_from_missing() { + let first_games = TempDir::new("lanspread-intent-first-games"); + let second_games = TempDir::new("lanspread-intent-second-games"); + let state = TempDir::new("lanspread-intent-state"); + let first_root = first_games.path().join("game"); + let second_root = second_games.path().join("game"); + let intent = InstallIntent::new(&first_root, "game", InstallIntentState::Updating, None) + .expect("intent root should resolve"); + write_intent(state.path(), "game", &intent).expect("intent should be written"); - let recovered = read_intent(temp.path(), "game").await; - assert_eq!(recovered.state, InstallIntentState::Updating); - assert_eq!(recovered.eti_version.as_deref(), Some("20240101")); + assert!(matches!( + read_intent(state.path(), &second_root, "game"), + LoadedInstallIntent::ForeignRoot + )); + } - write_intent(temp.path(), "game", &InstallIntent::none("game", None)) - .await - .expect("intent should be written"); - let written = tokio::fs::read_to_string(intent_path(temp.path(), "game")) - .await - .expect("intent should be readable"); - assert!( - serde_json::from_str::(&written) - .expect("intent should parse") - .get("manifest_hash") - .is_none() + #[test] + fn missing_and_unreadable_are_distinct() { + let games = TempDir::new("lanspread-intent-games"); + let state = TempDir::new("lanspread-intent-state"); + let root = games.path().join("game"); + assert!(matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Missing + )); + + std::fs::create_dir_all(intent_path(state.path(), "game")) + .expect("directory should occupy intent path"); + assert!(matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Invalid(_) + )); + } + + #[test] + fn clear_removes_settled_intent_and_tmp_file() { + let games = TempDir::new("lanspread-intent-games"); + let state = TempDir::new("lanspread-intent-state"); + let root = games.path().join("game"); + let intent = InstallIntent::none(&root, "game", None).expect("intent root should resolve"); + write_intent(state.path(), "game", &intent).expect("intent should be written"); + std::fs::write(intent_tmp_path(state.path(), "game"), b"tmp") + .expect("tmp should be written"); + + clear_intent(state.path(), "game").expect("intent should clear"); + assert!(!intent_path(state.path(), "game").exists()); + assert!(!intent_tmp_path(state.path(), "game").exists()); + } + + #[test] + fn scan_returns_every_current_root_active_intent_and_accepts_no_namespace() { + let games = TempDir::new("lanspread-intent-scan-games"); + let empty_state = TempDir::new("lanspread-intent-scan-empty-state"); + assert_eq!( + scan_active_install_intents(empty_state.path(), games.path()) + .expect("missing intent namespace should be clean"), + HashSet::new() + ); + + let state = TempDir::new("lanspread-intent-scan-state"); + for (id, intent_state) in [ + ("installing", InstallIntentState::Installing), + ("updating", InstallIntentState::Updating), + ("uninstalling", InstallIntentState::Uninstalling), + ] { + let intent = InstallIntent::new(&games.path().join(id), id, intent_state, None) + .expect("intent root should resolve"); + write_intent(state.path(), id, &intent).expect("intent should be written"); + } + + assert_eq!( + scan_active_install_intents(state.path(), games.path()) + .expect("current active intents should scan"), + HashSet::from([ + "installing".to_owned(), + "updating".to_owned(), + "uninstalling".to_owned(), + ]) ); } + + #[test] + fn scan_rejects_foreign_root_even_when_game_is_absent_from_candidate() { + let old_games = TempDir::new("lanspread-intent-scan-old-games"); + let candidate_games = TempDir::new("lanspread-intent-scan-candidate-games"); + let state = TempDir::new("lanspread-intent-scan-state"); + let intent = InstallIntent::new( + &old_games.path().join("orphan"), + "orphan", + InstallIntentState::Uninstalling, + None, + ) + .expect("intent root should resolve"); + write_intent(state.path(), "orphan", &intent).expect("intent should be written"); + let path = intent_path(state.path(), "orphan"); + let before = std::fs::read(&path).expect("intent should be readable"); + + let error = scan_active_install_intents(state.path(), candidate_games.path()) + .expect_err("foreign active intent must quarantine a root switch"); + assert!(error.to_string().contains("different configured games")); + assert!(!candidate_games.path().join("orphan").exists()); + assert_eq!( + std::fs::read(path).expect("foreign intent must be preserved"), + before + ); + } + + #[test] + fn scan_aggregates_corrupt_and_legacy_intents_without_mutation() { + let games = TempDir::new("lanspread-intent-scan-games"); + let state = TempDir::new("lanspread-intent-scan-state"); + write_raw_intent(state.path(), "corrupt", b"not json"); + write_raw_intent( + state.path(), + "legacy", + br#"{"schema_version":1,"id":"legacy","recorded_at":0,"state":"Updating","eti_version":null}"#, + ); + let corrupt_path = intent_path(state.path(), "corrupt"); + let legacy_path = intent_path(state.path(), "legacy"); + let corrupt_before = std::fs::read(&corrupt_path).expect("corrupt intent should exist"); + let legacy_before = std::fs::read(&legacy_path).expect("legacy intent should exist"); + + let error = scan_active_install_intents(state.path(), games.path()) + .expect_err("corrupt and legacy intents must quarantine a root switch"); + let message = error.to_string(); + assert!(message.contains("corrupt"), "{error:?}"); + assert!(message.contains("legacy"), "{error:?}"); + assert_eq!( + std::fs::read(corrupt_path).expect("corrupt intent must be preserved"), + corrupt_before + ); + assert_eq!( + std::fs::read(legacy_path).expect("legacy intent must be preserved"), + legacy_before + ); + } + + #[test] + fn scan_rejects_persisted_none_as_stale_settled_state() { + let games = TempDir::new("lanspread-intent-scan-games"); + let state = TempDir::new("lanspread-intent-scan-state"); + let intent = InstallIntent::none(&games.path().join("game"), "game", None) + .expect("intent root should resolve"); + write_intent(state.path(), "game", &intent).expect("intent should be written"); + + let error = scan_active_install_intents(state.path(), games.path()) + .expect_err("settled intents must be absent on disk"); + assert!(error.to_string().contains("state None"), "{error:?}"); + assert!(intent_path(state.path(), "game").is_file()); + } + + #[cfg(target_os = "linux")] + #[test] + fn scan_rejects_portable_game_id_aliases() { + let games = TempDir::new("lanspread-intent-scan-games"); + let state = TempDir::new("lanspread-intent-scan-state"); + for id in ["Game", "game"] { + let intent = InstallIntent::new( + &games.path().join(id), + id, + InstallIntentState::Installing, + None, + ) + .expect("intent root should resolve"); + write_intent(state.path(), id, &intent).expect("intent should be written"); + } + + let error = scan_active_install_intents(state.path(), games.path()) + .expect_err("portable aliases must quarantine recovery"); + assert!(error.to_string().contains("portable aliases"), "{error:?}"); + assert!(intent_path(state.path(), "Game").is_file()); + assert!(intent_path(state.path(), "game").is_file()); + } } diff --git a/crates/lanspread-peer/src/install/mod.rs b/crates/lanspread-peer/src/install/mod.rs index 0e45d3e..6f63ce5 100644 --- a/crates/lanspread-peer/src/install/mod.rs +++ b/crates/lanspread-peer/src/install/mod.rs @@ -1,13 +1,18 @@ pub(crate) mod intent; +mod mutation_root; mod transaction; pub mod unpack; -pub(crate) use transaction::root_eti_archives; +pub(crate) use transaction::{ + StreamedInstallCommitError, + recover_game_root, + recover_on_startup, + root_eti_archives, +}; pub use transaction::{ StreamedInstallTransaction, begin_streamed_install, install, - recover_on_startup, uninstall, update, }; diff --git a/crates/lanspread-peer/src/install/mutation_root.rs b/crates/lanspread-peer/src/install/mutation_root.rs new file mode 100644 index 0000000..c782b72 --- /dev/null +++ b/crates/lanspread-peer/src/install/mutation_root.rs @@ -0,0 +1,880 @@ +//! Retained no-follow authority for install mutations below one game root. + +use std::{ + collections::BTreeMap, + ffi::OsStr, + fmt, + fs::File, + io::ErrorKind, + path::{Component, Path, PathBuf}, +}; + +use cap_fs_ext::{ + FollowSymlinks, + OpenOptionsFollowExt, + OpenOptionsMaybeDirExt, + OpenOptionsSyncExt, +}; +use cap_primitives::{ + ambient_authority, + fs::{self, DirOptions, OpenOptions}, +}; +use lanspread_db::content_manifest::{ + CatalogEntryKind, + CatalogExtractedEntry, + MAX_CATALOG_ENTRIES, +}; +use tokio_util::sync::CancellationToken; +use unicode_normalization::is_nfc; + +use crate::game_paths::{ + INSTALL_OWNED_MARKER, + INSTALLING_DIR, + LOCAL_DIR, + is_download_protected_root_name, +}; + +// Extracted catalogs contain at most 100,000 explicit entries. Permit generous +// implicit-parent expansion without allowing a manifest to allocate or walk an +// impractically large directory plan. +const MAX_STAGING_SYNC_ENTRIES: usize = 1_000_001; + +/// Open authority for exactly one direct game directory. +/// +/// The handles are deliberately retained even though the current installer +/// still passes the ambient display path to the unpacker. Keeping them alive +/// establishes that both the configured directory and its direct child were +/// opened without following their final path components for the transaction's +/// full lifetime. +#[derive(Debug)] +pub(super) struct MutationGameRoot { + _games_folder: File, + game_root: File, + display_path: PathBuf, + created: bool, +} + +#[derive(Debug)] +pub(super) enum StagingPromotionOutcome { + Durable, + /// The rename is visible, but its parent-directory flush failed. Callers + /// must run intent recovery (which retries that flush) before publishing. + RenamedNeedsRecovery(eyre::Report), +} + +#[derive(Debug)] +struct StagingPromotionCancelled { + game_root: PathBuf, +} + +impl fmt::Display for StagingPromotionCancelled { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "streamed install for {} was cancelled before promotion", + self.game_root.display() + ) + } +} + +impl std::error::Error for StagingPromotionCancelled {} + +pub(super) fn is_staging_promotion_cancelled(error: &eyre::Report) -> bool { + error.downcast_ref::().is_some() +} + +impl MutationGameRoot { + pub(super) fn open_or_create_target(game_root: &Path, game_id: &str) -> eyre::Result { + validate_exact_target(game_root, game_id)?; + let games_folder = game_root + .parent() + .expect("validated game roots have one direct parent") + .to_path_buf(); + let game_id = game_id.to_owned(); + crate::scoped_blocking::scoped_blocking(move || { + Self::open_blocking(&games_folder, &game_id, OpenMode::Create) + }) + } + + pub(super) fn open_existing(games_folder: &Path, game_id: &str) -> eyre::Result> { + validate_game_id(game_id)?; + let games_folder = games_folder.to_path_buf(); + let game_id = game_id.to_owned(); + match crate::scoped_blocking::scoped_blocking(move || { + Self::open_blocking(&games_folder, &game_id, OpenMode::Existing) + }) { + Ok(root) => Ok(Some(root)), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == ErrorKind::NotFound) => + { + Ok(None) + } + Err(error) => Err(error), + } + } + + pub(super) fn open_existing_target( + game_root: &Path, + game_id: &str, + ) -> eyre::Result> { + validate_exact_target(game_root, game_id)?; + let games_folder = game_root + .parent() + .expect("validated game roots have one direct parent"); + Self::open_existing(games_folder, game_id) + } + + fn open_blocking(games_folder: &Path, game_id: &str, mode: OpenMode) -> eyre::Result { + let games_dir = open_ambient_directory_nofollow(games_folder)?; + let game_component = Path::new(game_id); + let (game_root, created) = match fs::open(&games_dir, game_component, &directory_options()) + { + Ok(root) => (root, false), + Err(error) if mode == OpenMode::Create && error.kind() == ErrorKind::NotFound => { + let created = match fs::create_dir(&games_dir, game_component, &DirOptions::new()) { + Ok(()) => { + sync_directory_handle(&games_dir)?; + true + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => false, + Err(error) => return Err(error.into()), + }; + ( + fs::open(&games_dir, game_component, &directory_options())?, + created, + ) + } + Err(error) => return Err(error.into()), + }; + validate_directory_handle(&game_root, "game root")?; + + Ok(Self { + _games_folder: games_dir, + game_root, + display_path: games_folder.join(game_id), + created, + }) + } + + pub(super) fn display_path(&self) -> &Path { + &self.display_path + } + + pub(super) const fn created(&self) -> bool { + self.created + } + + /// Flush directory-entry changes below this retained no-follow root. + pub(super) fn sync_game_root(&self) -> eyre::Result<()> { + crate::scoped_blocking::scoped_blocking(|| { + sync_directory_handle(&self.game_root).map_err(Into::into) + }) + } + + /// Durably flush a verified Stream Install tree and atomically promote it. + /// + /// The exact catalog file set is reopened through retained, no-follow + /// directory handles after launch-settings mutation, so every final file + /// version is flushed. All explicit and implicit directories are then + /// flushed deepest-first before the staging rename. The entire boundary is + /// finite blocking work: task cancellation or drop cannot detach a sync or + /// interleave between the final cancellation check, rename, and parent sync. + pub(super) fn sync_and_promote_staging( + &self, + entries: &[CatalogExtractedEntry], + cancel_token: &CancellationToken, + ) -> eyre::Result { + let plan = StagingSyncPlan::from_catalog(entries)?; + crate::scoped_blocking::scoped_blocking(|| { + self.sync_and_promote_staging_blocking(&plan, cancel_token) + }) + } + + fn sync_and_promote_staging_blocking( + &self, + plan: &StagingSyncPlan, + cancel_token: &CancellationToken, + ) -> eyre::Result { + self.sync_and_promote_staging_with(plan, cancel_token, &RealStagingDurabilityOps) + } + + fn sync_and_promote_staging_with( + &self, + plan: &StagingSyncPlan, + cancel_token: &CancellationToken, + ops: &impl StagingDurabilityOps, + ) -> eyre::Result { + let staging = open_directory_component(&self.game_root, INSTALLING_DIR)?; + let mut seen = 0_usize; + sync_verified_directory_tree( + &staging, + "", + plan, + &mut seen, + cancel_token, + &self.display_path, + ops, + )?; + if seen != plan.entries.len() { + eyre::bail!( + "verified streamed install staging tree has {seen} entries; expected {}", + plan.entries.len() + ); + } + reject_cancelled(cancel_token, &self.display_path)?; + + ops.rename_staging(&self.game_root)?; + match ops.sync_directory(&self.game_root, "") { + Ok(()) => Ok(StagingPromotionOutcome::Durable), + Err(error) => Ok(StagingPromotionOutcome::RenamedNeedsRecovery(error.into())), + } + } +} + +trait StagingDurabilityOps { + fn sync_file(&self, file: &File, relative_path: &str) -> std::io::Result<()>; + fn sync_directory(&self, directory: &File, relative_path: &str) -> std::io::Result<()>; + fn rename_staging(&self, game_root: &File) -> std::io::Result<()>; +} + +struct RealStagingDurabilityOps; + +impl StagingDurabilityOps for RealStagingDurabilityOps { + fn sync_file(&self, file: &File, _relative_path: &str) -> std::io::Result<()> { + file.sync_all() + } + + fn sync_directory(&self, directory: &File, _relative_path: &str) -> std::io::Result<()> { + sync_directory_handle(directory) + } + + fn rename_staging(&self, game_root: &File) -> std::io::Result<()> { + fs::rename( + game_root, + Path::new(INSTALLING_DIR), + game_root, + Path::new(LOCAL_DIR), + ) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StagingEntryKind { + Directory, + File, +} + +#[derive(Debug, Eq, PartialEq)] +struct StagingSyncPlan { + /// Exact catalog entries, their implicit parents, and the transaction marker. + entries: BTreeMap, +} + +impl StagingSyncPlan { + fn from_catalog(entries: &[CatalogExtractedEntry]) -> eyre::Result { + if entries.len() > MAX_CATALOG_ENTRIES { + eyre::bail!( + "streamed install sync plan exceeds the {MAX_CATALOG_ENTRIES}-entry catalog limit" + ); + } + + let mut expected = BTreeMap::new(); + for entry in entries { + let path = entry.canonical_path().as_str(); + let kind = match entry.kind() { + CatalogEntryKind::Directory => StagingEntryKind::Directory, + CatalogEntryKind::File => StagingEntryKind::File, + }; + insert_expected_staging_entry(&mut expected, path, kind)?; + for (separator, _) in path.match_indices('/') { + insert_expected_staging_entry( + &mut expected, + &path[..separator], + StagingEntryKind::Directory, + )?; + } + } + insert_expected_staging_entry(&mut expected, INSTALL_OWNED_MARKER, StagingEntryKind::File)?; + Ok(Self { entries: expected }) + } +} + +fn insert_expected_staging_entry( + entries: &mut BTreeMap, + path: &str, + kind: StagingEntryKind, +) -> eyre::Result<()> { + if !entries.contains_key(path) && entries.len() >= MAX_STAGING_SYNC_ENTRIES { + eyre::bail!( + "streamed install sync plan exceeds the {MAX_STAGING_SYNC_ENTRIES}-entry limit" + ); + } + match entries.insert(path.to_owned(), kind) { + Some(previous) if previous != kind => { + eyre::bail!("streamed install sync plan changes the shape of {path}"); + } + Some(_) | None => Ok(()), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OpenMode { + Existing, + Create, +} + +fn validate_exact_target(game_root: &Path, game_id: &str) -> eyre::Result<()> { + validate_game_id(game_id)?; + let Some(games_folder) = game_root.parent() else { + eyre::bail!( + "game root has no configured-games-directory parent: {}", + game_root.display() + ); + }; + if game_root.file_name() != Some(OsStr::new(game_id)) || games_folder.join(game_id) != game_root + { + eyre::bail!( + "game root is not the requested direct game-id child: {}", + game_root.display() + ); + } + Ok(()) +} + +pub(super) fn validate_game_id(game_id: &str) -> eyre::Result<()> { + if game_id.is_empty() || game_id.contains(['/', '\\', '\0']) { + eyre::bail!("game ID must be one non-empty path component: {game_id:?}"); + } + if !is_nfc(game_id) { + eyre::bail!("game ID must use Unicode NFC normalization: {game_id}"); + } + + let mut components = Path::new(game_id).components(); + if !matches!( + (components.next(), components.next()), + (Some(Component::Normal(component)), None) if component == OsStr::new(game_id) + ) { + eyre::bail!("game ID must be one normal path component: {game_id}"); + } + if game_id.ends_with([' ', '.']) { + eyre::bail!("game ID has a trailing dot or space: {game_id}"); + } + if game_id.len() > 255 { + eyre::bail!("game ID exceeds the 255-byte portable component limit"); + } + if game_id.chars().any(|character| { + character <= '\u{1f}' || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*') + }) { + eyre::bail!("game ID is not a portable path component: {game_id}"); + } + + let device_stem = game_id.split('.').next().unwrap_or_default().trim_end(); + if is_windows_device_name(device_stem) { + eyre::bail!("game ID uses a Windows device name: {game_id}"); + } + if looks_like_dos_short_name(game_id) { + eyre::bail!("game ID resembles a Windows short-name alias: {game_id}"); + } + if is_download_protected_root_name(game_id) { + eyre::bail!("game ID is reserved for application state: {game_id}"); + } + Ok(()) +} + +fn is_windows_device_name(stem: &str) -> bool { + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || upper + .strip_prefix("COM") + .or_else(|| upper.strip_prefix("LPT")) + .is_some_and(|number| { + (number.len() == 1 && number.as_bytes()[0].is_ascii_digit()) + || matches!(number, "¹" | "²" | "³") + }) +} + +fn looks_like_dos_short_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or_default(); + stem.rsplit_once('~').is_some_and(|(prefix, suffix)| { + !prefix.is_empty() + && !suffix.is_empty() + && suffix.len() <= 6 + && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn open_ambient_directory_nofollow(path: &Path) -> eyre::Result { + let directory = fs::open_ambient(path, &directory_options(), ambient_authority())?; + validate_directory_handle(&directory, &path.display().to_string())?; + Ok(directory) +} + +fn sync_verified_directory_tree( + directory: &File, + relative_dir: &str, + plan: &StagingSyncPlan, + seen: &mut usize, + cancel_token: &CancellationToken, + game_root: &Path, + ops: &impl StagingDurabilityOps, +) -> eyre::Result<()> { + for entry in fs::read_base_dir(directory)? { + reject_cancelled(cancel_token, game_root)?; + *seen = seen + .checked_add(1) + .ok_or_else(|| eyre::eyre!("streamed install staging entry count overflow"))?; + if *seen > plan.entries.len() { + eyre::bail!( + "verified streamed install staging tree contains more than the expected {} entries", + plan.entries.len() + ); + } + + let entry = entry?; + let name = entry.file_name(); + let name = name.to_str().ok_or_else(|| { + eyre::eyre!("verified streamed install staging tree contains a non-UTF-8 entry") + })?; + let relative_path = if relative_dir.is_empty() { + name.to_owned() + } else { + format!("{relative_dir}/{name}") + }; + let expected_kind = plan.entries.get(&relative_path).ok_or_else(|| { + eyre::eyre!( + "verified streamed install staging tree contains unmanifested entry {relative_path}" + ) + })?; + let component = Path::new(name); + + match expected_kind { + StagingEntryKind::Directory => { + let child = open_directory_at(directory, component, &relative_path)?; + sync_verified_directory_tree( + &child, + &relative_path, + plan, + seen, + cancel_token, + game_root, + ops, + )?; + } + StagingEntryKind::File => { + let file = open_regular_file_at(directory, component, &relative_path)?; + ops.sync_file(&file, &relative_path)?; + } + } + } + + reject_cancelled(cancel_token, game_root)?; + // On Unix this is the post-order durability barrier for the directory's + // children. The non-Unix helper below explicitly documents the portability + // gap where Rust has no durable directory-flush primitive. + ops.sync_directory(directory, relative_dir)?; + Ok(()) +} + +fn reject_cancelled(cancel_token: &CancellationToken, game_root: &Path) -> eyre::Result<()> { + if cancel_token.is_cancelled() { + return Err(eyre::Report::new(StagingPromotionCancelled { + game_root: game_root.to_path_buf(), + })); + } + Ok(()) +} + +fn open_directory_component(parent: &File, component: &str) -> eyre::Result { + open_directory_at(parent, Path::new(component), component) +} + +fn open_directory_at(parent: &File, path: &Path, display: &str) -> eyre::Result { + let directory = fs::open(parent, path, &directory_options())?; + validate_directory_handle(&directory, display)?; + Ok(directory) +} + +fn open_regular_file_at(parent: &File, path: &Path, display: &str) -> eyre::Result { + let file = fs::open(parent, path, ®ular_file_options())?; + validate_regular_file_handle(&file, display)?; + Ok(file) +} + +fn directory_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true); + options + .maybe_dir(true) + .follow(FollowSymlinks::No) + .nonblock(true); + options +} + +fn regular_file_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true).write(true); + options.follow(FollowSymlinks::No).nonblock(true); + options +} + +fn validate_directory_handle(file: &File, display: &str) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_dir() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!("install mutation target is not a directory: {display}"), + )); + } + reject_windows_reparse(&metadata, display) +} + +fn validate_regular_file_handle(file: &File, display: &str) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!("install mutation target is not a regular file: {display}"), + )); + } + reject_windows_reparse(&metadata, display) +} + +#[cfg(windows)] +fn reject_windows_reparse(metadata: &std::fs::Metadata, display: &str) -> std::io::Result<()> { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!("install mutation target is a Windows reparse point: {display}"), + )); + } + Ok(()) +} + +#[cfg(not(windows))] +#[allow(clippy::unnecessary_wraps)] +const fn reject_windows_reparse( + _metadata: &std::fs::Metadata, + _display: &str, +) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn sync_directory_handle(directory: &File) -> std::io::Result<()> { + directory.sync_all() +} + +#[cfg(not(unix))] +const fn sync_directory_handle(_directory: &File) -> std::io::Result<()> { + // Rust does not expose a portable durable directory flush on Windows. + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + + use super::*; + use crate::test_support::TempDir; + + #[derive(Default)] + struct RecordingDurabilityOps { + events: RefCell>, + fail_file: Option, + fail_directory: Option, + fail_rename: bool, + renamed: Cell, + } + + impl StagingDurabilityOps for RecordingDurabilityOps { + fn sync_file(&self, file: &File, relative_path: &str) -> std::io::Result<()> { + self.events + .borrow_mut() + .push(format!("file:{relative_path}")); + if self.fail_file.as_deref() == Some(relative_path) { + return Err(std::io::Error::other("injected file sync failure")); + } + file.sync_all() + } + + fn sync_directory(&self, directory: &File, relative_path: &str) -> std::io::Result<()> { + self.events + .borrow_mut() + .push(format!("dir:{relative_path}")); + if self.fail_directory.as_deref() == Some(relative_path) { + return Err(std::io::Error::other("injected directory sync failure")); + } + sync_directory_handle(directory) + } + + fn rename_staging(&self, game_root: &File) -> std::io::Result<()> { + self.renamed.set(true); + if self.fail_rename { + return Err(std::io::Error::other("injected rename failure")); + } + RealStagingDurabilityOps.rename_staging(game_root) + } + } + + fn staged_tree() -> (TempDir, MutationGameRoot, Vec) { + let games = TempDir::new("lanspread-staging-durability"); + let root = games.game_root(); + let capability = + MutationGameRoot::open_or_create_target(&root, "game").expect("game root should open"); + let staging = root.join(INSTALLING_DIR); + std::fs::create_dir_all(staging.join("nested/deep")) + .expect("nested staging should be created"); + std::fs::write(staging.join(INSTALL_OWNED_MARKER), []) + .expect("ownership marker should be written"); + std::fs::write(staging.join("nested/deep/payload.bin"), b"payload") + .expect("payload should be written"); + let entries = vec![ + CatalogExtractedEntry::file( + "nested/deep/payload.bin", + 7, + lanspread_db::content_manifest::Blake3Digest::hash(b"payload"), + ) + .expect("catalog entry should validate"), + ]; + (games, capability, entries) + } + + #[test] + fn exact_nested_staging_tree_syncs_files_and_directories_before_rename() { + let (_games, capability, entries) = staged_tree(); + let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); + let ops = RecordingDurabilityOps::default(); + + let outcome = capability + .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) + .expect("exact nested tree should promote"); + + assert!(matches!(outcome, StagingPromotionOutcome::Durable)); + assert!(ops.renamed.get()); + let events = ops.events.borrow(); + let payload = events + .iter() + .position(|event| event == "file:nested/deep/payload.bin") + .expect("payload should be synced"); + let marker = events + .iter() + .position(|event| event == "file:.lanspread_owned") + .expect("transaction marker should be synced"); + let deep = events + .iter() + .position(|event| event == "dir:nested/deep") + .expect("deep directory should be synced"); + let nested = events + .iter() + .position(|event| event == "dir:nested") + .expect("parent directory should be synced"); + let staging = events + .iter() + .position(|event| event == "dir:") + .expect("staging root should be synced"); + assert!(payload < deep && deep < nested && nested < staging); + assert!(marker < staging); + assert!(capability.display_path().join(LOCAL_DIR).is_dir()); + } + + #[test] + fn injected_pre_rename_sync_failures_never_rename_staging() { + for (fail_file, fail_directory) in [ + (Some("nested/deep/payload.bin".to_owned()), None), + (None, Some("nested/deep".to_owned())), + ] { + let (_games, capability, entries) = staged_tree(); + let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); + let ops = RecordingDurabilityOps { + fail_file, + fail_directory, + ..RecordingDurabilityOps::default() + }; + + let error = capability + .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) + .expect_err("injected sync failure should fail closed"); + + assert!(!is_staging_promotion_cancelled(&error)); + assert!(!ops.renamed.get()); + assert!(capability.display_path().join(INSTALLING_DIR).is_dir()); + assert!(!capability.display_path().join(LOCAL_DIR).exists()); + } + } + + #[test] + fn cancellation_and_unmanifested_entries_prevent_rename() { + let (_games, capability, entries) = staged_tree(); + let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); + let cancelled = CancellationToken::new(); + cancelled.cancel(); + let ops = RecordingDurabilityOps::default(); + + let error = capability + .sync_and_promote_staging_with(&plan, &cancelled, &ops) + .expect_err("pre-promote cancellation should fail closed"); + assert!(is_staging_promotion_cancelled(&error)); + assert!(!ops.renamed.get()); + assert!(!capability.display_path().join(LOCAL_DIR).exists()); + + std::fs::write( + capability + .display_path() + .join(INSTALLING_DIR) + .join("extra.bin"), + b"extra", + ) + .expect("extra staging file should be written"); + let ops = RecordingDurabilityOps::default(); + let _error = capability + .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) + .expect_err("unmanifested staging file should fail closed"); + assert!(!ops.renamed.get()); + assert!(!capability.display_path().join(LOCAL_DIR).exists()); + } + + #[test] + fn parent_sync_failure_is_phase_aware_and_can_be_retried() { + let (_games, capability, entries) = staged_tree(); + let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); + let ops = RecordingDurabilityOps { + fail_directory: Some("".to_owned()), + ..RecordingDurabilityOps::default() + }; + + let outcome = capability + .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) + .expect("post-rename sync failure should have a phase-aware outcome"); + + assert!(matches!( + outcome, + StagingPromotionOutcome::RenamedNeedsRecovery(_) + )); + assert!(ops.renamed.get()); + assert!(!capability.display_path().join(INSTALLING_DIR).exists()); + assert!(capability.display_path().join(LOCAL_DIR).is_dir()); + capability + .sync_game_root() + .expect("later recovery should retry the parent sync"); + } + + #[test] + fn rename_failure_leaves_the_exact_synced_tree_in_staging() { + let (_games, capability, entries) = staged_tree(); + let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); + let ops = RecordingDurabilityOps { + fail_rename: true, + ..RecordingDurabilityOps::default() + }; + + let _error = capability + .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) + .expect_err("rename failure should fail closed"); + + assert!(ops.renamed.get()); + assert!(capability.display_path().join(INSTALLING_DIR).is_dir()); + assert!(!capability.display_path().join(LOCAL_DIR).exists()); + } + + #[cfg(unix)] + #[test] + fn symlink_in_exact_staging_path_is_rejected_without_escape_or_rename() { + use std::os::unix::fs::symlink; + + let (games, capability, entries) = staged_tree(); + let outside = TempDir::new("lanspread-staging-durability-outside"); + let payload = games + .game_root() + .join(INSTALLING_DIR) + .join("nested/deep/payload.bin"); + std::fs::remove_file(&payload).expect("payload should be removed"); + std::fs::write(outside.path().join("canary"), b"outside") + .expect("outside canary should be written"); + symlink(outside.path().join("canary"), &payload) + .expect("staging symlink should be created"); + let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); + let ops = RecordingDurabilityOps::default(); + + let _error = capability + .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) + .expect_err("staging symlink should fail closed"); + + assert!(!ops.renamed.get()); + assert_eq!( + std::fs::read(outside.path().join("canary")).expect("canary should remain readable"), + b"outside" + ); + assert!(!capability.display_path().join(LOCAL_DIR).exists()); + } + + #[test] + fn creates_exact_direct_game_root() { + let games = TempDir::new("lanspread-mutation-root"); + let root = games.path().join("game"); + + let capability = MutationGameRoot::open_or_create_target(&root, "game") + .expect("direct game root should open"); + + assert!(capability.created()); + assert_eq!(capability.display_path(), root); + assert!(root.is_dir()); + } + + #[test] + fn rejects_non_component_ids_without_mutation() { + let games = TempDir::new("lanspread-mutation-root-invalid-id"); + + let error = MutationGameRoot::open_or_create_target( + &games.path().join("outside").join("game"), + "../game", + ) + .expect_err("traversal ID should fail"); + + assert!(error.to_string().contains("one non-empty path component")); + assert!( + std::fs::read_dir(games.path()) + .expect("games directory should remain readable") + .next() + .is_none() + ); + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_game_root() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-mutation-root-games"); + let outside = TempDir::new("lanspread-mutation-root-outside"); + symlink(outside.path(), games.path().join("game")) + .expect("game-root symlink should be created"); + + let error = MutationGameRoot::open_or_create_target(&games.path().join("game"), "game") + .expect_err("symlink game root should fail closed"); + assert!(!error.to_string().is_empty()); + } + + #[cfg(windows)] + #[test] + fn rejects_symlink_reparse_game_root_when_supported() { + use std::os::windows::fs::symlink_dir; + + let games = TempDir::new("lanspread-mutation-root-games"); + let outside = TempDir::new("lanspread-mutation-root-outside"); + if let Err(error) = symlink_dir(outside.path(), games.path().join("game")) { + if error.kind() == ErrorKind::PermissionDenied { + return; + } + panic!("game-root reparse fixture should be created: {error}"); + } + + let error = MutationGameRoot::open_or_create_target(&games.path().join("game"), "game") + .expect_err("Windows reparse game root should fail closed"); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/lanspread-peer/src/install/transaction.rs b/crates/lanspread-peer/src/install/transaction.rs index 11a2d02..8e64f65 100644 --- a/crates/lanspread-peer/src/install/transaction.rs +++ b/crates/lanspread-peer/src/install/transaction.rs @@ -1,19 +1,32 @@ use std::{ - collections::HashSet, + collections::{BTreeMap, BTreeSet, HashSet}, + ffi::OsString, + fs, io::ErrorKind, path::{Path, PathBuf}, sync::Arc, }; use eyre::WrapErr; +use lanspread_db::content_manifest::CatalogContentManifest; +use tokio_util::sync::CancellationToken; use super::{ - intent::{InstallIntent, InstallIntentState, read_intent, write_intent}, + intent::{ + InstallIntent, + InstallIntentState, + LoadedInstallIntent, + clear_intent, + read_intent, + scan_active_install_intents, + write_intent, + }, + mutation_root::{MutationGameRoot, StagingPromotionOutcome, is_staging_promotion_cancelled}, unpack::Unpacker, }; use crate::{ game_paths::{BACKUP_DIR, INSTALL_OWNED_MARKER, INSTALLING_DIR, LOCAL_DIR}, - local_games::version_ini_is_regular_file, + scoped_blocking::scoped_blocking, state_paths::launch_settings_applied_path, }; @@ -31,110 +44,165 @@ struct InstallFsState { } pub struct StreamedInstallTransaction { + root_capability: MutationGameRoot, game_root: PathBuf, state_dir: PathBuf, id: String, staging: PathBuf, - eti_version: Option, created_game_root: bool, } +#[derive(Debug)] +pub(crate) enum StreamedInstallCommitError { + Cancelled(eyre::Report), + OperationFailed(eyre::Report), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct StartupRecoveryReport { + failures: BTreeMap, +} + +impl StartupRecoveryReport { + #[must_use] + pub(crate) fn failed_ids(&self) -> HashSet { + self.failures.keys().cloned().collect() + } + + #[must_use] + pub(crate) fn failures(&self) -> &BTreeMap { + &self.failures + } + + pub(crate) fn summary_error(&self) -> Option { + if self.failures.is_empty() { + return None; + } + let failures = self + .failures + .iter() + .map(|(id, error)| format!("{id}: {error}")) + .collect::>() + .join("; "); + Some(eyre::eyre!( + "recovery failed for {} game root(s): {failures}", + self.failures.len() + )) + } +} + impl StreamedInstallTransaction { #[must_use] pub fn staging_dir(&self) -> &Path { &self.staging } - pub async fn commit(self) -> eyre::Result<()> { - let local = local_dir(&self.game_root); - if let Err(err) = tokio::fs::rename(&self.staging, &local) - .await - .wrap_err_with(|| format!("failed to promote streamed install for {}", self.id)) - { - if let Err(cleanup_err) = remove_dir_all_if_exists(&self.staging).await { - log::warn!( - "Failed to clean streamed install staging {}: {cleanup_err}", - self.staging.display() - ); - } - if let Err(cleanup_err) = - remove_created_empty_game_root(&self.game_root, self.created_game_root).await - { - log::warn!( - "Failed to clean streamed install game root {}: {cleanup_err}", - self.game_root.display() - ); - } - let _ = write_intent( - &self.state_dir, - &self.id, - &InstallIntent::none(&self.id, self.eti_version.clone()), - ) - .await; - return Err(err); + pub(crate) fn commit_classified( + self, + manifest: &CatalogContentManifest, + cancel_token: &CancellationToken, + ) -> Result<(), StreamedInstallCommitError> { + if manifest.game_id() != self.id || !manifest.supports_streamed_install() { + let err = eyre::eyre!( + "refusing to promote streamed install for {} with unsupported catalog manifest for {}", + self.id, + manifest.game_id() + ); + return Err(StreamedInstallCommitError::OperationFailed( + settle_operation_failure(&self.root_capability, &self.state_dir, &self.id, err), + )); } - if let Err(err) = reset_launch_settings_marker(&self.state_dir, &self.id).await { - log::error!( - "Streamed install for {} was promoted but launch-settings marker reset failed: {err}", - self.id - ); - } - if let Err(err) = write_intent( - &self.state_dir, - &self.id, - &InstallIntent::none(&self.id, self.eti_version.clone()), - ) - .await - { - log::error!( - "Streamed install for {} was promoted but intent cleanup failed: {err}", - self.id - ); + let promotion = self + .root_capability + .sync_and_promote_staging(manifest.streamed_install_files(), cancel_token); + match promotion { + Ok(StagingPromotionOutcome::Durable) => {} + Ok(StagingPromotionOutcome::RenamedNeedsRecovery(cause)) => { + log::warn!( + "Streamed install rename for {} needs recovery after its parent sync failed: {cause}", + self.id + ); + recover_current_install_state( + &self.root_capability, + &self.state_dir, + &self.id, + ) + .map_err(|recovery_error| { + StreamedInstallCommitError::OperationFailed(cause.wrap_err(format!( + "streamed install was renamed, but recovery could not make promotion durable: {recovery_error}" + ))) + })?; + } + Err(err) => { + let cancelled = is_staging_promotion_cancelled(&err); + let err = err.wrap_err(format!( + "failed to promote streamed install for {}", + self.id + )); + return Err(settle_streamed_commit_failure( + &self.root_capability, + &self.state_dir, + &self.id, + err, + cancelled, + )); + } } + reset_launch_settings_marker(&self.state_dir, &self.id) + .map_err(StreamedInstallCommitError::OperationFailed)?; + clear_intent(&self.state_dir, &self.id) + .map_err(StreamedInstallCommitError::OperationFailed)?; Ok(()) } - pub async fn rollback(self) -> eyre::Result<()> { - let cleanup_result = async { - remove_dir_all_if_exists(&self.staging).await?; - remove_created_empty_game_root(&self.game_root, self.created_game_root).await - } - .await; - let intent_result = write_intent( - &self.state_dir, - &self.id, - &InstallIntent::none(&self.id, self.eti_version.clone()), - ) - .await; - - cleanup_result?; - intent_result + pub fn rollback(self) -> eyre::Result<()> { + recover_current_install_state(&self.root_capability, &self.state_dir, &self.id)?; + remove_created_empty_game_root(&self.game_root, self.created_game_root) } } -pub async fn begin_streamed_install( +fn settle_streamed_commit_failure( + root_capability: &MutationGameRoot, + state_dir: &Path, + id: &str, + operation_error: eyre::Report, + cooperative_cancellation: bool, +) -> StreamedInstallCommitError { + match recover_current_install_state(root_capability, state_dir, id) { + Ok(()) if cooperative_cancellation => { + StreamedInstallCommitError::Cancelled(operation_error) + } + Ok(()) => StreamedInstallCommitError::OperationFailed(operation_error), + Err(recovery_error) => { + StreamedInstallCommitError::OperationFailed(operation_error.wrap_err(format!( + "install recovery also failed; active intent retained: {recovery_error}" + ))) + } + } +} + +pub fn begin_streamed_install( game_root: &Path, state_dir: &Path, id: &str, ) -> eyre::Result { - if path_is_dir(&local_dir(game_root)).await { + let root_capability = MutationGameRoot::open_or_create_target(game_root, id)?; + let game_root = root_capability.display_path().to_path_buf(); + if path_is_dir(&local_dir(&game_root)) { eyre::bail!("game {id} is already installed"); } - let created_game_root = !path_exists(game_root).await; - tokio::fs::create_dir_all(game_root).await?; - let eti_version = read_downloaded_version(game_root).await; + let created_game_root = root_capability.created(); + let eti_version = read_downloaded_version(&game_root); + preflight_install(&root_capability, &game_root, state_dir, id)?; if let Err(err) = write_intent( state_dir, id, - &InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()), - ) - .await - { - if let Err(cleanup_err) = remove_created_empty_game_root(game_root, created_game_root).await - { + &InstallIntent::new(&game_root, id, InstallIntentState::Installing, eti_version)?, + ) { + if let Err(cleanup_err) = remove_created_empty_game_root(&game_root, created_game_root) { log::warn!( "Failed to clean streamed install game root {}: {cleanup_err}", game_root.display() @@ -143,27 +211,25 @@ pub async fn begin_streamed_install( return Err(err); } - let staging = installing_dir(game_root); - if let Err(err) = prepare_owned_empty_dir(&staging).await { - let _ = write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await; - if let Err(cleanup_err) = remove_created_empty_game_root(game_root, created_game_root).await - { - log::warn!( - "Failed to clean streamed install game root {}: {cleanup_err}", - game_root.display() - ); - } - return Err(err); + let staging = installing_dir(&game_root); + if let Err(err) = prepare_owned_empty_dir(&staging) { + return Err(settle_operation_failure( + &root_capability, + state_dir, + id, + err, + )); } + root_capability.sync_game_root()?; - let staging = tokio::fs::canonicalize(&staging).await.unwrap_or(staging); + let staging = canonicalize_or_original(staging); Ok(StreamedInstallTransaction { - game_root: game_root.to_path_buf(), + root_capability, + game_root, state_dir: state_dir.to_path_buf(), id: id.to_string(), staging, - eti_version, created_game_root, }) } @@ -173,32 +239,31 @@ pub async fn install( state_dir: &Path, id: &str, unpacker: Arc, + cancel_token: CancellationToken, ) -> eyre::Result<()> { - let eti_version = read_downloaded_version(game_root).await; + let root_capability = MutationGameRoot::open_or_create_target(game_root, id)?; + let game_root = root_capability.display_path(); + let eti_version = read_downloaded_version(game_root); + preflight_install(&root_capability, game_root, state_dir, id)?; write_intent( state_dir, id, - &InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()), - ) - .await?; + &InstallIntent::new(game_root, id, InstallIntentState::Installing, eti_version)?, + )?; - let result = install_inner(game_root, id, unpacker).await; + let result = install_inner(&root_capability, game_root, id, unpacker, &cancel_token).await; match result { Ok(()) => { - reset_launch_settings_marker(state_dir, id).await?; - write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; + reset_launch_settings_marker(state_dir, id)?; + clear_intent(state_dir, id)?; Ok(()) } - Err(err) => { - if let Err(cleanup_err) = remove_dir_all_if_exists(&installing_dir(game_root)).await { - log::warn!( - "Failed to clean install staging {}: {cleanup_err}", - installing_dir(game_root).display() - ); - } - write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; - Err(err) - } + Err(err) => Err(settle_operation_failure( + &root_capability, + state_dir, + id, + err, + )), } } @@ -207,171 +272,242 @@ pub async fn update( state_dir: &Path, id: &str, unpacker: Arc, + cancel_token: CancellationToken, ) -> eyre::Result<()> { - let eti_version = read_downloaded_version(game_root).await; + let root_capability = MutationGameRoot::open_or_create_target(game_root, id)?; + let game_root = root_capability.display_path(); + let eti_version = read_downloaded_version(game_root); + preflight_update(&root_capability, game_root, state_dir, id)?; write_intent( state_dir, id, - &InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()), - ) - .await?; + &InstallIntent::new(game_root, id, InstallIntentState::Updating, eti_version)?, + )?; - let result = update_inner(game_root, id, unpacker).await; + let result = update_inner(&root_capability, game_root, id, unpacker, &cancel_token).await; match result { Ok(()) => { - reset_launch_settings_marker(state_dir, id).await?; - write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; - if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await { - log::warn!( - "Failed to clean install backup {}: {err}", - backup_dir(game_root).display() - ); - } + reset_launch_settings_marker(state_dir, id)?; + remove_owned_dir_if_exists(&backup_dir(game_root))?; + root_capability.sync_game_root()?; + clear_intent(state_dir, id)?; Ok(()) } - Err(err) => { - let rollback = rollback_update(game_root).await; - write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; - if let Err(rollback_err) = rollback { - return Err(err.wrap_err(format!("rollback also failed: {rollback_err}"))); - } - Err(err) - } + Err(err) => Err(settle_operation_failure( + &root_capability, + state_dir, + id, + err, + )), } } -pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> { - let eti_version = read_downloaded_version(game_root).await; +pub fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> { + let root_capability = MutationGameRoot::open_or_create_target(game_root, id)?; + let game_root = root_capability.display_path(); + let created_game_root = root_capability.created(); + let eti_version = read_downloaded_version(game_root); + preflight_uninstall(&root_capability, game_root, state_dir, id)?; write_intent( state_dir, id, - &InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()), - ) - .await?; + &InstallIntent::new(game_root, id, InstallIntentState::Uninstalling, eti_version)?, + )?; - let result = uninstall_inner(game_root).await; + let result = uninstall_inner(&root_capability, game_root); match result { Ok(()) => { - write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; + clear_intent(state_dir, id)?; + remove_created_empty_game_root(game_root, created_game_root)?; Ok(()) } - Err(err) => { - let rollback = restore_backup(game_root).await; - if let Err(rollback_err) = rollback { - return Err(err.wrap_err(format!("rollback also failed: {rollback_err}"))); - } - write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; - Err(err) - } + Err(err) => Err(settle_operation_failure( + &root_capability, + state_dir, + id, + err, + )), } } -pub async fn recover_on_startup( +pub(crate) async fn recover_on_startup( game_dir: &Path, state_dir: &Path, active_ids: &HashSet, -) -> eyre::Result<()> { - let mut entries = match tokio::fs::read_dir(game_dir).await { +) -> eyre::Result { + // Validate the entire persisted install-intent namespace before recovering + // any one game so foreign, corrupt, legacy, or unsafe state quarantines the + // root switch/startup pass without partial mutation. + let persisted_ids = scan_active_install_intents(state_dir, game_dir)?; + let ownership_ids = crate::download::scan_download_ownership_recovery_ids(state_dir, game_dir)?; + let entries = match directory_entry_names(game_dir) { Ok(entries) => entries, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(StartupRecoveryReport::default()); + } Err(err) => return Err(err.into()), }; + let mut report = StartupRecoveryReport::default(); + let mut recovery_ids = persisted_ids + .into_iter() + .chain(ownership_ids) + .collect::>(); - while let Some(entry) = entries.next_entry().await? { - if !entry.file_type().await?.is_dir() { - continue; - } - - let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else { + for entry_name in entries { + let Some(id) = entry_name.to_str().map(ToOwned::to_owned) else { continue; }; if id == ".lanspread" { continue; } + recovery_ids.insert(id); + } + + for id in recovery_ids { if active_ids.contains(&id) { log::debug!("Skipping recovery for active game root {id}"); continue; } - recover_game_root(&entry.path(), state_dir, &id).await?; + match MutationGameRoot::open_existing(game_dir, &id) { + Ok(Some(root_capability)) => { + if let Err(error) = + recover_game_root_with_capability(&root_capability, state_dir, &id).await + { + log::error!("Recovery failed for game root {id}: {error}"); + report.failures.insert(id, error.to_string()); + } + } + Ok(None) => { + let game_root = game_dir.join(&id); + if let Err(error) = recover_game_root_path(None, &game_root, state_dir, &id).await { + log::error!("Recovery failed for absent game root {id}: {error}"); + report.failures.insert(id, error.to_string()); + } + } + Err(error) => { + log::error!("Unsafe game root {id} rejected during startup recovery: {error}"); + report + .failures + .insert(id, format!("unsafe direct game root: {error}")); + } + } } - Ok(()) + Ok(report) } pub async fn recover_game_root(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> { - crate::download::recover_incomplete_download(game_root, state_dir, id).await?; + let Some(root_capability) = MutationGameRoot::open_existing_target(game_root, id)? else { + // With no game directory there is no payload to mutate, but state-only + // intent cleanup still needs to run. + return recover_game_root_path(None, game_root, state_dir, id).await; + }; + recover_game_root_with_capability(&root_capability, state_dir, id).await +} - let intent = read_intent(state_dir, id).await; - let fs = inspect_install_fs(game_root).await; - match intent.state { - InstallIntentState::None => recover_none_intent(game_root).await?, - InstallIntentState::Installing => { - recover_installing(game_root, state_dir, id, intent, fs).await?; +async fn recover_game_root_with_capability( + root_capability: &MutationGameRoot, + state_dir: &Path, + id: &str, +) -> eyre::Result<()> { + recover_game_root_path( + Some(root_capability), + root_capability.display_path(), + state_dir, + id, + ) + .await +} + +async fn recover_game_root_path( + root_capability: Option<&MutationGameRoot>, + game_root: &Path, + state_dir: &Path, + id: &str, +) -> eyre::Result<()> { + let intent = match read_intent(state_dir, game_root, id) { + LoadedInstallIntent::Missing => None, + LoadedInstallIntent::ForeignRoot => { + eyre::bail!("install intent for {id} belongs to a different configured games directory") } - InstallIntentState::Updating => { - recover_updating(game_root, state_dir, id, intent, fs).await?; + LoadedInstallIntent::Invalid(error) => { + eyre::bail!("install intent for {id} is invalid: {error}") } - InstallIntentState::Uninstalling => { - recover_uninstalling(game_root, state_dir, id, intent, fs).await?; + LoadedInstallIntent::Valid(intent) => Some(intent), + }; + let fs = inspect_install_fs(game_root)?; + crate::download::recover_incomplete_download(game_root, state_dir, id).await?; + match intent { + None => require_clean_missing_intent(fs), + Some(intent) => { + recover_valid_install_state(root_capability, game_root, state_dir, id, &intent, fs) } } - Ok(()) } async fn install_inner( + root_capability: &MutationGameRoot, game_root: &Path, id: &str, unpacker: Arc, + cancel_token: &CancellationToken, ) -> eyre::Result<()> { let local = local_dir(game_root); - if path_is_dir(&local).await { + if path_is_dir(&local) { eyre::bail!("game {id} is already installed"); } let staging = installing_dir(game_root); - prepare_owned_empty_dir(&staging).await?; - unpack_archives(game_root, &staging, unpacker).await?; - tokio::fs::rename(&staging, &local) - .await + prepare_owned_empty_dir(&staging)?; + root_capability.sync_game_root()?; + unpack_archives(game_root, &staging, unpacker, cancel_token).await?; + rename_path(&staging, &local) .wrap_err_with(|| format!("failed to promote install for {id}"))?; + root_capability.sync_game_root()?; Ok(()) } -async fn update_inner(game_root: &Path, id: &str, unpacker: Arc) -> eyre::Result<()> { +async fn update_inner( + root_capability: &MutationGameRoot, + game_root: &Path, + id: &str, + unpacker: Arc, + cancel_token: &CancellationToken, +) -> eyre::Result<()> { let local = local_dir(game_root); let backup = backup_dir(game_root); let staging = installing_dir(game_root); - if !path_is_dir(&local).await { + if !path_is_dir(&local) { eyre::bail!("game {id} is not installed"); } - prepare_backup_slot(&backup).await?; - tokio::fs::rename(&local, &backup) - .await + ensure_owned_marker(&local)?; + rename_path(&local, &backup) .wrap_err_with(|| format!("failed to move existing install for {id} to backup"))?; - drop_owned_marker(&backup).await?; + root_capability.sync_game_root()?; - prepare_owned_empty_dir(&staging).await?; - unpack_archives(game_root, &staging, unpacker).await?; - tokio::fs::rename(&staging, &local) - .await - .wrap_err_with(|| format!("failed to promote update for {id}"))?; + prepare_owned_empty_dir(&staging)?; + root_capability.sync_game_root()?; + unpack_archives(game_root, &staging, unpacker, cancel_token).await?; + rename_path(&staging, &local).wrap_err_with(|| format!("failed to promote update for {id}"))?; + root_capability.sync_game_root()?; Ok(()) } -async fn uninstall_inner(game_root: &Path) -> eyre::Result<()> { +fn uninstall_inner(root_capability: &MutationGameRoot, game_root: &Path) -> eyre::Result<()> { let local = local_dir(game_root); let backup = backup_dir(game_root); - if !path_is_dir(&local).await { + if !path_is_dir(&local) { return Ok(()); } - prepare_backup_slot(&backup).await?; - tokio::fs::rename(&local, &backup).await?; - drop_owned_marker(&backup).await?; - tokio::fs::remove_dir_all(&backup).await?; + ensure_owned_marker(&local)?; + rename_path(&local, &backup)?; + root_capability.sync_game_root()?; + remove_owned_dir(&backup)?; + root_capability.sync_game_root()?; Ok(()) } @@ -379,39 +515,57 @@ async fn unpack_archives( game_root: &Path, staging: &Path, unpacker: Arc, + cancel_token: &CancellationToken, ) -> eyre::Result<()> { - let archives = root_eti_archives(game_root).await?; + if cancel_token.is_cancelled() { + eyre::bail!( + "archive extraction for {} was cancelled", + game_root.display() + ); + } + let archives = root_eti_archives(game_root); + if cancel_token.is_cancelled() { + eyre::bail!( + "archive extraction for {} was cancelled", + game_root.display() + ); + } + let archives = archives?; if archives.is_empty() { eyre::bail!("no .eti archives found in {}", game_root.display()); } for archive in archives { - unpacker.unpack(&archive, staging).await?; + unpacker + .unpack(&archive, staging, cancel_token.clone()) + .await?; } Ok(()) } -pub(crate) async fn root_eti_archives(game_root: &Path) -> eyre::Result> { - let mut entries = tokio::fs::read_dir(game_root).await?; - let mut archives = Vec::new(); - while let Some(entry) = entries.next_entry().await? { - if !entry.file_type().await?.is_file() { - continue; +pub(crate) fn root_eti_archives(game_root: &Path) -> eyre::Result> { + scoped_blocking(|| { + let mut archives = Vec::new(); + for entry in fs::read_dir(game_root)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let path = entry.path(); + if path.extension().is_some_and(|extension| extension == "eti") { + archives.push(path); + } } - let path = entry.path(); - if path.extension().is_some_and(|extension| extension == "eti") { - archives.push(path); - } - } - archives.sort(); - Ok(archives) + archives.sort(); + Ok(archives) + }) } /// Drop the per-game launch-settings marker before committing install/update /// success, so recovery can retry the reset before publishing a clean intent. -async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> { +fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> { let marker = launch_settings_applied_path(state_dir, id); - remove_file_if_exists(&marker).await.wrap_err_with(|| { + remove_file_if_exists(&marker).wrap_err_with(|| { format!( "failed to reset launch-settings marker {}", marker.display() @@ -419,212 +573,482 @@ async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Resul }) } -async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> { - sweep_owned_orphan(&installing_dir(game_root)).await?; - sweep_owned_orphan(&backup_dir(game_root)).await?; +fn recover_valid_install_state( + root_capability: Option<&MutationGameRoot>, + game_root: &Path, + state_dir: &Path, + id: &str, + intent: &InstallIntent, + fs: InstallFsState, +) -> eyre::Result<()> { + match intent.state { + InstallIntentState::None => recover_none_intent(root_capability, game_root, fs)?, + InstallIntentState::Installing => { + recover_installing(root_capability, game_root, state_dir, id, fs)?; + } + InstallIntentState::Updating => { + recover_updating(root_capability, game_root, state_dir, id, fs)?; + } + InstallIntentState::Uninstalling => { + recover_uninstalling(root_capability, game_root, fs)?; + } + } + clear_intent(state_dir, id) +} + +fn recover_none_intent( + root_capability: Option<&MutationGameRoot>, + game_root: &Path, + fs: InstallFsState, +) -> eyre::Result<()> { + let mut mutated = false; + if fs.installing == FsEntryState::Present { + remove_owned_dir(&installing_dir(game_root))?; + mutated = true; + } + if fs.backup == FsEntryState::Present { + remove_owned_dir(&backup_dir(game_root))?; + mutated = true; + } + if mutated { + sync_recovery_root(root_capability)?; + } Ok(()) } -async fn recover_installing( +fn recover_installing( + root_capability: Option<&MutationGameRoot>, game_root: &Path, state_dir: &Path, id: &str, - intent: InstallIntent, - fs: InstallFsState, -) -> eyre::Result<()> { - let commit_landed = fs.local == FsEntryState::Present; - if let InstallFsState { - installing: FsEntryState::Present, - .. - } = fs - { - remove_dir_all_if_exists(&installing_dir(game_root)).await?; - } - if commit_landed { - reset_launch_settings_marker(state_dir, id).await?; - } - write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await -} - -async fn recover_updating( - game_root: &Path, - state_dir: &Path, - id: &str, - intent: InstallIntent, - fs: InstallFsState, -) -> eyre::Result<()> { - if matches!( - fs, - InstallFsState { - local: FsEntryState::Present, - backup: FsEntryState::Present, - .. - } - ) { - reset_launch_settings_marker(state_dir, id).await?; - } - match fs { - InstallFsState { - local: FsEntryState::Missing, - installing: FsEntryState::Present, - backup: FsEntryState::Present, - } => { - remove_dir_all_if_exists(&installing_dir(game_root)).await?; - restore_backup(game_root).await?; - } - InstallFsState { - local: FsEntryState::Present, - installing: FsEntryState::Present, - backup: FsEntryState::Present, - } => { - remove_dir_all_if_exists(&installing_dir(game_root)).await?; - remove_dir_all_if_exists(&backup_dir(game_root)).await?; - } - InstallFsState { - local: FsEntryState::Present, - installing: FsEntryState::Missing, - backup: FsEntryState::Present, - } => remove_dir_all_if_exists(&backup_dir(game_root)).await?, - _ => {} - } - write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await -} - -async fn recover_uninstalling( - game_root: &Path, - state_dir: &Path, - id: &str, - intent: InstallIntent, fs: InstallFsState, ) -> eyre::Result<()> { match fs { InstallFsState { local: FsEntryState::Missing, installing: FsEntryState::Missing, - backup: FsEntryState::Present, - } => remove_dir_all_if_exists(&backup_dir(game_root)).await?, + backup: FsEntryState::Missing, + } => {} + InstallFsState { + local: FsEntryState::Missing, + installing: FsEntryState::Present, + backup: FsEntryState::Missing, + } => { + remove_owned_dir(&installing_dir(game_root))?; + sync_recovery_root(root_capability)?; + } InstallFsState { local: FsEntryState::Present, installing: FsEntryState::Missing, backup: FsEntryState::Missing, - } => uninstall_inner(game_root).await?, - _ => {} + } => { + reset_launch_settings_marker(state_dir, id)?; + // This is the after-rename recovery state. Retrying the parent + // directory flush establishes the durable promotion boundary + // before the intent can be cleared and the game published. + sync_recovery_root(root_capability)?; + } + _ => eyre::bail!("ambiguous filesystem state for interrupted install"), } - write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await + Ok(()) } -async fn inspect_install_fs(game_root: &Path) -> InstallFsState { - InstallFsState { - local: path_is_dir(&local_dir(game_root)).await.into(), - installing: path_is_dir(&installing_dir(game_root)).await.into(), - backup: path_is_dir(&backup_dir(game_root)).await.into(), +fn recover_updating( + root_capability: Option<&MutationGameRoot>, + game_root: &Path, + state_dir: &Path, + id: &str, + fs: InstallFsState, +) -> eyre::Result<()> { + match fs { + InstallFsState { + local: FsEntryState::Present, + installing: FsEntryState::Missing, + backup: FsEntryState::Missing, + } => {} + InstallFsState { + local: FsEntryState::Missing, + installing: FsEntryState::Missing, + backup: FsEntryState::Present, + } => { + restore_owned_backup(game_root)?; + sync_recovery_root(root_capability)?; + } + InstallFsState { + local: FsEntryState::Missing, + installing: FsEntryState::Present, + backup: FsEntryState::Present, + } => { + remove_owned_dir(&installing_dir(game_root))?; + sync_recovery_root(root_capability)?; + restore_owned_backup(game_root)?; + sync_recovery_root(root_capability)?; + } + InstallFsState { + local: FsEntryState::Present, + installing: FsEntryState::Missing, + backup: FsEntryState::Present, + } => { + reset_launch_settings_marker(state_dir, id)?; + remove_owned_dir(&backup_dir(game_root))?; + sync_recovery_root(root_capability)?; + } + _ => eyre::bail!("ambiguous filesystem state for interrupted update"), + } + Ok(()) +} + +fn recover_uninstalling( + root_capability: Option<&MutationGameRoot>, + game_root: &Path, + fs: InstallFsState, +) -> eyre::Result<()> { + match fs { + InstallFsState { + local: FsEntryState::Missing, + installing: FsEntryState::Missing, + backup: FsEntryState::Missing, + } => {} + InstallFsState { + local: FsEntryState::Missing, + installing: FsEntryState::Missing, + backup: FsEntryState::Present, + } => { + remove_owned_dir(&backup_dir(game_root))?; + sync_recovery_root(root_capability)?; + } + InstallFsState { + local: FsEntryState::Present, + installing: FsEntryState::Missing, + backup: FsEntryState::Missing, + } => { + let root_capability = root_capability.ok_or_else(|| { + eyre::eyre!("cannot resume uninstall without retained game-root authority") + })?; + uninstall_inner(root_capability, game_root)?; + } + _ => eyre::bail!("ambiguous filesystem state for interrupted uninstall"), + } + Ok(()) +} + +fn require_clean_missing_intent(fs: InstallFsState) -> eyre::Result<()> { + if fs.installing == FsEntryState::Present || fs.backup == FsEntryState::Present { + eyre::bail!("reserved install state exists without a valid install intent"); + } + Ok(()) +} + +fn preflight_install( + root_capability: &MutationGameRoot, + game_root: &Path, + state_dir: &Path, + id: &str, +) -> eyre::Result<()> { + let fs = preflight_clean_slots(root_capability, game_root, state_dir, id)?; + if fs.local == FsEntryState::Present { + eyre::bail!("game {id} is already installed"); + } + Ok(()) +} + +fn preflight_update( + root_capability: &MutationGameRoot, + game_root: &Path, + state_dir: &Path, + id: &str, +) -> eyre::Result<()> { + let fs = preflight_clean_slots(root_capability, game_root, state_dir, id)?; + if fs.local == FsEntryState::Missing { + eyre::bail!("game {id} is not installed"); + } + Ok(()) +} + +fn preflight_uninstall( + root_capability: &MutationGameRoot, + game_root: &Path, + state_dir: &Path, + id: &str, +) -> eyre::Result<()> { + preflight_clean_slots(root_capability, game_root, state_dir, id).map(|_| ()) +} + +fn preflight_clean_slots( + root_capability: &MutationGameRoot, + game_root: &Path, + state_dir: &Path, + id: &str, +) -> eyre::Result { + match read_intent(state_dir, game_root, id) { + LoadedInstallIntent::Missing => {} + LoadedInstallIntent::ForeignRoot => eyre::bail!( + "refusing to overwrite install intent for {id} from another games directory" + ), + LoadedInstallIntent::Invalid(error) => { + eyre::bail!("refusing to overwrite invalid install intent for {id}: {error}") + } + LoadedInstallIntent::Valid(_) => { + eyre::bail!("install intent for {id} must be recovered before a new operation") + } + } + + let fs = inspect_install_fs(game_root)?; + require_clean_missing_intent(fs)?; + // Keep the retained authority in this preflight boundary so callers cannot + // accidentally validate one root and mutate another. + root_capability.sync_game_root()?; + Ok(fs) +} + +fn recover_current_install_state( + root_capability: &MutationGameRoot, + state_dir: &Path, + id: &str, +) -> eyre::Result<()> { + let game_root = root_capability.display_path(); + let fs = inspect_install_fs(game_root)?; + match read_intent(state_dir, game_root, id) { + LoadedInstallIntent::Missing => require_clean_missing_intent(fs), + LoadedInstallIntent::ForeignRoot => { + eyre::bail!("install intent for {id} belongs to a different configured games directory") + } + LoadedInstallIntent::Invalid(error) => { + eyre::bail!("install intent for {id} is invalid: {error}") + } + LoadedInstallIntent::Valid(intent) => recover_valid_install_state( + Some(root_capability), + game_root, + state_dir, + id, + &intent, + fs, + ), } } -async fn read_downloaded_version(game_root: &Path) -> Option { - if !version_ini_is_regular_file(game_root).await { - return None; +fn settle_operation_failure( + root_capability: &MutationGameRoot, + state_dir: &Path, + id: &str, + operation_error: eyre::Report, +) -> eyre::Report { + match recover_current_install_state(root_capability, state_dir, id) { + Ok(()) => operation_error, + Err(recovery_error) => operation_error.wrap_err(format!( + "install recovery also failed; active intent retained: {recovery_error}" + )), } - match lanspread_db::db::read_version_from_ini(game_root) { - Ok(version) => version, - Err(err) => { - log::warn!( - "Failed to read version.ini in {}: {err}", - game_root.display() +} + +fn sync_recovery_root(root_capability: Option<&MutationGameRoot>) -> eyre::Result<()> { + root_capability + .ok_or_else(|| eyre::eyre!("filesystem recovery mutation lacks game-root authority"))? + .sync_game_root() +} + +fn inspect_install_fs(game_root: &Path) -> eyre::Result { + let local = local_dir(game_root); + let installing = installing_dir(game_root); + let backup = backup_dir(game_root); + scoped_blocking(|| { + Ok(InstallFsState { + local: inspect_entry(&local, false)?, + installing: inspect_entry(&installing, true)?, + backup: inspect_entry(&backup, true)?, + }) + }) +} + +fn read_downloaded_version(game_root: &Path) -> Option { + scoped_blocking(|| { + if !version_ini_is_regular_file_blocking(game_root) { + return None; + } + match lanspread_db::db::read_version_from_ini(game_root) { + Ok(version) => version, + Err(err) => { + log::warn!( + "Failed to read version.ini in {}: {err}", + game_root.display() + ); + None + } + } + }) +} + +fn version_ini_is_regular_file_blocking(game_root: &Path) -> bool { + let root_is_safe = fs::symlink_metadata(game_root).is_ok_and(|metadata| { + metadata.is_dir() && !metadata.file_type().is_symlink() && !is_windows_reparse(&metadata) + }); + if !root_is_safe { + return false; + } + + fs::symlink_metadata(game_root.join(crate::game_paths::VERSION_INI)).is_ok_and(|metadata| { + metadata.is_file() && !metadata.file_type().is_symlink() && !is_windows_reparse(&metadata) + }) +} + +#[cfg(windows)] +fn is_windows_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +const fn is_windows_reparse(_metadata: &fs::Metadata) -> bool { + false +} + +fn prepare_owned_empty_dir(path: &Path) -> eyre::Result<()> { + scoped_blocking(|| { + if inspect_entry(path, true)? != FsEntryState::Missing { + eyre::bail!( + "refusing to replace existing reserved install path {}", + path.display() ); - None } - } + fs::create_dir(path)?; + let marker = owned_marker(path); + let marker_file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&marker)?; + marker_file.sync_all()?; + sync_directory_path(path)?; + Ok(()) + }) } -async fn prepare_owned_empty_dir(path: &Path) -> eyre::Result<()> { - if path.exists() { - if owned_marker(path).is_file() { - tokio::fs::remove_dir_all(path).await?; - } else { - eyre::bail!("refusing to reuse markerless directory {}", path.display()); +fn ensure_owned_marker(path: &Path) -> eyre::Result<()> { + scoped_blocking(|| { + inspect_entry(path, false)?; + let marker = owned_marker(path); + match fs::symlink_metadata(&marker) { + Ok(metadata) + if metadata.is_file() + && !metadata.file_type().is_symlink() + && !is_windows_reparse(&metadata) => {} + Ok(_) => eyre::bail!("unsafe install ownership marker {}", marker.display()), + Err(error) if error.kind() == ErrorKind::NotFound => { + let marker_file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&marker)?; + marker_file.sync_all()?; + sync_directory_path(path)?; + } + Err(error) => return Err(error.into()), } - } - tokio::fs::create_dir_all(path).await?; - drop_owned_marker(path).await + Ok(()) + }) } -async fn prepare_backup_slot(path: &Path) -> eyre::Result<()> { - if !path.exists() { - return Ok(()); +fn remove_owned_dir_if_exists(path: &Path) -> eyre::Result<()> { + match scoped_blocking(|| inspect_entry(path, true))? { + FsEntryState::Missing => Ok(()), + FsEntryState::Present => remove_owned_dir(path), } - if owned_marker(path).is_file() { - tokio::fs::remove_dir_all(path).await?; - return Ok(()); - } - eyre::bail!("refusing to replace markerless backup {}", path.display()); } -async fn drop_owned_marker(path: &Path) -> eyre::Result<()> { - tokio::fs::write(owned_marker(path), []).await?; - Ok(()) +fn remove_owned_dir(path: &Path) -> eyre::Result<()> { + scoped_blocking(|| { + if inspect_entry(path, true)? != FsEntryState::Present { + eyre::bail!("owned install directory is missing: {}", path.display()); + } + fs::remove_dir_all(path).map_err(Into::into) + }) } -async fn sweep_owned_orphan(path: &Path) -> eyre::Result<()> { - if !path.exists() { - return Ok(()); - } - if owned_marker(path).is_file() { - remove_dir_all_if_exists(path).await?; - } else { - log::warn!( - "Leaving markerless reserved directory untouched: {}", - path.display() - ); - } - Ok(()) +fn restore_owned_backup(game_root: &Path) -> eyre::Result<()> { + restore_backup_with_rename(game_root, |source, destination| { + fs::rename(source, destination) + }) } -async fn rollback_update(game_root: &Path) -> eyre::Result<()> { - remove_dir_all_if_exists(&installing_dir(game_root)).await?; - restore_backup(game_root).await -} - -async fn restore_backup(game_root: &Path) -> eyre::Result<()> { +fn restore_backup_with_rename( + game_root: &Path, + rename: impl FnOnce(&Path, &Path) -> std::io::Result<()>, +) -> eyre::Result<()> { let local = local_dir(game_root); let backup = backup_dir(game_root); - if !path_is_dir(&backup).await { - return Ok(()); + scoped_blocking(|| { + if inspect_entry(&local, false)? != FsEntryState::Missing { + eyre::bail!("refusing to replace local while restoring install backup"); + } + if inspect_entry(&backup, true)? != FsEntryState::Present { + eyre::bail!("owned install backup is missing"); + } + rename(&backup, &local)?; + Ok(()) + }) +} + +fn inspect_entry(path: &Path, require_owned_marker: bool) -> eyre::Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(FsEntryState::Missing), + Err(error) => return Err(error.into()), + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() || is_windows_reparse(&metadata) { + eyre::bail!("unsafe install filesystem entry {}", path.display()); } - remove_dir_all_if_exists(&local).await?; - tokio::fs::rename(&backup, &local).await?; + if require_owned_marker { + let marker = owned_marker(path); + let marker_metadata = match fs::symlink_metadata(&marker) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => { + eyre::bail!("markerless reserved install directory {}", path.display()); + } + Err(error) => return Err(error.into()), + }; + if !marker_metadata.is_file() + || marker_metadata.file_type().is_symlink() + || is_windows_reparse(&marker_metadata) + { + eyre::bail!("unsafe install ownership marker {}", marker.display()); + } + } + Ok(FsEntryState::Present) +} + +#[cfg(unix)] +fn sync_directory_path(path: &Path) -> std::io::Result<()> { + fs::File::open(path)?.sync_all() +} + +#[cfg(not(unix))] +const fn sync_directory_path(_path: &Path) -> std::io::Result<()> { + // Rust does not expose a portable durable directory flush on Windows. Ok(()) } -async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> { - if !path_exists(path).await { - return Ok(()); - } - - match tokio::fs::remove_file(path).await { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => Err(err.into()), - } +fn remove_file_if_exists(path: &Path) -> eyre::Result<()> { + scoped_blocking(|| { + if !path.exists() { + return Ok(()); + } + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } + }) } -async fn remove_dir_all_if_exists(path: &Path) -> eyre::Result<()> { - match tokio::fs::remove_dir_all(path).await { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => Err(err.into()), - } -} - -async fn remove_created_empty_game_root(game_root: &Path, created: bool) -> eyre::Result<()> { +fn remove_created_empty_game_root(game_root: &Path, created: bool) -> eyre::Result<()> { if !created { return Ok(()); } - remove_empty_dir_if_exists(game_root).await + remove_empty_dir_if_exists(game_root) } -async fn remove_empty_dir_if_exists(path: &Path) -> eyre::Result<()> { - match tokio::fs::remove_dir(path).await { +fn remove_empty_dir_if_exists(path: &Path) -> eyre::Result<()> { + scoped_blocking(|| match fs::remove_dir(path) { Ok(()) => Ok(()), Err(err) if matches!( @@ -635,17 +1059,31 @@ async fn remove_empty_dir_if_exists(path: &Path) -> eyre::Result<()> { Ok(()) } Err(err) => Err(err.into()), - } + }) } -async fn path_is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .is_ok_and(|metadata| metadata.is_dir()) +fn rename_path(source: &Path, destination: &Path) -> eyre::Result<()> { + scoped_blocking(|| fs::rename(source, destination).map_err(Into::into)) } -async fn path_exists(path: &Path) -> bool { - tokio::fs::metadata(path).await.is_ok() +fn canonicalize_or_original(path: PathBuf) -> PathBuf { + scoped_blocking(|| fs::canonicalize(&path)).unwrap_or(path) +} + +fn directory_entry_names(path: &Path) -> std::io::Result> { + scoped_blocking(|| { + fs::read_dir(path)? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect() + }) +} + +fn path_is_dir(path: &Path) -> bool { + scoped_blocking(|| path_is_dir_blocking(path)) +} + +fn path_is_dir_blocking(path: &Path) -> bool { + fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) } fn local_dir(game_root: &Path) -> PathBuf { @@ -675,16 +1113,38 @@ mod tests { use std::{ collections::HashSet, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{Arc, Condvar, Mutex, mpsc}, + thread, + time::Duration, + }; + + use lanspread_db::content_manifest::{ + Blake3Digest, + CatalogContentManifestBody, + CatalogExtractedEntry, + CatalogFileEntry, }; use super::*; use crate::{ + download::{ + DownloadOwnershipReadiness, + download_ownership_readiness, + seed_pending_download_ownership_for_test, + }, game_paths::{VERSION_DISCARDED_FILE, VERSION_TMP_FILE}, install::unpack::UnpackFuture, test_support::TempDir, }; + struct GuardDropSignal(mpsc::Sender<()>); + + impl Drop for GuardDropSignal { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + #[derive(Default)] struct FakeUnpacker { fail: bool, @@ -711,7 +1171,12 @@ mod tests { } impl Unpacker for FakeUnpacker { - fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { + fn unpack<'a>( + &'a self, + archive: &'a Path, + dest: &'a Path, + _cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { Box::pin(async move { self.archives .lock() @@ -720,16 +1185,18 @@ mod tests { if self.fail { eyre::bail!("forced unpack failure"); } - tokio::fs::write(dest.join("payload.txt"), b"installed").await?; - if self.create_commit_conflict { - let game_root = dest - .parent() - .ok_or_else(|| eyre::eyre!("staging dir should have parent"))?; - let local_conflict = game_root.join(LOCAL_DIR); - tokio::fs::create_dir_all(&local_conflict).await?; - tokio::fs::write(local_conflict.join("conflict.txt"), b"conflict").await?; - } - Ok(()) + scoped_blocking(|| { + fs::write(dest.join("payload.txt"), b"installed")?; + if self.create_commit_conflict { + let game_root = dest + .parent() + .ok_or_else(|| eyre::eyre!("staging dir should have parent"))?; + let local_conflict = game_root.join(LOCAL_DIR); + fs::create_dir_all(&local_conflict)?; + fs::write(local_conflict.join("conflict.txt"), b"conflict")?; + } + Ok(()) + }) }) } } @@ -741,6 +1208,20 @@ mod tests { std::fs::write(path, bytes).expect("file should be written"); } + fn mark_owned_dir(path: &Path) { + write_file(&owned_marker(path), b""); + } + + fn assert_intent_missing(state_dir: &Path, game_root: &Path, id: &str) { + assert!( + matches!( + read_intent(state_dir, game_root, id), + LoadedInstallIntent::Missing + ), + "settled intent for {id} should be absent" + ); + } + fn successful_unpacker() -> Arc { Arc::new(FakeUnpacker::default()) } @@ -749,6 +1230,29 @@ mod tests { TempDir::new("lanspread-install-state") } + fn streamed_manifest(entries: Vec) -> CatalogContentManifest { + let version = b"20250101"; + let version_digest = Blake3Digest::hash(version); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "game", + "20250101", + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("test version length should fit u64"), + version_digest, + vec![version_digest], + ) + .expect("test version entry should validate"), + ], + entries, + ) + .expect("test streamed manifest body should validate"), + ) + .expect("test streamed manifest should seal") + } + #[tokio::test] async fn install_success_promotes_staging_and_clears_intent() { let temp = TempDir::new("lanspread-install"); @@ -757,14 +1261,19 @@ mod tests { write_file(&root.join("game.eti"), b"archive"); write_file(&root.join("version.ini"), b"20250101"); - install(&root, state.path(), "game", successful_unpacker()) - .await - .expect("install should succeed"); + install( + &root, + state.path(), + "game", + successful_unpacker(), + CancellationToken::new(), + ) + .await + .expect("install should succeed"); assert!(root.join("local").join("payload.txt").is_file()); assert!(!root.join(".local.installing").exists()); - let intent = read_intent(state.path(), "game").await; - assert_eq!(intent.state, InstallIntentState::None); + assert_intent_missing(state.path(), &root, "game"); } #[tokio::test] @@ -776,48 +1285,185 @@ mod tests { write_file(&root.join("version.ini"), b"20250101"); write_file(&launch_settings_applied_path(state.path(), "game"), b""); - install(&root, state.path(), "game", successful_unpacker()) - .await - .expect("install should succeed"); + install( + &root, + state.path(), + "game", + successful_unpacker(), + CancellationToken::new(), + ) + .await + .expect("install should succeed"); assert!(!launch_settings_applied_path(state.path(), "game").exists()); } + #[cfg(unix)] #[tokio::test] - async fn streamed_install_rollback_removes_new_empty_game_root() { + async fn install_rejects_symlink_game_root_without_outside_mutation() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-install-symlink-games"); + let outside = TempDir::new("lanspread-install-symlink-outside"); + let state = test_state(); + write_file(&outside.path().join("version.ini"), b"20250101"); + write_file(&outside.path().join("game.eti"), b"archive"); + write_file(&outside.path().join("canary.txt"), b"outside"); + let game_root = games.path().join("game"); + symlink(outside.path(), &game_root).expect("game-root symlink should be created"); + + let error = install( + &game_root, + state.path(), + "game", + successful_unpacker(), + CancellationToken::new(), + ) + .await + .expect_err("install through a symlink game root should fail closed"); + assert!(!error.to_string().is_empty()); + + assert_eq!( + std::fs::read(outside.path().join("canary.txt")) + .expect("canary should remain readable"), + b"outside" + ); + assert!(!outside.path().join(INSTALLING_DIR).exists()); + assert!(!outside.path().join(LOCAL_DIR).exists()); + assert!(!crate::state_paths::game_state_dir(state.path(), "game").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn update_rejects_symlink_game_root_without_outside_mutation() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-update-symlink-games"); + let outside = TempDir::new("lanspread-update-symlink-outside"); + let state = test_state(); + write_file(&outside.path().join("version.ini"), b"20250101"); + write_file(&outside.path().join("game.eti"), b"archive"); + write_file(&outside.path().join("local/old.txt"), b"old"); + write_file(&outside.path().join("canary.txt"), b"outside"); + let game_root = games.path().join("game"); + symlink(outside.path(), &game_root).expect("game-root symlink should be created"); + + let error = update( + &game_root, + state.path(), + "game", + successful_unpacker(), + CancellationToken::new(), + ) + .await + .expect_err("update through a symlink game root should fail closed"); + assert!(!error.to_string().is_empty()); + + assert_eq!( + std::fs::read(outside.path().join("canary.txt")) + .expect("canary should remain readable"), + b"outside" + ); + assert_eq!( + std::fs::read(outside.path().join("local/old.txt")) + .expect("existing install should remain readable"), + b"old" + ); + assert!(!outside.path().join(INSTALLING_DIR).exists()); + assert!(!outside.path().join(BACKUP_DIR).exists()); + assert!(!crate::state_paths::game_state_dir(state.path(), "game").exists()); + } + + #[cfg(unix)] + #[test] + fn uninstall_rejects_symlink_game_root_without_outside_mutation() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-uninstall-symlink-games"); + let outside = TempDir::new("lanspread-uninstall-symlink-outside"); + let state = test_state(); + write_file(&outside.path().join("version.ini"), b"20250101"); + write_file(&outside.path().join("local/old.txt"), b"old"); + write_file(&outside.path().join("canary.txt"), b"outside"); + let game_root = games.path().join("game"); + symlink(outside.path(), &game_root).expect("game-root symlink should be created"); + + let error = uninstall(&game_root, state.path(), "game") + .expect_err("uninstall through a symlink game root should fail closed"); + assert!(!error.to_string().is_empty()); + + assert_eq!( + std::fs::read(outside.path().join("canary.txt")) + .expect("canary should remain readable"), + b"outside" + ); + assert_eq!( + std::fs::read(outside.path().join("local/old.txt")) + .expect("existing install should remain readable"), + b"old" + ); + assert!(!outside.path().join(BACKUP_DIR).exists()); + assert!(!crate::state_paths::game_state_dir(state.path(), "game").exists()); + } + + #[cfg(unix)] + #[test] + fn streamed_install_rejects_symlink_game_root_without_outside_mutation() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-stream-install-symlink-games"); + let outside = TempDir::new("lanspread-stream-install-symlink-outside"); + let state = test_state(); + write_file(&outside.path().join("canary.txt"), b"outside"); + let game_root = games.path().join("game"); + symlink(outside.path(), &game_root).expect("game-root symlink should be created"); + + let result = begin_streamed_install(&game_root, state.path(), "game"); + assert!( + result.is_err(), + "streamed install through a symlink game root should fail closed" + ); + + assert_eq!( + std::fs::read(outside.path().join("canary.txt")) + .expect("canary should remain readable"), + b"outside" + ); + assert!(!outside.path().join(INSTALLING_DIR).exists()); + assert!(!outside.path().join(LOCAL_DIR).exists()); + assert!(!crate::state_paths::game_state_dir(state.path(), "game").exists()); + } + + #[test] + fn streamed_install_rollback_removes_new_empty_game_root() { let temp = TempDir::new("lanspread-install"); let state = test_state(); let root = temp.path().join("streamed-game"); let transaction = begin_streamed_install(&root, state.path(), "streamed-game") - .await .expect("streamed transaction should begin"); assert!(transaction.staging_dir().is_dir()); transaction .rollback() - .await .expect("streamed rollback should succeed"); assert!(!root.exists()); - let intent = read_intent(state.path(), "streamed-game").await; - assert_eq!(intent.state, InstallIntentState::None); + assert_intent_missing(state.path(), &root, "streamed-game"); } - #[tokio::test] - async fn streamed_install_rollback_keeps_existing_game_root() { + #[test] + fn streamed_install_rollback_keeps_existing_game_root() { let temp = TempDir::new("lanspread-install"); let state = test_state(); let root = temp.game_root(); write_file(&root.join("version.ini"), b"20250101"); let transaction = begin_streamed_install(&root, state.path(), "game") - .await .expect("streamed transaction should begin"); transaction .rollback() - .await .expect("streamed rollback should succeed"); assert!(root.is_dir()); @@ -825,13 +1471,12 @@ mod tests { assert!(!root.join(INSTALLING_DIR).exists()); } - #[tokio::test] - async fn streamed_install_commit_succeeds_when_post_promote_intent_cleanup_fails() { + #[test] + fn streamed_install_commit_reports_post_promote_intent_cleanup_failure() { let temp = TempDir::new("lanspread-install"); let state = test_state(); let root = temp.game_root(); let transaction = begin_streamed_install(&root, state.path(), "game") - .await .expect("streamed transaction should begin"); write_file(&transaction.staging_dir().join("payload.txt"), b"installed"); @@ -839,10 +1484,19 @@ mod tests { std::fs::remove_dir_all(&game_state_dir).expect("game state dir should be removed"); write_file(&game_state_dir, b"not a directory"); - transaction - .commit() - .await - .expect("promoted streamed install should be reported as success"); + let error = transaction + .commit_classified( + &streamed_manifest(vec![ + CatalogExtractedEntry::file("payload.txt", 9, Blake3Digest::hash(b"installed")) + .expect("test streamed entry should validate"), + ]), + &CancellationToken::new(), + ) + .expect_err("unsettled intent cleanup must fail closed"); + let StreamedInstallCommitError::OperationFailed(error) = error else { + panic!("intent cleanup failure must not be classified as cancellation"); + }; + assert!(error.to_string().contains("Not a directory"), "{error:?}"); assert_eq!( std::fs::read(root.join(LOCAL_DIR).join("payload.txt")) @@ -851,6 +1505,140 @@ mod tests { ); } + #[test] + fn streamed_install_commit_cancellation_settles_without_promotion() { + let temp = TempDir::new("lanspread-stream-commit-cancel"); + let state = test_state(); + let root = temp.game_root(); + let transaction = begin_streamed_install(&root, state.path(), "game") + .expect("streamed transaction should begin"); + write_file(&transaction.staging_dir().join("payload.txt"), b"installed"); + let manifest = streamed_manifest(vec![ + CatalogExtractedEntry::file("payload.txt", 9, Blake3Digest::hash(b"installed")) + .expect("test streamed entry should validate"), + ]); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let error = transaction + .commit_classified(&manifest, &cancel) + .expect_err("cancelled commit should fail before rename"); + assert!(matches!(error, StreamedInstallCommitError::Cancelled(_))); + + assert!(!root.join(LOCAL_DIR).exists()); + assert!(!root.join(INSTALLING_DIR).exists()); + assert_intent_missing(state.path(), &root, "game"); + } + + #[test] + fn cancelled_token_never_hides_a_structural_commit_failure() { + let temp = TempDir::new("lanspread-stream-commit-cancel-structural"); + let state = test_state(); + let root = temp.game_root(); + let transaction = begin_streamed_install(&root, state.path(), "game") + .expect("streamed transaction should begin"); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let error = transaction + .commit_classified(&streamed_manifest(Vec::new()), &cancel) + .expect_err("unsupported catalog staging must fail structurally"); + + assert!(matches!( + error, + StreamedInstallCommitError::OperationFailed(_) + )); + assert!(!root.join(LOCAL_DIR).exists()); + assert!(!root.join(INSTALLING_DIR).exists()); + assert_intent_missing(state.path(), &root, "game"); + } + + #[test] + fn cancelled_commit_with_failed_recovery_is_an_operation_failure() { + let temp = TempDir::new("lanspread-stream-commit-cancel-recovery"); + let state = test_state(); + let root = temp.game_root(); + let transaction = begin_streamed_install(&root, state.path(), "game") + .expect("streamed transaction should begin"); + write_file(&transaction.staging_dir().join("payload.txt"), b"installed"); + let manifest = streamed_manifest(vec![ + CatalogExtractedEntry::file("payload.txt", 9, Blake3Digest::hash(b"installed")) + .expect("test streamed entry should validate"), + ]); + let game_state_dir = crate::state_paths::game_state_dir(state.path(), "game"); + std::fs::remove_dir_all(&game_state_dir).expect("game state dir should be removed"); + write_file(&game_state_dir, b"not a directory"); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let error = transaction + .commit_classified(&manifest, &cancel) + .expect_err("failed recovery must dominate cooperative cancellation"); + + assert!(matches!( + error, + StreamedInstallCommitError::OperationFailed(_) + )); + assert!(!root.join(LOCAL_DIR).exists()); + } + + #[test] + fn streamed_install_commit_rejects_inexact_or_unsupported_staging() { + for case in ["missing", "extra", "unsupported"] { + let temp = TempDir::new("lanspread-stream-commit-exact"); + let state = test_state(); + let root = temp.game_root(); + let transaction = begin_streamed_install(&root, state.path(), "game") + .expect("streamed transaction should begin"); + let manifest_entry = + CatalogExtractedEntry::file("payload.txt", 9, Blake3Digest::hash(b"installed")) + .expect("test streamed entry should validate"); + let manifest = if case == "unsupported" { + write_file(&transaction.staging_dir().join("payload.txt"), b"installed"); + streamed_manifest(Vec::new()) + } else { + if case != "missing" { + write_file(&transaction.staging_dir().join("payload.txt"), b"installed"); + write_file(&transaction.staging_dir().join("extra.bin"), b"extra"); + } + streamed_manifest(vec![manifest_entry]) + }; + + let _error = transaction + .commit_classified(&manifest, &CancellationToken::new()) + .expect_err("inexact or unsupported staging should fail closed"); + + assert!(!root.join(LOCAL_DIR).exists(), "case {case} promoted"); + assert!( + !root.join(INSTALLING_DIR).exists(), + "case {case} retained staging" + ); + assert_intent_missing(state.path(), &root, "game"); + } + } + + #[test] + fn interrupted_post_rename_state_retries_parent_sync_before_intent_clear() { + let temp = TempDir::new("lanspread-stream-post-rename-recovery"); + let state = test_state(); + let root = temp.game_root(); + let transaction = begin_streamed_install(&root, state.path(), "game") + .expect("streamed transaction should begin"); + write_file(&transaction.staging_dir().join("payload.txt"), b"installed"); + std::fs::rename(transaction.staging_dir(), root.join(LOCAL_DIR)) + .expect("test should simulate a completed rename before parent sync"); + assert!(!matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Missing + )); + + recover_current_install_state(&transaction.root_capability, state.path(), "game") + .expect("recovery should retry durability and settle the intent"); + + assert!(root.join(LOCAL_DIR).join("payload.txt").is_file()); + assert_intent_missing(state.path(), &root, "game"); + } + #[tokio::test] async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() { let temp = TempDir::new("lanspread-install"); @@ -861,9 +1649,15 @@ mod tests { write_file(&root.join("version.ini"), b"20250101"); let unpacker = Arc::new(FakeUnpacker::default()); - install(&root, state.path(), "game", unpacker.clone()) - .await - .expect("install should succeed"); + install( + &root, + state.path(), + "game", + unpacker.clone(), + CancellationToken::new(), + ) + .await + .expect("install should succeed"); let archives = unpacker .archives @@ -889,6 +1683,7 @@ mod tests { state.path(), "game", Arc::new(FakeUnpacker::failing()), + CancellationToken::new(), ) .await .expect_err("update should fail"); @@ -897,12 +1692,11 @@ mod tests { assert!(root.join("local").join("old.txt").is_file()); assert!(!root.join(".local.installing").exists()); assert!(!root.join(".local.backup").exists()); - let intent = read_intent(state.path(), "game").await; - assert_eq!(intent.state, InstallIntentState::None); + assert_intent_missing(state.path(), &root, "game"); } #[tokio::test] - async fn update_commit_rename_failure_restores_previous_local() { + async fn update_commit_conflict_preserves_every_ambiguous_directory() { let temp = TempDir::new("lanspread-install"); let state = test_state(); let root = temp.game_root(); @@ -915,24 +1709,130 @@ mod tests { state.path(), "game", Arc::new(FakeUnpacker::commit_conflict()), + CancellationToken::new(), ) .await .expect_err("update should fail at commit rename"); assert!( - err.to_string().contains("failed to promote update"), + format!("{err:?}").contains("failed to promote update"), + "{err:?}" + ); + assert!( + err.to_string().contains("active intent retained"), "{err:?}" ); assert_eq!( - std::fs::read(root.join("local").join("old.txt")) - .expect("old install should be restored"), + std::fs::read(root.join(LOCAL_DIR).join("conflict.txt")) + .expect("conflicting local bytes must be preserved"), + b"conflict" + ); + assert_eq!( + std::fs::read(root.join(INSTALLING_DIR).join("payload.txt")) + .expect("staged update bytes must be preserved"), + b"installed" + ); + assert_eq!( + std::fs::read(root.join(BACKUP_DIR).join("old.txt")) + .expect("old install backup must be preserved"), b"old" ); - assert!(!root.join("local").join("conflict.txt").exists()); - assert!(!root.join(".local.installing").exists()); - assert!(!root.join(".local.backup").exists()); - let intent = read_intent(state.path(), "game").await; - assert_eq!(intent.state, InstallIntentState::None); + assert!(matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Valid(InstallIntent { + state: InstallIntentState::Updating, + .. + }) + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn abort_waits_for_backup_restore_to_finish() { + let temp = TempDir::new("lanspread-install-restore-quiescence"); + let root = temp.game_root(); + write_file(&root.join(BACKUP_DIR).join("old.txt"), b"old"); + mark_owned_dir(&root.join(BACKUP_DIR)); + + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let task_gate = Arc::clone(&gate); + let release_gate = Arc::clone(&gate); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (dropped_tx, dropped_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + + // Releasing on timeout keeps runtime shutdown fail-safe if an assertion + // fails while the in-place filesystem closure is blocked. + let release_thread = thread::spawn(move || { + let _ = release_rx.recv_timeout(Duration::from_secs(2)); + let (is_released, release_wake) = &*release_gate; + let mut is_released = is_released + .lock() + .expect("release gate must not be poisoned"); + *is_released = true; + release_wake.notify_one(); + }); + + let task_root = root.clone(); + let mut task = tokio::spawn(async move { + let _operation_guard = GuardDropSignal(dropped_tx); + restore_backup_with_rename(&task_root, move |source, destination| { + entered_tx + .send(()) + .expect("restore must report reaching its rename"); + + let (is_released, release_wake) = &*task_gate; + let is_released = is_released + .lock() + .expect("restore gate must not be poisoned"); + let _is_released = release_wake + .wait_while(is_released, |is_released| !*is_released) + .expect("restore gate must not be poisoned"); + + fs::rename(source, destination) + }) + }); + + tokio::time::timeout(Duration::from_secs(2), entered_rx) + .await + .expect("backup restore must reach its rename") + .expect("restore task must retain the entry sender"); + task.abort(); + + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut task) + .await + .is_err(), + "aborting must not finish the task while backup restoration is blocked" + ); + assert_eq!(dropped_rx.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert!(!root.join(LOCAL_DIR).exists()); + assert!(root.join(BACKUP_DIR).join("old.txt").is_file()); + + release_tx + .send(()) + .expect("restore release thread must remain available"); + release_thread + .join() + .expect("restore release thread must not panic"); + + let completion = tokio::time::timeout(Duration::from_secs(2), &mut task) + .await + .expect("restore task must finish after its rename is released"); + match completion { + Ok(Ok(())) => {} + Ok(Err(error)) => panic!("backup restore failed unexpectedly: {error}"), + Err(error) if error.is_cancelled() => {} + Err(error) => panic!("restore task failed unexpectedly: {error}"), + } + dropped_rx + .recv_timeout(Duration::from_secs(1)) + .expect("operation guard must drop after backup restoration finishes"); + assert_eq!( + fs::read(root.join(LOCAL_DIR).join("old.txt")) + .expect("restored payload must be readable"), + b"old" + ); + assert!(!root.join(BACKUP_DIR).exists()); } #[tokio::test] @@ -945,21 +1845,26 @@ mod tests { write_file(&root.join("local").join("old.txt"), b"old"); write_file(&launch_settings_applied_path(state.path(), "game"), b""); - update(&root, state.path(), "game", successful_unpacker()) - .await - .expect("update should succeed"); + update( + &root, + state.path(), + "game", + successful_unpacker(), + CancellationToken::new(), + ) + .await + .expect("update should succeed"); assert!(root.join("local").join("payload.txt").is_file()); assert!(!root.join("local").join("old.txt").exists()); assert!(!root.join(".local.installing").exists()); assert!(!root.join(".local.backup").exists()); assert!(!launch_settings_applied_path(state.path(), "game").exists()); - let intent = read_intent(state.path(), "game").await; - assert_eq!(intent.state, InstallIntentState::None); + assert_intent_missing(state.path(), &root, "game"); } - #[tokio::test] - async fn uninstall_removes_only_local_install() { + #[test] + fn uninstall_removes_only_local_install() { let temp = TempDir::new("lanspread-install"); let state = test_state(); let root = temp.game_root(); @@ -967,9 +1872,7 @@ mod tests { write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("local").join("payload.txt"), b"installed"); - uninstall(&root, state.path(), "game") - .await - .expect("uninstall should succeed"); + uninstall(&root, state.path(), "game").expect("uninstall should succeed"); assert!(!root.join("local").exists()); assert!(root.join("game.eti").is_file()); @@ -977,8 +1880,8 @@ mod tests { } #[cfg(unix)] - #[tokio::test] - async fn uninstall_delete_failure_restores_backup() { + #[test] + fn uninstall_delete_failure_preserves_backup_and_active_intent() { use std::os::unix::fs::PermissionsExt; let temp = TempDir::new("lanspread-install"); @@ -992,7 +1895,6 @@ mod tests { .expect("locked dir permissions should be set"); let _err = uninstall(&root, state.path(), "game") - .await .expect_err("uninstall should fail while deleting backup"); for restored_locked_dir in [ @@ -1008,19 +1910,24 @@ mod tests { } } + assert!(!root.join(LOCAL_DIR).exists()); assert_eq!( - std::fs::read(root.join("local").join("old.txt")) - .expect("old install should be restored"), + std::fs::read(root.join(BACKUP_DIR).join("old.txt")) + .expect("old install backup must remain readable"), b"old" ); assert_eq!( - std::fs::read(root.join("local").join("locked").join("payload.txt")) - .expect("locked payload should be restored"), + std::fs::read(root.join(BACKUP_DIR).join("locked").join("payload.txt")) + .expect("locked backup payload must remain readable"), b"locked" ); - assert!(!root.join(".local.backup").exists()); - let intent = read_intent(state.path(), "game").await; - assert_eq!(intent.state, InstallIntentState::None); + assert!(matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Valid(InstallIntent { + state: InstallIntentState::Uninstalling, + .. + }) + )); } #[derive(Clone)] @@ -1072,12 +1979,12 @@ mod tests { expected_local_payload: Some(BACKUP_PAYLOAD), }, RecoveryCase { - name: "updating_commit_landed_with_stale_dirs", + name: "updating_after_backup_before_staging", intent_state: InstallIntentState::Updating, - has_local: true, - has_installing: true, + has_local: false, + has_installing: false, has_backup: true, - expected_local_payload: Some(LOCAL_PAYLOAD), + expected_local_payload: Some(BACKUP_PAYLOAD), }, RecoveryCase { name: "updating_commit_landed_before_backup_cleanup", @@ -1132,9 +2039,11 @@ mod tests { &root.join(INSTALLING_DIR).join("payload.txt"), INSTALLING_PAYLOAD, ); + mark_owned_dir(&root.join(INSTALLING_DIR)); } if case.has_backup { write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD); + mark_owned_dir(&root.join(BACKUP_DIR)); } } @@ -1173,9 +2082,14 @@ mod tests { write_intent( state_dir, "game", - &InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())), + &InstallIntent::new( + &root, + "game", + case.intent_state.clone(), + Some("20250101".into()), + ) + .expect("intent root should resolve"), ) - .await .unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name)); recover_game_root(&root, state_dir, "game") @@ -1183,14 +2097,7 @@ mod tests { .unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name)); assert_recovered_case(&root, &case); - let intent = read_intent(state_dir, "game").await; - assert_eq!(intent.state, InstallIntentState::None, "{}", case.name); - assert_eq!( - intent.eti_version.as_deref(), - Some("20250101"), - "{}", - case.name - ); + assert_intent_missing(state_dir, &root, "game"); } #[tokio::test] @@ -1220,14 +2127,15 @@ mod tests { write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD); if has_backup { write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD); + mark_owned_dir(&root.join(BACKUP_DIR)); } write_file(&launch_settings_applied_path(state.path(), id), b""); write_intent( state.path(), id, - &InstallIntent::new(id, intent_state, Some("20250101".into())), + &InstallIntent::new(&root, id, intent_state, Some("20250101".into())) + .expect("intent root should resolve"), ) - .await .expect("intent should be written"); recover_game_root(&root, state.path(), id) @@ -1238,8 +2146,7 @@ mod tests { !launch_settings_applied_path(state.path(), id).exists(), "{id} marker should be reset" ); - let intent = read_intent(state.path(), id).await; - assert_eq!(intent.state, InstallIntentState::None, "{id}"); + assert_intent_missing(state.path(), &root, id); } } @@ -1253,18 +2160,21 @@ mod tests { &root.join(INSTALLING_DIR).join("payload.txt"), INSTALLING_PAYLOAD, ); + mark_owned_dir(&root.join(INSTALLING_DIR)); write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD); + mark_owned_dir(&root.join(BACKUP_DIR)); write_file(&launch_settings_applied_path(state.path(), "game"), b""); write_intent( state.path(), "game", &InstallIntent::new( + &root, "game", InstallIntentState::Updating, Some("20250101".into()), - ), + ) + .expect("intent root should resolve"), ) - .await .expect("intent should be written"); recover_game_root(&root, state.path(), "game") @@ -1280,19 +2190,241 @@ mod tests { } #[tokio::test] - async fn none_recovery_leaves_markerless_reserved_dirs_untouched() { + async fn missing_intent_quarantines_markerless_reserved_dirs_without_mutation() { let temp = TempDir::new("lanspread-install"); let state = test_state(); let root = temp.game_root(); write_file(&root.join(".local.backup").join("user.txt"), b"user"); - recover_game_root(&root, state.path(), "game") + let error = recover_game_root(&root, state.path(), "game") .await - .expect("recovery should succeed"); + .expect_err("markerless reserved bytes must fail closed"); + assert!(error.to_string().contains("markerless"), "{error:?}"); assert!(root.join(".local.backup").join("user.txt").is_file()); } + #[tokio::test] + async fn root_binding_mismatch_quarantines_without_any_mutation_or_overwrite() { + let old_games = TempDir::new("lanspread-install-old-games"); + let new_games = TempDir::new("lanspread-install-new-games"); + let state = test_state(); + let old_root = old_games.path().join("game"); + let new_root = new_games.path().join("game"); + write_file(&old_root.join(LOCAL_DIR).join("old.txt"), b"old-root"); + write_file(&new_root.join("canary.txt"), b"new-root"); + write_file(&new_root.join(VERSION_TMP_FILE), b"download-recovery-state"); + let old_intent = InstallIntent::new( + &old_root, + "game", + InstallIntentState::Updating, + Some("20250101".into()), + ) + .expect("old intent root should resolve"); + write_intent(state.path(), "game", &old_intent).expect("old intent should be written"); + + let recovery_error = recover_game_root(&new_root, state.path(), "game") + .await + .expect_err("foreign intent must quarantine the new root"); + assert!( + recovery_error.to_string().contains("different configured"), + "{recovery_error:?}" + ); + let begin_error = begin_streamed_install(&new_root, state.path(), "game") + .err() + .expect("foreign intent must block a new transaction"); + assert!( + begin_error.to_string().contains("another games directory"), + "{begin_error:?}" + ); + + assert_eq!( + std::fs::read(new_root.join("canary.txt")).expect("new-root canary should remain"), + b"new-root" + ); + assert_eq!( + std::fs::read(new_root.join(VERSION_TMP_FILE)) + .expect("download recovery state must not be swept"), + b"download-recovery-state" + ); + assert!(!new_root.join(INSTALLING_DIR).exists()); + assert!(matches!( + read_intent(state.path(), &old_root, "game"), + LoadedInstallIntent::Valid(InstallIntent { + state: InstallIntentState::Updating, + .. + }) + )); + } + + #[tokio::test] + async fn settled_intent_unlink_allows_a_normal_root_switch() { + let old_games = TempDir::new("lanspread-install-old-games"); + let new_games = TempDir::new("lanspread-install-new-games"); + let state = test_state(); + let old_root = old_games.path().join("game"); + let new_root = new_games.path().join("game"); + write_file(&old_root.join("game.eti"), b"archive"); + + install( + &old_root, + state.path(), + "game", + successful_unpacker(), + CancellationToken::new(), + ) + .await + .expect("old-root install should settle"); + assert_intent_missing(state.path(), &old_root, "game"); + + let transaction = begin_streamed_install(&new_root, state.path(), "game") + .expect("new root should not inherit a settled old-root intent"); + transaction + .rollback() + .expect("new-root transaction should settle"); + assert_intent_missing(state.path(), &new_root, "game"); + } + + #[tokio::test] + async fn corrupt_intent_quarantines_without_sweeping_any_state() { + let temp = TempDir::new("lanspread-install"); + let state = test_state(); + let root = temp.game_root(); + write_file(&root.join(INSTALLING_DIR).join("user.txt"), b"user"); + mark_owned_dir(&root.join(INSTALLING_DIR)); + write_file(&root.join(VERSION_TMP_FILE), b"download-recovery-state"); + write_file( + &crate::install::intent::intent_path(state.path(), "game"), + b"not json", + ); + + let error = recover_game_root(&root, state.path(), "game") + .await + .expect_err("corrupt intent must fail closed"); + assert!(error.to_string().contains("invalid"), "{error:?}"); + assert_eq!( + std::fs::read(root.join(INSTALLING_DIR).join("user.txt")) + .expect("reserved bytes must remain"), + b"user" + ); + assert_eq!( + std::fs::read(root.join(VERSION_TMP_FILE)) + .expect("download recovery state must remain"), + b"download-recovery-state" + ); + assert!(matches!( + read_intent(state.path(), &root, "game"), + LoadedInstallIntent::Invalid(_) + )); + } + + #[tokio::test] + async fn markerless_reserved_state_blocks_every_operation_before_intent_publication() { + let games = TempDir::new("lanspread-install-games"); + let state = test_state(); + + let install_root = games.path().join("install-game"); + write_file(&install_root.join("game.eti"), b"archive"); + write_file( + &install_root.join(INSTALLING_DIR).join("user.txt"), + b"install-user", + ); + let install_error = install( + &install_root, + state.path(), + "install-game", + successful_unpacker(), + CancellationToken::new(), + ) + .await + .expect_err("install must reject markerless staging"); + assert!( + install_error.to_string().contains("markerless"), + "{install_error:?}" + ); + + let update_root = games.path().join("update-game"); + write_file(&update_root.join("game.eti"), b"archive"); + write_file(&update_root.join(LOCAL_DIR).join("old.txt"), b"old"); + write_file( + &update_root.join(BACKUP_DIR).join("user.txt"), + b"update-user", + ); + let update_error = update( + &update_root, + state.path(), + "update-game", + successful_unpacker(), + CancellationToken::new(), + ) + .await + .expect_err("update must reject markerless backup"); + assert!( + update_error.to_string().contains("markerless"), + "{update_error:?}" + ); + + let uninstall_root = games.path().join("uninstall-game"); + write_file(&uninstall_root.join(LOCAL_DIR).join("old.txt"), b"old"); + write_file( + &uninstall_root.join(INSTALLING_DIR).join("user.txt"), + b"uninstall-user", + ); + let uninstall_error = uninstall(&uninstall_root, state.path(), "uninstall-game") + .expect_err("uninstall must reject markerless staging"); + assert!( + uninstall_error.to_string().contains("markerless"), + "{uninstall_error:?}" + ); + + for (root, reserved, expected, id) in [ + ( + &install_root, + INSTALLING_DIR, + b"install-user".as_slice(), + "install-game", + ), + ( + &update_root, + BACKUP_DIR, + b"update-user".as_slice(), + "update-game", + ), + ( + &uninstall_root, + INSTALLING_DIR, + b"uninstall-user".as_slice(), + "uninstall-game", + ), + ] { + assert_eq!( + std::fs::read(root.join(reserved).join("user.txt")) + .expect("markerless bytes must remain"), + expected + ); + assert_intent_missing(state.path(), root, id); + } + assert!(update_root.join(LOCAL_DIR).join("old.txt").is_file()); + assert!(uninstall_root.join(LOCAL_DIR).join("old.txt").is_file()); + } + + #[test] + fn markerless_backup_is_never_promoted_by_restore() { + let temp = TempDir::new("lanspread-install"); + let root = temp.game_root(); + write_file(&root.join(BACKUP_DIR).join("user.txt"), b"user"); + + let error = + restore_owned_backup(&root).expect_err("markerless backup must not be promoted"); + assert!(error.to_string().contains("markerless"), "{error:?}"); + assert!(!root.join(LOCAL_DIR).exists()); + assert_eq!( + std::fs::read(root.join(BACKUP_DIR).join("user.txt")) + .expect("markerless backup must remain"), + b"user" + ); + } + #[tokio::test] async fn download_recovery_sweeps_reserved_version_files() { let temp = TempDir::new("lanspread-install"); @@ -1329,4 +2461,122 @@ mod tests { assert!(active_root.join(VERSION_TMP_FILE).is_file()); assert!(!inactive_root.join(VERSION_TMP_FILE).exists()); } + + #[tokio::test] + async fn startup_recovery_settles_active_intent_for_absent_game_root() { + let games = TempDir::new("lanspread-install-recovery-absent-root"); + let state = test_state(); + let root = games.path().join("absent"); + let intent = InstallIntent::new( + &root, + "absent", + InstallIntentState::Installing, + Some("20250101".to_owned()), + ) + .expect("intent root should resolve"); + write_intent(state.path(), "absent", &intent).expect("intent should be written"); + assert!(!root.exists()); + + let report = recover_on_startup(games.path(), state.path(), &HashSet::new()) + .await + .expect("state-only installing recovery should succeed"); + + assert!(report.failures().is_empty()); + assert!(!root.exists()); + assert_intent_missing(state.path(), &root, "absent"); + } + + #[tokio::test] + async fn startup_recovery_discovers_pending_ownership_for_an_absent_game_root() { + let games = TempDir::new("lanspread-download-recovery-absent-root"); + let state = test_state(); + seed_pending_download_ownership_for_test( + state.path(), + games.path(), + "absent", + &["old.eti"], + &["pending.eti"], + ) + .await; + assert!(!games.path().join("absent").exists()); + + let report = recover_on_startup(games.path(), state.path(), &HashSet::new()) + .await + .expect("ownership-only recovery should be discovered"); + + assert!(report.failures().is_empty()); + assert_eq!( + download_ownership_readiness(games.path(), state.path(), "absent").await, + DownloadOwnershipReadiness::Settled + ); + } + + #[tokio::test] + async fn startup_recovery_continues_after_one_game_fails() { + let temp = TempDir::new("lanspread-install-recovery-continue"); + let state = test_state(); + let broken_root = temp.path().join("broken"); + let healthy_root = temp.path().join("healthy"); + std::fs::create_dir_all(broken_root.join(VERSION_TMP_FILE)) + .expect("invalid scratch directory should be created"); + write_file(&healthy_root.join(VERSION_TMP_FILE), b"tmp"); + + let report = recover_on_startup(temp.path(), state.path(), &HashSet::new()) + .await + .expect("per-game recovery failures should be reported structurally"); + + assert_eq!(report.failed_ids(), HashSet::from(["broken".to_string()])); + assert!(!report.failures()["broken"].is_empty()); + assert!(!healthy_root.join(VERSION_TMP_FILE).exists()); + } + + #[tokio::test] + async fn startup_recovery_reports_non_directory_game_root_shape() { + let temp = TempDir::new("lanspread-install-recovery-file-root"); + let state = test_state(); + let root_file = temp.path().join("game"); + write_file(&root_file, b"user data"); + + let report = recover_on_startup(temp.path(), state.path(), &HashSet::new()) + .await + .expect("unsafe per-game shapes should be reported structurally"); + + assert_eq!(report.failed_ids(), HashSet::from(["game".to_string()])); + assert!(report.failures()["game"].contains("unsafe direct game root")); + assert_eq!( + std::fs::read(&root_file).expect("non-directory root should remain untouched"), + b"user data" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn startup_recovery_quarantines_symlink_root_without_outside_mutation() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-install-recovery-symlink-games"); + let outside = TempDir::new("lanspread-install-recovery-symlink-outside"); + let state = test_state(); + write_file(&outside.path().join(VERSION_TMP_FILE), b"outside scratch"); + write_file(&outside.path().join("canary.txt"), b"outside"); + symlink(outside.path(), games.path().join("game")) + .expect("game-root symlink should be created"); + + let report = recover_on_startup(games.path(), state.path(), &HashSet::new()) + .await + .expect("unsafe per-game shapes should be reported structurally"); + + assert_eq!(report.failed_ids(), HashSet::from(["game".to_string()])); + assert!(report.failures()["game"].contains("unsafe direct game root")); + assert_eq!( + std::fs::read(outside.path().join(VERSION_TMP_FILE)) + .expect("outside scratch should remain readable"), + b"outside scratch" + ); + assert_eq!( + std::fs::read(outside.path().join("canary.txt")) + .expect("canary should remain readable"), + b"outside" + ); + } } diff --git a/crates/lanspread-peer/src/install/unpack.rs b/crates/lanspread-peer/src/install/unpack.rs index 44ee67e..48bbff0 100644 --- a/crates/lanspread-peer/src/install/unpack.rs +++ b/crates/lanspread-peer/src/install/unpack.rs @@ -1,5 +1,7 @@ use std::{future::Future, path::Path, pin::Pin}; +use tokio_util::sync::CancellationToken; + /// Boxed future returned by an injected game archive unpacker. pub type UnpackFuture<'a> = Pin> + Send + 'a>>; @@ -8,5 +10,17 @@ pub type UnpackFuture<'a> = Pin> + Send /// The peer crate owns the install transaction while the shell-specific unrar /// integration stays in the Tauri crate through this injected trait. pub trait Unpacker: Send + Sync { - fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a>; + /// Extracts `archive` and leaves no child work behind when it returns. + /// + /// Implementations must observe `cancel_token` promptly. Cancellation and + /// every error path must terminate and reap child processes before the + /// returned future resolves. Dropping the future must at least kill any + /// lexically owned child so an unexpected operation-task exit cannot orphan + /// filesystem mutation. + fn unpack<'a>( + &'a self, + archive: &'a Path, + dest: &'a Path, + cancel_token: CancellationToken, + ) -> UnpackFuture<'a>; } diff --git a/crates/lanspread-peer/src/launch_settings.rs b/crates/lanspread-peer/src/launch_settings.rs index 07e1f2f..338d866 100644 --- a/crates/lanspread-peer/src/launch_settings.rs +++ b/crates/lanspread-peer/src/launch_settings.rs @@ -1,11 +1,12 @@ -//! One-shot launcher-setting application performed the first time a game is played. +//! Launcher-setting rewrites for installed and streamed-install game trees. //! //! Some games ship per-user setting files somewhere under their installed //! `local/` tree — an `account_name.txt`, a `language.txt`, and/or a -//! `SmartSteamEmu.ini` carrying a `PersonaName = ...` line. The first time the -//! user launches a game we stamp the launcher's configured username and language -//! into whichever of those files exist, then record a per-game marker so the -//! step never runs again. +//! `SmartSteamEmu.ini` carrying a `PersonaName = ...` line. Streamed installs +//! stamp sanitized values into catalog-verified staging before promotion. +//! Already-installed games retain the one-shot first-play path: it stamps the +//! launcher's configured username and language into whichever files exist, then +//! records a per-game marker so the step never runs again. //! //! The marker only records that we *tried*: it is written unconditionally after //! the first attempt, whether or not any file or matching line was found. Moving @@ -20,12 +21,92 @@ use std::{ use eyre::WrapErr; -use crate::{game_paths::LOCAL_DIR, state_paths::launch_settings_applied_path}; +use crate::{ + game_paths::LOCAL_DIR, + scoped_blocking::scoped_blocking, + state_paths::launch_settings_applied_path, +}; const ACCOUNT_NAME_FILE: &str = "account_name.txt"; const LANGUAGE_FILE: &str = "language.txt"; const SMART_STEAM_EMU_INI: &str = "SmartSteamEmu.ini"; const PERSONA_NAME_KEY: &str = "PersonaName"; +const DEFAULT_STREAM_INSTALL_NAME: &str = "Commander"; +const DEFAULT_STREAM_INSTALL_LANGUAGE: &str = "english"; +const MAX_STREAM_INSTALL_NAME_CHARS: usize = 24; + +/// Sanitized per-user values applied inside a verified Stream Install staging tree. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StreamInstallSettings { + account_name: String, + language: String, + persona_name: String, +} + +impl StreamInstallSettings { + /// Normalize values received at a UI or harness boundary exactly once. + #[must_use] + pub fn sanitized( + account_name: Option<&str>, + language: Option<&str>, + persona_name: Option<&str>, + ) -> Self { + Self { + account_name: sanitize_stream_install_name(account_name), + language: sanitize_stream_install_language(language), + persona_name: sanitize_stream_install_name(persona_name), + } + } + + #[must_use] + pub fn account_name(&self) -> &str { + &self.account_name + } + + #[must_use] + pub fn language(&self) -> &str { + &self.language + } + + #[must_use] + pub fn persona_name(&self) -> &str { + &self.persona_name + } +} + +impl Default for StreamInstallSettings { + fn default() -> Self { + Self::sanitized(None, None, None) + } +} + +fn sanitize_stream_install_name(value: Option<&str>) -> String { + let cleaned = value + .unwrap_or_default() + .trim() + .chars() + .filter(|character| !character.is_control() && *character != '"' && *character != '%') + .take(MAX_STREAM_INSTALL_NAME_CHARS) + .collect::(); + if cleaned.is_empty() { + DEFAULT_STREAM_INSTALL_NAME.to_string() + } else { + cleaned + } +} + +fn sanitize_stream_install_language(value: Option<&str>) -> String { + match value + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "de" | "german" => "german".to_string(), + "en" | "english" => "english".to_string(), + _ => DEFAULT_STREAM_INSTALL_LANGUAGE.to_string(), + } +} /// What the one-shot launcher-setting step did for a game. /// @@ -53,7 +134,48 @@ pub struct LaunchSettingsOutcome { /// line's existing line ending) and `language` into the first `language.txt`, /// and — whatever was or was not found — records the marker so the step never /// runs again for this game. -pub async fn apply_launch_settings_once( +pub fn apply_launch_settings_once( + state_dir: &Path, + game_root: &Path, + game_id: &str, + account_name: Option<&str>, + language: Option<&str>, +) -> eyre::Result { + scoped_blocking(|| { + apply_launch_settings_once_blocking(state_dir, game_root, game_id, account_name, language) + }) +} + +/// Apply launcher settings directly to a catalog-verified `local/` tree. +/// +/// Streamed installs call this with their staging directory after the receiver +/// has verified every entry against the exact catalog manifest and before the +/// transaction promotes that directory to `local/`. The rewrite intentionally +/// changes catalog-verified file bytes, so callers must not use this helper on +/// an unverified tree. It does not write the one-shot launch marker: a staging +/// transaction that is later rolled back must not affect installed-game state. +pub fn apply_launch_settings_to_verified_tree( + local_root: &Path, + account_name: Option<&str>, + language: Option<&str>, + persona_name: Option<&str>, +) -> eyre::Result { + scoped_blocking(|| { + apply_launch_settings_to_tree_blocking(local_root, account_name, language, persona_name) + }) +} + +/// Record that a successfully promoted Stream Install already received its +/// launch-settings rewrite. +/// +/// Callers must invoke this only after promotion succeeds. A marker failure is +/// recoverable: leaving it absent makes the existing first-play path retry the +/// rewrite. +pub fn mark_launch_settings_applied(state_dir: &Path, game_id: &str) -> eyre::Result<()> { + scoped_blocking(|| mark_applied(&launch_settings_applied_path(state_dir, game_id))) +} + +fn apply_launch_settings_once_blocking( state_dir: &Path, game_root: &Path, game_id: &str, @@ -61,64 +183,68 @@ pub async fn apply_launch_settings_once( language: Option<&str>, ) -> eyre::Result { let marker = launch_settings_applied_path(state_dir, game_id); - if tokio::fs::try_exists(&marker).await.unwrap_or(false) { + if marker.try_exists().unwrap_or(false) { return Ok(LaunchSettingsOutcome { already_applied: true, ..LaunchSettingsOutcome::default() }); } - let local_root = game_root.join(LOCAL_DIR); - let outcome = LaunchSettingsOutcome { - already_applied: false, - account_name_written: overwrite_first_file(&local_root, ACCOUNT_NAME_FILE, account_name) - .await?, - language_written: overwrite_first_file(&local_root, LANGUAGE_FILE, language).await?, - persona_name_written: rewrite_first_persona_name(&local_root, account_name).await?, - }; + let outcome = apply_launch_settings_to_tree_blocking( + &game_root.join(LOCAL_DIR), + account_name, + language, + account_name, + )?; - mark_applied(&marker).await?; + mark_applied(&marker)?; Ok(outcome) } +fn apply_launch_settings_to_tree_blocking( + local_root: &Path, + account_name: Option<&str>, + language: Option<&str>, + persona_name: Option<&str>, +) -> eyre::Result { + Ok(LaunchSettingsOutcome { + already_applied: false, + account_name_written: overwrite_first_file(local_root, ACCOUNT_NAME_FILE, account_name)?, + language_written: overwrite_first_file(local_root, LANGUAGE_FILE, language)?, + persona_name_written: rewrite_first_persona_name(local_root, persona_name)?, + }) +} + /// Overwrite the first file named `file_name` under `root` with `value`. /// /// Returns `false` without touching anything when `value` is `None` or no such /// file exists. -async fn overwrite_first_file( - root: &Path, - file_name: &str, - value: Option<&str>, -) -> eyre::Result { +fn overwrite_first_file(root: &Path, file_name: &str, value: Option<&str>) -> eyre::Result { let Some(value) = value else { return Ok(false); }; - let Some(path) = find_first_file(root, file_name).await? else { + let Some(path) = find_first_file(root, file_name)? else { return Ok(false); }; - tokio::fs::write(&path, value) - .await - .wrap_err_with(|| format!("failed to write {}", path.display()))?; + std::fs::write(&path, value).wrap_err_with(|| format!("failed to write {}", path.display()))?; Ok(true) } /// Rewrite the first `PersonaName` line found in any `SmartSteamEmu.ini` under `root`. -async fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result { +fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result { let Some(persona_name) = persona_name else { return Ok(false); }; - for path in find_files(root, SMART_STEAM_EMU_INI).await? { - let content = tokio::fs::read_to_string(&path) - .await + for path in find_files(root, SMART_STEAM_EMU_INI)? { + let content = std::fs::read_to_string(&path) .wrap_err_with(|| format!("failed to read {}", path.display()))?; let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else { continue; }; - tokio::fs::write(&path, rewritten) - .await + std::fs::write(&path, rewritten) .wrap_err_with(|| format!("failed to write {}", path.display()))?; return Ok(true); } @@ -130,10 +256,10 @@ async fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> /// /// A missing `root` (for example an uninstalled game with no `local/`) yields /// `None`. Directories are visited in sorted order for deterministic results. -async fn find_first_file(root: &Path, file_name: &str) -> eyre::Result> { +fn find_first_file(root: &Path, file_name: &str) -> eyre::Result> { let mut pending_dirs = vec![root.to_path_buf()]; while let Some(dir) = pending_dirs.pop() { - let mut entries = match tokio::fs::read_dir(&dir).await { + let entries = match std::fs::read_dir(&dir) { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => continue, Err(err) => { @@ -142,8 +268,9 @@ async fn find_first_file(root: &Path, file_name: &str) -> eyre::Result eyre::Result eyre::Result> { +fn find_files(root: &Path, file_name: &str) -> eyre::Result> { let mut matches = Vec::new(); let mut pending_dirs = vec![root.to_path_buf()]; while let Some(dir) = pending_dirs.pop() { - let mut entries = match tokio::fs::read_dir(&dir).await { + let entries = match std::fs::read_dir(&dir) { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => continue, Err(err) => { @@ -177,8 +304,9 @@ async fn find_files(root: &Path, file_name: &str) -> eyre::Result> }; let mut child_dirs = Vec::new(); - while let Some(entry) = entries.next_entry().await? { - let file_type = entry.file_type().await?; + for entry in entries { + let entry = entry?; + let file_type = entry.file_type()?; let path = entry.path(); if file_type.is_dir() { child_dirs.push(path); @@ -252,15 +380,12 @@ fn rewrite_persona_line(line: &str, persona_name: &str) -> Option { )) } -async fn mark_applied(marker: &Path) -> eyre::Result<()> { +fn mark_applied(marker: &Path) -> eyre::Result<()> { if let Some(parent) = marker.parent() { - tokio::fs::create_dir_all(parent) - .await + std::fs::create_dir_all(parent) .wrap_err_with(|| format!("failed to create {}", parent.display()))?; } - tokio::fs::write(marker, []) - .await - .wrap_err_with(|| format!("failed to write {}", marker.display()))?; + std::fs::write(marker, []).wrap_err_with(|| format!("failed to write {}", marker.display()))?; Ok(()) } @@ -269,6 +394,27 @@ mod tests { use super::*; use crate::test_support::TempDir; + #[test] + fn stream_install_settings_are_sanitized_once_at_construction() { + let settings = StreamInstallSettings::sanitized( + Some(" Alice \"Ace\"%PATH%\n "), + Some("DE"), + Some(" Player \"One\"%TEMP%\r "), + ); + + assert_eq!(settings.account_name(), "Alice AcePATH"); + assert_eq!(settings.language(), "german"); + assert_eq!(settings.persona_name(), "Player OneTEMP"); + assert_eq!( + StreamInstallSettings::default(), + StreamInstallSettings::sanitized( + Some(DEFAULT_STREAM_INSTALL_NAME), + Some(DEFAULT_STREAM_INSTALL_LANGUAGE), + Some(DEFAULT_STREAM_INSTALL_NAME), + ) + ); + } + #[test] fn rewrites_simple_line_and_preserves_unix_ending() { let content = "[User]\nPersonaName = stubname\nLanguage = english\n"; @@ -320,8 +466,8 @@ mod tests { assert!(rewrite_persona_name_content(content, "realuser").is_none()); } - #[tokio::test] - async fn applies_username_to_both_files_then_marks_done() { + #[test] + fn applies_username_to_both_files_then_marks_done() { let state = TempDir::new("lanspread-launch-state"); let game = TempDir::new("lanspread-launch-game"); let root = game.path(); @@ -343,7 +489,6 @@ mod tests { Some("realuser"), Some("german"), ) - .await .expect("apply should succeed"); assert_eq!( @@ -370,8 +515,73 @@ mod tests { assert!(launch_settings_applied_path(state.path(), "game").is_file()); } - #[tokio::test] - async fn is_noop_once_marker_exists() { + #[test] + fn applies_distinct_settings_to_verified_tree_without_marking() { + let state = TempDir::new("lanspread-launch-state"); + let staging = TempDir::new("lanspread-launch-staging"); + let account = staging.path().join("profile").join(ACCOUNT_NAME_FILE); + let ini = staging.path().join("config").join(SMART_STEAM_EMU_INI); + let language = staging.path().join(LANGUAGE_FILE); + write_file(&account, b"stub-account"); + write_file(&ini, b"[User]\nPersonaName = stub-persona\n"); + write_file(&language, b"english"); + + let outcome = apply_launch_settings_to_verified_tree( + staging.path(), + Some("real-account"), + Some("german"), + Some("real-persona"), + ) + .expect("verified staging rewrite should succeed"); + + assert_eq!( + outcome, + LaunchSettingsOutcome { + already_applied: false, + account_name_written: true, + language_written: true, + persona_name_written: true, + } + ); + assert_eq!( + std::fs::read_to_string(&account).expect("account file should be readable"), + "real-account" + ); + assert_eq!( + std::fs::read_to_string(&ini).expect("ini should be readable"), + "[User]\nPersonaName = real-persona\n" + ); + assert_eq!( + std::fs::read_to_string(&language).expect("language file should be readable"), + "german" + ); + assert!( + !launch_settings_applied_path(state.path(), "game") + .try_exists() + .expect("marker lookup should succeed") + ); + } + + #[test] + fn successful_promotion_can_mark_staged_settings_as_applied() { + let state = TempDir::new("lanspread-launch-state"); + let marker = launch_settings_applied_path(state.path(), "game"); + assert!( + !marker + .try_exists() + .expect("initial marker lookup should succeed") + ); + + mark_launch_settings_applied(state.path(), "game") + .expect("post-promotion marker should be written"); + mark_launch_settings_applied(state.path(), "game") + .expect("post-promotion marker should be idempotent"); + + assert!(marker.is_file()); + } + + #[test] + fn is_noop_once_marker_exists() { let state = TempDir::new("lanspread-launch-state"); let game = TempDir::new("lanspread-launch-game"); let root = game.path(); @@ -379,7 +589,6 @@ mod tests { write_file(&ini, b"PersonaName = stubname\n"); let first = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None) - .await .expect("first apply should succeed"); assert!(first.persona_name_written); assert!(!first.already_applied); @@ -387,7 +596,6 @@ mod tests { // Externally reset the value; a second apply must not touch it again. write_file(&ini, b"PersonaName = stubname\n"); let second = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None) - .await .expect("second apply should succeed"); assert!(second.already_applied); @@ -398,8 +606,8 @@ mod tests { ); } - #[tokio::test] - async fn marks_done_even_when_nothing_found() { + #[test] + fn marks_done_even_when_nothing_found() { let state = TempDir::new("lanspread-launch-state"); let game = TempDir::new("lanspread-launch-game"); let root = game.path(); @@ -415,15 +623,14 @@ mod tests { Some("realuser"), Some("german"), ) - .await .expect("apply should succeed"); assert_eq!(outcome, LaunchSettingsOutcome::default()); assert!(launch_settings_applied_path(state.path(), "game").is_file()); } - #[tokio::test] - async fn marks_done_when_local_missing() { + #[test] + fn marks_done_when_local_missing() { let state = TempDir::new("lanspread-launch-state"); let game = TempDir::new("lanspread-launch-game"); @@ -434,15 +641,14 @@ mod tests { Some("realuser"), Some("german"), ) - .await .expect("apply should succeed"); assert_eq!(outcome, LaunchSettingsOutcome::default()); assert!(launch_settings_applied_path(state.path(), "game").is_file()); } - #[tokio::test] - async fn overwrites_first_account_file_in_sorted_order() { + #[test] + fn overwrites_first_account_file_in_sorted_order() { let state = TempDir::new("lanspread-launch-state"); let game = TempDir::new("lanspread-launch-game"); let root = game.path(); @@ -452,7 +658,6 @@ mod tests { write_file(&second, b"old-z"); apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None) - .await .expect("apply should succeed"); assert_eq!( @@ -465,8 +670,8 @@ mod tests { ); } - #[tokio::test] - async fn searches_past_ini_files_without_persona_name() { + #[test] + fn searches_past_ini_files_without_persona_name() { let state = TempDir::new("lanspread-launch-state"); let game = TempDir::new("lanspread-launch-game"); let root = game.path(); @@ -477,7 +682,6 @@ mod tests { let outcome = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None) - .await .expect("apply should succeed"); assert!(outcome.persona_name_written); diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 8d1747f..db6c216 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -14,6 +14,7 @@ mod call_to_play; mod config; +mod content_quarantine; mod context; mod download; mod error; @@ -27,52 +28,82 @@ mod library; mod local_games; mod migration; mod network; +mod network_generation; mod path_validation; mod peer; mod peer_db; -mod remote_peer; +mod quic_runtime; +mod recovery_quarantine; +mod scoped_blocking; +mod scoped_process; mod services; mod startup; mod state_paths; mod stream_install; #[cfg(test)] mod test_support; +mod tls; +#[cfg(test)] +mod tls_identity_spike; +mod transfer_status; // ============================================================================= // Public re-exports // ============================================================================= -use std::{net::SocketAddr, path::PathBuf, sync::Arc}; +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, + sync::Arc, +}; -pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT}; +pub use config::CHUNK_SIZE; pub use error::PeerError; +pub use identity::{ + LoadedPeerIdentity, + PeerIdentity, + PeerIdentityPersistence, + PeerIdentityPersistenceFailure, + PeerIdentityPersistenceFailureKind, + load_peer_identity, +}; pub use install::{UnpackFuture, Unpacker}; -use lanspread_db::db::{Game, GameCatalog, GameFileDescription}; -pub use lanspread_proto::{CallToPlayAction, CallToPlayEvent}; +use lanspread_db::{ + content_manifest::{CanonicalCatalogPath, CatalogBundle, ContentId}, + db::Game, +}; +pub use lanspread_proto::{CallId, CallToPlayAction, EventNonce, PeerEndpoint}; pub use migration::{MigrationReport, migrate_legacy_state}; pub use peer_db::{ - MajorityValidationResult, + PeerEndpointGeneration, PeerGameDB, PeerId, PeerInfo, + PeerLivenessSnapshot, PeerSnapshot, PeerUpsert, }; +pub use scoped_blocking::scoped_blocking; +pub use scoped_process::{ScopedProcess, ScopedProcessOutput}; use tokio::sync::{ RwLock, mpsc::{UnboundedReceiver, UnboundedSender}, oneshot, }; use tokio_util::{sync::CancellationToken, task::TaskTracker}; +pub use transfer_status::{ + DownloadAttemptId, + DownloadAttemptKey, + DownloadFailureReason, + DownloadVerificationActivity, +}; use crate::{ context::Ctx, handlers::{ - GameDetailSource, handle_cancel_download_command, handle_connect_peer_command, handle_download_game_files_command, - handle_get_game_command, handle_get_peer_count_command, handle_install_game_command, handle_list_games_command, @@ -81,11 +112,18 @@ use crate::{ handle_uninstall_game_command, load_local_library, }, + network_generation::NetworkManager, state_paths::resolve_state_dir, }; pub use crate::{ - context::OutboundTransfers, - launch_settings::{LaunchSettingsOutcome, apply_launch_settings_once}, + context::{OutboundTransferChange, OutboundTransfers}, + launch_settings::{ + LaunchSettingsOutcome, + StreamInstallSettings, + apply_launch_settings_once, + apply_launch_settings_to_verified_tree, + mark_launch_settings_applied, + }, startup::PeerRuntimeHandle, state_paths::{launch_settings_applied_path, setup_done_path}, stream_install::{ @@ -101,36 +139,204 @@ pub use crate::{ // Public API types // ============================================================================= +/// One user-authored Call-to-Play mutation request. +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CallToPlayLocalIntent { + /// Existing call to mutate, or `None` when creating a new call. + pub call_id: Option, + pub action: CallToPlayLocalAction, +} + +/// User-controlled Call-to-Play action fields. Identity, nonces, and time are core-owned. +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub enum CallToPlayLocalAction { + Create { + game_id: String, + max_players: u16, + scheduled_for: Option, + deadline: i64, + }, + Respond { + ready_at: Option, + }, + Rsvp, + SendMessage { + text: String, + }, + Leave, + Cancel, + Start, + AddTime { + deadline: i64, + }, +} + +/// Core-generated identity and revision for an accepted local intent. +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CallToPlayReceipt { + pub call_id: CallId, + pub event_id: EventNonce, + pub revision: u64, +} + +/// Complete Call-to-Play projection. Every delivery replaces the previous projection. +#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CallToPlayView { + pub events: Vec, +} + +/// One author-attributed event in a complete Call-to-Play projection. +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CallToPlayViewEvent { + pub id: EventNonce, + pub call_id: CallId, + pub author_id: PeerId, + pub author_name: String, + pub at: i64, + pub action: CallToPlayAction, +} + +/// Exact remotely available catalog content and authenticated source count. +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RemoteGameAvailability { + pub game_id: String, + pub content_id: ContentId, + pub peer_count: u32, +} + +/// Pure remote availability projection, independent of UI catalog metadata. +#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RemoteLibraryView { + pub games: Vec, +} + +impl From for call_to_play::CallToPlayLocalIntent { + fn from(intent: CallToPlayLocalIntent) -> Self { + Self { + call_id: intent.call_id, + action: intent.action.into(), + } + } +} + +impl From for call_to_play::CallToPlayLocalAction { + fn from(action: CallToPlayLocalAction) -> Self { + match action { + CallToPlayLocalAction::Create { + game_id, + max_players, + scheduled_for, + deadline, + } => Self::Create { + game_id, + max_players, + scheduled_for, + deadline, + }, + CallToPlayLocalAction::Respond { ready_at } => Self::Respond { ready_at }, + CallToPlayLocalAction::Rsvp => Self::Rsvp, + CallToPlayLocalAction::SendMessage { text } => Self::SendMessage { text }, + CallToPlayLocalAction::Leave => Self::Leave, + CallToPlayLocalAction::Cancel => Self::Cancel, + CallToPlayLocalAction::Start => Self::Start, + CallToPlayLocalAction::AddTime { deadline } => Self::AddTime { deadline }, + } + } +} + +impl From for CallToPlayReceipt { + fn from(receipt: call_to_play::CallToPlayReceipt) -> Self { + Self { + call_id: receipt.call_id, + event_id: receipt.event_id, + revision: receipt.revision, + } + } +} + +impl From for CallToPlayView { + fn from(view: call_to_play::CallToPlayView) -> Self { + Self { + events: view.events.into_iter().map(Into::into).collect(), + } + } +} + +impl From for CallToPlayViewEvent { + fn from(event: call_to_play::CallToPlayViewEvent) -> Self { + Self { + id: event.id, + call_id: event.call_id, + author_id: event.author_id, + author_name: event.author_name, + at: event.at, + action: event.action, + } + } +} + +/// Lifecycle state of the process-wide Local network sharing policy. +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +pub enum LocalNetworkSharingState { + Disabled, + Enabling, + Enabled { peer_id: String, addr: SocketAddr }, + Disabling, +} + +/// Redacted durability outcome for the installation identity used by a runtime. +/// +/// This deliberately exposes no persistence path, failure category, key +/// material, or backend error text. +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)] +pub enum PeerIdentityDurability { + Persistent, + Ephemeral, + CallerProvided, +} + /// Events sent from the peer system to the UI. #[derive(Debug, strum::IntoStaticStr)] pub enum PeerEvent { /// The local QUIC server is listening and ready to accept peer connections. LocalPeerReady { peer_id: String, addr: SocketAddr }, - /// List of available games from peers. - ListGames(Vec), - /// File descriptions for a specific game. - GotGameFiles { - id: String, - file_descriptions: Vec, - }, - /// Download has started for a game. - DownloadGameFilesBegin { id: String }, + /// The current lifecycle state of the global Local network sharing policy. + LocalNetworkSharingStateChanged(LocalNetworkSharingState), + /// Complete exact-content availability projection from authenticated peers. + RemoteLibraryView(RemoteLibraryView), + /// Download has started for one exact command attempt. + DownloadGameFilesBegin { attempt: DownloadAttemptKey }, /// A file chunk has been downloaded from a peer. DownloadGameFileChunkFinished { id: String, + peer_id: PeerId, peer_addr: SocketAddr, - relative_path: String, + content_id: ContentId, + relative_path: CanonicalCatalogPath, offset: u64, length: u64, }, /// Download progress sampled while game files are being received. DownloadGameFilesProgress(DownloadProgress), + /// Verification-specific activity for one download attempt changed. + DownloadGameFilesActivityChanged { + attempt: DownloadAttemptKey, + activity: Option, + }, /// Download has completed successfully. - DownloadGameFilesFinished { id: String }, - /// Download has failed. - DownloadGameFilesFailed { id: String }, - /// All peers with the game have disconnected during download. - DownloadGameFilesAllPeersGone { id: String }, + DownloadGameFilesFinished { attempt: DownloadAttemptKey }, + /// Download has failed with a stable user-facing classification. + DownloadGameFilesFailed { + attempt: DownloadAttemptKey, + reason: DownloadFailureReason, + }, /// Install or update transaction has completed successfully. InstallGameFinished { id: String }, /// Install or update transaction has failed after rollback. @@ -143,28 +349,27 @@ pub enum PeerEvent { RemoveDownloadedGameFinished { id: String }, /// Downloaded archive removal has failed before deleting the game root. RemoveDownloadedGameFailed { id: String }, - /// No peers have the requested game. - NoPeersHaveGame { id: String }, - /// A peer has connected. - PeerConnected(SocketAddr), - /// A peer has disconnected. - PeerDisconnected(SocketAddr), /// A new peer was discovered via mDNS. - PeerDiscovered(SocketAddr), + PeerDiscovered(PeerEndpoint), /// A peer was lost (timed out or disconnected). - PeerLost(SocketAddr), + PeerLost(PeerEndpoint), /// The total peer count has changed. PeerCountUpdated(usize), + /// mDNS observed a nearby installation speaking a different wire protocol. + IncompatibleProtocolDetected { + observed: Option, + expected: u32, + }, /// The local library contents changed after a scan. LocalLibraryChanged { games: Vec }, /// The number of active outbound transfers changed. - OutboundTransferCountChanged, + OutboundTransferCountChanged(OutboundTransferChange), /// The set of in-progress local operations changed. ActiveOperationsChanged { active_operations: Vec, }, - /// New or requested Call to Play events in replication order. - CallToPlayEvents(Vec), + /// Complete Call-to-Play projection; consumers replace their previous view. + CallToPlayView(CallToPlayView), /// A required peer runtime component failed. RuntimeFailed { component: PeerRuntimeComponent, @@ -175,7 +380,7 @@ pub enum PeerEvent { /// Sampled byte progress for one active game download. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct DownloadProgress { - pub id: String, + pub attempt: DownloadAttemptKey, pub downloaded_bytes: u64, pub total_bytes: u64, pub bytes_per_second: u64, @@ -234,10 +439,6 @@ pub enum ActiveOperationKind { pub enum PeerCommand { /// Request a list of all available games. ListGames, - /// Request file details for a specific game, serving local files when available. - GetGame(String), - /// Request the latest peer-advertised file details for an update. - FetchLatestFromPeers { id: String }, /// Download game files. DownloadGameFiles { id: String }, /// Download game files with an explicit install policy. @@ -246,7 +447,10 @@ pub enum PeerCommand { install_after_download: bool, }, /// Stream archive-expanded bytes directly into `local/` without keeping root archives. - StreamInstallGame { id: String }, + StreamInstallGame { + id: String, + settings: StreamInstallSettings, + }, /// Install already-downloaded archives into `local/`. InstallGame { id: String }, /// Remove only the `local/` install for a game. @@ -255,37 +459,72 @@ pub enum PeerCommand { RemoveDownloadedGame { id: String }, /// Cancel an active peer download without emitting a user-facing failure. CancelDownload { id: String }, - /// Set the local game directory. - SetGameDir(PathBuf), + /// Set the local game directory and acknowledge the canonical path that + /// the peer accepted. + SetGameDir { + path: PathBuf, + reply: oneshot::Sender>, + }, + /// Enable or disable all Local network sharing services and await drainage. + SetLocalNetworkSharing { + enabled: bool, + reply: oneshot::Sender>, + }, /// Request the current peer count. GetPeerCount, - /// Connect directly to a peer address without waiting for mDNS discovery. - ConnectPeer(SocketAddr), - /// Publish one local Call to Play action to this peer and the LAN. - PublishCallToPlay { - event: CallToPlayEvent, - reply: oneshot::Sender>, + /// Connect directly to an authenticated peer endpoint without waiting for mDNS discovery. + ConnectPeer(PeerEndpoint), + /// Apply one local Call-to-Play intent; core generates all identity and time fields. + ApplyCallToPlayIntent { + intent: CallToPlayLocalIntent, + display_name: String, + reply: oneshot::Sender>, }, - /// Request the complete in-memory Call to Play history. - GetCallToPlayEvents { - reply: Option>>, + /// Update the locally authored display name, including without a new event. + SetCallToPlayDisplayName { + display_name: String, + reply: oneshot::Sender>, + }, + /// Request the complete in-memory Call-to-Play projection. + GetCallToPlayView { + reply: Option>>, }, } /// Optional startup settings for non-GUI callers and tests. -#[derive(Clone, Default)] +#[derive(Clone)] pub struct PeerStartOptions { /// Directory used for peer identity and other state. pub state_dir: Option, + /// Explicit installation identity. When absent, the state directory owns it. + pub identity: Option>, pub active_outbound_transfers: Option, /// Provider used to stream archive entries for low-disk streamed installs. pub stream_install_provider: Option>, + /// Whether Local network sharing services start enabled. + pub local_network_sharing: bool, +} + +impl Default for PeerStartOptions { + fn default() -> Self { + Self { + state_dir: None, + identity: None, + active_outbound_transfers: None, + stream_install_provider: None, + local_network_sharing: true, + } + } } impl std::fmt::Debug for PeerStartOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PeerStartOptions") .field("state_dir", &self.state_dir) + .field( + "identity", + &self.identity.as_ref().map(|identity| identity.peer_id()), + ) .field( "active_outbound_transfers", &self.active_outbound_transfers.as_ref().map(|_| "..."), @@ -294,6 +533,7 @@ impl std::fmt::Debug for PeerStartOptions { "stream_install_provider", &self.stream_install_provider.as_ref().map(|_| "..."), ) + .field("local_network_sharing", &self.local_network_sharing) .finish() } } @@ -309,6 +549,12 @@ impl std::fmt::Debug for PeerStartOptions { /// owns the command sender plus a shutdown signal callers can use for clean /// teardown. /// +/// A successful return acknowledges construction of the isolated runtime and +/// outgoing QUIC endpoint. Initial recovery and required-service startup remain +/// asynchronous. Later events report bootstrap progress and failures, including +/// [`PeerEvent::LocalPeerReady`] for listener readiness and +/// [`PeerEvent::RuntimeFailed`] for a required component failure. +/// /// # Arguments /// /// * `game_dir` - Path to the local game directory @@ -320,7 +566,7 @@ pub fn start_peer( tx_notify_ui: UnboundedSender, peer_game_db: Arc>, unpacker: Arc, - catalog: Arc>, + catalog: Arc, ) -> eyre::Result { start_peer_with_options( game_dir, @@ -339,16 +585,20 @@ pub fn start_peer_with_options( tx_notify_ui: UnboundedSender, peer_game_db: Arc>, unpacker: Arc, - catalog: Arc>, + catalog: Arc, options: PeerStartOptions, ) -> eyre::Result { let PeerStartOptions { state_dir, + identity: explicit_identity, active_outbound_transfers, stream_install_provider, + local_network_sharing, } = options; let state_dir = resolve_state_dir(state_dir.as_deref()); - let game_dir = game_dir.into(); + let requested_game_dir = game_dir.into(); + let game_dir = canonicalize_game_dir(&requested_game_dir)?; + install::intent::scan_active_install_intents(&state_dir, &game_dir)?; let active_outbound_transfers = active_outbound_transfers .unwrap_or_else(|| Arc::new(RwLock::new(std::collections::HashMap::new()))); let stream_install_provider = @@ -357,23 +607,62 @@ pub fn start_peer_with_options( "Starting peer system with game directory: {}", game_dir.display() ); - let peer_id = identity::load_or_create_peer_id(&state_dir)?; + let (peer_identity, identity_durability) = if let Some(identity) = explicit_identity { + (identity, PeerIdentityDurability::CallerProvided) + } else { + let loaded = identity::load_or_generate_peer_identity(&state_dir)?; + let durability = identity_durability(&loaded.persistence); + match &loaded.persistence { + PeerIdentityPersistence::Loaded => { + log::debug!("Loaded peer identity {}", loaded.identity.peer_id()); + } + PeerIdentityPersistence::Created => { + log::info!("Created peer identity {}", loaded.identity.peer_id()); + } + PeerIdentityPersistence::ReplacedCorrupt { quarantine_path } => { + log::warn!( + "Replaced corrupt peer identity; original preserved at {}", + quarantine_path.display() + ); + } + PeerIdentityPersistence::Ephemeral(failure) => { + log::warn!( + "Peer identity persistence {:?} failed at {}: {}; using an ephemeral identity for this runtime", + failure.kind, + failure.path.display(), + failure.message + ); + } + } + (Arc::new(loaded.identity), durability) + }; let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel(); - Ok(startup::spawn_peer_runtime( + startup::spawn_peer_runtime( tx_control, rx_control, tx_notify_ui, peer_game_db, - peer_id, + peer_identity, + identity_durability, game_dir, state_dir, unpacker, catalog, active_outbound_transfers, stream_install_provider, - )) + local_network_sharing, + ) +} + +const fn identity_durability(persistence: &PeerIdentityPersistence) -> PeerIdentityDurability { + match persistence { + PeerIdentityPersistence::Loaded + | PeerIdentityPersistence::Created + | PeerIdentityPersistence::ReplacedCorrupt { .. } => PeerIdentityDurability::Persistent, + PeerIdentityPersistence::Ephemeral(_) => PeerIdentityDurability::Ephemeral, + } } /// Main peer execution loop that handles peer commands and manages the peer system. @@ -382,19 +671,21 @@ async fn run_peer( mut rx_control: UnboundedReceiver, tx_notify_ui: UnboundedSender, peer_game_db: Arc>, - peer_id: String, + peer_identity: Arc, game_dir: PathBuf, state_dir: PathBuf, unpacker: Arc, shutdown: CancellationToken, task_tracker: TaskTracker, - catalog: Arc>, + catalog: Arc, active_outbound_transfers: crate::context::OutboundTransfers, stream_install_provider: Arc, + local_network_sharing: bool, ) -> eyre::Result<()> { + let (network_manager, network) = NetworkManager::new(); let ctx = Ctx::new( peer_game_db, - peer_id, + peer_identity, game_dir, state_dir, unpacker, @@ -403,11 +694,19 @@ async fn run_peer( catalog, active_outbound_transfers, stream_install_provider, - ); + network, + )?; if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await { log::error!("Failed to load initial local game database: {err}"); } - startup::spawn_startup_services(&ctx, &tx_notify_ui); + startup::spawn_core_services(&ctx, &tx_notify_ui); + let manager_ctx = ctx.clone(); + let manager_tx = tx_notify_ui.clone(); + ctx.task_tracker.clone().spawn(async move { + network_manager + .run(manager_ctx, manager_tx, local_network_sharing) + .await; + }); if let Err(err) = handle_peer_commands(&ctx, &tx_notify_ui, &mut rx_control).await { let error = err.to_string(); log::error!("Peer command loop failed: {error}"); @@ -420,8 +719,6 @@ async fn run_peer( ); ctx.shutdown.cancel(); } - startup::send_goodbye_notifications(&ctx).await; - Ok(()) } @@ -447,14 +744,6 @@ async fn handle_peer_commands( PeerCommand::ListGames => { handle_list_games_command(ctx, tx_notify_ui).await; } - PeerCommand::GetGame(id) => { - handle_get_game_command(ctx, tx_notify_ui, id, GameDetailSource::LocalOrPeers) - .await; - } - PeerCommand::FetchLatestFromPeers { id } => { - handle_get_game_command(ctx, tx_notify_ui, id, GameDetailSource::LatestPeersOnly) - .await; - } PeerCommand::DownloadGameFiles { id } => { handle_download_game_files_command(ctx, tx_notify_ui, id, true).await; } @@ -465,8 +754,8 @@ async fn handle_peer_commands( handle_download_game_files_command(ctx, tx_notify_ui, id, install_after_download) .await; } - PeerCommand::StreamInstallGame { id } => { - handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id).await; + PeerCommand::StreamInstallGame { id, settings } => { + handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id, settings).await; } PeerCommand::InstallGame { id } => { handle_install_game_command(ctx, tx_notify_ui, id).await; @@ -480,26 +769,390 @@ async fn handle_peer_commands( PeerCommand::CancelDownload { id } => { handle_cancel_download_command(ctx, tx_notify_ui, id).await; } - PeerCommand::SetGameDir(game_dir) => { - handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await; + PeerCommand::SetGameDir { path, reply } => { + let result = handle_set_game_dir_command(ctx, tx_notify_ui, path).await; + let _ = reply.send(result); + } + PeerCommand::SetLocalNetworkSharing { enabled, reply } => { + let _ = reply.send(ctx.network.set_enabled(enabled).await); } PeerCommand::GetPeerCount => { handle_get_peer_count_command(ctx, tx_notify_ui).await; } - PeerCommand::ConnectPeer(addr) => { - handle_connect_peer_command(ctx, tx_notify_ui, addr).await; + PeerCommand::ConnectPeer(endpoint) => { + handle_connect_peer_command(ctx, tx_notify_ui, endpoint).await; } - PeerCommand::PublishCallToPlay { event, reply } => { - let result = call_to_play::publish(ctx, tx_notify_ui, event).await; - let _ = reply.send(result); + PeerCommand::ApplyCallToPlayIntent { + intent, + display_name, + reply, + } => { + handle_apply_call_to_play_intent(ctx, tx_notify_ui, intent, display_name, reply) + .await; } - PeerCommand::GetCallToPlayEvents { reply } => { - let events = ctx.call_to_play.write().await.snapshot(); - events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(events.clone())); - if let Some(reply) = reply { - let _ = reply.send(events); - } + PeerCommand::SetCallToPlayDisplayName { + display_name, + reply, + } => { + handle_set_call_to_play_display_name(ctx, tx_notify_ui, display_name, reply).await; + } + PeerCommand::GetCallToPlayView { reply } => { + handle_get_call_to_play_view(ctx, tx_notify_ui, reply).await; } } } } + +async fn handle_apply_call_to_play_intent( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + intent: CallToPlayLocalIntent, + display_name: String, + reply: oneshot::Sender>, +) { + let _network_permit = match ctx.network.try_acquire() { + Ok(permit) => permit, + Err(error) => { + let _ = reply.send(Err(error.to_string())); + return; + } + }; + let result = { + let mut store = ctx.call_to_play.write().await; + match store.publish_local(intent.into(), display_name) { + Ok((receipt, publication)) => { + ctx.state_sync + .publish_call_to_play_revision(publication.local_revision); + events::send( + tx_notify_ui, + PeerEvent::CallToPlayView(CallToPlayView::from(publication.view)), + ); + Ok(CallToPlayReceipt::from(receipt)) + } + Err(error) => Err(error.to_string()), + } + }; + let _ = reply.send(result); +} + +async fn handle_set_call_to_play_display_name( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + display_name: String, + reply: oneshot::Sender>, +) { + let result = { + let mut store = ctx.call_to_play.write().await; + match store.set_local_display_name(display_name) { + Ok((mutation, publication)) => { + if publication.local_changed { + ctx.state_sync + .publish_call_to_play_revision(publication.local_revision); + events::send( + tx_notify_ui, + PeerEvent::CallToPlayView(CallToPlayView::from(publication.view)), + ); + } + Ok(mutation.is_some()) + } + Err(error) => Err(error.to_string()), + } + }; + let _ = reply.send(result); +} + +async fn handle_get_call_to_play_view( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + reply: Option>>, +) { + let result = { + let mut store = ctx.call_to_play.write().await; + match store.current_publication() { + Ok(publication) => { + if publication.local_changed { + ctx.state_sync + .publish_call_to_play_revision(publication.local_revision); + } + let view = CallToPlayView::from(publication.view); + events::send(tx_notify_ui, PeerEvent::CallToPlayView(view.clone())); + Ok(view) + } + Err(error) => Err(error.to_string()), + } + }; + if let Some(reply) = reply { + let _ = reply.send(result); + } +} + +pub(crate) fn canonicalize_game_dir(game_dir: &Path) -> eyre::Result { + let canonical = std::fs::canonicalize(game_dir).map_err(|error| { + eyre::eyre!( + "failed to canonicalize game directory {}: {error}", + game_dir.display() + ) + })?; + let metadata = std::fs::symlink_metadata(&canonical).map_err(|error| { + eyre::eyre!( + "failed to inspect canonical game directory {}: {error}", + canonical.display() + ) + })?; + if !metadata.is_dir() { + eyre::bail!( + "configured game directory is not a directory: {}", + canonical.display() + ); + } + Ok(canonical) +} + +#[cfg(test)] +mod configured_game_dir_tests { + use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, + }; + + use tokio::sync::{RwLock, mpsc}; + use tokio_util::sync::CancellationToken; + + use super::{ + PeerEvent, + PeerIdentityDurability, + PeerStartOptions, + UnpackFuture, + Unpacker, + canonicalize_game_dir, + identity_durability, + load_peer_identity, + start_peer_with_options, + }; + use crate::{ + NoopStreamInstallProvider, + PeerIdentityPersistence, + PeerIdentityPersistenceFailure, + PeerIdentityPersistenceFailureKind, + identity::load_or_generate_peer_identity_with_write_failure, + install::intent::{InstallIntent, InstallIntentState, intent_path, write_intent}, + peer_db::PeerGameDB, + state_paths::peer_identity_path, + test_support::{TempDir, empty_catalog_bundle}, + }; + + struct NoopUnpacker; + + #[test] + fn peer_start_options_default_to_local_network_sharing() { + let options = PeerStartOptions::default(); + + assert!(options.local_network_sharing); + assert!(format!("{options:?}").contains("local_network_sharing: true")); + } + + #[test] + fn identity_durability_redacts_persistence_details() { + for persistence in [ + PeerIdentityPersistence::Loaded, + PeerIdentityPersistence::Created, + PeerIdentityPersistence::ReplacedCorrupt { + quarantine_path: PathBuf::from("/private/quarantine-path"), + }, + ] { + assert_eq!( + identity_durability(&persistence), + PeerIdentityDurability::Persistent + ); + } + + let persistence = PeerIdentityPersistence::Ephemeral(PeerIdentityPersistenceFailure { + kind: PeerIdentityPersistenceFailureKind::Write, + path: PathBuf::from("/private/identity-path"), + message: "private backend detail".to_owned(), + }); + let durability = identity_durability(&persistence); + + assert_eq!(durability, PeerIdentityDurability::Ephemeral); + assert_eq!( + serde_json::to_string(&durability).expect("durability should serialize"), + "\"Ephemeral\"" + ); + } + + impl Unpacker for NoopUnpacker { + fn unpack<'a>( + &'a self, + _archive: &'a Path, + _dest: &'a Path, + _cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { + Box::pin(async { Ok(()) }) + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn startup_canonicalizes_lexical_root_alias_before_storage() { + let target = TempDir::new("lanspread-startup-game-root-target"); + let state = TempDir::new("lanspread-startup-state-root"); + let alias = target.path().join("."); + let expected = std::fs::canonicalize(target.path()).expect("target should canonicalize"); + let (events, _event_rx) = mpsc::unbounded_channel(); + + let mut handle = start_peer_with_options( + alias, + events, + Arc::new(RwLock::new(PeerGameDB::new())), + Arc::new(NoopUnpacker), + empty_catalog_bundle(), + PeerStartOptions { + state_dir: Some(state.path().to_path_buf()), + ..PeerStartOptions::default() + }, + ) + .expect("peer startup should accept a lexical root alias"); + + assert_eq!(handle.accepted_game_dir(), expected); + assert_eq!( + handle.identity_durability(), + PeerIdentityDurability::Persistent + ); + assert_eq!( + handle.peer_id(), + load_peer_identity(&peer_identity_path(state.path())) + .expect("persisted startup identity should load") + .peer_id() + ); + handle.shutdown(); + handle.wait_stopped().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn ephemeral_write_failed_identity_participates_as_local_peer_ready() { + let games = TempDir::new("lanspread-startup-ephemeral-write-games"); + let state = TempDir::new("lanspread-startup-ephemeral-write-state"); + let loaded = load_or_generate_peer_identity_with_write_failure(state.path()) + .expect("injected persistence failure should retain one identity"); + assert!(matches!( + &loaded.persistence, + PeerIdentityPersistence::Ephemeral(failure) + if failure.kind == PeerIdentityPersistenceFailureKind::Write + )); + let durability = identity_durability(&loaded.persistence); + let identity = Arc::new(loaded.identity); + let expected_peer_id = identity.peer_id(); + let (tx_control, rx_control) = mpsc::unbounded_channel(); + let (tx_events, mut rx_events) = mpsc::unbounded_channel(); + + let mut handle = crate::startup::spawn_peer_runtime( + tx_control, + rx_control, + tx_events, + Arc::new(RwLock::new(PeerGameDB::new())), + Arc::clone(&identity), + durability, + std::fs::canonicalize(games.path()).expect("games root should canonicalize"), + state.path().to_path_buf(), + Arc::new(NoopUnpacker), + empty_catalog_bundle(), + Arc::new(RwLock::new(HashMap::new())), + Arc::new(NoopStreamInstallProvider), + true, + ) + .expect("runtime should start with the ephemeral identity"); + + assert!(Arc::ptr_eq(&handle.identity(), &identity)); + assert_eq!( + handle.identity_durability(), + PeerIdentityDurability::Ephemeral + ); + let ready_peer_id = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match rx_events.recv().await { + Some(PeerEvent::LocalPeerReady { peer_id, .. }) => break peer_id, + Some(PeerEvent::RuntimeFailed { component, error }) => { + panic!("runtime component {component:?} failed before readiness: {error}"); + } + Some(_) => {} + None => panic!("runtime event channel closed before readiness"), + } + } + }) + .await + .expect("sharing-enabled runtime should publish listener readiness"); + assert_eq!(ready_peer_id, expected_peer_id.to_string()); + + handle.shutdown(); + handle.wait_stopped().await; + } + + #[cfg(unix)] + #[test] + fn startup_canonicalizes_symlink_root_alias_before_storage() { + use std::os::unix::fs::symlink; + + let target = TempDir::new("lanspread-startup-game-root-target"); + let aliases = TempDir::new("lanspread-startup-game-root-alias"); + let alias = aliases.path().join("games"); + symlink(target.path(), &alias).expect("configured-root alias should be created"); + + assert_eq!( + canonicalize_game_dir(&alias).expect("configured-root alias should be accepted"), + std::fs::canonicalize(target.path()).expect("target should canonicalize") + ); + } + + #[test] + fn startup_rejects_a_non_directory_root() { + let target = TempDir::new("lanspread-startup-game-root-file"); + let file = target.path().join("games"); + std::fs::write(&file, b"not a directory").expect("test file should be written"); + + let error = canonicalize_game_dir(&file).expect_err("file root should be rejected"); + + assert!(error.to_string().contains("is not a directory")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn startup_rejects_foreign_intent_for_game_absent_from_candidate_root() { + let old_games = TempDir::new("lanspread-startup-foreign-intent-old-root"); + let candidate = TempDir::new("lanspread-startup-foreign-intent-candidate"); + let state = TempDir::new("lanspread-startup-foreign-intent-state"); + let intent = InstallIntent::new( + &old_games.path().join("orphan"), + "orphan", + InstallIntentState::Updating, + None, + ) + .expect("old-root intent should be valid"); + write_intent(state.path(), "orphan", &intent).expect("old-root intent should be persisted"); + let (events, _event_rx) = mpsc::unbounded_channel(); + + let result = start_peer_with_options( + candidate.path().to_path_buf(), + events, + Arc::new(RwLock::new(PeerGameDB::new())), + Arc::new(NoopUnpacker), + empty_catalog_bundle(), + PeerStartOptions { + state_dir: Some(state.path().to_path_buf()), + ..PeerStartOptions::default() + }, + ); + let error = match result { + Ok(mut handle) => { + handle.shutdown(); + handle.wait_stopped().await; + panic!("foreign install intent should reject peer startup"); + } + Err(error) => error, + }; + + assert!(error.to_string().contains("different configured games")); + assert!(!candidate.path().join("orphan").exists()); + assert!(intent_path(state.path(), "orphan").is_file()); + assert!(!peer_identity_path(state.path()).exists()); + } +} diff --git a/crates/lanspread-peer/src/library.rs b/crates/lanspread-peer/src/library.rs index 033b238..7a8f7af 100644 --- a/crates/lanspread-peer/src/library.rs +++ b/crates/lanspread-peer/src/library.rs @@ -1,116 +1,408 @@ +//! Runtime-local library publication state. + use std::{ - collections::{HashMap, VecDeque}, - hash::{Hash, Hasher}, + collections::HashMap, + error::Error, + fmt, + path::{Path, PathBuf}, }; -use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot}; +use lanspread_db::{ + content_manifest::CatalogBundle, + db::{Availability, GameCatalog}, +}; +use lanspread_proto::{GameAvailability, LibrarySnapshot, MAX_LIBRARY_GAMES}; +use serde::{Deserialize, Serialize}; -const MAX_DELTA_HISTORY: usize = 8; +/// Local scan result retained by the runtime and never placed directly on the wire. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LocalGameSummary { + pub id: String, + pub name: String, + pub size: u64, + pub downloaded: bool, + pub installed: bool, + pub eti_version: Option, + pub availability: Availability, +} + +/// Immutable input for building one responder-owned wire snapshot off-lock. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalLibraryPublication { + pub revision: u64, + pub game_ids: Vec, +} #[derive(Debug, Clone)] pub struct LocalLibraryState { + /// Revision of the latest accepted on-disk library scan. + source_game_dir: Option, + source_revision: u64, + /// Runtime-session-scoped publication revision. pub revision: u64, - pub digest: u64, - pub games: HashMap, - pub recent_deltas: VecDeque, + pub games: HashMap, } impl LocalLibraryState { + #[must_use] pub fn empty() -> Self { - let games = HashMap::new(); - let digest = compute_library_digest(&games); Self { + source_game_dir: None, + source_revision: 0, revision: 0, - digest, - games, - recent_deltas: VecDeque::new(), + games: HashMap::new(), } } pub fn update_from_scan( &mut self, - summaries: HashMap, - revision: u64, - ) -> Option { - let new_digest = compute_library_digest(&summaries); - let changed = - self.revision != revision || self.digest != new_digest || self.games != summaries; - - if !changed { - return None; + source_game_dir: &Path, + summaries: HashMap, + source_revision: u64, + ) -> Result, LocalLibraryRevisionExhausted> { + let same_source = self.source_game_dir.as_deref() == Some(source_game_dir); + if same_source && source_revision < self.source_revision { + return Ok(None); } - let delta = compute_library_delta(self.revision, revision, &self.games, &summaries); - self.revision = revision; - self.digest = new_digest; + if self.games == summaries { + self.source_game_dir = Some(source_game_dir.to_path_buf()); + self.source_revision = source_revision; + return Ok(None); + } + + let next_revision = self + .revision + .checked_add(1) + .ok_or(LocalLibraryRevisionExhausted)?; + self.revision = next_revision; self.games = summaries; - self.recent_deltas.push_back(delta.clone()); - while self.recent_deltas.len() > MAX_DELTA_HISTORY { - self.recent_deltas.pop_front(); - } - Some(delta) + self.source_game_dir = Some(source_game_dir.to_path_buf()); + self.source_revision = source_revision; + Ok(Some(next_revision)) } -} -pub fn compute_library_digest(games: &HashMap) -> u64 { - let mut entries: Vec<&GameSummary> = games.values().collect(); - entries.sort_by(|a, b| a.id.cmp(&b.id)); - - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - for summary in entries { - summary.id.hash(&mut hasher); - summary.name.hash(&mut hasher); - summary.size.hash(&mut hasher); - summary.downloaded.hash(&mut hasher); - summary.installed.hash(&mut hasher); - summary.eti_version.hash(&mut hasher); - summary.manifest_hash.hash(&mut hasher); - summary.availability.hash(&mut hasher); - } - hasher.finish() -} - -pub fn build_library_snapshot(state: &LocalLibraryState) -> LibrarySnapshot { - let mut games: Vec = state.games.values().cloned().collect(); - games.sort_by(|a, b| a.id.cmp(&b.id)); - LibrarySnapshot { - library_rev: state.revision, - games, - } -} - -pub fn compute_library_delta( - from_rev: u64, - to_rev: u64, - previous: &HashMap, - next: &HashMap, -) -> LibraryDelta { - let mut added = Vec::new(); - let mut updated = Vec::new(); - let mut removed = Vec::new(); - - for (game_id, summary) in next { - match previous.get(game_id) { - None => added.push(summary.clone()), - Some(existing) => { - if existing != summary { - updated.push(summary.clone()); - } + /// Publishes an empty recovery projection without regressing source ordering. + pub fn clear_for_recovery( + &mut self, + source_game_dir: &Path, + ) -> Result, LocalLibraryRevisionExhausted> { + let same_source = self.source_game_dir.as_deref() == Some(source_game_dir); + if self.games.is_empty() { + if !same_source { + self.source_game_dir = Some(source_game_dir.to_path_buf()); + self.source_revision = 0; } + return Ok(None); } + + let next_revision = self + .revision + .checked_add(1) + .ok_or(LocalLibraryRevisionExhausted)?; + self.revision = next_revision; + self.games.clear(); + if !same_source { + self.source_game_dir = Some(source_game_dir.to_path_buf()); + self.source_revision = 0; + } + Ok(Some(next_revision)) } - for game_id in previous.keys() { - if !next.contains_key(game_id) { - removed.push(game_id.clone()); + /// Withdraws one cached game before a filesystem mutation is admitted. + /// Settlement rescans republish the resulting on-disk state. Revision + /// exhaustion is fail-closed and leaves the cached projection unchanged. + pub fn withdraw_for_operation( + &mut self, + game_id: &str, + ) -> Result, LocalLibraryRevisionExhausted> { + if !self.games.contains_key(game_id) { + return Ok(None); } + let next_revision = self + .revision + .checked_add(1) + .ok_or(LocalLibraryRevisionExhausted)?; + self.games.remove(game_id); + self.revision = next_revision; + Ok(Some(next_revision)) } - LibraryDelta { - from_rev, - to_rev, - added, - updated, - removed, + #[must_use] + pub(crate) fn source_revision_for(&self, source_game_dir: &Path) -> Option { + (self.source_game_dir.as_deref() == Some(source_game_dir)).then_some(self.source_revision) + } + + /// Captures the catalog-eligible local availability without doing manifest I/O. + #[must_use] + pub fn publication(&self, catalog: &GameCatalog) -> LocalLibraryPublication { + LocalLibraryPublication { + revision: self.revision, + game_ids: catalog_eligible_game_ids(&self.games, catalog), + } + } +} + +/// Returns deterministic IDs whose scanned state exactly matches catalog +/// availability policy. This is shared by pre-publication manifest priming and +/// by the committed responder projection so the two eligibility decisions +/// cannot drift. +#[must_use] +pub fn catalog_eligible_game_ids( + games: &HashMap, + catalog: &GameCatalog, +) -> Vec { + let mut game_ids = games + .values() + .filter(|game| { + game.downloaded + && game.availability == Availability::Ready + && catalog.contains(&game.id) + && catalog + .expected_version(&game.id) + .is_none_or(|expected| game.eti_version.as_deref() == Some(expected)) + }) + .map(|game| game.id.clone()) + .collect::>(); + game_ids.sort(); + game_ids +} + +/// Loads and validates exactly one prospective publication's manifests. +/// Callers perform this finite filesystem work before making its revision +/// visible to network responders. +pub fn prime_library_manifests(game_ids: &[String], catalog: &CatalogBundle) -> eyre::Result<()> { + validate_library_game_count(game_ids.len())?; + let mut games = Vec::with_capacity(game_ids.len()); + for game_id in game_ids { + games.push(GameAvailability { + game_id: game_id.clone(), + content_id: catalog.manifest(game_id)?.content_id(), + }); + } + LibrarySnapshot { revision: 0, games }.validate()?; + Ok(()) +} + +fn validate_library_game_count(count: usize) -> eyre::Result<()> { + if count > MAX_LIBRARY_GAMES { + eyre::bail!("local library exceeds the wire limit of {MAX_LIBRARY_GAMES} games"); + } + Ok(()) +} + +impl Default for LocalLibraryState { + fn default() -> Self { + Self::empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalLibraryRevisionExhausted; + +impl fmt::Display for LocalLibraryRevisionExhausted { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("local library publication revision is exhausted") + } +} + +impl Error for LocalLibraryRevisionExhausted {} + +/// Resolves catalog-owned identities strictly from the prevalidated cache. +/// This function is safe to call from a public responder: it performs no +/// filesystem I/O and fails closed if publication ordering was violated. +pub fn build_library_snapshot( + publication: LocalLibraryPublication, + catalog: &CatalogBundle, +) -> eyre::Result { + let games = publication + .game_ids + .into_iter() + .map(|game_id| { + let content_id = catalog.cached_manifest(&game_id)?.content_id(); + Ok(GameAvailability { + game_id, + content_id, + }) + }) + .collect::>>()?; + + let snapshot = LibrarySnapshot { + revision: publication.revision, + games, + }; + snapshot.validate()?; + Ok(snapshot) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary(size: u64) -> LocalGameSummary { + LocalGameSummary { + id: "game".to_owned(), + name: "game".to_owned(), + size, + downloaded: true, + installed: false, + eti_version: Some("20250101".to_owned()), + availability: Availability::Ready, + } + } + + fn games(size: u64) -> HashMap { + HashMap::from([("game".to_owned(), summary(size))]) + } + + #[test] + fn accepted_mutations_increment_one_runtime_revision() { + let mut state = LocalLibraryState::empty(); + assert_eq!( + state + .update_from_scan(Path::new("games"), games(1), 7) + .expect("revision should be available"), + Some(1) + ); + assert_eq!( + state + .update_from_scan(Path::new("games"), HashMap::new(), 7) + .expect("revision should be available"), + Some(2) + ); + assert_eq!( + state + .update_from_scan(Path::new("games"), games(1), 7) + .expect("revision should be available"), + Some(3) + ); + } + + #[test] + fn stale_scan_cannot_regress_library_state() { + let mut state = LocalLibraryState::empty(); + state + .update_from_scan(Path::new("games"), games(1), 2) + .expect("revision should be available"); + + assert!( + state + .update_from_scan(Path::new("games"), games(2), 1) + .expect("stale scan should not consume a revision") + .is_none() + ); + assert_eq!(state.revision, 1); + assert_eq!(state.source_revision_for(Path::new("games")), Some(2)); + assert_eq!(state.games["game"].size, 1); + } + + #[test] + fn identical_scan_only_advances_the_source_floor() { + let mut state = LocalLibraryState::empty(); + state + .update_from_scan(Path::new("games"), games(1), 7) + .expect("revision should be available"); + + assert!( + state + .update_from_scan(Path::new("games"), games(1), 8) + .expect("identical scan should be accepted") + .is_none() + ); + assert_eq!(state.revision, 1); + assert_eq!(state.source_revision_for(Path::new("games")), Some(8)); + } + + #[test] + fn publication_contains_only_ready_downloads_in_sorted_order() { + let mut state = LocalLibraryState::empty(); + let mut ready = summary(1); + ready.id = "b".to_owned(); + let mut local_only = summary(1); + local_only.id = "a".to_owned(); + local_only.downloaded = false; + local_only.availability = Availability::LocalOnly; + state.games = HashMap::from([ + (ready.id.clone(), ready), + (local_only.id.clone(), local_only), + ]); + state.revision = 4; + + let catalog = GameCatalog::from_ids(["a".to_owned(), "b".to_owned()]); + assert_eq!( + state.publication(&catalog), + LocalLibraryPublication { + revision: 4, + game_ids: vec!["b".to_owned()], + } + ); + } + + #[test] + fn publication_requires_the_catalog_version_before_claiming_content_identity() { + let mut state = LocalLibraryState::empty(); + let mut correct = summary(1); + correct.id = "correct".to_owned(); + correct.eti_version = Some("20250101".to_owned()); + let mut wrong = summary(1); + wrong.id = "wrong".to_owned(); + wrong.eti_version = Some("20240101".to_owned()); + state.games = HashMap::from([(correct.id.clone(), correct), (wrong.id.clone(), wrong)]); + let mut catalog = GameCatalog::empty(); + catalog.insert("correct".to_owned(), Some("20250101".to_owned())); + catalog.insert("wrong".to_owned(), Some("20250101".to_owned())); + + assert_eq!(state.publication(&catalog).game_ids, ["correct"]); + } + + #[test] + fn revision_exhaustion_leaves_state_unchanged() { + let mut state = LocalLibraryState::empty(); + state.revision = u64::MAX; + let before = state.clone(); + + assert_eq!( + state.update_from_scan(Path::new("games"), games(1), 1), + Err(LocalLibraryRevisionExhausted) + ); + assert_eq!(state.source_game_dir, before.source_game_dir); + assert_eq!(state.source_revision, before.source_revision); + assert_eq!(state.revision, before.revision); + assert_eq!(state.games, before.games); + } + + #[test] + fn wire_library_bound_accepts_exact_limit_and_rejects_one_more() { + assert!(validate_library_game_count(MAX_LIBRARY_GAMES).is_ok()); + assert!(validate_library_game_count(MAX_LIBRARY_GAMES + 1).is_err()); + } + + #[test] + fn operation_withdrawal_bumps_once_and_exhaustion_is_atomic() { + let mut state = LocalLibraryState::empty(); + state.games = games(1); + assert_eq!( + state + .withdraw_for_operation("game") + .expect("revision should be available"), + Some(1) + ); + assert!(state.games.is_empty()); + assert_eq!( + state + .withdraw_for_operation("game") + .expect("absent game should be a no-op"), + None + ); + + state.games = games(2); + state.revision = u64::MAX; + let before = state.clone(); + assert_eq!( + state.withdraw_for_operation("game"), + Err(LocalLibraryRevisionExhausted) + ); + assert_eq!(state.revision, before.revision); + assert_eq!(state.games, before.games); } } diff --git a/crates/lanspread-peer/src/local_games.rs b/crates/lanspread-peer/src/local_games.rs index fbdaf10..db37f7a 100644 --- a/crates/lanspread-peer/src/local_games.rs +++ b/crates/lanspread-peer/src/local_games.rs @@ -2,20 +2,20 @@ use std::{ collections::{HashMap, HashSet}, - hash::{Hash, Hasher}, - io::ErrorKind, - path::{Component, Path, PathBuf}, + fs::Metadata, + io::{ErrorKind, Write as _}, + path::{Path, PathBuf}, sync::LazyLock, time::{SystemTime, UNIX_EPOCH}, }; -use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription}; -use lanspread_proto::{Availability, GameSummary}; +use lanspread_db::db::{Availability, Game, GameCatalog, GameDB}; use serde::{Deserialize, Serialize}; -use tokio::{io::AsyncWriteExt, sync::Mutex}; +use tokio::sync::Mutex; use crate::{ context::OperationKind, + download::{DownloadOwnershipReadiness, download_ownership_readiness}, error::PeerError, game_paths::{ LEGACY_LIBRARY_INDEX_DIR, @@ -24,6 +24,8 @@ use crate::{ is_download_protected_root_name, is_ignored_games_root_name, }, + library::LocalGameSummary, + scoped_blocking::scoped_blocking, }; // ============================================================================= @@ -32,23 +34,72 @@ use crate::{ /// Checks if `local/` is a committed install directory. pub async fn local_dir_is_directory(path: &Path) -> bool { + tokio::task::yield_now().await; + scoped_blocking(|| local_dir_is_directory_sync(path)) +} + +fn local_dir_is_directory_sync(path: &Path) -> bool { + if !direct_game_root_is_safe_directory_sync(path) { + return false; + } let local_dir = path.join(LOCAL_DIR); - tokio::fs::metadata(&local_dir) - .await - .is_ok_and(|metadata| metadata.is_dir()) + std::fs::symlink_metadata(&local_dir) + .is_ok_and(|metadata| metadata.is_dir() && !is_link_or_reparse(&metadata)) } /// Checks if the root-level `version.ini` sentinel exists as a regular file. pub async fn version_ini_is_regular_file(game_path: &Path) -> bool { + tokio::task::yield_now().await; + scoped_blocking(|| version_ini_is_regular_file_sync(game_path)) +} + +fn version_ini_is_regular_file_sync(game_path: &Path) -> bool { + if !direct_game_root_is_safe_directory_sync(game_path) { + return false; + } let version_path = game_path.join(VERSION_INI); - tokio::fs::metadata(&version_path) - .await - .is_ok_and(|metadata| metadata.is_file()) + std::fs::symlink_metadata(&version_path) + .is_ok_and(|metadata| metadata.is_file() && !is_link_or_reparse(&metadata)) +} + +/// Returns whether a direct game root can be inspected without following its +/// final component. +#[cfg(test)] +pub(crate) async fn game_root_is_safe_directory(path: &Path) -> bool { + tokio::task::yield_now().await; + scoped_blocking(|| direct_game_root_is_safe_directory_sync(path)) +} + +fn safe_directory_entry_sync(path: &Path) -> bool { + std::fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata.is_dir() && !is_link_or_reparse(&metadata)) +} + +fn direct_game_root_is_safe_directory_sync(path: &Path) -> bool { + path.parent().is_some_and(safe_directory_entry_sync) && safe_directory_entry_sync(path) +} + +fn is_link_or_reparse(metadata: &Metadata) -> bool { + metadata.file_type().is_symlink() || is_windows_reparse_point(metadata) +} + +#[cfg(windows)] +fn is_windows_reparse_point(metadata: &Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +const fn is_windows_reparse_point(_metadata: &Metadata) -> bool { + false } /// Checks if a game is available for download locally. pub async fn local_download_available( game_dir: &Path, + state_dir: &Path, game_id: &str, active_operations: &HashMap, catalog: &GameCatalog, @@ -63,6 +114,22 @@ pub async fn local_download_available( return false; } + if !scoped_blocking(|| safe_directory_entry_sync(game_dir)) { + log::debug!( + "Not serving game {game_id} locally because the configured game directory is unsafe" + ); + return false; + } + + if download_ownership_readiness(game_dir, state_dir, game_id).await + == DownloadOwnershipReadiness::RecoveryRequired + { + log::debug!( + "Not serving game {game_id} locally because download ownership recovery is required" + ); + return false; + } + let game_path = game_dir.join(game_id); version_ini_is_regular_file(game_path.as_path()).await } @@ -70,11 +137,12 @@ pub async fn local_download_available( /// Checks if a local game may be served to peers under the authoritative catalog version. pub async fn local_download_matches_catalog( game_dir: &Path, + state_dir: &Path, game_id: &str, active_operations: &HashMap, catalog: &GameCatalog, ) -> bool { - if !local_download_available(game_dir, game_id, active_operations, catalog).await { + if !local_download_available(game_dir, state_dir, game_id, active_operations, catalog).await { return false; } @@ -83,7 +151,8 @@ pub async fn local_download_matches_catalog( }; let game_path = game_dir.join(game_id); - match lanspread_db::db::read_version_from_ini(&game_path) { + let local_version = scoped_blocking(|| lanspread_db::db::read_version_from_ini(&game_path)); + match local_version { Ok(Some(local_version)) if local_version == expected_version => true, Ok(Some(local_version)) => { log::debug!( @@ -107,7 +176,14 @@ pub async fn local_download_matches_catalog( const LIBRARY_INDEX_FILE: &str = "library_index.json"; -static LIBRARY_INDEX_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +#[derive(Debug, Clone, Copy, Default)] +struct LibraryIndexRuntimeState { + revision_floor: u64, + rewrite_required: bool, +} + +static LIBRARY_INDEX_STATES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); #[derive(Debug, Clone, Serialize, Deserialize)] struct LibraryIndex { @@ -117,7 +193,7 @@ struct LibraryIndex { #[derive(Debug, Clone, Serialize, Deserialize)] struct GameIndexEntry { - summary: GameSummary, + summary: LocalGameSummary, fingerprint: GameFingerprint, } @@ -128,6 +204,10 @@ struct GameFingerprint { #[serde(default)] version_contents: Option, local_dir_present: bool, + #[serde(default)] + download_recovery_required: bool, + #[serde(default)] + runtime_recovery_failed: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -139,8 +219,14 @@ struct EtiFingerprint { #[derive(Debug, Clone)] pub struct LocalLibraryScan { + /// Configured games directory from which this snapshot was produced. + /// + /// Publication compares this value with the current configuration so a + /// scan that completed after a directory switch cannot replace the new + /// library state. + pub source_game_dir: PathBuf, pub game_db: GameDB, - pub summaries: HashMap, + pub summaries: HashMap, pub revision: u64, } @@ -164,9 +250,9 @@ fn library_index_tmp_path(path: &Path) -> PathBuf { path.with_file_name(tmp_name) } -async fn sweep_stale_library_index_tmp(path: &Path) { +fn sweep_stale_library_index_tmp(path: &Path) { let tmp_path = library_index_tmp_path(path); - match tokio::fs::remove_file(&tmp_path).await { + match std::fs::remove_file(&tmp_path) { Ok(()) => log::debug!( "Removed stale library index temp file {}", tmp_path.display() @@ -179,10 +265,10 @@ async fn sweep_stale_library_index_tmp(path: &Path) { } } -async fn load_library_index(path: &Path) -> LibraryIndex { - sweep_stale_library_index_tmp(path).await; +fn load_library_index(path: &Path) -> LibraryIndex { + sweep_stale_library_index_tmp(path); - let data = match tokio::fs::read_to_string(path).await { + let data = match std::fs::read_to_string(path) { Ok(data) => data, Err(err) => { if err.kind() != ErrorKind::NotFound { @@ -207,19 +293,37 @@ async fn load_library_index(path: &Path) -> LibraryIndex { } } -async fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> { +fn load_library_index_with_floor(path: &Path, revision_floor: u64) -> (LibraryIndex, bool) { + let mut index = load_library_index(path); + let revision_regressed = index.revision < revision_floor; + index.revision = index.revision.max(revision_floor); + (index, revision_regressed) +} + +fn advance_library_index_revision(index: &mut LibraryIndex) -> eyre::Result<()> { + index.revision = index + .revision + .checked_add(1) + .ok_or_else(|| eyre::eyre!("local library revision overflow"))?; + Ok(()) +} + +fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> { if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + std::fs::create_dir_all(parent)?; + // Persist `local_library/` in the state directory. Repeating this on + // replacement also repairs an uncertain prior directory-sync failure. + sync_parent_dir(parent)?; } let data = serde_json::to_vec_pretty(index)?; let tmp_path = library_index_tmp_path(path); - let mut file = tokio::fs::File::create(&tmp_path).await?; - file.write_all(&data).await?; - file.sync_all().await?; + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(&data)?; + file.sync_all()?; drop(file); - tokio::fs::rename(&tmp_path, path).await?; + std::fs::rename(&tmp_path, path)?; sync_parent_dir(path)?; Ok(()) } @@ -249,17 +353,24 @@ fn is_root_eti_name(name: &str) -> bool { .is_some_and(|extension| extension == "eti") } -async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result> { - let mut entries = match tokio::fs::read_dir(game_path).await { +fn root_eti_fingerprints(game_path: &Path) -> eyre::Result> { + let entries = match std::fs::read_dir(game_path) { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), Err(err) => return Err(err.into()), }; let mut eti_files = Vec::new(); - while let Some(entry) = entries.next_entry().await? { - let file_type = entry.file_type().await?; - if !file_type.is_file() { + for entry in entries { + let entry = entry?; + let metadata = std::fs::symlink_metadata(entry.path())?; + if is_link_or_reparse(&metadata) { + eyre::bail!( + "local game root contains a link or reparse point: {}", + entry.path().display() + ); + } + if !metadata.is_file() { continue; } @@ -270,7 +381,6 @@ async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result eyre::Result eyre::Result { - let eti_files = root_eti_fingerprints(game_path).await?; +fn fingerprint_game_dir( + game_path: &Path, + download_recovery_required: bool, + runtime_recovery_failed: bool, +) -> eyre::Result { + validate_game_tree_shape(game_path)?; + let eti_files = root_eti_fingerprints(game_path)?; let version_path = game_path.join(VERSION_INI); - let (version_mtime, version_contents) = match tokio::fs::metadata(&version_path).await { - Ok(metadata) if metadata.is_file() => { - let contents = match tokio::fs::read_to_string(&version_path).await { + let (version_mtime, version_contents) = match std::fs::symlink_metadata(&version_path) { + Ok(metadata) if metadata.is_file() && !is_link_or_reparse(&metadata) => { + let contents = match std::fs::read_to_string(&version_path) { Ok(contents) => Some(contents.trim().to_string()), Err(err) => { log::warn!( @@ -303,13 +418,15 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result Err(_) | Ok(_) => (None, None), }; - let local_dir_present = local_dir_is_directory(game_path).await; + let local_dir_present = local_dir_is_directory_sync(game_path); Ok(GameFingerprint { eti_files, version_mtime, version_contents, local_dir_present, + download_recovery_required, + runtime_recovery_failed, }) } @@ -324,112 +441,89 @@ fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool { .is_some_and(is_download_protected_root_name) } -fn canonical_protocol_path(path: &Path) -> eyre::Result { - let mut components = Vec::new(); - for component in path.components() { - let Component::Normal(component) = component else { - eyre::bail!("local game path is not canonical: {}", path.display()); - }; - let component = component - .to_str() - .ok_or_else(|| eyre::eyre!("local game path is not valid UTF-8: {}", path.display()))?; - if component.contains('\\') { +fn validate_game_tree_shape(game_path: &Path) -> eyre::Result<()> { + let mut entries = walkdir::WalkDir::new(game_path).into_iter(); + while let Some(entry) = entries.next() { + let entry = entry?; + if should_skip_root_entry(&entry) { + entries.skip_current_dir(); + continue; + } + + let metadata = std::fs::symlink_metadata(entry.path())?; + if is_link_or_reparse(&metadata) { + if metadata.is_dir() { + entries.skip_current_dir(); + } eyre::bail!( - "local game path cannot be represented portably: {}", - path.display() + "local game tree contains a link or reparse point: {}", + entry.path().display() ); } - components.push(component); } - if components.is_empty() { - eyre::bail!("local game path cannot be empty"); - } - Ok(components.join("/")) + Ok(()) } -async fn scan_game_descriptions( - game_id: &str, - game_dir: &Path, -) -> Result, PeerError> { - let base_dir = game_dir; - let game_path = base_dir.join(game_id); +fn scan_game_size(game_id: &str, game_dir: &Path) -> Result { + let game_path = game_dir.join(game_id); - if !game_path.exists() { + if !direct_game_root_is_safe_directory_sync(&game_path) { return Err(PeerError::Other(eyre::eyre!( - "Game directory does not exist: {}", + "Game directory is missing or unsafe: {}", game_path.display() ))); } - let mut file_descriptions = Vec::new(); - - for entry in walkdir::WalkDir::new(&game_path) - .into_iter() - .filter_entry(|entry| !should_skip_root_entry(entry)) - .filter_map(std::result::Result::ok) - { - let relative_path = match entry.path().strip_prefix(base_dir) { - Ok(path) => canonical_protocol_path(path).map_err(PeerError::Other)?, - Err(e) => { - log::error!( - "Failed to get relative path for {}: {}", - entry.path().display(), - e - ); - continue; + let mut total_size = 0_u64; + let mut entries = walkdir::WalkDir::new(&game_path).into_iter(); + while let Some(entry) = entries.next() { + let entry = entry.map_err(|error| PeerError::Other(error.into()))?; + if should_skip_root_entry(&entry) { + entries.skip_current_dir(); + continue; + } + let metadata = match std::fs::symlink_metadata(entry.path()) { + Ok(metadata) => metadata, + Err(error) => { + let path = entry.path().display().to_string(); + log::error!("Failed to read metadata for {path}: {error}"); + return Err(PeerError::FileSizeDetermination { + path, + source: error, + }); } }; - - let is_dir = entry.file_type().is_dir(); - let size = if is_dir { - 0 - } else { - match tokio::fs::metadata(entry.path()).await { - Ok(metadata) => metadata.len(), - Err(e) => { - log::error!("Failed to read metadata for {relative_path}: {e}"); - return Err(PeerError::FileSizeDetermination { - path: relative_path.clone(), - source: e, - }); - } + if is_link_or_reparse(&metadata) { + if metadata.is_dir() { + entries.skip_current_dir(); } - }; + return Err(PeerError::Other(eyre::eyre!( + "Local game path is a link or reparse point: {}", + entry.path().display() + ))); + } - let file_desc = GameFileDescription { - game_id: game_id.to_string(), - relative_path, - is_dir, - size, - }; - - file_descriptions.push(file_desc); + if metadata.is_file() { + total_size = total_size + .checked_add(metadata.len()) + .ok_or_else(|| PeerError::Other(eyre::eyre!("local game size exceeds u64")))?; + } } - Ok(file_descriptions) + Ok(total_size) } -fn manifest_hash(file_descriptions: &[GameFileDescription]) -> u64 { - let mut entries: Vec<_> = file_descriptions - .iter() - .filter(|desc| !desc.is_dir) - .map(|desc| (&desc.relative_path, desc.size, desc.is_dir)) - .collect(); - entries.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(&b.1))); - - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - for (path, size, is_dir) in entries { - path.hash(&mut hasher); - size.hash(&mut hasher); - is_dir.hash(&mut hasher); - } - hasher.finish() -} - -async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result { +fn build_game_summary( + game_dir: &Path, + game_id: &str, + download_recovery_required: bool, + runtime_recovery_failed: bool, +) -> Result { let game_path = game_dir.join(game_id); - let downloaded = version_ini_is_regular_file(&game_path).await; - let installed = local_dir_is_directory(&game_path).await; + let downloaded = !download_recovery_required + && !runtime_recovery_failed + && version_ini_is_regular_file_sync(&game_path); + let installed = !runtime_recovery_failed && local_dir_is_directory_sync(&game_path); let eti_version = if downloaded { match lanspread_db::db::read_version_from_ini(&game_path) { @@ -443,32 +537,25 @@ async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result Game { +pub(crate) fn game_from_summary(summary: &LocalGameSummary) -> Game { Game { id: summary.id.clone(), name: summary.name.clone(), @@ -489,14 +576,16 @@ pub(crate) fn game_from_summary(summary: &GameSummary) -> Game { } struct IndexUpdate { - summary: Option, + summary: Option, changed: bool, } -async fn update_index_for_game( +fn update_index_for_game( game_root: &Path, game_id: &str, catalog: &GameCatalog, + ownership_readiness: DownloadOwnershipReadiness, + runtime_recovery_failed: bool, index: &mut LibraryIndex, ) -> eyre::Result { if !catalog.contains(game_id) { @@ -507,7 +596,21 @@ async fn update_index_for_game( } let game_path = game_root.join(game_id); - let fingerprint = fingerprint_game_dir(&game_path).await?; + if !direct_game_root_is_safe_directory_sync(&game_path) { + return Ok(IndexUpdate { + summary: None, + changed: index.games.remove(game_id).is_some(), + }); + } + let download_recovery_required = matches!( + ownership_readiness, + DownloadOwnershipReadiness::RecoveryRequired + ); + let fingerprint = fingerprint_game_dir( + &game_path, + download_recovery_required, + runtime_recovery_failed, + )?; if fingerprint.version_mtime.is_none() && !fingerprint.local_dir_present @@ -524,18 +627,15 @@ async fn update_index_for_game( Some(entry) if entry.fingerprint == fingerprint => entry.summary.clone(), _ => { changed = true; - build_game_summary(game_root, game_id).await? + build_game_summary( + game_root, + game_id, + download_recovery_required, + runtime_recovery_failed, + )? } }; - if index - .games - .get(game_id) - .is_some_and(|entry| entry.summary.manifest_hash != summary.manifest_hash) - { - changed = true; - } - index.games.insert( game_id.to_string(), GameIndexEntry { @@ -550,15 +650,32 @@ async fn update_index_for_game( }) } -fn empty_scan() -> LocalLibraryScan { +fn empty_scan(game_dir: &Path, revision: u64) -> LocalLibraryScan { LocalLibraryScan { + source_game_dir: game_dir.to_path_buf(), game_db: GameDB::empty(), summaries: HashMap::new(), - revision: 0, + revision, } } -fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan { +fn clear_index_and_scan_empty( + game_dir: &Path, + state_dir: &Path, + runtime_state: LibraryIndexRuntimeState, +) -> eyre::Result { + let index_path = library_index_path(state_dir); + let (mut index, revision_regressed) = + load_library_index_with_floor(&index_path, runtime_state.revision_floor); + if revision_regressed || runtime_state.rewrite_required || !index.games.is_empty() { + index.games.clear(); + advance_library_index_revision(&mut index)?; + save_library_index(&index_path, &index)?; + } + Ok(empty_scan(game_dir, index.revision)) +} + +fn scan_from_index(index: &LibraryIndex, game_dir: &Path) -> LocalLibraryScan { let summaries = index .games .iter() @@ -571,78 +688,138 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan { .collect::>(); LocalLibraryScan { + source_game_dir: game_dir.to_path_buf(), game_db: GameDB::from(games), summaries, revision: index.revision, } } -// ============================================================================= -// Game database loading -// ============================================================================= - -/// Scans the local game directory and returns summaries plus a game database. -pub async fn scan_local_library( - game_dir: impl AsRef, - state_dir: impl AsRef, - catalog: &GameCatalog, +fn settle_library_scan( + states: &mut HashMap, + index_path: &Path, + result: eyre::Result, ) -> eyre::Result { - let game_path = game_dir.as_ref(); - let state_path = state_dir.as_ref(); - - let metadata = match tokio::fs::metadata(game_path).await { - Ok(metadata) => metadata, - Err(err) => { - if err.kind() == ErrorKind::NotFound { - log::warn!( - "Local game directory {} missing; reporting empty game database", - game_path.display() - ); - return Ok(empty_scan()); - } - return Err(err.into()); + let state = states.entry(index_path.to_path_buf()).or_default(); + match result { + Ok(scan) => { + state.revision_floor = state.revision_floor.max(scan.revision); + state.rewrite_required = false; + Ok(scan) } + Err(error) => { + // A failed atomic save may already have renamed the new index but + // failed its final directory sync. Conservatively force the next + // successful scan to replace and sync a newer revision. + state.rewrite_required = true; + Err(error) + } + } +} + +enum GameDirectoryDiscovery { + Missing, + Unsafe, + Games(Vec), +} + +fn discover_game_ids(game_dir: &Path) -> eyre::Result { + let metadata = match std::fs::symlink_metadata(game_dir) { + Ok(metadata) => metadata, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(GameDirectoryDiscovery::Missing); + } + Err(err) => return Err(err.into()), }; - if !metadata.is_dir() { - log::warn!( - "Configured game directory {} is not a directory; reporting empty game database", - game_path.display() - ); - return Ok(empty_scan()); + if !metadata.is_dir() || is_link_or_reparse(&metadata) { + return Ok(GameDirectoryDiscovery::Unsafe); } - let _index_guard = LIBRARY_INDEX_LOCK.lock().await; - let index_path = library_index_path(state_path); - let mut index = load_library_index(&index_path).await; + let mut game_ids = Vec::new(); + for entry in std::fs::read_dir(game_dir)? { + let entry = entry?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path)?; + if !metadata.is_dir() || is_link_or_reparse(&metadata) { + continue; + } + + let Some(game_id) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !is_ignored_games_root_name(game_id) { + game_ids.push(game_id.to_string()); + } + } + + Ok(GameDirectoryDiscovery::Games(game_ids)) +} + +async fn collect_download_readiness( + game_dir: &Path, + state_dir: &Path, + catalog: &GameCatalog, + game_ids: &[String], +) -> HashMap { + // Ownership storage has its own lexical blocking boundary. Collect those + // fail-closed results before entering the one filesystem/index scan batch. + let mut readiness = HashMap::new(); + for game_id in game_ids { + if catalog.contains(game_id) { + readiness.insert( + game_id.clone(), + download_ownership_readiness(game_dir, state_dir, game_id).await, + ); + } + } + readiness +} + +fn scan_discovered_games( + game_dir: &Path, + state_dir: &Path, + runtime_state: LibraryIndexRuntimeState, + catalog: &GameCatalog, + recovery_failed_ids: &HashSet, + game_ids: &[String], + download_readiness: &HashMap, +) -> eyre::Result { + // The root may have changed while ownership readiness was collected. Fail + // closed instead of traversing through a newly unsafe configured root. + if !safe_directory_entry_sync(game_dir) { + return clear_index_and_scan_empty(game_dir, state_dir, runtime_state); + } + + let index_path = library_index_path(state_dir); + let (mut index, revision_regressed) = + load_library_index_with_floor(&index_path, runtime_state.revision_floor); let mut seen_ids = HashSet::new(); let mut summaries = HashMap::new(); let mut games = Vec::new(); - let mut changed = false; + let mut changed = revision_regressed || runtime_state.rewrite_required; - let mut entries = tokio::fs::read_dir(game_path).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - if is_ignored_games_root_name(game_id) { - continue; - } - - let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?; + for game_id in game_ids { + let readiness = download_readiness + .get(game_id) + .copied() + .unwrap_or(DownloadOwnershipReadiness::RecoveryRequired); + let update = update_index_for_game( + game_dir, + game_id, + catalog, + readiness, + recovery_failed_ids.contains(game_id), + &mut index, + )?; changed |= update.changed; let Some(summary) = update.summary else { continue; }; - seen_ids.insert(game_id.to_string()); - summaries.insert(game_id.to_string(), summary.clone()); + seen_ids.insert(game_id.clone()); + summaries.insert(game_id.clone(), summary.clone()); games.push(game_from_summary(&summary)); } @@ -653,69 +830,183 @@ pub async fn scan_local_library( } if changed { - index.revision = index.revision.saturating_add(1); - if let Err(err) = save_library_index(&index_path, &index).await { - log::warn!( - "Failed to persist library index {}: {err}", - index_path.display() - ); - } + advance_library_index_revision(&mut index)?; + save_library_index(&index_path, &index)?; } Ok(LocalLibraryScan { + source_game_dir: game_dir.to_path_buf(), game_db: GameDB::from(games), summaries, revision: index.revision, }) } +fn rescan_game( + game_dir: &Path, + state_dir: &Path, + runtime_state: LibraryIndexRuntimeState, + catalog: &GameCatalog, + game_id: &str, + download_readiness: DownloadOwnershipReadiness, + runtime_recovery_failed: bool, +) -> eyre::Result { + let index_path = library_index_path(state_dir); + let (mut index, revision_regressed) = + load_library_index_with_floor(&index_path, runtime_state.revision_floor); + + let update = update_index_for_game( + game_dir, + game_id, + catalog, + download_readiness, + runtime_recovery_failed, + &mut index, + )?; + if revision_regressed || runtime_state.rewrite_required || update.changed { + advance_library_index_revision(&mut index)?; + save_library_index(&index_path, &index)?; + } + + Ok(scan_from_index(&index, game_dir)) +} + +// ============================================================================= +// Game database loading +// ============================================================================= + +/// Scans the local game directory and returns summaries plus a game database. +#[cfg(test)] +pub async fn scan_local_library( + game_dir: impl AsRef, + state_dir: impl AsRef, + catalog: &GameCatalog, +) -> eyre::Result { + let recovery_failed_ids = HashSet::new(); + scan_local_library_with_recovery_failures(game_dir, state_dir, catalog, &recovery_failed_ids) + .await +} + +pub(crate) async fn scan_local_library_with_recovery_failures( + game_dir: impl AsRef, + state_dir: impl AsRef, + catalog: &GameCatalog, + recovery_failed_ids: &HashSet, +) -> eyre::Result { + let game_path = game_dir.as_ref(); + let state_path = state_dir.as_ref(); + let index_path = library_index_path(state_path); + let mut states = LIBRARY_INDEX_STATES.lock().await; + let runtime_state = states.get(&index_path).copied().unwrap_or_default(); + let discovery = scoped_blocking(|| discover_game_ids(game_path))?; + let game_ids = match discovery { + GameDirectoryDiscovery::Missing => { + log::warn!( + "Local game directory {} missing; reporting empty game database", + game_path.display() + ); + let result = scoped_blocking(|| { + clear_index_and_scan_empty(game_path, state_path, runtime_state) + }); + return settle_library_scan(&mut states, &index_path, result); + } + GameDirectoryDiscovery::Unsafe => { + log::warn!( + "Configured game directory {} is not a safe directory; reporting empty game database", + game_path.display() + ); + let result = scoped_blocking(|| { + clear_index_and_scan_empty(game_path, state_path, runtime_state) + }); + return settle_library_scan(&mut states, &index_path, result); + } + GameDirectoryDiscovery::Games(game_ids) => game_ids, + }; + + let download_readiness = + collect_download_readiness(game_path, state_path, catalog, &game_ids).await; + // Loading the prior index, inspecting game trees, and atomically replacing + // the index are one non-detachable filesystem batch. + let result = scoped_blocking(|| { + scan_discovered_games( + game_path, + state_path, + runtime_state, + catalog, + recovery_failed_ids, + &game_ids, + &download_readiness, + ) + }); + settle_library_scan(&mut states, &index_path, result) +} + /// Rescans a single game root through the cached index and returns full library state. +#[cfg(test)] pub async fn rescan_local_game( game_dir: impl AsRef, state_dir: impl AsRef, catalog: &GameCatalog, game_id: &str, ) -> eyre::Result { - let game_path = game_dir.as_ref(); - let state_path = state_dir.as_ref(); - let _index_guard = LIBRARY_INDEX_LOCK.lock().await; - let index_path = library_index_path(state_path); - let mut index = load_library_index(&index_path).await; - - let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?; - if update.changed { - index.revision = index.revision.saturating_add(1); - if let Err(err) = save_library_index(&index_path, &index).await { - log::warn!( - "Failed to persist library index {}: {err}", - index_path.display() - ); - } - } - - Ok(scan_from_index(&index)) + let recovery_failed_ids = HashSet::new(); + rescan_local_game_with_recovery_failures( + game_dir, + state_dir, + catalog, + game_id, + &recovery_failed_ids, + ) + .await } -// ============================================================================= -// Game file descriptions -// ============================================================================= - -/// Gets file descriptions for a game from the local filesystem. -pub async fn get_game_file_descriptions( - game_id: &str, +pub(crate) async fn rescan_local_game_with_recovery_failures( game_dir: impl AsRef, -) -> Result, PeerError> { - scan_game_descriptions(game_id, game_dir.as_ref()).await + state_dir: impl AsRef, + catalog: &GameCatalog, + game_id: &str, + recovery_failed_ids: &HashSet, +) -> eyre::Result { + let game_path = game_dir.as_ref(); + let state_path = state_dir.as_ref(); + let index_path = library_index_path(state_path); + let mut states = LIBRARY_INDEX_STATES.lock().await; + let runtime_state = states.get(&index_path).copied().unwrap_or_default(); + let should_check_ownership = catalog.contains(game_id) + && scoped_blocking(|| direct_game_root_is_safe_directory_sync(&game_path.join(game_id))); + let download_readiness = if should_check_ownership { + download_ownership_readiness(game_path, state_path, game_id).await + } else { + DownloadOwnershipReadiness::RecoveryRequired + }; + // Keep the read-modify-write index transaction lexically owned by this + // rescan after its ownership prerequisite has settled. + let result = scoped_blocking(|| { + rescan_game( + game_path, + state_path, + runtime_state, + catalog, + game_id, + download_readiness, + recovery_failed_ids.contains(game_id), + ) + }); + settle_library_scan(&mut states, &index_path, result) } #[cfg(test)] mod tests { use std::{collections::HashMap, path::Path}; - use lanspread_proto::Availability; + use lanspread_db::db::Availability; use super::*; - use crate::{context::OperationKind, test_support::TempDir}; + use crate::{ + context::OperationKind, + download::{seed_download_ownership_for_test, seed_pending_download_ownership_for_test}, + test_support::TempDir, + }; fn write_file(path: &Path, bytes: &[u8]) { if let Some(parent) = path.parent() { @@ -724,72 +1015,74 @@ mod tests { std::fs::write(path, bytes).expect("file should be written"); } - #[test] - fn protocol_paths_always_use_forward_slashes() { - let native = PathBuf::from("game").join("nested").join("archive.eti"); - assert_eq!( - canonical_protocol_path(&native).expect("native path should convert"), - "game/nested/archive.eti" - ); - } - - fn test_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex { + fn test_library_index(revision: u64, id: &str, shape: u64) -> LibraryIndex { LibraryIndex { revision, games: HashMap::from([( id.to_string(), GameIndexEntry { - summary: GameSummary { + summary: LocalGameSummary { id: id.to_string(), name: id.to_string(), - size: manifest_hash, + size: shape, downloaded: true, installed: false, eti_version: Some("20250101".to_string()), - manifest_hash, availability: Availability::Ready, }, fingerprint: GameFingerprint { eti_files: Vec::new(), - version_mtime: Some(manifest_hash), + version_mtime: Some(shape), version_contents: Some("20250101".to_string()), local_dir_present: false, + download_recovery_required: false, + runtime_recovery_failed: false, }, }, )]), } } - #[tokio::test] - async fn save_library_index_is_atomic_on_replace() { + #[test] + fn legacy_fingerprint_defaults_download_recovery_to_false() { + let fingerprint: GameFingerprint = serde_json::from_value(serde_json::json!({ + "eti_files": [], + "version_mtime": 1, + "version_contents": "20250101", + "local_dir_present": true + })) + .expect("legacy fingerprint should deserialize"); + + assert!(!fingerprint.download_recovery_required); + assert!(!fingerprint.runtime_recovery_failed); + } + + #[test] + fn save_library_index_is_atomic_on_replace() { let temp = TempDir::new("lanspread-local-games"); let index_path = library_index_path(temp.path()); let tmp_path = library_index_tmp_path(&index_path); let first = test_library_index(1, "game-a", 11); - save_library_index(&index_path, &first) - .await - .expect("first index write should succeed"); + save_library_index(&index_path, &first).expect("first index write should succeed"); assert!(!tmp_path.exists()); let second = test_library_index(2, "game-b", 22); - save_library_index(&index_path, &second) - .await - .expect("replacement index write should succeed"); + save_library_index(&index_path, &second).expect("replacement index write should succeed"); assert!(!tmp_path.exists()); - let loaded = load_library_index(&index_path).await; + let loaded = load_library_index(&index_path); assert_eq!(loaded.revision, 2); assert!(!loaded.games.contains_key("game-a")); let game_b = loaded .games .get("game-b") .expect("replacement index should be persisted"); - assert_eq!(game_b.summary.manifest_hash, 22); + assert_eq!(game_b.summary.size, 22); } - #[tokio::test] - async fn load_library_index_sweeps_stale_tmp() { + #[test] + fn load_library_index_sweeps_stale_tmp() { let temp = TempDir::new("lanspread-local-games"); let index_path = library_index_path(temp.path()); let tmp_path = library_index_tmp_path(&index_path); @@ -799,13 +1092,40 @@ mod tests { write_file(&index_path, &data); write_file(&tmp_path, b"{ not json"); - let loaded = load_library_index(&index_path).await; + let loaded = load_library_index(&index_path); assert_eq!(loaded.revision, 7); assert!(loaded.games.contains_key("game")); assert!(!tmp_path.exists()); } + #[tokio::test] + async fn failed_index_save_does_not_return_or_record_a_revision() { + let games = TempDir::new("lanspread-local-games-save-failure"); + let state = TempDir::new("lanspread-local-games-save-failure-state"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + write_file(&games.path().join("game/version.ini"), b"20250101"); + + let index_path = library_index_path(state.path()); + let index_parent = index_path + .parent() + .expect("index should have a parent") + .to_path_buf(); + write_file(&index_parent, b"blocks index directory creation"); + + let _error = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect_err("an index save failure must fail the scan"); + + std::fs::remove_file(&index_parent).expect("index blocker should be removable"); + let retry = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("scan should succeed once durable storage is available"); + + assert_eq!(retry.revision, 1); + assert_eq!(load_library_index(&index_path).revision, 1); + } + #[tokio::test] async fn scan_uses_version_ini_and_local_dir_as_independent_state() { let temp = TempDir::new("lanspread-local-games"); @@ -860,6 +1180,158 @@ mod tests { assert!(!scan.summaries.contains_key("non-catalog")); } + #[cfg(unix)] + #[tokio::test] + async fn scan_and_readiness_never_follow_symlink_game_root() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-local-games-symlink-root"); + let outside = TempDir::new("lanspread-local-games-symlink-outside"); + let state = TempDir::new("lanspread-local-games-symlink-state"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + let game_root = games.path().join("game"); + write_file(&game_root.join("version.ini"), b"20250101"); + write_file(&game_root.join("local/original.txt"), b"original"); + + let ready = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("ordinary game root should scan"); + assert!(ready.summaries["game"].downloaded); + assert!(ready.summaries["game"].installed); + + std::fs::remove_dir_all(&game_root).expect("ordinary game root should be removable"); + write_file(&outside.path().join("version.ini"), b"20250101"); + write_file(&outside.path().join("local/outside.txt"), b"outside"); + write_file(&outside.path().join("canary.txt"), b"canary"); + symlink(outside.path(), &game_root).expect("game-root symlink should be created"); + + assert!(!game_root_is_safe_directory(&game_root).await); + assert!(!version_ini_is_regular_file(&game_root).await); + assert!(!local_dir_is_directory(&game_root).await); + assert!( + !local_download_available( + games.path(), + state.path(), + "game", + &HashMap::new(), + &catalog, + ) + .await + ); + assert!(scan_game_size("game", games.path()).is_err()); + + let quarantined = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("unsafe root should be omitted without traversal"); + assert!(quarantined.revision > ready.revision); + assert!(!quarantined.summaries.contains_key("game")); + assert_eq!( + std::fs::read(outside.path().join("canary.txt")) + .expect("canary should remain readable"), + b"canary" + ); + assert_eq!( + std::fs::read(outside.path().join("local/outside.txt")) + .expect("outside install should remain readable"), + b"outside" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn targeted_rescan_and_readiness_never_follow_replaced_configured_root() { + use std::os::unix::fs::symlink; + + let container = TempDir::new("lanspread-local-games-configured-root-container"); + let configured_root = container.path().join("configured"); + let outside = TempDir::new("lanspread-local-games-configured-root-outside"); + let state = TempDir::new("lanspread-local-games-configured-root-state"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + write_file(&configured_root.join("game/version.ini"), b"20250101"); + + let ready = scan_local_library(&configured_root, state.path(), &catalog) + .await + .expect("ordinary configured root should scan"); + assert!(ready.summaries.contains_key("game")); + + std::fs::remove_dir_all(&configured_root) + .expect("ordinary configured root should be removable"); + write_file(&outside.path().join("game/version.ini"), b"20250101"); + write_file(&outside.path().join("game/canary.txt"), b"outside"); + symlink(outside.path(), &configured_root) + .expect("configured-root symlink should be created"); + + let game_root = configured_root.join("game"); + assert!(!game_root_is_safe_directory(&game_root).await); + assert!(!version_ini_is_regular_file(&game_root).await); + assert!( + !local_download_available( + &configured_root, + state.path(), + "game", + &HashMap::new(), + &catalog, + ) + .await + ); + assert!(scan_game_size("game", &configured_root).is_err()); + + let quarantined = rescan_local_game(&configured_root, state.path(), &catalog, "game") + .await + .expect("unsafe configured root should be removed from the index"); + assert_eq!(quarantined.revision, ready.revision + 1); + assert!(!quarantined.summaries.contains_key("game")); + assert_eq!( + std::fs::read(outside.path().join("game/canary.txt")) + .expect("outside canary should remain readable"), + b"outside" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn cached_scan_rejects_descendant_symlink() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-local-games-descendant-link"); + let outside = TempDir::new("lanspread-local-games-descendant-link-outside"); + let state = TempDir::new("lanspread-local-games-descendant-link-state"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + let game_root = games.path().join("game"); + write_file(&game_root.join("version.ini"), b"20250101"); + write_file(&game_root.join("nested/ordinary.txt"), b"ordinary"); + + let initial = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("ordinary game tree should scan"); + assert!(initial.summaries.contains_key("game")); + + write_file(&outside.path().join("canary.txt"), b"outside"); + symlink( + outside.path().join("canary.txt"), + game_root.join("nested/leak.txt"), + ) + .expect("descendant symlink should be created"); + + assert!( + rescan_local_game(games.path(), state.path(), &catalog, "game") + .await + .is_err(), + "a cached fingerprint must not hide a new descendant link" + ); + assert!(scan_game_size("game", games.path()).is_err()); + assert_eq!( + load_library_index(&library_index_path(state.path())).revision, + initial.revision, + "a rejected tree must not advance the durable index" + ); + assert_eq!( + std::fs::read(outside.path().join("canary.txt")) + .expect("outside canary should remain readable"), + b"outside" + ); + } + #[tokio::test] async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() { let temp = TempDir::new("lanspread-local-games"); @@ -894,6 +1366,183 @@ mod tests { assert_eq!(ready.availability, Availability::Ready); } + #[tokio::test] + async fn journal_only_recovery_transitions_invalidate_cached_ready_summary_once() { + let games = TempDir::new("lanspread-local-games-journal-transition"); + let state = TempDir::new("lanspread-local-games-journal-state"); + let game_root = games.path().join("game"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + + write_file(&game_root.join("version.ini"), b"20250101"); + write_file(&game_root.join("archive.eti"), b"archive"); + std::fs::create_dir_all(game_root.join("local")) + .expect("local install dir should be created"); + seed_download_ownership_for_test(state.path(), games.path(), "game", &["archive.eti"]) + .await; + + let ready_scan = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("initial scan should succeed"); + let initial_revision = ready_scan.revision; + let ready = ready_scan + .summaries + .get("game") + .expect("settled game should be indexed"); + assert!(ready.downloaded); + assert!(ready.installed); + assert_eq!(ready.eti_version.as_deref(), Some("20250101")); + assert_eq!(ready.availability, Availability::Ready); + + seed_pending_download_ownership_for_test( + state.path(), + games.path(), + "game", + &["archive.eti"], + &["archive.eti"], + ) + .await; + + let quarantined_scan = rescan_local_game(games.path(), state.path(), &catalog, "game") + .await + .expect("quarantine rescan should succeed"); + assert_eq!(quarantined_scan.revision, initial_revision + 1); + let quarantined = quarantined_scan + .summaries + .get("game") + .expect("quarantined installed game should remain indexed"); + assert!(!quarantined.downloaded); + assert!(quarantined.installed); + assert_eq!(quarantined.eti_version, None); + assert_eq!(quarantined.availability, Availability::LocalOnly); + + let unchanged_quarantine = rescan_local_game(games.path(), state.path(), &catalog, "game") + .await + .expect("repeated quarantine rescan should succeed"); + assert_eq!(unchanged_quarantine.revision, quarantined_scan.revision); + + seed_download_ownership_for_test(state.path(), games.path(), "game", &["archive.eti"]) + .await; + + let settled_scan = rescan_local_game(games.path(), state.path(), &catalog, "game") + .await + .expect("settled rescan should succeed"); + assert_eq!(settled_scan.revision, quarantined_scan.revision + 1); + let settled = settled_scan + .summaries + .get("game") + .expect("settled game should remain indexed"); + assert!(settled.downloaded); + assert!(settled.installed); + assert_eq!(settled.eti_version.as_deref(), Some("20250101")); + assert_eq!(settled.availability, Availability::Ready); + + let unchanged_settled = rescan_local_game(games.path(), state.path(), &catalog, "game") + .await + .expect("repeated settled rescan should succeed"); + assert_eq!(unchanged_settled.revision, settled_scan.revision); + } + + #[tokio::test] + async fn runtime_recovery_failure_hides_download_and_install_until_retry() { + let games = TempDir::new("lanspread-local-games-runtime-recovery"); + let state = TempDir::new("lanspread-local-games-runtime-recovery-state"); + let game_root = games.path().join("game"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + write_file(&game_root.join("version.ini"), b"20250101"); + write_file(&game_root.join("archive.eti"), b"archive"); + write_file(&game_root.join("local/payload.txt"), b"installed"); + + let ready = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("ready library should scan"); + let ready_revision = ready.revision; + assert!(ready.summaries["game"].downloaded); + assert!(ready.summaries["game"].installed); + + let failed_ids = HashSet::from(["game".to_string()]); + let failed = scan_local_library_with_recovery_failures( + games.path(), + state.path(), + &catalog, + &failed_ids, + ) + .await + .expect("failed recovery projection should scan"); + assert!(failed.revision > ready_revision); + assert!(!failed.summaries["game"].downloaded); + assert!(!failed.summaries["game"].installed); + assert_eq!( + failed.summaries["game"].availability, + Availability::LocalOnly + ); + + let retried = scan_local_library_with_recovery_failures( + games.path(), + state.path(), + &catalog, + &HashSet::new(), + ) + .await + .expect("successful retry projection should scan"); + assert!(retried.revision > failed.revision); + assert!(retried.summaries["game"].downloaded); + assert!(retried.summaries["game"].installed); + } + + #[tokio::test] + async fn missing_game_directory_produces_monotonic_empty_revision() { + let games = TempDir::new("lanspread-local-games-missing-root"); + let state = TempDir::new("lanspread-local-games-missing-root-state"); + let game_root = games.path().join("game"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + write_file(&game_root.join("version.ini"), b"20250101"); + write_file(&game_root.join("archive.eti"), b"archive"); + + let ready_scan = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("initial scan should succeed"); + assert!(ready_scan.summaries.contains_key("game")); + std::fs::remove_dir_all(games.path()).expect("games directory should be removable"); + + let empty = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("missing root should produce an empty scan"); + + assert!(empty.summaries.is_empty()); + assert!(empty.game_db.games.is_empty()); + assert!(empty.revision > ready_scan.revision); + } + + #[tokio::test] + async fn corrupt_index_rebuilds_above_in_process_revision_floor() { + let games = TempDir::new("lanspread-local-games-corrupt-index"); + let state = TempDir::new("lanspread-local-games-corrupt-index-state"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + let game_root = games.path().join("game"); + write_file(&game_root.join("version.ini"), b"20250101"); + + let first = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("initial scan should succeed"); + assert_eq!(first.revision, 1); + + write_file(&game_root.join("archive.eti"), b"archive"); + let second = rescan_local_game(games.path(), state.path(), &catalog, "game") + .await + .expect("changed game should rescan"); + assert_eq!(second.revision, 2); + + let index_path = library_index_path(state.path()); + write_file(&index_path, b"{ corrupt index"); + let rebuilt = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("corrupt index should rebuild above the live revision floor"); + + assert_eq!(rebuilt.revision, second.revision + 1); + assert!(rebuilt.summaries.contains_key("game")); + assert_eq!(load_library_index(&index_path).revision, rebuilt.revision); + } + #[tokio::test] async fn concurrent_rescans_preserve_both_index_updates() { let temp = TempDir::new("lanspread-local-games-concurrent"); @@ -917,7 +1566,7 @@ mod tests { scan_a.expect("game-a rescan should succeed"); scan_b.expect("game-b rescan should succeed"); - let index = load_library_index(&library_index_path(state.path())).await; + let index = load_library_index(&library_index_path(state.path())); assert_eq!(index.revision, 3); let game_a = index .games @@ -940,26 +1589,100 @@ mod tests { #[tokio::test] async fn local_download_available_gates_on_catalog_operation_and_sentinel() { let temp = TempDir::new("lanspread-local-games"); + let state = TempDir::new("lanspread-local-games-state"); let game_root = temp.path().join("game"); write_file(&game_root.join("version.ini"), b"20250101"); let catalog = GameCatalog::from_ids(["game".to_string()]); let no_operations = HashMap::new(); - assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await); - - let active_operations = HashMap::from([("game".to_string(), OperationKind::Downloading)]); - assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await); - assert!( - !local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty()) + local_download_available(temp.path(), state.path(), "game", &no_operations, &catalog) + .await + ); + + let active_operations = HashMap::from([("game".to_string(), OperationKind::Downloading)]); + assert!( + !local_download_available( + temp.path(), + state.path(), + "game", + &active_operations, + &catalog + ) + .await + ); + + assert!( + !local_download_available( + temp.path(), + state.path(), + "game", + &no_operations, + &GameCatalog::empty() + ) + .await + ); + assert!( + !local_download_available( + temp.path(), + state.path(), + "missing", + &no_operations, + &catalog + ) + .await + ); + } + + #[tokio::test] + async fn local_download_available_quarantines_only_matching_pending_ownership() { + let games = TempDir::new("lanspread-local-games-readiness"); + let foreign_games = TempDir::new("lanspread-local-games-foreign-readiness"); + let state = TempDir::new("lanspread-local-games-readiness-state"); + let game_root = games.path().join("game"); + write_file(&game_root.join("version.ini"), b"20250101"); + + let catalog = GameCatalog::from_ids(["game".to_string()]); + let no_operations = HashMap::new(); + + seed_pending_download_ownership_for_test( + state.path(), + games.path(), + "game", + &["archive.eti"], + &["archive.eti"], + ) + .await; + assert!( + !local_download_available(games.path(), state.path(), "game", &no_operations, &catalog) + .await + ); + + seed_download_ownership_for_test(state.path(), games.path(), "game", &["archive.eti"]) + .await; + assert!( + local_download_available(games.path(), state.path(), "game", &no_operations, &catalog) + .await + ); + + seed_pending_download_ownership_for_test( + state.path(), + foreign_games.path(), + "game", + &["archive.eti"], + &["archive.eti"], + ) + .await; + assert!( + local_download_available(games.path(), state.path(), "game", &no_operations, &catalog) .await ); - assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await); } #[tokio::test] async fn local_download_matches_catalog_requires_expected_version() { let temp = TempDir::new("lanspread-local-games"); + let state = TempDir::new("lanspread-local-games-state"); let game_root = temp.path().join("game"); write_file(&game_root.join("version.ini"), b"20260101"); @@ -968,12 +1691,26 @@ mod tests { let no_operations = HashMap::new(); assert!( - !local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await + !local_download_matches_catalog( + temp.path(), + state.path(), + "game", + &no_operations, + &catalog + ) + .await ); catalog.insert("game".to_string(), Some("20260101".to_string())); assert!( - local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await + local_download_matches_catalog( + temp.path(), + state.path(), + "game", + &no_operations, + &catalog + ) + .await ); } } diff --git a/crates/lanspread-peer/src/migration.rs b/crates/lanspread-peer/src/migration.rs index 019a5a3..8d66dc6 100644 --- a/crates/lanspread-peer/src/migration.rs +++ b/crates/lanspread-peer/src/migration.rs @@ -1,12 +1,12 @@ use std::{ - io::ErrorKind, + fs, + io::{ErrorKind, Write as _}, path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, + thread, time::Instant, }; -use futures::{StreamExt as _, stream}; -use tokio::io::AsyncWriteExt as _; - use crate::{ game_paths::{ LEGACY_FIRST_START_DONE_FILE, @@ -16,8 +16,8 @@ use crate::{ LEGACY_SOFTLAN_INSTALL_MARKER, is_ignored_games_root_name, }, - install::intent::{InstallIntent, intent_path, write_intent}, local_games::legacy_library_index_path, + scoped_blocking::scoped_blocking, state_paths::{local_library_index_path, setup_done_path}, }; @@ -54,9 +54,9 @@ pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> Migratio let started = Instant::now(); let mut report = MigrationReport::default(); - report.merge(migrate_library_index(game_dir, state_dir).await); + report.merge(migrate_library_index(game_dir, state_dir)); - let game_roots = match collect_game_roots(game_dir).await { + let game_roots = match scoped_blocking(|| collect_game_roots(game_dir)) { Ok(game_roots) => game_roots, Err(err) => { if err.kind() != ErrorKind::NotFound { @@ -71,25 +71,19 @@ pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> Migratio } }; - let game_reports = stream::iter(game_roots) - .map(|(id, root)| async move { migrate_game_root(state_dir, id, root).await }) - .buffer_unordered(MIGRATION_CONCURRENCY) - .collect::>() - .await; - - for game_report in game_reports { - report.merge(game_report); - } + report.merge(scoped_blocking(|| { + migrate_game_roots(state_dir, &game_roots) + })); log_migration_report(&report, started); report } -async fn collect_game_roots(game_dir: &Path) -> std::io::Result> { +fn collect_game_roots(game_dir: &Path) -> std::io::Result> { let mut roots = Vec::new(); - let mut entries = tokio::fs::read_dir(game_dir).await?; - while let Some(entry) = entries.next_entry().await? { - if !entry.file_type().await?.is_dir() { + for entry in fs::read_dir(game_dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { continue; } @@ -105,12 +99,48 @@ async fn collect_game_roots(game_dir: &Path) -> std::io::Result MigrationReport { +fn migrate_game_roots(state_dir: &Path, game_roots: &[(String, PathBuf)]) -> MigrationReport { + if game_roots.is_empty() { + return MigrationReport::default(); + } + + let next_root = AtomicUsize::new(0); + let worker_count = game_roots.len().min(MIGRATION_CONCURRENCY); + // Scoped workers preserve the former bounded overlap while guaranteeing + // that success, cancellation, and panic cannot leave filesystem work behind. + thread::scope(|scope| { + let mut workers = Vec::with_capacity(worker_count); + for _ in 0..worker_count { + workers.push(scope.spawn(|| { + let mut report = MigrationReport::default(); + loop { + let index = next_root.fetch_add(1, Ordering::Relaxed); + let Some((id, root)) = game_roots.get(index) else { + break; + }; + report.merge(migrate_game_root(state_dir, id, root)); + } + report + })); + } + + let mut report = MigrationReport::default(); + for worker in workers { + match worker.join() { + Ok(worker_report) => report.merge(worker_report), + Err(payload) => std::panic::resume_unwind(payload), + } + } + report + }) +} + +fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport { let mut report = MigrationReport::default(); let legacy_path = legacy_library_index_path(game_dir); let target_path = local_library_index_path(state_dir); - match migrate_raw_file(&legacy_path, &target_path).await { + match scoped_blocking(|| migrate_raw_file(&legacy_path, &target_path)) { Ok(MigrationOutcome::Migrated) => { report.library_index_migrated = true; report.legacy_files_deleted += 1; @@ -129,114 +159,56 @@ async fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationRe } } - report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)).await); - report.merge(remove_empty_legacy_library_dir(game_dir).await); + report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path))); + report.merge(remove_empty_legacy_library_dir(game_dir)); report } -async fn migrate_game_root(state_dir: &Path, id: String, root: PathBuf) -> MigrationReport { +fn migrate_game_root(state_dir: &Path, id: &str, root: &Path) -> MigrationReport { let mut report = MigrationReport { games_checked: 1, ..MigrationReport::default() }; - report.merge(migrate_install_intent(state_dir, &id, &root).await); - report.merge(delete_if_exists(&root.join(LEGACY_INTENT_TMP_FILE)).await); - report.merge(migrate_setup_marker(state_dir, &id, &root).await); - report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)).await); - report.merge(note_unknown_softlan_files(&root).await); + report.merge(note_legacy_install_intent(root)); + report.merge(migrate_setup_marker(state_dir, id, root)); + report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER))); + report.merge(note_unknown_softlan_files(root)); report } -async fn migrate_install_intent(state_dir: &Path, id: &str, root: &Path) -> MigrationReport { +fn note_legacy_install_intent(root: &Path) -> MigrationReport { let mut report = MigrationReport::default(); - let legacy_path = root.join(LEGACY_INTENT_FILE); - let target_path = intent_path(state_dir, id); - - match path_exists(&legacy_path).await { - Ok(false) => return report, - Ok(true) => {} - Err(err) => { - log::warn!( - "Failed to inspect legacy install intent {}: {err}", - legacy_path.display() - ); - report.failures += 1; - return report; + for name in [LEGACY_INTENT_FILE, LEGACY_INTENT_TMP_FILE] { + let path = root.join(name); + match scoped_blocking(|| path_exists(&path)) { + Ok(false) => {} + Ok(true) => { + log::warn!( + "Leaving unsupported legacy install intent in place: {}", + path.display() + ); + report.failures += 1; + } + Err(error) => { + log::warn!( + "Failed to inspect legacy install intent {}: {error}", + path.display() + ); + report.failures += 1; + } } } - - match path_exists(&target_path).await { - Ok(true) => { - report.merge(delete_file(&legacy_path).await); - return report; - } - Ok(false) => {} - Err(err) => { - log::warn!( - "Failed to inspect app-state install intent {}: {err}", - target_path.display() - ); - report.failures += 1; - return report; - } - } - - let data = match tokio::fs::read_to_string(&legacy_path).await { - Ok(data) => data, - Err(err) => { - log::warn!( - "Failed to read legacy install intent {}: {err}", - legacy_path.display() - ); - report.failures += 1; - return report; - } - }; - - let intent = match serde_json::from_str::(&data) { - Ok(intent) if intent.is_current_for(id) => intent, - Ok(intent) => { - log::warn!( - "Leaving legacy install intent {} in place because it belongs to id {} schema {}", - legacy_path.display(), - intent.id, - intent.schema_version - ); - report.failures += 1; - return report; - } - Err(err) => { - log::warn!( - "Leaving corrupt legacy install intent {} in place: {err}", - legacy_path.display() - ); - report.failures += 1; - return report; - } - }; - - if let Err(err) = write_intent(state_dir, id, &intent).await { - log::warn!( - "Failed to write migrated install intent {}: {err}", - target_path.display() - ); - report.failures += 1; - return report; - } - - report.install_intents_migrated += 1; - report.merge(delete_file(&legacy_path).await); report } -async fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport { +fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport { let mut report = MigrationReport::default(); let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); let target_path = setup_done_path(state_dir, id); - match migrate_empty_marker(&legacy_path, &target_path).await { + match scoped_blocking(|| migrate_empty_marker(&legacy_path, &target_path)) { Ok(MigrationOutcome::Migrated) => { report.setup_markers_migrated += 1; report.legacy_files_deleted += 1; @@ -258,16 +230,18 @@ async fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> Migrat report } -async fn note_unknown_softlan_files(root: &Path) -> MigrationReport { - let mut report = MigrationReport::default(); - report.unknown_softlan_files += count_unknown_softlan_files(root).await; - report.unknown_softlan_files += count_unknown_softlan_files(&root.join("local")).await; - report +fn note_unknown_softlan_files(root: &Path) -> MigrationReport { + MigrationReport { + unknown_softlan_files: scoped_blocking(|| { + count_unknown_softlan_files(root) + count_unknown_softlan_files(&root.join("local")) + }), + ..MigrationReport::default() + } } -async fn count_unknown_softlan_files(dir: &Path) -> usize { +fn count_unknown_softlan_files(dir: &Path) -> usize { let mut count = 0; - let mut entries = match tokio::fs::read_dir(dir).await { + let entries = match fs::read_dir(dir) { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => return 0, Err(err) => { @@ -279,7 +253,8 @@ async fn count_unknown_softlan_files(dir: &Path) -> usize { } }; - while let Ok(Some(entry)) = entries.next_entry().await { + for entry in entries { + let Ok(entry) = entry else { break }; let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; @@ -306,61 +281,55 @@ enum MigrationOutcome { Migrated, } -async fn migrate_raw_file( - legacy_path: &Path, - target_path: &Path, -) -> std::io::Result { - if !path_exists(legacy_path).await? { +fn migrate_raw_file(legacy_path: &Path, target_path: &Path) -> std::io::Result { + if !path_exists(legacy_path)? { return Ok(MigrationOutcome::SourceMissing); } - if path_exists(target_path).await? { - remove_file_if_exists(legacy_path).await?; + if path_exists(target_path)? { + remove_file_if_exists(legacy_path)?; return Ok(MigrationOutcome::TargetAlreadyExists); } - let data = tokio::fs::read(legacy_path).await?; - write_bytes_atomically(target_path, &data).await?; - remove_file_if_exists(legacy_path).await?; + let data = fs::read(legacy_path)?; + write_bytes_atomically(target_path, &data)?; + remove_file_if_exists(legacy_path)?; Ok(MigrationOutcome::Migrated) } -async fn migrate_empty_marker( +fn migrate_empty_marker( legacy_path: &Path, target_path: &Path, ) -> std::io::Result { - if !path_exists(legacy_path).await? { + if !path_exists(legacy_path)? { return Ok(MigrationOutcome::SourceMissing); } - if path_exists(target_path).await? { - remove_file_if_exists(legacy_path).await?; + if path_exists(target_path)? { + remove_file_if_exists(legacy_path)?; return Ok(MigrationOutcome::TargetAlreadyExists); } if let Some(parent) = target_path.parent() { - tokio::fs::create_dir_all(parent).await?; + fs::create_dir_all(parent)?; } - tokio::fs::File::create(target_path) - .await? - .sync_all() - .await?; - remove_file_if_exists(legacy_path).await?; + fs::File::create(target_path)?.sync_all()?; + remove_file_if_exists(legacy_path)?; Ok(MigrationOutcome::Migrated) } -async fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> { +fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> { if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + fs::create_dir_all(parent)?; } let tmp_path = library_index_tmp_path(path); - let mut file = tokio::fs::File::create(&tmp_path).await?; - file.write_all(data).await?; - file.sync_all().await?; + let mut file = fs::File::create(&tmp_path)?; + file.write_all(data)?; + file.sync_all()?; drop(file); - tokio::fs::rename(&tmp_path, path).await?; + fs::rename(&tmp_path, path)?; sync_parent_dir(path) } @@ -374,16 +343,16 @@ fn library_index_tmp_path(path: &Path) -> PathBuf { path.with_file_name(tmp_name) } -async fn path_exists(path: &Path) -> std::io::Result { - match tokio::fs::metadata(path).await { +fn path_exists(path: &Path) -> std::io::Result { + match fs::metadata(path) { Ok(_) => Ok(true), Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), Err(err) => Err(err), } } -async fn delete_if_exists(path: &Path) -> MigrationReport { - match remove_file_if_exists(path).await { +fn delete_if_exists(path: &Path) -> MigrationReport { + match scoped_blocking(|| remove_file_if_exists(path)) { Ok(true) => MigrationReport { legacy_files_deleted: 1, ..MigrationReport::default() @@ -399,75 +368,61 @@ async fn delete_if_exists(path: &Path) -> MigrationReport { } } -async fn delete_file(path: &Path) -> MigrationReport { - match remove_file_if_exists(path).await { - Ok(true) => MigrationReport { - legacy_files_deleted: 1, - ..MigrationReport::default() - }, - Ok(false) => MigrationReport::default(), - Err(err) => { - log::warn!("Failed to delete legacy file {}: {err}", path.display()); - MigrationReport { - failures: 1, - ..MigrationReport::default() - } - } - } -} - -async fn remove_file_if_exists(path: &Path) -> std::io::Result { - if !path_exists(path).await? { +fn remove_file_if_exists(path: &Path) -> std::io::Result { + if !path_exists(path)? { return Ok(false); } - match tokio::fs::remove_file(path).await { + match fs::remove_file(path) { Ok(()) => Ok(true), Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), Err(err) => Err(err), } } -async fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport { +fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport { let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR); - let exists = match path_exists(&path).await { - Ok(exists) => exists, - Err(err) => { - log::warn!( - "Failed to inspect legacy library index directory {}: {err}", - path.display() - ); - return MigrationReport { - failures: 1, - ..MigrationReport::default() - }; + scoped_blocking(|| { + let exists = match path_exists(&path) { + Ok(exists) => exists, + Err(err) => { + log::warn!( + "Failed to inspect legacy library index directory {}: {err}", + path.display() + ); + return MigrationReport { + failures: 1, + ..MigrationReport::default() + }; + } + }; + if !exists { + return MigrationReport::default(); } - }; - if !exists { - return MigrationReport::default(); - } - match tokio::fs::remove_dir(&path).await { - Ok(()) => MigrationReport { - legacy_files_deleted: 1, - ..MigrationReport::default() - }, - Err(err) - if err.kind() == ErrorKind::NotFound || err.kind() == ErrorKind::DirectoryNotEmpty => - { - MigrationReport::default() - } - Err(err) => { - log::warn!( - "Failed to remove empty legacy library index directory {}: {err}", - path.display() - ); - MigrationReport { - failures: 1, + match fs::remove_dir(&path) { + Ok(()) => MigrationReport { + legacy_files_deleted: 1, ..MigrationReport::default() + }, + Err(err) + if err.kind() == ErrorKind::NotFound + || err.kind() == ErrorKind::DirectoryNotEmpty => + { + MigrationReport::default() + } + Err(err) => { + log::warn!( + "Failed to remove empty legacy library index directory {}: {err}", + path.display() + ); + MigrationReport { + failures: 1, + ..MigrationReport::default() + } } } - } + }) } fn log_migration_report(report: &MigrationReport, started: Instant) { @@ -503,7 +458,13 @@ fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { mod tests { use super::*; use crate::{ - install::intent::{InstallIntentState, read_intent}, + install::intent::{ + InstallIntent, + InstallIntentState, + LoadedInstallIntent, + read_intent, + write_intent, + }, test_support::TempDir, }; @@ -538,15 +499,10 @@ mod tests { } #[tokio::test] - async fn migrates_per_game_intent_and_setup_marker() { + async fn legacy_install_intent_is_rejected_without_deletion() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let root = games.path().join("game"); - let intent = InstallIntent::new( - "game", - InstallIntentState::Updating, - Some("20250101".to_string()), - ); let legacy_intent = root.join(LEGACY_INTENT_FILE); let legacy_tmp = root.join(LEGACY_INTENT_TMP_FILE); let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); @@ -554,7 +510,7 @@ mod tests { write_file( &legacy_intent, - &serde_json::to_vec_pretty(&intent).expect("intent should serialize"), + br#"{"schema_version":1,"state":"Updating"}"#, ); write_file(&legacy_tmp, b"tmp"); write_file(&legacy_setup, b""); @@ -562,38 +518,58 @@ mod tests { let report = migrate_legacy_state(games.path(), state.path()).await; - assert_eq!(report.install_intents_migrated, 1); + assert_eq!(report.install_intents_migrated, 0); + assert_eq!(report.failures, 2); assert_eq!(report.setup_markers_migrated, 1); - let migrated_intent = read_intent(state.path(), "game").await; - assert_eq!(migrated_intent.state, InstallIntentState::Updating); - assert_eq!(migrated_intent.eti_version.as_deref(), Some("20250101")); assert!(setup_done_path(state.path(), "game").is_file()); - assert!(!legacy_intent.exists()); - assert!(!legacy_tmp.exists()); + assert!(legacy_intent.exists()); + assert!(legacy_tmp.exists()); assert!(!legacy_setup.exists()); assert!(!legacy_marker.exists()); } + #[tokio::test] + async fn migrates_multiple_roots_and_second_run_is_idempotent() { + let games = TempDir::new("lanspread-migration-games"); + let state = TempDir::new("lanspread-migration-state"); + let root_count = MIGRATION_CONCURRENCY + 3; + + for index in 0..root_count { + write_file( + &games + .path() + .join(format!("game-{index}")) + .join(LEGACY_SOFTLAN_INSTALL_MARKER), + b"", + ); + } + + let first = migrate_legacy_state(games.path(), state.path()).await; + assert_eq!(first.games_checked, root_count); + assert_eq!(first.legacy_files_deleted, root_count); + assert_eq!(first.failures, 0); + + let second = migrate_legacy_state(games.path(), state.path()).await; + assert_eq!(second.games_checked, root_count); + assert_eq!(second.legacy_files_deleted, 0); + assert_eq!(second.failures, 0); + } + #[tokio::test] async fn app_state_wins_over_legacy_per_game_state() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let root = games.path().join("game"); - let app_intent = InstallIntent::none("game", Some("app".to_string())); - let legacy_intent = InstallIntent::new( - "game", - InstallIntentState::Installing, - Some("legacy".to_string()), - ); + let app_intent = InstallIntent::none(&root, "game", Some("app".to_string())) + .expect("intent root should resolve"); let legacy_intent_path = root.join(LEGACY_INTENT_FILE); let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); write_intent(state.path(), "game", &app_intent) - .await .expect("app-state intent should be written"); write_file( &legacy_intent_path, - &serde_json::to_vec_pretty(&legacy_intent).expect("intent should serialize"), + br#"{"schema_version":1,"state":"Installing"}"#, ); write_file(&setup_done_path(state.path(), "game"), b""); write_file(&legacy_setup, b""); @@ -601,11 +577,14 @@ mod tests { let report = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(report.install_intents_migrated, 0); + assert_eq!(report.failures, 1); assert_eq!(report.setup_markers_migrated, 0); - let intent = read_intent(state.path(), "game").await; + let LoadedInstallIntent::Valid(intent) = read_intent(state.path(), &root, "game") else { + panic!("current app-state intent should remain valid"); + }; assert_eq!(intent.state, InstallIntentState::None); assert_eq!(intent.eti_version.as_deref(), Some("app")); - assert!(!legacy_intent_path.exists()); + assert!(legacy_intent_path.exists()); assert!(!legacy_setup.exists()); } } diff --git a/crates/lanspread-peer/src/network.rs b/crates/lanspread-peer/src/network.rs index 0727f44..13260a3 100644 --- a/crates/lanspread-peer/src/network.rs +++ b/crates/lanspread-peer/src/network.rs @@ -1,255 +1,216 @@ //! Network utilities for QUIC connections and peer communication. use std::{ + future::Future, net::{IpAddr, SocketAddr}, time::Duration, }; -use bytes::BytesMut; use futures::{SinkExt, StreamExt}; use if_addrs::{IfAddr, Interface, get_if_addrs}; -use lanspread_db::db::GameFileDescription; use lanspread_proto::{ - CallToPlayAck, - CallToPlayEvent, - Hello, - HelloAck, - LibraryDelta, - Message, + ChangeHint, + ControlMessage, + MAX_CONTROL_FRAME_BYTES, + PeerEndpoint, + PeerRevisions, + PeerStateSnapshot, Request, Response, }; -use s2n_quic::{ - Client as QuicClient, - Connection, - client::Connect, - provider::{ - congestion_controller, - io::tokio::{Builder as QuicIoBuilder, Provider as QuicIoProvider}, - limits::Limits, - }, -}; -use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; - -use crate::config::{ - CERT_PEM, - QUIC_CONNECTION_DATA_WINDOW, - QUIC_INITIAL_CONGESTION_WINDOW, - QUIC_MAX_SEND_BUFFER_SIZE, - QUIC_SOCKET_BUFFER_SIZE, - QUIC_STREAM_DATA_WINDOW, +use tokio_util::{ + codec::{FramedRead, FramedWrite, LengthDelimitedCodec}, + sync::CancellationToken, }; -pub(crate) fn quic_limits() -> eyre::Result { - Ok(Limits::default() - .with_data_window(QUIC_CONNECTION_DATA_WINDOW)? - .with_bidirectional_local_data_window(QUIC_STREAM_DATA_WINDOW)? - .with_bidirectional_remote_data_window(QUIC_STREAM_DATA_WINDOW)? - .with_unidirectional_data_window(QUIC_STREAM_DATA_WINDOW)? - .with_max_send_buffer_size(QUIC_MAX_SEND_BUFFER_SIZE)?) -} - -pub(crate) fn quic_congestion_controller() -> congestion_controller::Bbr { - congestion_controller::bbr::Builder::default() - .with_initial_congestion_window(QUIC_INITIAL_CONGESTION_WINDOW) - .build() -} - -pub(crate) fn quic_io(addr: SocketAddr) -> eyre::Result { - Ok(QuicIoBuilder::default() - .with_receive_address(addr)? - .with_send_buffer_size(QUIC_SOCKET_BUFFER_SIZE)? - .with_recv_buffer_size(QUIC_SOCKET_BUFFER_SIZE)? - .build()?) -} +use crate::{ + config::{QUIC_HANDSHAKE_TIMEOUT, QUIC_REQUEST_TIMEOUT}, + quic_runtime::{PeerConnection, QuicConnector}, +}; /// Establishes a QUIC connection to a peer. -pub async fn connect_to_peer(addr: SocketAddr) -> eyre::Result { - let limits = quic_limits()?.with_max_handshake_duration(Duration::from_secs(3))?; - - let client = QuicClient::builder() - .with_tls(CERT_PEM)? - .with_io(quic_io(SocketAddr::from(([0, 0, 0, 0], 0)))?)? - .with_limits(limits)? - .with_congestion_controller(quic_congestion_controller())? - .start()?; - - let conn = Connect::new(addr).with_server_name("localhost"); - let conn = client.connect(conn).await?; - Ok(conn) -} - -/// Performs an initial ping check to verify peer is alive. -pub async fn initial_peer_alive_check(conn: &mut Connection) -> bool { - let remote_addr = conn.remote_addr().ok(); - - let stream = match conn.open_bidirectional_stream().await { - Ok(stream) => stream, - Err(e) => { - log::error!("{remote_addr:?} failed to open stream: {e}"); - return false; - } - }; - - let (rx, tx) = stream.split(); - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - // send ping - if let Err(e) = framed_tx.send(Request::Ping.encode()).await { - log::error!("{remote_addr:?} failed to send ping to peer: {e}"); - return false; - } - let _ = framed_tx.close().await; - - // receive pong - if let Some(Ok(response_bytes)) = framed_rx.next().await { - let response = Response::decode(response_bytes.freeze()); - match response { - Response::Pong => { - log::trace!("{remote_addr:?} peer is alive"); - return true; - } - _ => { - log::error!("{remote_addr:?} peer sent invalid response to ping: {response:?}"); - } - } - } - - false -} - -/// Pings a peer to check if it's alive. -pub async fn ping_peer(peer_addr: SocketAddr) -> eyre::Result { - let mut conn = connect_to_peer(peer_addr).await?; - let is_alive = initial_peer_alive_check(&mut conn).await; - Ok(is_alive) -} - -/// Sends a single request without waiting for a response. -pub async fn send_oneway_request(peer_addr: SocketAddr, request: Request) -> eyre::Result<()> { - let mut conn = connect_to_peer(peer_addr).await?; - - let stream = conn.open_bidirectional_stream().await?; - let (_, tx) = stream.split(); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - framed_tx.send(request.encode()).await?; - let _ = framed_tx.close().await; - Ok(()) -} - -/// Performs a hello/ack handshake with a peer. -pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result { - let response = exchange_request(peer_addr, Request::Hello(hello)).await?; - match response { - Response::HelloAck(ack) => Ok(ack), - other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"), - } -} - -async fn exchange_request(peer_addr: SocketAddr, request: Request) -> eyre::Result { - let mut conn = connect_to_peer(peer_addr).await?; - - let stream = conn.open_bidirectional_stream().await?; - let (rx, tx) = stream.split(); - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - framed_tx.send(request.encode()).await?; - framed_tx.close().await?; - - let mut data = BytesMut::new(); - while let Some(frame) = framed_rx.next().await { - data.extend_from_slice(&frame?); - } - - Ok(Response::decode(data.freeze())) -} - -pub async fn send_library_delta( - peer_addr: SocketAddr, - peer_id: &str, - delta: LibraryDelta, -) -> eyre::Result<()> { - send_oneway_request( - peer_addr, - Request::LibraryDelta { - peer_id: peer_id.to_string(), - delta, - }, +pub(crate) async fn connect_to_peer( + connector: &QuicConnector, + endpoint: &PeerEndpoint, + cancellation: &CancellationToken, +) -> eyre::Result { + run_network_operation( + endpoint.addr, + cancellation, + QUIC_HANDSHAKE_TIMEOUT, + connector.connect(endpoint), ) .await } -pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Result<()> { - send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await +/// Pings a peer to check if it's alive. +pub(crate) async fn ping_peer( + connector: &QuicConnector, + endpoint: &PeerEndpoint, + cancellation: &CancellationToken, +) -> eyre::Result { + match exchange_request(connector, endpoint, Request::Ping, cancellation).await? { + Response::Pong(revisions) => Ok(revisions), + Response::Error(code) => { + eyre::bail!("peer {} rejected Ping with {code:?}", endpoint.addr) + } + response @ Response::HelloSnapshot(_) => eyre::bail!( + "peer {} returned an unexpected Ping response: {response:?}", + endpoint.addr + ), + } } -pub async fn send_call_to_play_events( - peer_addr: SocketAddr, - peer_id: &str, - events: Vec, -) -> eyre::Result { - let response = exchange_request( - peer_addr, - Request::CallToPlayEvents { - peer_id: peer_id.to_string(), - events, - }, - ) - .await?; +/// Sends a single request without waiting for a response. +async fn send_oneway_request( + connector: &QuicConnector, + endpoint: &PeerEndpoint, + request: Request, + cancellation: &CancellationToken, +) -> eyre::Result<()> { + run_short_request(endpoint.addr, cancellation, async { + let mut conn = connector.connect(endpoint).await?; + + let stream = conn.open_bidirectional_stream().await?; + let (rx, tx) = stream.split(); + let mut framed_rx = FramedRead::new(rx, control_codec()); + let mut framed_tx = FramedWrite::new(tx, control_codec()); + + framed_tx.send(request.encode()?).await?; + framed_tx.close().await?; + + match framed_rx.next().await { + None => {} + Some(Ok(frame)) => { + let response = Response::decode(frame.freeze())?; + eyre::bail!( + "peer {} unexpectedly responded to one-way request: {response:?}", + endpoint.addr + ); + } + Some(Err(error)) => return Err(error.into()), + } + Ok(()) + }) + .await +} + +/// Pulls the responder's complete current state over a pinned connection. +pub(crate) async fn exchange_hello( + connector: &QuicConnector, + endpoint: &PeerEndpoint, + cancellation: &CancellationToken, +) -> eyre::Result { + let response = exchange_request(connector, endpoint, Request::Hello, cancellation).await?; match response { - Response::CallToPlayAck(ack) => Ok(ack), - other => eyre::bail!("Unexpected Call to Play response from peer {peer_addr}: {other:?}"), + Response::HelloSnapshot(snapshot) => Ok(snapshot), + Response::Error(code) => { + eyre::bail!("peer {} rejected Hello with {code:?}", endpoint.addr) + } + response @ Response::Pong(_) => eyre::bail!( + "peer {} returned an unexpected Hello response: {response:?}", + endpoint.addr + ), } } -/// Requests game file details from a peer. -pub async fn request_game_details_from_peer( +async fn exchange_request( + connector: &QuicConnector, + endpoint: &PeerEndpoint, + request: Request, + cancellation: &CancellationToken, +) -> eyre::Result { + run_short_request(endpoint.addr, cancellation, async { + let mut conn = connector.connect(endpoint).await?; + + let stream = conn.open_bidirectional_stream().await?; + let (rx, tx) = stream.split(); + let mut framed_rx = FramedRead::new(rx, control_codec()); + let mut framed_tx = FramedWrite::new(tx, control_codec()); + + framed_tx.send(request.encode()?).await?; + framed_tx.close().await?; + + let frame = framed_rx + .next() + .await + .ok_or_else(|| eyre::eyre!("peer {} returned no control response", endpoint.addr))??; + let response = Response::decode(frame.freeze())?; + + match framed_rx.next().await { + None => Ok(response), + Some(Ok(_)) => eyre::bail!( + "peer {} returned more than one control response frame", + endpoint.addr + ), + Some(Err(error)) => Err(error.into()), + } + }) + .await +} + +pub(crate) async fn send_library_changed( + connector: &QuicConnector, + endpoint: &PeerEndpoint, + hint: ChangeHint, + cancellation: &CancellationToken, +) -> eyre::Result<()> { + send_oneway_request( + connector, + endpoint, + Request::LibraryChanged(hint), + cancellation, + ) + .await +} + +pub(crate) async fn send_call_to_play_changed( + connector: &QuicConnector, + endpoint: &PeerEndpoint, + hint: ChangeHint, + cancellation: &CancellationToken, +) -> eyre::Result<()> { + send_oneway_request( + connector, + endpoint, + Request::CallToPlayChanged(hint), + cancellation, + ) + .await +} + +fn control_codec() -> LengthDelimitedCodec { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_CONTROL_FRAME_BYTES) + .new_codec() +} + +async fn run_short_request( peer_addr: SocketAddr, - game_id: &str, -) -> eyre::Result<(Vec, Response)> { - let mut conn = connect_to_peer(peer_addr).await?; + cancellation: &CancellationToken, + operation: impl Future>, +) -> eyre::Result { + run_network_operation(peer_addr, cancellation, QUIC_REQUEST_TIMEOUT, operation).await +} - let stream = conn.open_bidirectional_stream().await?; - let (rx, tx) = stream.split(); - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); +async fn run_network_operation( + peer_addr: SocketAddr, + cancellation: &CancellationToken, + timeout: Duration, + operation: impl Future>, +) -> eyre::Result { + let deadline = tokio::time::Instant::now() + timeout; + tokio::pin!(operation); - framed_tx - .send( - Request::GetGame { - id: game_id.to_string(), - } - .encode(), - ) - .await?; - framed_tx.close().await?; - - let mut data = BytesMut::new(); - while let Some(Ok(bytes)) = framed_rx.next().await { - data.extend_from_slice(&bytes); - } - - let response = Response::decode(data.freeze()); - match &response { - Response::GetGame { - id, - file_descriptions, - } => { - if id != game_id { - eyre::bail!("peer {peer_addr} responded with mismatched game id {id}"); - } - Ok((file_descriptions.clone(), response)) + tokio::select! { + biased; + () = cancellation.cancelled() => { + eyre::bail!("network operation with {peer_addr} was cancelled"); } - Response::GameNotFound(_) => { - eyre::bail!("peer {peer_addr} does not have game {game_id}") + () = tokio::time::sleep_until(deadline) => { + eyre::bail!("network operation with {peer_addr} timed out after {timeout:?}"); } - Response::InternalPeerError(error_msg) => { - eyre::bail!("peer {peer_addr} reported internal error: {error_msg}") - } - _ => eyre::bail!("unexpected response from {peer_addr}: {response:?}"), + result = operation.as_mut() => result, } } @@ -337,3 +298,116 @@ fn is_virtual_interface(name: &str) -> bool { let lower = name.to_ascii_lowercase(); VIRTUAL_HINTS.iter().any(|hint| lower.contains(hint)) } + +#[cfg(test)] +mod tests { + use std::{ + future::pending, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, + }; + + use tokio::sync::oneshot; + use tokio_util::sync::CancellationToken; + + use super::run_network_operation; + + struct DropProbe(Arc); + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + fn peer_addr() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 12345)) + } + + #[tokio::test] + async fn cancellation_drops_operation_scope_before_returning() { + let cancellation = CancellationToken::new(); + let dropped = Arc::new(AtomicBool::new(false)); + let (started_tx, started_rx) = oneshot::channel(); + let operation = { + let dropped = dropped.clone(); + async move { + let _drop_probe = DropProbe(dropped); + started_tx + .send(()) + .expect("test runner should wait for operation startup"); + pending::>().await + } + }; + let runner = run_network_operation( + peer_addr(), + &cancellation, + Duration::from_secs(1), + operation, + ); + tokio::pin!(runner); + + tokio::select! { + result = runner.as_mut() => panic!("operation ended before cancellation: {result:?}"), + result = started_rx => result.expect("operation should start"), + } + cancellation.cancel(); + + assert!(runner.await.is_err()); + assert!(dropped.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn deadline_drops_operation_scope_before_returning() { + let cancellation = CancellationToken::new(); + let dropped = Arc::new(AtomicBool::new(false)); + let operation = { + let dropped = dropped.clone(); + async move { + let _drop_probe = DropProbe(dropped); + pending::>().await + } + }; + + let result = run_network_operation( + peer_addr(), + &cancellation, + Duration::from_millis(10), + operation, + ) + .await; + + assert!(result.is_err()); + assert!(dropped.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn pre_cancelled_scope_never_starts_network_operation() { + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let started = Arc::new(AtomicBool::new(false)); + let operation = { + let started = started.clone(); + async move { + started.store(true, Ordering::SeqCst); + Ok(()) + } + }; + + assert!( + run_network_operation( + peer_addr(), + &cancellation, + Duration::from_secs(1), + operation, + ) + .await + .is_err() + ); + assert!(!started.load(Ordering::SeqCst)); + } +} diff --git a/crates/lanspread-peer/src/network_generation.rs b/crates/lanspread-peer/src/network_generation.rs new file mode 100644 index 0000000..0904658 --- /dev/null +++ b/crates/lanspread-peer/src/network_generation.rs @@ -0,0 +1,1532 @@ +//! Structured ownership for the process-wide Local network sharing policy. + +use std::{ + fmt, + future::Future, + net::SocketAddr, + panic::AssertUnwindSafe, + pin::Pin, + sync::{Arc, Mutex, MutexGuard}, + time::Duration, +}; + +use futures::FutureExt as _; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::{ + sync::CancellationToken, + task::{TaskTracker, task_tracker::TaskTrackerToken}, +}; + +use crate::{ + LocalNetworkSharingState, + PeerEvent, + PeerRuntimeComponent, + context::{Ctx, NetworkServiceCtx}, + events, + quic_runtime::{QuicClientRuntime, QuicConnector, start_quic_client}, + services::{ + clear_remote_state_and_publish, + run_peer_discovery, + run_ping_service, + run_server_component, + }, +}; + +const NETWORK_REQUEST_CAPACITY: usize = 8; +const NETWORK_SERVICE_RESTART_BACKOFF: Duration = Duration::from_secs(5); + +/// Monotonic runtime-local identity for one enabled network generation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct NetworkGenerationId(u64); + +#[derive(Clone)] +struct NetworkAdmission { + open: Arc>>, +} + +struct OpenGeneration { + id: NetworkGenerationId, + connector: QuicConnector, + cancellation: CancellationToken, + tasks: TaskTracker, +} + +impl NetworkAdmission { + fn new() -> Self { + Self { + open: Arc::new(Mutex::new(None)), + } + } + + fn open( + &self, + id: NetworkGenerationId, + connector: QuicConnector, + cancellation: CancellationToken, + tasks: TaskTracker, + ) -> Result<(), NetworkUnavailable> { + let mut open = self.lock(); + if open.is_some() { + return Err(NetworkUnavailable::new( + "another Local network sharing generation is already admitted", + )); + } + if cancellation.is_cancelled() || tasks.is_closed() { + return Err(NetworkUnavailable::new( + "Local network sharing generation stopped before admission", + )); + } + *open = Some(OpenGeneration { + id, + connector, + cancellation, + tasks, + }); + Ok(()) + } + + fn acquire(&self) -> Result { + let open = self.lock(); + let generation = open.as_ref().ok_or_else(|| { + NetworkUnavailable::new("Local network sharing is disabled or changing state") + })?; + if generation.cancellation.is_cancelled() || generation.tasks.is_closed() { + return Err(NetworkUnavailable::new( + "Local network sharing generation is stopping", + )); + } + let activity = generation.tasks.token(); + Ok(NetworkPermit { + connector: generation.connector.clone(), + cancellation: generation.cancellation.clone(), + _activity: activity, + }) + } + + fn close(&self, expected: NetworkGenerationId) { + let mut open = self.lock(); + let Some(generation) = open.take() else { + return; + }; + if generation.id != expected { + log::error!( + "Refusing to close network generation {:?} while stopping stale generation {:?}", + generation.id, + expected + ); + *open = Some(generation); + } + } + + fn close_any(&self) { + self.lock().take(); + } + + fn lock(&self) -> MutexGuard<'_, Option> { + self.open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// Cloneable command and admission handle retained by the core context. +#[derive(Clone)] +pub(crate) struct NetworkControl { + requests: mpsc::Sender, + admission: NetworkAdmission, +} + +impl fmt::Debug for NetworkControl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NetworkControl") + .finish_non_exhaustive() + } +} + +impl NetworkControl { + /// Enables or disables sharing and resolves only after the effective state + /// and all required cleanup/publication have settled. + pub(crate) async fn set_enabled(&self, enabled: bool) -> Result { + let (reply, response) = oneshot::channel(); + self.requests + .send(NetworkRequest { enabled, reply }) + .await + .map_err(|_| "Local network sharing manager is unavailable".to_owned())?; + response + .await + .map_err(|_| "Local network sharing manager stopped without replying".to_owned())? + } + + /// Synchronously admits one complete outbound network operation. + pub(crate) fn try_acquire(&self) -> Result { + self.admission.acquire() + } + + #[cfg(test)] + pub(crate) fn disabled_for_test() -> Self { + let (_manager, control) = NetworkManager::new(); + control + } + + #[cfg(test)] + pub(crate) fn enabled_for_test() -> Self { + let (_manager, control) = NetworkManager::new(); + control + .admission + .open( + NetworkGenerationId(1), + QuicConnector::unavailable(), + CancellationToken::new(), + TaskTracker::new(), + ) + .expect("test generation should be admitted"); + control + } +} + +/// Admission error for an outbound operation while sharing is unavailable. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct NetworkUnavailable { + message: &'static str, +} + +impl NetworkUnavailable { + const fn new(message: &'static str) -> Self { + Self { message } + } +} + +impl fmt::Display for NetworkUnavailable { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message) + } +} + +impl std::error::Error for NetworkUnavailable {} + +/// Non-cloneable proof that one outbound operation belongs to a live network +/// generation. Keeping this value alive keeps generation drainage pending. +#[must_use = "the network permit must live until every child network operation has drained"] +pub(crate) struct NetworkPermit { + connector: QuicConnector, + cancellation: CancellationToken, + _activity: TaskTrackerToken, +} + +impl NetworkPermit { + #[must_use] + pub(crate) const fn connector(&self) -> &QuicConnector { + &self.connector + } + + #[must_use] + pub(crate) fn child_token(&self) -> CancellationToken { + self.cancellation.child_token() + } + + #[must_use] + pub(crate) fn service_context(&self, core: Ctx) -> NetworkServiceCtx { + NetworkServiceCtx::new(core, self.connector.clone(), self.cancellation.clone()) + } +} + +struct NetworkRequest { + enabled: bool, + reply: oneshot::Sender>, +} + +#[derive(Debug)] +struct GenerationFailure { + id: NetworkGenerationId, + component: PeerRuntimeComponent, + error: String, +} + +type RuntimeShutdown = Pin> + Send>>; + +trait GenerationRuntime: Send { + fn shutdown(self: Box) -> RuntimeShutdown; +} + +impl GenerationRuntime for QuicClientRuntime { + fn shutdown(self: Box) -> RuntimeShutdown { + Box::pin(async move { (*self).shutdown().await }) + } +} + +struct StartingGeneration { + connector: QuicConnector, + runtime: Box, + ready: oneshot::Receiver, +} + +enum ReadinessOutcome { + Ready(SocketAddr), + DisableRequested(oneshot::Sender>), +} + +enum EnableFailure { + Failed(String), + Superseded, +} + +impl EnableFailure { + fn into_message(self) -> String { + match self { + Self::Failed(message) => message, + Self::Superseded => "Local network sharing enable was superseded by disable".to_owned(), + } + } +} + +struct GenerationStart { + id: NetworkGenerationId, + core: Ctx, + tx_notify_ui: mpsc::UnboundedSender, + shutdown: CancellationToken, + tasks: TaskTracker, + failure_tx: mpsc::UnboundedSender, +} + +trait NetworkGenerationFactory: Send + Sync { + fn start(&self, start: GenerationStart) -> eyre::Result; +} + +struct LiveNetworkGenerationFactory; + +impl NetworkGenerationFactory for LiveNetworkGenerationFactory { + fn start(&self, start: GenerationStart) -> eyre::Result { + let GenerationStart { + id, + core, + tx_notify_ui, + shutdown, + tasks, + failure_tx, + } = start; + let (runtime, connector) = start_quic_client()?; + let service_ctx = NetworkServiceCtx::new(core, connector.clone(), shutdown.clone()); + let peer_ctx = service_ctx.to_peer_ctx(tx_notify_ui.clone()); + let (ready_tx, ready) = oneshot::channel(); + + spawn_required_service( + &tasks, + &shutdown, + &failure_tx, + id, + PeerRuntimeComponent::QuicServer, + run_server_component(SocketAddr::from(([0, 0, 0, 0], 0)), peer_ctx, ready_tx), + ); + + let discovery_ctx = service_ctx.clone(); + let discovery_tx = tx_notify_ui.clone(); + spawn_restarting_service( + &tasks, + &shutdown, + id, + PeerRuntimeComponent::Discovery, + move || { + let ctx = discovery_ctx.clone(); + let tx = discovery_tx.clone(); + async move { run_peer_discovery(tx, ctx).await } + }, + ); + + let liveness_ctx = service_ctx; + spawn_restarting_service( + &tasks, + &shutdown, + id, + PeerRuntimeComponent::Liveness, + move || { + let ctx = liveness_ctx.clone(); + let tx = tx_notify_ui.clone(); + async move { run_ping_service(tx, ctx).await } + }, + ); + + Ok(StartingGeneration { + connector, + runtime: Box::new(runtime), + ready, + }) + } +} + +struct NetworkGeneration { + id: NetworkGenerationId, + shutdown: CancellationToken, + tasks: TaskTracker, + runtime: Option>, +} + +impl Drop for NetworkGeneration { + fn drop(&mut self) { + self.shutdown.cancel(); + self.tasks.close(); + } +} + +/// Serial actor that owns the complete lifecycle of the current network +/// generation while remaining a child of the process-wide core runtime. +pub(crate) struct NetworkManager { + requests: mpsc::Receiver, + admission: NetworkAdmission, + failure_tx: mpsc::UnboundedSender, + failures: mpsc::UnboundedReceiver, + factory: Arc, + last_generation: u64, +} + +impl NetworkManager { + #[must_use] + pub(crate) fn new() -> (Self, NetworkControl) { + Self::with_factory(Arc::new(LiveNetworkGenerationFactory)) + } + + fn with_factory(factory: Arc) -> (Self, NetworkControl) { + let (request_tx, requests) = mpsc::channel(NETWORK_REQUEST_CAPACITY); + let (failure_tx, failures) = mpsc::unbounded_channel(); + let admission = NetworkAdmission::new(); + let control = NetworkControl { + requests: request_tx, + admission: admission.clone(), + }; + ( + Self { + requests, + admission, + failure_tx, + failures, + factory, + last_generation: 0, + }, + control, + ) + } + + /// Runs until core shutdown, draining the current generation before return. + pub(crate) async fn run( + mut self, + ctx: Ctx, + tx_notify_ui: mpsc::UnboundedSender, + initially_enabled: bool, + ) { + let mut current = None; + let outcome = + AssertUnwindSafe(self.run_body(&ctx, &tx_notify_ui, initially_enabled, &mut current)) + .catch_unwind() + .await; + + if let Err(payload) = outcome { + report_failure( + &tx_notify_ui, + PeerRuntimeComponent::QuicServer, + format!( + "Local network sharing manager panicked: {}", + panic_payload_to_string(payload.as_ref()) + ), + ); + ctx.shutdown.cancel(); + if current.is_none() { + self.admission.close_any(); + clear_local_peer_addr(&ctx).await; + clear_remote_state_and_publish(&ctx, &tx_notify_ui).await; + send_state(&tx_notify_ui, LocalNetworkSharingState::Disabled); + return; + } + } + + self.disable(&ctx, &tx_notify_ui, &mut current).await; + self.admission.close_any(); + } + + async fn run_body( + &mut self, + ctx: &Ctx, + tx_notify_ui: &mpsc::UnboundedSender, + initially_enabled: bool, + current: &mut Option, + ) { + self.admission.close_any(); + clear_local_peer_addr(ctx).await; + clear_remote_state_and_publish(ctx, tx_notify_ui).await; + send_state(tx_notify_ui, LocalNetworkSharingState::Disabled); + + if initially_enabled { + match self.enable(ctx, tx_notify_ui, current).await { + Err(EnableFailure::Failed(error)) if !ctx.shutdown.is_cancelled() => { + report_failure(tx_notify_ui, PeerRuntimeComponent::QuicServer, error); + } + _ => {} + } + } + + loop { + tokio::select! { + biased; + () = ctx.shutdown.cancelled() => break, + failure = self.failures.recv() => { + let Some(failure) = failure else { continue; }; + if current.as_ref().is_some_and(|generation| generation.id == failure.id) { + report_failure(tx_notify_ui, failure.component, failure.error); + self.disable(ctx, tx_notify_ui, current).await; + } else { + log::debug!("Ignoring failure from stale network generation {:?}", failure.id); + } + } + request = self.requests.recv() => { + let Some(request) = request else { break; }; + let result = if request.enabled { + self.enable(ctx, tx_notify_ui, current) + .await + .map_err(EnableFailure::into_message) + } else { + self.disable(ctx, tx_notify_ui, current).await; + Ok(false) + }; + let _ = request.reply.send(result); + } + } + } + } + + async fn enable( + &mut self, + ctx: &Ctx, + tx_notify_ui: &mpsc::UnboundedSender, + current: &mut Option, + ) -> Result { + if current.is_some() { + return Ok(true); + } + if ctx.shutdown.is_cancelled() { + return Err(EnableFailure::Failed( + "peer runtime is shutting down".to_owned(), + )); + } + + let id = self.next_generation().map_err(EnableFailure::Failed)?; + send_state(tx_notify_ui, LocalNetworkSharingState::Enabling); + clear_local_peer_addr(ctx).await; + let shutdown = ctx.shutdown.child_token(); + let tasks = TaskTracker::new(); + let starting = self + .start_generation(ctx, tx_notify_ui, id, &shutdown, &tasks) + .await?; + let StartingGeneration { + connector, + runtime, + mut ready, + } = starting; + let generation = NetworkGeneration { + id, + shutdown, + tasks, + runtime: Some(runtime), + }; + *current = Some(generation); + + let ready_addr = match self.await_readiness(ctx, id, &mut ready).await { + Ok(ReadinessOutcome::Ready(addr)) => addr, + Ok(ReadinessOutcome::DisableRequested(reply)) => { + drop(connector); + self.disable(ctx, tx_notify_ui, current).await; + let _ = reply.send(Ok(false)); + return Err(EnableFailure::Superseded); + } + Err(error) => { + drop(connector); + let generation = current + .as_mut() + .expect("enabling generation must remain manager-owned"); + self.stop_generation(ctx, tx_notify_ui, generation).await; + *current = None; + send_state(tx_notify_ui, LocalNetworkSharingState::Disabled); + return Err(EnableFailure::Failed(error)); + } + }; + + let generation = current + .as_ref() + .expect("ready generation must remain manager-owned"); + if let Err(error) = self.admission.open( + id, + connector, + generation.shutdown.clone(), + generation.tasks.clone(), + ) { + let generation = current + .as_mut() + .expect("rejected generation must remain manager-owned"); + self.stop_generation(ctx, tx_notify_ui, generation).await; + *current = None; + send_state(tx_notify_ui, LocalNetworkSharingState::Disabled); + return Err(EnableFailure::Failed(error.to_string())); + } + events::send( + tx_notify_ui, + PeerEvent::LocalPeerReady { + peer_id: ctx.peer_id.to_string(), + addr: ready_addr, + }, + ); + send_state( + tx_notify_ui, + LocalNetworkSharingState::Enabled { + peer_id: ctx.peer_id.to_string(), + addr: ready_addr, + }, + ); + Ok(true) + } + + async fn start_generation( + &self, + ctx: &Ctx, + tx_notify_ui: &mpsc::UnboundedSender, + id: NetworkGenerationId, + shutdown: &CancellationToken, + tasks: &TaskTracker, + ) -> Result { + let starting = std::panic::catch_unwind(AssertUnwindSafe(|| { + self.factory.start(GenerationStart { + id, + core: ctx.clone(), + tx_notify_ui: tx_notify_ui.clone(), + shutdown: shutdown.clone(), + tasks: tasks.clone(), + failure_tx: self.failure_tx.clone(), + }) + })); + let starting = match starting { + Ok(Ok(starting)) => starting, + Ok(Err(error)) => { + shutdown.cancel(); + tasks.close(); + tasks.wait().await; + clear_local_peer_addr(ctx).await; + clear_remote_state_and_publish(ctx, tx_notify_ui).await; + send_state(tx_notify_ui, LocalNetworkSharingState::Disabled); + return Err(EnableFailure::Failed(format!( + "failed to start Local network sharing: {error:#}" + ))); + } + Err(payload) => { + shutdown.cancel(); + tasks.close(); + tasks.wait().await; + std::panic::resume_unwind(payload); + } + }; + Ok(starting) + } + + async fn await_readiness( + &mut self, + ctx: &Ctx, + id: NetworkGenerationId, + ready: &mut oneshot::Receiver, + ) -> Result { + loop { + tokio::select! { + biased; + () = ctx.shutdown.cancelled() => { + return Err("peer runtime stopped while enabling Local network sharing".to_owned()); + } + failure = self.failures.recv() => { + let Some(failure) = failure else { continue; }; + if failure.id != id { + log::debug!("Ignoring failure from stale network generation {:?}", failure.id); + continue; + } + return Err(format!( + "{:?} failed while enabling Local network sharing: {}", + failure.component, + failure.error, + )); + } + request = self.requests.recv() => { + let Some(request) = request else { + return Err("Local network sharing command channel closed while enabling".to_owned()); + }; + if request.enabled { + let _ = request.reply.send(Err( + "Local network sharing is already enabling".to_owned(), + )); + } else { + return Ok(ReadinessOutcome::DisableRequested(request.reply)); + } + } + ready_result = &mut *ready => { + return ready_result + .map(ReadinessOutcome::Ready) + .map_err(|_| { + "QUIC server stopped before Local network sharing became ready".to_owned() + }); + } + } + } + } + + async fn disable( + &self, + ctx: &Ctx, + tx_notify_ui: &mpsc::UnboundedSender, + current: &mut Option, + ) { + let Some(generation) = current.as_mut() else { + return; + }; + // This synchronous boundary is the privacy guarantee attached to the + // `Disabling` event: once an observer can see that state, no new + // generation permit can be minted and every service has already been + // asked to stop. The remaining awaits only drain admitted work. + self.begin_stop_generation(generation); + send_state(tx_notify_ui, LocalNetworkSharingState::Disabling); + self.stop_generation(ctx, tx_notify_ui, generation).await; + *current = None; + send_state(tx_notify_ui, LocalNetworkSharingState::Disabled); + } + + async fn stop_generation( + &self, + ctx: &Ctx, + tx_notify_ui: &mpsc::UnboundedSender, + generation: &mut NetworkGeneration, + ) { + self.begin_stop_generation(generation); + generation.tasks.wait().await; + if let Some(runtime) = generation.runtime.take() + && let Err(error) = runtime.shutdown().await + { + report_failure( + tx_notify_ui, + PeerRuntimeComponent::QuicServer, + format!("network client cleanup failed: {error:#}"), + ); + } + clear_local_peer_addr(ctx).await; + clear_remote_state_and_publish(ctx, tx_notify_ui).await; + } + + fn begin_stop_generation(&self, generation: &NetworkGeneration) { + self.admission.close(generation.id); + generation.shutdown.cancel(); + generation.tasks.close(); + } + + fn next_generation(&mut self) -> Result { + self.last_generation = self + .last_generation + .checked_add(1) + .ok_or_else(|| "Local network sharing generation counter exhausted".to_owned())?; + Ok(NetworkGenerationId(self.last_generation)) + } +} + +fn spawn_required_service( + tasks: &TaskTracker, + shutdown: &CancellationToken, + failure_tx: &mpsc::UnboundedSender, + id: NetworkGenerationId, + component: PeerRuntimeComponent, + service: Fut, +) where + Fut: Future> + Send + 'static, +{ + let shutdown = shutdown.clone(); + let failure_tx = failure_tx.clone(); + tasks.spawn(async move { + let result = AssertUnwindSafe(service).catch_unwind().await; + if shutdown.is_cancelled() { + return; + } + let error = match result { + Ok(Ok(())) => "component exited unexpectedly".to_owned(), + Ok(Err(error)) => format!("{error:#}"), + Err(payload) => format!("component panicked: {}", panic_payload_to_string(&payload)), + }; + let _ = failure_tx.send(GenerationFailure { + id, + component, + error, + }); + }); +} + +fn spawn_restarting_service( + tasks: &TaskTracker, + shutdown: &CancellationToken, + id: NetworkGenerationId, + component: PeerRuntimeComponent, + mut make_service: F, +) where + F: FnMut() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let shutdown = shutdown.clone(); + tasks.spawn(async move { + loop { + if shutdown.is_cancelled() { + return; + } + let result = AssertUnwindSafe(make_service()).catch_unwind().await; + if shutdown.is_cancelled() { + return; + } + match result { + Ok(Ok(())) => log::warn!( + "{component:?} exited in network generation {id:?}; restarting in {NETWORK_SERVICE_RESTART_BACKOFF:?}" + ), + Ok(Err(error)) => log::error!( + "{component:?} failed in network generation {id:?}: {error:#}; restarting in {NETWORK_SERVICE_RESTART_BACKOFF:?}" + ), + Err(payload) => log::error!( + "{component:?} panicked in network generation {id:?}: {}; restarting in {NETWORK_SERVICE_RESTART_BACKOFF:?}", + panic_payload_to_string(&payload) + ), + } + tokio::select! { + () = shutdown.cancelled() => return, + () = tokio::time::sleep(NETWORK_SERVICE_RESTART_BACKOFF) => {} + } + } + }); +} + +async fn clear_local_peer_addr(ctx: &Ctx) { + *ctx.local_peer_addr.write().await = None; +} + +fn send_state(tx_notify_ui: &mpsc::UnboundedSender, state: LocalNetworkSharingState) { + events::send( + tx_notify_ui, + PeerEvent::LocalNetworkSharingStateChanged(state), + ); +} + +fn report_failure( + tx_notify_ui: &mpsc::UnboundedSender, + component: PeerRuntimeComponent, + error: String, +) { + log::error!("{component:?} failed: {error}"); + events::send(tx_notify_ui, PeerEvent::RuntimeFailed { component, error }); +} + +fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&'static str>() { + return (*message).to_owned(); + } + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + "unknown panic payload".to_owned() +} + +#[cfg(test)] +mod tests { + use std::{ + path::Path, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + }; + + use tokio::sync::{RwLock, mpsc}; + + use super::*; + use crate::{ + NoopStreamInstallProvider, + UnpackFuture, + Unpacker, + identity::PeerIdentity, + peer_db::PeerGameDB, + test_support::{TempDir, empty_catalog_bundle}, + }; + + fn ready_addr() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 42_424)) + } + + struct NoopUnpacker; + + impl Unpacker for NoopUnpacker { + fn unpack<'a>( + &'a self, + _archive: &'a Path, + _dest: &'a Path, + _cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { + Box::pin(async { Ok(()) }) + } + } + + struct FakeRuntime { + shutdowns: Arc, + shutdown_fails: Arc, + } + + impl GenerationRuntime for FakeRuntime { + fn shutdown(self: Box) -> RuntimeShutdown { + Box::pin(async move { + self.shutdowns.fetch_add(1, Ordering::SeqCst); + if self.shutdown_fails.load(Ordering::SeqCst) { + eyre::bail!("injected network client cleanup failure"); + } + Ok(()) + }) + } + } + + struct FakeStartControl { + id: NetworkGenerationId, + ready: oneshot::Sender, + failures: mpsc::UnboundedSender, + } + + struct FakeFactory { + starts: Arc, + shutdowns: Arc, + shutdown_fails: Arc, + start_panics: Arc, + panic_child_stops: Arc, + started_tx: mpsc::UnboundedSender, + } + + impl NetworkGenerationFactory for FakeFactory { + fn start(&self, start: GenerationStart) -> eyre::Result { + self.starts.fetch_add(1, Ordering::SeqCst); + if self.start_panics.load(Ordering::SeqCst) { + let shutdown = start.shutdown.clone(); + let panic_child_stops = Arc::clone(&self.panic_child_stops); + start.tasks.spawn(async move { + shutdown.cancelled().await; + panic_child_stops.fetch_add(1, Ordering::SeqCst); + }); + panic!("injected network generation factory panic"); + } + let (ready, ready_rx) = oneshot::channel(); + self.started_tx + .send(FakeStartControl { + id: start.id, + ready, + failures: start.failure_tx, + }) + .expect("test should retain generation controls"); + Ok(StartingGeneration { + connector: QuicConnector::unavailable(), + runtime: Box::new(FakeRuntime { + shutdowns: Arc::clone(&self.shutdowns), + shutdown_fails: Arc::clone(&self.shutdown_fails), + }), + ready: ready_rx, + }) + } + } + + struct TestHarness { + manager: NetworkManager, + control: NetworkControl, + started: mpsc::UnboundedReceiver, + starts: Arc, + shutdowns: Arc, + shutdown_fails: Arc, + start_panics: Arc, + panic_child_stops: Arc, + } + + fn test_harness() -> TestHarness { + let (started_tx, started) = mpsc::unbounded_channel(); + let starts = Arc::new(AtomicUsize::new(0)); + let shutdowns = Arc::new(AtomicUsize::new(0)); + let shutdown_fails = Arc::new(AtomicBool::new(false)); + let start_panics = Arc::new(AtomicBool::new(false)); + let panic_child_stops = Arc::new(AtomicUsize::new(0)); + let factory = Arc::new(FakeFactory { + starts: Arc::clone(&starts), + shutdowns: Arc::clone(&shutdowns), + shutdown_fails: Arc::clone(&shutdown_fails), + start_panics: Arc::clone(&start_panics), + panic_child_stops: Arc::clone(&panic_child_stops), + started_tx, + }); + let (manager, control) = NetworkManager::with_factory(factory); + TestHarness { + manager, + control, + started, + starts, + shutdowns, + shutdown_fails, + start_panics, + panic_child_stops, + } + } + + fn test_ctx(control: NetworkControl) -> (TempDir, Ctx) { + let temp = TempDir::new("lanspread-network-generation"); + let game_dir = temp.path().join("games"); + let state_dir = temp.path().join("state"); + std::fs::create_dir_all(&game_dir).expect("test game directory should exist"); + std::fs::create_dir_all(&state_dir).expect("test state directory should exist"); + let ctx = Ctx::new( + Arc::new(RwLock::new(PeerGameDB::new())), + Arc::new(PeerIdentity::generate().expect("test identity should generate")), + game_dir, + state_dir, + Arc::new(NoopUnpacker), + CancellationToken::new(), + TaskTracker::new(), + empty_catalog_bundle(), + Arc::new(RwLock::new(std::collections::HashMap::new())), + Arc::new(NoopStreamInstallProvider), + control, + ) + .expect("test context should initialize"); + (temp, ctx) + } + + async fn wait_for_state( + events: &mut mpsc::UnboundedReceiver, + expected: LocalNetworkSharingState, + ) { + loop { + let event = events + .recv() + .await + .expect("manager event channel should remain open"); + if let PeerEvent::LocalNetworkSharingStateChanged(state) = event + && state == expected + { + return; + } + } + } + + async fn enable_fake_generation( + control: &NetworkControl, + started: &mut mpsc::UnboundedReceiver, + ) -> ( + NetworkGenerationId, + mpsc::UnboundedSender, + ) { + let control = control.clone(); + let enable = tokio::spawn(async move { control.set_enabled(true).await }); + let FakeStartControl { + id, + ready, + failures, + } = started.recv().await.expect("generation should start"); + ready + .send(ready_addr()) + .expect("manager should await readiness"); + assert_eq!( + enable.await.expect("enable task should not panic"), + Ok(true) + ); + (id, failures) + } + + #[test] + fn admission_mismatch_does_not_close_a_newer_generation() { + let admission = NetworkAdmission::new(); + admission + .open( + NetworkGenerationId(2), + QuicConnector::unavailable(), + CancellationToken::new(), + TaskTracker::new(), + ) + .expect("generation should open"); + + admission.close(NetworkGenerationId(1)); + + let permit = admission + .acquire() + .expect("newer generation must remain open"); + drop(permit); + } + + #[tokio::test] + async fn startup_disabled_does_not_construct_a_network_generation() { + let harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + assert_eq!(harness.starts.load(Ordering::SeqCst), 0); + assert!(harness.control.try_acquire().is_err()); + + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn enable_reply_waits_for_readiness_and_atomic_admission() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + let control = harness.control.clone(); + let enable = tokio::spawn(async move { control.set_enabled(true).await }); + let start = harness + .started + .recv() + .await + .expect("generation should start"); + tokio::task::yield_now().await; + assert!(!enable.is_finished()); + assert!(harness.control.try_acquire().is_err()); + + start + .ready + .send(ready_addr()) + .expect("manager should await readiness"); + assert_eq!( + enable.await.expect("enable task should not panic"), + Ok(true) + ); + assert!(harness.control.try_acquire().is_ok()); + + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn disable_reply_waits_for_every_admitted_permit() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + let control = harness.control.clone(); + let enable = tokio::spawn(async move { control.set_enabled(true).await }); + harness + .started + .recv() + .await + .expect("generation should start") + .ready + .send(ready_addr()) + .expect("manager should await readiness"); + assert_eq!( + enable.await.expect("enable task should not panic"), + Ok(true) + ); + while events.try_recv().is_ok() {} + let permit = harness + .control + .try_acquire() + .expect("enabled generation should admit work"); + + let control = harness.control.clone(); + let disable = tokio::spawn(async move { control.set_enabled(false).await }); + let disabling = events + .recv() + .await + .expect("disable should publish its admission-closed state"); + assert!(matches!( + disabling, + PeerEvent::LocalNetworkSharingStateChanged(LocalNetworkSharingState::Disabling) + )); + assert!(!disable.is_finished()); + assert!( + harness.control.try_acquire().is_err(), + "observing Disabling must prove that admission is already closed" + ); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 0); + + drop(permit); + assert_eq!( + disable.await.expect("disable task should not panic"), + Ok(false) + ); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 1); + + let mut settled_events = vec!["disabling"]; + while let Ok(event) = events.try_recv() { + let label = match event { + PeerEvent::LocalNetworkSharingStateChanged(LocalNetworkSharingState::Disabling) => { + "disabling" + } + PeerEvent::PeerCountUpdated(0) => "peer-count", + PeerEvent::RemoteLibraryView(view) if view.games.is_empty() => "remote-library", + PeerEvent::CallToPlayView(view) if view.events.is_empty() => "call-to-play", + PeerEvent::LocalNetworkSharingStateChanged(LocalNetworkSharingState::Disabled) => { + "disabled" + } + unexpected => panic!("unexpected disable-settlement event: {unexpected:?}"), + }; + settled_events.push(label); + } + assert_eq!( + settled_events, + [ + "disabling", + "peer-count", + "remote-library", + "call-to-play", + "disabled", + ] + ); + + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn readiness_failure_drains_partial_generation_and_allows_reenable() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + let control = harness.control.clone(); + let first_enable = tokio::spawn(async move { control.set_enabled(true).await }); + let first = harness + .started + .recv() + .await + .expect("first generation should start"); + drop(first.ready); + assert!( + first_enable + .await + .expect("enable task should not panic") + .is_err() + ); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 1); + assert!(harness.control.try_acquire().is_err()); + + enable_fake_generation(&harness.control, &mut harness.started).await; + assert!(harness.control.try_acquire().is_ok()); + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 2); + + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn disable_interrupts_initial_enable_without_a_failure_diagnostic() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, true)); + let pending_ready = harness + .started + .recv() + .await + .expect("initial generation should start") + .ready; + + let control = harness.control.clone(); + let disable = tokio::spawn(async move { control.set_enabled(false).await }); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), disable) + .await + .expect("disable must interrupt pending readiness") + .expect("disable task should not panic"), + Ok(false) + ); + drop(pending_ready); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 1); + assert!(harness.control.try_acquire().is_err()); + + let mut saw_disabling = false; + loop { + let event = events + .recv() + .await + .expect("manager event channel should remain open"); + assert!(!matches!(event, PeerEvent::RuntimeFailed { .. })); + match event { + PeerEvent::LocalNetworkSharingStateChanged(LocalNetworkSharingState::Disabling) => { + saw_disabling = true; + } + PeerEvent::LocalNetworkSharingStateChanged(LocalNetworkSharingState::Disabled) + if saw_disabling => + { + break; + } + _ => {} + } + } + + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn current_required_failure_auto_disables_and_closes_admission() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + let (id, failures) = enable_fake_generation(&harness.control, &mut harness.started).await; + + failures + .send(GenerationFailure { + id, + component: PeerRuntimeComponent::QuicServer, + error: "injected required service failure".to_owned(), + }) + .expect("manager should retain failure receiver"); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + assert!(harness.control.try_acquire().is_err()); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 1); + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn root_shutdown_waits_for_held_permit_and_joins_runtime() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + enable_fake_generation(&harness.control, &mut harness.started).await; + let permit = harness + .control + .try_acquire() + .expect("enabled generation should admit work"); + + ctx.shutdown.cancel(); + wait_for_state(&mut events, LocalNetworkSharingState::Disabling).await; + assert!(harness.control.try_acquire().is_err()); + assert!(!manager_task.is_finished()); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 0); + + drop(permit); + manager_task.await.expect("manager task should not panic"); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn dropping_enable_reply_does_not_cancel_the_transition() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + let control = harness.control.clone(); + let enable = tokio::spawn(async move { control.set_enabled(true).await }); + let start = harness + .started + .recv() + .await + .expect("generation should start"); + enable.abort(); + assert!( + enable + .await + .expect_err("enable task should be cancelled") + .is_cancelled() + ); + start + .ready + .send(ready_addr()) + .expect("manager should still await readiness"); + wait_for_state( + &mut events, + LocalNetworkSharingState::Enabled { + peer_id: ctx.peer_id.to_string(), + addr: ready_addr(), + }, + ) + .await; + assert!(harness.control.try_acquire().is_ok()); + + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn idempotent_stable_requests_do_not_restart_or_republish() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + assert!(events.try_recv().is_err()); + enable_fake_generation(&harness.control, &mut harness.started).await; + while events.try_recv().is_ok() {} + + assert_eq!(harness.control.set_enabled(true).await, Ok(true)); + assert_eq!(harness.starts.load(Ordering::SeqCst), 1); + assert!(events.try_recv().is_err()); + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + assert_eq!(harness.shutdowns.load(Ordering::SeqCst), 1); + assert!(events.try_recv().is_err()); + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn client_cleanup_error_is_diagnostic_but_off_ack_is_authoritative() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + enable_fake_generation(&harness.control, &mut harness.started).await; + while events.try_recv().is_ok() {} + harness.shutdown_fails.store(true, Ordering::SeqCst); + + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + let mut saw_cleanup_failure = false; + while let Ok(event) = events.try_recv() { + match event { + PeerEvent::RuntimeFailed { + component: PeerRuntimeComponent::QuicServer, + error, + } if error.contains("injected network client cleanup failure") => { + saw_cleanup_failure = true; + } + PeerEvent::LocalNetworkSharingStateChanged(LocalNetworkSharingState::Disabled) => { + assert!(saw_cleanup_failure); + } + _ => {} + } + } + assert!(saw_cleanup_failure); + assert!(harness.control.try_acquire().is_err()); + + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn factory_panic_drains_partial_children_and_fails_closed() { + let harness = test_harness(); + harness.start_panics.store(true, Ordering::SeqCst); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + assert!(harness.control.set_enabled(true).await.is_err()); + manager_task + .await + .expect("manager task should contain its panic"); + assert!(ctx.shutdown.is_cancelled()); + assert!(harness.control.try_acquire().is_err()); + assert_eq!(harness.panic_child_stops.load(Ordering::SeqCst), 1); + let mut saw_final_disabled = false; + while let Ok(event) = events.try_recv() { + saw_final_disabled |= matches!( + event, + PeerEvent::LocalNetworkSharingStateChanged(LocalNetworkSharingState::Disabled) + ); + } + assert!(saw_final_disabled); + } + + #[tokio::test] + async fn stale_generation_failure_cannot_close_current_admission() { + let mut harness = test_harness(); + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + let control = harness.control.clone(); + let enable = tokio::spawn(async move { control.set_enabled(true).await }); + let first = harness + .started + .recv() + .await + .expect("first generation should start"); + first + .ready + .send(ready_addr()) + .expect("first generation should become ready"); + assert_eq!( + enable.await.expect("enable task should not panic"), + Ok(true) + ); + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + + let control = harness.control.clone(); + let enable = tokio::spawn(async move { control.set_enabled(true).await }); + let second = harness + .started + .recv() + .await + .expect("second generation should start"); + second + .ready + .send(ready_addr()) + .expect("second generation should become ready"); + assert_eq!( + enable.await.expect("enable task should not panic"), + Ok(true) + ); + assert_ne!(first.id, second.id); + + first + .failures + .send(GenerationFailure { + id: first.id, + component: PeerRuntimeComponent::QuicServer, + error: "late injected failure".to_owned(), + }) + .expect("manager should retain failure receiver"); + for _ in 0..4 { + tokio::task::yield_now().await; + } + let permit = harness + .control + .try_acquire() + .expect("stale failure must not close current generation"); + drop(permit); + + assert_eq!(harness.control.set_enabled(false).await, Ok(false)); + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } + + #[tokio::test] + async fn exhausted_generation_counter_does_not_emit_enabling() { + let mut harness = test_harness(); + harness.manager.last_generation = u64::MAX; + let (_temp, ctx) = test_ctx(harness.control.clone()); + let (event_tx, mut events) = mpsc::unbounded_channel(); + let manager_task = tokio::spawn(harness.manager.run(ctx.clone(), event_tx, false)); + wait_for_state(&mut events, LocalNetworkSharingState::Disabled).await; + + assert!(harness.control.set_enabled(true).await.is_err()); + while let Ok(event) = events.try_recv() { + assert!(!matches!( + event, + PeerEvent::LocalNetworkSharingStateChanged(LocalNetworkSharingState::Enabling) + )); + } + assert_eq!(harness.starts.load(Ordering::SeqCst), 0); + + ctx.shutdown.cancel(); + manager_task.await.expect("manager task should not panic"); + } +} diff --git a/crates/lanspread-peer/src/peer.rs b/crates/lanspread-peer/src/peer.rs index 6005fe9..13c2cbd 100644 --- a/crates/lanspread-peer/src/peer.rs +++ b/crates/lanspread-peer/src/peer.rs @@ -1,23 +1,57 @@ -use std::{convert::TryInto, path::Path}; +use std::{ + convert::TryInto, + fs::File, + io::{self, Read, SeekFrom}, + path::Path, +}; use bytes::Bytes; -use lanspread_db::db::GameFileDescription; use lanspread_utils::maybe_addr; use s2n_quic::{ application, connection, stream::{Error as StreamError, SendStream}, }; -use tokio::{ - io::{AsyncReadExt, AsyncSeekExt}, - time::Instant, -}; +use tokio::time::Instant; -use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path}; +use crate::{config::FILE_TRANSFER_BUFFER_SIZE, scoped_blocking::scoped_blocking}; -fn cancel_send_stream(tx: &mut SendStream, remote_addr: impl std::fmt::Display, path: &Path) { +const RAW_CHUNK_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ChunkCloseOutcome { + Closed, + CleanRemoteClose, +} + +trait RawChunkSend { + async fn send_chunk(&mut self, bytes: Bytes) -> Result<(), String>; + async fn close_chunk(&mut self) -> Result; + fn reset_chunk(&mut self) -> Result<(), String>; +} + +impl RawChunkSend for SendStream { + async fn send_chunk(&mut self, bytes: Bytes) -> Result<(), String> { + self.send(bytes).await.map_err(|error| error.to_string()) + } + + async fn close_chunk(&mut self) -> Result { + match self.close().await { + Ok(()) => Ok(ChunkCloseOutcome::Closed), + Err(error) if is_clean_remote_close(&error) => Ok(ChunkCloseOutcome::CleanRemoteClose), + Err(error) => Err(error.to_string()), + } + } + + fn reset_chunk(&mut self) -> Result<(), String> { + self.reset(application::Error::UNKNOWN) + .map_err(|error| error.to_string()) + } +} + +fn cancel_send_stream(tx: &mut impl RawChunkSend, remote_addr: &str, path: &Path) { // Reset instead of finishing so truncated whole-file transfers cannot look like EOF. - if let Err(err) = tx.reset(application::Error::UNKNOWN) { + if let Err(err) = tx.reset_chunk() { log::debug!( "{remote_addr} failed to reset cancelled transfer for {}: {err}", path.display() @@ -25,32 +59,83 @@ fn cancel_send_stream(tx: &mut SendStream, remote_addr: impl std::fmt::Display, } } +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum FileReadOutcome { + BytesRead(usize), + Cancelled, +} + +/// Completes one bounded read in the caller's task so cancellation cannot +/// detach filesystem work onto Tokio's blocking pool. +pub(crate) fn read_file_bytes_scoped( + cancel_token: &tokio_util::sync::CancellationToken, + reader: &mut impl Read, + buffer: &mut [u8], +) -> io::Result { + if cancel_token.is_cancelled() { + return Ok(FileReadOutcome::Cancelled); + } + + let result = scoped_blocking(|| reader.read(buffer)); + + if cancel_token.is_cancelled() { + Ok(FileReadOutcome::Cancelled) + } else { + result.map(FileReadOutcome::BytesRead) + } +} + +fn seek_file_scoped(file: &mut impl io::Seek, offset: u64) -> io::Result<()> { + scoped_blocking(|| io::Seek::seek(file, SeekFrom::Start(offset)).map(|_| ())) +} + #[allow(clippy::too_many_lines)] async fn stream_file_bytes( tx: &mut SendStream, - base_dir: &Path, - relative_path: &str, + file: File, + display_path: &Path, offset: u64, - length: Option, + length: u64, cancel_token: tokio_util::sync::CancellationToken, ) -> eyre::Result<()> { - let remote_addr = maybe_addr!(tx.connection().remote_addr()); + let remote_addr = format!("{}", maybe_addr!(tx.connection().remote_addr())); + stream_file_bytes_with_timeout( + tx, + file, + display_path, + offset, + length, + cancel_token, + &remote_addr, + RAW_CHUNK_SEND_TIMEOUT, + ) + .await +} - // Validate the path to prevent directory traversal - let validated_path = validate_game_file_path(base_dir, relative_path)?; +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +async fn stream_file_bytes_with_timeout( + tx: &mut impl RawChunkSend, + mut file: impl Read + io::Seek, + display_path: &Path, + offset: u64, + length: u64, + cancel_token: tokio_util::sync::CancellationToken, + remote_addr: &str, + send_timeout: std::time::Duration, +) -> eyre::Result<()> { log::debug!( - "{remote_addr} streaming file bytes for peer: {}, offset: {offset}, length: {length:?}", - validated_path.display() + "{remote_addr} streaming file bytes for peer: {}, offset: {offset}, length: {length}", + display_path.display() ); - let mut file = tokio::fs::File::open(&validated_path).await?; - if offset > 0 { - file.seek(std::io::SeekFrom::Start(offset)).await?; + if offset > 0 + && let Err(error) = seek_file_scoped(&mut file, offset) + { + cancel_send_stream(tx, remote_addr, display_path); + return Err(error.into()); } - let mut remaining = length.unwrap_or(u64::MAX); - let expect_exact = length.is_some(); - let mut transfer_complete = matches!(length, Some(0)); + let mut remaining = length; let mut total_bytes = 0u64; let mut last_total_bytes = 0u64; let started = Instant::now(); @@ -61,9 +146,9 @@ async fn stream_file_bytes( if cancel_token.is_cancelled() { log::info!( "{remote_addr} transfer cancelled for {}", - validated_path.display() + display_path.display() ); - cancel_send_stream(tx, remote_addr, &validated_path); + cancel_send_stream(tx, remote_addr, display_path); return Err(eyre::eyre!("File transfer cancelled by user")); } @@ -73,44 +158,59 @@ async fn stream_file_bytes( break; } - let bytes_read = tokio::select! { - () = cancel_token.cancelled() => { - log::info!( - "{remote_addr} transfer cancelled for {}", - validated_path.display() - ); - cancel_send_stream(tx, remote_addr, &validated_path); - return Err(eyre::eyre!("File transfer cancelled by user")); - } - res = file.read(&mut buf[..read_len]) => { - res? - } - }; + let bytes_read = + match read_file_bytes_scoped(&cancel_token, &mut file, &mut buf[..read_len]) { + Err(error) => { + cancel_send_stream(tx, remote_addr, display_path); + return Err(error.into()); + } + Ok(outcome) => match outcome { + FileReadOutcome::Cancelled => { + log::info!( + "{remote_addr} transfer cancelled for {}", + display_path.display() + ); + cancel_send_stream(tx, remote_addr, display_path); + return Err(eyre::eyre!("File transfer cancelled by user")); + } + FileReadOutcome::BytesRead(bytes_read) => bytes_read, + }, + }; if bytes_read == 0 { - if !expect_exact { - transfer_complete = true; - } - break; + cancel_send_stream(tx, remote_addr, display_path); + return Err(eyre::eyre!( + "catalog file became shorter while streaming {}: {remaining} bytes missing", + display_path.display() + )); } tokio::select! { () = cancel_token.cancelled() => { log::info!( "{remote_addr} transfer cancelled for {}", - validated_path.display() + display_path.display() ); - cancel_send_stream(tx, remote_addr, &validated_path); + cancel_send_stream(tx, remote_addr, display_path); return Err(eyre::eyre!("File transfer cancelled by user")); } - res = tx.send(Bytes::copy_from_slice(&buf[..bytes_read])) => { - res?; + () = tokio::time::sleep(send_timeout) => { + cancel_send_stream(tx, remote_addr, display_path); + return Err(eyre::eyre!( + "catalog chunk send timed out after {send_timeout:?} for {}", + display_path.display() + )); + } + res = tx.send_chunk(Bytes::copy_from_slice(&buf[..bytes_read])) => { + if let Err(error) = res { + cancel_send_stream(tx, remote_addr, display_path); + return Err(eyre::eyre!(error)); + } } } remaining = remaining.saturating_sub(bytes_read as u64); total_bytes += bytes_read as u64; - if expect_exact && remaining == 0 { - transfer_complete = true; + if remaining == 0 { break; } @@ -123,7 +223,7 @@ async fn stream_file_bytes( let mb_per_s = (diff_bytes as f64) / (elapsed.as_secs_f64() * 1_000_000.0); log::debug!( "{remote_addr} sending file data: {}, MB/s: {mb_per_s:.2}", - validated_path.display() + display_path.display() ); last_total_bytes = total_bytes; timestamp = Instant::now(); @@ -140,22 +240,32 @@ async fn stream_file_bytes( }; log::info!( "{remote_addr} finished streaming file bytes: {}, total_bytes: {total_bytes}, MiB/s: {mib_per_s:.2}", - validated_path.display() + display_path.display() ); tokio::select! { () = cancel_token.cancelled() => { log::info!("{remote_addr} transfer cancelled while closing stream"); - cancel_send_stream(tx, remote_addr, &validated_path); + cancel_send_stream(tx, remote_addr, display_path); return Err(eyre::eyre!("File transfer cancelled by user")); } - res = tx.close() => { + () = tokio::time::sleep(send_timeout) => { + cancel_send_stream(tx, remote_addr, display_path); + return Err(eyre::eyre!( + "catalog chunk close timed out after {send_timeout:?} for {}", + display_path.display() + )); + } + res = tx.close_chunk() => { match res { - Ok(()) => {} - Err(err) if transfer_complete && is_clean_remote_close(&err) => { - log::debug!("{remote_addr} closed stream after transfer completion: {err}"); + Ok(ChunkCloseOutcome::Closed) => {} + Ok(ChunkCloseOutcome::CleanRemoteClose) => { + log::debug!("{remote_addr} closed stream after transfer completion"); + } + Err(err) => { + cancel_send_stream(tx, remote_addr, display_path); + return Err(eyre::eyre!(err)); } - Err(err) => return Err(err.into()), } } } @@ -172,52 +282,235 @@ fn is_clean_remote_close(err: &StreamError) -> bool { ) } -pub async fn send_game_file_data( - game_file_desc: &GameFileDescription, - tx: &mut SendStream, - game_dir: &Path, - cancel_token: tokio_util::sync::CancellationToken, -) { - if let Err(e) = stream_file_bytes( - tx, - game_dir, - &game_file_desc.relative_path, - 0, - None, - cancel_token, - ) - .await - { - let remote_addr = maybe_addr!(tx.connection().remote_addr()); - log::error!( - "{remote_addr} failed to stream file {}: {e}", - game_file_desc.relative_path - ); - } -} - pub async fn send_game_file_chunk( - game_id: &str, relative_path: &str, offset: u64, length: u64, + file: File, tx: &mut SendStream, - game_dir: &Path, cancel_token: tokio_util::sync::CancellationToken, -) { - if let Err(e) = stream_file_bytes( +) -> eyre::Result<()> { + let result = stream_file_bytes( tx, - game_dir, - relative_path, + file, + Path::new(relative_path), offset, - Some(length), + length, cancel_token, ) - .await - { + .await; + if let Err(e) = &result { let remote_addr = maybe_addr!(tx.connection().remote_addr()); log::error!( - "{remote_addr} failed to stream chunk {game_id}/{relative_path} offset {offset} length {length}: {e}" + "{remote_addr} failed to stream chunk {relative_path} offset {offset} length {length}: {e}" ); } + result +} + +#[cfg(test)] +mod tests { + use std::{future::pending, io::Cursor, time::Duration}; + + use tokio_util::sync::CancellationToken; + + use super::*; + + #[derive(Clone, Copy)] + enum ControlledIo { + Ready, + Fail, + Pending, + } + + struct ControlledSender { + send: ControlledIo, + close: ControlledIo, + reset_count: usize, + } + + impl ControlledSender { + const fn new(send: ControlledIo, close: ControlledIo) -> Self { + Self { + send, + close, + reset_count: 0, + } + } + } + + impl RawChunkSend for ControlledSender { + async fn send_chunk(&mut self, _bytes: Bytes) -> Result<(), String> { + match self.send { + ControlledIo::Ready => Ok(()), + ControlledIo::Fail => Err("controlled send failure".to_string()), + ControlledIo::Pending => pending().await, + } + } + + async fn close_chunk(&mut self) -> Result { + match self.close { + ControlledIo::Ready => Ok(ChunkCloseOutcome::Closed), + ControlledIo::Fail => Err("controlled close failure".to_string()), + ControlledIo::Pending => pending().await, + } + } + + fn reset_chunk(&mut self) -> Result<(), String> { + self.reset_count += 1; + Ok(()) + } + } + + struct ControlledReader { + bytes: Cursor>, + fail_read: bool, + fail_seek: bool, + } + + impl ControlledReader { + fn bytes(bytes: &[u8]) -> Self { + Self { + bytes: Cursor::new(bytes.to_vec()), + fail_read: false, + fail_seek: false, + } + } + } + + impl Read for ControlledReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.fail_read { + return Err(io::Error::other("controlled read failure")); + } + Read::read(&mut self.bytes, buffer) + } + } + + impl io::Seek for ControlledReader { + fn seek(&mut self, position: SeekFrom) -> io::Result { + if self.fail_seek { + return Err(io::Error::other("controlled seek failure")); + } + io::Seek::seek(&mut self.bytes, position) + } + } + + async fn controlled_stream( + sender: &mut ControlledSender, + reader: ControlledReader, + offset: u64, + length: u64, + timeout: Duration, + ) -> eyre::Result<()> { + stream_file_bytes_with_timeout( + sender, + reader, + Path::new("payload.bin"), + offset, + length, + CancellationToken::new(), + "controlled-peer", + timeout, + ) + .await + } + + #[tokio::test] + async fn seek_read_and_send_failures_reset_the_admitted_chunk() { + let mut seek_sender = ControlledSender::new(ControlledIo::Ready, ControlledIo::Ready); + let mut seek_reader = ControlledReader::bytes(b"payload"); + seek_reader.fail_seek = true; + assert!( + controlled_stream(&mut seek_sender, seek_reader, 1, 1, Duration::from_secs(1)) + .await + .is_err() + ); + assert_eq!(seek_sender.reset_count, 1); + + let mut read_sender = ControlledSender::new(ControlledIo::Ready, ControlledIo::Ready); + let mut read_reader = ControlledReader::bytes(b"payload"); + read_reader.fail_read = true; + assert!( + controlled_stream(&mut read_sender, read_reader, 0, 1, Duration::from_secs(1)) + .await + .is_err() + ); + assert_eq!(read_sender.reset_count, 1); + + let mut send_sender = ControlledSender::new(ControlledIo::Fail, ControlledIo::Ready); + assert!( + controlled_stream( + &mut send_sender, + ControlledReader::bytes(b"payload"), + 0, + 1, + Duration::from_secs(1) + ) + .await + .is_err() + ); + assert_eq!(send_sender.reset_count, 1); + } + + #[tokio::test(start_paused = true)] + async fn blocked_send_and_close_hit_the_application_deadline_and_reset() { + let timeout = Duration::from_secs(30); + let mut send_sender = ControlledSender::new(ControlledIo::Pending, ControlledIo::Ready); + let send_error = controlled_stream( + &mut send_sender, + ControlledReader::bytes(b"payload"), + 0, + 1, + timeout, + ) + .await + .expect_err("blocked send should time out"); + assert!(send_error.to_string().contains("send timed out")); + assert_eq!(send_sender.reset_count, 1); + + let mut close_sender = ControlledSender::new(ControlledIo::Ready, ControlledIo::Pending); + let close_error = controlled_stream( + &mut close_sender, + ControlledReader::bytes(&[]), + 0, + 0, + timeout, + ) + .await + .expect_err("blocked close should time out"); + assert!(close_error.to_string().contains("close timed out")); + assert_eq!(close_sender.reset_count, 1); + } + + #[tokio::test] + async fn short_file_and_close_failure_reset_instead_of_finishing() { + let mut short_sender = ControlledSender::new(ControlledIo::Ready, ControlledIo::Ready); + assert!( + controlled_stream( + &mut short_sender, + ControlledReader::bytes(&[]), + 0, + 1, + Duration::from_secs(1) + ) + .await + .is_err() + ); + assert_eq!(short_sender.reset_count, 1); + + let mut close_sender = ControlledSender::new(ControlledIo::Ready, ControlledIo::Fail); + assert!( + controlled_stream( + &mut close_sender, + ControlledReader::bytes(&[]), + 0, + 0, + Duration::from_secs(1) + ) + .await + .is_err() + ); + assert_eq!(close_sender.reset_count, 1); + } } diff --git a/crates/lanspread-peer/src/peer_db.rs b/crates/lanspread-peer/src/peer_db.rs index 89d9d41..48bc8b3 100644 --- a/crates/lanspread-peer/src/peer_db.rs +++ b/crates/lanspread-peer/src/peer_db.rs @@ -1,62 +1,283 @@ -//! Peer database and consensus validation for tracking remote peers and their games. +//! Authenticated remote-peer endpoint and responder-owned state cache. use std::{ - cmp::Reverse, - collections::{BTreeMap, HashMap}, + collections::{HashMap, HashSet}, + fmt, net::SocketAddr, - time::{Duration, Instant}, + sync::{Arc, Mutex, MutexGuard}, + time::Duration, }; -use lanspread_db::db::{Availability, Game, GameCatalog, GameFileDescription}; -use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot}; +use lanspread_db::content_manifest::ContentId; +pub use lanspread_proto::PeerId; +use lanspread_proto::{ + GameAvailability, + LibrarySnapshot, + PeerEndpoint, + PeerRevisions, + RuntimeSessionId, +}; +use tokio::time::Instant; -use crate::{game_paths::portable_name_key, library::compute_library_digest}; -pub type PeerId = String; +pub const MAX_AUTHENTICATED_PEERS: usize = 64; -/// Information about a discovered peer. -#[derive(Clone, Debug)] -pub struct PeerInfo { - /// Stable peer identifier. - pub peer_id: PeerId, - /// Network address of the peer. - pub addr: SocketAddr, - /// Last time we heard from this peer. - pub last_seen: Instant, - /// Latest library revision advertised by the peer. - pub library_rev: u64, - /// Digest of the peer library state. - pub library_digest: u64, - /// Capability flags advertised by the peer. - pub features: Vec, - /// Games this peer has available, keyed by game ID. - pub games: HashMap, - /// File descriptions for each game, keyed by game ID. - pub files: HashMap>, +/// Monotonic identity for one successfully authenticated endpoint observation. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct PeerEndpointGeneration(u64); + +impl PeerEndpointGeneration { + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } } -/// Immutable peer state suitable for CLI assertions and tests. +/// One endpoint generation retired by an authenticated topology transition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RetiredPeerEndpoint { + pub endpoint: PeerEndpoint, + pub generation: PeerEndpointGeneration, +} + +/// Immutable liveness probe target and the generation it may update. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PeerLivenessSnapshot { + pub endpoint: PeerEndpoint, + pub generation: PeerEndpointGeneration, + pub last_seen: Instant, + pub last_revision_check: Instant, + pub consecutive_ping_failures: u8, +} + +/// Cached revision authority for one authenticated endpoint generation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PeerRevisionSnapshot { + pub endpoint: PeerEndpoint, + pub generation: PeerEndpointGeneration, + pub runtime_session_id: RuntimeSessionId, + pub library_revision: Option, + pub call_to_play_revision: Option, +} + +/// Opaque authority to commit one peer negotiation if it is still current. +pub struct PeerNegotiationTicket { + claim: PeerNegotiationClaim, + registry: Arc>, + active: bool, +} + +impl fmt::Debug for PeerNegotiationTicket { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PeerNegotiationTicket") + .field("endpoint", &self.claim.endpoint) + .field("active", &self.active) + .finish_non_exhaustive() + } +} + +impl Drop for PeerNegotiationTicket { + fn drop(&mut self) { + if self.active { + lock_negotiation_registry(&self.registry).release_if_current(self.claim); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct PeerNegotiationTicketId(u64); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PeerNegotiationClaim { + endpoint: PeerEndpoint, + ticket_id: PeerNegotiationTicketId, +} + +#[derive(Debug, Default)] +struct PeerNegotiationRegistry { + latest_by_peer: HashMap, + latest_by_addr: HashMap, + /// Invalidated claims continue occupying their peer and address until the + /// owner releases the ticket, but can no longer commit responder state. + fenced: HashSet, + last_ticket: u64, +} + +impl PeerNegotiationTicket { + fn new(claim: PeerNegotiationClaim, registry: Arc>) -> Self { + Self { + claim, + registry, + active: true, + } + } + + fn consume_if_current_and_valid(&mut self) -> bool { + if !self.active { + return false; + } + let released = lock_negotiation_registry(&self.registry).release_if_current(self.claim); + if released.is_some() { + self.active = false; + } + released == Some(true) + } + + fn release_if_current(&mut self) -> bool { + if !self.active { + return false; + } + let released = lock_negotiation_registry(&self.registry).release_if_current(self.claim); + if released.is_some() { + self.active = false; + } + released.is_some() + } + + /// Returns the generation a successful commit of this ticket will publish. + /// Wasted ticket values are intentional: domain preparation can therefore + /// finish before either state lock is acquired. + #[must_use] + pub(crate) const fn endpoint_generation(&self) -> PeerEndpointGeneration { + PeerEndpointGeneration(self.claim.ticket_id.0) + } +} + +impl PeerNegotiationRegistry { + fn reserve_candidate(&mut self, endpoint: PeerEndpoint) -> eyre::Result { + if self.latest_by_peer.contains_key(&endpoint.peer_id) + || self.latest_by_addr.contains_key(&endpoint.addr) + { + eyre::bail!("peer or address already has an in-flight authenticated negotiation"); + } + self.reserve(endpoint) + } + + fn reserve_refresh( + &mut self, + endpoint: PeerEndpoint, + ) -> eyre::Result> { + if self.latest_by_peer.contains_key(&endpoint.peer_id) + || self.latest_by_addr.contains_key(&endpoint.addr) + { + return Ok(None); + } + self.reserve(endpoint).map(Some) + } + + fn reserve(&mut self, endpoint: PeerEndpoint) -> eyre::Result { + let ticket_value = self + .last_ticket + .checked_add(1) + .ok_or_else(|| eyre::eyre!("peer negotiation ticket exhausted"))?; + let claim = PeerNegotiationClaim { + endpoint, + ticket_id: PeerNegotiationTicketId(ticket_value), + }; + + debug_assert!(!self.latest_by_peer.contains_key(&endpoint.peer_id)); + debug_assert!(!self.latest_by_addr.contains_key(&endpoint.addr)); + + self.last_ticket = ticket_value; + self.latest_by_peer.insert(endpoint.peer_id, claim); + self.latest_by_addr.insert(endpoint.addr, claim); + Ok(claim) + } + + /// Releases physical occupancy and reports whether the claim retained + /// commit authority. `None` means a non-current/foreign ticket. + fn release_if_current(&mut self, claim: PeerNegotiationClaim) -> Option { + if self.latest_by_peer.get(&claim.endpoint.peer_id) != Some(&claim) + || self.latest_by_addr.get(&claim.endpoint.addr) != Some(&claim) + { + return None; + } + self.latest_by_peer.remove(&claim.endpoint.peer_id); + self.latest_by_addr.remove(&claim.endpoint.addr); + Some(!self.fenced.remove(&claim.ticket_id)) + } + + fn fence_peer_or_addr(&mut self, endpoint: PeerEndpoint) { + if let Some(claim) = self.latest_by_peer.get(&endpoint.peer_id) { + self.fenced.insert(claim.ticket_id); + } + if let Some(claim) = self.latest_by_addr.get(&endpoint.addr) { + self.fenced.insert(claim.ticket_id); + } + } +} + +fn lock_negotiation_registry( + registry: &Mutex, +) -> MutexGuard<'_, PeerNegotiationRegistry> { + registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Information cached only after a pinned responder exchange. +#[derive(Clone, Debug)] +pub struct PeerInfo { + pub peer_id: PeerId, + pub addr: SocketAddr, + pub endpoint_generation: PeerEndpointGeneration, + pub last_seen: Instant, + pub last_revision_check: Instant, + consecutive_ping_failures: u8, + pub runtime_session_id: RuntimeSessionId, + pub library_revision: Option, + pub call_to_play_revision: Option, + pub games: HashMap, +} + +/// Immutable peer state suitable for CLI assertions and diagnostics. #[derive(Clone, Debug)] pub struct PeerSnapshot { pub peer_id: PeerId, pub addr: SocketAddr, - pub library_rev: u64, - pub library_digest: u64, - pub features: Vec, + pub endpoint_generation: PeerEndpointGeneration, + pub runtime_session_id: RuntimeSessionId, + pub library_revision: Option, + pub call_to_play_revision: Option, pub game_count: usize, - pub games: Vec, + pub games: Vec, } -/// Database tracking all discovered peers and their games. #[derive(Debug)] pub struct PeerGameDB { peers: HashMap, addr_index: HashMap, + negotiation_registry: Arc>, } +#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Copy)] pub struct PeerUpsert { pub is_new: bool, pub addr_changed: bool, + pub endpoint_generation: PeerEndpointGeneration, + pub previous_endpoint: Option, + pub evicted_endpoint: Option, + pub session_changed: bool, + pub library_changed: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PongObservation { + StaleGeneration, + Current, + RevisionMismatch, + NewSession, +} + +/// Result of reserving a refresh for one captured authenticated generation. +#[derive(Debug)] +pub(crate) enum RefreshReservation { + Reserved(PeerNegotiationTicket), + /// Another physical negotiation owns the peer or address claim. The + /// scheduler retains one bounded/coalesced retry for this case. + DeferredByCandidate, + Stale, } impl Default for PeerGameDB { @@ -71,1318 +292,1011 @@ impl PeerGameDB { Self { peers: HashMap::new(), addr_index: HashMap::new(), + negotiation_registry: Arc::new(Mutex::new(PeerNegotiationRegistry::default())), } } - /// Adds a new peer to the database or updates its address. - pub fn upsert_peer(&mut self, peer_id: PeerId, addr: SocketAddr) -> PeerUpsert { - if let Some(existing_id) = self.addr_index.get(&addr).cloned() - && existing_id != peer_id + #[cfg(test)] + pub(crate) fn negotiation_claim_counts(&self) -> (usize, usize) { + let registry = lock_negotiation_registry(&self.negotiation_registry); + (registry.latest_by_peer.len(), registry.latest_by_addr.len()) + } + + pub fn begin_candidate_negotiation( + &mut self, + endpoint: PeerEndpoint, + ) -> eyre::Result { + let claim = + lock_negotiation_registry(&self.negotiation_registry).reserve_candidate(endpoint)?; + Ok(PeerNegotiationTicket::new( + claim, + Arc::clone(&self.negotiation_registry), + )) + } + + pub(crate) fn begin_peer_refresh( + &mut self, + snapshot: PeerLivenessSnapshot, + ) -> eyre::Result { + let Some(peer) = self.peers.get(&snapshot.endpoint.peer_id) else { + return Ok(RefreshReservation::Stale); + }; + if peer.addr != snapshot.endpoint.addr || peer.endpoint_generation != snapshot.generation { + return Ok(RefreshReservation::Stale); + } + let claim = lock_negotiation_registry(&self.negotiation_registry) + .reserve_refresh(snapshot.endpoint)?; + Ok( + claim.map_or(RefreshReservation::DeferredByCandidate, |claim| { + RefreshReservation::Reserved(PeerNegotiationTicket::new( + claim, + Arc::clone(&self.negotiation_registry), + )) + }), + ) + } + + /// Commits one independently validated responder snapshot. + #[allow(clippy::needless_pass_by_value, clippy::too_many_lines)] + pub fn commit_authenticated_snapshot( + &mut self, + endpoint: PeerEndpoint, + mut ticket: PeerNegotiationTicket, + runtime_session_id: RuntimeSessionId, + library: Option, + ) -> eyre::Result> { + let endpoint_generation = ticket.endpoint_generation(); + if endpoint != ticket.claim.endpoint + || !Arc::ptr_eq(&self.negotiation_registry, &ticket.registry) + || !ticket.consume_if_current_and_valid() { - self.peers.remove(&existing_id); - self.addr_index.remove(&addr); + return Ok(None); } + let existing_at_addr = self.addr_index.get(&endpoint.addr).copied(); + let is_new = !self.peers.contains_key(&endpoint.peer_id); + let makes_room = existing_at_addr.is_some_and(|id| id != endpoint.peer_id); + if is_new && self.peers.len() >= MAX_AUTHENTICATED_PEERS && !makes_room { + eyre::bail!("authenticated peer limit of {MAX_AUTHENTICATED_PEERS} reached"); + } + + let PeerEndpoint { peer_id, addr } = endpoint; + let now = Instant::now(); + + let evicted_endpoint = existing_at_addr + .filter(|existing_id| *existing_id != peer_id) + .and_then(|existing_id| { + self.addr_index.remove(&addr); + self.peers + .remove(&existing_id) + .map(|evicted| RetiredPeerEndpoint { + endpoint: PeerEndpoint::new(evicted.peer_id, evicted.addr), + generation: evicted.endpoint_generation, + }) + }); + + let incoming_library = library + .as_ref() + .map(|snapshot| (snapshot.revision, library_map(snapshot))); if let Some(peer) = self.peers.get_mut(&peer_id) { - let addr_changed = peer.addr != addr; + let previous_endpoint = + (peer.addr != addr).then(|| PeerEndpoint::new(peer.peer_id, peer.addr)); + let addr_changed = previous_endpoint.is_some(); if addr_changed { self.addr_index.remove(&peer.addr); - self.addr_index.insert(addr, peer_id.clone()); + self.addr_index.insert(addr, peer_id); peer.addr = addr; } - peer.last_seen = Instant::now(); - return PeerUpsert { + + let session_changed = peer.runtime_session_id != runtime_session_id; + let library_changed = match incoming_library { + Some((revision, games)) + if session_changed + || peer + .library_revision + .is_none_or(|current| revision > current) => + { + let changed = peer.library_revision != Some(revision) || peer.games != games; + peer.library_revision = Some(revision); + peer.games = games; + changed + } + None if session_changed => { + let changed = peer.library_revision.is_some() || !peer.games.is_empty(); + peer.library_revision = None; + peer.games.clear(); + changed + } + Some(_) | None => false, + }; + + peer.endpoint_generation = endpoint_generation; + peer.last_seen = now; + peer.last_revision_check = now; + peer.consecutive_ping_failures = 0; + peer.runtime_session_id = runtime_session_id; + if session_changed { + peer.call_to_play_revision = None; + } + return Ok(Some(PeerUpsert { is_new: false, addr_changed, - }; + endpoint_generation, + previous_endpoint, + evicted_endpoint, + session_changed, + library_changed, + })); } - let peer_info = PeerInfo { - peer_id: peer_id.clone(), - addr, - last_seen: Instant::now(), - library_rev: 0, - library_digest: 0, - features: Vec::new(), - games: HashMap::new(), - files: HashMap::new(), - }; - self.peers.insert(peer_id.clone(), peer_info); + self.peers.insert( + peer_id, + PeerInfo { + peer_id, + addr, + endpoint_generation, + last_seen: now, + last_revision_check: now, + consecutive_ping_failures: 0, + runtime_session_id, + library_revision: incoming_library.as_ref().map(|(revision, _)| *revision), + call_to_play_revision: None, + games: incoming_library.map_or_else(HashMap::new, |(_, games)| games), + }, + ); self.addr_index.insert(addr, peer_id); - log::info!("Added peer: {addr}"); - PeerUpsert { + Ok(Some(PeerUpsert { is_new: true, addr_changed: false, - } + endpoint_generation, + previous_endpoint: None, + evicted_endpoint, + session_changed: true, + library_changed: true, + })) } - /// Removes a peer from the database by id. - pub fn remove_peer(&mut self, peer_id: &PeerId) -> Option { - if let Some(peer) = self.peers.remove(peer_id) { - self.addr_index.remove(&peer.addr); - return Some(peer); - } - None - } - - /// Removes a peer by address. - pub fn remove_peer_by_addr(&mut self, addr: &SocketAddr) -> Option { - let peer_id = self.addr_index.remove(addr)?; - self.peers.remove(&peer_id) - } - - /// Returns the peer id for an address if known. - #[must_use] - pub fn peer_id_for_addr(&self, addr: &SocketAddr) -> Option<&PeerId> { - self.addr_index.get(addr) - } - - /// Returns the peer id for a transport source address. - /// - /// QUIC clients connect from ephemeral source ports, while peer records are - /// keyed by their advertised listening address. If the exact socket address - /// is unknown, fall back to a unique peer with the same IP address. - #[must_use] - pub fn peer_id_for_transport_addr(&self, addr: &SocketAddr) -> Option { - if let Some(peer_id) = self.addr_index.get(addr) { - return Some(peer_id.clone()); - } - - let mut matches = self - .peers - .values() - .filter(|peer| peer.addr.ip() == addr.ip()) - .map(|peer| peer.peer_id.clone()); - let peer_id = matches.next()?; - if matches.next().is_some() { - return None; - } - - Some(peer_id) - } - - /// Returns the library state for a peer if known. - #[must_use] - pub fn peer_library_state(&self, peer_id: &PeerId) -> Option<(u64, u64)> { - self.peers - .get(peer_id) - .map(|peer| (peer.library_rev, peer.library_digest)) - } - - /// Returns the number of games known for a peer. - #[must_use] - pub fn peer_game_count(&self, peer_id: &PeerId) -> usize { - self.peers.get(peer_id).map_or(0, |peer| peer.games.len()) - } - - /// Returns the feature list for a peer. - #[must_use] - pub fn peer_features(&self, peer_id: &PeerId) -> Vec { - self.peers - .get(peer_id) - .map(|peer| peer.features.clone()) - .unwrap_or_default() - } - - /// Returns the address for a peer id. - #[must_use] - pub fn peer_addr(&self, peer_id: &PeerId) -> Option { - self.peers.get(peer_id).map(|peer| peer.addr) - } - - /// Updates the games list for a peer. - pub fn update_peer_games(&mut self, peer_id: &PeerId, games: Vec) { - if let Some(peer) = self.peers.get_mut(peer_id) { - let mut map = HashMap::with_capacity(games.len()); - for game in games { - map.insert(game.id.clone(), game); - } - peer.games = map; - peer.last_seen = Instant::now(); - log::info!("Updated games for peer: {}", peer.addr); - } - } - - /// Updates the file descriptions for a specific game from a peer. - pub fn update_peer_game_files( + pub fn abandon_peer_negotiation( &mut self, - peer_id: &PeerId, - game_id: &str, - files: Vec, - ) { - if let Some(peer) = self.peers.get_mut(peer_id) { - peer.files.insert(game_id.to_string(), files); - peer.last_seen = Instant::now(); - } + endpoint: PeerEndpoint, + mut ticket: PeerNegotiationTicket, + ) -> bool { + endpoint == ticket.claim.endpoint + && Arc::ptr_eq(&self.negotiation_registry, &ticket.registry) + && ticket.release_if_current() } - /// Updates the last seen timestamp for a peer. - pub fn update_last_seen(&mut self, peer_id: &PeerId) { - if let Some(peer) = self.peers.get_mut(peer_id) { - peer.last_seen = Instant::now(); + /// Stamps a pinned Pong and clears previous-session state before a pull. + pub fn observe_pong_if_generation( + &mut self, + snapshot: PeerLivenessSnapshot, + revisions: PeerRevisions, + ) -> PongObservation { + let Some(peer) = self.peers.get_mut(&snapshot.endpoint.peer_id) else { + return PongObservation::StaleGeneration; + }; + if peer.addr != snapshot.endpoint.addr || peer.endpoint_generation != snapshot.generation { + return PongObservation::StaleGeneration; } - } - /// Updates the last seen timestamp for a peer by address. - pub fn update_last_seen_by_addr(&mut self, addr: &SocketAddr) { - if let Some(peer_id) = self.peer_id_for_transport_addr(addr) - && let Some(peer) = self.peers.get_mut(&peer_id) + let now = Instant::now(); + peer.last_seen = now; + peer.last_revision_check = now; + peer.consecutive_ping_failures = 0; + if peer.runtime_session_id != revisions.runtime_session_id { + peer.runtime_session_id = revisions.runtime_session_id; + peer.library_revision = None; + peer.call_to_play_revision = None; + peer.games.clear(); + lock_negotiation_registry(&self.negotiation_registry) + .fence_peer_or_addr(snapshot.endpoint); + return PongObservation::NewSession; + } + if peer.library_revision != Some(revisions.library_revision) + || peer.call_to_play_revision != Some(revisions.call_to_play_revision) { - peer.last_seen = Instant::now(); + PongObservation::RevisionMismatch + } else { + PongObservation::Current } } - /// Updates the library metadata for a peer. - pub fn update_peer_library( + pub fn set_call_to_play_revision_if_generation( &mut self, - peer_id: &PeerId, - library_rev: u64, - library_digest: u64, - features: Vec, - ) { - if let Some(peer) = self.peers.get_mut(peer_id) { - peer.library_rev = library_rev; - peer.library_digest = library_digest; - peer.features = features; - peer.last_seen = Instant::now(); - } - } - - /// Updates the advertised feature list for a peer. - pub fn update_peer_features(&mut self, peer_id: &PeerId, features: Vec) { - if let Some(peer) = self.peers.get_mut(peer_id) { - peer.features = features; - peer.last_seen = Instant::now(); - } - } - - /// Applies a full library snapshot for a peer. - pub fn apply_library_snapshot(&mut self, peer_id: &PeerId, snapshot: LibrarySnapshot) { - if let Some(peer) = self.peers.get_mut(peer_id) { - let mut map = HashMap::with_capacity(snapshot.games.len()); - for game in snapshot.games { - map.insert(game.id.clone(), game); - } - let digest = compute_library_digest(&map); - peer.games = map; - peer.library_rev = snapshot.library_rev; - peer.library_digest = digest; - peer.last_seen = Instant::now(); - } - } - - /// Applies a library delta for a peer. Returns true when applied. - pub fn apply_library_delta(&mut self, peer_id: &PeerId, delta: LibraryDelta) -> bool { - let Some(peer) = self.peers.get_mut(peer_id) else { + endpoint: PeerEndpoint, + generation: PeerEndpointGeneration, + revision: Option, + ) -> bool { + let Some(peer) = self.peers.get_mut(&endpoint.peer_id) else { return false; }; - - if delta.to_rev <= peer.library_rev { + if peer.addr != endpoint.addr || peer.endpoint_generation != generation { return false; } - - if delta.from_rev != peer.library_rev { - return false; - } - - for game in delta.added { - peer.games.insert(game.id.clone(), game); - } - for game in delta.updated { - peer.games.insert(game.id.clone(), game); - } - for game_id in delta.removed { - peer.games.remove(&game_id); - } - - peer.library_rev = delta.to_rev; - peer.library_digest = compute_library_digest(&peer.games); - peer.last_seen = Instant::now(); + peer.call_to_play_revision = revision; true } - /// Returns all games aggregated from all peers. - #[must_use] - pub fn get_all_games(&self) -> Vec { - let mut aggregated: HashMap = HashMap::new(); - let mut peer_counts: HashMap = HashMap::new(); - - // Count peers per game - for peer in self.peers.values() { - for game in peer.games.values().filter(|game| game_is_ready(game)) { - *peer_counts.entry(game.id.clone()).or_insert(0) += 1; - } + /// Records a failed pinned Ping without refreshing either liveness clock. + /// A generation mismatch is a strict no-op. + pub fn record_ping_failure_if_generation(&mut self, snapshot: PeerLivenessSnapshot) -> bool { + let Some(peer) = self.peers.get_mut(&snapshot.endpoint.peer_id) else { + return false; + }; + if peer.addr != snapshot.endpoint.addr || peer.endpoint_generation != snapshot.generation { + return false; } - - // Aggregate games with peer counts - for peer in self.peers.values() { - for game in peer.games.values() { - aggregated - .entry(game.id.clone()) - .and_modify(|existing| { - if game_is_ready(game) { - if let (Some(new_version), Some(current)) = - (&game.eti_version, &existing.eti_game_version) - { - if new_version > current { - existing.eti_game_version = Some(new_version.clone()); - } - } else if existing.eti_game_version.is_none() { - existing.eti_game_version.clone_from(&game.eti_version); - } - } - existing.peer_count = *peer_counts.get(&game.id).unwrap_or(&0); - if game.size > existing.size { - existing.size = game.size; - } - if game_is_ready(game) { - existing.set_downloaded(true); - } else if !existing.downloaded { - existing.availability = game.availability.clone(); - } - if game.installed { - existing.installed = true; - } - }) - .or_insert_with(|| { - let mut game_clone = summary_to_game(game); - game_clone.peer_count = *peer_counts.get(&game.id).unwrap_or(&0); - game_clone - }); - } - } - - let mut games: Vec = aggregated.into_values().collect(); - games.sort_by(|a, b| a.name.cmp(&b.name)); - games + peer.consecutive_ping_failures = peer.consecutive_ping_failures.saturating_add(1); + true } - /// Returns catalog games aggregated from peers that advertise the expected catalog version. #[must_use] - pub fn get_catalog_games(&self, catalog: &GameCatalog) -> Vec { - let mut aggregated: HashMap = HashMap::new(); - let mut peer_counts: HashMap = HashMap::new(); - - for peer in self.peers.values() { - for game in peer.games.values().filter(|game| { - catalog.contains(&game.id) - && game_matches_expected_version(game, catalog.expected_version(&game.id)) - }) { - *peer_counts.entry(game.id.clone()).or_insert(0) += 1; - } - } - - for peer in self.peers.values() { - for game in peer.games.values().filter(|game| { - catalog.contains(&game.id) - && game_matches_expected_version(game, catalog.expected_version(&game.id)) - }) { - aggregated - .entry(game.id.clone()) - .and_modify(|existing| { - existing.peer_count = *peer_counts.get(&game.id).unwrap_or(&0); - if game.size > existing.size { - existing.size = game.size; - } - existing.set_downloaded(true); - if game.installed { - existing.installed = true; - } - }) - .or_insert_with(|| { - let mut game_clone = summary_to_game(game); - if let Some(expected_version) = catalog.expected_version(&game.id) { - game_clone.eti_game_version = Some(expected_version.to_string()); - } - game_clone.peer_count = *peer_counts.get(&game.id).unwrap_or(&0); - game_clone - }); - } - } - - let mut games: Vec = aggregated.into_values().collect(); - games.sort_by(|a, b| a.name.cmp(&b.name)); - games + pub fn revision_snapshot(&self, peer_id: &PeerId) -> Option { + self.peers.get(peer_id).map(|peer| PeerRevisionSnapshot { + endpoint: PeerEndpoint::new(peer.peer_id, peer.addr), + generation: peer.endpoint_generation, + runtime_session_id: peer.runtime_session_id, + library_revision: peer.library_revision, + call_to_play_revision: peer.call_to_play_revision, + }) } - /// Returns the latest version of a game across all peers. - #[must_use] - pub fn get_latest_version_for_game(&self, game_id: &str) -> Option { - let mut latest_version: Option = None; + pub fn remove_peer(&mut self, peer_id: &PeerId) -> Option { + let peer = self.peers.remove(peer_id)?; + self.addr_index.remove(&peer.addr); + lock_negotiation_registry(&self.negotiation_registry) + .fence_peer_or_addr(PeerEndpoint::new(peer.peer_id, peer.addr)); + Some(peer) + } - for peer in self.peers.values() { - if let Some(game) = peer.games.get(game_id) - && game_is_ready(game) - && let Some(ref version) = game.eti_version - { - match &latest_version { - None => latest_version = Some(version.clone()), - Some(current_latest) => { - if version > current_latest { - latest_version = Some(version.clone()); - } - } - } - } + pub fn remove_peer_if_generation( + &mut self, + endpoint: PeerEndpoint, + generation: PeerEndpointGeneration, + ) -> Option { + let peer = self.peers.get(&endpoint.peer_id)?; + if peer.addr != endpoint.addr || peer.endpoint_generation != generation { + return None; } - - latest_version + self.remove_peer(&endpoint.peer_id) } - /// Returns all peer addresses. + /// Retires every authenticated remote peer and invalidates every + /// outstanding negotiation ticket. + /// + /// The fresh registry retains the monotonic ticket counter so a later + /// endpoint generation cannot repeat one issued before the clear. Replacing + /// the registry [`Arc`] also makes every old ticket fail pointer-identity + /// validation without allowing its eventual release to affect new claims. #[must_use] - pub fn get_peer_addresses(&self) -> Vec { - self.peers.values().map(|peer| peer.addr).collect() + pub fn clear_remote_peers(&mut self) -> Vec { + let last_ticket = lock_negotiation_registry(&self.negotiation_registry).last_ticket; + self.negotiation_registry = Arc::new(Mutex::new(PeerNegotiationRegistry { + last_ticket, + ..PeerNegotiationRegistry::default() + })); + + self.addr_index.clear(); + let mut retired = self + .peers + .drain() + .map(|(_, peer)| RetiredPeerEndpoint { + endpoint: PeerEndpoint::new(peer.peer_id, peer.addr), + generation: peer.endpoint_generation, + }) + .collect::>(); + retired.sort_by_key(|peer| peer.endpoint.peer_id); + retired } - /// Returns peer liveness info for ping scheduling. #[must_use] - pub fn peer_liveness_snapshot(&self) -> Vec<(PeerId, SocketAddr, Instant)> { + pub fn peer_endpoint(&self, peer_id: &PeerId) -> Option { + self.peers + .get(peer_id) + .map(|peer| PeerEndpoint::new(peer.peer_id, peer.addr)) + } + + #[must_use] + pub fn peer_endpoints(&self) -> Vec { self.peers .values() - .map(|peer| (peer.peer_id.clone(), peer.addr, peer.last_seen)) + .map(|peer| PeerEndpoint::new(peer.peer_id, peer.addr)) .collect() } - /// Returns peer ids with their current addresses. #[must_use] - pub fn peer_identities(&self) -> Vec<(PeerId, SocketAddr)> { - self.peers - .values() - .map(|peer| (peer.peer_id.clone(), peer.addr)) - .collect() + pub fn peer_liveness_snapshot(&self) -> Vec { + self.peers.values().map(peer_liveness_snapshot).collect() + } + + #[must_use] + pub fn peer_liveness_for(&self, peer_id: &PeerId) -> Option { + self.peers.get(peer_id).map(peer_liveness_snapshot) } - /// Returns immutable snapshots for all known peers. #[must_use] pub fn peer_snapshots(&self) -> Vec { let mut peers = self .peers .values() .map(|peer| { - let mut games = peer.games.values().cloned().collect::>(); - games.sort_by(|a, b| a.id.cmp(&b.id)); + let mut games = peer + .games + .iter() + .map(|(game_id, content_id)| GameAvailability { + game_id: game_id.clone(), + content_id: *content_id, + }) + .collect::>(); + games.sort_by(|left, right| left.game_id.cmp(&right.game_id)); PeerSnapshot { - peer_id: peer.peer_id.clone(), + peer_id: peer.peer_id, addr: peer.addr, - library_rev: peer.library_rev, - library_digest: peer.library_digest, - features: peer.features.clone(), + endpoint_generation: peer.endpoint_generation, + runtime_session_id: peer.runtime_session_id, + library_revision: peer.library_revision, + call_to_play_revision: peer.call_to_play_revision, game_count: games.len(), games, } }) .collect::>(); - peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id)); + peers.sort_by_key(|peer| peer.peer_id); peers } - /// Checks if a peer is in the database. #[must_use] pub fn contains_peer(&self, peer_id: &PeerId) -> bool { self.peers.contains_key(peer_id) } - /// Checks if a peer address is in the database. #[must_use] - pub fn contains_peer_addr(&self, addr: &SocketAddr) -> bool { - self.addr_index.contains_key(addr) - } - - /// Returns addresses of peers that have a specific game. - #[must_use] - pub fn peers_with_game(&self, game_id: &str) -> Vec { - self.peers - .iter() - .filter(|(_, peer)| peer.games.get(game_id).is_some_and(game_is_ready)) - .map(|(_, peer)| peer.addr) - .collect() - } - - /// Returns addresses of peers that have the expected catalog version of a game. - #[must_use] - pub fn peers_with_expected_version( + pub fn peer_endpoints_with_content( &self, game_id: &str, - expected_version: Option<&str>, - ) -> Vec { - self.peers - .iter() - .filter(|(_, peer)| { - peer.games - .get(game_id) - .is_some_and(|game| game_matches_expected_version(game, expected_version)) - }) - .map(|(_, peer)| peer.addr) - .collect() + content_id: ContentId, + ) -> Vec { + let mut endpoints = self + .peers + .values() + .filter(|peer| peer.games.get(game_id) == Some(&content_id)) + .map(|peer| PeerEndpoint::new(peer.peer_id, peer.addr)) + .collect::>(); + endpoints.sort_by_key(|endpoint| endpoint.peer_id); + endpoints } - /// Returns addresses of peers that have the latest version of a game. #[must_use] - pub fn peers_with_latest_version(&self, game_id: &str) -> Vec { - let latest_version = self.get_latest_version_for_game(game_id); - - if let Some(ref latest) = latest_version { + pub fn peer_count_for_content(&self, game_id: &str, content_id: ContentId) -> u32 { + u32::try_from( self.peers - .iter() - .filter(|(_, peer)| { - if let Some(game) = peer.games.get(game_id) { - if game_is_ready(game) - && let Some(ref version) = game.eti_version - { - version == latest - } else { - false - } - } else { - false - } - }) - .map(|(_, peer)| peer.addr) - .collect() - } else { - // If no version info is available, fall back to all peers with the game - self.peers_with_game(game_id) - } + .values() + .filter(|peer| peer.games.get(game_id) == Some(&content_id)) + .count(), + ) + .unwrap_or(u32::MAX) } - /// Returns file descriptions for a game from all peers. #[must_use] - pub fn game_files_for(&self, game_id: &str) -> Vec<(SocketAddr, Vec)> { + pub fn stale_peer_liveness_snapshots(&self, timeout: Duration) -> Vec { self.peers .values() - .filter_map(|peer| { - if !peer.games.get(game_id).is_some_and(game_is_ready) { - return None; - } - peer.files - .get(game_id) - .cloned() - .map(|files| (peer.addr, files)) + .filter(|peer| { + peer.consecutive_ping_failures >= 2 && peer.last_seen.elapsed() > timeout }) - .collect() - } - - /// Returns file descriptions from peers that advertise the latest game version. - #[must_use] - pub fn latest_game_files_for( - &self, - game_id: &str, - ) -> Vec<(SocketAddr, Vec)> { - let latest_peers = self.peers_with_latest_version(game_id); - if latest_peers.is_empty() { - return Vec::new(); - } - - self.game_files_for(game_id) - .into_iter() - .filter(|(addr, _)| latest_peers.contains(addr)) - .collect() - } - - /// Returns file descriptions from peers that advertise the expected catalog version. - #[must_use] - pub fn expected_version_game_files_for( - &self, - game_id: &str, - expected_version: Option<&str>, - ) -> Vec<(SocketAddr, Vec)> { - let expected_peers = self.peers_with_expected_version(game_id, expected_version); - if expected_peers.is_empty() { - return Vec::new(); - } - - self.game_files_for(game_id) - .into_iter() - .filter(|(addr, _)| expected_peers.contains(addr)) - .collect() - } - - /// Returns aggregated file descriptions for a game across all peers. - #[must_use] - pub fn aggregated_game_files( - &self, - game_id: &str, - expected_version: Option<&str>, - ) -> Vec { - let mut seen: HashMap = HashMap::new(); - for (_, files) in self.expected_version_game_files_for(game_id, expected_version) { - for file in files { - seen.entry(file.relative_path.clone()).or_insert(file); - } - } - seen.into_values().collect() - } - - /// Returns the majority-agreed size for a game. - #[must_use] - pub fn majority_game_size(&self, game_id: &str) -> Option { - let mut size_counts: HashMap = HashMap::new(); - - for peer in self.peers.values() { - if let Some(game) = peer.games.get(game_id) { - if !game_is_ready(game) { - continue; - } - if game.size == 0 { - continue; - } - *size_counts.entry(game.size).or_insert(0) += 1; - } - } - - size_counts - .into_iter() - .max_by(|(size_a, count_a), (size_b, count_b)| { - count_a.cmp(count_b).then_with(|| size_a.cmp(size_b)) - }) - .map(|(size, _)| size) - } - - /// Validates entry shapes and file sizes across all peers. - /// - /// Returns a tuple of (`validated_files`, `peer_whitelist`, `file_peer_map`) where - /// `peer_whitelist` contains peers that have at least one majority-approved file and - /// `file_peer_map` lists which peers were validated for each file. - pub fn validate_file_sizes_majority( - &self, - game_id: &str, - expected_version: Option<&str>, - ) -> eyre::Result { - let game_files = self.expected_version_game_files_for(game_id, expected_version); - self.validate_file_sizes_majority_from(game_id, &game_files) - } - - /// Validates entry consensus over caller-sanitized complete peer manifests. - pub(crate) fn validate_file_sizes_majority_from( - &self, - game_id: &str, - game_files: &[(SocketAddr, Vec)], - ) -> eyre::Result { - if game_files.is_empty() { - return Ok((Vec::new(), Vec::new(), HashMap::new())); - } - - let entry_consensus_map = collect_manifest_entries(game_files)?; - let (validated_files, peer_scores, file_peer_map) = - self.validate_each_entry_consensus(game_id, entry_consensus_map)?; - let peer_whitelist = create_peer_whitelist(peer_scores); - - Ok((validated_files, peer_whitelist, file_peer_map)) - } - - /// Validates consensus for each entry and returns validated descriptions with peer scores. - fn validate_each_entry_consensus( - &self, - game_id: &str, - entry_consensus_map: EntryConsensusMap, - ) -> eyre::Result { - let mut validated_files = Vec::new(); - let mut peer_whitelist_scores: HashMap = HashMap::new(); - let mut file_peer_map: HashMap> = HashMap::new(); - - for (relative_path, descriptor_map) in entry_consensus_map { - let total_peers: usize = descriptor_map.values().map(Vec::len).sum(); - - if total_peers == 0 { - continue; - } - - let (consensus_descriptor, consensus_peers) = - self.determine_entry_consensus(&descriptor_map, total_peers, &relative_path)?; - update_peer_scores(&consensus_peers, &mut peer_whitelist_scores); - - if let Some((descriptor, peers)) = consensus_descriptor { - file_peer_map.insert(relative_path.clone(), peers.clone()); - validated_files.push(GameFileDescription { - game_id: game_id.to_owned(), - relative_path, - is_dir: descriptor.is_dir, - size: descriptor.size, - }); - } - } - - Ok((validated_files, peer_whitelist_scores, file_peer_map)) - } - - /// Determines the consensus shape and size for an entry based on peer reports. - /// - /// # Panics - /// - /// Panics if `descriptor_map.iter().next()` returns None when `total_peers` == 1 - #[allow(clippy::unused_self)] - fn determine_entry_consensus( - &self, - descriptor_map: &BTreeMap>, - total_peers: usize, - relative_path: &str, - ) -> eyre::Result<(ConsensusResult, Vec)> { - if total_peers == 1 { - // Only one peer has this entry - trust it. - let (&descriptor, peers) = descriptor_map - .iter() - .next() - .expect("descriptor_map should have an entry when total_peers == 1"); - return Ok((Some((descriptor, peers.clone())), peers.clone())); - } - - let (majority_descriptor, _majority_count) = find_majority_descriptor(descriptor_map); - - if let Some(descriptor) = majority_descriptor { - let majority_peers = &descriptor_map[&descriptor]; - let is_majority = majority_peers.len() > total_peers / 2; - - if is_majority { - // We have a clear majority - Ok(( - Some((descriptor, majority_peers.clone())), - majority_peers.clone(), - )) - } else if total_peers == 2 { - // Two peers with different descriptions - ambiguous, fail. - eyre::bail!( - "Manifest entry ambiguity for '{}': two peers report different shapes or sizes, cannot determine majority", - relative_path - ); - } - // If no majority and more than 2 peers, fall back to the unique plurality. - else { - Ok(( - Some((descriptor, majority_peers.clone())), - majority_peers.clone(), - )) - } - } else { - // No clear majority and the largest groups are tied. - if total_peers == 2 { - eyre::bail!( - "Manifest entry ambiguity for '{}': two peers report different shapes or sizes, cannot determine majority", - relative_path - ); - } - eyre::bail!( - "Manifest entry ambiguity for '{}': no unique plurality among {} peers", - relative_path, - total_peers - ); - } - } - - /// Returns peers that haven't been seen within the timeout duration. - #[must_use] - pub fn get_stale_peers(&self, timeout: Duration) -> Vec { - self.peers - .values() - .filter(|peer| peer.last_seen.elapsed() > timeout) - .map(|peer| peer.addr) - .collect() - } - - /// Returns stale peer ids that exceeded the timeout. - #[must_use] - pub fn get_stale_peer_ids(&self, timeout: Duration) -> Vec { - self.peers - .values() - .filter(|peer| peer.last_seen.elapsed() > timeout) - .map(|peer| peer.peer_id.clone()) + .map(peer_liveness_snapshot) .collect() } } -// ============================================================================= -// Type aliases for consensus validation -// ============================================================================= - -/// The complete description that peers vote on for one exact protocol path. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct EntryDescriptor { - is_dir: bool, - size: u64, +fn library_map(snapshot: &LibrarySnapshot) -> HashMap { + snapshot + .games + .iter() + .map(|game| (game.game_id.clone(), game.content_id)) + .collect() } -impl From<&GameFileDescription> for EntryDescriptor { - fn from(description: &GameFileDescription) -> Self { - Self { - is_dir: description.is_dir, - size: description.size, - } - } -} - -/// Type alias for entry consensus mapping: path -> descriptor -> peers. -type EntryConsensusMap = BTreeMap>>; - -/// Type alias for consensus result: (descriptor, peers) or None. -type ConsensusResult = Option<(EntryDescriptor, Vec)>; - -/// Type alias for the aggregated majority validation result. -pub type MajorityValidationResult = ( - Vec, - Vec, - HashMap>, -); - -/// Type alias for per-file consensus aggregation results. -type FileConsensusAggregation = ( - Vec, - HashMap, - HashMap>, -); - -// ============================================================================= -// Helper functions for consensus validation -// ============================================================================= - -/// Collects exact entry descriptions from all peers. -/// -/// A portable alias cannot be merged here: the current transfer map carries one -/// protocol spelling per entry and therefore cannot translate that spelling for -/// a peer that advertised an alias-equivalent path. Reject the aggregate rather -/// than constructing a manifest that is unsafe on case-insensitive filesystems. -fn collect_manifest_entries( - game_files: &[(SocketAddr, Vec)], -) -> eyre::Result { - let mut peer_entries: BTreeMap> = BTreeMap::new(); - - for (peer_addr, files) in game_files { - let entries = peer_entries.entry(*peer_addr).or_default(); - for file in files { - let descriptor = EntryDescriptor::from(file); - if let Some(previous) = entries.get(&file.relative_path) { - if *previous != descriptor { - eyre::bail!( - "peer {peer_addr} reported conflicting shapes or sizes for {}", - file.relative_path - ); - } - // Repeated identical entries from one peer are one vote. - continue; - } - entries.insert(file.relative_path.clone(), descriptor); - } - } - - let mut alias_paths = BTreeMap::::new(); - let mut entry_consensus_map: EntryConsensusMap = BTreeMap::new(); - for (peer_addr, entries) in peer_entries { - for (relative_path, descriptor) in entries { - let alias = portable_manifest_path_key(&relative_path); - if let Some(previous_path) = alias_paths.get(&alias) - && previous_path != &relative_path - { - let (first, second) = if previous_path < &relative_path { - (previous_path.as_str(), relative_path.as_str()) - } else { - (relative_path.as_str(), previous_path.as_str()) - }; - eyre::bail!("peer manifests contain platform-alias paths: {first} and {second}"); - } - alias_paths.insert(alias, relative_path.clone()); - entry_consensus_map - .entry(relative_path) - .or_default() - .entry(descriptor) - .or_default() - .push(peer_addr); - } - } - - Ok(entry_consensus_map) -} - -fn portable_manifest_path_key(relative_path: &str) -> String { - relative_path - .split('/') - .map(portable_name_key) - .collect::>() - .join("/") -} - -/// Finds the unique most popular descriptor from a map of peer votes. -fn find_majority_descriptor( - descriptor_map: &BTreeMap>, -) -> (Option, usize) { - let mut majority_descriptor = None; - let mut majority_count = 0; - - for (&descriptor, peers) in descriptor_map { - let count = peers.len(); - if count > majority_count { - majority_count = count; - majority_descriptor = Some(descriptor); - } else if count == majority_count { - majority_descriptor = None; - } - } - - (majority_descriptor, majority_count) -} - -/// Updates peer scores based on consensus participation. -fn update_peer_scores( - peers: &[SocketAddr], - peer_whitelist_scores: &mut HashMap, -) { - for &peer in peers { - *peer_whitelist_scores.entry(peer).or_insert(0) += 1; - } -} - -/// Creates a peer whitelist from scores, including peers with the highest scores. -fn create_peer_whitelist(peer_scores: HashMap) -> Vec { - if peer_scores.is_empty() { - return Vec::new(); - } - - let mut peers: Vec<_> = peer_scores - .into_iter() - .filter_map(|(peer, score)| (score > 0).then_some((peer, score))) - .collect(); - - peers.sort_by_key(|(peer, score)| (Reverse(*score), *peer)); - - peers.into_iter().map(|(peer, _)| peer).collect() -} - -fn game_is_ready(summary: &GameSummary) -> bool { - summary.availability == Availability::Ready -} - -fn game_matches_expected_version(summary: &GameSummary, expected_version: Option<&str>) -> bool { - if !game_is_ready(summary) { - return false; - } - - expected_version.is_none_or(|expected| summary.eti_version.as_deref() == Some(expected)) -} - -fn summary_to_game(summary: &GameSummary) -> Game { - let eti_game_version = game_is_ready(summary) - .then(|| summary.eti_version.clone()) - .flatten(); - - Game { - id: summary.id.clone(), - name: summary.name.clone(), - description: String::new(), - release_year: String::new(), - publisher: String::new(), - max_players: 1, - version: "1.0".to_string(), - genre: String::new(), - size: summary.size, - downloaded: game_is_ready(summary), - installed: summary.installed, - availability: summary.availability.clone(), - eti_game_version, - local_version: None, - peer_count: 0, +fn peer_liveness_snapshot(peer: &PeerInfo) -> PeerLivenessSnapshot { + PeerLivenessSnapshot { + endpoint: PeerEndpoint::new(peer.peer_id, peer.addr), + generation: peer.endpoint_generation, + last_seen: peer.last_seen, + last_revision_check: peer.last_revision_check, + consecutive_ping_failures: peer.consecutive_ping_failures, } } #[cfg(test)] mod tests { - use std::net::SocketAddr; - use super::*; - use crate::{download::ValidatedDownloadManifest, test_support::TempDir}; fn addr(port: u16) -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], port)) } - fn ip_addr(ip: [u8; 4], port: u16) -> SocketAddr { - SocketAddr::from((ip, port)) + fn peer_id(seed: u8) -> PeerId { + PeerId::from_bytes([seed; 32]) } - fn summary(id: &str, version: &str, availability: Availability) -> GameSummary { - GameSummary { - id: id.to_string(), - name: id.to_string(), - size: 42, - downloaded: availability == Availability::Ready, - installed: true, - eti_version: Some(version.to_string()), - manifest_hash: 7, - availability, + fn endpoint(seed: u8, port: u16) -> PeerEndpoint { + PeerEndpoint::new(peer_id(seed), addr(port)) + } + + fn session(seed: u8) -> RuntimeSessionId { + RuntimeSessionId::from_bytes([seed; 16]) + } + + fn library(revision: u64, game: Option<(&str, u8)>) -> LibrarySnapshot { + LibrarySnapshot { + revision, + games: game + .into_iter() + .map(|(game_id, seed)| GameAvailability { + game_id: game_id.to_owned(), + content_id: ContentId::from_bytes([seed; 32]), + }) + .collect(), } } - fn file_desc(game_id: &str, relative_path: &str, size: u64) -> GameFileDescription { - GameFileDescription { - game_id: game_id.to_string(), - relative_path: relative_path.to_string(), - is_dir: false, - size, - } - } - - fn directory_desc(game_id: &str, relative_path: &str) -> GameFileDescription { - GameFileDescription { - game_id: game_id.to_string(), - relative_path: relative_path.to_string(), - is_dir: true, - size: 0, - } + fn commit( + db: &mut PeerGameDB, + endpoint: PeerEndpoint, + runtime_session_id: RuntimeSessionId, + library: LibrarySnapshot, + ctp_revision: Option, + ) -> PeerUpsert { + let ticket = db + .begin_candidate_negotiation(endpoint) + .expect("candidate should reserve"); + let upsert = db + .commit_authenticated_snapshot(endpoint, ticket, runtime_session_id, Some(library)) + .expect("snapshot commit should not fail") + .expect("ticket should be current"); + assert!(db.set_call_to_play_revision_if_generation( + endpoint, + upsert.endpoint_generation, + ctp_revision, + )); + upsert } #[test] - fn aggregation_counts_only_ready_peers_as_download_sources() { - let ready_addr = addr(12000); - let local_only_addr = addr(12001); + fn reconnect_gets_new_generation_and_stale_removal_loses_authority() { + let endpoint = endpoint(1, 12001); let mut db = PeerGameDB::new(); - db.upsert_peer("ready".to_string(), ready_addr); - db.upsert_peer("local".to_string(), local_only_addr); - db.update_peer_games( - &"ready".to_string(), - vec![summary("game", "20240101", Availability::Ready)], - ); - db.update_peer_games( - &"local".to_string(), - vec![summary("game", "20990101", Availability::LocalOnly)], - ); + let first = commit(&mut db, endpoint, session(1), library(0, None), Some(0)); + let stale = db + .peer_liveness_for(&endpoint.peer_id) + .expect("peer should exist"); + let second = commit(&mut db, endpoint, session(1), library(0, None), Some(0)); - let games = db.get_all_games(); - assert_eq!(games.len(), 1); - assert_eq!(games[0].peer_count, 1); - assert!(games[0].downloaded); - assert_eq!(games[0].eti_game_version.as_deref(), Some("20240101")); - - assert_eq!(db.peers_with_game("game"), vec![ready_addr]); - assert_eq!( - db.get_latest_version_for_game("game").as_deref(), - Some("20240101") + assert!(second.endpoint_generation.get() > first.endpoint_generation.get()); + assert!( + db.remove_peer_if_generation(stale.endpoint, stale.generation) + .is_none() ); - assert_eq!(db.peers_with_latest_version("game"), vec![ready_addr]); + assert_eq!(db.peer_endpoint(&endpoint.peer_id), Some(endpoint)); } #[test] - fn local_only_peer_does_not_make_game_downloadable() { - let local_only_addr = addr(12002); + fn clear_remote_peers_returns_peer_id_sorted_retirements() { let mut db = PeerGameDB::new(); - db.upsert_peer("local".to_string(), local_only_addr); - db.update_peer_games( - &"local".to_string(), - vec![summary("game", "20240101", Availability::LocalOnly)], + let third_endpoint = endpoint(23, 12123); + let first_endpoint = endpoint(21, 12121); + let second_endpoint = endpoint(22, 12122); + let third = commit( + &mut db, + third_endpoint, + session(23), + library(0, None), + Some(0), ); - - let games = db.get_all_games(); - assert_eq!(games.len(), 1); - assert_eq!(games[0].peer_count, 0); - assert!(!games[0].downloaded); - assert_eq!(games[0].availability, Availability::LocalOnly); - assert_eq!(games[0].eti_game_version, None); - - assert!(db.peers_with_game("game").is_empty()); - assert_eq!(db.get_latest_version_for_game("game"), None); - assert!(db.peers_with_latest_version("game").is_empty()); - } - - #[test] - fn catalog_aggregation_counts_only_expected_version_peers() { - let old_addr = addr(12003); - let expected_addr = addr(12004); - let newer_addr = addr(12005); - let mut db = PeerGameDB::new(); - db.upsert_peer("old".to_string(), old_addr); - db.upsert_peer("expected".to_string(), expected_addr); - db.upsert_peer("newer".to_string(), newer_addr); - db.update_peer_games( - &"old".to_string(), - vec![summary("game", "20240101", Availability::Ready)], + let first = commit( + &mut db, + first_endpoint, + session(21), + library(0, None), + Some(0), ); - db.update_peer_games( - &"expected".to_string(), - vec![summary("game", "20250101", Availability::Ready)], + let second = commit( + &mut db, + second_endpoint, + session(22), + library(0, None), + Some(0), ); - db.update_peer_games( - &"newer".to_string(), - vec![summary("game", "20260101", Availability::Ready)], - ); - let mut catalog = GameCatalog::empty(); - catalog.insert("game".to_string(), Some("20250101".to_string())); - - let games = db.get_catalog_games(&catalog); - - assert_eq!(games.len(), 1); - assert_eq!(games[0].peer_count, 1); - assert_eq!(games[0].eti_game_version.as_deref(), Some("20250101")); - assert_eq!( - db.peers_with_expected_version("game", Some("20250101")), - vec![expected_addr] - ); - } - - #[test] - fn transport_addr_matches_known_peer_on_ephemeral_port() { - let advertised = ip_addr([10, 66, 0, 2], 40000); - let transport_source = ip_addr([10, 66, 0, 2], 52000); - let mut db = PeerGameDB::new(); - db.upsert_peer("peer".to_string(), advertised); assert_eq!( - db.peer_id_for_transport_addr(&transport_source).as_deref(), - Some("peer") - ); - } - - #[test] - fn transport_addr_fallback_requires_unique_peer_ip() { - let source = ip_addr([10, 66, 0, 2], 52000); - let mut db = PeerGameDB::new(); - db.upsert_peer("first".to_string(), ip_addr([10, 66, 0, 2], 40000)); - db.upsert_peer("second".to_string(), ip_addr([10, 66, 0, 2], 41000)); - - assert_eq!(db.peer_id_for_transport_addr(&source), None); - } - - #[test] - fn address_update_preserves_peer_identity_and_library() { - let old_addr = ip_addr([10, 66, 0, 2], 40000); - let new_addr = ip_addr([10, 66, 0, 3], 41000); - let mut db = PeerGameDB::new(); - - let first = db.upsert_peer("peer".to_string(), old_addr); - assert!(first.is_new); - db.update_peer_games( - &"peer".to_string(), - vec![summary("game", "20250101", Availability::Ready)], - ); - - let second = db.upsert_peer("peer".to_string(), new_addr); - assert!(!second.is_new); - assert!(second.addr_changed); - - let peers = db.peer_snapshots(); - assert_eq!(peers.len(), 1); - assert_eq!(peers[0].peer_id, "peer"); - assert_eq!(peers[0].addr, new_addr); - assert_eq!(peers[0].games.len(), 1); - assert_eq!(peers[0].games[0].id, "game"); - assert_eq!(db.peer_id_for_addr(&old_addr), None); - assert_eq!( - db.peer_id_for_addr(&new_addr).map(String::as_str), - Some("peer") - ); - } - - #[test] - fn validation_uses_expected_version_file_metadata() { - let old_addr = addr(12003); - let new_addr = addr(12004); - let mut db = PeerGameDB::new(); - db.upsert_peer("old".to_string(), old_addr); - db.upsert_peer("new".to_string(), new_addr); - db.update_peer_games( - &"old".to_string(), - vec![summary("game", "20240101", Availability::Ready)], - ); - db.update_peer_games( - &"new".to_string(), - vec![summary("game", "20250101", Availability::Ready)], - ); - db.update_peer_game_files( - &"old".to_string(), - "game", + db.clear_remote_peers(), vec![ - file_desc("game", "game/version.ini", 8), - file_desc("game", "game/archive.eti", 10), - ], + RetiredPeerEndpoint { + endpoint: first_endpoint, + generation: first.endpoint_generation, + }, + RetiredPeerEndpoint { + endpoint: second_endpoint, + generation: second.endpoint_generation, + }, + RetiredPeerEndpoint { + endpoint: third_endpoint, + generation: third.endpoint_generation, + }, + ] ); - db.update_peer_game_files( - &"new".to_string(), - "game", - vec![ - file_desc("game", "game/version.ini", 8), - file_desc("game", "game/archive.eti", 20), - ], - ); - - let aggregated = db.aggregated_game_files("game", Some("20250101")); - let archive = aggregated - .iter() - .find(|desc| desc.relative_path == "game/archive.eti") - .expect("expected-version archive should be present"); - assert_eq!(archive.size, 20); - - let (validated, peers, file_peer_map) = db - .validate_file_sizes_majority("game", Some("20250101")) - .expect("old-version file metadata should not create ambiguity"); - assert_eq!(peers, vec![new_addr]); - let archive = validated - .iter() - .find(|desc| desc.relative_path == "game/archive.eti") - .expect("expected-version archive should validate"); - assert_eq!(archive.size, 20); - assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr])); } #[test] - fn duplicate_entries_from_one_peer_do_not_manufacture_consensus() { - let attacker = addr(12005); - let honest_a = addr(12006); - let honest_b = addr(12007); + fn clear_remote_peers_empties_peer_state_and_address_index() { let mut db = PeerGameDB::new(); - for (peer_id, peer_addr) in [ - ("attacker", attacker), - ("honest-a", honest_a), - ("honest-b", honest_b), - ] { - let peer_id = peer_id.to_owned(); - db.upsert_peer(peer_id.clone(), peer_addr); - db.update_peer_games( - &peer_id, - vec![summary("game", "20250101", Availability::Ready)], - ); - } + let first = endpoint(24, 12124); + let second = endpoint(25, 12125); + commit(&mut db, first, session(24), library(0, None), Some(0)); + commit(&mut db, second, session(25), library(0, None), Some(0)); - let mut attacker_files = vec![file_desc("game", "game/version.ini", 8)]; - attacker_files - .extend(std::iter::repeat_with(|| file_desc("game", "game/archive.eti", 99)).take(10)); - let honest_files = vec![ - file_desc("game", "game/version.ini", 8), - file_desc("game", "game/archive.eti", 20), - ]; - db.update_peer_game_files(&"attacker".to_owned(), "game", attacker_files.clone()); - db.update_peer_game_files(&"honest-a".to_owned(), "game", honest_files.clone()); - db.update_peer_game_files(&"honest-b".to_owned(), "game", honest_files.clone()); + assert_eq!(db.clear_remote_peers().len(), 2); + assert!(db.peers.is_empty()); + assert!(db.addr_index.is_empty()); + assert!(db.peer_endpoints().is_empty()); + assert_eq!(db.peer_endpoint(&first.peer_id), None); + assert_eq!(db.peer_endpoint(&second.peer_id), None); + assert_eq!(db.negotiation_claim_counts(), (0, 0)); + } - let manifests = vec![ - (attacker, attacker_files), - (honest_a, honest_files.clone()), - (honest_b, honest_files), - ]; - let (validated, _, file_peer_map) = db - .validate_file_sizes_majority_from("game", &manifests) - .expect("honest peers should determine consensus"); - let archive = validated - .iter() - .find(|description| description.relative_path == "game/archive.eti") - .expect("archive should validate"); - assert_eq!(archive.size, 20); + #[test] + fn clear_remote_peers_invalidates_ticket_reserved_before_clear() { + let stale_endpoint = endpoint(26, 12126); + let mut db = PeerGameDB::new(); + let stale_ticket = db + .begin_candidate_negotiation(stale_endpoint) + .expect("stale candidate should reserve"); + + assert!(db.clear_remote_peers().is_empty()); + assert!( + db.commit_authenticated_snapshot( + stale_endpoint, + stale_ticket, + session(26), + Some(library(0, None)), + ) + .expect("stale commit check should work") + .is_none() + ); + assert!(!db.contains_peer(&stale_endpoint.peer_id)); + assert_eq!(db.negotiation_claim_counts(), (0, 0)); + } + + #[test] + fn clear_remote_peers_isolates_held_old_ticket_occupancy() { + let shared_endpoint = endpoint(27, 12127); + let mut db = PeerGameDB::new(); + let old_ticket = db + .begin_candidate_negotiation(shared_endpoint) + .expect("old candidate should reserve"); + + assert!(db.clear_remote_peers().is_empty()); + let fresh_ticket = db + .begin_candidate_negotiation(shared_endpoint) + .expect("old registry occupancy must not block the fresh registry"); + assert_eq!(db.negotiation_claim_counts(), (1, 1)); + + drop(old_ticket); assert_eq!( - file_peer_map.get("game/archive.eti"), - Some(&vec![honest_a, honest_b]) + db.negotiation_claim_counts(), + (1, 1), + "releasing an old ticket must not release a fresh claim" + ); + assert!( + db.commit_authenticated_snapshot( + shared_endpoint, + fresh_ticket, + session(27), + Some(library(0, None)), + ) + .expect("fresh commit should work") + .is_some() ); } #[test] - fn majority_validation_preserves_empty_directory_shape() { - let peer_a = addr(12008); - let peer_b = addr(12009); - let peer_c = addr(12010); - let version = file_desc("game", "game/version.ini", 8); - let manifests = vec![ - ( - peer_c, - vec![version.clone(), file_desc("game", "game/empty-child", 0)], - ), - ( - peer_b, - vec![version.clone(), directory_desc("game", "game/empty-child")], - ), - ( - peer_a, - vec![version, directory_desc("game", "game/empty-child")], - ), - ]; - - let (validated, peer_whitelist, file_peer_map) = PeerGameDB::new() - .validate_file_sizes_majority_from("game", &manifests) - .expect("directory shape should win the majority vote"); - - assert_eq!(peer_whitelist, vec![peer_a, peer_b, peer_c]); - assert_eq!( - validated - .iter() - .map(|entry| entry.relative_path.as_str()) - .collect::>(), - vec!["game/empty-child", "game/version.ini"] + fn clear_remote_peers_keeps_next_generation_strictly_monotonic() { + let first_endpoint = endpoint(28, 12128); + let wasted_endpoint = endpoint(29, 12129); + let fresh_endpoint = endpoint(30, 12130); + let mut db = PeerGameDB::new(); + let first = commit( + &mut db, + first_endpoint, + session(28), + library(0, None), + Some(0), ); - let empty_child = validated - .iter() - .find(|entry| entry.relative_path == "game/empty-child") - .expect("empty directory should survive aggregation"); - assert!(empty_child.is_dir); - assert_eq!(empty_child.size, 0); - assert_eq!( - file_peer_map.get("game/empty-child"), - Some(&vec![peer_a, peer_b]) + let wasted_ticket = db + .begin_candidate_negotiation(wasted_endpoint) + .expect("pre-clear candidate should reserve"); + let highest_pre_clear_generation = wasted_ticket.endpoint_generation(); + assert!(highest_pre_clear_generation.get() > first.endpoint_generation.get()); + + assert_eq!(db.clear_remote_peers().len(), 1); + let fresh_ticket = db + .begin_candidate_negotiation(fresh_endpoint) + .expect("post-clear candidate should reserve"); + assert!( + fresh_ticket.endpoint_generation().get() > highest_pre_clear_generation.get(), + "the fresh registry must continue after every issued ticket" ); - let temp = TempDir::new("lanspread-peer-db-empty-directory"); - let manifest = ValidatedDownloadManifest::from_protocol_v7( - temp.path(), - "game", - validated, - &GameCatalog::from_ids(["game".to_owned()]), - ) - .expect("aggregated directory should enter the validated storage manifest"); - let storage_entries = manifest.transfer_entries().collect::>(); - assert_eq!(storage_entries.len(), 1); - let storage_entry = storage_entries[0]; - assert!(storage_entry.is_dir()); - assert_eq!(storage_entry.size(), 0); + drop(wasted_ticket); + assert!( + db.commit_authenticated_snapshot( + fresh_endpoint, + fresh_ticket, + session(30), + Some(library(0, None)), + ) + .expect("fresh commit should work") + .is_some() + ); } #[test] - fn two_peer_file_directory_shape_conflict_is_deterministic() { - let peer_a = addr(12011); - let peer_b = addr(12012); - let version = file_desc("game", "game/version.ini", 8); - let first_order = vec![ - ( - peer_a, - vec![version.clone(), directory_desc("game", "game/cache")], - ), - ( - peer_b, - vec![version.clone(), file_desc("game", "game/cache", 0)], - ), - ]; - let mut reverse_order = first_order.clone(); - reverse_order.reverse(); - - let first_error = PeerGameDB::new() - .validate_file_sizes_majority_from("game", &first_order) - .expect_err("a tied file/directory shape must be rejected") - .to_string(); - let reverse_error = PeerGameDB::new() - .validate_file_sizes_majority_from("game", &reverse_order) - .expect_err("input ordering must not resolve a tied shape") - .to_string(); - - assert_eq!(first_error, reverse_error); - assert!(first_error.contains("different shapes or sizes")); - assert!(first_error.contains("game/cache")); + fn one_candidate_per_peer_is_physically_in_flight() { + let old = endpoint(2, 12002); + let new = endpoint(2, 12003); + let mut db = PeerGameDB::new(); + let old_ticket = db + .begin_candidate_negotiation(old) + .expect("old candidate should reserve"); + assert!(db.begin_candidate_negotiation(new).is_err()); + assert_eq!(db.negotiation_claim_counts(), (1, 1)); + drop(old_ticket); + let new_ticket = db + .begin_candidate_negotiation(new) + .expect("new candidate should reserve after release"); + assert!( + db.commit_authenticated_snapshot(new, new_ticket, session(2), Some(library(0, None)),) + .expect("new commit should work") + .is_some() + ); + assert_eq!(db.peer_endpoint(&new.peer_id), Some(new)); + assert_eq!(db.negotiation_claim_counts(), (0, 0)); } #[test] - fn cross_peer_platform_aliases_fail_deterministically() { - let peer_a = addr(12013); - let peer_b = addr(12014); - let version = file_desc("game", "game/version.ini", 8); - let first_order = vec![ - ( - peer_a, - vec![version.clone(), directory_desc("game", "game/Data")], + fn reserved_refresh_blocks_overlapping_candidate_until_release() { + let owner = endpoint(3, 12004); + let replacement = endpoint(4, 12004); + let mut db = PeerGameDB::new(); + commit(&mut db, owner, session(3), library(0, None), Some(0)); + let liveness = db + .peer_liveness_for(&owner.peer_id) + .expect("owner should exist"); + let RefreshReservation::Reserved(refresh) = db + .begin_peer_refresh(liveness) + .expect("refresh check should work") + else { + panic!("refresh should reserve"); + }; + assert!(db.begin_candidate_negotiation(replacement).is_err()); + drop(refresh); + let candidate = db + .begin_candidate_negotiation(replacement) + .expect("candidate should reserve after refresh release"); + let upsert = db + .commit_authenticated_snapshot( + replacement, + candidate, + session(4), + Some(library(0, None)), + ) + .expect("replacement should commit") + .expect("replacement should be current"); + assert_eq!( + upsert.evicted_endpoint.map(|evicted| evicted.endpoint), + Some(owner) + ); + } + + #[test] + fn candidate_owned_address_defers_refresh_until_candidate_releases() { + let known = endpoint(30, 12030); + let forged = endpoint(31, 12030); + let mut db = PeerGameDB::new(); + commit(&mut db, known, session(30), library(0, None), Some(0)); + let liveness = db + .peer_liveness_for(&known.peer_id) + .expect("known peer should exist"); + let candidate = db + .begin_candidate_negotiation(forged) + .expect("candidate should reserve the address"); + + assert!(matches!( + db.begin_peer_refresh(liveness) + .expect("refresh decision should work"), + RefreshReservation::DeferredByCandidate + )); + assert!(matches!( + db.begin_peer_refresh(liveness) + .expect("repeated refresh decision should work"), + RefreshReservation::DeferredByCandidate + )); + assert_eq!(db.negotiation_claim_counts(), (1, 1)); + + drop(candidate); + assert!(matches!( + db.begin_peer_refresh(liveness) + .expect("released candidate should unblock refresh"), + RefreshReservation::Reserved(_) + )); + } + + #[test] + fn same_session_accepts_higher_library_and_preserves_lower_or_conflicting_equal() { + let endpoint = endpoint(5, 12005); + let mut db = PeerGameDB::new(); + commit( + &mut db, + endpoint, + session(5), + library(2, Some(("game", 1))), + Some(2), + ); + commit( + &mut db, + endpoint, + session(5), + library(2, Some(("game", 9))), + Some(2), + ); + assert_eq!( + db.peer_endpoints_with_content("game", ContentId::from_bytes([1; 32])), + [endpoint] + ); + commit(&mut db, endpoint, session(5), library(1, None), Some(2)); + assert_eq!( + db.peer_endpoints_with_content("game", ContentId::from_bytes([1; 32])), + [endpoint] + ); + commit( + &mut db, + endpoint, + session(5), + library(3, Some(("game", 7))), + Some(3), + ); + assert_eq!( + db.peer_endpoints_with_content("game", ContentId::from_bytes([7; 32])), + [endpoint] + ); + } + + #[test] + fn new_session_replaces_library_even_at_revision_zero() { + let endpoint = endpoint(6, 12006); + let mut db = PeerGameDB::new(); + commit( + &mut db, + endpoint, + session(6), + library(8, Some(("old", 1))), + Some(8), + ); + let upsert = commit( + &mut db, + endpoint, + session(7), + library(0, Some(("new", 2))), + Some(0), + ); + assert!(upsert.session_changed); + assert!( + db.peer_endpoints_with_content("old", ContentId::from_bytes([1; 32])) + .is_empty() + ); + assert_eq!( + db.peer_endpoints_with_content("new", ContentId::from_bytes([2; 32])), + [endpoint] + ); + } + + #[test] + fn pong_new_session_clears_old_domains_before_pull() { + let endpoint = endpoint(7, 12007); + let mut db = PeerGameDB::new(); + commit( + &mut db, + endpoint, + session(7), + library(4, Some(("old", 1))), + Some(4), + ); + let probe = db + .peer_liveness_for(&endpoint.peer_id) + .expect("peer should exist"); + + assert_eq!( + db.observe_pong_if_generation( + probe, + PeerRevisions { + runtime_session_id: session(8), + library_revision: 0, + call_to_play_revision: 0, + }, ), - (peer_b, vec![version, directory_desc("game", "game/data")]), - ]; - let mut reverse_order = first_order.clone(); - reverse_order.reverse(); + PongObservation::NewSession + ); + let cached = db + .revision_snapshot(&endpoint.peer_id) + .expect("peer should remain"); + assert_eq!(cached.runtime_session_id, session(8)); + assert_eq!(cached.library_revision, None); + assert_eq!(cached.call_to_play_revision, None); + assert!( + db.peer_endpoints_with_content("old", ContentId::from_bytes([1; 32])) + .is_empty() + ); + } - let first_error = PeerGameDB::new() - .validate_file_sizes_majority_from("game", &first_order) - .expect_err("portable path aliases cannot share one transfer spelling") - .to_string(); - let reverse_error = PeerGameDB::new() - .validate_file_sizes_majority_from("game", &reverse_order) - .expect_err("alias rejection must not depend on peer ordering") - .to_string(); + #[test] + fn new_session_pong_fences_an_older_reserved_snapshot() { + let endpoint = endpoint(40, 12040); + let mut db = PeerGameDB::new(); + commit( + &mut db, + endpoint, + session(40), + library(4, Some(("old", 1))), + Some(4), + ); + let probe = db + .peer_liveness_for(&endpoint.peer_id) + .expect("peer should exist"); + let RefreshReservation::Reserved(old_ticket) = db + .begin_peer_refresh(probe) + .expect("old refresh should reserve") + else { + panic!("old refresh should reserve"); + }; - assert_eq!(first_error, reverse_error); - assert!(first_error.contains("game/Data and game/data")); + assert_eq!( + db.observe_pong_if_generation( + probe, + PeerRevisions { + runtime_session_id: session(41), + library_revision: 0, + call_to_play_revision: 0, + }, + ), + PongObservation::NewSession + ); + assert!(matches!( + db.begin_peer_refresh(probe) + .expect("fenced occupancy check should work"), + RefreshReservation::DeferredByCandidate + )); + assert!( + db.begin_candidate_negotiation(endpoint).is_err(), + "a fenced ticket owner must retain physical peer/address occupancy" + ); + assert!( + db.commit_authenticated_snapshot( + endpoint, + old_ticket, + session(40), + Some(library(5, Some(("resurrected", 2)))), + ) + .expect("stale commit check should work") + .is_none() + ); + assert_eq!(db.negotiation_claim_counts(), (0, 0)); + assert!(matches!( + db.begin_peer_refresh(probe) + .expect("released stale ticket should unblock refresh"), + RefreshReservation::Reserved(_) + )); + let cached = db + .revision_snapshot(&endpoint.peer_id) + .expect("peer should remain"); + assert_eq!(cached.runtime_session_id, session(41)); + assert_eq!(cached.library_revision, None); + assert!( + db.peer_endpoints_with_content("resurrected", ContentId::from_bytes([2; 32])) + .is_empty() + ); + } + + #[test] + fn generation_removal_fences_an_older_reserved_snapshot() { + let endpoint = endpoint(42, 12042); + let mut db = PeerGameDB::new(); + commit(&mut db, endpoint, session(42), library(0, None), Some(0)); + let probe = db + .peer_liveness_for(&endpoint.peer_id) + .expect("peer should exist"); + let RefreshReservation::Reserved(old_ticket) = db + .begin_peer_refresh(probe) + .expect("old refresh should reserve") + else { + panic!("old refresh should reserve"); + }; + + assert!( + db.remove_peer_if_generation(probe.endpoint, probe.generation) + .is_some() + ); + assert!( + db.begin_candidate_negotiation(endpoint).is_err(), + "removal must fence commit without releasing physical occupancy" + ); + assert!( + db.commit_authenticated_snapshot( + endpoint, + old_ticket, + session(42), + Some(library(1, Some(("resurrected", 3)))), + ) + .expect("stale commit check should work") + .is_none() + ); + assert!(db.peer_endpoint(&endpoint.peer_id).is_none()); + assert_eq!(db.negotiation_claim_counts(), (0, 0)); + let replacement = db + .begin_candidate_negotiation(endpoint) + .expect("released stale ticket should unblock a candidate"); + drop(replacement); + } + + #[test] + fn pong_stamps_only_its_current_generation() { + let endpoint = endpoint(8, 12008); + let mut db = PeerGameDB::new(); + commit(&mut db, endpoint, session(8), library(0, None), Some(0)); + let stale = db + .peer_liveness_for(&endpoint.peer_id) + .expect("peer should exist"); + commit(&mut db, endpoint, session(8), library(0, None), Some(0)); + + assert_eq!( + db.observe_pong_if_generation( + stale, + PeerRevisions { + runtime_session_id: session(8), + library_revision: 0, + call_to_play_revision: 0, + }, + ), + PongObservation::StaleGeneration + ); + } + + #[test] + fn exact_content_identity_selects_sources() { + let mut db = PeerGameDB::new(); + let first = endpoint(9, 12009); + let second = endpoint(10, 12010); + commit( + &mut db, + first, + session(9), + library(1, Some(("game", 1))), + Some(0), + ); + commit( + &mut db, + second, + session(10), + library(1, Some(("game", 2))), + Some(0), + ); + + assert_eq!( + db.peer_endpoints_with_content("game", ContentId::from_bytes([1; 32])), + [first] + ); + assert_eq!( + db.peer_count_for_content("game", ContentId::from_bytes([2; 32])), + 1 + ); } } diff --git a/crates/lanspread-peer/src/quic_runtime.rs b/crates/lanspread-peer/src/quic_runtime.rs new file mode 100644 index 0000000..232df18 --- /dev/null +++ b/crates/lanspread-peer/src/quic_runtime.rs @@ -0,0 +1,592 @@ +//! Application-owned QUIC endpoint and connection lifecycles. +//! +//! s2n-quic's default Tokio IO adapter discards the endpoint task handle. This +//! module keeps that handle and gives the application an explicit stop signal, +//! so a peer runtime does not report itself stopped while its QUIC endpoint is +//! still running. + +use std::{ + future::Future as _, + io, + net::SocketAddr, + ops::{Deref, DerefMut}, + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, +}; + +use lanspread_proto::{MAX_CONTROL_FRAME_BYTES, PeerEndpoint}; +use s2n_quic::{ + Client as QuicClient, + Connection, + client::Connect, + provider::{ + congestion_controller, + io::{Provider as IoProvider, tokio::Provider as TokioIo}, + limits::Limits, + }, +}; +use s2n_quic_core::{ + endpoint::{self, CloseError, Endpoint}, + inet::SocketAddress, + io::{rx, tx}, + path::mtu, + time::{Clock, Timestamp}, +}; +use tokio::task::{JoinError, JoinHandle}; +use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; + +use crate::{ + config::{ + QUIC_CONNECTION_DATA_WINDOW, + QUIC_ENDPOINT_SHUTDOWN_GRACE, + QUIC_HANDSHAKE_TIMEOUT, + QUIC_IDLE_TIMEOUT, + QUIC_INITIAL_CONGESTION_WINDOW, + QUIC_MAX_SEND_BUFFER_SIZE, + QUIC_SOCKET_BUFFER_SIZE, + QUIC_STREAM_DATA_WINDOW, + }, + tls, +}; + +/// Transport-level bound on simultaneously open bidirectional streams in +/// either direction. The server independently enforces the same application +/// control-stream task bound before spawning work. +pub(crate) const MAX_OPEN_BIDIRECTIONAL_STREAMS: u64 = 32; +/// One server connection exposes only enough aggregate receive credit for one +/// maximal length-delimited control frame at a time. +pub(crate) const SERVER_CONTROL_RECEIVE_WINDOW: u64 = MAX_CONTROL_FRAME_BYTES as u64 + 4; + +pub(crate) fn quic_client_limits() -> eyre::Result { + Ok(Limits::default() + .with_data_window(QUIC_CONNECTION_DATA_WINDOW)? + .with_bidirectional_local_data_window(QUIC_STREAM_DATA_WINDOW)? + .with_bidirectional_remote_data_window(0)? + // The application protocol never opens or accepts unidirectional + // streams, so expose neither stream IDs nor receive-window state. + .with_unidirectional_data_window(0)? + .with_max_open_local_bidirectional_streams(MAX_OPEN_BIDIRECTIONAL_STREAMS)? + .with_max_open_remote_bidirectional_streams(0)? + .with_max_open_local_unidirectional_streams(0)? + .with_max_open_remote_unidirectional_streams(0)? + .with_max_send_buffer_size(QUIC_MAX_SEND_BUFFER_SIZE)? + .with_max_handshake_duration(QUIC_HANDSHAKE_TIMEOUT)? + .with_max_idle_timeout(QUIC_IDLE_TIMEOUT)?) +} + +/// Server-specific transport limits for public remote-initiated control +/// streams. Large client receive windows remain available only on the +/// separately configured outbound connector. +pub(crate) fn quic_server_limits() -> eyre::Result { + Ok(Limits::default() + .with_data_window(SERVER_CONTROL_RECEIVE_WINDOW)? + .with_bidirectional_local_data_window(0)? + .with_bidirectional_remote_data_window(SERVER_CONTROL_RECEIVE_WINDOW)? + .with_unidirectional_data_window(0)? + .with_max_open_local_bidirectional_streams(0)? + .with_max_open_remote_bidirectional_streams(MAX_OPEN_BIDIRECTIONAL_STREAMS)? + .with_max_open_local_unidirectional_streams(0)? + .with_max_open_remote_unidirectional_streams(0)? + .with_max_send_buffer_size( + u32::try_from(MAX_CONTROL_FRAME_BYTES) + .map_err(|_| eyre::eyre!("control frame bound exceeds u32"))?, + )? + .with_max_handshake_duration(QUIC_HANDSHAKE_TIMEOUT)? + .with_max_idle_timeout(QUIC_IDLE_TIMEOUT)?) +} + +pub(crate) fn quic_congestion_controller() -> congestion_controller::Bbr { + congestion_controller::bbr::Builder::default() + .with_initial_congestion_window(QUIC_INITIAL_CONGESTION_WINDOW) + .build() +} + +/// Builds an IO provider together with the owner used to retrieve its endpoint +/// task after s2n-quic has started it. +pub(crate) fn tracked_quic_io( + addr: SocketAddr, +) -> eyre::Result<(TrackedIoProvider, EndpointTaskControl)> { + let inner = s2n_quic::provider::io::tokio::Builder::default() + .with_receive_address(addr)? + .with_send_buffer_size(QUIC_SOCKET_BUFFER_SIZE)? + .with_recv_buffer_size(QUIC_SOCKET_BUFFER_SIZE)? + .build()?; + let control = EndpointTaskControl::new(); + Ok(( + TrackedIoProvider { + inner, + control: control.clone(), + }, + control, + )) +} + +/// Tokio IO provider that retains the s2n endpoint task instead of discarding +/// its handle like the default adapter does. +pub(crate) struct TrackedIoProvider { + inner: TokioIo, + control: EndpointTaskControl, +} + +impl IoProvider for TrackedIoProvider { + type PathHandle = ::PathHandle; + type Error = io::Error; + + fn start>( + self, + endpoint: E, + ) -> Result { + // Lock before starting the endpoint. That way a poisoned/invalid slot + // cannot cause a newly spawned endpoint task to escape unowned. + let mut task_slot = self.control.lock_task_slot()?; + if task_slot.is_some() { + return Err(io::Error::other("QUIC endpoint task was already started")); + } + + let endpoint = ControlledEndpoint::new(endpoint, self.control.stop.clone()); + let (task, local_addr) = self.inner.start(endpoint)?; + *task_slot = Some(task); + Ok(local_addr) + } +} + +#[derive(Clone)] +pub(crate) struct EndpointTaskControl { + stop: CancellationToken, + task: Arc, +} + +struct EndpointTaskSlot(Mutex>>); + +impl Drop for EndpointTaskSlot { + fn drop(&mut self) { + let task = self + .0 + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(task) = task { + // A successful pinned s2n builder returns immediately after IO + // start, so normal code always transfers this handle into + // EndpointTask. This is only a synchronous panic-safety fallback; + // the peer supervisor destroys and joins the isolated Tokio + // runtime before its public owner may return. + log::error!("QUIC endpoint started but ownership was not transferred; aborting it"); + task.abort(); + } + } +} + +impl EndpointTaskControl { + fn new() -> Self { + Self { + stop: CancellationToken::new(), + task: Arc::new(EndpointTaskSlot(Mutex::new(None))), + } + } + + fn lock_task_slot(&self) -> io::Result>>> { + self.task + .0 + .lock() + .map_err(|_| io::Error::other("QUIC endpoint task slot was poisoned")) + } + + /// Takes ownership of the endpoint task after a successful s2n-quic start. + pub(crate) fn take_started(&self) -> eyre::Result { + let task = self + .lock_task_slot()? + .take() + .ok_or_else(|| eyre::eyre!("QUIC IO provider did not start an endpoint task"))?; + Ok(EndpointTask { + stop: self.stop.clone(), + task: Some(task), + abort_requested: false, + }) + } +} + +struct ControlledEndpoint { + inner: E, + stop: Pin>, +} + +impl ControlledEndpoint { + fn new(inner: E, stop: CancellationToken) -> Self { + Self { + inner, + stop: Box::pin(stop.cancelled_owned()), + } + } +} + +impl Endpoint for ControlledEndpoint { + type PathHandle = E::PathHandle; + type Subscriber = E::Subscriber; + + const ENDPOINT_TYPE: endpoint::Type = E::ENDPOINT_TYPE; + + fn receive(&mut self, queue: &mut Rx, clock: &C) + where + Rx: rx::Queue, + C: Clock, + { + self.inner.receive(queue, clock); + } + + fn transmit(&mut self, queue: &mut Tx, clock: &C) + where + Tx: tx::Queue, + C: Clock, + { + self.inner.transmit(queue, clock); + } + + fn poll_wakeups( + &mut self, + cx: &mut Context<'_>, + clock: &C, + ) -> Poll> { + if self.stop.as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(CloseError)); + } + self.inner.poll_wakeups(cx, clock) + } + + fn timeout(&self) -> Option { + self.inner.timeout() + } + + fn set_mtu_config(&mut self, config: mtu::Config) { + self.inner.set_mtu_config(config); + } + + fn subscriber(&mut self) -> &mut Self::Subscriber { + self.inner.subscriber() + } +} + +/// Application-owned endpoint task. +/// +/// Every normal owner path consumes this value through [`Self::shutdown_and_join`]. +/// Drop is only a panic-safety fallback: it prevents continued endpoint work, +/// while the peer supervisor's final runtime teardown bounds and destroys the +/// aborted task before `PeerRuntimeHandle` may finish joining. +#[must_use = "the QUIC endpoint task must be explicitly stopped and joined"] +pub(crate) struct EndpointTask { + stop: CancellationToken, + task: Option>, + abort_requested: bool, +} + +impl EndpointTask { + pub(crate) async fn shutdown_and_join(mut self) -> eyre::Result<()> { + self.shutdown_and_join_with_grace(QUIC_ENDPOINT_SHUTDOWN_GRACE) + .await + } + + async fn shutdown_and_join_with_grace( + &mut self, + grace: std::time::Duration, + ) -> eyre::Result<()> { + self.stop.cancel(); + let Some(task) = self.task.as_mut() else { + eyre::bail!("QUIC endpoint task was already joined"); + }; + + // Keep the handle in `self` until every await has completed. If the + // caller cancels this cleanup future, dropping `EndpointTask` can then + // still abort the endpoint instead of silently detaching a handle that + // was moved into the cancelled future's local state. + let result = if let Ok(result) = tokio::time::timeout(grace, &mut *task).await { + join_result(result, self.abort_requested) + } else { + log::warn!("QUIC endpoint did not stop within {grace:?}; aborting and joining it"); + self.abort_requested = true; + task.abort(); + join_result(task.await, true) + }; + + // A completed Tokio JoinHandle is inert. Remove it only after the + // terminal await so `Drop` remains a cancellation-safety guard for the + // whole shutdown operation. + self.task = None; + result + } +} + +impl Drop for EndpointTask { + fn drop(&mut self) { + if let Some(task) = self.task.take() { + log::error!( + "QUIC endpoint owner was dropped before join; aborting as panic-safety fallback" + ); + self.stop.cancel(); + // The task remains owned by the isolated peer Tokio runtime. Its + // supervisor thread cannot finish (and its public handle cannot + // return from Drop/wait) until that runtime has been destroyed. + task.abort(); + } + } +} + +fn join_result(result: Result<(), JoinError>, expected_abort: bool) -> eyre::Result<()> { + match result { + Ok(()) => Ok(()), + Err(error) if expected_abort && error.is_cancelled() => Ok(()), + Err(error) => Err(eyre::eyre!("QUIC endpoint task failed to join: {error}")), + } +} + +/// Cloneable capability for opening connections on the runtime-owned client +/// endpoint. +#[derive(Clone, Debug)] +pub(crate) struct QuicConnector { + client: Option, +} + +impl QuicConnector { + pub(crate) async fn connect(&self, endpoint: &PeerEndpoint) -> eyre::Result { + let client = self + .client + .as_ref() + .ok_or_else(|| eyre::eyre!("QUIC connector is unavailable"))?; + let server_name = tls::sni_for_peer(endpoint.peer_id)?; + let connect = Connect::new(endpoint.addr).with_server_name(server_name); + Ok(PeerConnection::new(client.connect(connect).await?)) + } + + #[cfg(test)] + pub(crate) fn unavailable() -> Self { + Self { client: None } + } +} + +/// Runtime owner for the single outgoing client endpoint. +pub(crate) struct QuicClientRuntime { + client: QuicClient, + endpoint: EndpointTask, +} + +impl QuicClientRuntime { + /// Waits for outstanding connections to settle, then always stops and + /// joins the endpoint. A stuck transport can consume the grace period, but + /// cannot make peer shutdown unbounded. + pub(crate) async fn shutdown(mut self) -> eyre::Result<()> { + let idle_result = + match tokio::time::timeout(QUIC_ENDPOINT_SHUTDOWN_GRACE, self.client.wait_idle()).await + { + Ok(result) => result.map_err(eyre::Report::from), + Err(_) => Err(eyre::eyre!( + "QUIC client did not become idle within {QUIC_ENDPOINT_SHUTDOWN_GRACE:?}" + )), + }; + + // No application connector remains after the runtime's child scopes + // have drained. Drop this final handle before forcing endpoint stop. + drop(self.client); + let endpoint_result = self.endpoint.shutdown_and_join().await; + + match (idle_result, endpoint_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Err(idle_error), Err(endpoint_error)) => Err(eyre::eyre!( + "QUIC client shutdown failed: {idle_error:#}; endpoint shutdown also failed: {endpoint_error:#}" + )), + } + } + + /// Settles the test-only hostile-handshake fixture after the verifier has + /// already proved that connection establishment was rejected. + /// + /// A failed TLS `CertificateVerify` can remain in s2n-quic's endpoint-owned + /// closing state beyond the normal wait-idle grace. This seam is not a + /// general timeout bypass: it drops the final client handle, then still + /// explicitly stops and joins the endpoint task. + #[cfg(test)] + pub(crate) async fn shutdown_rejected_handshake_fixture(self) -> eyre::Result<()> { + drop(self.client); + self.endpoint.shutdown_and_join().await + } +} + +pub(crate) fn start_quic_client() -> eyre::Result<(QuicClientRuntime, QuicConnector)> { + let (io, endpoint_control) = tracked_quic_io(SocketAddr::from(([0, 0, 0, 0], 0)))?; + let client = QuicClient::builder() + .with_tls(tls::client_provider()?)? + .with_io(io)? + .with_limits(quic_client_limits()?)? + .with_congestion_controller(quic_congestion_controller())? + .start()?; + let endpoint = endpoint_control.take_started()?; + let connector = QuicConnector { + client: Some(client.clone()), + }; + + Ok((QuicClientRuntime { client, endpoint }, connector)) +} + +/// Connection whose lexical owner always initiates QUIC close before dropping +/// the handle, including when a request future is cancelled or times out. +pub(crate) struct PeerConnection { + connection: Connection, +} + +impl PeerConnection { + fn new(connection: Connection) -> Self { + Self { connection } + } +} + +impl Deref for PeerConnection { + type Target = Connection; + + fn deref(&self) -> &Self::Target { + &self.connection + } +} + +impl DerefMut for PeerConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.connection + } +} + +impl Drop for PeerConnection { + fn drop(&mut self) { + self.connection.close(0u32.into()); + } +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use tokio::sync::Notify; + use tokio_util::sync::CancellationToken; + + use super::{EndpointTask, EndpointTaskControl, start_quic_client}; + + #[test] + fn endpoint_control_rejects_take_before_provider_start() { + let control = EndpointTaskControl::new(); + assert!(control.take_started().is_err()); + } + + #[tokio::test] + async fn client_start_captures_an_endpoint_that_can_be_joined() { + let (runtime, connector) = start_quic_client().expect("client endpoint should start"); + drop(connector); + + tokio::time::timeout(Duration::from_secs(2), runtime.shutdown()) + .await + .expect("client endpoint shutdown should be bounded") + .expect("client endpoint should join cleanly"); + } + + #[tokio::test] + async fn endpoint_owner_signals_and_joins_its_task() { + let stop = CancellationToken::new(); + let stopped = Arc::new(Notify::new()); + let task_stop = stop.clone(); + let task_stopped = stopped.clone(); + let task = tokio::spawn(async move { + task_stop.cancelled().await; + task_stopped.notify_one(); + }); + let owner = EndpointTask { + stop, + task: Some(task), + abort_requested: false, + }; + + owner + .shutdown_and_join() + .await + .expect("synthetic endpoint should join"); + tokio::time::timeout(Duration::from_secs(1), stopped.notified()) + .await + .expect("endpoint cleanup should happen before owner returns"); + } + + #[tokio::test] + async fn endpoint_owner_aborts_but_still_joins_uncooperative_task() { + let stop = CancellationToken::new(); + let dropped = Arc::new(Notify::new()); + let task_dropped = dropped.clone(); + let task = tokio::spawn(async move { + struct DropNotice(Arc); + impl Drop for DropNotice { + fn drop(&mut self) { + self.0.notify_one(); + } + } + let _drop_notice = DropNotice(task_dropped); + std::future::pending::<()>().await; + }); + let owner = EndpointTask { + stop, + task: Some(task), + abort_requested: false, + }; + + let mut owner = owner; + owner + .shutdown_and_join_with_grace(Duration::from_millis(10)) + .await + .expect("aborted endpoint should still join"); + tokio::time::timeout(Duration::from_secs(1), dropped.notified()) + .await + .expect("aborted endpoint should finish unwinding before owner returns"); + } + + #[tokio::test] + async fn cancelling_endpoint_cleanup_retains_task_for_retry() { + let stop = CancellationToken::new(); + let started = Arc::new(Notify::new()); + let dropped = Arc::new(Notify::new()); + let task_started = started.clone(); + let task_dropped = dropped.clone(); + let task = tokio::spawn(async move { + struct DropNotice(Arc); + impl Drop for DropNotice { + fn drop(&mut self) { + self.0.notify_one(); + } + } + + let _drop_notice = DropNotice(task_dropped); + task_started.notify_one(); + std::future::pending::<()>().await; + }); + let mut owner = EndpointTask { + stop, + task: Some(task), + abort_requested: false, + }; + + started.notified().await; + let mut cleanup = Box::pin(owner.shutdown_and_join_with_grace(Duration::from_secs(10))); + assert!( + tokio::time::timeout(Duration::from_millis(10), cleanup.as_mut()) + .await + .is_err(), + "synthetic endpoint should still be awaiting its grace period" + ); + + drop(cleanup); + assert!( + owner.task.is_some(), + "cancelling cleanup must leave the JoinHandle with its owner" + ); + owner + .shutdown_and_join_with_grace(Duration::from_millis(10)) + .await + .expect("the owner should be able to retry and join after cancellation"); + + tokio::time::timeout(Duration::from_secs(1), dropped.notified()) + .await + .expect("retried cleanup must join the task retained by its owner"); + } +} diff --git a/crates/lanspread-peer/src/recovery_quarantine.rs b/crates/lanspread-peer/src/recovery_quarantine.rs new file mode 100644 index 0000000..c5529a2 --- /dev/null +++ b/crates/lanspread-peer/src/recovery_quarantine.rs @@ -0,0 +1,126 @@ +//! Runtime fail-closed gate for local recovery state. + +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, +}; + +#[derive(Clone, Debug)] +pub(crate) struct RecoveryQuarantine { + inner: Arc>, +} + +#[derive(Debug)] +struct RecoveryState { + root: PathBuf, + status: RecoveryStatus, +} + +#[derive(Debug)] +enum RecoveryStatus { + Recovering, + Settled { failed_ids: HashSet }, +} + +impl RecoveryQuarantine { + pub(crate) fn recovering(root: PathBuf) -> Self { + Self { + inner: Arc::new(RwLock::new(RecoveryState { + root, + status: RecoveryStatus::Recovering, + })), + } + } + + pub(crate) fn begin(&self, root: PathBuf) { + let mut state = self.write(); + state.root = root; + state.status = RecoveryStatus::Recovering; + } + + /// Settles the current recovery epoch. A result from a superseded root is + /// ignored and leaves the newer root fail-closed. + pub(crate) fn settle(&self, root: &Path, failed_ids: HashSet) -> bool { + let mut state = self.write(); + if state.root != root { + return false; + } + state.status = RecoveryStatus::Settled { failed_ids }; + true + } + + pub(crate) fn is_blocked(&self, current_root: &Path, game_id: &str) -> bool { + let state = self.read(); + if state.root != current_root { + return true; + } + match &state.status { + RecoveryStatus::Recovering => true, + RecoveryStatus::Settled { failed_ids } => failed_ids.contains(game_id), + } + } + + /// Returns the failed IDs for a settled snapshot of this exact root. + /// Recovering or mismatched roots deliberately return no projection: the + /// runtime gate still blocks every game until the authoritative load + /// publishes and settles. + pub(crate) fn failed_ids(&self, current_root: &Path) -> HashSet { + let state = self.read(); + if state.root != current_root { + return HashSet::new(); + } + match &state.status { + RecoveryStatus::Recovering => HashSet::new(), + RecoveryStatus::Settled { failed_ids } => failed_ids.clone(), + } + } + + fn read(&self) -> RwLockReadGuard<'_, RecoveryState> { + self.inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write(&self) -> RwLockWriteGuard<'_, RecoveryState> { + self.inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recovering_and_root_mismatch_block_every_game() { + let gate = RecoveryQuarantine::recovering(PathBuf::from("/games")); + + assert!(gate.is_blocked(Path::new("/games"), "healthy")); + assert!(gate.is_blocked(Path::new("/other"), "healthy")); + } + + #[test] + fn settled_gate_blocks_only_failed_ids_for_exact_root() { + let gate = RecoveryQuarantine::recovering(PathBuf::from("/games")); + assert!(gate.settle(Path::new("/games"), HashSet::from(["broken".to_string()]))); + + assert!(gate.is_blocked(Path::new("/games"), "broken")); + assert!(!gate.is_blocked(Path::new("/games"), "healthy")); + assert!(gate.is_blocked(Path::new("/other"), "healthy")); + assert_eq!( + gate.failed_ids(Path::new("/games")), + HashSet::from(["broken".to_string()]) + ); + } + + #[test] + fn stale_root_cannot_settle_new_recovery_epoch() { + let gate = RecoveryQuarantine::recovering(PathBuf::from("/old")); + gate.begin(PathBuf::from("/new")); + + assert!(!gate.settle(Path::new("/old"), HashSet::new())); + assert!(gate.is_blocked(Path::new("/new"), "game")); + } +} diff --git a/crates/lanspread-peer/src/remote_peer.rs b/crates/lanspread-peer/src/remote_peer.rs deleted file mode 100644 index f90d6cd..0000000 --- a/crates/lanspread-peer/src/remote_peer.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Shared helpers for remote peer identity. - -use std::{net::SocketAddr, sync::Arc}; - -use tokio::sync::RwLock; - -use crate::peer_db::{PeerGameDB, PeerId}; - -pub async fn ensure_peer_id_for_addr( - peer_game_db: &Arc>, - peer_addr: SocketAddr, -) -> PeerId { - let mut db = peer_game_db.write().await; - if let Some(peer_id) = db.peer_id_for_transport_addr(&peer_addr) { - return peer_id; - } - - let addr_id = format!("addr-{peer_addr}"); - db.upsert_peer(addr_id.clone(), peer_addr); - addr_id -} diff --git a/crates/lanspread-peer/src/scoped_blocking.rs b/crates/lanspread-peer/src/scoped_blocking.rs new file mode 100644 index 0000000..a131256 --- /dev/null +++ b/crates/lanspread-peer/src/scoped_blocking.rs @@ -0,0 +1,142 @@ +//! Lexically scoped execution for finite blocking work. + +use tokio::runtime::{Handle, RuntimeFlavor}; + +/// Runs finite blocking work without detaching it from the calling task. +/// +/// On a multi-thread Tokio runtime, `block_in_place` lets the executor hand the +/// caller's other asynchronous work to another worker while this closure runs. +/// A current-thread runtime cannot make that handoff, so it executes the closure +/// directly, as does code running outside a Tokio runtime. +/// +/// This function deliberately has no cancellation point: when it returns, the +/// closure has completed and all values owned by the closure have been dropped. +pub fn scoped_blocking(work: F) -> R +where + F: FnOnce() -> R, +{ + if matches!( + Handle::try_current().map(|handle| handle.runtime_flavor()), + Ok(RuntimeFlavor::MultiThread) + ) { + tokio::task::block_in_place(work) + } else { + work() + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{Arc, Condvar, Mutex, mpsc}, + thread, + time::Duration, + }; + + use tokio::runtime::{Handle, RuntimeFlavor}; + + use super::scoped_blocking; + + struct DropSignal(mpsc::Sender<()>); + + impl Drop for DropSignal { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + + #[test] + fn runs_directly_without_a_runtime() { + let calling_thread = thread::current().id(); + let execution_thread = scoped_blocking(|| thread::current().id()); + + assert_eq!(execution_thread, calling_thread); + } + + #[tokio::test] + async fn runs_directly_on_a_current_thread_runtime() { + assert_eq!( + Handle::current().runtime_flavor(), + RuntimeFlavor::CurrentThread + ); + + let calling_thread = thread::current().id(); + let execution_thread = scoped_blocking(|| thread::current().id()); + + assert_eq!(execution_thread, calling_thread); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn abort_waits_for_in_place_work_to_finish() { + assert_eq!( + Handle::current().runtime_flavor(), + RuntimeFlavor::MultiThread + ); + + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let gate_for_task = Arc::clone(&gate); + let gate_for_release_thread = Arc::clone(&gate); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (dropped_tx, dropped_rx) = mpsc::channel(); + let (request_release, release_requested) = mpsc::channel(); + + // The timeout also makes this test fail safe: if an assertion panics, + // dropping `request_release` wakes this thread so the runtime can shut down. + let release_thread = thread::spawn(move || { + let _ = release_requested.recv_timeout(Duration::from_secs(2)); + let (gate_open, wake) = &*gate_for_release_thread; + let mut gate_open = gate_open.lock().expect("release gate must not be poisoned"); + *gate_open = true; + wake.notify_one(); + }); + + let mut task = tokio::spawn(async move { + scoped_blocking(move || { + let _drop_signal = DropSignal(dropped_tx); + entered_tx + .send(()) + .expect("test task must report entering blocking work"); + + let (gate_open, wake) = &*gate_for_task; + let gate_open = gate_open.lock().expect("task gate must not be poisoned"); + let _gate_open = wake + .wait_while(gate_open, |gate_open| !*gate_open) + .expect("task gate must not be poisoned"); + }); + }); + + tokio::time::timeout(Duration::from_secs(2), entered_rx) + .await + .expect("blocking work must start") + .expect("blocking task must retain the entry sender"); + + task.abort(); + + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut task) + .await + .is_err(), + "aborting the task must not complete it while in-place work is blocked" + ); + assert_eq!(dropped_rx.try_recv(), Err(mpsc::TryRecvError::Empty)); + + request_release + .send(()) + .expect("release thread must remain available"); + release_thread + .join() + .expect("release thread must not panic"); + + let completion = tokio::time::timeout(Duration::from_secs(2), &mut task) + .await + .expect("task must finish after its blocking work is released"); + match completion { + Ok(()) => {} + Err(error) if error.is_cancelled() => {} + Err(error) => panic!("blocking task failed unexpectedly: {error}"), + } + dropped_rx + .recv_timeout(Duration::from_secs(1)) + .expect("blocking closure values must be dropped before task completion"); + } +} diff --git a/crates/lanspread-peer/src/scoped_process.rs b/crates/lanspread-peer/src/scoped_process.rs new file mode 100644 index 0000000..3c72215 --- /dev/null +++ b/crates/lanspread-peer/src/scoped_process.rs @@ -0,0 +1,557 @@ +//! Cancellation-safe ownership of finite child processes. + +use std::{ + ffi::OsString, + path::Path, + process::{ExitStatus, Stdio}, + thread::{self, JoinHandle}, + time::Duration, +}; + +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(5); +#[cfg(target_os = "windows")] +pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Captured output from a child that has exited and been reaped. +#[derive(Debug)] +pub struct ScopedProcessOutput { + pub status: ExitStatus, + pub stdout: Vec, + pub stderr: Vec, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +/// A child-process worker whose destructor is a strict quiescence boundary. +/// +/// A dedicated OS thread owns the child and its pipe readers. Cancellation +/// kills and waits for the child, then joins both readers. Dropping this value +/// requests cancellation and synchronously joins that supervisor thread, so no +/// direct child or owned pipe-reader work can outlive the owner. +/// +/// Ownership covers the direct child, not an operating-system process group or +/// Windows job object. A descendant that inherits either output pipe keeps the +/// corresponding reader (and therefore this owner) alive until it closes it. +pub struct ScopedProcess { + cancel_token: CancellationToken, + result_rx: oneshot::Receiver>, + supervisor: Option>, +} + +/// Tokio child ownership for streaming subprocesses that cannot use +/// [`ScopedProcess`]'s captured-output worker. +pub(crate) struct ReapedTokioChild { + child: tokio::process::Child, +} + +/// Synchronous fallback ownership while the process supervisor is assembled. +/// +/// This guard is established immediately after `spawn`, before pipe extraction +/// or reader-thread creation can fail. +struct DirectChildGuard { + child: std::process::Child, + reaped: bool, +} + +impl DirectChildGuard { + fn new(child: std::process::Child) -> Self { + Self { + child, + reaped: false, + } + } +} + +impl Drop for DirectChildGuard { + fn drop(&mut self) { + if self.reaped { + return; + } + + let kill_error = self.child.kill().err(); + if let Err(wait_error) = self.child.wait() { + log::error!( + "Failed to synchronously reap guarded child process: {wait_error}; kill error: {kill_error:?}" + ); + } + } +} + +impl ReapedTokioChild { + pub(crate) fn new(child: tokio::process::Child) -> Self { + Self { child } + } + + pub(crate) fn child_mut(&mut self) -> &mut tokio::process::Child { + &mut self.child + } + + pub(crate) async fn wait(&mut self) -> std::io::Result { + self.child.wait().await + } + + pub(crate) async fn terminate_and_wait(&mut self) -> eyre::Result<()> { + let kill_error = self.child.start_kill().err(); + if let Err(wait_error) = self.child.wait().await { + eyre::bail!("failed to reap child process: {wait_error}; kill error: {kill_error:?}"); + } + Ok(()) + } +} + +impl Drop for ReapedTokioChild { + fn drop(&mut self) { + let kill_error = self.child.start_kill().err(); + loop { + match self.child.try_wait() { + Ok(Some(_status)) => break, + Ok(None) => thread::sleep(PROCESS_POLL_INTERVAL), + Err(wait_error) => { + log::error!( + "Failed to synchronously reap dropped child process: {wait_error}; kill error: {kill_error:?}" + ); + break; + } + } + } + } +} + +impl ScopedProcess { + /// Spawns a captured child under strict lexical ownership. + /// + /// At most `max_output_bytes_per_pipe` bytes are retained from each pipe; + /// excess bytes are still drained and reported through the output's + /// truncation flags. + /// + /// # Errors + /// + /// Returns an error if the supervisor thread cannot be created. Process + /// spawn failures are reported by [`Self::wait`]. + pub fn spawn( + program: impl AsRef, + args: impl IntoIterator, + cancel_token: &CancellationToken, + max_output_bytes_per_pipe: usize, + ) -> eyre::Result { + let program = program.as_ref().to_path_buf(); + let args = args.into_iter().collect::>(); + let worker_cancel = cancel_token.child_token(); + let thread_cancel = worker_cancel.clone(); + let (result_tx, result_rx) = oneshot::channel(); + let supervisor = thread::Builder::new() + .name("lanspread-child-process".to_string()) + .spawn(move || { + let result = + run_process_worker(&program, &args, &thread_cancel, max_output_bytes_per_pipe); + let _ = result_tx.send(result); + })?; + + Ok(Self { + cancel_token: worker_cancel, + result_rx, + supervisor: Some(supervisor), + }) + } + + /// Waits until the child is reaped and both output readers have stopped. + /// + /// # Errors + /// + /// Returns an error when the child cannot be spawned, waited for, or + /// captured, when cancellation is requested, or if its supervisor panics. + pub async fn wait(mut self) -> eyre::Result { + let result = (&mut self.result_rx) + .await + .map_err(|_| eyre::eyre!("child-process supervisor ended without a result")); + self.join_supervisor()?; + result? + } + + fn join_supervisor(&mut self) -> eyre::Result<()> { + let Some(supervisor) = self.supervisor.take() else { + return Ok(()); + }; + supervisor + .join() + .map_err(|_| eyre::eyre!("child-process supervisor panicked")) + } +} + +impl Drop for ScopedProcess { + fn drop(&mut self) { + self.cancel_token.cancel(); + if let Err(err) = self.join_supervisor() { + log::error!("Failed to settle dropped child process: {err}"); + } + } +} + +fn run_process_worker( + program: &Path, + args: &[OsString], + cancel_token: &CancellationToken, + max_output_bytes_per_pipe: usize, +) -> eyre::Result { + if cancel_token.is_cancelled() { + eyre::bail!("child process {} was cancelled", program.display()); + } + + let mut command = std::process::Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt as _; + + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = DirectChildGuard::new(command.spawn().map_err(|err| { + eyre::eyre!("failed to spawn child process {}: {err}", program.display()) + })?); + let Some(stdout) = child.child.stdout.take() else { + return Err(kill_and_wait( + &mut child, + program, + "started without its requested stdout pipe", + )); + }; + let Some(stderr) = child.child.stderr.take() else { + return Err(kill_and_wait( + &mut child, + program, + "started without its requested stderr pipe", + )); + }; + + thread::scope(move |scope| { + // This local guard unwinds before the scope joins any reader that was + // successfully started. If a later reader cannot be created, killing + // the child lets an earlier reader reach EOF before the scope returns. + let mut child = child; + let stdout_reader = thread::Builder::new() + .name("lanspread-child-stdout".to_string()) + .spawn_scoped(scope, move || { + read_process_pipe(stdout, max_output_bytes_per_pipe) + }) + .map_err(|err| { + eyre::eyre!( + "failed to start stdout reader for child process {}: {err}", + program.display() + ) + })?; + let stderr_reader = thread::Builder::new() + .name("lanspread-child-stderr".to_string()) + .spawn_scoped(scope, move || { + read_process_pipe(stderr, max_output_bytes_per_pipe) + }) + .map_err(|err| { + eyre::eyre!( + "failed to start stderr reader for child process {}: {err}", + program.display() + ) + })?; + let completion = wait_for_process(&mut child, cancel_token, program); + let stdout = join_pipe_reader(stdout_reader, "stdout", program); + let stderr = join_pipe_reader(stderr_reader, "stderr", program); + + let status = completion?; + let stdout = stdout?; + let stderr = stderr?; + Ok(ScopedProcessOutput { + status, + stdout: stdout.bytes, + stderr: stderr.bytes, + stdout_truncated: stdout.truncated, + stderr_truncated: stderr.truncated, + }) + }) +} + +struct CapturedPipe { + bytes: Vec, + truncated: bool, +} + +fn read_process_pipe( + mut pipe: impl std::io::Read, + max_output_bytes: usize, +) -> std::io::Result { + let mut bytes = Vec::with_capacity(max_output_bytes.min(8 * 1024)); + let mut buffer = [0_u8; 8 * 1024]; + let mut truncated = false; + loop { + let read = pipe.read(&mut buffer)?; + if read == 0 { + break; + } + + let remaining = max_output_bytes.saturating_sub(bytes.len()); + let retained = read.min(remaining); + bytes.extend_from_slice(&buffer[..retained]); + truncated |= retained != read; + } + Ok(CapturedPipe { bytes, truncated }) +} + +fn join_pipe_reader( + reader: thread::ScopedJoinHandle<'_, std::io::Result>, + pipe_name: &str, + program: &Path, +) -> eyre::Result { + reader + .join() + .map_err(|_| { + eyre::eyre!( + "{pipe_name} reader for child process {} panicked", + program.display() + ) + })? + .map_err(|err| { + eyre::eyre!( + "failed to read {pipe_name} from child process {}: {err}", + program.display() + ) + }) +} + +fn wait_for_process( + child: &mut DirectChildGuard, + cancel_token: &CancellationToken, + program: &Path, +) -> eyre::Result { + loop { + if cancel_token.is_cancelled() { + return Err(kill_and_wait(child, program, "was cancelled")); + } + + match child.child.try_wait() { + Ok(Some(status)) => { + child.reaped = true; + return Ok(status); + } + Ok(None) => thread::sleep(PROCESS_POLL_INTERVAL), + Err(wait_error) => { + return Err(kill_and_wait( + child, + program, + &format!("wait failed: {wait_error}"), + )); + } + } + } +} + +fn kill_and_wait(child: &mut DirectChildGuard, program: &Path, reason: &str) -> eyre::Report { + let kill_error = child.child.kill().err(); + let wait_result = child.child.wait(); + match wait_result { + Ok(_status) => { + child.reaped = true; + let kill_context = kill_error + .map(|error| format!("; kill reported: {error}")) + .unwrap_or_default(); + eyre::eyre!("child process {} {reason}{kill_context}", program.display()) + } + Err(wait_error) => eyre::eyre!( + "child process {} {reason}; failed to reap it: {wait_error}; kill error: {kill_error:?}", + program.display() + ), + } +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "lanspread-scoped-process-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )); + fs::create_dir_all(&path).expect("temp directory should be created"); + Self(path) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[cfg(unix)] + fn controlled_program(temp: &TempDir) -> PathBuf { + let program = temp.0.join("controlled-child"); + fs::write( + &program, + r#"#!/bin/sh +set -eu +marker=$1 +printf 'started' > "$marker" +while [ ! -e "$marker.release" ]; do :; done +printf 'late' > "$marker.late" +"#, + ) + .expect("controlled child should be written"); + let mut permissions = fs::metadata(&program) + .expect("controlled child metadata should be readable") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&program, permissions).expect("controlled child should be executable"); + program + } + + #[cfg(unix)] + fn wait_for_file(path: &Path) { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while !path.exists() { + assert!( + std::time::Instant::now() < deadline, + "controlled child marker should appear" + ); + thread::sleep(Duration::from_millis(5)); + } + } + + #[cfg(unix)] + #[test] + fn drop_kills_reaps_and_joins_child_before_returning() { + let temp = TempDir::new(); + let program = controlled_program(&temp); + let marker = temp.0.join("marker"); + let process = ScopedProcess::spawn( + program, + [marker.clone().into_os_string()], + &CancellationToken::new(), + 1024, + ) + .expect("supervisor should start"); + wait_for_file(&marker); + + drop(process); + fs::write(marker.with_extension("release"), b"").expect("release marker should be written"); + thread::sleep(Duration::from_millis(25)); + + assert!(!marker.with_extension("late").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn cancellation_waits_for_child_and_reader_quiescence() { + let temp = TempDir::new(); + let program = controlled_program(&temp); + let marker = temp.0.join("marker"); + let cancel_token = CancellationToken::new(); + let process = ScopedProcess::spawn( + program, + [marker.clone().into_os_string()], + &cancel_token, + 1024, + ) + .expect("supervisor should start"); + let (marker_ready_tx, marker_ready_rx) = oneshot::channel(); + let marker_for_wait = marker.clone(); + let marker_waiter = thread::spawn(move || { + wait_for_file(&marker_for_wait); + let _ = marker_ready_tx.send(()); + }); + tokio::time::timeout(Duration::from_secs(2), marker_ready_rx) + .await + .expect("controlled child should start") + .expect("marker waiter should report readiness"); + marker_waiter + .join() + .expect("marker waiter should not panic"); + + cancel_token.cancel(); + let error = process + .wait() + .await + .expect_err("cancelled child should fail"); + assert!(error.to_string().contains("cancelled")); + fs::write(marker.with_extension("release"), b"").expect("release marker should be written"); + thread::sleep(Duration::from_millis(25)); + assert!(!marker.with_extension("late").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn bounded_capture_keeps_draining_both_pipes_to_process_exit() { + let temp = TempDir::new(); + let program = temp.0.join("large-output-child"); + fs::write( + &program, + r#"#!/bin/sh +set -eu +i=0 +while [ "$i" -lt 20000 ]; do + printf '0123456789' + printf 'abcdefghij' >&2 + i=$((i + 1)) +done +"#, + ) + .expect("large-output child should be written"); + let mut permissions = fs::metadata(&program) + .expect("large-output child metadata should be readable") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&program, permissions) + .expect("large-output child should be executable"); + let cancel_token = CancellationToken::new(); + let process = + ScopedProcess::spawn(&program, std::iter::empty::(), &cancel_token, 17) + .expect("supervisor should start"); + + let output = tokio::time::timeout(Duration::from_secs(3), process.wait()) + .await + .expect("large-output child must not deadlock on full pipes") + .expect("large-output child should complete"); + + assert!(output.status.success()); + assert_eq!(output.stdout.len(), 17); + assert_eq!(output.stderr.len(), 17); + assert!(output.stdout_truncated); + assert!(output.stderr_truncated); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn streaming_child_drop_kills_and_reaps_synchronously() { + let child = tokio::process::Command::new("sleep") + .arg("30") + .spawn() + .expect("controlled child should start"); + let pid = child.id().expect("controlled child should have a pid"); + let process_path = PathBuf::from(format!("/proc/{pid}")); + assert!(process_path.exists()); + + drop(ReapedTokioChild::new(child)); + + assert!( + !process_path.exists(), + "Drop must not return before the OS child is reaped" + ); + } +} diff --git a/crates/lanspread-peer/src/services.rs b/crates/lanspread-peer/src/services.rs index 1bfd6c7..482c254 100644 --- a/crates/lanspread-peer/src/services.rs +++ b/crates/lanspread-peer/src/services.rs @@ -9,11 +9,21 @@ mod discovery; mod handshake; mod liveness; mod local_monitor; +mod remote_state; mod server; +mod state_sync; mod stream; +mod transfer; pub use discovery::run_peer_discovery; -pub(crate) use handshake::{HandshakeCtx, perform_handshake_with_peer}; +pub(crate) use handshake::{ + HandshakeCtx, + PeerRefreshOutcome, + ReservedCandidateHandshake, + perform_peer_refresh, +}; pub use liveness::run_ping_service; pub use local_monitor::run_local_game_monitor; +pub(crate) use remote_state::clear_remote_state_and_publish; pub use server::run_server_component; +pub(crate) use state_sync::StateSyncHandle; diff --git a/crates/lanspread-peer/src/services/advertise.rs b/crates/lanspread-peer/src/services/advertise.rs index e504154..08c3bcc 100644 --- a/crates/lanspread-peer/src/services/advertise.rs +++ b/crates/lanspread-peer/src/services/advertise.rs @@ -3,10 +3,10 @@ use std::{collections::HashMap, net::SocketAddr, time::Duration}; use lanspread_mdns::{DaemonEvent, LANSPREAD_SERVICE_TYPE, MdnsAdvertiser, MdnsMonitor}; -use lanspread_proto::PROTOCOL_VERSION; +use lanspread_proto::{PROTOCOL_VERSION, PeerId}; use tokio_util::sync::CancellationToken; -use crate::{context::PeerCtx, network::select_advertise_ip}; +use crate::{context::PeerCtx, network::select_advertise_ip, scoped_blocking::scoped_blocking}; pub(super) async fn start_mdns_advertiser( ctx: &PeerCtx, @@ -21,26 +21,29 @@ pub(super) async fn start_mdns_advertiser( *guard = Some(advertise_addr); } - let peer_id = ctx.peer_id.as_ref().clone(); + let peer_id = ctx.peer_id; let hostname = gethostname::gethostname().to_string_lossy().into_owned(); - let advertised_name = advertised_service_name(&hostname, &peer_id); + let advertised_name = advertised_service_name(&hostname, peer_id); let monitor_name = advertised_name.clone(); - let properties = advertisement_properties(ctx, &hostname, &peer_id).await; + let properties = advertisement_properties(&hostname, peer_id); - let mdns = tokio::task::spawn_blocking(move || { + let mdns = scoped_blocking(move || { MdnsAdvertiser::new( LANSPREAD_SERVICE_TYPE, &advertised_name, advertise_addr, Some(properties), ) - }) - .await??; + })?; log::info!("Registered mDNS service with name: {monitor_name}"); Ok(mdns) } +pub(super) fn close_mdns_advertiser(advertiser: MdnsAdvertiser) -> eyre::Result<()> { + scoped_blocking(move || advertiser.close()) +} + pub(super) async fn monitor_mdns_events(monitor: MdnsMonitor, shutdown: CancellationToken) { loop { let event = tokio::select! { @@ -67,7 +70,8 @@ pub(super) async fn monitor_mdns_events(monitor: MdnsMonitor, shutdown: Cancella } } -fn advertised_service_name(hostname: &str, peer_id: &str) -> String { +fn advertised_service_name(hostname: &str, peer_id: PeerId) -> String { + let peer_id = peer_id.to_string(); let max_hostname_len = 63usize.saturating_sub(peer_id.len() + 1); let truncated_hostname = if hostname.len() > max_hostname_len { hostname.get(..max_hostname_len).unwrap_or(hostname) @@ -76,27 +80,16 @@ fn advertised_service_name(hostname: &str, peer_id: &str) -> String { }; if truncated_hostname.is_empty() { - peer_id.to_string() + peer_id } else { format!("{truncated_hostname}-{peer_id}") } } -async fn advertisement_properties( - ctx: &PeerCtx, - hostname: &str, - peer_id: &str, -) -> HashMap { - let (library_rev, library_digest) = { - let library_guard = ctx.local_library.read().await; - (library_guard.revision, library_guard.digest) - }; - +fn advertisement_properties(hostname: &str, peer_id: PeerId) -> HashMap { let mut properties = HashMap::new(); properties.insert("peer_id".to_string(), peer_id.to_string()); properties.insert("proto_ver".to_string(), PROTOCOL_VERSION.to_string()); - properties.insert("library_rev".to_string(), library_rev.to_string()); - properties.insert("library_digest".to_string(), library_digest.to_string()); if !hostname.is_empty() { properties.insert("hostname".to_string(), hostname.to_string()); } diff --git a/crates/lanspread-peer/src/services/discovery.rs b/crates/lanspread-peer/src/services/discovery.rs index 2c81f62..d5b6166 100644 --- a/crates/lanspread-peer/src/services/discovery.rs +++ b/crates/lanspread-peer/src/services/discovery.rs @@ -1,31 +1,175 @@ //! mDNS peer discovery and discovery-time protocol negotiation. -use std::time::Duration; +use std::{ + collections::{HashSet, VecDeque}, + future::Future, + thread::JoinHandle, + time::Duration, +}; +use eyre::WrapErr as _; +use futures::{StreamExt as _, stream::FuturesUnordered}; use lanspread_mdns::{LANSPREAD_SERVICE_TYPE, MdnsBrowser, MdnsService, MdnsServicePoll}; -use lanspread_proto::PROTOCOL_VERSION; -use tokio::sync::mpsc::UnboundedSender; +use lanspread_proto::{PROTOCOL_VERSION, PeerEndpoint, PeerId}; +use tokio::sync::{ + mpsc::{self, UnboundedSender}, + oneshot, +}; +use tokio_util::sync::CancellationToken; use crate::{ PeerEvent, - context::Ctx, + context::NetworkServiceCtx, events, - peer_db::PeerId, - services::handshake::{HandshakeCtx, perform_handshake_with_peer}, + services::{ + handshake::{HandshakeCtx, ReservedCandidateHandshake}, + state_sync::run_state_sync, + }, }; +const MAX_ACTIVE_DISCOVERY_CANDIDATES: usize = 64; +const MAX_PENDING_MDNS_SERVICES: usize = 64; +const DISCOVERY_CANDIDATE_COOLDOWN: Duration = Duration::from_secs(5); + +#[derive(Default)] +struct RecentCandidates { + entries: VecDeque<(PeerEndpoint, tokio::time::Instant)>, +} + +impl RecentCandidates { + fn try_record(&mut self, candidate: PeerEndpoint, now: tokio::time::Instant) -> bool { + self.expire(now); + if self.entries.iter().any(|(endpoint, _)| { + endpoint.peer_id == candidate.peer_id || endpoint.addr == candidate.addr + }) || self.entries.len() >= MAX_ACTIVE_DISCOVERY_CANDIDATES + { + return false; + } + self.entries + .push_back((candidate, now + DISCOVERY_CANDIDATE_COOLDOWN)); + true + } + + fn expire(&mut self, now: tokio::time::Instant) { + while self + .entries + .front() + .is_some_and(|(_, deadline)| *deadline <= now) + { + self.entries.pop_front(); + } + } + + #[cfg(test)] + fn len(&self) -> usize { + self.entries.len() + } +} + struct MdnsPeerInfo { addr: std::net::SocketAddr, peer_id: Option, proto_ver: Option, - library_rev: u64, - library_digest: u64, +} + +struct ProtocolNegotiation { + endpoint: PeerEndpoint, + handshake: ReservedCandidateHandshake, +} + +struct DiscoveryWorker { + shutdown: CancellationToken, + result_rx: Option>>, + thread: Option>, +} + +impl DiscoveryWorker { + fn spawn( + service_type: String, + service_tx: mpsc::Sender, + shutdown: CancellationToken, + ) -> eyre::Result { + Self::spawn_with(shutdown, move |shutdown| { + run_mdns_browser(&service_type, &service_tx, &shutdown) + }) + } + + fn spawn_with( + shutdown: CancellationToken, + worker: impl FnOnce(CancellationToken) -> eyre::Result<()> + Send + 'static, + ) -> eyre::Result { + let (result_tx, result_rx) = oneshot::channel(); + let worker_shutdown = shutdown.clone(); + let thread = std::thread::Builder::new() + .name("lanspread-mdns-browser".to_owned()) + .spawn(move || { + let result = worker(worker_shutdown); + let _ = result_tx.send(result); + }) + .wrap_err("failed to spawn mDNS discovery worker")?; + + Ok(Self { + shutdown, + result_rx: Some(result_rx), + thread: Some(thread), + }) + } + + async fn wait_result(&mut self) -> eyre::Result<()> { + let result_rx = self + .result_rx + .as_mut() + .ok_or_else(|| eyre::eyre!("mDNS discovery result was already consumed"))?; + result_rx + .await + .map_err(|_| eyre::eyre!("mDNS discovery worker stopped without a result"))? + } + + async fn shutdown_and_join( + mut self, + observed_result: Option>, + ) -> eyre::Result<()> { + self.shutdown.cancel(); + let result = if let Some(result) = observed_result { + self.result_rx.take(); + result + } else { + let result_rx = self + .result_rx + .take() + .ok_or_else(|| eyre::eyre!("mDNS discovery result was already consumed"))?; + result_rx + .await + .map_err(|_| eyre::eyre!("mDNS discovery worker stopped without a result"))? + }; + self.join_thread()?; + result + } + + fn join_thread(&mut self) -> eyre::Result<()> { + let Some(thread) = self.thread.take() else { + return Ok(()); + }; + thread + .join() + .map_err(|_| eyre::eyre!("mDNS discovery worker panicked")) + } +} + +impl Drop for DiscoveryWorker { + fn drop(&mut self) { + self.shutdown.cancel(); + if let Err(err) = self.join_thread() { + log::error!("Failed to join mDNS discovery worker during cleanup: {err}"); + } + } } /// Runs the peer discovery service using mDNS. +#[allow(clippy::too_many_lines)] pub async fn run_peer_discovery( tx_notify_ui: UnboundedSender, - ctx: Ctx, + ctx: NetworkServiceCtx, ) -> eyre::Result<()> { log::info!("Starting peer discovery task"); @@ -34,38 +178,36 @@ pub async fn run_peer_discovery( } let service_type = LANSPREAD_SERVICE_TYPE.to_string(); - let (service_tx, mut service_rx) = tokio::sync::mpsc::unbounded_channel(); - let worker_shutdown = ctx.shutdown.clone(); - let service_type_clone = service_type.clone(); + let (service_tx, mut service_rx) = tokio::sync::mpsc::channel(MAX_PENDING_MDNS_SERVICES); + let service_shutdown = ctx.shutdown.child_token(); + let mut worker = DiscoveryWorker::spawn(service_type, service_tx, service_shutdown.clone())?; + let mut negotiations = FuturesUnordered::new(); + let mut active_candidates = HashSet::new(); + let mut recent_candidates = RecentCandidates::default(); + let mut mismatch_emitted = false; + let mut state_sync = Box::pin(run_state_sync( + ctx.clone(), + tx_notify_ui.clone(), + service_shutdown.clone(), + )); + let mut observed_state_sync_result = None; - let worker_handle = ctx - .task_tracker - .spawn_blocking(move || -> eyre::Result<()> { - let browser = MdnsBrowser::new(&service_type_clone)?; - while !worker_shutdown.is_cancelled() { - match browser.next_service_timeout(None, Duration::from_millis(250))? { - MdnsServicePoll::Service(service) => { - if service_tx.send(service).is_err() { - log::debug!("Peer discovery consumer dropped; stopping worker"); - break; - } - } - MdnsServicePoll::Timeout => {} - MdnsServicePoll::Closed => { - log::warn!("mDNS browser closed; stopping peer discovery worker"); - break; - } + let observed_worker_result = loop { + tokio::select! { + () = ctx.shutdown.cancelled() => break None, + result = worker.wait_result() => break Some(result), + result = &mut state_sync => { + observed_state_sync_result = Some(result); + break None; + } + completed = negotiations.next(), if !negotiations.is_empty() => { + if let Some(endpoint) = completed { + active_candidates.remove(&endpoint); } } - Ok(()) - }); - - loop { - tokio::select! { - () = ctx.shutdown.cancelled() => break, service = service_rx.recv() => { let Some(service) = service else { - break; + break None; }; let info = parse_mdns_peer(&service); @@ -74,30 +216,145 @@ pub async fn run_peer_discovery( continue; } - handle_discovered_peer(info, &ctx, &tx_notify_ui).await; + if info.proto_ver != Some(PROTOCOL_VERSION) { + if !mismatch_emitted { + events::send( + &tx_notify_ui, + PeerEvent::IncompatibleProtocolDetected { + observed: info.proto_ver, + expected: PROTOCOL_VERSION, + }, + ); + mismatch_emitted = true; + } + continue; + } + + if let Some(endpoint) = validated_candidate_endpoint(&info) { + if !candidate_is_admissible( + &active_candidates, + &mut recent_candidates, + endpoint, + tokio::time::Instant::now(), + ) { + log::warn!( + "Discovery candidate is cooling down or the recent-attempt limit is full; ignoring {}", + endpoint.addr + ); + continue; + } + let handshake_ctx = HandshakeCtx::from_network(&ctx, &tx_notify_ui) + .with_cancellation(service_shutdown.clone()); + let handshake = match ReservedCandidateHandshake::reserve(handshake_ctx, endpoint).await { + Ok(handshake) => handshake, + Err(error) => { + log::warn!("Failed to reserve discovery candidate {}: {error}", endpoint.addr); + continue; + } + }; + active_candidates.insert(endpoint); + negotiations.push(run_protocol_negotiation(ProtocolNegotiation { + endpoint, + handshake, + })); + } } } + }; + + service_shutdown.cancel(); + drain_service_children(negotiations).await; + let state_sync_exited_early = observed_state_sync_result.is_some(); + let state_sync_result = match observed_state_sync_result { + Some(result) => result, + None => state_sync.await, + }; + let worker_result = worker.shutdown_and_join(observed_worker_result).await; + + if let Err(error) = state_sync_result { + return Err(error.wrap_err("peer state-sync service failed")); + } + if state_sync_exited_early && !ctx.shutdown.is_cancelled() { + eyre::bail!("peer state-sync service exited unexpectedly"); } - match worker_handle.await { - Ok(Ok(())) if ctx.shutdown.is_cancelled() => Ok(()), - Ok(Ok(())) => { + match worker_result { + Ok(()) if ctx.shutdown.is_cancelled() => Ok(()), + Ok(()) => { eyre::bail!("mDNS discovery worker exited unexpectedly"); } - Ok(Err(err)) if ctx.shutdown.is_cancelled() => { + Err(err) if ctx.shutdown.is_cancelled() => { log::debug!("Peer discovery worker stopped during shutdown: {err}"); Ok(()) } - Ok(Err(err)) => Err(err.wrap_err("peer discovery worker failed")), - Err(err) if ctx.shutdown.is_cancelled() => { - log::debug!("Peer discovery worker join ended during shutdown: {err}"); - Ok(()) - } - Err(err) => Err(eyre::eyre!("peer discovery worker join error: {err}")), + Err(err) => Err(err.wrap_err("peer discovery worker failed")), } } -async fn wait_for_local_peer_addr(ctx: &Ctx) -> bool { +fn candidate_conflicts(active: &HashSet, candidate: PeerEndpoint) -> bool { + active + .iter() + .any(|endpoint| endpoint.peer_id == candidate.peer_id || endpoint.addr == candidate.addr) +} + +fn candidate_is_admissible( + active: &HashSet, + recent: &mut RecentCandidates, + candidate: PeerEndpoint, + now: tokio::time::Instant, +) -> bool { + active.len() < MAX_ACTIVE_DISCOVERY_CANDIDATES + && !candidate_conflicts(active, candidate) + && recent.try_record(candidate, now) +} + +fn run_mdns_browser( + service_type: &str, + service_tx: &mpsc::Sender, + shutdown: &CancellationToken, +) -> eyre::Result<()> { + let browser = MdnsBrowser::new(service_type)?; + let browse_result = (|| { + while !shutdown.is_cancelled() { + match browser.next_service_timeout(None, Duration::from_millis(250))? { + MdnsServicePoll::Service(service) => { + match service_tx.try_send(service) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + // Repeated mDNS observations are hints only. Coalesce an + // overflow by dropping it rather than letting the native + // browser thread allocate without bound or block shutdown. + log::trace!( + "Coalescing mDNS observation while the bounded discovery queue is full" + ); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + log::debug!("Peer discovery consumer dropped; stopping worker"); + break; + } + } + } + MdnsServicePoll::Timeout => {} + MdnsServicePoll::Closed => { + log::warn!("mDNS browser closed; stopping peer discovery worker"); + break; + } + } + } + Ok(()) + })(); + let close_result = browser.close(); + + match (browse_result, close_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(err), Ok(())) | (Ok(()), Err(err)) => Err(err), + (Err(browse_err), Err(close_err)) => Err(eyre::eyre!( + "mDNS browse failed: {browse_err:#}; browser shutdown also failed: {close_err:#}" + )), + } +} + +async fn wait_for_local_peer_addr(ctx: &NetworkServiceCtx) -> bool { loop { if ctx.local_peer_addr.read().await.is_some() { return true; @@ -113,88 +370,244 @@ async fn wait_for_local_peer_addr(ctx: &Ctx) -> bool { fn parse_mdns_peer(service: &MdnsService) -> MdnsPeerInfo { MdnsPeerInfo { addr: service.addr, - peer_id: service.properties.get("peer_id").cloned(), + peer_id: service + .properties + .get("peer_id") + .and_then(|value| value.parse::().ok()), proto_ver: service .properties .get("proto_ver") .and_then(|value| value.parse::().ok()), - library_rev: service - .properties - .get("library_rev") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0), - library_digest: service - .properties - .get("library_digest") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0), } } -async fn is_self_advertisement(info: &MdnsPeerInfo, ctx: &Ctx) -> bool { +async fn is_self_advertisement(info: &MdnsPeerInfo, ctx: &NetworkServiceCtx) -> bool { let guard = ctx.local_peer_addr.read().await; guard.as_ref().is_some_and(|addr| *addr == info.addr) || info .peer_id .as_ref() - .is_some_and(|peer_id| peer_id == ctx.peer_id.as_ref()) + .is_some_and(|peer_id| *peer_id == ctx.peer_id) } -async fn handle_discovered_peer( - info: MdnsPeerInfo, - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, -) { +fn validated_candidate_endpoint(info: &MdnsPeerInfo) -> Option { if info.proto_ver != Some(PROTOCOL_VERSION) { log::debug!( "Ignoring peer at {} with protocol {:?}; expected {PROTOCOL_VERSION}", info.addr, info.proto_ver ); - return; + return None; } - let Some(peer_id) = info.peer_id.clone() else { + let Some(peer_id) = info.peer_id else { log::debug!( "Ignoring current-protocol peer at {} without a peer_id TXT record", info.addr ); - return; + return None; }; - let upsert = { - let mut db = ctx.peer_game_db.write().await; - let upsert = db.upsert_peer(peer_id.clone(), info.addr); - let features = db.peer_features(&peer_id); - if info.library_rev > 0 || info.library_digest > 0 { - db.update_peer_library(&peer_id, info.library_rev, info.library_digest, features); - } - upsert + Some(PeerEndpoint::new(peer_id, info.addr)) +} + +async fn run_protocol_negotiation(negotiation: ProtocolNegotiation) -> PeerEndpoint { + let endpoint = negotiation.endpoint; + let result = negotiation.handshake.run().await; + if let Err(err) = result { + log::warn!( + "Failed to negotiate protocol with peer {}: {err}", + endpoint.addr + ); + } + endpoint +} + +async fn drain_service_children(mut children: FuturesUnordered) +where + F: Future, +{ + while children.next().await.is_some() {} +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashSet, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, + }, + time::Duration, }; - if upsert.is_new { - log::info!("Discovered peer at: {}", info.addr); - events::emit_peer_discovered(&ctx.peer_game_db, tx_notify_ui, info.addr).await; + use futures::stream::FuturesUnordered; + use lanspread_proto::{PeerEndpoint, PeerId}; + use tokio_util::sync::CancellationToken; + + use super::{ + DISCOVERY_CANDIDATE_COOLDOWN, + DiscoveryWorker, + MAX_ACTIVE_DISCOVERY_CANDIDATES, + RecentCandidates, + candidate_conflicts, + candidate_is_admissible, + drain_service_children, + }; + + fn endpoint(seed: u8, port: u16) -> PeerEndpoint { + PeerEndpoint::new( + PeerId::from_bytes([seed; 32]), + SocketAddr::from(([127, 0, 0, 1], port)), + ) } - if upsert.is_new || upsert.addr_changed { - spawn_protocol_negotiation(&info, ctx, tx_notify_ui, peer_id); + #[test] + fn active_candidate_keys_bound_both_claimed_identity_and_address() { + let active_endpoint = endpoint(1, 12001); + let mut active = HashSet::from([active_endpoint]); + + assert!(candidate_conflicts(&active, endpoint(1, 12002))); + assert!(candidate_conflicts(&active, endpoint(2, 12001))); + assert!(!candidate_conflicts(&active, endpoint(2, 12002))); + + active.remove(&active_endpoint); + assert!(!candidate_conflicts(&active, endpoint(1, 12002))); + assert!(!candidate_conflicts(&active, endpoint(2, 12001))); } -} -fn spawn_protocol_negotiation( - info: &MdnsPeerInfo, - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - peer_id: PeerId, -) { - let peer_addr = info.addr; - let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui); + #[test] + fn completed_candidate_attempts_are_bounded_and_rate_limited_by_identity_and_address() { + let now = tokio::time::Instant::now(); + let first = endpoint(1, 12001); + let mut recent = RecentCandidates::default(); + assert!(recent.try_record(first, now)); - ctx.task_tracker.spawn(async move { - if let Err(err) = perform_handshake_with_peer(handshake_ctx, peer_addr, Some(peer_id)).await - { - log::warn!("Failed to negotiate protocol with peer {peer_addr}: {err}"); + for port in 12002..12102 { + assert!(!recent.try_record(endpoint(1, port), now)); } - }); + for seed in 2..=101 { + assert!(!recent.try_record(endpoint(seed, 12001), now)); + } + for seed in 2..=u8::try_from(MAX_ACTIVE_DISCOVERY_CANDIDATES).expect("test bound fits u8") { + assert!(recent.try_record(endpoint(seed, 13000 + u16::from(seed)), now)); + } + assert_eq!(recent.len(), MAX_ACTIVE_DISCOVERY_CANDIDATES); + assert!(!recent.try_record(endpoint(200, 14000), now)); + + let after_cooldown = now + DISCOVERY_CANDIDATE_COOLDOWN; + assert!(recent.try_record(endpoint(200, 14000), after_cooldown)); + assert_eq!(recent.len(), 1); + } + + #[test] + fn expiring_recent_entries_never_bypasses_the_independent_active_cap() { + let now = tokio::time::Instant::now(); + let mut active = (0..MAX_ACTIVE_DISCOVERY_CANDIDATES) + .map(|index| { + endpoint( + u8::try_from(index + 1).expect("test index fits u8"), + 15000 + u16::try_from(index).expect("test index fits u16"), + ) + }) + .collect::>(); + let mut recent = RecentCandidates::default(); + for candidate in &active { + assert!(recent.try_record(*candidate, now)); + } + + let after_cooldown = now + DISCOVERY_CANDIDATE_COOLDOWN; + let next = endpoint(100, 16000); + assert!(!candidate_is_admissible( + &active, + &mut recent, + next, + after_cooldown, + )); + assert_eq!(active.len(), MAX_ACTIVE_DISCOVERY_CANDIDATES); + + let completed = *active.iter().next().expect("active set should be nonempty"); + active.remove(&completed); + assert!(candidate_is_admissible( + &active, + &mut recent, + next, + after_cooldown, + )); + } + + async fn cancellation_aware_child( + started: tokio::sync::mpsc::UnboundedSender<()>, + shutdown: CancellationToken, + completed: Arc, + ) { + started.send(()).expect("start receiver should remain open"); + shutdown.cancelled().await; + tokio::task::yield_now().await; + completed.fetch_add(1, Ordering::SeqCst); + } + + #[tokio::test] + async fn negotiation_batch_drains_started_children_on_shutdown() { + let shutdown = CancellationToken::new(); + let (started_tx, mut started_rx) = tokio::sync::mpsc::unbounded_channel(); + let completed = Arc::new(AtomicUsize::new(0)); + let children = FuturesUnordered::new(); + for _ in 0..2 { + children.push(cancellation_aware_child( + started_tx.clone(), + shutdown.clone(), + completed.clone(), + )); + } + + let control_shutdown = shutdown.clone(); + let control = async move { + for _ in 0..2 { + started_rx + .recv() + .await + .expect("every negotiation should start"); + } + control_shutdown.cancel(); + }; + + tokio::time::timeout(Duration::from_secs(1), async { + tokio::join!(drain_service_children(children), control); + }) + .await + .expect("shutdown should drain every negotiation"); + assert_eq!(completed.load(Ordering::SeqCst), 2); + } + + #[test] + fn dropping_discovery_worker_cancels_and_joins_its_thread() { + let shutdown = CancellationToken::new(); + let (started_tx, started_rx) = mpsc::sync_channel(0); + let stopped = Arc::new(AtomicBool::new(false)); + let worker_stopped = stopped.clone(); + let worker = DiscoveryWorker::spawn_with(shutdown, move |shutdown| { + started_tx + .send(()) + .expect("test should wait for worker startup"); + while !shutdown.is_cancelled() { + std::thread::sleep(Duration::from_millis(1)); + } + worker_stopped.store(true, Ordering::SeqCst); + Ok(()) + }) + .expect("discovery worker should spawn"); + + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("discovery worker should start"); + drop(worker); + + assert!( + stopped.load(Ordering::SeqCst), + "worker Drop must not return before its thread stops" + ); + } } diff --git a/crates/lanspread-peer/src/services/handshake.rs b/crates/lanspread-peer/src/services/handshake.rs index 914d014..46de77c 100644 --- a/crates/lanspread-peer/src/services/handshake.rs +++ b/crates/lanspread-peer/src/services/handshake.rs @@ -1,561 +1,89 @@ -//! Protocol handshakes and library synchronization between peers. +//! Pinned responder pulls used for discovery and known-peer refreshes. -use std::{net::SocketAddr, sync::Arc}; - -use lanspread_db::db::GameCatalog; -use lanspread_proto::{Hello, HelloAck, PROTOCOL_VERSION}; -use tokio::sync::{RwLock, mpsc::UnboundedSender}; +use lanspread_proto::PeerEndpoint; use crate::{ - PeerEvent, - call_to_play::CallToPlayStore, - context::{Ctx, PeerCtx}, - events, - identity::default_features, - library::{LocalLibraryState, build_library_snapshot}, - network::exchange_hello, - peer_db::{PeerGameDB, PeerId, PeerUpsert}, + peer_db::{PeerLivenessSnapshot, PeerNegotiationTicket, RefreshReservation}, + services::remote_state::{self, RemoteStateCtx}, }; -#[derive(Clone)] -pub(crate) struct HandshakeCtx { - peer_id: Arc, - local_peer_addr: Arc>>, - local_library: Arc>, - peer_game_db: Arc>, - tx_notify_ui: UnboundedSender, - catalog: Arc>, - call_to_play: Arc>, +pub(crate) type HandshakeCtx = RemoteStateCtx; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PeerRefreshOutcome { + Completed, + DeferredByCandidate, + Stale, } -impl HandshakeCtx { - pub(crate) fn from_ctx(ctx: &Ctx, tx_notify_ui: &UnboundedSender) -> Self { - Self { - peer_id: ctx.peer_id.clone(), - local_peer_addr: ctx.local_peer_addr.clone(), - local_library: ctx.local_library.clone(), - peer_game_db: ctx.peer_game_db.clone(), - tx_notify_ui: tx_notify_ui.clone(), - catalog: ctx.catalog.clone(), - call_to_play: ctx.call_to_play.clone(), - } +/// A candidate pull reserved before it enters an async child scope. +/// +/// The ticket is an RAII lease. Dropping this value or its running future +/// synchronously clears the candidate's peer/address claims. +pub(crate) struct ReservedCandidateHandshake { + ctx: HandshakeCtx, + endpoint: PeerEndpoint, + ticket: PeerNegotiationTicket, +} + +impl ReservedCandidateHandshake { + pub(crate) async fn reserve(ctx: HandshakeCtx, endpoint: PeerEndpoint) -> eyre::Result { + let ticket = ctx.reserve_candidate(endpoint).await?; + Ok(Self { + ctx, + endpoint, + ticket, + }) } - pub(crate) fn from_peer_ctx(ctx: &PeerCtx) -> Self { - Self { - peer_id: ctx.peer_id.clone(), - local_peer_addr: ctx.local_peer_addr.clone(), - local_library: ctx.local_library.clone(), - peer_game_db: ctx.peer_game_db.clone(), - tx_notify_ui: ctx.tx_notify_ui.clone(), - catalog: ctx.catalog.clone(), - call_to_play: ctx.call_to_play.clone(), + pub(crate) async fn run(self) -> eyre::Result<()> { + let committed = + remote_state::pull_and_commit(&self.ctx, self.endpoint, self.ticket).await?; + if !committed { + log::debug!( + "Discarding stale authenticated candidate result for {} at {}", + self.endpoint.peer_id, + self.endpoint.addr + ); } + Ok(()) } } -async fn required_listen_addr( - local_peer_addr: &Arc>>, -) -> eyre::Result { - (*local_peer_addr.read().await) - .ok_or_else(|| eyre::eyre!("local peer listener address is not ready")) -} - -pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result { - let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?; - let library = { - let library_guard = ctx.local_library.read().await; - build_library_snapshot(&library_guard) +pub(crate) async fn perform_peer_refresh( + ctx: HandshakeCtx, + snapshot: PeerLivenessSnapshot, +) -> eyre::Result { + let ticket = match ctx.begin_peer_refresh(snapshot).await? { + RefreshReservation::Reserved(ticket) => ticket, + RefreshReservation::DeferredByCandidate => { + log::debug!( + "Deferring refresh for {} at {} behind a discovery candidate", + snapshot.endpoint.peer_id, + snapshot.endpoint.addr + ); + return Ok(PeerRefreshOutcome::DeferredByCandidate); + } + RefreshReservation::Stale => { + log::debug!( + "Discarding refresh for stale peer endpoint {} at {}", + snapshot.endpoint.peer_id, + snapshot.endpoint.addr + ); + return Ok(PeerRefreshOutcome::Stale); + } }; - let call_to_play_events = ctx.call_to_play.write().await.snapshot(); - Ok(HelloAck { - peer_id: ctx.peer_id.as_ref().clone(), - proto_ver: PROTOCOL_VERSION, - listen_addr, - library, - features: default_features(), - call_to_play_events, + let committed = remote_state::pull_and_commit(&ctx, snapshot.endpoint, ticket).await?; + if !committed { + log::debug!( + "Discarding stale authenticated refresh result for {} at {}", + snapshot.endpoint.peer_id, + snapshot.endpoint.addr + ); + } + Ok(if committed { + PeerRefreshOutcome::Completed + } else { + PeerRefreshOutcome::Stale }) } - -async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result { - let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?; - let library = { - let library_guard = ctx.local_library.read().await; - build_library_snapshot(&library_guard) - }; - let call_to_play_events = ctx.call_to_play.write().await.snapshot(); - Ok(Hello { - peer_id: ctx.peer_id.as_ref().clone(), - proto_ver: PROTOCOL_VERSION, - listen_addr, - library, - features: default_features(), - call_to_play_events, - }) -} - -pub(crate) async fn perform_handshake_with_peer( - ctx: HandshakeCtx, - peer_addr: SocketAddr, - peer_id_hint: Option, -) -> eyre::Result<()> { - let hello = build_hello_from_state(&ctx).await?; - let ack = exchange_hello(peer_addr, hello).await?; - - if ack.proto_ver != PROTOCOL_VERSION { - log::warn!( - "Peer {peer_addr} uses incompatible protocol {} (expected {PROTOCOL_VERSION})", - ack.proto_ver - ); - return Ok(()); - } - - if ack.peer_id == *ctx.peer_id { - log::trace!("Ignoring handshake with self for {peer_addr}"); - return Ok(()); - } - - if let Some(expected) = peer_id_hint.as_ref() - && expected != &ack.peer_id - { - log::warn!( - "Peer {peer_addr} id mismatch: mDNS advertised {expected}, hello ack returned {}", - ack.peer_id - ); - let _ = ctx.peer_game_db.write().await.remove_peer(expected); - } - - merge_call_to_play_events( - &ctx.call_to_play, - &ctx.tx_notify_ui, - ack.call_to_play_events, - ) - .await; - - let record_addr = ack.listen_addr; - let upsert = record_remote_library( - &ctx.peer_game_db, - ack.peer_id.clone(), - record_addr, - ack.features.clone(), - ack.library, - ) - .await; - - after_peer_library_recorded(&ctx, upsert, record_addr).await; - events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await; - - Ok(()) -} - -pub(super) async fn accept_inbound_hello( - ctx: &PeerCtx, - transport_addr: Option, - hello: Hello, -) -> eyre::Result { - if hello.peer_id == *ctx.peer_id { - log::trace!("Ignoring hello from self"); - return build_hello_ack(ctx).await; - } - - if hello.proto_ver != PROTOCOL_VERSION { - log::warn!( - "Incompatible protocol from {transport_addr:?}: {}", - hello.proto_ver - ); - return build_hello_ack(ctx).await; - } - - let addr = hello.listen_addr; - merge_call_to_play_events( - &ctx.call_to_play, - &ctx.tx_notify_ui, - hello.call_to_play_events, - ) - .await; - let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx); - let upsert = record_remote_library( - &ctx.peer_game_db, - hello.peer_id.clone(), - addr, - hello.features.clone(), - hello.library, - ) - .await; - - after_peer_library_recorded(&handshake_ctx, upsert, addr).await; - events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await; - - build_hello_ack(ctx).await -} - -async fn merge_call_to_play_events( - store: &Arc>, - tx_notify_ui: &UnboundedSender, - incoming: Vec, -) { - match store.write().await.merge_batch(incoming) { - Ok(merged) => { - if merged.needs_history() { - log::warn!( - "Call to Play handshake omitted roots for calls: {}", - merged.missing_call_ids.join(", ") - ); - } - if !merged.applied.is_empty() { - events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied)); - } - } - Err(err) => { - log::warn!("Rejecting Call to Play handshake history: {err}"); - } - } -} - -pub(super) fn spawn_library_resync( - ctx: HandshakeCtx, - peer_addr: SocketAddr, - peer_id_hint: PeerId, - reason: &'static str, -) { - tokio::spawn(async move { - if let Err(err) = perform_handshake_with_peer(ctx, peer_addr, Some(peer_id_hint)).await { - log::warn!("Failed to {reason} library from {peer_addr}: {err}"); - } - }); -} - -async fn record_remote_library( - peer_game_db: &Arc>, - peer_id: PeerId, - peer_addr: SocketAddr, - features: Vec, - snapshot: lanspread_proto::LibrarySnapshot, -) -> PeerUpsert { - let mut db = peer_game_db.write().await; - let upsert = db.upsert_peer(peer_id.clone(), peer_addr); - db.apply_library_snapshot(&peer_id, snapshot); - db.update_peer_features(&peer_id, features); - upsert -} - -async fn after_peer_library_recorded( - ctx: &HandshakeCtx, - upsert: PeerUpsert, - peer_addr: SocketAddr, -) { - if upsert.is_new { - events::emit_peer_discovered(&ctx.peer_game_db, &ctx.tx_notify_ui, peer_addr).await; - } -} - -#[cfg(test)] -mod tests { - use std::{ - collections::HashMap, - net::SocketAddr, - path::{Path, PathBuf}, - sync::Arc, - }; - - use lanspread_db::db::GameCatalog; - use lanspread_proto::{ - Availability, - CallToPlayAction, - CallToPlayEvent, - GameSummary, - Hello, - LibrarySnapshot, - PROTOCOL_VERSION, - }; - use tokio::sync::{RwLock, mpsc}; - use tokio_util::{sync::CancellationToken, task::TaskTracker}; - - use super::{HandshakeCtx, accept_inbound_hello, build_hello_from_state}; - use crate::{ - PeerEvent, - UnpackFuture, - Unpacker, - context::Ctx, - library::LocalLibraryState, - peer_db::PeerGameDB, - }; - - struct NoopUnpacker; - - impl Unpacker for NoopUnpacker { - fn unpack<'a>(&'a self, _archive: &'a Path, _dest: &'a Path) -> UnpackFuture<'a> { - Box::pin(async { Ok(()) }) - } - } - - fn addr(ip: [u8; 4], port: u16) -> SocketAddr { - SocketAddr::from((ip, port)) - } - - fn test_handshake_ctx(local_peer_addr: Option) -> HandshakeCtx { - let (tx_notify_ui, _rx_notify_ui) = mpsc::unbounded_channel(); - let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - HandshakeCtx { - peer_id: Arc::new("local-peer".to_string()), - local_peer_addr: Arc::new(RwLock::new(local_peer_addr)), - local_library: Arc::new(RwLock::new(LocalLibraryState::empty())), - peer_game_db, - tx_notify_ui, - catalog: Arc::new(RwLock::new(GameCatalog::empty())), - call_to_play: Arc::new(RwLock::new(crate::call_to_play::CallToPlayStore::default())), - } - } - - fn summary(id: &str) -> GameSummary { - GameSummary { - id: id.to_string(), - name: id.to_string(), - size: 42, - downloaded: true, - installed: true, - eti_version: Some("20250101".to_string()), - manifest_hash: 7, - availability: Availability::Ready, - } - } - - fn call_to_play_event() -> CallToPlayEvent { - CallToPlayEvent { - id: "event-1".to_string(), - call_id: "call-1".to_string(), - actor_id: "peer-alice".to_string(), - actor_name: "Alice".to_string(), - at: 8_000_000_000_000, - action: CallToPlayAction::Create { - game_id: "game".to_string(), - max_players: 4, - scheduled_for: None, - deadline: 8_000_000_060_000, - }, - } - } - - #[tokio::test] - async fn outbound_hello_requires_local_listener_addr() { - let ctx = test_handshake_ctx(None); - - let err = build_hello_from_state(&ctx) - .await - .expect_err("hello without listener must fail"); - - assert_eq!(err.to_string(), "local peer listener address is not ready"); - } - - #[tokio::test] - async fn outbound_hello_carries_local_listener_addr() { - let advertised = addr([10, 66, 0, 2], 40000); - let ctx = test_handshake_ctx(Some(advertised)); - - let hello = build_hello_from_state(&ctx) - .await - .expect("listener address is present"); - - assert_eq!(hello.listen_addr, advertised); - } - - #[tokio::test] - async fn outbound_hello_carries_local_library_snapshot() { - let ctx = test_handshake_ctx(Some(addr([10, 66, 0, 2], 40000))); - ctx.local_library - .write() - .await - .update_from_scan(HashMap::from([("game".to_string(), summary("game"))]), 7); - - let hello = build_hello_from_state(&ctx) - .await - .expect("listener address is present"); - - assert_eq!(hello.library.library_rev, 7); - assert_eq!(hello.library.games.len(), 1); - assert_eq!(hello.library.games[0].id, "game"); - } - - #[tokio::test] - async fn outbound_hello_carries_call_to_play_history() { - let ctx = test_handshake_ctx(Some(addr([10, 66, 0, 2], 40000))); - ctx.call_to_play - .write() - .await - .merge_batch(vec![call_to_play_event()]) - .expect("valid event should be merged"); - - let hello = build_hello_from_state(&ctx) - .await - .expect("listener address is present"); - - assert_eq!(hello.call_to_play_events, [call_to_play_event()]); - } - - #[tokio::test] - async fn inbound_hello_applies_remote_library_snapshot() { - let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - let mut catalog = GameCatalog::empty(); - catalog.insert("remote-game".to_string(), Some("20250101".to_string())); - let ctx = Ctx::new( - peer_game_db.clone(), - "local-peer".to_string(), - PathBuf::new(), - PathBuf::new(), - Arc::new(NoopUnpacker), - CancellationToken::new(), - TaskTracker::new(), - Arc::new(RwLock::new(catalog)), - Arc::new(RwLock::new(HashMap::new())), - Arc::new(crate::NoopStreamInstallProvider), - ); - *ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000)); - - let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel(); - let peer_ctx = ctx.to_peer_ctx(tx_notify_ui); - let remote_addr = addr([127, 0, 0, 1], 5000); - let hello = Hello { - peer_id: "remote-peer".to_string(), - proto_ver: PROTOCOL_VERSION, - listen_addr: remote_addr, - library: LibrarySnapshot { - library_rev: 3, - games: vec![summary("remote-game")], - }, - features: Vec::new(), - call_to_play_events: Vec::new(), - }; - - let ack = accept_inbound_hello(&peer_ctx, None, hello) - .await - .expect("current protocol hello should be accepted"); - - assert_eq!(ack.peer_id, "local-peer"); - let snapshots = peer_game_db.read().await.peer_snapshots(); - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].addr, remote_addr); - assert_eq!(snapshots[0].game_count, 1); - assert_eq!(snapshots[0].games[0].id, "remote-game"); - - assert!(matches!( - rx_notify_ui - .recv() - .await - .expect("peer discovery event should be emitted"), - PeerEvent::PeerDiscovered(addr) if addr == remote_addr - )); - assert!(matches!( - rx_notify_ui - .recv() - .await - .expect("peer count event should be emitted"), - PeerEvent::PeerCountUpdated(1) - )); - let PeerEvent::ListGames(games) = rx_notify_ui - .recv() - .await - .expect("peer game list should be emitted") - else { - panic!("expected ListGames"); - }; - assert_eq!(games.len(), 1); - assert_eq!(games[0].id, "remote-game"); - assert_eq!(games[0].peer_count, 1); - } - - #[tokio::test] - async fn inbound_hello_from_self_is_ignored() { - // Protocol-level self-detection: a hello whose peer_id matches the local - // peer id must be acknowledged but never recorded as a peer. The CLI - // harness short-circuits self-connects with a string compare before any - // network call, so this guard (handshake.rs) is only exercised here. - let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - let ctx = Ctx::new( - peer_game_db.clone(), - "local-peer".to_string(), - PathBuf::new(), - PathBuf::new(), - Arc::new(NoopUnpacker), - CancellationToken::new(), - TaskTracker::new(), - Arc::new(RwLock::new(GameCatalog::empty())), - Arc::new(RwLock::new(HashMap::new())), - Arc::new(crate::NoopStreamInstallProvider), - ); - *ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000)); - - let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel(); - let peer_ctx = ctx.to_peer_ctx(tx_notify_ui); - let self_hello = Hello { - peer_id: "local-peer".to_string(), - proto_ver: PROTOCOL_VERSION, - listen_addr: addr([127, 0, 0, 1], 4000), - library: LibrarySnapshot { - library_rev: 9, - games: vec![summary("self-game")], - }, - features: Vec::new(), - call_to_play_events: Vec::new(), - }; - - let ack = accept_inbound_hello(&peer_ctx, None, self_hello) - .await - .expect("self hello should still be acknowledged"); - - assert_eq!(ack.peer_id, "local-peer"); - assert!( - peer_game_db.read().await.peer_snapshots().is_empty(), - "self must never be recorded as a peer" - ); - assert!( - rx_notify_ui.try_recv().is_err(), - "self hello must emit no peer discovery events" - ); - } - - #[tokio::test] - async fn inbound_hello_merges_call_to_play_history_once() { - let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - let ctx = Ctx::new( - peer_game_db, - "local-peer".to_string(), - PathBuf::new(), - PathBuf::new(), - Arc::new(NoopUnpacker), - CancellationToken::new(), - TaskTracker::new(), - Arc::new(RwLock::new(GameCatalog::empty())), - Arc::new(RwLock::new(HashMap::new())), - Arc::new(crate::NoopStreamInstallProvider), - ); - *ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000)); - let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel(); - let peer_ctx = ctx.to_peer_ctx(tx_notify_ui); - let remote_addr = addr([127, 0, 0, 1], 5000); - let hello = Hello { - peer_id: "remote-peer".to_string(), - proto_ver: PROTOCOL_VERSION, - listen_addr: remote_addr, - library: LibrarySnapshot { - library_rev: 0, - games: Vec::new(), - }, - features: Vec::new(), - call_to_play_events: vec![call_to_play_event(), call_to_play_event()], - }; - - accept_inbound_hello(&peer_ctx, None, hello) - .await - .expect("current protocol hello should be accepted"); - - assert_eq!( - ctx.call_to_play.write().await.snapshot(), - [call_to_play_event()] - ); - assert!(matches!( - rx_notify_ui.recv().await, - Some(PeerEvent::CallToPlayEvents(events)) if events == [call_to_play_event()] - )); - } -} diff --git a/crates/lanspread-peer/src/services/liveness.rs b/crates/lanspread-peer/src/services/liveness.rs index c41fd50..da8118f 100644 --- a/crates/lanspread-peer/src/services/liveness.rs +++ b/crates/lanspread-peer/src/services/liveness.rs @@ -1,367 +1,315 @@ -//! Peer liveness checks and stale-peer cleanup. +//! Pinned liveness checks and generation-conditional peer cleanup. -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{sync::Arc, time::Duration}; -use lanspread_db::db::GameCatalog; -use tokio::sync::{RwLock, mpsc::UnboundedSender}; -use tokio_util::{sync::CancellationToken, task::TaskTracker}; +use futures::{StreamExt as _, stream}; +use tokio::sync::mpsc::UnboundedSender; use crate::{ PeerEvent, config::{PEER_PING_IDLE_SECS, PEER_PING_INTERVAL_SECS, peer_stale_timeout}, - context::OperationKind, - events, + content_quarantine::ContentQuarantine, + context::{NetworkServiceCtx, OperationKind}, network::ping_peer, - peer_db::{PeerGameDB, PeerId}, + peer_db::PeerLivenessSnapshot, + scoped_blocking::scoped_blocking, + services::{HandshakeCtx, remote_state}, }; -/// Runs the ping service to check peer liveness. +const MAX_CONCURRENT_PINGS: usize = 8; + +/// Runs revision-bearing pinned liveness checks. The idle gate deliberately +/// uses `last_revision_check`; inbound and content traffic only affect +/// `last_seen`, which remains the stale-pruning clock. pub async fn run_ping_service( tx_notify_ui: UnboundedSender, - peer_game_db: Arc>, - catalog: Arc>, - active_operations: Arc>>, - active_downloads: Arc>>, - shutdown: CancellationToken, - task_tracker: TaskTracker, + ctx: NetworkServiceCtx, ) -> eyre::Result<()> { log::info!( "Starting ping service ({PEER_PING_INTERVAL_SECS}s interval, \ -{}s idle threshold, {}s timeout)", +{}s revision-check idle threshold, {}s stale timeout)", PEER_PING_IDLE_SECS, peer_stale_timeout().as_secs() ); - let mut interval = tokio::time::interval(Duration::from_secs(PEER_PING_INTERVAL_SECS)); + let remote_ctx = HandshakeCtx::from_network(&ctx, &tx_notify_ui); loop { tokio::select! { - () = shutdown.cancelled() => return Ok(()), + biased; + () = ctx.shutdown.cancelled() => return Ok(()), _ = interval.tick() => {} } - ping_idle_peers( - &peer_game_db, - &catalog, - &active_operations, - &active_downloads, - &tx_notify_ui, - &shutdown, - &task_tracker, - ) - .await; + let snapshots = ctx + .peer_game_db + .read() + .await + .peer_liveness_snapshot() + .into_iter() + .filter(revision_check_due) + .collect::>(); + let mut checks = stream::iter(snapshots.into_iter().map(|snapshot| { + let ctx = ctx.clone(); + let remote_ctx = remote_ctx.clone(); + async move { check_peer_liveness(&ctx, &remote_ctx, snapshot).await } + })) + .buffer_unordered(MAX_CONCURRENT_PINGS); + while checks.next().await.is_some() {} - prune_stale_peers( - &peer_game_db, - &catalog, - &active_operations, - &active_downloads, - &tx_notify_ui, - ) - .await; + if ctx.shutdown.is_cancelled() { + return Ok(()); + } + prune_stale_peers(&ctx, &remote_ctx).await?; } } -async fn ping_idle_peers( - peer_game_db: &Arc>, - catalog: &Arc>, - active_operations: &Arc>>, - active_downloads: &Arc>>, - tx_notify_ui: &UnboundedSender, - shutdown: &CancellationToken, - task_tracker: &TaskTracker, +fn revision_check_due(snapshot: &PeerLivenessSnapshot) -> bool { + snapshot.last_revision_check.elapsed() >= Duration::from_secs(PEER_PING_IDLE_SECS) +} + +async fn check_peer_liveness( + ctx: &NetworkServiceCtx, + remote_ctx: &HandshakeCtx, + snapshot: PeerLivenessSnapshot, ) { - let peer_snapshots = { peer_game_db.read().await.peer_liveness_snapshot() }; - - for (peer_id, peer_addr, last_seen) in peer_snapshots { - if last_seen.elapsed() < Duration::from_secs(PEER_PING_IDLE_SECS) { - continue; - } - - let tx_notify_ui = tx_notify_ui.clone(); - let peer_game_db = peer_game_db.clone(); - let catalog = catalog.clone(); - let active_operations = active_operations.clone(); - let active_downloads = active_downloads.clone(); - let shutdown = shutdown.clone(); - - task_tracker.spawn(async move { - let ping_result = tokio::select! { - () = shutdown.cancelled() => return, - result = ping_peer(peer_addr) => result, - }; - - match ping_result { - Ok(true) => { - peer_game_db.write().await.update_last_seen(&peer_id); - } - Ok(false) => { - log::warn!("Peer {peer_addr} failed ping check"); - remove_peer_and_refresh( - &peer_game_db, - &catalog, - &active_operations, - &active_downloads, - &tx_notify_ui, - peer_id, - "Removed stale peer", - ) - .await; - } - Err(err) => { - log::error!("Failed to ping peer {peer_addr}: {err}"); - remove_peer_and_refresh( - &peer_game_db, - &catalog, - &active_operations, - &active_downloads, - &tx_notify_ui, - peer_id, - "Removed peer due to ping error", - ) - .await; + match ping_peer(&ctx.quic, &snapshot.endpoint, &ctx.shutdown).await { + Ok(revisions) => { + match remote_state::observe_pinned_pong(remote_ctx, snapshot, revisions).await { + Ok(remote_state::PongCommit::NeedsPull { .. }) => { + if let Err(error) = ctx + .state_sync + .schedule_pinned_pull(snapshot.endpoint.peer_id, &ctx.shutdown) + .await + { + log::debug!( + "Could not schedule revision refresh for {}: {error:#}", + snapshot.endpoint.peer_id + ); + } } + Ok( + remote_state::PongCommit::Current | remote_state::PongCommit::StaleGeneration, + ) => {} + Err(error) => log::error!( + "Failed to apply Pong from {}: {error:#}", + snapshot.endpoint.addr + ), } - }); + } + Err(error) => { + log::warn!( + "Pinned ping to {} failed: {error:#}", + snapshot.endpoint.addr + ); + // A capacity reset or transient transport failure is not topology + // authority. Leave both clocks unchanged and require repeated + // generation-current failures plus the stale timeout before the + // normal pruning path removes state. + ctx.peer_game_db + .write() + .await + .record_ping_failure_if_generation(snapshot); + } } } -async fn prune_stale_peers( - peer_game_db: &Arc>, - catalog: &Arc>, - active_operations: &Arc>>, - active_downloads: &Arc>>, - tx_notify_ui: &UnboundedSender, -) { - let stale_peers = { - peer_game_db - .read() - .await - .get_stale_peer_ids(peer_stale_timeout()) - }; - +async fn prune_stale_peers(ctx: &NetworkServiceCtx, remote_ctx: &HandshakeCtx) -> eyre::Result<()> { + let stale = ctx + .peer_game_db + .read() + .await + .stale_peer_liveness_snapshots(peer_stale_timeout()); let mut removed_any = false; - for peer_id in stale_peers { - removed_any |= remove_peer(peer_game_db, tx_notify_ui, peer_id, "Removed stale peer").await; + for snapshot in stale { + removed_any |= remote_state::remove_peer_if_generation(remote_ctx, snapshot).await?; } - if removed_any { - events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await; - handle_active_downloads_without_peers( - peer_game_db, - active_operations, - active_downloads, - tx_notify_ui, - ) - .await; + handle_active_downloads_without_peers(ctx).await; } + Ok(()) } -async fn remove_peer_and_refresh( - peer_game_db: &Arc>, - catalog: &Arc>, - active_operations: &Arc>>, - active_downloads: &Arc>>, - tx_notify_ui: &UnboundedSender, - peer_id: PeerId, - log_label: &str, -) { - if remove_peer(peer_game_db, tx_notify_ui, peer_id, log_label).await { - events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await; - handle_active_downloads_without_peers( - peer_game_db, - active_operations, - active_downloads, - tx_notify_ui, - ) - .await; - } -} - -async fn remove_peer( - peer_game_db: &Arc>, - tx_notify_ui: &UnboundedSender, - peer_id: PeerId, - log_label: &str, -) -> bool { - let removed_peer = { peer_game_db.write().await.remove_peer(&peer_id) }; - let Some(peer) = removed_peer else { - return false; - }; - - log::info!("{log_label}: {}", peer.addr); - events::emit_peer_lost(peer_game_db, tx_notify_ui, peer.addr).await; - true -} - -async fn handle_active_downloads_without_peers( - peer_game_db: &Arc>, - active_operations: &Arc>>, - active_downloads: &Arc>>, - tx_notify_ui: &UnboundedSender, -) { - let active_ids = { - active_operations - .read() - .await - .iter() - .filter_map(|(id, kind)| (*kind == OperationKind::Downloading).then_some(id.clone())) - .collect::>() - }; - if active_ids.is_empty() { - return; - } - +async fn handle_active_downloads_without_peers(ctx: &NetworkServiceCtx) { + let active_ids = ctx + .active_operations + .read() + .await + .iter() + .filter_map(|(id, kind)| (*kind == OperationKind::Downloading).then_some(id.clone())) + .collect::>(); for id in active_ids { - if peers_still_have_game(peer_game_db, &id).await { + if eligible_source_remains(ctx, &id).await { continue; } - - let cancelled = { - // An exclusive guard makes the check-and-cancel transition one-shot even when - // concurrent liveness checks remove the last peers at the same time. - let active_downloads = active_downloads.write().await; - let Some(cancel_token) = active_downloads.get(&id) else { + { + // Signalling is one-shot; the download task retains ownership of + // operation-map cleanup until every transfer worker has drained. + let active_downloads = ctx.active_downloads.read().await; + let Some(download) = active_downloads.get(&id) else { continue; }; - if cancel_token.is_cancelled() { - false - } else { - cancel_token.cancel(); - true - } - }; - if !cancelled { - continue; + download.cancel_sources_exhausted(); } - - events::send( - tx_notify_ui, - PeerEvent::DownloadGameFilesAllPeersGone { id: id.clone() }, - ); } } -async fn peers_still_have_game(peer_game_db: &Arc>, game_id: &str) -> bool { - let guard = peer_game_db.read().await; - !guard.peers_with_game(game_id).is_empty() +async fn eligible_source_remains(ctx: &NetworkServiceCtx, game_id: &str) -> bool { + let catalog = Arc::clone(&ctx.catalog); + let game_id_owned = game_id.to_owned(); + let Ok(manifest) = scoped_blocking(move || catalog.manifest(&game_id_owned)) else { + return false; + }; + let content_id = manifest.content_id(); + let endpoints = ctx + .peer_game_db + .read() + .await + .peer_endpoints_with_content(game_id, content_id); + has_nonquarantined_source(&endpoints, content_id, &ctx.content_quarantine) +} + +fn has_nonquarantined_source( + endpoints: &[lanspread_proto::PeerEndpoint], + content_id: lanspread_db::content_manifest::ContentId, + quarantine: &ContentQuarantine, +) -> bool { + endpoints + .iter() + .any(|endpoint| !quarantine.is_quarantined(endpoint, content_id)) } #[cfg(test)] mod tests { - use std::{collections::HashMap, sync::Arc}; + use lanspread_db::content_manifest::ContentId; + use lanspread_proto::{LibrarySnapshot, PeerEndpoint, PeerId, RuntimeSessionId}; - use tokio::sync::RwLock; - use tokio_util::sync::CancellationToken; + use super::*; + use crate::peer_db::PeerGameDB; - use super::handle_active_downloads_without_peers; - use crate::{PeerEvent, context::OperationKind, peer_db::PeerGameDB}; - - #[tokio::test] - async fn all_peers_gone_cancels_once_and_leaves_cleanup_to_download_owner() { - let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - let active_operations = Arc::new(RwLock::new(HashMap::from([( - "game".to_string(), - OperationKind::Downloading, - )]))); - let cancel = CancellationToken::new(); - let active_downloads = Arc::new(RwLock::new(HashMap::from([( - "game".to_string(), - cancel.clone(), - )]))); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - - handle_active_downloads_without_peers( - &peer_game_db, - &active_operations, - &active_downloads, - &tx, - ) - .await; - - assert!(cancel.is_cancelled()); - assert_eq!( - active_operations.read().await.get("game"), - Some(&OperationKind::Downloading) + #[tokio::test(start_paused = true)] + async fn dropped_hint_is_revision_checked_within_configured_bound() { + let endpoint = PeerEndpoint::new( + PeerId::from_bytes([1; 32]), + std::net::SocketAddr::from(([127, 0, 0, 1], 12001)), ); - assert!(active_downloads.read().await.contains_key("game")); + let mut interval = tokio::time::interval(Duration::from_secs(PEER_PING_INTERVAL_SECS)); + interval.tick().await; + tokio::time::advance(Duration::from_millis(1)).await; - let event = rx.recv().await.expect("peers-gone event should be emitted"); - assert!(matches!( - event, - PeerEvent::DownloadGameFilesAllPeersGone { id } if id == "game" - )); + let mut db = PeerGameDB::new(); + let ticket = db + .begin_candidate_negotiation(endpoint) + .expect("candidate should reserve"); + db.commit_authenticated_snapshot( + endpoint, + ticket, + RuntimeSessionId::from_bytes([1; 16]), + Some(LibrarySnapshot { + revision: 0, + games: Vec::new(), + }), + ) + .expect("peer should commit") + .expect("ticket should remain current"); + let start = tokio::time::Instant::now(); + + interval.tick().await; + let mut snapshot = db + .peer_liveness_for(&endpoint.peer_id) + .expect("peer should remain"); + snapshot.last_seen = tokio::time::Instant::now(); assert!( - rx.try_recv().is_err(), - "cancellation must not emit a premature active-operation snapshot" + !revision_check_due(&snapshot), + "a tick just before the idle threshold should not ping" ); - handle_active_downloads_without_peers( - &peer_game_db, - &active_operations, - &active_downloads, - &tx, - ) - .await; - + interval.tick().await; + let mut snapshot = db + .peer_liveness_for(&endpoint.peer_id) + .expect("peer should remain"); + // Simulate repeated inbound/content activity. It may refresh liveness, + // but must not touch the independently captured revision-check clock. + snapshot.last_seen = tokio::time::Instant::now(); + assert!(revision_check_due(&snapshot)); assert!( - rx.try_recv().is_err(), - "an already-cancelled download must not emit peers-gone twice" + start.elapsed() <= Duration::from_secs(PEER_PING_IDLE_SECS + PEER_PING_INTERVAL_SECS) ); } - #[tokio::test] - async fn all_peers_gone_cancels_multiple_downloads_without_releasing_admission() { - let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); - let first_cancel = CancellationToken::new(); - let second_cancel = CancellationToken::new(); - let active_operations = Arc::new(RwLock::new(HashMap::from([ - ("first".to_string(), OperationKind::Downloading), - ("second".to_string(), OperationKind::Downloading), - ("installing".to_string(), OperationKind::Installing), - ]))); - let active_downloads = Arc::new(RwLock::new(HashMap::from([ - ("first".to_string(), first_cancel.clone()), - ("second".to_string(), second_cancel.clone()), - ]))); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - - handle_active_downloads_without_peers( - &peer_game_db, - &active_operations, - &active_downloads, - &tx, + #[tokio::test(start_paused = true)] + async fn one_transient_ping_failure_never_removes_and_success_resets_failure_history() { + let endpoint = PeerEndpoint::new( + PeerId::from_bytes([9; 32]), + std::net::SocketAddr::from(([127, 0, 0, 1], 12009)), + ); + let mut db = PeerGameDB::new(); + let ticket = db + .begin_candidate_negotiation(endpoint) + .expect("candidate should reserve"); + db.commit_authenticated_snapshot( + endpoint, + ticket, + RuntimeSessionId::from_bytes([9; 16]), + Some(LibrarySnapshot { + revision: 0, + games: Vec::new(), + }), ) - .await; + .expect("peer should commit") + .expect("ticket should remain current"); + let probe = db + .peer_liveness_for(&endpoint.peer_id) + .expect("peer should exist"); - assert!(first_cancel.is_cancelled()); - assert!(second_cancel.is_cancelled()); - let operations = active_operations.read().await; - assert_eq!(operations.get("first"), Some(&OperationKind::Downloading)); - assert_eq!(operations.get("second"), Some(&OperationKind::Downloading)); - assert_eq!( - operations.get("installing"), - Some(&OperationKind::Installing) - ); - drop(operations); - let downloads = active_downloads.read().await; - assert!(downloads.contains_key("first")); - assert!(downloads.contains_key("second")); - drop(downloads); - - let mut cancelled_ids = Vec::new(); - for _ in 0..2 { - let event = rx.recv().await.expect("peers-gone event should be emitted"); - let PeerEvent::DownloadGameFilesAllPeersGone { id } = event else { - panic!("expected peers-gone event"); - }; - cancelled_ids.push(id); - } - cancelled_ids.sort(); - assert_eq!(cancelled_ids, vec!["first", "second"]); + tokio::time::advance(peer_stale_timeout() + Duration::from_secs(1)).await; + assert!(db.record_ping_failure_if_generation(probe)); assert!( - rx.try_recv().is_err(), - "multiple cancellations must not emit an active-operation snapshot" + db.stale_peer_liveness_snapshots(peer_stale_timeout()) + .is_empty(), + "one transport failure must preserve authenticated state" ); + + assert!(matches!( + db.observe_pong_if_generation( + probe, + lanspread_proto::PeerRevisions { + runtime_session_id: RuntimeSessionId::from_bytes([9; 16]), + library_revision: 0, + call_to_play_revision: 0, + }, + ), + crate::peer_db::PongObservation::Current + | crate::peer_db::PongObservation::RevisionMismatch + )); + let refreshed = db + .peer_liveness_for(&endpoint.peer_id) + .expect("successful Pong should preserve peer"); + assert_eq!(refreshed.consecutive_ping_failures, 0); + } + + #[test] + fn wrong_content_and_quarantined_sources_do_not_keep_download_alive() { + let expected = ContentId::from_bytes([7; 32]); + let endpoint = PeerEndpoint::new( + PeerId::from_bytes([2; 32]), + std::net::SocketAddr::from(([127, 0, 0, 1], 12002)), + ); + let quarantine = ContentQuarantine::default(); + + // Exact-content filtering happens before this seam, so a wrong-content + // peer produces the empty eligible endpoint set. + assert!(!has_nonquarantined_source(&[], expected, &quarantine)); + assert!(has_nonquarantined_source( + &[endpoint], + expected, + &quarantine + )); + quarantine.record_integrity_failure(&endpoint, expected); + assert!(!has_nonquarantined_source( + &[endpoint], + expected, + &quarantine + )); } } diff --git a/crates/lanspread-peer/src/services/local_monitor.rs b/crates/lanspread-peer/src/services/local_monitor.rs index 0e75adf..4418e28 100644 --- a/crates/lanspread-peer/src/services/local_monitor.rs +++ b/crates/lanspread-peer/src/services/local_monitor.rs @@ -1,28 +1,63 @@ //! Local game directory monitor. use std::{ - collections::HashSet, - path::{Component, Path, PathBuf}, + any::Any, + collections::{BTreeMap, BTreeSet, HashSet}, + ffi::OsString, + fs, + io, + panic::AssertUnwindSafe, + path::{Path, PathBuf}, sync::Arc, - time::Duration, + time::SystemTime, }; -use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use tokio::sync::{RwLock, mpsc::UnboundedSender}; +use futures::FutureExt; +use tokio::{ + sync::{RwLock, mpsc::UnboundedSender}, + task::{JoinError, JoinSet}, + time::{Instant, MissedTickBehavior}, +}; use crate::{ PeerEvent, - config::LOCAL_GAME_FALLBACK_SCAN_SECS, + config::{LOCAL_GAME_FALLBACK_SCAN_SECS, LOCAL_GAME_POLL_INTERVAL_SECS}, context::Ctx, game_paths::{is_download_protected_root_name, is_ignored_games_root_name}, handlers::update_and_announce_games, - local_games::{rescan_local_game, scan_local_library}, + local_games::{ + rescan_local_game_with_recovery_failures, + scan_local_library_with_recovery_failures, + }, + scoped_blocking::scoped_blocking, }; -struct WatchState { - watcher: RecommendedWatcher, +#[derive(Debug, Eq, PartialEq)] +struct PollSnapshot { game_dir: PathBuf, - watched: HashSet, + games: BTreeMap, +} + +#[derive(Debug, Eq, PartialEq)] +enum GameRootSnapshot { + Readable(BTreeMap), + NonDirectory(EntryFingerprint), + Unreadable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct EntryFingerprint { + kind: EntryKind, + len: u64, + modified: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EntryKind { + File, + Directory, + LinkOrReparse, + Other, } #[derive(Clone, Default)] @@ -36,202 +71,284 @@ pub async fn run_local_game_monitor( tx_notify_ui: UnboundedSender, ctx: Ctx, ) -> eyre::Result<()> { - log::info!("Starting notify-based local game directory monitor"); + log::info!("Starting polling-based local game directory monitor"); - let (watch_tx, mut watch_rx) = tokio::sync::mpsc::unbounded_channel::>(); - let mut watch_state = build_watch_state(&ctx, watch_tx.clone()).await; + let mut snapshot = initial_poll_snapshot(&ctx).await; let gate = RescanGate::default(); - let mut fallback_interval = - tokio::time::interval(Duration::from_secs(LOCAL_GAME_FALLBACK_SCAN_SECS)); + let mut rescans = JoinSet::new(); + let now = Instant::now(); + let poll_period = std::time::Duration::from_secs(LOCAL_GAME_POLL_INTERVAL_SECS); + let fallback_period = std::time::Duration::from_secs(LOCAL_GAME_FALLBACK_SCAN_SECS); + let mut poll_interval = tokio::time::interval_at(now + poll_period, poll_period); + let mut fallback_interval = tokio::time::interval_at(now + fallback_period, fallback_period); + poll_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + fallback_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - loop { - tokio::select! { - () = ctx.shutdown.cancelled() => return Ok(()), - _ = fallback_interval.tick() => { - run_fallback_scan(&ctx, &tx_notify_ui).await; - reconcile_watch_state(&ctx, &mut watch_state, watch_tx.clone()).await; - } - Some(event) = watch_rx.recv() => { - handle_watch_event( - &ctx, - &tx_notify_ui, - &gate, - event, - ).await; - reconcile_watch_state(&ctx, &mut watch_state, watch_tx.clone()).await; + let loop_outcome = AssertUnwindSafe(async { + loop { + tokio::select! { + biased; + () = ctx.shutdown.cancelled() => break, + result = rescans.join_next(), if !rescans.is_empty() => { + if let Some(result) = result { + log_rescan_join(result); + } + } + _ = poll_interval.tick() => { + poll_local_game_changes( + &ctx, + &tx_notify_ui, + &gate, + &mut rescans, + &mut snapshot, + ).await; + } + _ = fallback_interval.tick() => { + run_fallback_scan(&ctx, &tx_notify_ui).await; + } } } - } -} - -async fn build_watch_state( - ctx: &Ctx, - watch_tx: tokio::sync::mpsc::UnboundedSender>, -) -> Option { - let game_dir = ctx.game_dir.read().await.clone(); - let mut fs_watcher = match RecommendedWatcher::new( - move |result| { - let _ = watch_tx.send(result); - }, - Config::default(), - ) { - Ok(watcher) => watcher, - Err(err) => { - log::warn!("Filesystem watcher unavailable; falling back to periodic scans: {err}"); - return None; - } - }; - - let watched_paths = match watch_game_roots(&mut fs_watcher, &game_dir).await { - Ok(paths) => paths, - Err(err) => { - log::warn!( - "Failed to initialize filesystem watcher for {}: {err}; falling back to periodic scans", - game_dir.display() - ); - return None; - } - }; - - Some(WatchState { - watcher: fs_watcher, - game_dir, - watched: watched_paths, + Ok(()) }) + .catch_unwind() + .await; + + finish_monitor_loop(loop_outcome, &mut rescans, &gate).await } -async fn reconcile_watch_state( - ctx: &Ctx, - watch_state: &mut Option, - watch_tx: tokio::sync::mpsc::UnboundedSender>, -) { - let current_game_dir = ctx.game_dir.read().await.clone(); - if watch_state - .as_ref() - .is_none_or(|state| state.game_dir != current_game_dir) - { - *watch_state = build_watch_state(ctx, watch_tx).await; - return; - } +type MonitorLoopOutcome = std::thread::Result>; - if let Some(state) = watch_state - && let Err(err) = reconcile_game_root_watches(state).await - { - log::warn!( - "Failed to reconcile filesystem watches for {}: {err}", - state.game_dir.display() - ); +async fn finish_monitor_loop( + loop_outcome: MonitorLoopOutcome, + rescans: &mut JoinSet<()>, + gate: &RescanGate, +) -> eyre::Result<()> { + // Stop admitting polls, then wait for every lexically owned rescan on every + // loop exit, including an unwind. Poll snapshots execute inline and + // therefore cannot outlive this task. + drain_rescans(rescans, gate).await; + + match loop_outcome { + Ok(result) => result, + Err(payload) => Err(eyre::eyre!( + "Local game monitor loop panicked: {}", + describe_panic(payload.as_ref()) + )), } } -async fn watch_game_roots( - watcher: &mut RecommendedWatcher, - game_dir: &Path, -) -> eyre::Result> { - let mut watched_paths = HashSet::new(); - watch_path(watcher, game_dir, &mut watched_paths)?; - - for root in list_game_roots(game_dir).await? { - watch_path(watcher, &root, &mut watched_paths)?; - } - - Ok(watched_paths) +fn describe_panic(payload: &(dyn Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("non-string panic payload") } -async fn reconcile_game_root_watches(state: &mut WatchState) -> eyre::Result<()> { - let desired = { - let mut desired = HashSet::from([state.game_dir.clone()]); - desired.extend(list_game_roots(&state.game_dir).await?); - desired - }; +fn log_rescan_join(result: Result<(), JoinError>) { + if let Err(error) = result { + log::error!("Local game rescan task failed: {error}"); + } +} - let stale_paths = state - .watched - .difference(&desired) - .cloned() - .collect::>(); - for path in stale_paths { - if let Err(err) = state.watcher.unwatch(&path) { - log::debug!("Failed to unwatch {}: {err}", path.display()); +async fn drain_rescans(rescans: &mut JoinSet<()>, gate: &RescanGate) { + while let Some(result) = rescans.join_next().await { + log_rescan_join(result); + } + gate.running.write().await.clear(); + gate.pending.write().await.clear(); +} + +async fn initial_poll_snapshot(ctx: &Ctx) -> Option { + match capture_poll_snapshot(ctx).await { + Ok(snapshot) => Some(snapshot), + Err(error) => { + log::warn!("Failed to initialize local game polling snapshot: {error}"); + None } - state.watched.remove(&path); } - - let new_paths = desired - .difference(&state.watched) - .cloned() - .collect::>(); - for path in new_paths { - watch_path(&mut state.watcher, &path, &mut state.watched)?; - } - - Ok(()) } -fn watch_path( - watcher: &mut RecommendedWatcher, - path: &Path, - watched_paths: &mut HashSet, -) -> notify::Result<()> { - watcher.watch(path, RecursiveMode::NonRecursive)?; - watched_paths.insert(path.to_path_buf()); - Ok(()) -} - -async fn list_game_roots(game_dir: &Path) -> eyre::Result> { - let mut roots = Vec::new(); - let mut entries = match tokio::fs::read_dir(game_dir).await { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(roots), - Err(err) => return Err(err.into()), - }; - - while let Some(entry) = entries.next_entry().await? { - if !entry.file_type().await?.is_dir() { - continue; - } - let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { - continue; - }; - if is_ignored_games_root_name(&name) { - continue; - } - roots.push(entry.path()); - } - Ok(roots) -} - -async fn handle_watch_event( +async fn poll_local_game_changes( ctx: &Ctx, tx_notify_ui: &UnboundedSender, gate: &RescanGate, - event: notify::Result, + rescans: &mut JoinSet<()>, + previous: &mut Option, ) { - let event = match event { - Ok(event) => event, - Err(err) => { - log::warn!("Filesystem watcher event error: {err}"); + let current = match capture_poll_snapshot(ctx).await { + Ok(snapshot) => snapshot, + Err(error) => { + log::warn!("Failed to poll local game directory: {error}"); return; } }; - if matches!(event.kind, EventKind::Access(_)) { - return; - } + let changed_ids = advance_poll_snapshot(previous, current); + queue_changed_games(ctx, tx_notify_ui, gate, rescans, changed_ids).await; +} +async fn capture_poll_snapshot(ctx: &Ctx) -> io::Result { + // Serialize the root read with SetGameDir so one snapshot cannot combine + // entries from two configured roots. The blocking traversal stays scoped + // to this task and is complete before the admission guard is released. + let _admission = ctx.operation_admission.lock().await; let game_dir = ctx.game_dir.read().await.clone(); - let ids = event - .paths - .iter() - .filter_map(|path| game_id_from_event_path(&game_dir, path)) - .collect::>(); + scoped_blocking(|| snapshot_game_directory(&game_dir)) +} - for id in ids { - if ctx.active_operations.read().await.contains_key(&id) { - log::debug!("Dropping filesystem event for {id}: operation active"); +fn snapshot_game_directory(game_dir: &Path) -> io::Result { + let mut games = BTreeMap::new(); + let entries = match fs::read_dir(game_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(PollSnapshot { + game_dir: game_dir.to_path_buf(), + games, + }); + } + Err(error) => return Err(error), + }; + + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + let Some(id) = name.to_str() else { + continue; + }; + if is_ignored_games_root_name(id) { continue; } - queue_rescan(ctx, tx_notify_ui, gate, id).await; + + let game_root = match snapshot_game_root(&entry.path()) { + Ok(Some(snapshot)) => snapshot, + Ok(None) => continue, + Err(error) => { + log::debug!( + "Could not snapshot local game root {}: {error}", + entry.path().display() + ); + GameRootSnapshot::Unreadable + } + }; + games.insert(id.to_owned(), game_root); + } + + Ok(PollSnapshot { + game_dir: game_dir.to_path_buf(), + games, + }) +} + +fn snapshot_game_root(game_root: &Path) -> io::Result> { + let root_fingerprint = match fingerprint_entry(game_root) { + Ok(fingerprint) => fingerprint, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if root_fingerprint.kind != EntryKind::Directory { + return Ok(Some(GameRootSnapshot::NonDirectory(root_fingerprint))); + } + + let entries = match fs::read_dir(game_root) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let mut fingerprints = BTreeMap::new(); + + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + if name.to_str().is_some_and(is_download_protected_root_name) { + continue; + } + fingerprints.insert(name, fingerprint_entry(&entry.path())?); + } + + Ok(Some(GameRootSnapshot::Readable(fingerprints))) +} + +fn fingerprint_entry(path: &Path) -> io::Result { + let metadata = fs::symlink_metadata(path)?; + let file_type = metadata.file_type(); + let kind = if file_type.is_symlink() || is_windows_reparse_point(&metadata) { + EntryKind::LinkOrReparse + } else if file_type.is_file() { + EntryKind::File + } else if file_type.is_dir() { + EntryKind::Directory + } else { + EntryKind::Other + }; + + Ok(EntryFingerprint { + kind, + len: metadata.len(), + modified: metadata.modified().ok(), + }) +} + +#[cfg(target_os = "windows")] +fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(target_os = "windows"))] +const fn is_windows_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn advance_poll_snapshot( + previous: &mut Option, + current: PollSnapshot, +) -> BTreeSet { + let changed_ids = previous + .as_ref() + .filter(|snapshot| snapshot.game_dir == current.game_dir) + .map_or_else(BTreeSet::new, |snapshot| { + changed_game_ids(snapshot, ¤t) + }); + *previous = Some(current); + changed_ids +} + +fn changed_game_ids(previous: &PollSnapshot, current: &PollSnapshot) -> BTreeSet { + previous + .games + .keys() + .chain(current.games.keys()) + .filter(|id| previous.games.get(*id) != current.games.get(*id)) + .cloned() + .collect() +} + +async fn queue_changed_games( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + gate: &RescanGate, + rescans: &mut JoinSet<()>, + changed_ids: BTreeSet, +) { + let active_operations = ctx.active_operations.read().await; + let ready_ids = changed_ids + .into_iter() + .filter(|id| { + if active_operations.contains_key(id) { + log::debug!("Ignoring polled filesystem change for {id}: operation active"); + false + } else { + true + } + }) + .collect::>(); + drop(active_operations); + + for id in ready_ids { + queue_rescan(ctx, tx_notify_ui, gate, rescans, id).await; } } @@ -239,6 +356,7 @@ async fn queue_rescan( ctx: &Ctx, tx_notify_ui: &UnboundedSender, gate: &RescanGate, + rescans: &mut JoinSet<()>, id: String, ) { { @@ -253,7 +371,7 @@ async fn queue_rescan( let ctx = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); let gate = gate.clone(); - ctx.task_tracker.clone().spawn(async move { + rescans.spawn(async move { run_gated_rescan(ctx, tx_notify_ui, gate, id).await; }); } @@ -267,13 +385,27 @@ async fn run_gated_rescan( loop { gate.pending.write().await.remove(&id); + // SetGameDir holds this barrier through recovery, scan, publication, + // and quarantine settlement. A monitor scan therefore cannot publish + // a pre-recovery projection afterward. + let _admission = ctx.operation_admission.lock().await; + if ctx.active_operations.read().await.contains_key(&id) { break; } let game_dir = ctx.game_dir.read().await.clone(); - let catalog = ctx.catalog.read().await.clone(); - match rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, &id).await { + let catalog = ctx.catalog.catalog(); + let failed_ids = ctx.recovery_quarantine.failed_ids(&game_dir); + match rescan_local_game_with_recovery_failures( + &game_dir, + ctx.state_dir.as_ref(), + catalog, + &id, + &failed_ids, + ) + .await + { Ok(scan) => update_and_announce_games(&ctx, &tx_notify_ui, scan).await, Err(err) => log::error!("Failed to rescan local game {id}: {err}"), } @@ -287,42 +419,23 @@ async fn run_gated_rescan( } async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { + let _admission = ctx.operation_admission.lock().await; let game_dir = ctx.game_dir.read().await.clone(); - let catalog = ctx.catalog.read().await.clone(); - match scan_local_library(&game_dir, ctx.state_dir.as_ref(), &catalog).await { + let catalog = ctx.catalog.catalog(); + let failed_ids = ctx.recovery_quarantine.failed_ids(&game_dir); + match scan_local_library_with_recovery_failures( + &game_dir, + ctx.state_dir.as_ref(), + catalog, + &failed_ids, + ) + .await + { Ok(scan) => update_and_announce_games(ctx, tx_notify_ui, scan).await, Err(err) => log::error!("Failed to scan local games directory: {err}"), } } -fn game_id_from_event_path(game_dir: &Path, path: &Path) -> Option { - let relative = path.strip_prefix(game_dir).ok()?; - let mut components = relative.components(); - let game_id = component_name(components.next()?)?; - if is_ignored_games_root_name(game_id) { - return None; - } - - if let Some(second) = components.next().and_then(component_name) - && should_ignore_game_child(second) - { - return None; - } - - Some(game_id.to_string()) -} - -fn component_name(component: Component<'_>) -> Option<&str> { - match component { - Component::Normal(name) => name.to_str(), - _ => None, - } -} - -fn should_ignore_game_child(name: &str) -> bool { - is_download_protected_root_name(name) -} - #[cfg(test)] mod tests { use std::{ @@ -331,11 +444,7 @@ mod tests { time::Duration, }; - use lanspread_db::db::GameCatalog; - use notify::{ - EventKind, - event::{AccessKind, AccessMode}, - }; + use lanspread_db::content_manifest::CatalogBundle; use tokio::sync::{RwLock, mpsc}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; @@ -344,14 +453,21 @@ mod tests { UnpackFuture, Unpacker, context::OperationKind, + identity::PeerIdentity, + network_generation::NetworkControl, peer_db::PeerGameDB, - test_support::TempDir, + test_support::{TempDir, catalog_bundle, empty_catalog_bundle}, }; struct NoopUnpacker; impl Unpacker for NoopUnpacker { - fn unpack<'a>(&'a self, _archive: &'a Path, _dest: &'a Path) -> UnpackFuture<'a> { + fn unpack<'a>( + &'a self, + _archive: &'a Path, + _dest: &'a Path, + _cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { Box::pin(async { Ok(()) }) } } @@ -363,24 +479,37 @@ mod tests { std::fs::write(path, bytes).expect("file should be written"); } - fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> Ctx { + fn test_ctx(game_dir: PathBuf, catalog: Arc) -> Ctx { let state_dir = game_dir.join(".test-state"); - Ctx::new( + let recovery_root = game_dir.clone(); + let ctx = Ctx::new( Arc::new(RwLock::new(PeerGameDB::new())), - "peer".to_string(), + Arc::new(PeerIdentity::generate().expect("test identity should generate")), game_dir, state_dir, Arc::new(NoopUnpacker), CancellationToken::new(), TaskTracker::new(), - Arc::new(RwLock::new(catalog)), + catalog, Arc::new(RwLock::new(std::collections::HashMap::new())), Arc::new(crate::NoopStreamInstallProvider), + NetworkControl::disabled_for_test(), ) + .expect("test context should initialize"); + assert!( + ctx.recovery_quarantine + .settle(&recovery_root, HashSet::new()) + ); + ctx } - fn watch_event(path: PathBuf) -> Event { - Event::new(EventKind::Any).add_path(path) + fn snapshot(game_dir: &Path) -> PollSnapshot { + snapshot_game_directory(game_dir).expect("poll snapshot should succeed") + } + + async fn injected_monitor_loop_panic() -> eyre::Result<()> { + tokio::task::yield_now().await; + panic!("injected monitor loop panic"); } async fn recv_local_update( @@ -397,134 +526,164 @@ mod tests { } #[test] - fn event_paths_map_to_top_level_game_id() { - let root = std::path::Path::new("/games"); + fn first_snapshot_and_configured_root_change_establish_baselines() { + let first = TempDir::new("lanspread-local-monitor-first-root"); + let second = TempDir::new("lanspread-local-monitor-second-root"); + write_file(&first.path().join("game/version.ini"), b"20250101"); + write_file(&second.path().join("other/version.ini"), b"20250101"); + + let mut state = None; + assert!(advance_poll_snapshot(&mut state, snapshot(first.path())).is_empty()); + assert!(advance_poll_snapshot(&mut state, snapshot(second.path())).is_empty()); assert_eq!( - game_id_from_event_path(root, std::path::Path::new("/games/aoe2/version.ini")) - .as_deref(), - Some("aoe2") - ); - assert_eq!( - game_id_from_event_path(root, std::path::Path::new("/games/aoe2/local/save.dat")), - None - ); - assert_eq!( - game_id_from_event_path(root, std::path::Path::new("/games/.lanspread/index.json")), - None + state.as_ref().map(|state| state.game_dir.as_path()), + Some(second.path()) ); } #[test] - fn event_ignore_list_covers_reserved_names() { - for name in [ - "local", - ".local.installing", - ".local.backup", - ".version.ini.tmp", - ".version.ini.discarded", - ".lanspread", - ".lanspread.json", - ".sync", - ".softlan_game_installed", - ] { - assert!(should_ignore_game_child(name)); - } - assert!(!should_ignore_game_child("version.ini")); - assert!(!should_ignore_game_child("game.eti")); + fn snapshot_diff_detects_game_change_and_disappearance() { + let temp = TempDir::new("lanspread-local-monitor-change"); + let version = temp.path().join("game/version.ini"); + write_file(&version, b"20250101"); + let mut state = Some(snapshot(temp.path())); + + write_file(&version, b"202501010"); + assert_eq!( + advance_poll_snapshot(&mut state, snapshot(temp.path())), + BTreeSet::from(["game".to_string()]) + ); + + std::fs::remove_dir_all(temp.path().join("game")).expect("game root should be removable"); + assert_eq!( + advance_poll_snapshot(&mut state, snapshot(temp.path())), + BTreeSet::from(["game".to_string()]), + "a vanished directory must retain the prior game ID for its removal rescan" + ); + } + + #[test] + fn snapshot_diff_detects_new_non_directory_game_root_shape() { + let temp = TempDir::new("lanspread-local-monitor-unsafe-root"); + let mut state = Some(snapshot(temp.path())); + + write_file(&temp.path().join("game"), b"not a directory"); + + assert_eq!( + advance_poll_snapshot(&mut state, snapshot(temp.path())), + BTreeSet::from(["game".to_string()]) + ); + } + + #[test] + fn snapshot_diff_ignores_protected_game_state_and_library_state() { + let temp = TempDir::new("lanspread-local-monitor-ignore"); + write_file(&temp.path().join("game/version.ini"), b"20250101"); + let mut state = Some(snapshot(temp.path())); + + write_file(&temp.path().join("game/local/save.dat"), b"save"); + write_file( + &temp.path().join("game/.local.installing/staged.dat"), + b"staged", + ); + write_file(&temp.path().join(".lanspread/library_index.json"), b"index"); + + assert!(advance_poll_snapshot(&mut state, snapshot(temp.path())).is_empty()); } #[tokio::test] - async fn watch_event_for_active_game_is_dropped() { + async fn polled_change_for_active_game_is_dropped() { let temp = TempDir::new("lanspread-local-monitor"); let ctx = test_ctx( temp.path().to_path_buf(), - GameCatalog::from_ids(["game".to_string()]), + catalog_bundle([("game", "20250101")]), ); ctx.active_operations .write() .await .insert("game".to_string(), OperationKind::Downloading); - let gate = RescanGate::default(); + let rescan_gate = RescanGate::default(); + let mut rescans = JoinSet::new(); let (tx, mut rx) = mpsc::unbounded_channel(); - handle_watch_event( + queue_changed_games( &ctx, &tx, - &gate, - Ok(watch_event(temp.path().join("game").join("version.ini"))), + &rescan_gate, + &mut rescans, + BTreeSet::from(["game".to_string()]), ) .await; - ctx.task_tracker.close(); - ctx.task_tracker.wait().await; assert!( tokio::time::timeout(Duration::from_millis(50), rx.recv()) .await .is_err(), - "active game event should not schedule a UI update" + "an active game change should not schedule a UI update" ); - assert!(gate.running.read().await.is_empty()); - assert!(gate.pending.read().await.is_empty()); + assert!(rescans.is_empty()); + assert!(rescan_gate.running.read().await.is_empty()); + assert!(rescan_gate.pending.read().await.is_empty()); } #[tokio::test] - async fn access_watch_event_is_ignored() { + async fn polling_detects_sideload_and_runs_per_game_rescan() { let temp = TempDir::new("lanspread-local-monitor"); - write_file(&temp.path().join("game").join("version.ini"), b"20250101"); let ctx = test_ctx( temp.path().to_path_buf(), - GameCatalog::from_ids(["game".to_string()]), + catalog_bundle([("game", "20250101")]), ); - let gate = RescanGate::default(); + let rescan_gate = RescanGate::default(); + let mut rescans = JoinSet::new(); let (tx, mut rx) = mpsc::unbounded_channel(); - - handle_watch_event( - &ctx, - &tx, - &gate, - Ok( - Event::new(EventKind::Access(AccessKind::Close(AccessMode::Read))) - .add_path(temp.path().join("game").join("version.ini")), - ), - ) - .await; - ctx.task_tracker.close(); - ctx.task_tracker.wait().await; - - assert!( - tokio::time::timeout(Duration::from_millis(50), rx.recv()) + let mut state = Some( + capture_poll_snapshot(&ctx) .await - .is_err(), - "access events should not schedule a UI update" + .expect("initial snapshot should succeed"), ); - assert!(gate.running.read().await.is_empty()); - assert!(gate.pending.read().await.is_empty()); + + write_file(&temp.path().join("game/version.ini"), b"20250101"); + poll_local_game_changes(&ctx, &tx, &rescan_gate, &mut rescans, &mut state).await; + drain_rescans(&mut rescans, &rescan_gate).await; + + let games = recv_local_update(&mut rx).await; + let game = games + .iter() + .find(|game| game.id == "game") + .expect("sideloaded catalog game should be emitted"); + assert!(game.downloaded); + assert!(!game.installed); + assert!(rescans.is_empty()); + assert!(rescan_gate.running.read().await.is_empty()); + assert!(rescan_gate.pending.read().await.is_empty()); } #[tokio::test] - async fn burst_watch_events_collapse_to_two_rescans_for_same_game() { + async fn burst_poll_changes_collapse_to_two_rescans_for_same_game() { let temp = TempDir::new("lanspread-local-monitor"); let game_root = temp.path().join("game"); write_file(&game_root.join("version.ini"), b"20250101"); let ctx = test_ctx( temp.path().to_path_buf(), - GameCatalog::from_ids(["game".to_string()]), + catalog_bundle([("game", "20250101")]), ); let gate = RescanGate::default(); + let mut rescans = JoinSet::new(); let (tx, mut rx) = mpsc::unbounded_channel(); let library_guard = ctx.local_library.write().await; - queue_rescan(&ctx, &tx, &gate, "game".to_string()).await; + queue_rescan(&ctx, &tx, &gate, &mut rescans, "game".to_string()).await; tokio::time::sleep(Duration::from_millis(20)).await; for _ in 0..5 { - queue_rescan(&ctx, &tx, &gate, "game".to_string()).await; + queue_rescan(&ctx, &tx, &gate, &mut rescans, "game".to_string()).await; } assert_eq!(gate.pending.read().await.len(), 1); drop(library_guard); - ctx.task_tracker.close(); - ctx.task_tracker.wait().await; + while let Some(result) = rescans.join_next().await { + result.expect("rescan task should finish successfully"); + } let mut update_count = 0; while let Ok(Some(PeerEvent::LocalLibraryChanged { .. })) = @@ -538,13 +697,121 @@ mod tests { ); } + #[tokio::test] + async fn rescan_shutdown_waits_for_blocked_children_to_finish() { + let temp = TempDir::new("lanspread-local-monitor-shutdown"); + write_file(&temp.path().join("game/version.ini"), b"20250101"); + let ctx = test_ctx( + temp.path().to_path_buf(), + catalog_bundle([("game", "20250101")]), + ); + let gate = RescanGate::default(); + let mut rescans = JoinSet::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let admission = ctx.operation_admission.lock().await; + + queue_rescan(&ctx, &tx, &gate, &mut rescans, "game".to_string()).await; + tokio::task::yield_now().await; + + assert_eq!(rescans.len(), 1); + assert!(gate.running.read().await.contains("game")); + + let gate_after_shutdown = gate.clone(); + let shutdown = tokio::spawn(async move { + drain_rescans(&mut rescans, &gate_after_shutdown).await; + (rescans, gate_after_shutdown) + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + !shutdown.is_finished(), + "shutdown must remain pending while a rescan child owns unfinished work" + ); + + drop(admission); + let (rescans, gate) = tokio::time::timeout(Duration::from_secs(5), shutdown) + .await + .expect("shutdown should finish after its child") + .expect("shutdown task should not panic"); + assert!(rescans.is_empty()); + assert!(gate.running.read().await.is_empty()); + assert!(gate.pending.read().await.is_empty()); + + let games = recv_local_update(&mut rx).await; + assert_eq!(games.len(), 1); + assert_eq!(games[0].id, "game"); + } + + #[tokio::test] + async fn monitor_loop_panic_drains_owned_rescans_before_returning_error() { + let temp = TempDir::new("lanspread-local-monitor-panic"); + write_file(&temp.path().join("game/version.ini"), b"20250101"); + let ctx = test_ctx( + temp.path().to_path_buf(), + catalog_bundle([("game", "20250101")]), + ); + let gate = RescanGate::default(); + let mut rescans = JoinSet::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let admission = ctx.operation_admission.lock().await; + + queue_rescan(&ctx, &tx, &gate, &mut rescans, "game".to_string()).await; + tokio::task::yield_now().await; + let loop_outcome = AssertUnwindSafe(injected_monitor_loop_panic()) + .catch_unwind() + .await; + + let gate_after_panic = gate.clone(); + let finishing = tokio::spawn(async move { + let result = finish_monitor_loop(loop_outcome, &mut rescans, &gate_after_panic).await; + (result, rescans, gate_after_panic) + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + !finishing.is_finished(), + "a caught loop panic must not abort an unfinished rescan child" + ); + + drop(admission); + let (result, rescans, gate) = tokio::time::timeout(Duration::from_secs(5), finishing) + .await + .expect("panic epilogue should finish after its child") + .expect("panic epilogue task should not panic"); + let error = result.expect_err("caught loop panic should become an error report"); + assert!(error.to_string().contains("injected monitor loop panic")); + assert!(rescans.is_empty()); + assert!(gate.running.read().await.is_empty()); + assert!(gate.pending.read().await.is_empty()); + + let games = recv_local_update(&mut rx).await; + assert_eq!(games.len(), 1); + assert_eq!(games[0].id, "game"); + } + + #[tokio::test] + async fn monitor_shutdown_returns_without_background_children() { + let temp = TempDir::new("lanspread-local-monitor-structured-shutdown"); + let ctx = test_ctx(temp.path().to_path_buf(), empty_catalog_bundle()); + let monitor_ctx = ctx.clone(); + let (tx, _rx) = mpsc::unbounded_channel(); + let monitor = tokio::spawn(run_local_game_monitor(tx, monitor_ctx)); + + tokio::task::yield_now().await; + ctx.shutdown.cancel(); + + tokio::time::timeout(Duration::from_secs(2), monitor) + .await + .expect("monitor shutdown must not wait for an unowned backend") + .expect("monitor task should not panic") + .expect("monitor should stop successfully"); + } + #[tokio::test] async fn fallback_scan_picks_up_sideloaded_catalog_game() { let temp = TempDir::new("lanspread-local-monitor"); write_file(&temp.path().join("game").join("version.ini"), b"20250101"); let ctx = test_ctx( temp.path().to_path_buf(), - GameCatalog::from_ids(["game".to_string()]), + catalog_bundle([("game", "20250101")]), ); let (tx, mut rx) = mpsc::unbounded_channel(); @@ -568,7 +835,7 @@ mod tests { ); let ctx = test_ctx( temp.path().to_path_buf(), - GameCatalog::from_ids(["game".to_string()]), + catalog_bundle([("game", "20250101")]), ); let (tx, mut rx) = mpsc::unbounded_channel(); @@ -582,7 +849,6 @@ mod tests { ); let library = ctx.local_library.read().await; assert!(library.games.is_empty()); - assert!(library.recent_deltas.is_empty()); assert!( !temp .path() diff --git a/crates/lanspread-peer/src/services/remote_state.rs b/crates/lanspread-peer/src/services/remote_state.rs new file mode 100644 index 0000000..ba6afc2 --- /dev/null +++ b/crates/lanspread-peer/src/services/remote_state.rs @@ -0,0 +1,510 @@ +//! Preparation and atomic commit of responder-owned peer state. + +use std::sync::Arc; + +use lanspread_proto::{ + LibrarySnapshot, + PeerEndpoint, + PeerId, + PeerRevisions, + PeerStateSnapshot, + RuntimeSessionId, +}; +use tokio::sync::{RwLock, mpsc::UnboundedSender}; +use tokio_util::sync::CancellationToken; + +use crate::{ + CallToPlayView, + PeerEvent, + call_to_play::{ + CallToPlayPublication, + CallToPlayStore, + ObserveRemoteAuthorOutcome, + PreparedCallToPlayPublication, + PreparedRemoteAuthor, + }, + context::{Ctx, NetworkServiceCtx, PeerCtx}, + events, + library::build_library_snapshot, + network::exchange_hello, + peer_db::{ + PeerEndpointGeneration, + PeerGameDB, + PeerLivenessSnapshot, + PeerNegotiationTicket, + PeerUpsert, + PongObservation, + RefreshReservation, + RetiredPeerEndpoint, + }, + quic_runtime::QuicConnector, + services::StateSyncHandle, +}; + +#[derive(Clone)] +pub(crate) struct RemoteStateCtx { + local_peer_id: PeerId, + peer_game_db: Arc>, + tx_notify_ui: UnboundedSender, + call_to_play: Arc>, + quic: QuicConnector, + cancellation: CancellationToken, + state_sync: StateSyncHandle, +} + +impl RemoteStateCtx { + #[must_use] + pub(crate) fn from_network( + ctx: &NetworkServiceCtx, + tx_notify_ui: &UnboundedSender, + ) -> Self { + Self { + local_peer_id: ctx.peer_id, + peer_game_db: ctx.peer_game_db.clone(), + tx_notify_ui: tx_notify_ui.clone(), + call_to_play: ctx.call_to_play.clone(), + quic: ctx.quic.clone(), + cancellation: ctx.shutdown.clone(), + state_sync: ctx.state_sync.clone(), + } + } + + #[must_use] + pub(crate) fn with_cancellation(mut self, cancellation: CancellationToken) -> Self { + self.cancellation = cancellation; + self + } + + pub(crate) async fn reserve_candidate( + &self, + endpoint: PeerEndpoint, + ) -> eyre::Result { + self.peer_game_db + .write() + .await + .begin_candidate_negotiation(endpoint) + } + + pub(crate) async fn begin_peer_refresh( + &self, + snapshot: PeerLivenessSnapshot, + ) -> eyre::Result { + self.peer_game_db.write().await.begin_peer_refresh(snapshot) + } + + pub(crate) async fn peer_liveness_for(&self, peer_id: PeerId) -> Option { + self.peer_game_db.read().await.peer_liveness_for(&peer_id) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PongCommit { + StaleGeneration, + Current, + NeedsPull { new_session: bool }, +} + +/// Captures local revision state for a pinned Pong response. +pub(super) async fn local_revisions(ctx: &PeerCtx) -> eyre::Result { + // Keep the local-library -> CTP lock order through publication enqueue. + // A remote DB -> CTP commit therefore cannot publish a newer view before + // this responder publication reaches the ordered UI channel. + let library = ctx.local_library.read().await; + let mut call_to_play = ctx.call_to_play.write().await; + let (call_to_play_revision, publication) = call_to_play.current_responder_state()?; + let library_revision = library.revision; + if let Some(publication) = publication { + enqueue_call_to_play_publication(publication, &ctx.tx_notify_ui, &ctx.state_sync, false); + } + Ok(PeerRevisions { + runtime_session_id: ctx.runtime_session_id, + library_revision, + call_to_play_revision, + }) +} + +/// Captures both local domains in lock order, then resolves catalog identities +/// from the manifest cache preloaded before network admission. +pub(super) async fn local_snapshot(ctx: &PeerCtx) -> eyre::Result { + let library = ctx.local_library.read().await; + let mut call_to_play_store = ctx.call_to_play.write().await; + let library_publication = library.publication(ctx.catalog.catalog()); + let (call_to_play, _call_to_play_revision, publication) = + call_to_play_store.local_responder_snapshot()?; + if let Some(publication) = publication { + enqueue_call_to_play_publication(publication, &ctx.tx_notify_ui, &ctx.state_sync, false); + } + drop(call_to_play_store); + drop(library); + let library = build_library_snapshot(library_publication, &ctx.catalog)?; + Ok(PeerStateSnapshot { + runtime_session_id: ctx.runtime_session_id, + library, + call_to_play, + }) +} + +/// Performs a pinned full pull and commits its independently validated domains. +pub(crate) async fn pull_and_commit( + ctx: &RemoteStateCtx, + endpoint: PeerEndpoint, + ticket: PeerNegotiationTicket, +) -> eyre::Result { + if endpoint.peer_id == ctx.local_peer_id { + return Ok(false); + } + let generation = ticket.endpoint_generation(); + let snapshot = exchange_hello(&ctx.quic, &endpoint, &ctx.cancellation).await?; + let prepared = PreparedSnapshot::prepare(endpoint.peer_id, generation, snapshot); + commit_prepared(ctx, endpoint, ticket, prepared).await +} + +struct PreparedSnapshot { + runtime_session_id: RuntimeSessionId, + library: Option, + library_error: Option, + call_to_play: PreparedRemoteAuthor, +} + +impl PreparedSnapshot { + fn prepare( + author_id: PeerId, + generation: PeerEndpointGeneration, + snapshot: PeerStateSnapshot, + ) -> Self { + let PeerStateSnapshot { + runtime_session_id, + library, + call_to_play, + } = snapshot; + let library_error = library.validate().err(); + let library = library_error.is_none().then_some(library); + let call_to_play = + PreparedRemoteAuthor::prepare(author_id, generation, runtime_session_id, call_to_play); + Self { + runtime_session_id, + library, + library_error, + call_to_play, + } + } +} + +async fn commit_prepared( + ctx: &RemoteStateCtx, + endpoint: PeerEndpoint, + ticket: PeerNegotiationTicket, + prepared: PreparedSnapshot, +) -> eyre::Result { + if let Some(error) = &prepared.library_error { + log::warn!( + "Rejecting invalid library domain from {}: {error}", + endpoint.peer_id + ); + } + if let Some(error) = prepared.call_to_play.validation_error() { + log::warn!( + "Rejecting invalid Call-to-Play domain from {}: {error}", + endpoint.peer_id + ); + } + + let mut db = ctx.peer_game_db.write().await; + let mut call_to_play = ctx.call_to_play.write().await; + // Resolve the fallible local clock/prune boundary before mutating remote + // state, while retaining the fixed DB-to-CTP lock order through commit. + let publication = call_to_play.prepare_publication()?; + let commit_result = db.commit_authenticated_snapshot( + endpoint, + ticket, + prepared.runtime_session_id, + prepared.library, + ); + let upsert = match commit_result { + Ok(Some(upsert)) => upsert, + Ok(None) => { + drop(db); + enqueue_local_prune_if_changed( + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + ); + return Ok(false); + } + Err(error) => { + drop(db); + enqueue_local_prune_if_changed( + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + ); + return Err(error); + } + }; + + let evicted_call_to_play_changed = if let Some(evicted) = upsert.evicted_endpoint { + call_to_play + .remove_remote_author_if_generation(evicted.endpoint.peer_id, evicted.generation) + } else { + false + }; + let call_to_play_outcome = call_to_play.observe_prepared_remote(prepared.call_to_play); + log_call_to_play_outcome(endpoint.peer_id, &call_to_play_outcome); + let accepted_call_to_play_revision = call_to_play + .remote_author_state(endpoint.peer_id) + .filter(|state| state.endpoint_generation == upsert.endpoint_generation) + .map(|state| state.revision); + debug_assert!(db.set_call_to_play_revision_if_generation( + endpoint, + upsert.endpoint_generation, + accepted_call_to_play_revision, + )); + + enqueue_commit_transition( + &db, + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + endpoint, + upsert, + call_to_play_outcome.view_changed() || evicted_call_to_play_changed, + ); + Ok(true) +} + +fn log_call_to_play_outcome(peer_id: PeerId, outcome: &ObserveRemoteAuthorOutcome) { + match outcome { + ObserveRemoteAuthorOutcome::InvalidCleared(error) + | ObserveRemoteAuthorOutcome::InvalidAbsent(error) => { + log::warn!("Rejected Call-to-Play author {peer_id}: {error}"); + } + ObserveRemoteAuthorOutcome::InvalidPreserved { error, .. } => { + log::warn!("Preserving prior Call-to-Play author {peer_id}: {error}"); + } + ObserveRemoteAuthorOutcome::AtCapacity => { + log::warn!("Call-to-Play author limit reached; ignoring {peer_id}"); + } + ObserveRemoteAuthorOutcome::RejectedLocalIdentity => { + log::warn!("Rejected remote Call-to-Play snapshot for local identity {peer_id}"); + } + ObserveRemoteAuthorOutcome::Applied { .. } + | ObserveRemoteAuthorOutcome::Unchanged { .. } + | ObserveRemoteAuthorOutcome::IgnoredStale { .. } => {} + ObserveRemoteAuthorOutcome::EqualRevisionConflict { .. } => { + log::warn!( + "Preserving prior Call-to-Play author {peer_id}: equal revision had different content" + ); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn enqueue_commit_transition( + db: &PeerGameDB, + call_to_play: &mut CallToPlayStore, + tx_notify_ui: &UnboundedSender, + state_sync: &StateSyncHandle, + publication: PreparedCallToPlayPublication, + endpoint: PeerEndpoint, + upsert: PeerUpsert, + call_to_play_changed: bool, +) { + let topology_changed = upsert.is_new + || upsert.addr_changed + || upsert.previous_endpoint.is_some() + || upsert.evicted_endpoint.is_some(); + if let Some(previous) = upsert.previous_endpoint { + events::send(tx_notify_ui, PeerEvent::PeerLost(previous)); + } + if let Some(evicted) = upsert.evicted_endpoint { + events::send(tx_notify_ui, PeerEvent::PeerLost(evicted.endpoint)); + } + if upsert.is_new || upsert.addr_changed { + events::send(tx_notify_ui, PeerEvent::PeerDiscovered(endpoint)); + } + if topology_changed { + events::send( + tx_notify_ui, + PeerEvent::PeerCountUpdated(db.peer_endpoints().len()), + ); + } + if topology_changed || upsert.library_changed { + events::send( + tx_notify_ui, + PeerEvent::RemoteLibraryView(events::remote_library_view(db)), + ); + } + if topology_changed || call_to_play_changed || publication.local_changed() { + let publication = call_to_play.view_from_prepared(publication); + enqueue_call_to_play_publication(publication, tx_notify_ui, state_sync, true); + } +} + +fn enqueue_final_views( + db: &PeerGameDB, + call_to_play: &mut CallToPlayStore, + tx_notify_ui: &UnboundedSender, + state_sync: &StateSyncHandle, + publication: PreparedCallToPlayPublication, +) { + events::send( + tx_notify_ui, + PeerEvent::PeerCountUpdated(db.peer_endpoints().len()), + ); + events::send( + tx_notify_ui, + PeerEvent::RemoteLibraryView(events::remote_library_view(db)), + ); + let publication = call_to_play.view_from_prepared(publication); + enqueue_call_to_play_publication(publication, tx_notify_ui, state_sync, true); +} + +/// Clears every remote projection after one network generation has fully +/// drained, then queues the authoritative empty replacement views while the +/// database-to-Call-to-Play lock order is still held. +pub(crate) async fn clear_remote_state_and_publish( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, +) { + let mut db = ctx.peer_game_db.write().await; + let mut call_to_play = ctx.call_to_play.write().await; + let retired = db.clear_remote_peers(); + let (publication, preparation_error) = call_to_play.clear_remote_authors_and_project(); + + for RetiredPeerEndpoint { endpoint, .. } in retired { + events::send(tx_notify_ui, PeerEvent::PeerLost(endpoint)); + } + events::send(tx_notify_ui, PeerEvent::PeerCountUpdated(0)); + events::send( + tx_notify_ui, + PeerEvent::RemoteLibraryView(events::remote_library_view(&db)), + ); + enqueue_call_to_play_publication(publication, tx_notify_ui, &ctx.state_sync, true); + + if let Some(error) = preparation_error { + log::warn!( + "Cleared remote Call-to-Play state, but local retention maintenance failed: {error}" + ); + } +} + +fn enqueue_local_prune_if_changed( + call_to_play: &mut CallToPlayStore, + tx_notify_ui: &UnboundedSender, + state_sync: &StateSyncHandle, + publication: PreparedCallToPlayPublication, +) { + if !publication.local_changed() { + return; + } + let publication = call_to_play.view_from_prepared(publication); + enqueue_call_to_play_publication(publication, tx_notify_ui, state_sync, true); +} + +fn enqueue_call_to_play_publication( + publication: CallToPlayPublication, + tx_notify_ui: &UnboundedSender, + state_sync: &StateSyncHandle, + force_view: bool, +) { + state_sync.publish_call_to_play_revision(publication.local_revision); + if force_view || publication.local_changed { + events::send( + tx_notify_ui, + PeerEvent::CallToPlayView(CallToPlayView::from(publication.view)), + ); + } +} + +/// Applies a pinned Pong while holding the fixed DB-to-CTP lock order. A new +/// runtime session clears both cached domains before any follow-up pull starts. +pub(crate) async fn observe_pinned_pong( + ctx: &RemoteStateCtx, + snapshot: PeerLivenessSnapshot, + revisions: PeerRevisions, +) -> eyre::Result { + let mut db = ctx.peer_game_db.write().await; + let mut call_to_play = ctx.call_to_play.write().await; + let publication = call_to_play.prepare_publication()?; + match db.observe_pong_if_generation(snapshot, revisions) { + PongObservation::StaleGeneration => { + drop(db); + enqueue_local_prune_if_changed( + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + ); + Ok(PongCommit::StaleGeneration) + } + PongObservation::Current => { + drop(db); + enqueue_local_prune_if_changed( + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + ); + Ok(PongCommit::Current) + } + PongObservation::RevisionMismatch => { + drop(db); + enqueue_local_prune_if_changed( + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + ); + Ok(PongCommit::NeedsPull { new_session: false }) + } + PongObservation::NewSession => { + call_to_play + .remove_remote_author_if_generation(snapshot.endpoint.peer_id, snapshot.generation); + enqueue_final_views( + &db, + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + ); + Ok(PongCommit::NeedsPull { new_session: true }) + } + } +} + +/// Removes one generation and its authored slice atomically, then enqueues the +/// final replacement views before releasing either state lock. +pub(crate) async fn remove_peer_if_generation( + ctx: &RemoteStateCtx, + snapshot: PeerLivenessSnapshot, +) -> eyre::Result { + let mut db = ctx.peer_game_db.write().await; + let mut call_to_play = ctx.call_to_play.write().await; + let publication = call_to_play.prepare_publication()?; + let Some(peer) = db.remove_peer_if_generation(snapshot.endpoint, snapshot.generation) else { + drop(db); + enqueue_local_prune_if_changed( + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + ); + return Ok(false); + }; + call_to_play.remove_remote_author_if_generation(peer.peer_id, peer.endpoint_generation); + events::send( + &ctx.tx_notify_ui, + PeerEvent::PeerLost(PeerEndpoint::new(peer.peer_id, peer.addr)), + ); + enqueue_final_views( + &db, + &mut call_to_play, + &ctx.tx_notify_ui, + &ctx.state_sync, + publication, + ); + Ok(true) +} diff --git a/crates/lanspread-peer/src/services/server.rs b/crates/lanspread-peer/src/services/server.rs index 4a37d68..89d6d77 100644 --- a/crates/lanspread-peer/src/services/server.rs +++ b/crates/lanspread-peer/src/services/server.rs @@ -1,80 +1,255 @@ //! QUIC server accept loop. -use std::{net::SocketAddr, time::Duration}; +use std::{future::Future, net::SocketAddr, panic::AssertUnwindSafe, sync::Arc, time::Duration}; -use s2n_quic::{Connection, Server}; -use tokio::sync::mpsc::UnboundedSender; +use futures::FutureExt as _; +use s2n_quic::{ + Connection, + Server, + application, + provider::endpoint_limits, + stream::BidirectionalStream, +}; +use tokio::{ + sync::{OwnedSemaphorePermit, Semaphore, oneshot}, + task::JoinSet, +}; +use tokio_util::sync::CancellationToken; use crate::{ - PeerEvent, - config::{CERT_PEM, KEY_PEM}, context::PeerCtx, - events, - network::{quic_congestion_controller, quic_io, quic_limits}, + library::prime_library_manifests, + quic_runtime::{quic_congestion_controller, quic_server_limits, tracked_quic_io}, + scoped_blocking::scoped_blocking, services::{ - advertise::{monitor_mdns_events, start_mdns_advertiser}, + advertise::{close_mdns_advertiser, monitor_mdns_events, start_mdns_advertiser}, stream::handle_peer_stream, }, + tls, }; +/// Limits unauthenticated handshake memory before QUIC admission completes. +const MAX_INFLIGHT_HANDSHAKES: usize = 64; +/// Limits established connection scopes owned by the application accept loop. +const MAX_ESTABLISHED_CONNECTIONS: usize = 64; +/// Mirrors the transport stream limit and bounds application stream futures. +const MAX_CONTROL_STREAM_TASKS: usize = 32; +/// Server-wide cap acquired before any length-delimited decoder is allocated. +const MAX_GLOBAL_CONTROL_STREAM_TASKS: usize = 64; +/// Long-lived transfers move from the decoder pool to this smaller pool so +/// saturated bulk egress cannot consume every control-plane permit. +const MAX_GLOBAL_BULK_TRANSFER_TASKS: usize = 48; +/// Application idle bound for an established connection with no active +/// request streams. This is independent of transport keepalive traffic. +const CONNECTION_NO_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(10); + +struct BoundedEndpointLimits { + inner: endpoint_limits::Default, +} + +impl endpoint_limits::Limiter for BoundedEndpointLimits { + fn on_connection_attempt( + &mut self, + info: &endpoint_limits::ConnectionAttempt<'_>, + ) -> endpoint_limits::Outcome { + if !endpoint_connection_capacity_available(info.connection_count) { + return endpoint_limits::Outcome::close(); + } + endpoint_limits::Limiter::on_connection_attempt(&mut self.inner, info) + } +} + +const fn endpoint_connection_capacity_available(connection_count: usize) -> bool { + connection_count < MAX_ESTABLISHED_CONNECTIONS +} + +fn bounded_endpoint_limits() -> eyre::Result { + Ok(BoundedEndpointLimits { + inner: endpoint_limits::Default::builder() + .with_inflight_handshake_limit(MAX_INFLIGHT_HANDSHAKES)? + .build()?, + }) +} + /// Runs the QUIC server and mDNS advertiser. pub async fn run_server_component( addr: SocketAddr, ctx: PeerCtx, - tx_notify_ui: UnboundedSender, + ready: oneshot::Sender, ) -> eyre::Result<()> { - let limits = quic_limits()? - .with_max_handshake_duration(Duration::from_secs(3))? - .with_max_idle_timeout(Duration::from_secs(3))?; + // Manifest bodies may be disk-backed on their first access. Resolve only + // the currently publishable local set before opening the public endpoint; + // this bounds retained manifest memory by actual local availability. + let publication = { + let library = ctx.local_library.read().await; + library.publication(ctx.catalog.catalog()) + }; + let catalog = Arc::clone(&ctx.catalog); + scoped_blocking(move || prime_library_manifests(&publication.game_ids, &catalog))?; - let mut server = Server::builder() - .with_tls((CERT_PEM, KEY_PEM))? - .with_io(quic_io(addr)?)? - .with_limits(limits)? + let (io, endpoint_control) = tracked_quic_io(addr)?; + + let server = Server::builder() + .with_tls(tls::server_provider(&ctx.peer_identity)?)? + .with_io(io)? + .with_endpoint_limits(bounded_endpoint_limits()?)? + .with_limits(quic_server_limits()?)? .with_congestion_controller(quic_congestion_controller())? .start()?; + let endpoint_task = endpoint_control.take_started()?; + run_body_with_cleanup( + run_server_body(server, ctx, ready), + endpoint_task.shutdown_and_join(), + ) + .await +} + +async fn run_server_body( + mut server: Server, + ctx: PeerCtx, + ready: oneshot::Sender, +) -> eyre::Result<()> { let server_addr = server.local_addr()?; log::info!("Peer server listening on {server_addr}"); let mdns_advertiser = start_mdns_advertiser(&ctx, server_addr).await?; let mdns_monitor = mdns_advertiser.monitor.clone(); - let mdns_shutdown = ctx.shutdown.clone(); - ctx.task_tracker.spawn(async move { + let server_children_shutdown = ctx.shutdown.child_token(); + let mut mdns_tasks = JoinSet::new(); + let mdns_shutdown = server_children_shutdown.clone(); + mdns_tasks.spawn(async move { monitor_mdns_events(mdns_monitor, mdns_shutdown).await; + Ok(()) }); + let mut connection_tasks = JoinSet::new(); + let control_stream_permits = Arc::new(Semaphore::new(MAX_GLOBAL_CONTROL_STREAM_TASKS)); + let bulk_transfer_permits = Arc::new(Semaphore::new(MAX_GLOBAL_BULK_TRANSFER_TASKS)); let ready_addr = (*ctx.local_peer_addr.read().await).unwrap_or_else(|| direct_connect_addr(server_addr)); - let _mdns_advertiser = mdns_advertiser; - events::send( - &tx_notify_ui, - PeerEvent::LocalPeerReady { - peer_id: ctx.peer_id.as_ref().clone(), - addr: ready_addr, - }, - ); + ready + .send(ready_addr) + .map_err(|_| eyre::eyre!("network manager stopped before server readiness"))?; - loop { - let connection = tokio::select! { - () = ctx.shutdown.cancelled() => return Ok(()), - connection = server.accept() => connection, - }; + let server_result = match AssertUnwindSafe(async { + loop { + tokio::select! { + biased; + () = ctx.shutdown.cancelled() => break Ok(()), + result = mdns_tasks.join_next(), if !mdns_tasks.is_empty() => { + log_joined_child_result( + "mDNS monitor", + result.expect("non-empty child set"), + ); + log::warn!("mDNS monitor ended while the QUIC server is still running"); + } + result = connection_tasks.join_next(), if !connection_tasks.is_empty() => { + log_joined_child_result( + "peer connection", + result.expect("non-empty child set"), + ); + } + connection = server.accept() => { + let Some(connection) = connection else { + break Err(eyre::eyre!("QUIC server accept loop ended unexpectedly")); + }; - let Some(connection) = connection else { - eyre::bail!("QUIC server accept loop ended unexpectedly"); - }; + if !has_child_capacity(connection_tasks.len(), MAX_ESTABLISHED_CONNECTIONS) { + log::warn!( + "Closing excess peer connection from {} at application limit {}", + connection.remote_addr().map_or_else( + |_| "unknown".to_owned(), + |addr| addr.to_string(), + ), + MAX_ESTABLISHED_CONNECTIONS, + ); + connection.close(application::Error::UNKNOWN); + continue; + } - let ctx = ctx.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - let task_tracker = ctx.task_tracker.clone(); - - task_tracker.spawn(async move { - if let Err(err) = handle_peer_connection(connection, ctx, tx_notify_ui).await { - log::error!("Peer connection error: {err}"); + connection_tasks.spawn(handle_peer_connection( + connection, + ctx.clone(), + server_children_shutdown.clone(), + Arc::clone(&control_stream_permits), + Arc::clone(&bulk_transfer_permits), + )); + } } - }); + } + }) + .catch_unwind() + .await + { + Ok(result) => result, + Err(payload) => Err(eyre::eyre!( + "QUIC server loop panicked: {}", + panic_payload_to_string(payload.as_ref()) + )), + }; + + // Stop future work before dropping the accept owner. Connection children + // cooperatively observe this token and close their stream scopes before + // they return. + server_children_shutdown.cancel(); + drop(server); + drain_joined_child_tasks(&mut connection_tasks, "peer connection").await; + drain_joined_child_tasks(&mut mdns_tasks, "mDNS monitor").await; + let mdns_close_result = close_mdns_advertiser(mdns_advertiser); + combine_server_results(server_result, mdns_close_result, Ok(())) +} + +async fn run_body_with_cleanup(body: Body, cleanup: Cleanup) -> eyre::Result<()> +where + Body: Future>, + Cleanup: Future>, +{ + let body_result = match AssertUnwindSafe(body).catch_unwind().await { + Ok(result) => result, + Err(payload) => Err(eyre::eyre!( + "QUIC server body panicked: {}", + panic_payload_to_string(payload.as_ref()) + )), + }; + + // This is the unconditional endpoint-owner epilogue. Even an unexpected + // panic during setup, serving, or descendant cleanup cannot skip the join. + let endpoint_result = cleanup.await; + combine_server_results(body_result, Ok(()), endpoint_result) +} + +fn combine_server_results( + server: eyre::Result<()>, + mdns: eyre::Result<()>, + endpoint: eyre::Result<()>, +) -> eyre::Result<()> { + let mut errors = Vec::new(); + if let Err(error) = server { + errors.push(format!("QUIC server failed: {error:#}")); } + if let Err(error) = mdns { + errors.push(format!("mDNS advertiser shutdown failed: {error:#}")); + } + if let Err(error) = endpoint { + errors.push(format!("QUIC endpoint shutdown failed: {error:#}")); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(eyre::eyre!(errors.join("; "))) + } +} + +fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&'static str>() { + return (*message).to_string(); + } + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + "unknown panic payload".to_string() } fn direct_connect_addr(server_addr: SocketAddr) -> SocketAddr { @@ -87,31 +262,417 @@ fn direct_connect_addr(server_addr: SocketAddr) -> SocketAddr { async fn handle_peer_connection( mut connection: Connection, ctx: PeerCtx, - tx_notify_ui: UnboundedSender, + server_shutdown: CancellationToken, + control_stream_permits: Arc, + bulk_transfer_permits: Arc, ) -> eyre::Result<()> { let remote_addr = connection.remote_addr()?; log::info!("{remote_addr} peer connected"); - events::send(&tx_notify_ui, PeerEvent::PeerConnected(remote_addr)); - loop { - let stream = tokio::select! { - () = ctx.shutdown.cancelled() => break, - stream = connection.accept_bidirectional_stream() => stream, - }; - - let Some(stream) = stream? else { - break; - }; - - let ctx = ctx.clone(); - let task_tracker = ctx.task_tracker.clone(); - task_tracker.spawn(async move { - if let Err(err) = handle_peer_stream(stream, ctx, Some(remote_addr)).await { - log::error!("{remote_addr:?} peer stream error: {err}"); + let connection_shutdown = server_shutdown.child_token(); + let mut stream_tasks = JoinSet::new(); + let connection_result = match AssertUnwindSafe(async { + loop { + tokio::select! { + biased; + () = connection_shutdown.cancelled() => break Ok(()), + result = stream_tasks.join_next(), if !stream_tasks.is_empty() => { + log_joined_child_result( + &format!("{remote_addr} peer stream"), + result.expect("non-empty child set"), + ); + } + () = tokio::time::sleep(CONNECTION_NO_STREAM_IDLE_TIMEOUT), + if stream_tasks.is_empty() => { + log::debug!( + "Closing idle peer connection from {remote_addr} after {CONNECTION_NO_STREAM_IDLE_TIMEOUT:?}" + ); + break Ok(()); + } + stream = connection.accept_bidirectional_stream() => { + match stream { + Ok(Some(mut stream)) => { + if !has_child_capacity(stream_tasks.len(), MAX_CONTROL_STREAM_TASKS) { + let _ = stream.stop_sending(application::Error::UNKNOWN); + let _ = stream.reset(application::Error::UNKNOWN); + continue; + } + let Ok(control_permit) = Arc::clone(&control_stream_permits) + .try_acquire_owned() + else { + let _ = stream.stop_sending(application::Error::UNKNOWN); + let _ = stream.reset(application::Error::UNKNOWN); + continue; + }; + let stream_ctx = ctx.clone(); + let stream_shutdown = connection_shutdown.child_token(); + stream_tasks.spawn(handle_admitted_peer_stream( + stream, + stream_ctx, + Some(remote_addr), + stream_shutdown, + control_permit, + Arc::clone(&bulk_transfer_permits), + )); + } + Ok(None) => break Ok(()), + Err(error) => break Err(error.into()), + } + } } - }); + } + }) + .catch_unwind() + .await + { + Ok(result) => result, + Err(payload) => Err(eyre::eyre!( + "{remote_addr} peer connection scope panicked: {}", + panic_payload_to_string(payload.as_ref()) + )), + }; + + // Cancel the connection-local scope before closing its QUIC owner. Every + // accepted stream and outbound transfer derives from this token, so child + // futures can settle without waiting for process-wide shutdown. Closing + // the connection also wakes any transport operation already in progress. + connection_shutdown.cancel(); + connection.close(0u32.into()); + drop(connection); + let stream_label = format!("{remote_addr} peer stream"); + drain_joined_child_tasks(&mut stream_tasks, &stream_label).await; + + log::info!("{remote_addr} peer disconnected"); + connection_result +} + +async fn handle_admitted_peer_stream( + stream: BidirectionalStream, + ctx: PeerCtx, + remote_addr: Option, + stream_shutdown: CancellationToken, + control_permit: OwnedSemaphorePermit, + bulk_transfer_permits: Arc, +) -> eyre::Result<()> { + handle_peer_stream( + stream, + ctx, + remote_addr, + stream_shutdown, + control_permit, + bulk_transfer_permits, + ) + .await +} + +const fn has_child_capacity(active: usize, limit: usize) -> bool { + active < limit +} + +fn log_child_result(label: &str, result: eyre::Result<()>) { + if let Err(error) = result { + log::error!("{label} error: {error}"); + } +} + +fn log_joined_child_result(label: &str, result: Result, tokio::task::JoinError>) { + match result { + Ok(result) => log_child_result(label, result), + Err(error) => log::error!("{label} task failed: {error}"), + } +} + +async fn drain_joined_child_tasks(children: &mut JoinSet>, label: &str) { + while let Some(result) = children.join_next().await { + log_joined_child_result(label, result); + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, + }; + + use tokio::{ + sync::{Semaphore, mpsc}, + task::JoinSet, + }; + use tokio_util::sync::CancellationToken; + + use super::{ + CONNECTION_NO_STREAM_IDLE_TIMEOUT, + MAX_CONTROL_STREAM_TASKS, + MAX_ESTABLISHED_CONNECTIONS, + MAX_GLOBAL_BULK_TRANSFER_TASKS, + MAX_GLOBAL_CONTROL_STREAM_TASKS, + MAX_INFLIGHT_HANDSHAKES, + bounded_endpoint_limits, + drain_joined_child_tasks, + endpoint_connection_capacity_available, + has_child_capacity, + run_body_with_cleanup, + }; + + #[test] + fn unauthenticated_runtime_bounds_are_explicit_and_closed_at_capacity() { + assert_eq!(MAX_INFLIGHT_HANDSHAKES, 64); + assert_eq!(MAX_ESTABLISHED_CONNECTIONS, 64); + assert_eq!(MAX_CONTROL_STREAM_TASKS, 32); + assert_eq!(MAX_GLOBAL_CONTROL_STREAM_TASKS, 64); + assert_eq!(MAX_GLOBAL_BULK_TRANSFER_TASKS, 48); + assert_eq!( + u64::try_from(MAX_CONTROL_STREAM_TASKS).expect("stream task bound fits u64"), + crate::quic_runtime::MAX_OPEN_BIDIRECTIONAL_STREAMS, + ); + assert!(has_child_capacity(63, MAX_ESTABLISHED_CONNECTIONS)); + assert!(!has_child_capacity(64, MAX_ESTABLISHED_CONNECTIONS)); + assert!(!has_child_capacity(32, MAX_CONTROL_STREAM_TASKS)); } - events::send(&tx_notify_ui, PeerEvent::PeerDisconnected(remote_addr)); - Ok(()) + #[test] + fn endpoint_limiter_rejects_before_the_internal_accept_queue_can_exceed_the_cap() { + bounded_endpoint_limits().expect("endpoint limiter should build"); + assert!(endpoint_connection_capacity_available( + MAX_ESTABLISHED_CONNECTIONS - 1 + )); + assert!(!endpoint_connection_capacity_available( + MAX_ESTABLISHED_CONNECTIONS + )); + } + + #[test] + fn global_control_permit_saturates_and_releases_across_scopes() { + let permits = Arc::new(Semaphore::new(MAX_GLOBAL_CONTROL_STREAM_TASKS)); + let held = (0..MAX_GLOBAL_CONTROL_STREAM_TASKS) + .map(|_| { + Arc::clone(&permits) + .try_acquire_owned() + .expect("configured global permit should be available") + }) + .collect::>(); + assert!(Arc::clone(&permits).try_acquire_owned().is_err()); + + drop(held); + assert!(Arc::clone(&permits).try_acquire_owned().is_ok()); + } + + #[test] + fn saturated_bulk_pool_preserves_control_plane_permits() { + let control = Arc::new(Semaphore::new(MAX_GLOBAL_CONTROL_STREAM_TASKS)); + let bulk = Arc::new(Semaphore::new(MAX_GLOBAL_BULK_TRANSFER_TASKS)); + let held_bulk = (0..MAX_GLOBAL_BULK_TRANSFER_TASKS) + .map(|_| { + Arc::clone(&bulk) + .try_acquire_owned() + .expect("configured bulk permit should be available") + }) + .collect::>(); + assert!(Arc::clone(&bulk).try_acquire_owned().is_err()); + assert!( + Arc::clone(&control).try_acquire_owned().is_ok(), + "bulk saturation must not consume control-plane admission" + ); + drop(held_bulk); + } + + #[tokio::test] + async fn draining_waits_for_every_childs_natural_cleanup() { + let owner_closed = CancellationToken::new(); + let cleanup_release = CancellationToken::new(); + let completed = Arc::new(AtomicUsize::new(0)); + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let mut children = JoinSet::new(); + + for child_id in 0..2 { + let owner_closed = owner_closed.clone(); + let cleanup_release = cleanup_release.clone(); + let completed = completed.clone(); + let started_tx = started_tx.clone(); + children.spawn(async move { + started_tx + .send(child_id) + .expect("test observer should remain available"); + owner_closed.cancelled().await; + cleanup_release.cancelled().await; + completed.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + } + drop(started_tx); + + let drain_task = tokio::spawn(async move { + let mut children = children; + drain_joined_child_tasks(&mut children, "synthetic child").await; + }); + for _ in 0..2 { + tokio::time::timeout(Duration::from_secs(1), started_rx.recv()) + .await + .expect("child should start") + .expect("start channel should remain open"); + } + + owner_closed.cancel(); + tokio::task::yield_now().await; + assert_eq!(completed.load(Ordering::SeqCst), 0); + assert!(!drain_task.is_finished(), "drain must await child cleanup"); + + cleanup_release.cancel(); + tokio::time::timeout(Duration::from_secs(1), drain_task) + .await + .expect("drain should finish after cleanup is released") + .expect("drain task should not panic"); + assert_eq!(completed.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn one_panicking_child_does_not_skip_a_siblings_cleanup() { + let cleanup_release = CancellationToken::new(); + let completed = Arc::new(AtomicUsize::new(0)); + let mut children = JoinSet::new(); + children.spawn(async { + panic!("injected child panic"); + #[allow(unreachable_code)] + Ok(()) + }); + let child_release = cleanup_release.clone(); + let child_completed = completed.clone(); + children.spawn(async move { + child_release.cancelled().await; + child_completed.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + + let drain_task = tokio::spawn(async move { + drain_joined_child_tasks(&mut children, "synthetic joined child").await; + }); + tokio::task::yield_now().await; + assert!(!drain_task.is_finished()); + cleanup_release.cancel(); + tokio::time::timeout(Duration::from_secs(1), drain_task) + .await + .expect("drain should await the surviving child") + .expect("drain task should not panic"); + assert_eq!(completed.load(Ordering::SeqCst), 1); + } + + #[tokio::test(start_paused = true)] + async fn an_established_connection_without_streams_has_a_finite_application_idle_bound() { + let started = tokio::time::Instant::now(); + tokio::time::sleep(CONNECTION_NO_STREAM_IDLE_TIMEOUT).await; + assert_eq!(started.elapsed(), CONNECTION_NO_STREAM_IDLE_TIMEOUT); + } + + #[tokio::test] + async fn server_body_panic_still_awaits_owner_cleanup() { + let cleanup_finished = Arc::new(AtomicUsize::new(0)); + let cleanup_probe = cleanup_finished.clone(); + + let result = run_body_with_cleanup( + async { + panic!("injected server body panic"); + #[allow(unreachable_code)] + Ok(()) + }, + async move { + tokio::task::yield_now().await; + cleanup_probe.store(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!(cleanup_finished.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn nested_scopes_publish_completion_from_children_outward() { + let global_shutdown = CancellationToken::new(); + let server_shutdown = global_shutdown.child_token(); + let stream_cleanup_release = CancellationToken::new(); + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let (order_tx, mut order_rx) = mpsc::unbounded_channel(); + let mut connections = JoinSet::new(); + + let connection_shutdown = server_shutdown.child_token(); + let connection_stream_cleanup_release = stream_cleanup_release.clone(); + let connection_order_tx = order_tx.clone(); + connections.spawn(async move { + let mut streams = JoinSet::new(); + for label in ["stream-a", "stream-b"] { + let shutdown = connection_shutdown.child_token(); + let cleanup_release = connection_stream_cleanup_release.clone(); + let started_tx = started_tx.clone(); + let order_tx = connection_order_tx.clone(); + streams.spawn(async move { + started_tx + .send(label) + .expect("test observer should remain available"); + shutdown.cancelled().await; + cleanup_release.cancelled().await; + order_tx + .send(label) + .expect("order observer should remain available"); + Ok(()) + }); + } + + connection_shutdown.cancelled().await; + drain_joined_child_tasks(&mut streams, "synthetic stream").await; + connection_order_tx + .send("peer-disconnected") + .expect("order observer should remain available"); + Ok(()) + }); + + let hierarchy_task = tokio::spawn(async move { + server_shutdown.cancel(); + drain_joined_child_tasks(&mut connections, "synthetic connection").await; + order_tx + .send("server-return") + .expect("order observer should remain available"); + }); + for _ in 0..2 { + tokio::time::timeout(Duration::from_secs(1), started_rx.recv()) + .await + .expect("stream should start") + .expect("start channel should remain open"); + } + assert!(!hierarchy_task.is_finished()); + + stream_cleanup_release.cancel(); + tokio::time::timeout(Duration::from_secs(1), hierarchy_task) + .await + .expect("hierarchy should drain") + .expect("hierarchy task should not panic"); + + let mut order = Vec::new(); + while let Ok(event) = order_rx.try_recv() { + order.push(event); + } + let disconnected = order + .iter() + .position(|event| *event == "peer-disconnected") + .expect("disconnect should be published"); + let server_return = order + .iter() + .position(|event| *event == "server-return") + .expect("server return should be published"); + assert!( + order[..disconnected] + .iter() + .all(|event| event.starts_with("stream-")) + ); + assert_eq!(disconnected, 2); + assert_eq!(server_return, 3); + assert!( + !global_shutdown.is_cancelled(), + "server-local shutdown must not cancel its runtime parent" + ); + } } diff --git a/crates/lanspread-peer/src/services/state_sync.rs b/crates/lanspread-peer/src/services/state_sync.rs new file mode 100644 index 0000000..7d0ff4f --- /dev/null +++ b/crates/lanspread-peer/src/services/state_sync.rs @@ -0,0 +1,727 @@ +//! Bounded scheduling for responder-owned state pulls and change-hint fanout. + +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration}; + +use futures::{StreamExt as _, stream::FuturesUnordered}; +use lanspread_proto::{ChangeHint, PeerId}; +use tokio::sync::{Mutex, mpsc, watch}; +use tokio_util::sync::CancellationToken; + +use crate::{ + PeerEvent, + context::NetworkServiceCtx, + network::{send_call_to_play_changed, send_library_changed}, + peer_db::PeerRevisionSnapshot, + services::{HandshakeCtx, PeerRefreshOutcome, perform_peer_refresh}, +}; + +const SYNC_QUEUE_CAPACITY: usize = 64; +const MAX_TRACKED_PEERS: usize = 64; +const MAX_CONCURRENT_PULLS: usize = 8; +const MAX_CONCURRENT_HINT_SENDS: usize = 8; +pub(crate) const PEER_PULL_COALESCE_WINDOW: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StateDomain { + Library, + CallToPlay, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct LocalRevisions { + library: u64, + call_to_play: u64, +} + +#[derive(Clone, Copy, Debug)] +struct HintTrigger { + domain: StateDomain, + hint: ChangeHint, +} + +struct StateSyncInbox { + pinned_rx: Mutex>, + hint_rx: Mutex>, +} + +/// Non-blocking ingress for untrusted hints and latest-value local revisions. +/// +/// Pinned Pong mismatches use a separate bounded queue and an async admission +/// method so hostile hint saturation cannot displace reconciliation work. +#[derive(Clone)] +pub(crate) struct StateSyncHandle { + local_peer_id: PeerId, + pinned_tx: mpsc::Sender, + hint_tx: mpsc::Sender, + local_revisions_tx: watch::Sender, + inbox: Arc, +} + +impl StateSyncHandle { + #[must_use] + pub(crate) fn new(local_peer_id: PeerId) -> Self { + let (pinned_tx, pinned_rx) = mpsc::channel(SYNC_QUEUE_CAPACITY); + let (hint_tx, hint_rx) = mpsc::channel(SYNC_QUEUE_CAPACITY); + let (local_revisions_tx, _) = watch::channel(LocalRevisions { + library: 0, + call_to_play: 0, + }); + Self { + local_peer_id, + pinned_tx, + hint_tx, + local_revisions_tx, + inbox: Arc::new(StateSyncInbox { + pinned_rx: Mutex::new(pinned_rx), + hint_rx: Mutex::new(hint_rx), + }), + } + } + + /// Enqueues a generation-current pinned Pong mismatch without sharing the + /// untrusted hint queue. + pub(crate) async fn schedule_pinned_pull( + &self, + peer_id: PeerId, + cancellation: &CancellationToken, + ) -> eyre::Result<()> { + if peer_id == self.local_peer_id { + return Ok(()); + } + tokio::select! { + biased; + () = cancellation.cancelled() => eyre::bail!("state-sync admission was cancelled"), + result = self.pinned_tx.send(peer_id) => { + result.map_err(|_| eyre::eyre!("state-sync pinned queue is closed")) + } + } + } + + /// Treats an inbound message only as a lossy invalidation hint. + pub(crate) fn schedule_hint(&self, domain: StateDomain, hint: ChangeHint) { + if hint.claimed_peer_id == self.local_peer_id { + return; + } + match self.hint_tx.try_send(HintTrigger { domain, hint }) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + log::trace!("Coalescing remote state hint because the bounded queue is full"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + log::debug!("Ignoring remote state hint because state sync is stopped"); + } + } + } + + pub(crate) fn publish_library_revision(&self, revision: u64) { + self.local_revisions_tx.send_if_modified(|current| { + if revision <= current.library { + return false; + } + current.library = revision; + true + }); + } + + pub(crate) fn publish_call_to_play_revision(&self, revision: u64) { + self.local_revisions_tx.send_if_modified(|current| { + if revision <= current.call_to_play { + return false; + } + current.call_to_play = revision; + true + }); + } +} + +#[derive(Debug)] +struct PeerSlot { + in_flight: bool, + pending: bool, + pinned: bool, + next_allowed: tokio::time::Instant, +} + +struct PullCompletion { + peer_id: PeerId, + result: eyre::Result, +} + +type PullFuture = Pin + Send>>; +type FanoutFuture = Pin + Send>>; + +/// Runs the bounded pull scheduler and local hint fanout in one lexical scope. +pub(crate) async fn run_state_sync( + ctx: NetworkServiceCtx, + tx_notify_ui: tokio::sync::mpsc::UnboundedSender, + cancellation: CancellationToken, +) -> eyre::Result<()> { + let mut pinned_rx = ctx.state_sync.inbox.pinned_rx.lock().await; + let mut hint_rx = ctx.state_sync.inbox.hint_rx.lock().await; + let mut local_revisions_rx = ctx.state_sync.local_revisions_tx.subscribe(); + let mut last_fanout = *local_revisions_rx.borrow_and_update(); + let mut fanout: Option = None; + let mut fanout_pending = false; + // One actor-owned pinned item preserves backpressure when all tracked + // slots are temporarily non-replaceable. While occupied, the bounded + // pinned channel is deliberately not drained. + let mut blocked_pinned = None; + let mut slots = HashMap::::new(); + let mut pulls = FuturesUnordered::::new(); + let work_cancellation = cancellation.child_token(); + let handshake_ctx = HandshakeCtx::from_network(&ctx, &tx_notify_ui) + .with_cancellation(work_cancellation.clone()); + + loop { + expire_idle_slots(&mut slots); + if let Some(peer_id) = blocked_pinned + && try_enqueue_peer(&mut slots, peer_id, true) + { + blocked_pinned = None; + } + start_ready_pulls(&mut slots, &mut pulls, &handshake_ctx); + + if fanout.is_none() && fanout_pending { + let target = *local_revisions_rx.borrow_and_update(); + fanout_pending = false; + if target != last_fanout { + fanout = Some(Box::pin(fanout_local_hints( + ctx.clone(), + last_fanout, + target, + work_cancellation.clone(), + ))); + last_fanout = target; + } + } + + let deadline = next_slot_deadline(&slots); + tokio::select! { + biased; + () = cancellation.cancelled() => break, + peer_id = pinned_rx.recv(), if blocked_pinned.is_none() => { + let Some(peer_id) = peer_id else { break; }; + if !try_enqueue_peer(&mut slots, peer_id, true) { + blocked_pinned = Some(peer_id); + } + } + completed = pulls.next(), if !pulls.is_empty() => { + if let Some(completed) = completed + && let Some(slot) = slots.get_mut(&completed.peer_id) { + settle_pull_completion(slot, completed.peer_id, completed.result); + } + } + () = async { + if let Some(fanout) = fanout.as_mut() { + fanout.await; + } + }, if fanout.is_some() => { + fanout = None; + if *local_revisions_rx.borrow() != last_fanout { + fanout_pending = true; + } + } + changed = local_revisions_rx.changed() => { + if changed.is_err() { + break; + } + fanout_pending = true; + } + trigger = hint_rx.recv() => { + let Some(trigger) = trigger else { break; }; + if hint_requires_pull(&ctx, trigger).await { + let _ = try_enqueue_peer( + &mut slots, + trigger.hint.claimed_peer_id, + false, + ); + } + } + () = wait_for_deadline(deadline), if deadline.is_some() => {} + } + } + + work_cancellation.cancel(); + drain_state_sync_children(&mut pulls, fanout).await; + Ok(()) +} + +fn settle_pull_completion( + slot: &mut PeerSlot, + peer_id: PeerId, + result: eyre::Result, +) { + slot.in_flight = false; + match result { + Ok(PeerRefreshOutcome::DeferredByCandidate) => { + // Candidate ownership is temporary authority, not a completed + // refresh. Retain exactly one coalesced retry behind the existing + // rate gate. + slot.pending = true; + slot.pinned = true; + } + Ok(PeerRefreshOutcome::Completed | PeerRefreshOutcome::Stale) => {} + Err(error) => log::warn!("Failed to refresh peer {peer_id}: {error:#}"), + } +} + +async fn drain_state_sync_children( + pulls: &mut FuturesUnordered, + fanout: Option, +) { + while let Some(completed) = pulls.next().await { + if let Err(error) = completed.result { + log::debug!("Peer refresh stopped during state-sync shutdown: {error:#}"); + } + } + if let Some(fanout) = fanout { + fanout.await; + } +} + +/// Returns whether the trigger was coalesced into a tracked slot. A pinned +/// trigger that returns false must remain actor-owned so bounded-channel +/// backpressure is preserved until a slot becomes replaceable. +fn try_enqueue_peer(slots: &mut HashMap, peer_id: PeerId, pinned: bool) -> bool { + if let Some(slot) = slots.get_mut(&peer_id) { + slot.pending = true; + slot.pinned |= pinned; + return true; + } + if slots.len() >= MAX_TRACKED_PEERS { + if !pinned { + return false; + } + let replaceable = slots + .iter() + .find_map(|(id, slot)| (!slot.in_flight && !slot.pinned).then_some(*id)); + let Some(replaceable) = replaceable else { + return false; + }; + slots.remove(&replaceable); + } + slots.insert( + peer_id, + PeerSlot { + in_flight: false, + pending: true, + pinned, + next_allowed: tokio::time::Instant::now(), + }, + ); + true +} + +fn start_ready_pulls( + slots: &mut HashMap, + pulls: &mut FuturesUnordered, + handshake_ctx: &HandshakeCtx, +) { + while pulls.len() < MAX_CONCURRENT_PULLS { + let Some(peer_id) = take_ready_peer(slots, tokio::time::Instant::now()) else { + break; + }; + let ctx = handshake_ctx.clone(); + pulls.push(Box::pin(async move { + let result = perform_refresh_for_peer(ctx, peer_id).await; + PullCompletion { peer_id, result } + })); + } +} + +fn take_ready_peer( + slots: &mut HashMap, + now: tokio::time::Instant, +) -> Option { + let peer_id = slots + .iter() + .filter(|(_, slot)| slot.pending && !slot.in_flight && slot.next_allowed <= now) + .max_by_key(|(_, slot)| slot.pinned) + .map(|(peer_id, _)| *peer_id)?; + let slot = slots + .get_mut(&peer_id) + .expect("selected state-sync slot must still exist"); + slot.in_flight = true; + slot.pending = false; + slot.pinned = false; + slot.next_allowed = now + PEER_PULL_COALESCE_WINDOW; + Some(peer_id) +} + +async fn perform_refresh_for_peer( + ctx: HandshakeCtx, + peer_id: PeerId, +) -> eyre::Result { + let snapshot = ctx.peer_liveness_for(peer_id).await; + let Some(snapshot) = snapshot else { + return Ok(PeerRefreshOutcome::Stale); + }; + perform_peer_refresh(ctx, snapshot).await +} + +async fn hint_requires_pull(ctx: &NetworkServiceCtx, trigger: HintTrigger) -> bool { + let snapshot = ctx + .peer_game_db + .read() + .await + .revision_snapshot(&trigger.hint.claimed_peer_id); + hint_requires_pull_from_snapshot(ctx.peer_id, trigger, snapshot.as_ref()) +} + +fn hint_requires_pull_from_snapshot( + local_peer_id: PeerId, + trigger: HintTrigger, + snapshot: Option<&PeerRevisionSnapshot>, +) -> bool { + if trigger.hint.claimed_peer_id == local_peer_id { + return false; + } + let Some(snapshot) = snapshot else { + return false; + }; + if snapshot.runtime_session_id != trigger.hint.runtime_session_id { + return true; + } + match trigger.domain { + StateDomain::Library => snapshot.library_revision != Some(trigger.hint.revision), + StateDomain::CallToPlay => snapshot.call_to_play_revision != Some(trigger.hint.revision), + } +} + +fn expire_idle_slots(slots: &mut HashMap) { + let now = tokio::time::Instant::now(); + slots.retain(|_, slot| slot.in_flight || slot.pending || slot.next_allowed > now); +} + +fn next_slot_deadline(slots: &HashMap) -> Option { + let now = tokio::time::Instant::now(); + slots + .values() + .filter(|slot| !slot.in_flight && slot.next_allowed > now) + .map(|slot| slot.next_allowed) + .min() +} + +async fn wait_for_deadline(deadline: Option) { + if let Some(deadline) = deadline { + tokio::time::sleep_until(deadline).await; + } +} + +async fn fanout_local_hints( + ctx: NetworkServiceCtx, + previous: LocalRevisions, + target: LocalRevisions, + cancellation: CancellationToken, +) { + let endpoints = ctx.peer_game_db.read().await.peer_endpoints(); + let local_peer_id = ctx.peer_id; + let runtime_session_id = ctx.runtime_session_id; + let send_library = target.library != previous.library; + let send_call_to_play = target.call_to_play != previous.call_to_play; + let deliveries = endpoints.into_iter().map(|endpoint| { + let quic = ctx.quic.clone(); + let cancellation = cancellation.clone(); + async move { + if send_library { + let hint = ChangeHint { + claimed_peer_id: local_peer_id, + runtime_session_id, + revision: target.library, + }; + if let Err(error) = + send_library_changed(&quic, &endpoint, hint, &cancellation).await + { + log::debug!( + "Failed to send library hint to {}: {error:#}", + endpoint.addr + ); + } + } + if send_call_to_play { + let hint = ChangeHint { + claimed_peer_id: local_peer_id, + runtime_session_id, + revision: target.call_to_play, + }; + if let Err(error) = + send_call_to_play_changed(&quic, &endpoint, hint, &cancellation).await + { + log::debug!( + "Failed to send Call-to-Play hint to {}: {error:#}", + endpoint.addr + ); + } + } + } + }); + drive_bounded_hint_fanout(deliveries).await; +} + +async fn drive_bounded_hint_fanout(deliveries: impl IntoIterator) +where + F: Future, +{ + let mut deliveries = + futures::stream::iter(deliveries).buffer_unordered(MAX_CONCURRENT_HINT_SENDS); + while deliveries.next().await.is_some() {} +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use lanspread_proto::RuntimeSessionId; + + use super::*; + + fn peer(seed: u8) -> PeerId { + PeerId::from_bytes([seed; 32]) + } + + fn session(seed: u8) -> RuntimeSessionId { + RuntimeSessionId::from_bytes([seed; 16]) + } + + #[test] + fn repeated_triggers_coalesce_to_one_pending_follow_up() { + let mut slots = HashMap::new(); + assert!(try_enqueue_peer(&mut slots, peer(1), false)); + let now = tokio::time::Instant::now(); + assert_eq!(take_ready_peer(&mut slots, now), Some(peer(1))); + for _ in 0..100 { + assert!(try_enqueue_peer(&mut slots, peer(1), false)); + } + assert_eq!(slots.len(), 1); + assert!(slots[&peer(1)].pending); + slots + .get_mut(&peer(1)) + .expect("slot should exist") + .in_flight = false; + assert_eq!( + take_ready_peer( + &mut slots, + now + PEER_PULL_COALESCE_WINDOW - Duration::from_millis(1) + ), + None + ); + assert_eq!( + take_ready_peer(&mut slots, now + PEER_PULL_COALESCE_WINDOW), + Some(peer(1)) + ); + assert!(!slots[&peer(1)].pending); + slots + .get_mut(&peer(1)) + .expect("slot should exist") + .in_flight = false; + assert_eq!( + take_ready_peer(&mut slots, now + PEER_PULL_COALESCE_WINDOW), + None, + "completion or failure must not self-retry" + ); + } + + #[test] + fn candidate_deferral_rearms_exactly_one_rate_limited_pinned_retry() { + let peer_id = peer(1); + let mut slots = HashMap::new(); + assert!(try_enqueue_peer(&mut slots, peer_id, true)); + let now = tokio::time::Instant::now(); + assert_eq!(take_ready_peer(&mut slots, now), Some(peer_id)); + + settle_pull_completion( + slots.get_mut(&peer_id).expect("slot should remain"), + peer_id, + Ok(PeerRefreshOutcome::DeferredByCandidate), + ); + assert!(slots[&peer_id].pending); + assert!(slots[&peer_id].pinned); + assert_eq!(slots.len(), 1); + assert_eq!( + take_ready_peer( + &mut slots, + now + PEER_PULL_COALESCE_WINDOW - Duration::from_millis(1), + ), + None + ); + assert_eq!( + take_ready_peer(&mut slots, now + PEER_PULL_COALESCE_WINDOW), + Some(peer_id) + ); + + settle_pull_completion( + slots.get_mut(&peer_id).expect("slot should remain"), + peer_id, + Ok(PeerRefreshOutcome::Completed), + ); + assert!(!slots[&peer_id].pending); + assert!(!slots[&peer_id].pinned); + assert_eq!( + take_ready_peer(&mut slots, now + PEER_PULL_COALESCE_WINDOW), + None + ); + } + + #[test] + fn pinned_trigger_displaces_only_idle_untrusted_slot_at_capacity() { + let mut slots = HashMap::new(); + for seed in 0..u8::try_from(MAX_TRACKED_PEERS).expect("bound fits u8") { + assert!(try_enqueue_peer(&mut slots, peer(seed), false)); + } + assert!(try_enqueue_peer(&mut slots, peer(200), true)); + assert_eq!(slots.len(), MAX_TRACKED_PEERS); + assert!(slots.contains_key(&peer(200))); + assert!(slots[&peer(200)].pinned); + assert_eq!( + take_ready_peer(&mut slots, tokio::time::Instant::now()), + Some(peer(200)), + "pinned work must start before saturated untrusted work" + ); + } + + #[test] + fn pinned_trigger_waits_when_every_slot_is_nonreplaceable() { + let mut slots = HashMap::new(); + for seed in 0..u8::try_from(MAX_TRACKED_PEERS).expect("bound fits u8") { + assert!(try_enqueue_peer(&mut slots, peer(seed), true)); + } + + let blocked = peer(200); + assert!(!try_enqueue_peer(&mut slots, blocked, true)); + assert_eq!(slots.len(), MAX_TRACKED_PEERS); + assert!(!slots.contains_key(&blocked)); + + let completed = peer(0); + let slot = slots + .get_mut(&completed) + .expect("tracked pinned slot should exist"); + slot.pinned = false; + slot.pending = false; + slot.in_flight = false; + assert!(try_enqueue_peer(&mut slots, blocked, true)); + assert_eq!(slots.len(), MAX_TRACKED_PEERS); + assert!(slots.contains_key(&blocked)); + assert!(!slots.contains_key(&completed)); + } + + #[test] + fn quiet_cooldown_slots_have_an_expiry_deadline_before_new_hint_admission() { + let now = tokio::time::Instant::now(); + let mut slots = HashMap::new(); + for seed in 0..u8::try_from(MAX_TRACKED_PEERS).expect("bound fits u8") { + slots.insert( + peer(seed), + PeerSlot { + in_flight: false, + pending: false, + pinned: false, + next_allowed: now + PEER_PULL_COALESCE_WINDOW, + }, + ); + } + + assert_eq!( + next_slot_deadline(&slots), + Some(now + PEER_PULL_COALESCE_WINDOW) + ); + } + + #[test] + fn scheduler_starts_at_most_eight_of_sixty_four_ready_peers() { + let mut slots = HashMap::new(); + for seed in 0..u8::try_from(MAX_TRACKED_PEERS).expect("bound fits u8") { + assert!(try_enqueue_peer(&mut slots, peer(seed), false)); + } + let now = tokio::time::Instant::now(); + let started = (0..MAX_CONCURRENT_PULLS) + .filter_map(|_| take_ready_peer(&mut slots, now)) + .collect::>(); + assert_eq!(started.len(), MAX_CONCURRENT_PULLS); + assert_eq!(slots.values().filter(|slot| slot.in_flight).count(), 8); + assert_eq!(slots.values().filter(|slot| slot.pending).count(), 56); + assert_eq!( + next_slot_deadline(&slots), + None, + "ready work waiting on the global pull bound must be completion-driven" + ); + } + + #[test] + fn unknown_and_self_hints_do_not_allocate_slots() { + let local = peer(1); + let mut slots = HashMap::new(); + for claimed_peer_id in [local, peer(2)] { + let trigger = HintTrigger { + domain: StateDomain::Library, + hint: ChangeHint { + claimed_peer_id, + runtime_session_id: session(1), + revision: 99, + }, + }; + if hint_requires_pull_from_snapshot(local, trigger, None) { + let _ = try_enqueue_peer(&mut slots, claimed_peer_id, false); + } + } + assert!(slots.is_empty()); + } + + #[tokio::test] + async fn local_revision_watch_coalesces_to_latest_value() { + let handle = StateSyncHandle::new(peer(1)); + let mut rx = handle.local_revisions_tx.subscribe(); + handle.publish_library_revision(1); + handle.publish_library_revision(2); + handle.publish_library_revision(7); + rx.changed().await.expect("watch sender should remain live"); + assert_eq!(rx.borrow_and_update().library, 7); + assert!(!rx.has_changed().expect("watch sender should remain live")); + } + + #[tokio::test] + async fn hint_fanout_never_exceeds_eight_simultaneous_sends() { + let active = Arc::new(AtomicUsize::new(0)); + let maximum = Arc::new(AtomicUsize::new(0)); + let deliveries = (0..64).map(|_| { + let active = Arc::clone(&active); + let maximum = Arc::clone(&maximum); + async move { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(current, Ordering::SeqCst); + tokio::task::yield_now().await; + active.fetch_sub(1, Ordering::SeqCst); + } + }); + drive_bounded_hint_fanout(deliveries).await; + assert!(maximum.load(Ordering::SeqCst) <= MAX_CONCURRENT_HINT_SENDS); + assert_eq!(active.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn cancellation_drains_pull_and_fanout_futures() { + let cancellation = CancellationToken::new(); + let completed = Arc::new(AtomicUsize::new(0)); + let mut pulls = FuturesUnordered::::new(); + for seed in 1..=2 { + let cancellation = cancellation.clone(); + let completed = Arc::clone(&completed); + pulls.push(Box::pin(async move { + cancellation.cancelled().await; + completed.fetch_add(1, Ordering::SeqCst); + PullCompletion { + peer_id: peer(seed), + result: Ok(PeerRefreshOutcome::Completed), + } + })); + } + let fanout_cancellation = cancellation.clone(); + let fanout_completed = Arc::clone(&completed); + let fanout: FanoutFuture = Box::pin(async move { + fanout_cancellation.cancelled().await; + fanout_completed.fetch_add(1, Ordering::SeqCst); + }); + cancellation.cancel(); + drain_state_sync_children(&mut pulls, Some(fanout)).await; + assert_eq!(completed.load(Ordering::SeqCst), 3); + } +} diff --git a/crates/lanspread-peer/src/services/stream.rs b/crates/lanspread-peer/src/services/stream.rs index 5255f10..12cef1b 100644 --- a/crates/lanspread-peer/src/services/stream.rs +++ b/crates/lanspread-peer/src/services/stream.rs @@ -1,187 +1,334 @@ -//! Request dispatch for a single bidirectional QUIC stream. +//! Bounded one-control-frame dispatch for a bidirectional QUIC stream. -use std::net::SocketAddr; +use std::{net::SocketAddr, sync::Arc, time::Duration}; -use futures::{SinkExt, StreamExt}; -use lanspread_db::db::{Game, GameFileDescription}; -use lanspread_proto::{CallToPlayAck, LibraryDelta, Message, Request, Response}; -use s2n_quic::stream::{BidirectionalStream, SendStream}; -use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; +use futures::{SinkExt as _, StreamExt as _}; +use lanspread_proto::{ + ControlErrorCode, + ControlMessage, + MAX_CONTROL_FRAME_BYTES, + Request, + Response, +}; +use s2n_quic::{ + application, + stream::{BidirectionalStream, SendStream}, +}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio_util::{ + codec::{FramedRead, FramedWrite, LengthDelimitedCodec}, + sync::CancellationToken, +}; use crate::{ context::PeerCtx, - error::PeerError, - events, - game_paths::is_local_dir_name, - local_games::{get_game_file_descriptions, local_download_matches_catalog}, - peer::{send_game_file_chunk, send_game_file_data}, - services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync}, - stream_install::{send_game_install_stream, send_stream_install_error}, + services::{ + remote_state, + state_sync::StateDomain, + transfer::{ChunkDispatch, handle_file_chunk_request, handle_stream_install_request}, + }, }; type ResponseWriter = FramedWrite; -/// Handles a bidirectional stream from a peer. +const INBOUND_CONTROL_FRAME_TIMEOUT: Duration = Duration::from_secs(10); +const OUTBOUND_CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(10); + +fn control_codec() -> LengthDelimitedCodec { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_CONTROL_FRAME_BYTES) + .new_codec() +} + +/// Reads exactly one bounded request frame, requires request-side EOF, sends at +/// most one control response, and then closes the stream. Raw transfer requests +/// consume the response side after the same single control-frame admission. pub(super) async fn handle_peer_stream( stream: BidirectionalStream, ctx: PeerCtx, remote_addr: Option, + stream_shutdown: CancellationToken, + control_permit: OwnedSemaphorePermit, + bulk_transfer_permits: Arc, ) -> eyre::Result<()> { let (rx, tx) = stream.split(); - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - + let mut framed_rx = FramedRead::new(rx, control_codec()); + let mut framed_tx = FramedWrite::new(tx, control_codec()); log::trace!("{remote_addr:?} peer stream opened"); - loop { - let next_message = tokio::select! { - () = ctx.shutdown.cancelled() => break, - next_message = framed_rx.next() => next_message, - }; - - match next_message { - Some(Ok(data)) => { - log::trace!( - "{:?} msg: (raw): {}", - remote_addr, - String::from_utf8_lossy(&data) - ); - - let request = Request::decode(data.freeze()); - log::debug!("{remote_addr:?} msg: {request:?}"); - note_peer_activity(&ctx, remote_addr).await; - framed_tx = dispatch_request(&ctx, remote_addr, request, framed_tx).await; - } - Some(Err(err)) => { - log::error!("{remote_addr:?} peer stream error: {err}"); - break; - } - None => { - log::trace!("{remote_addr:?} peer stream closed"); - break; + let first_frame = read_expected_frame(&mut framed_rx, &stream_shutdown).await; + let mut control_permit = Some(control_permit); + let mut _bulk_permit = None; + let mut response_reset = false; + match first_frame { + FrameRead::Frame(data) => { + let trailing = read_expected_eof(&mut framed_rx, &stream_shutdown).await; + if trailing == TrailingRead::Eof { + match Request::decode(data.freeze()) { + Ok(request) => { + log::debug!("{remote_addr:?} msg: {request:?}"); + if request_is_bulk(&request) { + let bulk_permit = + Arc::clone(&bulk_transfer_permits).try_acquire_owned(); + // Once the single bounded request is decoded, bulk + // work moves to its smaller pool so it cannot hold + // every control-plane permit during long egress. + drop(control_permit.take()); + if let Ok(permit) = bulk_permit { + _bulk_permit = Some(permit); + let dispatched = + dispatch_request(&ctx, request, framed_tx, &stream_shutdown) + .await; + framed_tx = dispatched.writer; + response_reset = dispatched.response_reset; + } else { + let mut tx = framed_tx.into_inner(); + let _ = tx.reset(application::Error::UNKNOWN); + framed_tx = FramedWrite::new(tx, control_codec()); + response_reset = true; + } + } else { + let dispatched = + dispatch_request(&ctx, request, framed_tx, &stream_shutdown).await; + framed_tx = dispatched.writer; + response_reset = dispatched.response_reset; + } + } + Err(error) => { + log::warn!( + "Rejecting invalid control request from {remote_addr:?}: {error}" + ); + framed_tx = send_response( + framed_tx, + Response::Error(ControlErrorCode::InvalidRequest), + "invalid-request", + &stream_shutdown, + ) + .await; + } + } + } else if trailing != TrailingRead::Cancelled { + log::warn!("Rejecting non-singular control request from {remote_addr:?}"); + framed_tx = send_response( + framed_tx, + Response::Error(ControlErrorCode::InvalidRequest), + "invalid-request", + &stream_shutdown, + ) + .await; } } + FrameRead::Invalid(error) => { + log::warn!("Rejecting malformed control frame from {remote_addr:?}: {error}"); + framed_tx = send_response( + framed_tx, + Response::Error(ControlErrorCode::InvalidRequest), + "invalid-request", + &stream_shutdown, + ) + .await; + } + FrameRead::Eof => log::trace!("{remote_addr:?} peer stream closed without a request"), + FrameRead::Cancelled => {} } + close_or_reset_stream( + framed_rx, + framed_tx, + remote_addr, + &stream_shutdown, + response_reset, + ) + .await; Ok(()) } +const fn request_is_bulk(request: &Request) -> bool { + matches!( + request, + Request::GetGameFileChunk { .. } | Request::StreamInstall { .. } + ) +} + +enum FrameRead { + Frame(bytes::BytesMut), + Invalid(std::io::Error), + Eof, + Cancelled, +} + +async fn read_expected_frame( + framed_rx: &mut FramedRead, + cancellation: &CancellationToken, +) -> FrameRead { + tokio::select! { + biased; + () = cancellation.cancelled() => FrameRead::Cancelled, + () = tokio::time::sleep(INBOUND_CONTROL_FRAME_TIMEOUT) => FrameRead::Invalid( + std::io::Error::new(std::io::ErrorKind::TimedOut, "control request timed out") + ), + frame = framed_rx.next() => match frame { + Some(Ok(bytes)) => FrameRead::Frame(bytes), + Some(Err(error)) => FrameRead::Invalid(error), + None => FrameRead::Eof, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TrailingRead { + Eof, + ExtraFrame, + Invalid, + TimedOut, + Cancelled, +} + +async fn read_expected_eof( + framed_rx: &mut FramedRead, + cancellation: &CancellationToken, +) -> TrailingRead { + tokio::select! { + biased; + () = cancellation.cancelled() => TrailingRead::Cancelled, + () = tokio::time::sleep(INBOUND_CONTROL_FRAME_TIMEOUT) => TrailingRead::TimedOut, + frame = framed_rx.next() => match frame { + Some(Ok(_)) => TrailingRead::ExtraFrame, + Some(Err(_)) => TrailingRead::Invalid, + None => TrailingRead::Eof, + } + } +} + async fn dispatch_request( ctx: &PeerCtx, - remote_addr: Option, request: Request, framed_tx: ResponseWriter, -) -> ResponseWriter { + stream_shutdown: &CancellationToken, +) -> DispatchResult { match request { - Request::Ping => send_response(framed_tx, Response::Pong, "pong").await, - Request::Hello(hello) => match accept_inbound_hello(ctx, remote_addr, hello).await { - Ok(ack) => send_response(framed_tx, Response::HelloAck(ack), "HelloAck").await, - Err(err) => { - log::error!("Failed to accept inbound hello: {err}"); - send_response( - framed_tx, - Response::InternalPeerError(err.to_string()), - "HelloAck", - ) + Request::Ping => { + match control_io_with_deadline(remote_state::local_revisions(ctx), stream_shutdown) .await + { + Some(Ok(revisions)) => DispatchResult::close( + send_response( + framed_tx, + Response::Pong(revisions), + "pong", + stream_shutdown, + ) + .await, + ), + Some(Err(error)) => { + log::error!("Failed to build local revisions: {error:#}"); + DispatchResult::close( + send_response( + framed_tx, + Response::Error(ControlErrorCode::Internal), + "pong-error", + stream_shutdown, + ) + .await, + ) + } + None => reset_response_writer(framed_tx, "pong-computation"), } - }, - Request::ListGames => handle_list_games(ctx, framed_tx).await, - Request::LibraryDelta { peer_id, delta } => { - handle_library_delta(ctx, peer_id, delta).await; - framed_tx } - Request::CallToPlayEvents { - peer_id, - events: incoming, - } => { - let ack = handle_call_to_play_events(ctx, &peer_id, incoming).await; - send_response(framed_tx, Response::CallToPlayAck(ack), "CallToPlayAck").await + Request::Hello => { + match control_io_with_deadline(remote_state::local_snapshot(ctx), stream_shutdown).await + { + Some(Ok(snapshot)) => DispatchResult::close( + send_response( + framed_tx, + Response::HelloSnapshot(snapshot), + "hello-snapshot", + stream_shutdown, + ) + .await, + ), + Some(Err(error)) => { + log::error!("Failed to build local peer snapshot: {error:#}"); + DispatchResult::close( + send_response( + framed_tx, + Response::Error(ControlErrorCode::Internal), + "hello-error", + stream_shutdown, + ) + .await, + ) + } + None => reset_response_writer(framed_tx, "hello-computation"), + } + } + Request::LibraryChanged(hint) => { + ctx.state_sync.schedule_hint(StateDomain::Library, hint); + DispatchResult::close(framed_tx) + } + Request::CallToPlayChanged(hint) => { + ctx.state_sync.schedule_hint(StateDomain::CallToPlay, hint); + DispatchResult::close(framed_tx) } - Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await, - Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await, Request::GetGameFileChunk { game_id, + content_id, relative_path, offset, length, } => { - handle_file_chunk_request(ctx, game_id, relative_path, offset, length, framed_tx).await - } - Request::StreamInstall { game_id } => { - handle_stream_install_request(ctx, game_id, framed_tx).await - } - Request::Goodbye { peer_id } => { - handle_goodbye(ctx, remote_addr, peer_id).await; - framed_tx - } - Request::Invalid(_, _) => { - log::error!("Received invalid request from peer"); - framed_tx - } - } -} - -async fn handle_call_to_play_events( - ctx: &PeerCtx, - peer_id: &str, - incoming: Vec, -) -> CallToPlayAck { - let peer_id = peer_id.to_string(); - if ctx.peer_game_db.read().await.peer_addr(&peer_id).is_none() { - log::debug!("Requesting a handshake before accepting Call to Play events from {peer_id}"); - return CallToPlayAck::NeedHandshake; - } - if incoming.iter().any(|event| event.actor_id != peer_id) { - let reason = format!("event actor does not match envelope peer {peer_id}"); - log::warn!("Rejecting Call to Play events: {reason}"); - return CallToPlayAck::Rejected { reason }; - } - - match ctx.call_to_play.write().await.merge_batch(incoming) { - Ok(merged) => { - let ack = if merged.needs_history() { - CallToPlayAck::NeedHistory - } else if !merged.applied.is_empty() { - CallToPlayAck::Applied - } else if merged.obsolete > 0 { - CallToPlayAck::Obsolete - } else if merged.duplicates > 0 { - CallToPlayAck::Duplicate - } else { - CallToPlayAck::Rejected { - reason: "empty Call to Play event batch".to_string(), - } - }; - if merged.needs_history() { - log::warn!( - "Ignoring Call to Play actions without history from {peer_id}: {}", - merged.missing_call_ids.join(", ") - ); - } - if !merged.applied.is_empty() { - events::send( - &ctx.tx_notify_ui, - crate::PeerEvent::CallToPlayEvents(merged.applied), - ); - } - ack - } - Err(err) => { - log::warn!("Rejecting Call to Play events from {peer_id}: {err}"); - CallToPlayAck::Rejected { - reason: err.to_string(), - } - } - } -} - -async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option) { - if let Some(addr) = remote_addr { - ctx.peer_game_db - .write() + match handle_file_chunk_request( + ctx, + game_id, + content_id, + relative_path, + offset, + length, + framed_tx, + stream_shutdown, + ) .await - .update_last_seen_by_addr(&addr); + { + ChunkDispatch::Finished(writer) => DispatchResult::close(writer), + ChunkDispatch::Reset(writer) => DispatchResult::reset(writer), + } + } + Request::StreamInstall { + game_id, + content_id, + } => DispatchResult::close( + handle_stream_install_request(ctx, game_id, content_id, framed_tx, stream_shutdown) + .await, + ), + } +} + +fn reset_response_writer(framed_tx: ResponseWriter, label: &str) -> DispatchResult { + let mut tx = framed_tx.into_inner(); + if let Err(error) = tx.reset(application::Error::UNKNOWN) { + log::debug!("Failed to reset timed-out {label} response: {error}"); + } + DispatchResult::reset(FramedWrite::new(tx, control_codec())) +} + +struct DispatchResult { + writer: ResponseWriter, + response_reset: bool, +} + +impl DispatchResult { + const fn close(writer: ResponseWriter) -> Self { + Self { + writer, + response_reset: false, + } + } + + const fn reset(writer: ResponseWriter) -> Self { + Self { + writer, + response_reset: true, + } } } @@ -189,610 +336,120 @@ async fn send_response( mut framed_tx: ResponseWriter, response: Response, label: &str, + stream_shutdown: &CancellationToken, ) -> ResponseWriter { - if let Err(err) = framed_tx.send(response.encode()).await { - log::error!("Failed to send {label} response: {err}"); + let encoded = match response.encode() { + Ok(encoded) => encoded, + Err(error) => { + log::error!("Failed to encode {label} response: {error}"); + let mut tx = framed_tx.into_inner(); + if let Err(reset_error) = tx.reset(application::Error::UNKNOWN) { + log::debug!("Failed to reset unencodable {label} response: {reset_error}"); + } + return FramedWrite::new(tx, control_codec()); + } + }; + let send_result = control_io_with_deadline(framed_tx.send(encoded), stream_shutdown).await; + let Some(send_result) = send_result else { + let mut tx = framed_tx.into_inner(); + let _ = tx.reset(application::Error::UNKNOWN); + return FramedWrite::new(tx, control_codec()); + }; + if let Err(error) = send_result { + log::debug!("Failed to send {label} response: {error}"); } framed_tx } -async fn handle_list_games(ctx: &PeerCtx, framed_tx: ResponseWriter) -> ResponseWriter { - log::info!("Received ListGames request from peer"); - let snapshot = { - let db_guard = ctx.local_game_db.read().await; - if let Some(db) = db_guard.as_ref() { - db.all_games().into_iter().cloned().collect::>() - } else { - log::info!("Local game database not yet loaded, responding with empty game list"); - Vec::new() - } - }; - - let games = if snapshot.is_empty() { - snapshot - } else { - let active_operations = ctx.active_operations.read().await; - snapshot - .into_iter() - .filter(|game| !active_operations.contains_key(&game.id)) - .collect() - }; - - send_response(framed_tx, Response::ListGames(games), "ListGames").await -} - -async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelta) { - let applied = { - let mut db = ctx.peer_game_db.write().await; - db.apply_library_delta(&peer_id, delta) - }; - - if applied { - events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await; - } else { - let addr = { - let db = ctx.peer_game_db.read().await; - db.peer_addr(&peer_id) - }; - let Some(addr) = addr else { - log::debug!("Ignoring library delta from unknown peer {peer_id}"); - return; - }; - - spawn_library_resync(HandshakeCtx::from_peer_ctx(ctx), addr, peer_id, "resync"); +async fn close_or_reset_stream( + framed_rx: FramedRead, + mut framed_tx: ResponseWriter, + remote_addr: Option, + cancellation: &CancellationToken, + response_reset: bool, +) { + if cancellation.is_cancelled() { + let mut rx = framed_rx.into_inner(); + let _ = rx.stop_sending(application::Error::UNKNOWN); + let mut tx = framed_tx.into_inner(); + let _ = tx.reset(application::Error::UNKNOWN); + return; + } + if response_reset { + // The transfer handler already sent RESET_STREAM. A later clean FIN + // would make a rejected zero-byte or truncated raw chunk ambiguous to + // the receiver. + drop(framed_rx); + drop(framed_tx); + return; + } + let close_result = control_io_with_deadline(framed_tx.close(), cancellation).await; + if close_result.is_none() { + let mut rx = framed_rx.into_inner(); + let _ = rx.stop_sending(application::Error::UNKNOWN); + let mut tx = framed_tx.into_inner(); + let _ = tx.reset(application::Error::UNKNOWN); + return; + } + if let Some(Err(error)) = close_result { + log::debug!("{remote_addr:?} failed to close peer response stream: {error}"); } } -async fn handle_get_game(ctx: &PeerCtx, id: String, framed_tx: ResponseWriter) -> ResponseWriter { - log::info!("Received GetGame request for {id} from peer"); - let response = get_game_response(ctx, id).await; - send_response(framed_tx, response, "GetGame").await -} - -async fn get_game_response(ctx: &PeerCtx, id: String) -> Response { - let game_dir = ctx.game_dir.read().await.clone(); - if !can_serve_game(ctx, &game_dir, &id).await { - return Response::GameNotFound(id); +async fn control_io_with_deadline( + operation: impl std::future::Future, + cancellation: &CancellationToken, +) -> Option { + tokio::select! { + biased; + () = cancellation.cancelled() => None, + () = tokio::time::sleep(OUTBOUND_CONTROL_IO_TIMEOUT) => None, + result = operation => Some(result), } - - match get_game_file_descriptions(&id, &game_dir).await { - Ok(file_descriptions) => Response::GetGame { - id, - file_descriptions, - }, - Err(PeerError::FileSizeDetermination { path, source }) => { - let error_msg = format!("Failed to determine file size for {path}: {source}"); - log::error!("File size determination error for game {id}: {error_msg}"); - Response::InternalPeerError(error_msg) - } - Err(err) => { - log::error!("Failed to get game file descriptions for {id}: {err}"); - Response::GameNotFound(id) - } - } -} - -async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool { - let active_operations = ctx.active_operations.read().await; - let catalog = ctx.catalog.read().await; - local_download_matches_catalog(game_dir, game_id, &active_operations, &catalog).await -} - -async fn can_dispatch_file_transfer( - ctx: &PeerCtx, - game_dir: &std::path::Path, - game_id: &str, - relative_path: &str, -) -> bool { - relative_path_belongs_to_game(game_id, relative_path) - && !path_points_inside_local(game_id, relative_path) - && can_serve_game(ctx, game_dir, game_id).await -} - -fn relative_path_belongs_to_game(game_id: &str, relative_path: &str) -> bool { - let normalised = relative_path.replace('\\', "/"); - if normalised.starts_with('/') { - return false; - } - - normalised - .split('/') - .find(|part| !part.is_empty()) - .is_some_and(|first| first == game_id) -} - -fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool { - let normalised = relative_path.replace('\\', "/"); - let mut parts = normalised.split('/').filter(|part| !part.is_empty()); - match (parts.next(), parts.next()) { - (Some(first), _) if is_local_dir_name(first) => true, - (Some(first), Some(second)) if first == game_id && is_local_dir_name(second) => true, - _ => false, - } -} - -use std::sync::atomic::{AtomicU64, Ordering}; - -static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1); - -struct TransferGuard { - game_id: String, - id: u64, - active_outbound_transfers: crate::context::OutboundTransfers, - tx_notify_ui: tokio::sync::mpsc::UnboundedSender, -} - -impl TransferGuard { - async fn new( - game_id: String, - active_outbound_transfers: crate::context::OutboundTransfers, - tx_notify_ui: tokio::sync::mpsc::UnboundedSender, - shutdown: &tokio_util::sync::CancellationToken, - ) -> (Self, tokio_util::sync::CancellationToken) { - let id = NEXT_TRANSFER_ID.fetch_add(1, Ordering::SeqCst); - let token = shutdown.child_token(); - { - let mut active = active_outbound_transfers.write().await; - active - .entry(game_id.clone()) - .or_default() - .push((id, token.clone())); - } - let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged); - ( - Self { - game_id, - id, - active_outbound_transfers, - tx_notify_ui, - }, - token, - ) - } -} - -impl Drop for TransferGuard { - fn drop(&mut self) { - let game_id = self.game_id.clone(); - let id = self.id; - let active_outbound_transfers = self.active_outbound_transfers.clone(); - let tx_notify_ui = self.tx_notify_ui.clone(); - tokio::spawn(async move { - { - let mut active = active_outbound_transfers.write().await; - if let Some(tokens) = active.get_mut(&game_id) { - tokens.retain(|(tid, _)| *tid != id); - if tokens.is_empty() { - active.remove(&game_id); - } - } - } - let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged); - }); - } -} - -async fn handle_file_data_request( - ctx: &PeerCtx, - desc: GameFileDescription, - framed_tx: ResponseWriter, -) -> ResponseWriter { - log::info!( - "Received GetGameFileData request for {} from peer", - desc.relative_path - ); - - let (guard, cancel_token) = TransferGuard::new( - desc.game_id.clone(), - ctx.active_outbound_transfers.clone(), - ctx.tx_notify_ui.clone(), - &ctx.shutdown, - ) - .await; - - let mut tx = framed_tx.into_inner(); - let game_dir = ctx.game_dir.read().await.clone(); - if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await { - log::info!( - "Declining GetGameFileData for {} because the game is not currently transferable", - desc.relative_path - ); - drop(guard); - let _ = tx.close().await; - return FramedWrite::new(tx, LengthDelimitedCodec::new()); - } - - send_game_file_data(&desc, &mut tx, &game_dir, cancel_token).await; - drop(guard); - FramedWrite::new(tx, LengthDelimitedCodec::new()) -} - -async fn handle_file_chunk_request( - ctx: &PeerCtx, - game_id: String, - relative_path: String, - offset: u64, - length: u64, - framed_tx: ResponseWriter, -) -> ResponseWriter { - log::info!( - "Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})" - ); - - let (guard, cancel_token) = TransferGuard::new( - game_id.clone(), - ctx.active_outbound_transfers.clone(), - ctx.tx_notify_ui.clone(), - &ctx.shutdown, - ) - .await; - - let mut tx = framed_tx.into_inner(); - let game_dir = ctx.game_dir.read().await.clone(); - if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await { - log::info!( - "Declining GetGameFileChunk for {relative_path} because the game is not currently transferable" - ); - drop(guard); - let _ = tx.close().await; - return FramedWrite::new(tx, LengthDelimitedCodec::new()); - } - - send_game_file_chunk( - &game_id, - &relative_path, - offset, - length, - &mut tx, - &game_dir, - cancel_token, - ) - .await; - drop(guard); - FramedWrite::new(tx, LengthDelimitedCodec::new()) -} - -async fn handle_stream_install_request( - ctx: &PeerCtx, - game_id: String, - framed_tx: ResponseWriter, -) -> ResponseWriter { - log::info!("Received StreamInstall request for {game_id} from peer"); - - let (guard, cancel_token) = TransferGuard::new( - game_id.clone(), - ctx.active_outbound_transfers.clone(), - ctx.tx_notify_ui.clone(), - &ctx.shutdown, - ) - .await; - - let mut tx = framed_tx.into_inner(); - let game_dir = ctx.game_dir.read().await.clone(); - if !can_serve_game(ctx, &game_dir, &game_id).await { - log::info!( - "Declining StreamInstall for {game_id} because the game is not currently transferable" - ); - tx = send_stream_install_error(tx, format!("game {game_id} is not transferable")).await; - drop(guard); - return FramedWrite::new(tx, LengthDelimitedCodec::new()); - } - - let game_root = game_dir.join(&game_id); - let (returned_tx, result) = send_game_install_stream( - ctx.stream_install_provider.clone(), - tx, - &game_root, - &game_id, - cancel_token, - ) - .await; - if let Err(err) = result { - log::warn!("StreamInstall for {game_id} ended with error: {err}"); - } - - drop(guard); - FramedWrite::new(returned_tx, LengthDelimitedCodec::new()) -} - -async fn handle_goodbye(ctx: &PeerCtx, _remote_addr: Option, peer_id: String) { - log::info!("Received Goodbye from peer {peer_id}"); - let removed = { ctx.peer_game_db.write().await.remove_peer(&peer_id) }; - let Some(peer) = removed else { return }; - - events::emit_peer_lost(&ctx.peer_game_db, &ctx.tx_notify_ui, peer.addr).await; - events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await; } #[cfg(test)] mod tests { - use std::{ - path::{Path, PathBuf}, - sync::Arc, - }; - - use lanspread_db::db::GameCatalog; - use lanspread_proto::{CallToPlayAction, CallToPlayEvent}; - use tokio::sync::{RwLock, mpsc}; - use tokio_util::{sync::CancellationToken, task::TaskTracker}; + use lanspread_db::content_manifest::ContentId; use super::*; - use crate::{ - UnpackFuture, - Unpacker, - context::{Ctx, OperationKind}, - peer_db::PeerGameDB, - test_support::TempDir, - }; - struct NoopUnpacker; - - impl Unpacker for NoopUnpacker { - fn unpack<'a>(&'a self, _archive: &'a Path, _dest: &'a Path) -> UnpackFuture<'a> { - Box::pin(async { Ok(()) }) - } - } - - fn write_file(path: &Path, bytes: &[u8]) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("parent dir should be created"); - } - std::fs::write(path, bytes).expect("file should be written"); - } - - fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx { - let (tx_notify_ui, _rx) = mpsc::unbounded_channel(); - let state_dir = game_dir.join(".test-state"); - Ctx::new( - Arc::new(RwLock::new(PeerGameDB::new())), - "peer".to_string(), - game_dir, - state_dir, - Arc::new(NoopUnpacker), - CancellationToken::new(), - TaskTracker::new(), - Arc::new(RwLock::new(catalog)), - Arc::new(RwLock::new(std::collections::HashMap::new())), - Arc::new(crate::NoopStreamInstallProvider), - ) - .to_peer_ctx(tx_notify_ui) - } - - fn call_to_play_event(actor_id: &str, action: CallToPlayAction) -> CallToPlayEvent { - CallToPlayEvent { - id: "event-1".to_string(), - call_id: "call-1".to_string(), - actor_id: actor_id.to_string(), - actor_name: "Alice".to_string(), - at: 8_000_000_000_000, - action, - } - } - - fn call_to_play_create(actor_id: &str) -> CallToPlayEvent { - call_to_play_event( - actor_id, - CallToPlayAction::Create { - game_id: "game".to_string(), - max_players: 4, - scheduled_for: None, - deadline: 8_000_000_060_000, - }, - ) + #[test] + fn every_control_codec_enforces_the_protocol_frame_bound() { + let codec = control_codec(); + assert_eq!(codec.max_frame_length(), MAX_CONTROL_FRAME_BYTES); } #[test] - fn local_relative_paths_are_never_transferable() { - assert!(path_points_inside_local("game", "game/local/save.dat")); - assert!(path_points_inside_local("game", "local/save.dat")); - assert!(path_points_inside_local("game", "game\\local\\save.dat")); - assert!(!path_points_inside_local("game", "game/version.ini")); - assert!(!path_points_inside_local("game", "game/archive.eti")); + fn trailing_frame_outcomes_are_never_accepted_as_eof() { + for outcome in [ + TrailingRead::ExtraFrame, + TrailingRead::Invalid, + TrailingRead::TimedOut, + ] { + assert_ne!(outcome, TrailingRead::Eof); + assert_ne!(outcome, TrailingRead::Cancelled); + } + } + + #[tokio::test(start_paused = true)] + async fn public_control_computation_and_egress_have_an_absolute_deadline() { + let cancellation = CancellationToken::new(); + let start = tokio::time::Instant::now(); + assert!( + control_io_with_deadline(std::future::pending::<()>(), &cancellation) + .await + .is_none() + ); + assert_eq!(start.elapsed(), OUTBOUND_CONTROL_IO_TIMEOUT); } #[test] - fn transferable_paths_must_belong_to_requested_game() { - assert!(relative_path_belongs_to_game("game", "game/version.ini")); - assert!(relative_path_belongs_to_game("game", "game\\archive.eti")); - assert!(!relative_path_belongs_to_game("game", "other/archive.eti")); - assert!(!relative_path_belongs_to_game("game", "archive.eti")); - assert!(!relative_path_belongs_to_game("game", "/game/archive.eti")); - assert!(!relative_path_belongs_to_game( - "game", - "../game/archive.eti" - )); - } - - #[tokio::test] - async fn known_peer_id_accepts_live_events_without_transport_ip_matching() { - let temp = TempDir::new("lanspread-call-to-play-known-peer"); - let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty()); - ctx.peer_game_db.write().await.upsert_peer( - "peer-alice".to_string(), - SocketAddr::from(([10, 66, 0, 2], 40000)), - ); - - let ack = - handle_call_to_play_events(&ctx, "peer-alice", vec![call_to_play_create("peer-alice")]) - .await; - - assert_eq!(ack, CallToPlayAck::Applied); - assert_eq!(ctx.call_to_play.write().await.snapshot().len(), 1); - } - - #[tokio::test] - async fn unknown_peer_and_mismatched_actor_receive_explicit_acks() { - let temp = TempDir::new("lanspread-call-to-play-identity"); - let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty()); - - assert_eq!( - handle_call_to_play_events( - &ctx, - "peer-alice", - vec![call_to_play_create("peer-alice")], - ) - .await, - CallToPlayAck::NeedHandshake - ); - - ctx.peer_game_db.write().await.upsert_peer( - "peer-alice".to_string(), - SocketAddr::from(([10, 66, 0, 2], 40000)), - ); - assert!(matches!( - handle_call_to_play_events( - &ctx, - "peer-alice", - vec![call_to_play_create("peer-mallory")], - ) - .await, - CallToPlayAck::Rejected { reason } - if reason.contains("does not match envelope peer") - )); - } - - #[tokio::test] - async fn live_event_ack_reports_missing_history_and_duplicates() { - let temp = TempDir::new("lanspread-call-to-play-outcomes"); - let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty()); - ctx.peer_game_db.write().await.upsert_peer( - "peer-alice".to_string(), - SocketAddr::from(([10, 66, 0, 2], 40000)), - ); - let orphan = call_to_play_event( - "peer-alice", - CallToPlayAction::AddTime { - deadline: 8_000_000_600_000, - }, - ); - assert_eq!( - handle_call_to_play_events(&ctx, "peer-alice", vec![orphan]).await, - CallToPlayAck::NeedHistory - ); - - let create = call_to_play_create("peer-alice"); - assert_eq!( - handle_call_to_play_events(&ctx, "peer-alice", vec![create.clone()]).await, - CallToPlayAck::Applied - ); - assert_eq!( - handle_call_to_play_events(&ctx, "peer-alice", vec![create]).await, - CallToPlayAck::Duplicate - ); - } - - #[tokio::test] - async fn get_game_response_respects_serve_gates() { - let temp = TempDir::new("lanspread-stream"); - write_file(&temp.path().join("ready").join("version.ini"), b"20250101"); - write_file( - &temp.path().join("non-catalog").join("version.ini"), - b"20250101", - ); - write_file(&temp.path().join("active").join("version.ini"), b"20250101"); - write_file( - &temp.path().join("wrong-version").join("version.ini"), - b"20260101", - ); - std::fs::create_dir_all(temp.path().join("missing-sentinel")) - .expect("missing sentinel root should be created"); - - let mut catalog = GameCatalog::empty(); - catalog.insert("ready".to_string(), Some("20250101".to_string())); - catalog.insert("active".to_string(), Some("20250101".to_string())); - catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string())); - catalog.insert("wrong-version".to_string(), Some("20250101".to_string())); - let ctx = test_ctx(temp.path().to_path_buf(), catalog); - ctx.active_operations - .write() - .await - .insert("active".to_string(), OperationKind::Downloading); - - assert!(matches!( - get_game_response(&ctx, "ready".to_string()).await, - Response::GetGame { id, .. } if id == "ready" - )); - assert!(matches!( - get_game_response(&ctx, "non-catalog".to_string()).await, - Response::GameNotFound(id) if id == "non-catalog" - )); - assert!(matches!( - get_game_response(&ctx, "active".to_string()).await, - Response::GameNotFound(id) if id == "active" - )); - assert!(matches!( - get_game_response(&ctx, "wrong-version".to_string()).await, - Response::GameNotFound(id) if id == "wrong-version" - )); - assert!(matches!( - get_game_response(&ctx, "missing-sentinel".to_string()).await, - Response::GameNotFound(id) if id == "missing-sentinel" - )); - } - - #[tokio::test] - async fn file_transfer_dispatch_respects_serve_gates() { - let temp = TempDir::new("lanspread-stream"); - write_file(&temp.path().join("ready").join("version.ini"), b"20250101"); - write_file( - &temp.path().join("non-catalog").join("version.ini"), - b"20250101", - ); - write_file(&temp.path().join("active").join("version.ini"), b"20250101"); - write_file( - &temp.path().join("wrong-version").join("version.ini"), - b"20260101", - ); - std::fs::create_dir_all(temp.path().join("missing-sentinel")) - .expect("missing sentinel root should be created"); - - let mut catalog = GameCatalog::empty(); - catalog.insert("ready".to_string(), Some("20250101".to_string())); - catalog.insert("active".to_string(), Some("20250101".to_string())); - catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string())); - catalog.insert("wrong-version".to_string(), Some("20250101".to_string())); - let ctx = test_ctx(temp.path().to_path_buf(), catalog); - ctx.active_operations - .write() - .await - .insert("active".to_string(), OperationKind::Downloading); - - assert!(can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/version.ini").await); - assert!( - !can_dispatch_file_transfer(&ctx, temp.path(), "ready", "active/version.ini").await - ); - assert!( - !can_dispatch_file_transfer( - &ctx, - temp.path(), - "non-catalog", - "non-catalog/version.ini", - ) - .await - ); - assert!( - !can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await - ); - assert!( - !can_dispatch_file_transfer( - &ctx, - temp.path(), - "wrong-version", - "wrong-version/version.ini", - ) - .await - ); - assert!( - !can_dispatch_file_transfer( - &ctx, - temp.path(), - "missing-sentinel", - "missing-sentinel/archive.eti", - ) - .await - ); - assert!( - !can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/local/save.dat").await - ); + fn only_long_lived_payload_requests_move_to_the_reserved_bulk_pool() { + assert!(!request_is_bulk(&Request::Ping)); + assert!(request_is_bulk(&Request::StreamInstall { + game_id: "game".to_owned(), + content_id: ContentId::from_bytes([1; 32]), + })); } } diff --git a/crates/lanspread-peer/src/services/transfer.rs b/crates/lanspread-peer/src/services/transfer.rs new file mode 100644 index 0000000..faeb9f5 --- /dev/null +++ b/crates/lanspread-peer/src/services/transfer.rs @@ -0,0 +1,952 @@ +//! Catalog-authorized outbound chunk and streamed-install admission. + +use std::{ + fs::File, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use lanspread_db::{ + content_manifest::{ + CanonicalCatalogPath, + CatalogContentManifest, + CatalogEntryKind, + CatalogFileEntry, + ContentId, + }, + db::Availability, +}; +use lanspread_proto::MAX_CONTROL_FRAME_BYTES; +use s2n_quic::{application, stream::SendStream}; +use tokio_util::{ + codec::{FramedWrite, LengthDelimitedCodec}, + sync::CancellationToken, +}; + +use crate::{ + context::PeerCtx, + download::open_catalog_file_for_read, + local_games::version_ini_is_regular_file, + peer::send_game_file_chunk, + scoped_blocking::scoped_blocking, + stream_install::{send_game_install_stream, send_stream_install_error}, +}; + +type ResponseWriter = FramedWrite; + +pub(super) enum ChunkDispatch { + Finished(ResponseWriter), + Reset(ResponseWriter), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ChunkSendDisposition { + Finished, + Reset, +} + +fn chunk_send_disposition(result: &eyre::Result<()>) -> ChunkSendDisposition { + if result.is_ok() { + ChunkSendDisposition::Finished + } else { + ChunkSendDisposition::Reset + } +} + +fn control_codec() -> LengthDelimitedCodec { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_CONTROL_FRAME_BYTES) + .new_codec() +} + +fn load_expected_catalog_manifest( + ctx: &PeerCtx, + game_id: &str, +) -> Option> { + let catalog = Arc::clone(&ctx.catalog); + let manifest_game_id = game_id.to_owned(); + match scoped_blocking(move || catalog.manifest(&manifest_game_id)) { + Ok(manifest) => Some(manifest), + Err(error) => { + log::error!("Failed to load catalog content manifest for {game_id}: {error}"); + None + } + } +} + +async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool { + if ctx.recovery_quarantine.is_blocked(game_dir, game_id) + || ctx.active_operations.read().await.contains_key(game_id) + { + return false; + } + + let summary = ctx.local_library.read().await.games.get(game_id).cloned(); + let Some(summary) = summary else { + return false; + }; + if !summary.downloaded || summary.availability != Availability::Ready { + return false; + } + + let catalog = ctx.catalog.catalog(); + if !catalog.contains(game_id) { + return false; + } + let expected_version = catalog.expected_version(game_id).map(str::to_owned); + if expected_version + .as_deref() + .is_some_and(|expected| summary.eti_version.as_deref() != Some(expected)) + { + return false; + } + + let game_root = game_dir.join(game_id); + if !version_ini_is_regular_file(&game_root).await { + return false; + } + let expected_version_for_read = expected_version.clone(); + scoped_blocking(move || { + expected_version_for_read.as_deref().is_none_or(|expected| { + lanspread_db::db::read_version_from_ini(&game_root) + .is_ok_and(|version| version.as_deref() == Some(expected)) + }) + }) +} + +fn authorize_catalog_file_request<'a>( + manifest: &'a CatalogContentManifest, + game_id: &str, + content_id: ContentId, + relative_path: &CanonicalCatalogPath, + offset: u64, + length: u64, +) -> Option<&'a CatalogFileEntry> { + if manifest.game_id() != game_id || manifest.content_id() != content_id { + return None; + } + + // Exact lookup intentionally performs no normalization. Manifest keys have + // already passed the canonical, portable, and reserved-path policy. + let entry = manifest.file_entry(relative_path.as_str())?; + if entry.kind() != CatalogEntryKind::File + || !catalog_chunk_range_is_exact(entry.size(), manifest.chunk_size(), offset, length) + { + return None; + } + Some(entry) +} + +fn catalog_chunk_range_is_exact(file_size: u64, chunk_size: u64, offset: u64, length: u64) -> bool { + if chunk_size == 0 { + return false; + } + if file_size == 0 { + return offset == 0 && length == 0; + } + offset < file_size + && offset.is_multiple_of(chunk_size) + && length == std::cmp::min(chunk_size, file_size - offset) +} + +static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1); + +struct TransferGuard { + game_id: String, + id: u64, + cancel_token: CancellationToken, + active_outbound_transfers: crate::context::OutboundTransfers, + notifier: crate::context::OutboundTransferNotifier, + armed: bool, +} + +impl TransferGuard { + async fn new( + game_id: String, + active_outbound_transfers: crate::context::OutboundTransfers, + notifier: crate::context::OutboundTransferNotifier, + shutdown: &CancellationToken, + ) -> (Self, CancellationToken) { + let id = NEXT_TRANSFER_ID.fetch_add(1, Ordering::SeqCst); + let token = shutdown.child_token(); + { + let mut active = active_outbound_transfers.write().await; + active + .entry(game_id.clone()) + .or_default() + .push((id, token.clone())); + } + notifier.notify(); + ( + Self { + game_id, + id, + cancel_token: token.clone(), + active_outbound_transfers, + notifier, + armed: true, + }, + token, + ) + } + + /// Removes the registry entry before returning. Dropping this future while + /// it waits retains fail-closed tracking and cancels the transfer. + async fn finish(mut self) { + { + let mut active = self.active_outbound_transfers.write().await; + if let Some(tokens) = active.get_mut(&self.game_id) { + tokens.retain(|(transfer_id, _)| *transfer_id != self.id); + if tokens.is_empty() { + active.remove(&self.game_id); + } + } + } + self.armed = false; + self.notifier.notify(); + } +} + +impl Drop for TransferGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + log::error!( + "Outbound transfer guard for {} ended unexpectedly; retaining transfer tracking until process restart", + self.game_id + ); + self.cancel_token.cancel(); + } +} + +#[derive(Clone, Copy)] +enum OutboundTransferRequest<'a> { + CatalogChunk { + content_id: ContentId, + relative_path: &'a CanonicalCatalogPath, + offset: u64, + length: u64, + }, + StreamInstall { + content_id: ContentId, + }, +} + +enum AdmittedOutboundPayload { + CatalogFile { + file: File, + }, + StreamInstall { + game_dir: PathBuf, + manifest: Arc, + }, +} + +struct AdmittedOutboundTransfer { + guard: TransferGuard, + cancel_token: CancellationToken, + payload: AdmittedOutboundPayload, +} + +/// Validates content identity before readiness checks, filesystem opens, +/// transfer registration, or provider work. `SetGameDir` holds the same +/// admission barrier while draining the prior directory epoch. +async fn admit_outbound_transfer( + ctx: &PeerCtx, + game_id: &str, + request: OutboundTransferRequest<'_>, + stream_shutdown: &CancellationToken, +) -> Option { + let admission = ctx.operation_admission.lock().await; + if stream_shutdown.is_cancelled() { + return None; + } + let game_dir = ctx.game_dir.read().await.clone(); + let payload = match request { + OutboundTransferRequest::CatalogChunk { + content_id, + relative_path, + offset, + length, + } => { + let manifest = load_expected_catalog_manifest(ctx, game_id)?; + if manifest.content_id() != content_id { + log::warn!( + "Declining catalog chunk for {game_id}: requested content {content_id} does not match the local catalog" + ); + return None; + } + if !can_serve_game(ctx, &game_dir, game_id).await { + return None; + } + let authorized_entry = authorize_catalog_file_request( + &manifest, + game_id, + content_id, + relative_path, + offset, + length, + )?; + let file = match open_catalog_file_for_read( + &game_dir, + game_id, + authorized_entry.canonical_path(), + authorized_entry.size(), + ) { + Ok(file) => file, + Err(error) => { + log::warn!("Declining catalog file transfer for {relative_path}: {error}"); + return None; + } + }; + AdmittedOutboundPayload::CatalogFile { file } + } + OutboundTransferRequest::StreamInstall { content_id } => { + let manifest = load_expected_catalog_manifest(ctx, game_id)?; + if manifest.content_id() != content_id { + log::warn!( + "Declining StreamInstall for {game_id}: requested content {content_id} does not match the local catalog" + ); + return None; + } + if !can_serve_game(ctx, &game_dir, game_id).await + || !manifest.supports_streamed_install() + { + return None; + } + AdmittedOutboundPayload::StreamInstall { game_dir, manifest } + } + }; + if stream_shutdown.is_cancelled() { + return None; + } + + let (guard, cancel_token) = TransferGuard::new( + game_id.to_string(), + ctx.active_outbound_transfers.clone(), + ctx.outbound_transfer_notifier.clone(), + stream_shutdown, + ) + .await; + drop(admission); + Some(AdmittedOutboundTransfer { + guard, + cancel_token, + payload, + }) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_file_chunk_request( + ctx: &PeerCtx, + game_id: String, + content_id: ContentId, + relative_path: CanonicalCatalogPath, + offset: u64, + length: u64, + framed_tx: ResponseWriter, + stream_shutdown: &CancellationToken, +) -> ChunkDispatch { + log::info!( + "Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})" + ); + let mut tx = framed_tx.into_inner(); + let Some(AdmittedOutboundTransfer { + guard, + cancel_token, + payload: AdmittedOutboundPayload::CatalogFile { file }, + }) = admit_outbound_transfer( + ctx, + &game_id, + OutboundTransferRequest::CatalogChunk { + content_id, + relative_path: &relative_path, + offset, + length, + }, + stream_shutdown, + ) + .await + else { + log::info!("Declining GetGameFileChunk for {relative_path}"); + reset_declined_transfer(&mut tx, "GetGameFileChunk"); + return ChunkDispatch::Reset(FramedWrite::new(tx, control_codec())); + }; + + let send_result = send_game_file_chunk( + relative_path.as_str(), + offset, + length, + file, + &mut tx, + cancel_token, + ) + .await; + guard.finish().await; + match chunk_send_disposition(&send_result) { + ChunkSendDisposition::Finished => { + ChunkDispatch::Finished(FramedWrite::new(tx, control_codec())) + } + ChunkSendDisposition::Reset => { + // The sender resets on every exceptional exit. Preserve that + // transport failure classification by preventing the dispatcher + // from following it with a clean FIN. + if let Err(error) = send_result { + log::debug!("Chunk send ended with a reset: {error:#}"); + } + ChunkDispatch::Reset(FramedWrite::new(tx, control_codec())) + } + } +} + +pub(super) async fn handle_stream_install_request( + ctx: &PeerCtx, + game_id: String, + content_id: ContentId, + framed_tx: ResponseWriter, + stream_shutdown: &CancellationToken, +) -> ResponseWriter { + log::info!("Received StreamInstall request for {game_id} from peer"); + let mut tx = framed_tx.into_inner(); + let Some(AdmittedOutboundTransfer { + guard, + cancel_token, + payload: AdmittedOutboundPayload::StreamInstall { game_dir, manifest }, + }) = admit_outbound_transfer( + ctx, + &game_id, + OutboundTransferRequest::StreamInstall { content_id }, + stream_shutdown, + ) + .await + else { + tx = send_stream_install_error( + tx, + format!("game {game_id} is not transferable"), + &game_id, + stream_shutdown, + ) + .await; + return FramedWrite::new(tx, control_codec()); + }; + + let game_root = game_dir.join(&game_id); + let (returned_tx, result) = send_game_install_stream( + ctx.stream_install_provider.clone(), + tx, + &game_root, + &game_id, + manifest, + cancel_token, + ) + .await; + if let Err(error) = result { + log::warn!("StreamInstall for {game_id} ended with error: {error}"); + } + guard.finish().await; + FramedWrite::new(returned_tx, control_codec()) +} + +fn reset_declined_transfer(tx: &mut SendStream, label: &str) { + // A clean zero-length FIN is ambiguous with a valid empty catalog chunk, + // and a short clean FIN is an integrity failure at the receiver. + if let Err(error) = tx.reset(application::Error::UNKNOWN) { + log::debug!("Failed to reset declined {label} response: {error}"); + } +} + +#[cfg(test)] +mod tests { + use std::{ + collections::{HashMap, HashSet}, + path::Path, + sync::atomic::AtomicUsize, + }; + + use lanspread_db::content_manifest::{ + Blake3Digest, + CATALOG_CHUNK_SIZE, + CatalogBundle, + CatalogContentManifestBody, + CatalogExtractedEntry, + }; + use tokio::sync::{RwLock, mpsc}; + use tokio_util::task::TaskTracker; + + use super::*; + use crate::{ + StreamInstallFrameSink, + StreamInstallFuture, + StreamInstallProvider, + UnpackFuture, + Unpacker, + context::{Ctx, OperationKind, PeerCtx}, + identity::PeerIdentity, + library::LocalGameSummary, + network_generation::NetworkControl, + peer_db::PeerGameDB, + test_support::TempDir, + }; + + struct NoopUnpacker; + + impl Unpacker for NoopUnpacker { + fn unpack<'a>( + &'a self, + _archive: &'a Path, + _dest: &'a Path, + _cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { + Box::pin(async { Ok(()) }) + } + } + + #[derive(Default)] + struct CountingStreamInstallProvider { + calls: AtomicUsize, + } + + impl StreamInstallProvider for CountingStreamInstallProvider { + fn stream_archive<'a>( + &'a self, + _archive: &'a Path, + _frames: StreamInstallFrameSink, + _cancel_token: CancellationToken, + ) -> StreamInstallFuture<'a> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } + } + + fn manifest_with_streamed_install( + streamed_install_files: Vec, + ) -> CatalogContentManifest { + let bytes = b"payload"; + let archive = b"archive"; + let version = b"20250101"; + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "game", + "20250101", + vec![ + CatalogFileEntry::directory("directory") + .expect("test directory path is canonical"), + CatalogFileEntry::file("empty.bin", 0, Blake3Digest::hash(&[]), Vec::new()) + .expect("test empty path is canonical"), + CatalogFileEntry::file( + "game.eti", + u64::try_from(archive.len()).expect("test archive length fits"), + Blake3Digest::hash(archive), + vec![Blake3Digest::hash(archive)], + ) + .expect("test archive path is canonical"), + CatalogFileEntry::file( + "payload.bin", + u64::try_from(bytes.len()).expect("test length fits"), + Blake3Digest::hash(bytes), + vec![Blake3Digest::hash(bytes)], + ) + .expect("test path is canonical"), + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("test length fits"), + Blake3Digest::hash(version), + vec![Blake3Digest::hash(version)], + ) + .expect("test version path is canonical"), + ], + streamed_install_files, + ) + .expect("test manifest body is valid"), + ) + .expect("test manifest seals") + } + + fn manifest() -> CatalogContentManifest { + manifest_with_streamed_install(Vec::new()) + } + + fn streamable_manifest() -> CatalogContentManifest { + manifest_with_streamed_install(vec![ + CatalogExtractedEntry::file("installed/payload.bin", 7, Blake3Digest::hash(b"payload")) + .expect("test extracted path is canonical"), + ]) + } + + async fn test_peer_ctx( + root: &Path, + manifest: &CatalogContentManifest, + provider: Arc, + ) -> (PeerCtx, mpsc::UnboundedReceiver) { + let catalog = Arc::new( + CatalogBundle::from_manifests([manifest.clone()]) + .expect("test catalog should be complete"), + ); + let ctx = Ctx::new( + Arc::new(RwLock::new(PeerGameDB::new())), + Arc::new(PeerIdentity::generate().expect("test identity should generate")), + root.to_path_buf(), + root.join(".state"), + Arc::new(NoopUnpacker), + CancellationToken::new(), + TaskTracker::new(), + catalog, + Arc::new(RwLock::new(HashMap::new())), + provider, + NetworkControl::disabled_for_test(), + ) + .expect("test context should initialize"); + assert!(ctx.recovery_quarantine.settle(root, HashSet::new())); + ctx.local_library.write().await.games.insert( + "game".to_owned(), + LocalGameSummary { + id: "game".to_owned(), + name: "game".to_owned(), + size: 15, + downloaded: true, + installed: false, + eti_version: Some("20250101".to_owned()), + availability: Availability::Ready, + }, + ); + let (tx, rx) = mpsc::unbounded_channel(); + (ctx.to_peer_ctx(tx, CancellationToken::new()), rx) + } + + async fn assert_chunk_rejected( + ctx: &PeerCtx, + content_id: ContentId, + relative_path: &CanonicalCatalogPath, + offset: u64, + length: u64, + ) { + assert!( + admit_outbound_transfer( + ctx, + "game", + OutboundTransferRequest::CatalogChunk { + content_id, + relative_path, + offset, + length, + }, + &CancellationToken::new(), + ) + .await + .is_none() + ); + assert!(ctx.active_outbound_transfers.read().await.is_empty()); + } + + #[test] + fn wrong_identity_path_or_range_never_authorizes_an_entry() { + let manifest = manifest(); + let path = CanonicalCatalogPath::new("payload.bin").expect("test path is canonical"); + let wrong_path = CanonicalCatalogPath::new("other.bin").expect("test path is canonical"); + let content_id = manifest.content_id(); + assert!( + authorize_catalog_file_request( + &manifest, + "game", + ContentId::from_bytes([9; 32]), + &path, + 0, + 7, + ) + .is_none() + ); + assert!( + authorize_catalog_file_request(&manifest, "wrong-game", content_id, &path, 0, 7,) + .is_none() + ); + assert!( + authorize_catalog_file_request(&manifest, "game", content_id, &wrong_path, 0, 7,) + .is_none() + ); + assert!( + authorize_catalog_file_request(&manifest, "game", content_id, &path, 1, 6,).is_none() + ); + assert!( + authorize_catalog_file_request(&manifest, "game", content_id, &path, 0, 7,).is_some() + ); + } + + #[test] + fn chunk_boundaries_are_exact_including_empty_files() { + assert!(catalog_chunk_range_is_exact(10, 4, 0, 4)); + assert!(catalog_chunk_range_is_exact(10, 4, 4, 4)); + assert!(catalog_chunk_range_is_exact(10, 4, 8, 2)); + assert!(!catalog_chunk_range_is_exact(10, 4, 1, 4)); + assert!(!catalog_chunk_range_is_exact(10, 4, 8, 1)); + assert!(catalog_chunk_range_is_exact(0, CATALOG_CHUNK_SIZE, 0, 0)); + assert!(!catalog_chunk_range_is_exact(0, CATALOG_CHUNK_SIZE, 0, 1)); + } + + #[test] + fn admitted_chunk_send_error_preserves_reset_dispatch() { + let error = Err(eyre::eyre!("injected sender I/O failure")); + assert_eq!(chunk_send_disposition(&error), ChunkSendDisposition::Reset); + assert_eq!( + chunk_send_disposition(&Ok(())), + ChunkSendDisposition::Finished + ); + } + + #[allow(clippy::too_many_lines)] + #[tokio::test] + async fn admission_rejects_every_ineligible_v8_case_before_transfer_registration() { + let temp = TempDir::new("lanspread-transfer-admission"); + let game_root = temp.path().join("game"); + std::fs::create_dir_all(&game_root).expect("game root should be created"); + std::fs::write(game_root.join("version.ini"), b"20250101") + .expect("version sentinel should be written"); + std::fs::write(game_root.join("payload.bin"), b"payload") + .expect("payload should be written"); + std::fs::write(game_root.join("empty.bin"), b"").expect("empty payload should be written"); + std::fs::write(game_root.join("game.eti"), b"archive").expect("archive should be written"); + + let manifest = manifest(); + let content_id = manifest.content_id(); + let payload = CanonicalCatalogPath::new("payload.bin").expect("path should be canonical"); + let provider = Arc::new(CountingStreamInstallProvider::default()); + let (ctx, mut events) = test_peer_ctx(temp.path(), &manifest, Arc::clone(&provider)).await; + + assert_chunk_rejected(&ctx, ContentId::from_bytes([9; 32]), &payload, 0, 7).await; + assert!( + admit_outbound_transfer( + &ctx, + "not-in-catalog", + OutboundTransferRequest::CatalogChunk { + content_id, + relative_path: &payload, + offset: 0, + length: 7, + }, + &CancellationToken::new(), + ) + .await + .is_none() + ); + let missing = CanonicalCatalogPath::new("missing.bin").expect("path should be canonical"); + assert_chunk_rejected(&ctx, content_id, &missing, 0, 7).await; + let local_path = + CanonicalCatalogPath::new("local/payload.bin").expect("path should be canonical"); + assert_chunk_rejected(&ctx, content_id, &local_path, 0, 7).await; + let directory = CanonicalCatalogPath::new("directory").expect("path should be canonical"); + assert_chunk_rejected(&ctx, content_id, &directory, 0, 0).await; + assert_chunk_rejected(&ctx, content_id, &payload, 1, 6).await; + + ctx.local_library + .write() + .await + .games + .get_mut("game") + .expect("summary should exist") + .downloaded = false; + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + ctx.local_library + .write() + .await + .games + .get_mut("game") + .expect("summary should exist") + .downloaded = true; + + ctx.local_library + .write() + .await + .games + .get_mut("game") + .expect("summary should exist") + .availability = Availability::LocalOnly; + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + ctx.local_library + .write() + .await + .games + .get_mut("game") + .expect("summary should exist") + .availability = Availability::Ready; + + ctx.active_operations + .write() + .await + .insert("game".to_owned(), OperationKind::Updating); + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + ctx.active_operations.write().await.remove("game"); + + ctx.recovery_quarantine.begin(temp.path().to_path_buf()); + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + assert!(ctx.recovery_quarantine.settle(temp.path(), HashSet::new())); + + ctx.local_library + .write() + .await + .games + .get_mut("game") + .expect("summary should exist") + .eti_version = Some("20240101".to_owned()); + std::fs::write(game_root.join("version.ini"), b"20240101") + .expect("wrong version should be written"); + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + ctx.local_library + .write() + .await + .games + .get_mut("game") + .expect("summary should exist") + .eti_version = Some("20250101".to_owned()); + std::fs::write(game_root.join("version.ini"), b"20250101") + .expect("correct version should be restored"); + + std::fs::remove_file(game_root.join("version.ini")).expect("sentinel should be removable"); + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + std::fs::create_dir(game_root.join("version.ini")) + .expect("nonregular sentinel should be created"); + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + std::fs::remove_dir(game_root.join("version.ini")) + .expect("nonregular sentinel should be removable"); + std::fs::write(game_root.join("version.ini"), b"20250101") + .expect("sentinel should be restored"); + + std::fs::write(game_root.join("payload.bin"), b"short") + .expect("wrong-sized payload should be written"); + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + std::fs::remove_file(game_root.join("payload.bin")) + .expect("wrong-sized payload should be removable"); + std::fs::write(temp.path().join("outside.bin"), b"payload") + .expect("outside payload should be written"); + symlink( + temp.path().join("outside.bin"), + game_root.join("payload.bin"), + ) + .expect("payload symlink should be created"); + assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await; + std::fs::remove_file(game_root.join("payload.bin")) + .expect("payload symlink should be removable"); + } + + assert!( + admit_outbound_transfer( + &ctx, + "game", + OutboundTransferRequest::StreamInstall { + content_id: ContentId::from_bytes([9; 32]), + }, + &CancellationToken::new(), + ) + .await + .is_none(), + "wrong-content StreamInstall must be rejected" + ); + assert!( + admit_outbound_transfer( + &ctx, + "game", + OutboundTransferRequest::StreamInstall { content_id }, + &CancellationToken::new(), + ) + .await + .is_none(), + "manifest without extracted output must not admit StreamInstall" + ); + assert!(ctx.active_outbound_transfers.read().await.is_empty()); + assert_eq!(provider.calls.load(Ordering::SeqCst), 0); + assert!(events.try_recv().is_err()); + + std::fs::write(game_root.join("payload.bin"), b"payload") + .expect("valid payload should be restored"); + let admitted = admit_outbound_transfer( + &ctx, + "game", + OutboundTransferRequest::CatalogChunk { + content_id, + relative_path: &payload, + offset: 0, + length: 7, + }, + &CancellationToken::new(), + ) + .await + .expect("exact catalog request should be admitted"); + assert_eq!(ctx.active_outbound_transfers.read().await.len(), 1); + let AdmittedOutboundTransfer { guard, payload, .. } = admitted; + assert!(matches!( + payload, + AdmittedOutboundPayload::CatalogFile { .. } + )); + drop(payload); + guard.finish().await; + assert!(ctx.active_outbound_transfers.read().await.is_empty()); + assert_eq!(provider.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn stream_install_admission_separates_identity_capability_and_valid_payload() { + let temp = TempDir::new("lanspread-stream-install-admission"); + let game_root = temp.path().join("game"); + std::fs::create_dir_all(&game_root).expect("game root should be created"); + std::fs::write(game_root.join("version.ini"), b"20250101") + .expect("version sentinel should be written"); + std::fs::write(game_root.join("game.eti"), b"archive").expect("archive should be written"); + + let manifest = streamable_manifest(); + let content_id = manifest.content_id(); + let provider = Arc::new(CountingStreamInstallProvider::default()); + let (ctx, mut events) = test_peer_ctx(temp.path(), &manifest, Arc::clone(&provider)).await; + + assert!( + admit_outbound_transfer( + &ctx, + "game", + OutboundTransferRequest::StreamInstall { + content_id: ContentId::from_bytes([9; 32]), + }, + &CancellationToken::new(), + ) + .await + .is_none(), + "a stream-capable manifest must still reject the wrong content identity" + ); + assert!(ctx.active_outbound_transfers.read().await.is_empty()); + assert_eq!(provider.calls.load(Ordering::SeqCst), 0); + assert!(events.try_recv().is_err()); + + let admitted = admit_outbound_transfer( + &ctx, + "game", + OutboundTransferRequest::StreamInstall { content_id }, + &CancellationToken::new(), + ) + .await + .expect("exact stream-capable request should cross the provider boundary"); + assert_eq!(ctx.active_outbound_transfers.read().await.len(), 1); + let AdmittedOutboundTransfer { guard, payload, .. } = admitted; + let AdmittedOutboundPayload::StreamInstall { + game_dir, + manifest: admitted_manifest, + } = payload + else { + panic!("StreamInstall admission returned a catalog-file payload"); + }; + assert_eq!(game_dir, temp.path()); + assert_eq!(admitted_manifest.content_id(), content_id); + assert!(admitted_manifest.supports_streamed_install()); + guard.finish().await; + assert!(ctx.active_outbound_transfers.read().await.is_empty()); + assert_eq!( + provider.calls.load(Ordering::SeqCst), + 0, + "provider work starts only after admission hands the payload to the sender" + ); + } +} diff --git a/crates/lanspread-peer/src/startup.rs b/crates/lanspread-peer/src/startup.rs index 96e4479..6782d26 100644 --- a/crates/lanspread-peer/src/startup.rs +++ b/crates/lanspread-peer/src/startup.rs @@ -1,52 +1,64 @@ //! Peer runtime task startup and shutdown orchestration. +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; +#[cfg(test)] +use std::time::Duration; use std::{ any::Any, future::Future, - net::SocketAddr, - panic::AssertUnwindSafe, - path::PathBuf, - sync::Arc, - time::Duration, + panic::{self, AssertUnwindSafe}, + path::{Path, PathBuf}, + sync::{Arc, mpsc as std_mpsc}, + thread::{self, JoinHandle as ThreadJoinHandle}, }; use futures::FutureExt as _; -use lanspread_db::db::GameCatalog; +use lanspread_db::content_manifest::CatalogBundle; use tokio::sync::{ RwLock, mpsc::{UnboundedReceiver, UnboundedSender}, - watch, }; use tokio_util::{sync::CancellationToken, task::TaskTracker}; use crate::{ PeerCommand, PeerEvent, + PeerId, + PeerIdentityDurability, PeerRuntimeComponent, StreamInstallProvider, Unpacker, context::Ctx, - events, - network::send_goodbye, + identity::PeerIdentity, peer_db::PeerGameDB, run_peer, - services::{ - run_local_game_monitor, - run_peer_discovery, - run_ping_service, - run_server_component, - }, + services::run_local_game_monitor, }; /// Handle to a running peer runtime. /// -/// Holds the command sender plus the runtime's shutdown token and a `stopped` -/// signal so callers can request a clean shutdown and wait for goodbye -/// notifications to flush. +/// Holds the command sender and a joinable runtime supervisor so callers can +/// request a clean shutdown and join the complete child-drain/transport-cleanup +/// sequence. +/// The supervisor runs on its own OS thread and owns its Tokio runtime. That +/// isolated runtime keeps making progress while an active handle Drop +/// synchronously cancels and joins, even if the caller is on a current-thread +/// executor. +/// +/// Explicit [`Self::shutdown`] plus [`Self::wait_stopped`] is the ordinary +/// ownership boundary. Active Drop is deliberately blocking: callers must not +/// invoke it reentrantly from peer-owned work or while holding a lock/resource +/// that peer shutdown needs. Those ownership cycles cannot be made quiescent by +/// any synchronous destructor. +#[must_use = "call shutdown and await wait_stopped before dropping the peer runtime handle"] pub struct PeerRuntimeHandle { tx: UnboundedSender, - shutdown: CancellationToken, - stopped: watch::Receiver, + supervisor: RuntimeSupervisor, + accepted_game_dir: PathBuf, + identity: Arc, + peer_id: PeerId, + identity_durability: PeerIdentityDurability, } impl PeerRuntimeHandle { @@ -56,23 +68,215 @@ impl PeerRuntimeHandle { self.tx.clone() } + /// Returns the canonical game directory accepted by peer startup. + #[must_use] + pub fn accepted_game_dir(&self) -> &Path { + &self.accepted_game_dir + } + + /// Returns the exact installation identity retained by this runtime. + #[must_use] + pub fn identity(&self) -> Arc { + Arc::clone(&self.identity) + } + + /// Returns the installation identity used by this runtime. + #[must_use] + pub const fn peer_id(&self) -> PeerId { + self.peer_id + } + + /// Returns the redacted identity durability outcome for this runtime. + #[must_use] + pub const fn identity_durability(&self) -> PeerIdentityDurability { + self.identity_durability + } + /// Signals the runtime to shut down. Idempotent. pub fn shutdown(&self) { - self.shutdown.cancel(); + self.supervisor.shutdown(); } /// Resolves once the runtime task has fully stopped (services drained, - /// goodbye notifications sent). Returns even if the runtime stopped - /// without an explicit shutdown request. + /// transport endpoints joined). Returns + /// even if the runtime stopped without an explicit shutdown request. + /// Cancelling this wait leaves the join right in the handle, so callers may + /// retry it or rely on the handle's strict Drop boundary. pub async fn wait_stopped(&mut self) { - let _ = self.stopped.wait_for(|stopped| *stopped).await; + self.supervisor.wait_stopped().await; + } +} + +impl Drop for PeerRuntimeHandle { + fn drop(&mut self) { + if !self.supervisor.is_joined() { + log::error!( + "Peer runtime handle dropped before wait_stopped completed; cancelling and synchronously joining its isolated runtime" + ); + #[cfg(test)] + ACTIVE_RUNTIME_HANDLE_DROPS.fetch_add(1, Ordering::SeqCst); + } + self.supervisor.shutdown_and_join(); + } +} + +#[cfg(test)] +static ACTIVE_RUNTIME_HANDLE_DROPS: AtomicUsize = AtomicUsize::new(0); + +struct RuntimeSupervisor { + shutdown: CancellationToken, + completion: CancellationToken, + thread: Option>, +} + +impl RuntimeSupervisor { + fn shutdown(&self) { + self.shutdown.cancel(); + } + + fn is_joined(&self) -> bool { + self.thread.is_none() + } + + /// Waits without moving the thread handle across an await. Cancelling this + /// future therefore leaves the sole join right in `self` for a retry or + /// synchronous Drop. + async fn wait_stopped(&mut self) { + if self.thread.is_none() { + return; + } + + self.completion.cancelled().await; + while self + .thread + .as_ref() + .is_some_and(|thread| !thread.is_finished()) + { + tokio::task::yield_now().await; + } + self.join_completed(); + } + + fn join_completed(&mut self) { + if let Some(thread) = self.thread.take() { + report_supervisor_join(thread.join()); + } + } + + fn shutdown_and_join(&mut self) { + self.shutdown.cancel(); + if let Some(thread) = self.thread.take() { + report_supervisor_join(thread.join()); + } + } +} + +impl Drop for RuntimeSupervisor { + fn drop(&mut self) { + self.shutdown_and_join(); + } +} + +struct ThreadCompletionGuard(CancellationToken); + +impl Drop for ThreadCompletionGuard { + fn drop(&mut self) { + self.0.cancel(); + } +} + +fn report_supervisor_join(result: thread::Result<()>) { + if let Err(payload) = result { + log::error!( + "Peer runtime supervisor thread panicked: {}", + panic_payload_to_string(payload.as_ref()) + ); + } +} + +fn spawn_runtime_supervisor( + shutdown: CancellationToken, + start: Start, +) -> eyre::Result +where + Start: FnOnce() -> eyre::Result + Send + 'static, + Root: Future + 'static, +{ + let completion = CancellationToken::new(); + let completion_guard = ThreadCompletionGuard(completion.clone()); + let (startup_tx, startup_rx) = std_mpsc::sync_channel::>(1); + let startup_ready = startup_tx.clone(); + + let thread = thread::Builder::new() + .name("lanspread-peer-supervisor".to_string()) + .spawn(move || { + let _completion_guard = completion_guard; + let outcome = panic::catch_unwind(AssertUnwindSafe(|| -> eyre::Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("lanspread-peer-worker") + .build() + .map_err(|error| eyre::eyre!("failed to build peer Tokio runtime: {error}"))?; + + runtime.block_on(async move { + let root = start()?; + // This acknowledges runtime-local construction only. + // Initial recovery and required-service readiness remain + // asynchronous and are reported through PeerEvent. + startup_ready + .send(Ok(())) + .map_err(|_| eyre::eyre!("peer startup receiver was dropped"))?; + root.await; + Ok(()) + }) + })); + + let failure = match outcome { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(format!("{error:#}")), + Err(payload) => Some(format!( + "supervisor panicked: {}", + panic_payload_to_string(payload.as_ref()) + )), + }; + if let Some(failure) = failure { + log::error!("Peer runtime supervisor failed: {failure}"); + let _ = startup_tx.send(Err(failure)); + } + // `runtime` has been destroyed before this closure reaches its + // completion guard. Its worker pool and every detached async task + // are therefore gone before waiters can join this thread. + }) + .map_err(|error| eyre::eyre!("failed to spawn peer runtime supervisor: {error}"))?; + + let mut supervisor = RuntimeSupervisor { + shutdown, + completion, + thread: Some(thread), + }; + match startup_rx.recv() { + Ok(Ok(())) => Ok(supervisor), + Ok(Err(error)) => { + supervisor.shutdown_and_join(); + Err(eyre::eyre!("peer runtime startup failed: {error}")) + } + Err(error) => { + supervisor.shutdown_and_join(); + Err(eyre::eyre!( + "peer runtime supervisor stopped before reporting startup: {error}" + )) + } } } #[derive(Clone, Copy, Debug)] pub(crate) enum SupervisionPolicy { + #[cfg(test)] Required, - Restart { backoff: Duration }, + #[cfg(test)] + Restart { + backoff: Duration, + }, BestEffort, } @@ -82,159 +286,104 @@ pub(crate) fn spawn_peer_runtime( rx_control: UnboundedReceiver, tx_notify_ui: UnboundedSender, peer_game_db: Arc>, - peer_id: String, + peer_identity: Arc, + identity_durability: PeerIdentityDurability, game_dir: PathBuf, state_dir: PathBuf, unpacker: Arc, - catalog: Arc>, + catalog: Arc, active_outbound_transfers: crate::context::OutboundTransfers, stream_install_provider: Arc, -) -> PeerRuntimeHandle { + local_network_sharing: bool, +) -> eyre::Result { let shutdown = CancellationToken::new(); - let task_tracker = TaskTracker::new(); - let (tx_stopped, stopped) = watch::channel(false); - + let accepted_game_dir = game_dir.clone(); + let handle_identity = Arc::clone(&peer_identity); + let peer_id = peer_identity.peer_id(); let runtime_shutdown = shutdown.clone(); - let runtime_tracker = task_tracker.clone(); - tokio::spawn(async move { - if let Err(err) = run_peer( - rx_control, - tx_notify_ui, - peer_game_db, - peer_id, - game_dir, - state_dir, - unpacker, - runtime_shutdown.clone(), - runtime_tracker.clone(), - catalog, - active_outbound_transfers, - stream_install_provider, - ) - .await - { - log::error!("Peer system failed: {err}"); - } + let supervisor = spawn_runtime_supervisor(shutdown, move || { + let task_tracker = TaskTracker::new(); + let runtime_tracker = task_tracker.clone(); + let root_shutdown = runtime_shutdown.clone(); - runtime_shutdown.cancel(); - runtime_tracker.close(); - runtime_tracker.wait().await; - if tx_stopped.send(true).is_err() { - log::debug!("Peer runtime stopped after handle was dropped"); - } - }); + Ok(run_runtime_root( + runtime_shutdown, + runtime_tracker, + run_peer( + rx_control, + tx_notify_ui, + peer_game_db, + peer_identity, + game_dir, + state_dir, + unpacker, + root_shutdown.clone(), + task_tracker, + catalog, + active_outbound_transfers, + stream_install_provider, + local_network_sharing, + ), + async { Ok(()) }, + )) + })?; - PeerRuntimeHandle { + Ok(PeerRuntimeHandle { tx: tx_control, - shutdown, - stopped, + supervisor, + accepted_game_dir, + identity: handle_identity, + peer_id, + identity_durability, + }) +} + +async fn run_runtime_root( + shutdown: CancellationToken, + task_tracker: TaskTracker, + root: Root, + cleanup: Cleanup, +) where + Root: Future>, + Cleanup: Future>, +{ + let outcome = AssertUnwindSafe(root).catch_unwind().await; + + // Cancellation and child drainage are the root task's unconditional + // epilogue. In particular, a panic in `run_peer` must not detach services or + // mutation tasks while the handle appears stopped. + shutdown.cancel(); + task_tracker.close(); + task_tracker.wait().await; + + // A caller-supplied final cleanup seam remains under the same root + // boundary. Network generations own and join their QUIC endpoints before + // their manager task returns, so production currently supplies a no-op. + let cleanup_outcome = AssertUnwindSafe(cleanup).catch_unwind().await; + + match cleanup_outcome { + Ok(Ok(())) => {} + Ok(Err(error)) => log::error!("Peer transport cleanup failed: {error:#}"), + Err(payload) => log::error!( + "Peer transport cleanup panicked: {}", + panic_payload_to_string(payload.as_ref()) + ), + } + + match outcome { + Ok(Ok(())) => {} + Ok(Err(error)) => log::error!("Peer system failed: {error}"), + Err(payload) => log::error!( + "Peer system panicked: {}", + panic_payload_to_string(payload.as_ref()) + ), } } -pub(crate) fn spawn_startup_services(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { - spawn_quic_server(ctx, tx_notify_ui); - spawn_peer_discovery_service(ctx, tx_notify_ui); - spawn_peer_liveness_service(ctx, tx_notify_ui); +pub(crate) fn spawn_core_services(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { spawn_local_library_monitor(ctx, tx_notify_ui); } -pub(crate) async fn send_goodbye_notifications(ctx: &Ctx) { - let peer_id = ctx.peer_id.as_ref().clone(); - let peer_addresses = { ctx.peer_game_db.read().await.get_peer_addresses() }; - - futures::future::join_all( - peer_addresses - .into_iter() - .map(|peer_addr| send_goodbye_notification(peer_addr, peer_id.clone())), - ) - .await; -} - -fn spawn_quic_server(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { - let server_addr = SocketAddr::from(([0, 0, 0, 0], 0)); - let peer_ctx = ctx.to_peer_ctx(tx_notify_ui.clone()); - let tx_notify_ui = tx_notify_ui.clone(); - let supervisor_tx = tx_notify_ui.clone(); - - spawn_supervised_service( - &ctx.task_tracker, - &ctx.shutdown, - &supervisor_tx, - PeerRuntimeComponent::QuicServer, - SupervisionPolicy::Required, - move || { - let peer_ctx = peer_ctx.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - async move { run_server_component(server_addr, peer_ctx, tx_notify_ui).await } - }, - ); -} - -fn spawn_peer_discovery_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { - let ctx = ctx.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - let task_tracker = ctx.task_tracker.clone(); - let shutdown = ctx.shutdown.clone(); - let supervisor_tx = tx_notify_ui.clone(); - - spawn_supervised_service( - &task_tracker, - &shutdown, - &supervisor_tx, - PeerRuntimeComponent::Discovery, - SupervisionPolicy::Restart { - backoff: Duration::from_secs(5), - }, - move || { - let ctx = ctx.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - async move { run_peer_discovery(tx_notify_ui, ctx).await } - }, - ); -} - -fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { - let tx_notify_ui = tx_notify_ui.clone(); - let peer_game_db = ctx.peer_game_db.clone(); - let catalog = ctx.catalog.clone(); - let active_operations = ctx.active_operations.clone(); - let active_downloads = ctx.active_downloads.clone(); - let shutdown = ctx.shutdown.clone(); - let task_tracker = ctx.task_tracker.clone(); - let supervisor_tx = tx_notify_ui.clone(); - - spawn_supervised_service( - &ctx.task_tracker, - &ctx.shutdown, - &supervisor_tx, - PeerRuntimeComponent::Liveness, - SupervisionPolicy::Restart { - backoff: Duration::from_secs(5), - }, - move || { - let tx_notify_ui = tx_notify_ui.clone(); - let peer_game_db = peer_game_db.clone(); - let catalog = catalog.clone(); - let active_operations = active_operations.clone(); - let active_downloads = active_downloads.clone(); - let shutdown = shutdown.clone(); - let task_tracker = task_tracker.clone(); - async move { - run_ping_service( - tx_notify_ui, - peer_game_db, - catalog, - active_operations, - active_downloads, - shutdown, - task_tracker, - ) - .await - } - }, - ); -} - fn spawn_local_library_monitor(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { let ctx = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); @@ -256,14 +405,6 @@ fn spawn_local_library_monitor(ctx: &Ctx, tx_notify_ui: &UnboundedSender {} - Ok(Err(err)) => log::warn!("Failed to send Goodbye to {peer_addr}: {err}"), - Err(_) => log::warn!("Timed out sending Goodbye to {peer_addr}"), - } -} - fn spawn_supervised_service( task_tracker: &TaskTracker, shutdown: &CancellationToken, @@ -277,14 +418,15 @@ fn spawn_supervised_service( { let task_tracker = task_tracker.clone(); let shutdown = shutdown.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - task_tracker.spawn(async move { - loop { + #[cfg(not(test))] + { + let _ = tx_notify_ui; + debug_assert!(matches!(policy, SupervisionPolicy::BestEffort)); + task_tracker.spawn(async move { if shutdown.is_cancelled() { - break; + return; } - let result = match AssertUnwindSafe(make_service()).catch_unwind().await { Ok(result) => result, Err(payload) => Err(eyre::eyre!( @@ -293,43 +435,75 @@ fn spawn_supervised_service( )), }; if shutdown.is_cancelled() { - break; + return; } + match result { + Ok(()) => log::warn!("{component:?} exited"), + Err(err) => log::error!("{component:?} failed: {err}"), + } + }); + } - match policy { - SupervisionPolicy::Required => { - let error = match result { - Ok(()) => "component exited unexpectedly".to_string(), - Err(err) => err.to_string(), - }; - report_required_service_failure(&tx_notify_ui, component, error, &shutdown); + #[cfg(test)] + { + let tx_notify_ui = tx_notify_ui.clone(); + task_tracker.spawn(async move { + loop { + if shutdown.is_cancelled() { break; } - SupervisionPolicy::Restart { backoff } => { - match result { - Ok(()) => log::warn!("{component:?} exited; restarting in {backoff:?}"), - Err(err) => { - log::error!("{component:?} failed: {err}; restarting in {backoff:?}"); + + let result = match AssertUnwindSafe(make_service()).catch_unwind().await { + Ok(result) => result, + Err(payload) => Err(eyre::eyre!( + "component panicked: {}", + panic_payload_to_string(&payload) + )), + }; + if shutdown.is_cancelled() { + break; + } + + match policy { + #[cfg(test)] + SupervisionPolicy::Required => { + let error = match result { + Ok(()) => "component exited unexpectedly".to_string(), + Err(err) => err.to_string(), + }; + report_required_service_failure(&tx_notify_ui, component, error, &shutdown); + break; + } + #[cfg(test)] + SupervisionPolicy::Restart { backoff } => { + match result { + Ok(()) => log::warn!("{component:?} exited; restarting in {backoff:?}"), + Err(err) => { + log::error!( + "{component:?} failed: {err}; restarting in {backoff:?}" + ); + } + } + + tokio::select! { + () = shutdown.cancelled() => break, + () = tokio::time::sleep(backoff) => {} } } - - tokio::select! { - () = shutdown.cancelled() => break, - () = tokio::time::sleep(backoff) => {} + SupervisionPolicy::BestEffort => { + match result { + Ok(()) => log::warn!("{component:?} exited"), + Err(err) => log::error!("{component:?} failed: {err}"), + } + break; } } - SupervisionPolicy::BestEffort => { - match result { - Ok(()) => log::warn!("{component:?} exited"), - Err(err) => log::error!("{component:?} failed: {err}"), - } - break; - } } - } - }); + }); + } } +#[cfg(test)] fn report_required_service_failure( tx_notify_ui: &UnboundedSender, component: PeerRuntimeComponent, @@ -337,7 +511,7 @@ fn report_required_service_failure( shutdown: &CancellationToken, ) { log::error!("{component:?} failed: {error}"); - events::send(tx_notify_ui, PeerEvent::RuntimeFailed { component, error }); + crate::events::send(tx_notify_ui, PeerEvent::RuntimeFailed { component, error }); shutdown.cancel(); } @@ -356,17 +530,82 @@ fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String { #[cfg(test)] mod tests { use std::{ + future::Future, + path::{Path, PathBuf}, sync::{ Arc, - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, time::Duration, }; use tokio_util::{sync::CancellationToken, task::TaskTracker}; - use super::{SupervisionPolicy, spawn_supervised_service}; - use crate::{PeerRuntimeComponent, startup::PeerRuntimeHandle}; + use super::{ + ACTIVE_RUNTIME_HANDLE_DROPS, + PeerRuntimeHandle, + SupervisionPolicy, + run_runtime_root, + spawn_runtime_supervisor, + spawn_supervised_service, + }; + use crate::{PeerIdentity, PeerIdentityDurability, PeerRuntimeComponent}; + + fn test_runtime_handle( + shutdown: CancellationToken, + start: Start, + ) -> PeerRuntimeHandle + where + Start: FnOnce() -> eyre::Result + Send + 'static, + Root: Future + 'static, + { + test_runtime_handle_with_identity( + shutdown, + Arc::new(PeerIdentity::generate().expect("test identity should generate")), + PeerIdentityDurability::CallerProvided, + start, + ) + } + + fn test_runtime_handle_with_identity( + shutdown: CancellationToken, + identity: Arc, + identity_durability: PeerIdentityDurability, + start: Start, + ) -> PeerRuntimeHandle + where + Start: FnOnce() -> eyre::Result + Send + 'static, + Root: Future + 'static, + { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let peer_id = identity.peer_id(); + PeerRuntimeHandle { + tx, + supervisor: spawn_runtime_supervisor(shutdown, start) + .expect("test peer runtime supervisor should start"), + accepted_game_dir: PathBuf::from("/test/games"), + identity, + peer_id, + identity_durability, + } + } + + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + async fn panic_after_child_starts( + child_started: tokio::sync::oneshot::Receiver<()>, + ) -> eyre::Result<()> { + child_started + .await + .map_err(|_| eyre::eyre!("test child stopped before starting"))?; + panic!("injected peer root panic"); + } #[tokio::test] async fn required_service_failure_cancels_runtime_and_emits_event() { @@ -449,24 +688,444 @@ mod tests { } #[tokio::test] - async fn runtime_handle_can_shutdown_and_await_stopped() { - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + async fn runtime_handle_joins_child_drain_and_transport_cleanup_on_normal_shutdown() { let shutdown = CancellationToken::new(); - let (tx_stopped, stopped) = tokio::sync::watch::channel(false); - let mut handle = PeerRuntimeHandle { - tx, - shutdown: shutdown.clone(), - stopped, - }; - - tokio::spawn(async move { - shutdown.cancelled().await; - let _ = tx_stopped.send(true); + let (order_tx, mut order_rx) = tokio::sync::mpsc::unbounded_channel(); + let runtime_shutdown = shutdown.clone(); + let supervisor_order_tx = order_tx.clone(); + let mut handle = test_runtime_handle(shutdown, move || { + let tracker = TaskTracker::new(); + tracker.spawn({ + let shutdown = runtime_shutdown.clone(); + let order_tx = supervisor_order_tx.clone(); + async move { + shutdown.cancelled().await; + order_tx + .send("child-drained") + .expect("order receiver should stay open"); + } + }); + let root_shutdown = runtime_shutdown.clone(); + Ok(run_runtime_root( + runtime_shutdown, + tracker, + async move { + root_shutdown.cancelled().await; + Ok(()) + }, + async move { + supervisor_order_tx + .send("transport-cleanup") + .expect("order receiver should stay open"); + Ok(()) + }, + )) }); handle.shutdown(); tokio::time::timeout(Duration::from_secs(1), handle.wait_stopped()) .await - .expect("runtime handle should observe stopped"); + .expect("runtime handle should join normal shutdown"); + order_tx + .send("stopped") + .expect("order receiver should stay open"); + + assert_eq!(order_rx.recv().await, Some("child-drained")); + assert_eq!(order_rx.recv().await, Some("transport-cleanup")); + assert_eq!(order_rx.recv().await, Some("stopped")); + assert!(handle.supervisor.is_joined()); + assert_eq!(handle.accepted_game_dir(), Path::new("/test/games")); + assert_eq!(handle.peer_id(), handle.identity().peer_id()); + assert_eq!( + handle.identity_durability(), + PeerIdentityDurability::CallerProvided + ); + } + + #[tokio::test] + async fn runtime_handle_retains_exact_identity_arc_and_durability() { + let shutdown = CancellationToken::new(); + let runtime_shutdown = shutdown.clone(); + let identity = Arc::new(PeerIdentity::generate().expect("test identity should generate")); + let mut handle = test_runtime_handle_with_identity( + shutdown, + Arc::clone(&identity), + PeerIdentityDurability::Ephemeral, + move || { + Ok(async move { + runtime_shutdown.cancelled().await; + }) + }, + ); + + let retained_identity = handle.identity(); + assert!(Arc::ptr_eq(&retained_identity, &identity)); + assert_eq!(retained_identity.peer_id(), handle.peer_id()); + assert_eq!( + handle.identity_durability(), + PeerIdentityDurability::Ephemeral + ); + + handle.shutdown(); + handle.wait_stopped().await; + } + + #[tokio::test] + async fn root_panic_cancels_and_drains_live_child_before_transport_cleanup() { + let shutdown = CancellationToken::new(); + let (child_started_tx, child_started_rx) = tokio::sync::oneshot::channel(); + let (order_tx, mut order_rx) = tokio::sync::mpsc::unbounded_channel(); + let runtime_shutdown = shutdown.clone(); + let supervisor_order_tx = order_tx.clone(); + let mut handle = test_runtime_handle(shutdown.clone(), move || { + let tracker = TaskTracker::new(); + tracker.spawn({ + let shutdown = runtime_shutdown.clone(); + let order_tx = supervisor_order_tx.clone(); + async move { + child_started_tx + .send(()) + .expect("root should wait for the child"); + shutdown.cancelled().await; + order_tx + .send("child-drained") + .expect("order receiver should stay open"); + } + }); + Ok(run_runtime_root( + runtime_shutdown, + tracker, + panic_after_child_starts(child_started_rx), + async move { + supervisor_order_tx + .send("transport-cleanup") + .expect("order receiver should stay open"); + Ok(()) + }, + )) + }); + + tokio::time::timeout(Duration::from_secs(1), handle.wait_stopped()) + .await + .expect("panic-safe root should drain and join"); + order_tx + .send("stopped") + .expect("order receiver should stay open"); + + assert!(shutdown.is_cancelled()); + assert_eq!(order_rx.recv().await, Some("child-drained")); + assert_eq!(order_rx.recv().await, Some("transport-cleanup")); + assert_eq!(order_rx.recv().await, Some("stopped")); + assert!(handle.supervisor.is_joined()); + } + + #[tokio::test] + async fn cancelling_wait_stopped_retains_the_supervisor_thread_for_retry() { + let shutdown = CancellationToken::new(); + let (cleanup_started_tx, cleanup_started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let runtime_shutdown = shutdown.clone(); + let mut handle = test_runtime_handle(shutdown, move || { + let tracker = TaskTracker::new(); + let root_shutdown = runtime_shutdown.clone(); + Ok(run_runtime_root( + runtime_shutdown, + tracker, + async move { + root_shutdown.cancelled().await; + Ok(()) + }, + async move { + let _ = cleanup_started_tx.send(()); + release_rx + .await + .map_err(|_| eyre::eyre!("test cleanup release sender was dropped")) + }, + )) + }); + + handle.shutdown(); + cleanup_started_rx + .await + .expect("runtime should begin transport cleanup"); + assert!( + tokio::time::timeout(Duration::from_millis(10), handle.wait_stopped()) + .await + .is_err(), + "synthetic transport cleanup should keep the first join attempt pending" + ); + assert!( + !handle.supervisor.is_joined(), + "cancelled wait must leave the thread JoinHandle with the runtime owner" + ); + + release_tx + .send(()) + .expect("runtime should retain the transport cleanup future"); + tokio::time::timeout(Duration::from_secs(1), handle.wait_stopped()) + .await + .expect("retry should join the runtime after transport cleanup settles"); + assert!(handle.supervisor.is_joined()); + } + + #[tokio::test] + async fn active_drop_on_current_thread_runtime_waits_for_transport_cleanup() { + let shutdown = CancellationToken::new(); + let runtime_shutdown = shutdown.clone(); + let (cleanup_started_tx, cleanup_started_rx) = tokio::sync::oneshot::channel(); + let (cleanup_release_tx, cleanup_release_rx) = tokio::sync::oneshot::channel(); + let cleanup_finished = Arc::new(AtomicBool::new(false)); + let cleanup_finished_in_runtime = cleanup_finished.clone(); + let handle = test_runtime_handle(shutdown.clone(), move || { + let tracker = TaskTracker::new(); + let root_shutdown = runtime_shutdown.clone(); + Ok(run_runtime_root( + runtime_shutdown, + tracker, + async move { + root_shutdown.cancelled().await; + Ok(()) + }, + async move { + let _ = cleanup_started_tx.send(()); + cleanup_release_rx + .await + .map_err(|_| eyre::eyre!("test cleanup release sender was dropped"))?; + cleanup_finished_in_runtime.store(true, Ordering::SeqCst); + Ok(()) + }, + )) + }); + let drop_count = ACTIVE_RUNTIME_HANDLE_DROPS.load(Ordering::SeqCst); + let (drop_done_tx, drop_done_rx) = std::sync::mpsc::sync_channel(1); + let drop_thread = std::thread::spawn(move || { + let caller_runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("test caller runtime should build"); + caller_runtime.block_on(async move { + drop(handle); + }); + drop_done_tx + .send(()) + .expect("drop completion receiver should stay open"); + }); + + tokio::time::timeout(Duration::from_secs(1), cleanup_started_rx) + .await + .expect("active Drop should drive the root into transport cleanup") + .expect("transport cleanup should report that it started"); + assert!( + matches!( + drop_done_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ), + "Drop must not return while transport cleanup is blocked" + ); + + cleanup_release_tx + .send(()) + .expect("Drop should retain the cleanup future"); + drop_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("Drop should return after transport cleanup settles"); + drop_thread + .join() + .expect("runtime-handle drop thread should not panic"); + + assert!( + ACTIVE_RUNTIME_HANDLE_DROPS.load(Ordering::SeqCst) > drop_count, + "dropping an unjoined runtime must report lifecycle misuse" + ); + assert!(cleanup_finished.load(Ordering::SeqCst)); + assert!(shutdown.is_cancelled()); + } + + #[tokio::test] + async fn cancelled_wait_then_drop_still_joins_the_supervisor() { + let shutdown = CancellationToken::new(); + let runtime_shutdown = shutdown.clone(); + let (cleanup_started_tx, cleanup_started_rx) = tokio::sync::oneshot::channel(); + let (cleanup_release_tx, cleanup_release_rx) = tokio::sync::oneshot::channel(); + let cleanup_finished = Arc::new(AtomicBool::new(false)); + let cleanup_finished_in_runtime = cleanup_finished.clone(); + let mut handle = test_runtime_handle(shutdown, move || { + let tracker = TaskTracker::new(); + let root_shutdown = runtime_shutdown.clone(); + Ok(run_runtime_root( + runtime_shutdown, + tracker, + async move { + root_shutdown.cancelled().await; + Ok(()) + }, + async move { + let _ = cleanup_started_tx.send(()); + cleanup_release_rx + .await + .map_err(|_| eyre::eyre!("test cleanup release sender was dropped"))?; + cleanup_finished_in_runtime.store(true, Ordering::SeqCst); + Ok(()) + }, + )) + }); + + handle.shutdown(); + cleanup_started_rx + .await + .expect("runtime should enter transport cleanup"); + assert!( + tokio::time::timeout(Duration::from_millis(10), handle.wait_stopped()) + .await + .is_err(), + "the first wait should be cancelled while cleanup is blocked" + ); + assert!(!handle.supervisor.is_joined()); + + let (drop_done_tx, drop_done_rx) = std::sync::mpsc::sync_channel(1); + let drop_thread = std::thread::spawn(move || { + drop(handle); + drop_done_tx + .send(()) + .expect("drop completion receiver should stay open"); + }); + assert!(matches!( + drop_done_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + cleanup_release_tx + .send(()) + .expect("Drop should retain the cleanup future after a cancelled wait"); + drop_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("Drop should join after the retained cleanup settles"); + drop_thread + .join() + .expect("runtime-handle drop thread should not panic"); + assert!(cleanup_finished.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn root_and_cleanup_panics_are_bounded_before_drop_returns() { + let shutdown = CancellationToken::new(); + let runtime_shutdown = shutdown.clone(); + let (root_release_tx, root_release_rx) = tokio::sync::oneshot::channel(); + let (child_started_tx, child_started_rx) = tokio::sync::oneshot::channel(); + let child_drained = Arc::new(AtomicBool::new(false)); + let child_drained_in_runtime = child_drained.clone(); + let detached_task_dropped = Arc::new(AtomicBool::new(false)); + let detached_task_dropped_in_runtime = detached_task_dropped.clone(); + let handle = test_runtime_handle(shutdown, move || { + let tracker = TaskTracker::new(); + tracker.spawn({ + let child_shutdown = runtime_shutdown.clone(); + async move { + let _ = child_started_tx.send(()); + child_shutdown.cancelled().await; + child_drained_in_runtime.store(true, Ordering::SeqCst); + } + }); + Ok(run_runtime_root( + runtime_shutdown, + tracker, + async move { + root_release_rx + .await + .map_err(|_| eyre::eyre!("test root release sender was dropped"))?; + panic!("injected peer root panic"); + }, + async move { + let (task_started_tx, task_started_rx) = tokio::sync::oneshot::channel(); + let drop_flag = DropFlag(detached_task_dropped_in_runtime); + tokio::spawn(async move { + let _drop_flag = drop_flag; + let _ = task_started_tx.send(()); + std::future::pending::<()>().await; + }); + task_started_rx + .await + .map_err(|_| eyre::eyre!("detached cleanup task did not start"))?; + panic!("injected transport cleanup panic"); + }, + )) + }); + + child_started_rx + .await + .expect("tracked child should start before active Drop"); + let (drop_done_tx, drop_done_rx) = std::sync::mpsc::sync_channel(1); + let drop_thread = std::thread::spawn(move || { + drop(handle); + drop_done_tx + .send(()) + .expect("drop completion receiver should stay open"); + }); + assert!(matches!( + drop_done_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + + root_release_tx + .send(()) + .expect("active Drop should retain the panicking root future"); + drop_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("Drop should return after panic containment and runtime teardown"); + drop_thread + .join() + .expect("runtime-handle drop thread should not panic"); + + assert!(child_drained.load(Ordering::SeqCst)); + assert!( + detached_task_dropped.load(Ordering::SeqCst), + "supervisor runtime teardown must destroy even a task detached by panicking cleanup" + ); + } + + #[test] + fn startup_error_is_joined_before_spawn_returns() { + let shutdown = CancellationToken::new(); + let shutdown_observer = shutdown.clone(); + let startup_task_dropped = Arc::new(AtomicBool::new(false)); + let startup_task_dropped_in_runtime = startup_task_dropped.clone(); + let result = spawn_runtime_supervisor(shutdown, move || { + let drop_flag = DropFlag(startup_task_dropped_in_runtime); + tokio::spawn(async move { + let _drop_flag = drop_flag; + std::future::pending::<()>().await; + }); + Err::, _>(eyre::eyre!("injected startup error")) + }); + + let Err(error) = result else { + panic!("injected startup error should fail supervisor startup"); + }; + assert!(error.to_string().contains("injected startup error")); + assert!(startup_task_dropped.load(Ordering::SeqCst)); + assert!(shutdown_observer.is_cancelled()); + } + + #[test] + fn startup_panic_is_joined_before_spawn_returns() { + let shutdown = CancellationToken::new(); + let shutdown_observer = shutdown.clone(); + let startup_task_dropped = Arc::new(AtomicBool::new(false)); + let startup_task_dropped_in_runtime = startup_task_dropped.clone(); + let result = spawn_runtime_supervisor(shutdown, move || { + let drop_flag = DropFlag(startup_task_dropped_in_runtime); + tokio::spawn(async move { + let _drop_flag = drop_flag; + std::future::pending::<()>().await; + }); + panic!("injected startup panic"); + #[allow(unreachable_code)] + Ok(std::future::pending::<()>()) + }); + + let Err(error) = result else { + panic!("injected startup panic should fail supervisor startup"); + }; + assert!(error.to_string().contains("injected startup panic")); + assert!(startup_task_dropped.load(Ordering::SeqCst)); + assert!(shutdown_observer.is_cancelled()); } } diff --git a/crates/lanspread-peer/src/state_paths.rs b/crates/lanspread-peer/src/state_paths.rs index 13e1126..4dfc846 100644 --- a/crates/lanspread-peer/src/state_paths.rs +++ b/crates/lanspread-peer/src/state_paths.rs @@ -1,13 +1,19 @@ use std::path::{Path, PathBuf}; -const PEER_ID_FILE: &str = "peer_id"; +const PEER_IDENTITY_FILE: &str = "peer-identity-v1.json"; const LOCAL_LIBRARY_DIR: &str = "local_library"; const LOCAL_LIBRARY_INDEX_FILE: &str = "index.json"; const GAMES_DIR: &str = "games"; const SETUP_DONE_FILE: &str = "setup_done"; const LAUNCH_SETTINGS_APPLIED_FILE: &str = "launch_settings_applied"; -const DOWNLOAD_OWNERSHIP_FILE: &str = "download_ownership.json"; -const DOWNLOAD_OWNERSHIP_TMP_FILE: &str = "download_ownership.json.tmp"; +pub(crate) const DOWNLOAD_OWNERSHIP_DIR: &str = "download_ownership"; +pub(crate) const DOWNLOAD_OWNERSHIP_RECORD_FILE: &str = "record.json"; +pub(crate) const DOWNLOAD_OWNERSHIP_TMP_FILE: &str = "record.json.tmp"; +pub(crate) const DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE: &str = "recovery-required"; +pub(crate) const LEGACY_DOWNLOAD_OWNERSHIP_FILE: &str = "download_ownership.json"; +pub(crate) const LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE: &str = "download_ownership.json.tmp"; +pub(crate) const LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE: &str = + "download_ownership.recovery-required"; pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf { if let Some(dir) = explicit { @@ -25,8 +31,8 @@ pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf { std::env::temp_dir().join("lanspread") } -pub(crate) fn peer_id_path(state_dir: &Path) -> PathBuf { - state_dir.join(PEER_ID_FILE) +pub(crate) fn peer_identity_path(state_dir: &Path) -> PathBuf { + state_dir.join(PEER_IDENTITY_FILE) } pub(crate) fn local_library_index_path(state_dir: &Path) -> PathBuf { @@ -36,7 +42,11 @@ pub(crate) fn local_library_index_path(state_dir: &Path) -> PathBuf { } pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf { - state_dir.join(GAMES_DIR).join(game_id) + games_state_dir(state_dir).join(game_id) +} + +pub(crate) fn games_state_dir(state_dir: &Path) -> PathBuf { + state_dir.join(GAMES_DIR) } #[must_use] @@ -49,10 +59,105 @@ pub fn launch_settings_applied_path(state_dir: &Path, game_id: &str) -> PathBuf game_state_dir(state_dir, game_id).join(LAUNCH_SETTINGS_APPLIED_FILE) } -pub(crate) fn download_ownership_path(state_dir: &Path, game_id: &str) -> PathBuf { - game_state_dir(state_dir, game_id).join(DOWNLOAD_OWNERSHIP_FILE) +pub(crate) fn download_ownership_namespaces_dir(state_dir: &Path, game_id: &str) -> PathBuf { + game_state_dir(state_dir, game_id).join(DOWNLOAD_OWNERSHIP_DIR) } -pub(crate) fn download_ownership_tmp_path(state_dir: &Path, game_id: &str) -> PathBuf { - game_state_dir(state_dir, game_id).join(DOWNLOAD_OWNERSHIP_TMP_FILE) +pub(crate) fn download_ownership_namespace_component(games_folder_key: &str) -> String { + let key = games_folder_key.as_bytes(); + let mut hasher = blake3::Hasher::new(); + hasher.update(b"lanspread-download-ownership-root\0"); + hasher.update(&u64::try_from(key.len()).unwrap_or(u64::MAX).to_le_bytes()); + hasher.update(key); + format!("v1-{}", hasher.finalize().to_hex()) +} + +pub(crate) fn download_ownership_namespace_dir( + state_dir: &Path, + game_id: &str, + games_folder_key: &str, +) -> PathBuf { + download_ownership_namespaces_dir(state_dir, game_id) + .join(download_ownership_namespace_component(games_folder_key)) +} + +pub(crate) fn download_ownership_path( + state_dir: &Path, + game_id: &str, + games_folder_key: &str, +) -> PathBuf { + download_ownership_namespace_dir(state_dir, game_id, games_folder_key) + .join(DOWNLOAD_OWNERSHIP_RECORD_FILE) +} + +pub(crate) fn download_ownership_tmp_path( + state_dir: &Path, + game_id: &str, + games_folder_key: &str, +) -> PathBuf { + download_ownership_namespace_dir(state_dir, game_id, games_folder_key) + .join(DOWNLOAD_OWNERSHIP_TMP_FILE) +} + +pub(crate) fn download_ownership_recovery_required_path( + state_dir: &Path, + game_id: &str, + games_folder_key: &str, +) -> PathBuf { + download_ownership_namespace_dir(state_dir, game_id, games_folder_key) + .join(DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE) +} + +pub(crate) fn legacy_download_ownership_path(state_dir: &Path, game_id: &str) -> PathBuf { + game_state_dir(state_dir, game_id).join(LEGACY_DOWNLOAD_OWNERSHIP_FILE) +} + +pub(crate) fn legacy_download_ownership_tmp_path(state_dir: &Path, game_id: &str) -> PathBuf { + game_state_dir(state_dir, game_id).join(LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE) +} + +pub(crate) fn legacy_download_ownership_recovery_required_path( + state_dir: &Path, + game_id: &str, +) -> PathBuf { + game_state_dir(state_dir, game_id).join(LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE) +} + +/// Stable, lossless platform-native identity for a canonical games directory. +/// +/// Callers must canonicalize and validate the directory before deriving its +/// key. Persistent state uses this value to prevent records from one configured +/// games directory from authorizing mutations in another. +#[cfg(unix)] +pub(crate) fn games_folder_key(path: &Path) -> String { + use std::os::unix::ffi::OsStrExt as _; + + format!("unix:{}", hex_encode(path.as_os_str().as_bytes())) +} + +#[cfg(windows)] +pub(crate) fn games_folder_key(path: &Path) -> String { + use std::{fmt::Write as _, os::windows::ffi::OsStrExt as _}; + + let mut encoded = String::from("windows:"); + for unit in path.as_os_str().encode_wide() { + let _ = write!(encoded, "{unit:04x}"); + } + encoded +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn games_folder_key(path: &Path) -> String { + format!("native:{}", hex_encode(path.as_os_str().as_encoded_bytes())) +} + +#[cfg(not(windows))] +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(encoded, "{byte:02x}"); + } + encoded } diff --git a/crates/lanspread-peer/src/stream_install.rs b/crates/lanspread-peer/src/stream_install.rs index 08cc962..580351a 100644 --- a/crates/lanspread-peer/src/stream_install.rs +++ b/crates/lanspread-peer/src/stream_install.rs @@ -1,6 +1,7 @@ use std::{ + collections::HashSet, + fs::File, future::Future, - net::SocketAddr, path::{Path, PathBuf}, pin::Pin, process::Stdio, @@ -11,14 +12,32 @@ use std::{ use bytes::Bytes; use crc32fast::Hasher; use futures::{SinkExt, StreamExt}; -use lanspread_proto::{Message, Request, StreamInstallFrame}; -use s2n_quic::{application, stream::SendStream}; +use lanspread_db::content_manifest::{ + Blake3Digest, + CanonicalCatalogPath, + CatalogContentManifest, + CatalogEntryKind, + ContentId, + MAX_CATALOG_ENTRIES, + MAX_CATALOG_TOTAL_BYTES, +}; +use lanspread_proto::{ + ControlMessage, + MAX_STREAM_INSTALL_FRAME_BYTES, + Message, + PeerEndpoint, + Request, + StreamInstallFrame, +}; +use s2n_quic::{ + application, + stream::{ReceiveStream, SendStream}, +}; use tokio::{ - fs::File, - io::{AsyncRead, AsyncReadExt, AsyncWriteExt}, + io::{AsyncRead, AsyncReadExt}, process::Command, sync::{mpsc, mpsc::UnboundedSender}, - time::{self, MissedTickBehavior}, + time::{self, Instant as TokioInstant, MissedTickBehavior}, }; use tokio_util::{ codec::{FramedRead, FramedWrite, LengthDelimitedCodec}, @@ -31,17 +50,71 @@ use crate::{ install::root_eti_archives, network::connect_to_peer, path_validation::validate_game_file_path, + quic_runtime::QuicConnector, + scoped_blocking::scoped_blocking, + scoped_process::{ReapedTokioChild, ScopedProcess}, + transfer_status::DownloadAttemptReporter, }; const FRAME_CHANNEL_DEPTH: usize = 16; const STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(500); const STREAM_CHUNK_SIZE: usize = 256 * 1024; +const UNRAR_LISTING_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; +const STREAM_INSTALL_INACTIVITY_TIMEOUT: Duration = Duration::from_mins(10); + +#[derive(Clone, Copy, Debug)] +struct StreamInstallInactivityDeadline { + expires_at: TokioInstant, + timeout: Duration, +} + +impl StreamInstallInactivityDeadline { + fn ordinary() -> Self { + Self::after(STREAM_INSTALL_INACTIVITY_TIMEOUT) + } + + fn after(timeout: Duration) -> Self { + Self { + expires_at: TokioInstant::now() + timeout, + timeout, + } + } + + fn reset_after_frame(&mut self) { + self.expires_at = TokioInstant::now() + self.timeout; + } + + fn timeout_error(self, game_id: &str, context: &str) -> StreamInstallReceiveError { + let timeout = self.timeout; + StreamInstallReceiveError::transport(eyre::eyre!( + "streamed install for {game_id} made no frame progress for {timeout:?} {context}" + )) + } + + async fn run( + self, + operation: impl Future, + game_id: &str, + cancel_token: &CancellationToken, + context: &str, + ) -> StreamInstallReceiveResult { + tokio::select! { + biased; + () = cancel_token.cancelled() => { + Err(StreamInstallReceiveError::cancelled(game_id, context)) + } + () = time::sleep_until(self.expires_at) => { + Err(self.timeout_error(game_id, context)) + } + result = operation => Ok(result), + } + } +} /// Integrity metadata advertised by the sender's RAR archive. /// -/// This catches transport corruption, truncation, and provider bugs. It is not -/// a trusted-content guarantee because a malicious peer controls both the bytes -/// and the archive metadata. Trusted content would need catalog-owned hashes. +/// This remains an early corruption check only. The catalog-owned BLAKE3 digest +/// in [`IncomingFile`] is the trusted-content boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct SenderArchiveIntegrity { expected_size: u64, @@ -56,7 +129,12 @@ impl SenderArchiveIntegrity { } } - fn verify(self, relative_path: &str, received: u64, actual_crc32: u32) -> eyre::Result<()> { + fn verify( + self, + relative_path: &CanonicalCatalogPath, + received: u64, + actual_crc32: u32, + ) -> eyre::Result<()> { if received != self.expected_size { eyre::bail!( "streamed file {relative_path} size mismatch: got {received}, expected {}", @@ -77,6 +155,76 @@ impl SenderArchiveIntegrity { pub type StreamInstallFuture<'a> = Pin> + Send + 'a>>; +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum StreamInstallReceiveErrorKind { + Integrity, + Transport, + Cancelled, + Setup, +} + +#[derive(Debug)] +pub(crate) struct StreamInstallReceiveError { + kind: StreamInstallReceiveErrorKind, + report: eyre::Report, +} + +impl StreamInstallReceiveError { + #[must_use] + pub(crate) const fn kind(&self) -> StreamInstallReceiveErrorKind { + self.kind + } + + fn integrity(error: impl Into) -> Self { + Self::new(StreamInstallReceiveErrorKind::Integrity, error) + } + + fn transport(error: impl Into) -> Self { + Self::new(StreamInstallReceiveErrorKind::Transport, error) + } + + fn setup(error: impl Into) -> Self { + Self::new(StreamInstallReceiveErrorKind::Setup, error) + } + + fn cancelled(game_id: &str, context: &str) -> Self { + Self::new( + StreamInstallReceiveErrorKind::Cancelled, + eyre::eyre!("streamed install for {game_id} was cancelled {context}"), + ) + } + + fn transport_or_cancelled( + error: impl Into, + game_id: &str, + cancel_token: &CancellationToken, + context: &str, + ) -> Self { + if cancel_token.is_cancelled() { + Self::cancelled(game_id, context) + } else { + Self::transport(error) + } + } + + fn new(kind: StreamInstallReceiveErrorKind, error: impl Into) -> Self { + Self { + kind, + report: error.into(), + } + } +} + +impl std::fmt::Display for StreamInstallReceiveError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.report, formatter) + } +} + +impl std::error::Error for StreamInstallReceiveError {} + +type StreamInstallReceiveResult = Result; + #[derive(Clone)] pub struct StreamInstallFrameSink { frames: mpsc::Sender, @@ -158,12 +306,8 @@ impl StreamInstallProvider for ExternalUnrarStreamProvider { cancel_token: CancellationToken, ) -> StreamInstallFuture<'a> { Box::pin(async move { + let archive_name = archive_catalog_name(archive)?; let listing = unrar_listing(&self.program, archive, &cancel_token).await?; - let archive_name = archive - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("archive.eti") - .to_string(); frames .send(StreamInstallFrame::ArchiveBegin { @@ -189,6 +333,19 @@ impl StreamInstallProvider for ExternalUnrarStreamProvider { } } +fn archive_catalog_name(archive: &Path) -> eyre::Result { + let name = archive + .file_name() + .ok_or_else(|| eyre::eyre!("archive path has no file name: {}", archive.display()))?; + let name = name.to_str().ok_or_else(|| { + eyre::eyre!( + "archive file name is not valid UTF-8: {}", + archive.display() + ) + })?; + CanonicalCatalogPath::new(name) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RarListing { solid: bool, @@ -207,7 +364,7 @@ impl RarListing { #[derive(Debug, Clone, PartialEq, Eq)] struct RarEntry { - relative_path: String, + relative_path: CanonicalCatalogPath, kind: RarEntryKind, size: u64, crc32: Option, @@ -232,25 +389,19 @@ async fn unrar_listing( archive: &Path, cancel_token: &CancellationToken, ) -> eyre::Result { - let mut child = Command::new(program) - .arg("lt") - .arg("-cfg-") - .arg(archive) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn()?; - let mut stdout = child - .stdout - .take() - .ok_or_else(|| eyre::eyre!("unrar listing stdout was not captured"))?; - let mut stderr = child - .stderr - .take() - .ok_or_else(|| eyre::eyre!("unrar listing stderr was not captured"))?; - - let output = - capture_unrar_output(&mut child, &mut stdout, &mut stderr, cancel_token, archive).await?; + let process = ScopedProcess::spawn( + program, + [ + std::ffi::OsString::from("lt"), + std::ffi::OsString::from("-cfg-"), + std::ffi::OsString::from("-p-"), + archive.as_os_str().to_owned(), + ], + cancel_token, + UNRAR_LISTING_CAPTURE_LIMIT, + )?; + let output = process.wait().await?; + reject_truncated_unrar_listing(output.stdout_truncated, output.stderr_truncated, archive)?; if !output.status.success() { eyre::bail!( "unrar lt failed for {} with status {}: {}", @@ -263,75 +414,16 @@ async fn unrar_listing( parse_unrar_listing(&String::from_utf8_lossy(&output.stdout)) } -#[derive(Debug)] -struct CapturedProcessOutput { - status: std::process::ExitStatus, - stdout: Vec, - stderr: Vec, -} - -async fn capture_unrar_output( - child: &mut tokio::process::Child, - stdout: &mut (impl AsyncRead + Unpin), - stderr: &mut (impl AsyncRead + Unpin), - cancel_token: &CancellationToken, - archive: &Path, -) -> eyre::Result { - let mut stdout_bytes = Vec::new(); - let mut stderr_bytes = Vec::new(); - - let capture_result = { - let capture = async { - let (status, _, _) = tokio::try_join!( - child.wait(), - stdout.read_to_end(&mut stdout_bytes), - stderr.read_to_end(&mut stderr_bytes), - )?; - Ok::<_, std::io::Error>(status) - }; - tokio::pin!(capture); - - tokio::select! { - biased; - () = cancel_token.cancelled() => None, - result = &mut capture => Some(result), - } - }; - - match capture_result { - Some(Ok(status)) => Ok(CapturedProcessOutput { - status, - stdout: stdout_bytes, - stderr: stderr_bytes, - }), - Some(Err(capture_error)) => { - if let Err(cleanup_error) = terminate_and_reap_unrar(child, archive).await { - return Err(eyre::eyre!( - "failed to capture unrar listing for {}: {capture_error}; cleanup also failed: {cleanup_error}", - archive.display() - )); - } - Err(eyre::eyre!( - "failed to capture unrar listing for {}: {capture_error}", - archive.display() - )) - } - None => { - terminate_and_reap_unrar(child, archive).await?; - eyre::bail!("streamed archive {} was cancelled", archive.display()); - } - } -} - -async fn terminate_and_reap_unrar( - child: &mut tokio::process::Child, +fn reject_truncated_unrar_listing( + stdout_truncated: bool, + stderr_truncated: bool, archive: &Path, ) -> eyre::Result<()> { - let kill_error = child.start_kill().err(); - if let Err(wait_error) = child.wait().await { + if stdout_truncated || stderr_truncated { eyre::bail!( - "failed to reap unrar listing for {}: {wait_error}; kill error: {kill_error:?}", - archive.display() + "unrar lt output for {} exceeded the {} byte per-pipe metadata limit", + archive.display(), + UNRAR_LISTING_CAPTURE_LIMIT ); } Ok(()) @@ -405,7 +497,7 @@ fn push_rar_entry(entries: &mut Vec, draft: RarEntryDraft) -> eyre::Re }; entries.push(RarEntry { - relative_path, + relative_path: CanonicalCatalogPath::new(relative_path)?, kind, size, crc32, @@ -420,20 +512,24 @@ async fn stream_unrar_entries( frames: &StreamInstallFrameSink, cancel_token: CancellationToken, ) -> eyre::Result<()> { - let mut child = Command::new(program) + let mut command = Command::new(program); + command .arg("p") .arg("-inul") .arg("-cfg-") + .arg("-p-") .arg(archive) + .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) - // Safety net: if this task is dropped before its cancel/error path runs - // (e.g. on shutdown), tokio still kills unrar instead of orphaning it. - .kill_on_drop(true) - .spawn()?; + .stderr(Stdio::null()); + #[cfg(target_os = "windows")] + command.creation_flags(crate::scoped_process::CREATE_NO_WINDOW); + let child = command.spawn()?; + let mut child = ReapedTokioChild::new(child); let result = async { let mut stdout = child + .child_mut() .stdout .take() .ok_or_else(|| eyre::eyre!("unrar stdout was not captured"))?; @@ -502,11 +598,17 @@ async fn stream_unrar_entries( } .await; - if result.is_err() { - let _ = child.kill().await; + if let Err(error) = result { + if let Err(cleanup_error) = child.terminate_and_wait().await { + return Err(eyre::eyre!( + "{error}; failed to settle unrar for {}: {cleanup_error}", + archive.display() + )); + } + return Err(error); } - result + Ok(()) } async fn stream_unrar_file_from_stdout( @@ -556,13 +658,13 @@ async fn read_unrar_stdout( } async fn wait_unrar_child( - child: &mut tokio::process::Child, + child: &mut ReapedTokioChild, cancel_token: &CancellationToken, archive: &Path, ) -> eyre::Result { tokio::select! { () = cancel_token.cancelled() => { - let _ = child.kill().await; + child.terminate_and_wait().await?; eyre::bail!("streamed archive {} was cancelled", archive.display()); } status = child.wait() => Ok(status?), @@ -572,23 +674,10 @@ async fn wait_unrar_child( pub(crate) async fn send_stream_install_error( tx: SendStream, message: impl Into, + game_id: &str, + cancel_token: &CancellationToken, ) -> SendStream { - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - if let Err(err) = framed_tx - .send( - StreamInstallFrame::Error { - message: message.into(), - } - .encode(), - ) - .await - { - log::warn!("Failed to send streamed install error frame: {err}"); - } - if let Err(err) = framed_tx.close().await { - log::debug!("Failed to close streamed install error response: {err}"); - } - framed_tx.into_inner() + send_stream_install_error_cancellable(tx, message.into(), game_id, cancel_token).await } pub(crate) async fn send_game_install_stream( @@ -596,9 +685,10 @@ pub(crate) async fn send_game_install_stream( tx: SendStream, game_root: &Path, game_id: &str, + manifest: Arc, cancel_token: CancellationToken, ) -> (SendStream, eyre::Result<()>) { - let archives = match root_eti_archives(game_root).await { + let archives = match catalog_stream_archives(game_root, game_id, &manifest) { Ok(archives) => archives, Err(err) => { let message = err.to_string(); @@ -608,13 +698,6 @@ pub(crate) async fn send_game_install_stream( return (tx, Err(eyre::eyre!(message))); } }; - if archives.is_empty() { - let message = format!("no .eti archives found for {game_id}"); - let tx = send_stream_install_error_cancellable(tx, message.clone(), game_id, &cancel_token) - .await; - return (tx, Err(eyre::eyre!(message))); - } - let (frame_tx, mut frame_rx) = mpsc::channel(FRAME_CHANNEL_DEPTH); let producer_cancel = cancel_token.child_token(); let frame_sink = StreamInstallFrameSink::new(frame_tx, producer_cancel.clone()); @@ -679,6 +762,67 @@ pub(crate) async fn send_game_install_stream( (tx, result) } +fn catalog_stream_archives( + game_root: &Path, + game_id: &str, + manifest: &CatalogContentManifest, +) -> eyre::Result> { + if manifest.game_id() != game_id { + eyre::bail!( + "streamed-install catalog game mismatch: requested {game_id}, manifest is for {}", + manifest.game_id() + ); + } + if !manifest.supports_streamed_install() { + eyre::bail!("catalog game {game_id} does not support streamed install"); + } + + let expected = manifest + .files() + .iter() + .filter(|entry| entry.kind() == CatalogEntryKind::File) + .map(|entry| entry.canonical_path().as_str()) + .filter(|path| { + !path.contains('/') + && Path::new(path) + .extension() + .is_some_and(|extension| extension == "eti") + }) + .collect::>(); + if expected.is_empty() { + eyre::bail!("catalog game {game_id} has streamed output but no root .eti archive"); + } + + let archives = root_eti_archives(game_root)?; + let mut actual = archives + .into_iter() + .map(|archive| { + let name = archive + .file_name() + .and_then(std::ffi::OsStr::to_str) + .map(str::to_owned) + .ok_or_else(|| { + eyre::eyre!( + "streamed-install archive name is not valid UTF-8: {}", + archive.display() + ) + })?; + Ok((name, archive)) + }) + .collect::>>()?; + actual.sort_by(|(left, _), (right, _)| left.cmp(right)); + let actual_names = actual + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(); + if actual_names != expected { + eyre::bail!( + "streamed-install archive set does not match catalog for {game_id}: expected {expected:?}, found {actual_names:?}" + ); + } + Ok(actual.into_iter().map(|(_, archive)| archive).collect()) +} + async fn send_stream_install_error_cancellable( tx: SendStream, message: String, @@ -722,6 +866,24 @@ async fn forward_stream_install_frames( frame_rx: &mut mpsc::Receiver, cancel_token: &CancellationToken, ) -> StreamInstallEgressOutcome +where + W: tokio::io::AsyncWrite + Unpin, +{ + forward_stream_install_frames_with_timeout( + framed_tx, + frame_rx, + cancel_token, + STREAM_INSTALL_INACTIVITY_TIMEOUT, + ) + .await +} + +async fn forward_stream_install_frames_with_timeout( + framed_tx: &mut FramedWrite, + frame_rx: &mut mpsc::Receiver, + cancel_token: &CancellationToken, + inactivity_timeout: Duration, +) -> StreamInstallEgressOutcome where W: tokio::io::AsyncWrite + Unpin, { @@ -731,6 +893,11 @@ where () = cancel_token.cancelled() => { return StreamInstallEgressOutcome::Cancelled; } + () = tokio::time::sleep(inactivity_timeout) => { + return StreamInstallEgressOutcome::Failed(eyre::eyre!( + "streamed install producer timed out after {inactivity_timeout:?} without a frame" + )); + } frame = frame_rx.recv() => frame, }; let Some(frame) = frame else { @@ -742,6 +909,11 @@ where () = cancel_token.cancelled() => { return StreamInstallEgressOutcome::Cancelled; } + () = tokio::time::sleep(inactivity_timeout) => { + return StreamInstallEgressOutcome::Failed(eyre::eyre!( + "streamed install frame send timed out after {inactivity_timeout:?}" + )); + } result = framed_tx.send(frame.encode()) => result, }; if let Err(err) = send_result { @@ -754,6 +926,11 @@ where tokio::select! { biased; () = cancel_token.cancelled() => StreamInstallEgressOutcome::Cancelled, + () = tokio::time::sleep(inactivity_timeout) => { + StreamInstallEgressOutcome::Failed(eyre::eyre!( + "streamed install close timed out after {inactivity_timeout:?}" + )) + } result = framed_tx.close() => match result { Ok(()) => StreamInstallEgressOutcome::Complete, Err(err) => StreamInstallEgressOutcome::Failed(eyre::eyre!( @@ -769,120 +946,804 @@ fn reset_stream_install(tx: &mut SendStream, game_id: &str) { } } -pub(crate) async fn receive_streamed_install( - peer_addr: SocketAddr, - game_id: &str, - staging_dir: &Path, - tx_notify_ui: UnboundedSender, - cancel_token: CancellationToken, -) -> eyre::Result<()> { - let staging_dir = tokio::fs::canonicalize(staging_dir) - .await - .unwrap_or_else(|_| staging_dir.to_path_buf()); - let mut conn = connect_to_peer(peer_addr).await?; - let stream = conn.open_bidirectional_stream().await?; - let (rx, tx) = stream.split(); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); +/// Catalog-owned acceptance state for one streamed-install response. +/// +/// The sender's archive framing is useful for bounded extraction progress, but +/// it is not authority. Every materialized path, shape, size, and file digest +/// ultimately comes from `manifest`. +#[derive(Debug)] +struct CatalogStreamVerifier { + manifest: Arc, + expected_archives: HashSet, + expected_file_bytes: u64, + seen_archives: HashSet, + reported_unpacked_bytes: u64, + active_archive: Option, + open_file: Option, + seen_entries: Vec, + entry_frames: usize, +} - framed_tx - .send( - Request::StreamInstall { - game_id: game_id.to_string(), - } - .encode(), - ) - .await?; - framed_tx.close().await?; +#[derive(Debug)] +struct ActiveArchive { + name: CanonicalCatalogPath, +} - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut current_file: Option = None; - let mut progress = StreamInstallProgress::new(game_id.to_string()); - let mut progress_interval = time::interval(STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL); - progress_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - progress_interval.tick().await; +impl CatalogStreamVerifier { + fn new(game_id: &str, manifest: Arc) -> eyre::Result { + if manifest.game_id() != game_id { + eyre::bail!( + "streamed install catalog game mismatch: requested {game_id}, manifest is for {}", + manifest.game_id() + ); + } + if !manifest.supports_streamed_install() { + eyre::bail!("catalog game {game_id} does not support streamed install"); + } - loop { - let next = tokio::select! { - () = cancel_token.cancelled() => eyre::bail!("streamed install for {game_id} was cancelled"), - _ = progress_interval.tick() => { - progress.emit_current(&tx_notify_ui); - continue; - } - next = framed_rx.next() => next, + let expected_archives = manifest + .files() + .iter() + .filter(|entry| entry.kind() == CatalogEntryKind::File) + .map(lanspread_db::content_manifest::CatalogFileEntry::canonical_path) + .filter(|path| { + !path.as_str().contains('/') + && Path::new(path.as_str()) + .extension() + .is_some_and(|extension| extension == "eti") + }) + .cloned() + .collect::>(); + if expected_archives.is_empty() { + eyre::bail!("catalog game {game_id} has streamed output but no root .eti archive"); + } + + let expected_file_bytes = manifest + .streamed_install_files() + .iter() + .filter(|entry| entry.kind() == CatalogEntryKind::File) + .try_fold(0_u64, |total, entry| total.checked_add(entry.size())) + .ok_or_else(|| eyre::eyre!("catalog streamed file-size total overflow"))?; + if expected_file_bytes > MAX_CATALOG_TOTAL_BYTES { + eyre::bail!( + "catalog streamed files exceed the {MAX_CATALOG_TOTAL_BYTES}-byte total limit" + ); + } + + let seen_entries = vec![false; manifest.streamed_install_files().len()]; + Ok(Self { + manifest, + expected_archives, + expected_file_bytes, + seen_archives: HashSet::new(), + reported_unpacked_bytes: 0, + active_archive: None, + open_file: None, + seen_entries, + entry_frames: 0, + }) + } + + const fn expected_file_bytes(&self) -> u64 { + self.expected_file_bytes + } + + fn begin_archive( + &mut self, + archive_name: &CanonicalCatalogPath, + unpacked_size: u64, + ) -> eyre::Result<()> { + self.ensure_no_open_file("ArchiveBegin")?; + if let Some(active) = &self.active_archive { + eyre::bail!( + "received ArchiveBegin for {archive_name} before ArchiveEnd for {}", + active.name + ); + } + let reported_unpacked_bytes = self + .reported_unpacked_bytes + .checked_add(unpacked_size) + .ok_or_else(|| eyre::eyre!("streamed archive unpacked-size total overflow"))?; + if reported_unpacked_bytes > MAX_CATALOG_TOTAL_BYTES { + eyre::bail!( + "streamed archives exceed the {MAX_CATALOG_TOTAL_BYTES}-byte reported unpacked limit" + ); + } + + if !self.expected_archives.contains(archive_name) { + eyre::bail!("received unknown streamed archive {archive_name}"); + } + if !self.seen_archives.insert(archive_name.clone()) { + eyre::bail!("received duplicate streamed archive {archive_name}"); + } + + self.reported_unpacked_bytes = reported_unpacked_bytes; + self.active_archive = Some(ActiveArchive { + name: archive_name.clone(), + }); + Ok(()) + } + + fn record_directory(&mut self, relative_path: &CanonicalCatalogPath) -> eyre::Result<()> { + self.ensure_no_open_file("Directory")?; + self.record_entry_frame()?; + let entry_index = self.expected_entry_index(relative_path, CatalogEntryKind::Directory)?; + debug_assert_eq!( + self.manifest.streamed_install_files()[entry_index].size(), + 0 + ); + + self.active_archive_mut("Directory")?; + self.seen_entries[entry_index] = true; + Ok(()) + } + + fn begin_file( + &mut self, + relative_path: &CanonicalCatalogPath, + size: u64, + ) -> eyre::Result { + self.ensure_no_open_file("FileBegin")?; + self.record_entry_frame()?; + self.active_archive_mut("FileBegin")?; + let entry_index = self.expected_entry_index(relative_path, CatalogEntryKind::File)?; + let expected = &self.manifest.streamed_install_files()[entry_index]; + let expected_size = expected.size(); + let expected_blake3 = expected.file_blake3().ok_or_else(|| { + eyre::eyre!("catalog streamed file {relative_path} has no BLAKE3 digest") + })?; + if size != expected_size { + eyre::bail!( + "streamed file {relative_path} size mismatch: sender declared {size}, catalog expects {}", + expected_size + ); + } + if self.seen_entries[entry_index] { + eyre::bail!("streamed install repeated file {relative_path}"); + } + self.seen_entries[entry_index] = true; + self.open_file = Some(entry_index); + Ok(expected_blake3) + } + + fn record_file_chunk(&self, length: usize) -> eyre::Result<()> { + if self.open_file.is_none() { + eyre::bail!("received FileChunk without FileBegin"); + } + if length == 0 { + eyre::bail!("received an empty FileChunk"); + } + Ok(()) + } + + fn end_file(&mut self, relative_path: &CanonicalCatalogPath) -> eyre::Result<()> { + let Some(open_file) = self.open_file else { + eyre::bail!("received FileEnd for {relative_path} without FileBegin"); }; + let open_path = self.manifest.streamed_install_files()[open_file].canonical_path(); + if open_path != relative_path { + eyre::bail!("streamed file end mismatch: began {open_path}, ended {relative_path}"); + } + self.open_file = None; + Ok(()) + } - let Some(frame) = next else { - eyre::bail!("streamed install ended before Complete"); + fn end_archive(&mut self, archive_name: &CanonicalCatalogPath) -> eyre::Result<()> { + self.ensure_no_open_file("ArchiveEnd")?; + let Some(active) = self.active_archive.take() else { + eyre::bail!("received ArchiveEnd for {archive_name} without ArchiveBegin"); }; - let frame = frame?.freeze(); - let frame = StreamInstallFrame::decode(frame); + if &active.name != archive_name { + eyre::bail!( + "streamed archive end mismatch: began {}, ended {archive_name}", + active.name + ); + } + Ok(()) + } + fn verify_complete(&self) -> eyre::Result<()> { + self.ensure_no_open_file("Complete")?; + if let Some(active) = &self.active_archive { + eyre::bail!( + "streamed install completed before ArchiveEnd for {}", + active.name + ); + } + if let Some(missing) = self + .expected_archives + .difference(&self.seen_archives) + .next() + { + eyre::bail!("streamed install completed before expected archive {missing}"); + } + + for (entry, seen) in self + .manifest + .streamed_install_files() + .iter() + .zip(&self.seen_entries) + { + if !*seen { + let path = entry.canonical_path(); + eyre::bail!("streamed install is missing catalog entry {path}"); + } + } + Ok(()) + } + + fn expected_entry_index( + &self, + relative_path: &CanonicalCatalogPath, + kind: CatalogEntryKind, + ) -> eyre::Result { + let entry_index = self + .manifest + .streamed_install_files() + .binary_search_by(|entry| entry.canonical_path().cmp(relative_path)) + .map_err(|_| eyre::eyre!("streamed install sent unknown path {relative_path}"))?; + let entry = &self.manifest.streamed_install_files()[entry_index]; + if entry.kind() != kind { + eyre::bail!( + "streamed install path {relative_path} has kind {:?}, catalog expects {:?}", + kind, + entry.kind() + ); + } + Ok(entry_index) + } + + fn active_archive_mut(&mut self, frame: &str) -> eyre::Result<&mut ActiveArchive> { + self.active_archive + .as_mut() + .ok_or_else(|| eyre::eyre!("received {frame} outside an archive")) + } + + fn ensure_no_open_file(&self, frame: &str) -> eyre::Result<()> { + if let Some(open_file) = self.open_file { + let open_path = self.manifest.streamed_install_files()[open_file] + .canonical_path() + .as_str(); + eyre::bail!("received {frame} while streamed file {open_path} is open"); + } + Ok(()) + } + + fn record_entry_frame(&mut self) -> eyre::Result<()> { + // The publisher applies the same bound to the aggregate raw archive + // listings before it folds repeated directories into final outputs. + self.entry_frames = self + .entry_frames + .checked_add(1) + .ok_or_else(|| eyre::eyre!("streamed install entry-frame count overflow"))?; + if self.entry_frames > MAX_CATALOG_ENTRIES { + eyre::bail!("streamed install exceeds the {MAX_CATALOG_ENTRIES}-entry frame limit"); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ReceiveFrameOutcome { + Continue, + Complete, +} + +struct StreamInstallReceiveState { + verifier: CatalogStreamVerifier, + staging_dir: PathBuf, + current_file: Option, + progress: StreamInstallProgress, +} + +impl StreamInstallReceiveState { + fn new( + game_id: &str, + manifest: Arc, + staging_dir: &Path, + attempt: DownloadAttemptReporter, + ) -> StreamInstallReceiveResult { + let verifier = CatalogStreamVerifier::new(game_id, manifest) + .map_err(StreamInstallReceiveError::setup)?; + let staging_dir = + scoped_blocking(|| std::fs::canonicalize(staging_dir)).map_err(|error| { + StreamInstallReceiveError::setup(eyre::eyre!( + "failed to resolve streamed install staging directory {}: {error}", + staging_dir.display() + )) + })?; + let progress = StreamInstallProgress::new(attempt, verifier.expected_file_bytes()); + Ok(Self { + verifier, + staging_dir, + current_file: None, + progress, + }) + } + + fn emit_current_progress(&mut self) { + self.progress.emit_current(); + } + + fn handle_frame( + &mut self, + frame: StreamInstallFrame, + game_id: &str, + peer_endpoint: PeerEndpoint, + content_id: ContentId, + tx_notify_ui: &UnboundedSender, + ) -> StreamInstallReceiveResult { match frame { StreamInstallFrame::ArchiveBegin { archive_name, solid, unpacked_size, } => { - progress.add_total(unpacked_size); - progress.emit_snapshot(&tx_notify_ui, 0); + self.verifier + .begin_archive(&archive_name, unpacked_size) + .map_err(StreamInstallReceiveError::integrity)?; + self.progress.emit_snapshot(0); log::info!( "Receiving streamed install archive {archive_name} for {game_id} \ (solid={solid}, unpacked_size={unpacked_size})" ); } StreamInstallFrame::Directory { relative_path } => { - let path = resolve_stream_path(&staging_dir, &relative_path)?; - tokio::fs::create_dir_all(path).await?; + self.verifier + .record_directory(&relative_path) + .map_err(StreamInstallReceiveError::integrity)?; + let path = resolve_stream_path(&self.staging_dir, &relative_path) + .map_err(StreamInstallReceiveError::setup)?; + scoped_blocking(|| std::fs::create_dir_all(path)) + .map_err(StreamInstallReceiveError::setup)?; } StreamInstallFrame::FileBegin { relative_path, size, crc32, } => { - if current_file.is_some() { - eyre::bail!("received FileBegin for {relative_path} before previous FileEnd"); - } - let path = resolve_stream_path(&staging_dir, &relative_path)?; + let expected_blake3 = self + .verifier + .begin_file(&relative_path, size) + .map_err(StreamInstallReceiveError::integrity)?; + let path = resolve_stream_path(&self.staging_dir, &relative_path) + .map_err(StreamInstallReceiveError::setup)?; if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + scoped_blocking(|| std::fs::create_dir_all(parent)) + .map_err(StreamInstallReceiveError::setup)?; } - let file = File::create(&path).await?; - current_file = Some(IncomingFile::new(relative_path, path, size, crc32, file)); + let file = scoped_blocking(|| File::create(&path)) + .map_err(StreamInstallReceiveError::setup)?; + self.current_file = Some(IncomingFile::new( + relative_path, + path, + size, + crc32, + expected_blake3, + file, + )); } StreamInstallFrame::FileChunk { bytes } => { - let Some(file) = current_file.as_mut() else { - eyre::bail!("received FileChunk without FileBegin"); + self.verifier + .record_file_chunk(bytes.len()) + .map_err(StreamInstallReceiveError::integrity)?; + let Some(file) = self.current_file.as_mut() else { + unreachable!("verifier and incoming file state must stay in lockstep"); }; - let length = file - .write_chunk(game_id, peer_addr, &tx_notify_ui, bytes) - .await?; - progress.record_bytes(length); + let length = file.write_chunk(&bytes)?; + self.progress.record_bytes(length); } StreamInstallFrame::FileEnd { relative_path } => { - let Some(file) = current_file.take() else { - eyre::bail!("received FileEnd for {relative_path} without FileBegin"); + self.verifier + .end_file(&relative_path) + .map_err(StreamInstallReceiveError::integrity)?; + let Some(file) = self.current_file.take() else { + unreachable!("verifier and incoming file state must stay in lockstep"); }; - file.finish(&relative_path).await?; + file.finish( + &relative_path, + game_id, + peer_endpoint, + content_id, + tx_notify_ui, + )?; } StreamInstallFrame::ArchiveEnd { archive_name } => { + self.verifier + .end_archive(&archive_name) + .map_err(StreamInstallReceiveError::integrity)?; log::info!("Finished streamed install archive {archive_name} for {game_id}"); } StreamInstallFrame::Complete => { - if current_file.is_some() { - eyre::bail!("streamed install completed with an open file"); - } - progress.emit_snapshot(&tx_notify_ui, 0); - return Ok(()); + self.verifier + .verify_complete() + .map_err(StreamInstallReceiveError::integrity)?; + debug_assert!(self.current_file.is_none()); + self.progress.emit_snapshot(0); + return Ok(ReceiveFrameOutcome::Complete); } StreamInstallFrame::Error { message } => { - eyre::bail!("streamed install sender failed: {message}"); + return Err(StreamInstallReceiveError::transport(eyre::eyre!( + "streamed install sender failed: {message}" + ))); } } + Ok(ReceiveFrameOutcome::Continue) + } +} + +pub(crate) struct ReceiveStreamedInstallRequest<'a> { + pub(crate) endpoint: PeerEndpoint, + pub(crate) game_id: &'a str, + pub(crate) manifest: Arc, + pub(crate) staging_dir: &'a Path, + pub(crate) attempt: DownloadAttemptReporter, + pub(crate) tx_notify_ui: UnboundedSender, + pub(crate) quic: &'a QuicConnector, + pub(crate) cancel_token: CancellationToken, +} + +pub(crate) async fn receive_streamed_install( + request: ReceiveStreamedInstallRequest<'_>, +) -> StreamInstallReceiveResult<()> { + let ReceiveStreamedInstallRequest { + endpoint, + game_id, + manifest, + staging_dir, + attempt, + tx_notify_ui, + quic, + cancel_token, + } = request; + let content_id = manifest.content_id(); + let mut state = StreamInstallReceiveState::new(game_id, manifest, staging_dir, attempt)?; + let mut conn = connect_to_peer(quic, &endpoint, &cancel_token) + .await + .map_err(|error| { + StreamInstallReceiveError::transport_or_cancelled( + error, + game_id, + &cancel_token, + "while connecting", + ) + })?; + let mut inactivity = StreamInstallInactivityDeadline::ordinary(); + let stream = await_stream_install_open( + async { Ok(conn.open_bidirectional_stream().await?) }, + game_id, + &cancel_token, + inactivity, + ) + .await?; + let (rx, tx) = stream.split(); + let mut request_scope = StreamInstallRequestScope::new(rx, tx, game_id, content_id); + request_scope + .send_request(&cancel_token, inactivity) + .await?; + let rx = request_scope.into_receive(); + let mut framed_rx = StreamInstallReceiveScope::new(rx, game_id); + let mut progress_interval = time::interval(STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL); + progress_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + progress_interval.tick().await; + + loop { + let next = match await_stream_install_input( + framed_rx.next(), + progress_interval.tick(), + game_id, + &cancel_token, + inactivity, + ) + .await? + { + StreamInstallInput::ProgressTick => { + state.emit_current_progress(); + continue; + } + StreamInstallInput::Frame(next) => next, + }; + + let Some(frame) = next else { + return Err(stream_ended_before_complete(game_id)); + }; + let frame = frame + .map_err(|error| { + classify_stream_install_read_error( + error, + game_id, + &cancel_token, + "while reading its response", + ) + })? + .freeze(); + inactivity.reset_after_frame(); + let frame = decode_received_stream_install_frame(frame)?; + if state.handle_frame(frame, game_id, endpoint, content_id, &tx_notify_ui)? + == ReceiveFrameOutcome::Complete + { + return framed_rx.drain_fin(&cancel_token, inactivity).await; + } + } +} + +#[derive(Debug)] +enum StreamInstallInput { + ProgressTick, + Frame(T), +} + +async fn await_stream_install_input( + frame: impl Future, + progress_tick: impl Future, + game_id: &str, + cancel_token: &CancellationToken, + inactivity: StreamInstallInactivityDeadline, +) -> StreamInstallReceiveResult> { + tokio::select! { + biased; + () = cancel_token.cancelled() => { + Err(StreamInstallReceiveError::cancelled(game_id, "while receiving")) + } + () = time::sleep_until(inactivity.expires_at) => { + Err(inactivity.timeout_error(game_id, "while waiting for a response frame")) + } + _ = progress_tick => Ok(StreamInstallInput::ProgressTick), + frame = frame => Ok(StreamInstallInput::Frame(frame)), + } +} + +fn stream_ended_before_complete(game_id: &str) -> StreamInstallReceiveError { + StreamInstallReceiveError::transport(eyre::eyre!( + "streamed install for {game_id} ended before Complete" + )) +} + +fn data_after_complete(game_id: &str) -> StreamInstallReceiveError { + StreamInstallReceiveError::integrity(eyre::eyre!( + "streamed install for {game_id} sent data after Complete" + )) +} + +fn decode_received_stream_install_frame( + frame: Bytes, +) -> StreamInstallReceiveResult { + StreamInstallFrame::decode_checked(frame).map_err(|error| { + StreamInstallReceiveError::integrity(eyre::eyre!( + "invalid streamed install frame from peer: {error}" + )) + }) +} + +fn classify_stream_install_read_error( + error: std::io::Error, + game_id: &str, + cancel_token: &CancellationToken, + context: &str, +) -> StreamInstallReceiveError { + if error.kind() == std::io::ErrorKind::InvalidData { + return StreamInstallReceiveError::integrity(eyre::eyre!( + "invalid or oversized streamed install frame from peer: {error}" + )); + } + StreamInstallReceiveError::transport_or_cancelled(error, game_id, cancel_token, context) +} + +async fn await_stream_install_open( + open: impl Future>, + game_id: &str, + cancel_token: &CancellationToken, + inactivity: StreamInstallInactivityDeadline, +) -> StreamInstallReceiveResult { + inactivity + .run(open, game_id, cancel_token, "while opening its stream") + .await? + .map_err(|error| { + StreamInstallReceiveError::transport_or_cancelled( + error, + game_id, + cancel_token, + "while opening its stream", + ) + }) +} + +async fn send_stream_install_request_frame( + framed_tx: &mut FramedWrite, + game_id: &str, + content_id: ContentId, + cancel_token: &CancellationToken, + inactivity: StreamInstallInactivityDeadline, +) -> StreamInstallReceiveResult<()> +where + W: tokio::io::AsyncWrite + Unpin, +{ + let request = Request::StreamInstall { + game_id: game_id.to_string(), + content_id, + } + .encode() + .map_err(StreamInstallReceiveError::setup)?; + inactivity + .run( + framed_tx.send(request), + game_id, + cancel_token, + "while sending its request", + ) + .await? + .map_err(|error| { + StreamInstallReceiveError::transport_or_cancelled( + error, + game_id, + cancel_token, + "while sending its request", + ) + })?; + + inactivity + .run( + framed_tx.close(), + game_id, + cancel_token, + "while closing its request", + ) + .await? + .map_err(|error| { + StreamInstallReceiveError::transport_or_cancelled( + error, + game_id, + cancel_token, + "while closing its request", + ) + })?; + Ok(()) +} + +struct StreamInstallRequestScope<'a> { + rx: Option, + framed_tx: Option>, + game_id: &'a str, + content_id: ContentId, +} + +impl<'a> StreamInstallRequestScope<'a> { + fn new(rx: ReceiveStream, tx: SendStream, game_id: &'a str, content_id: ContentId) -> Self { + Self { + rx: Some(rx), + framed_tx: Some(FramedWrite::new(tx, LengthDelimitedCodec::new())), + game_id, + content_id, + } + } + + async fn send_request( + &mut self, + cancel_token: &CancellationToken, + inactivity: StreamInstallInactivityDeadline, + ) -> StreamInstallReceiveResult<()> { + let framed_tx = self + .framed_tx + .as_mut() + .expect("request scope should retain its send half until completion"); + send_stream_install_request_frame( + framed_tx, + self.game_id, + self.content_id, + cancel_token, + inactivity, + ) + .await + } + + fn into_receive(mut self) -> ReceiveStream { + let framed_tx = self + .framed_tx + .take() + .expect("completed request should retain its send half"); + drop(framed_tx.into_inner()); + self.rx + .take() + .expect("completed request should retain its receive half") + } +} + +impl Drop for StreamInstallRequestScope<'_> { + fn drop(&mut self) { + if let Some(framed_tx) = self.framed_tx.take() { + let mut tx = framed_tx.into_inner(); + reset_stream_install(&mut tx, self.game_id); + } + if let Some(mut rx) = self.rx.take() { + stop_stream_install_receive(&mut rx, self.game_id); + } + } +} + +fn stop_stream_install_receive(rx: &mut ReceiveStream, game_id: &str) { + if let Err(err) = rx.stop_sending(application::Error::UNKNOWN) { + log::debug!("Failed to stop streamed install receive for {game_id}: {err}"); + } +} + +struct StreamInstallReceiveScope<'a> { + framed_rx: FramedRead, + game_id: &'a str, + finished: bool, +} + +impl<'a> StreamInstallReceiveScope<'a> { + fn new(rx: ReceiveStream, game_id: &'a str) -> Self { + Self { + framed_rx: FramedRead::new( + rx, + LengthDelimitedCodec::builder() + .max_frame_length(MAX_STREAM_INSTALL_FRAME_BYTES) + .new_codec(), + ), + game_id, + finished: false, + } + } + + async fn next(&mut self) -> Option> { + self.framed_rx.next().await + } + + async fn drain_fin( + mut self, + cancel_token: &CancellationToken, + inactivity: StreamInstallInactivityDeadline, + ) -> StreamInstallReceiveResult<()> { + let trailing = await_stream_install_fin( + self.framed_rx.next(), + self.game_id, + cancel_token, + inactivity, + ) + .await?; + match trailing { + None => { + self.finished = true; + Ok(()) + } + Some(Ok(_)) => Err(data_after_complete(self.game_id)), + Some(Err(error)) => Err(classify_stream_install_read_error( + error, + self.game_id, + cancel_token, + "while draining its response", + )), + } + } +} + +async fn await_stream_install_fin( + next: impl Future>>, + game_id: &str, + cancel_token: &CancellationToken, + inactivity: StreamInstallInactivityDeadline, +) -> StreamInstallReceiveResult>> { + inactivity + .run( + next, + game_id, + cancel_token, + "while waiting for response FIN", + ) + .await +} + +impl Drop for StreamInstallReceiveScope<'_> { + fn drop(&mut self) { + if !self.finished { + stop_stream_install_receive(self.framed_rx.get_mut(), self.game_id); + } } } struct StreamInstallProgress { - id: String, + attempt: DownloadAttemptReporter, total_bytes: u64, downloaded_bytes: u64, last_downloaded_bytes: u64, @@ -890,25 +1751,21 @@ struct StreamInstallProgress { } impl StreamInstallProgress { - fn new(id: String) -> Self { + fn new(attempt: DownloadAttemptReporter, total_bytes: u64) -> Self { Self { - id, - total_bytes: 0, + attempt, + total_bytes, downloaded_bytes: 0, last_downloaded_bytes: 0, last_at: Instant::now(), } } - fn add_total(&mut self, bytes: u64) { - self.total_bytes = self.total_bytes.saturating_add(bytes); - } - fn record_bytes(&mut self, bytes: u64) { self.downloaded_bytes = self.downloaded_bytes.saturating_add(bytes); } - fn emit_current(&mut self, tx_notify_ui: &UnboundedSender) { + fn emit_current(&mut self) { let now = Instant::now(); let speed = bytes_per_second( self.downloaded_bytes @@ -918,17 +1775,17 @@ impl StreamInstallProgress { self.last_downloaded_bytes = self.downloaded_bytes; self.last_at = now; - self.emit_snapshot(tx_notify_ui, speed); + self.emit_snapshot(speed); } - fn emit_snapshot(&self, tx_notify_ui: &UnboundedSender, bytes_per_second: u64) { - let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesProgress(DownloadProgress { - id: self.id.clone(), + fn emit_snapshot(&self, bytes_per_second: u64) { + self.attempt.emit_progress(DownloadProgress { + attempt: self.attempt.key().clone(), downloaded_bytes: self.downloaded_bytes, total_bytes: self.total_bytes, bytes_per_second, active_peer_count: 1, - })); + }); } } @@ -938,75 +1795,93 @@ fn bytes_per_second(bytes: u64, elapsed: Duration) -> u64 { u64::try_from(rate).unwrap_or(u64::MAX) } -struct IncomingFile { - relative_path: String, +struct IncomingFile { + relative_path: CanonicalCatalogPath, path: PathBuf, integrity: SenderArchiveIntegrity, + expected_blake3: Blake3Digest, received: u64, crc32: Hasher, - file: File, + blake3: blake3::Hasher, + file: W, } -impl IncomingFile { +impl IncomingFile { fn new( - relative_path: String, + relative_path: CanonicalCatalogPath, path: PathBuf, expected_size: u64, expected_crc32: u32, - file: File, + expected_blake3: Blake3Digest, + file: W, ) -> Self { Self { relative_path, path, integrity: SenderArchiveIntegrity::new(expected_size, expected_crc32), + expected_blake3, received: 0, crc32: Hasher::new(), + blake3: blake3::Hasher::new(), file, } } - async fn write_chunk( - &mut self, - game_id: &str, - peer_addr: SocketAddr, - tx_notify_ui: &UnboundedSender, - bytes: Bytes, - ) -> eyre::Result { + fn write_chunk(&mut self, bytes: &[u8]) -> StreamInstallReceiveResult { let offset = self.received; - let length = u64::try_from(bytes.len())?; + let length = u64::try_from(bytes.len()).map_err(StreamInstallReceiveError::setup)?; if offset.saturating_add(length) > self.integrity.expected_size { - eyre::bail!( + return Err(StreamInstallReceiveError::integrity(eyre::eyre!( "streamed file {} exceeded expected size {}", self.relative_path, self.integrity.expected_size - ); + ))); } - self.file.write_all(&bytes).await?; - self.crc32.update(&bytes); + scoped_blocking(|| self.file.write_all(bytes)).map_err(StreamInstallReceiveError::setup)?; + self.crc32.update(bytes); + self.blake3.update(bytes); self.received = self.received.saturating_add(length); - let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished { - id: game_id.to_string(), - peer_addr, - relative_path: format!("{game_id}/.local.installing/{}", self.relative_path), - offset, - length, - }); Ok(length) } - async fn finish(mut self, relative_path: &str) -> eyre::Result<()> { - if self.relative_path != relative_path { - eyre::bail!( + fn finish( + mut self, + relative_path: &CanonicalCatalogPath, + game_id: &str, + peer_endpoint: PeerEndpoint, + content_id: ContentId, + tx_notify_ui: &UnboundedSender, + ) -> StreamInstallReceiveResult<()> { + if &self.relative_path != relative_path { + return Err(StreamInstallReceiveError::integrity(eyre::eyre!( "streamed file end mismatch: began {}, ended {relative_path}", self.relative_path - ); + ))); } - self.file.flush().await?; - let actual_crc32 = self.crc32.finalize(); self.integrity - .verify(&self.relative_path, self.received, actual_crc32)?; + .verify(&self.relative_path, self.received, actual_crc32) + .map_err(StreamInstallReceiveError::integrity)?; + let actual_blake3 = Blake3Digest::from_bytes(*self.blake3.finalize().as_bytes()); + if actual_blake3 != self.expected_blake3 { + return Err(StreamInstallReceiveError::integrity(eyre::eyre!( + "streamed file {} catalog BLAKE3 mismatch: got {actual_blake3}, expected {}", + self.relative_path, + self.expected_blake3 + ))); + } + scoped_blocking(|| self.file.flush()).map_err(StreamInstallReceiveError::setup)?; + + let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished { + id: game_id.to_string(), + peer_id: peer_endpoint.peer_id, + peer_addr: peer_endpoint.addr, + content_id, + relative_path: self.relative_path.clone(), + offset: 0, + length: self.received, + }); log::debug!( "Received streamed file {} -> {}", @@ -1017,8 +1892,11 @@ impl IncomingFile { } } -fn resolve_stream_path(staging_dir: &Path, relative_path: &str) -> eyre::Result { - validate_game_file_path(staging_dir, relative_path) +fn resolve_stream_path( + staging_dir: &Path, + relative_path: &CanonicalCatalogPath, +) -> eyre::Result { + validate_game_file_path(staging_dir, relative_path.as_str()) } #[cfg(test)] @@ -1026,16 +1904,39 @@ mod tests { use std::{ sync::{ Arc, - atomic::{AtomicUsize, Ordering}, + Condvar, + Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc as std_mpsc, }, task::{Context, Poll}, }; + use lanspread_db::content_manifest::{ + CatalogContentManifestBody, + CatalogExtractedEntry, + CatalogFileEntry, + }; use tokio::sync::Notify; use super::*; use crate::test_support::TempDir; + fn canonical_path(value: &str) -> CanonicalCatalogPath { + CanonicalCatalogPath::new(value).expect("test path should be canonical") + } + + fn content_id() -> ContentId { + ContentId::from_bytes([7; 32]) + } + + fn peer_endpoint() -> PeerEndpoint { + PeerEndpoint::new( + lanspread_proto::PeerId::from_bytes([9; 32]), + "127.0.0.1:1".parse().expect("address should parse"), + ) + } + struct PendingWriter { write_polls: Arc, first_write: Arc, @@ -1049,17 +1950,54 @@ mod tests { first_shutdown: Arc, } - #[cfg(unix)] - struct FailingReader; + struct DelayedFileWriter { + gate: Arc<(Mutex, Condvar)>, + entered: Option>, + quiesced: Arc, + } - #[cfg(unix)] - impl tokio::io::AsyncRead for FailingReader { - fn poll_read( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - _buf: &mut tokio::io::ReadBuf<'_>, - ) -> Poll> { - Poll::Ready(Err(std::io::Error::other("synthetic pipe read failure"))) + struct RollbackCanary { + quiesced: Arc, + rollback_started: Arc, + rollback_before_quiescence: Arc, + } + + struct DropProbe(Arc); + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + impl std::io::Write for DelayedFileWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + let (gate_open, wake) = &*self.gate; + let gate_open = gate_open.lock().expect("write gate must not be poisoned"); + let _gate_open = wake + .wait_while(gate_open, |gate_open| !*gate_open) + .expect("write gate must not be poisoned"); + self.quiesced + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl Drop for RollbackCanary { + fn drop(&mut self) { + if !self.quiesced.load(std::sync::atomic::Ordering::SeqCst) { + self.rollback_before_quiescence + .store(true, std::sync::atomic::Ordering::SeqCst); + } + self.rollback_started + .store(true, std::sync::atomic::Ordering::SeqCst); } } @@ -1121,6 +2059,80 @@ mod tests { } } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn aborted_incoming_write_quiesces_before_rollback_can_start() { + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let gate_for_release = gate.clone(); + let (request_release, release_requested) = std_mpsc::channel(); + let release_thread = std::thread::spawn(move || { + let _ = release_requested.recv_timeout(Duration::from_secs(2)); + let (gate_open, wake) = &*gate_for_release; + let mut gate_open = gate_open.lock().expect("write gate must not be poisoned"); + *gate_open = true; + wake.notify_one(); + }); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let quiesced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let rollback_started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let rollback_before_quiescence = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut task = tokio::spawn({ + let quiesced = quiesced.clone(); + let rollback_started = rollback_started.clone(); + let rollback_before_quiescence = rollback_before_quiescence.clone(); + async move { + let _rollback = RollbackCanary { + quiesced: quiesced.clone(), + rollback_started, + rollback_before_quiescence, + }; + let bytes = Bytes::from_static(b"x"); + let mut incoming = IncomingFile::new( + canonical_path("payload.bin"), + PathBuf::from("payload.bin"), + 1, + crc32_of(&bytes), + Blake3Digest::hash(&bytes), + DelayedFileWriter { + gate, + entered: Some(entered_tx), + quiesced, + }, + ); + incoming + .write_chunk(&bytes) + .expect("controlled write should succeed"); + std::future::pending::<()>().await; + } + }); + + tokio::time::timeout(Duration::from_secs(2), entered_rx) + .await + .expect("controlled write should start") + .expect("controlled writer should retain entry sender"); + task.abort(); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut task) + .await + .is_err(), + "aborted receiver must remain alive while its write is in progress" + ); + assert!(!quiesced.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!rollback_started.load(std::sync::atomic::Ordering::SeqCst)); + + request_release + .send(()) + .expect("release thread should remain available"); + release_thread + .join() + .expect("release thread should not panic"); + task.await + .expect_err("aborted receiver should stop after write quiescence"); + + assert!(quiesced.load(std::sync::atomic::Ordering::SeqCst)); + assert!(rollback_started.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!rollback_before_quiescence.load(std::sync::atomic::Ordering::SeqCst)); + } + #[tokio::test] async fn producer_sink_cancellation_wins_over_channel_capacity() { let (frame_tx, mut frame_rx) = mpsc::channel(1); @@ -1140,12 +2152,286 @@ mod tests { )); } + #[tokio::test] + async fn incoming_open_cancellation_settles_its_inline_scope() { + let cancelled = CancellationToken::new(); + let dropped = Arc::new(AtomicBool::new(false)); + let probe = DropProbe(dropped.clone()); + let open = async move { + let _probe = probe; + std::future::pending::>().await + }; + cancelled.cancel(); + + let error = await_stream_install_open( + open, + "game", + &cancelled, + StreamInstallInactivityDeadline::ordinary(), + ) + .await + .expect_err("cancelled stream open should fail"); + + assert!(error.to_string().contains("opening its stream")); + assert!(dropped.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn incoming_open_has_an_application_inactivity_deadline() { + let cancellation = CancellationToken::new(); + let error = await_stream_install_open( + std::future::pending::>(), + "game", + &cancellation, + StreamInstallInactivityDeadline::after(Duration::from_millis(25)), + ) + .await + .expect_err("pending stream open must time out"); + + assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport); + assert!(error.to_string().contains("no frame progress")); + } + + #[tokio::test] + async fn progress_ticks_do_not_reset_a_pending_frame_deadline() { + let cancellation = CancellationToken::new(); + let inactivity = StreamInstallInactivityDeadline::after(Duration::from_millis(35)); + let mut progress_ticks = 0; + + let error = loop { + match await_stream_install_input( + std::future::pending::<()>(), + time::sleep(Duration::from_millis(5)), + "game", + &cancellation, + inactivity, + ) + .await + { + Ok(StreamInstallInput::ProgressTick) => progress_ticks += 1, + Ok(StreamInstallInput::Frame(())) => { + panic!("pending frame future must not complete") + } + Err(error) => break error, + } + }; + + assert!( + progress_ticks > 1, + "test must observe periodic progress ticks" + ); + assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport); + assert!(error.to_string().contains("no frame progress")); + } + + #[tokio::test] + async fn post_complete_fin_has_the_same_inactivity_deadline() { + let cancellation = CancellationToken::new(); + let error = await_stream_install_fin( + std::future::pending::>>(), + "game", + &cancellation, + StreamInstallInactivityDeadline::after(Duration::from_millis(25)), + ) + .await + .expect_err("pending response FIN must time out"); + + assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport); + assert!(error.to_string().contains("response FIN")); + } + + #[tokio::test] + async fn incoming_request_cancellation_interrupts_a_blocked_send() { + let write_polls = Arc::new(AtomicUsize::new(0)); + let first_write = Arc::new(Notify::new()); + let mut framed_tx = FramedWrite::new( + PendingWriter { + write_polls: write_polls.clone(), + first_write: first_write.clone(), + }, + LengthDelimitedCodec::new(), + ); + let cancel_token = CancellationToken::new(); + let cancellation = tokio::spawn({ + let cancel_token = cancel_token.clone(); + async move { + first_write.notified().await; + cancel_token.cancel(); + } + }); + + let error = send_stream_install_request_frame( + &mut framed_tx, + "game", + content_id(), + &cancel_token, + StreamInstallInactivityDeadline::ordinary(), + ) + .await + .expect_err("blocked request send should observe cancellation"); + cancellation + .await + .expect("cancellation helper should finish"); + + assert!(error.to_string().contains("sending its request")); + let polls_after_return = write_polls.load(Ordering::SeqCst); + assert!(polls_after_return > 0, "test must reach the blocked send"); + tokio::task::yield_now().await; + assert_eq!(write_polls.load(Ordering::SeqCst), polls_after_return); + } + + #[tokio::test] + async fn incoming_request_send_has_an_application_inactivity_deadline() { + let write_polls = Arc::new(AtomicUsize::new(0)); + let mut framed_tx = FramedWrite::new( + PendingWriter { + write_polls: write_polls.clone(), + first_write: Arc::new(Notify::new()), + }, + LengthDelimitedCodec::new(), + ); + + let error = send_stream_install_request_frame( + &mut framed_tx, + "game", + content_id(), + &CancellationToken::new(), + StreamInstallInactivityDeadline::after(Duration::from_millis(25)), + ) + .await + .expect_err("pending request send must time out"); + + assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport); + assert!(error.to_string().contains("sending its request")); + assert!(error.to_string().contains("no frame progress")); + assert!(write_polls.load(Ordering::SeqCst) > 0); + } + + #[tokio::test] + async fn incoming_request_cancellation_interrupts_a_blocked_close() { + let first_shutdown = Arc::new(Notify::new()); + let mut framed_tx = FramedWrite::new( + PendingShutdownWriter { + first_shutdown: first_shutdown.clone(), + }, + LengthDelimitedCodec::new(), + ); + let cancel_token = CancellationToken::new(); + let cancellation = tokio::spawn({ + let cancel_token = cancel_token.clone(); + async move { + first_shutdown.notified().await; + cancel_token.cancel(); + } + }); + + let error = send_stream_install_request_frame( + &mut framed_tx, + "game", + content_id(), + &cancel_token, + StreamInstallInactivityDeadline::ordinary(), + ) + .await + .expect_err("blocked request close should observe cancellation"); + cancellation + .await + .expect("cancellation helper should finish"); + + assert!(error.to_string().contains("closing its request")); + } + + #[tokio::test] + async fn incoming_request_fin_has_an_application_inactivity_deadline() { + let mut framed_tx = FramedWrite::new( + PendingShutdownWriter { + first_shutdown: Arc::new(Notify::new()), + }, + LengthDelimitedCodec::new(), + ); + + let error = send_stream_install_request_frame( + &mut framed_tx, + "game", + content_id(), + &CancellationToken::new(), + StreamInstallInactivityDeadline::after(Duration::from_millis(25)), + ) + .await + .expect_err("pending request FIN must time out"); + + assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport); + assert!(error.to_string().contains("closing its request")); + assert!(error.to_string().contains("no frame progress")); + } + + #[tokio::test] + async fn incoming_request_success_waits_for_its_close_completion() { + let cancel_token = CancellationToken::new(); + let mut framed_tx = FramedWrite::new( + CancelOnShutdownWriter { + cancel_token: cancel_token.clone(), + }, + LengthDelimitedCodec::new(), + ); + + send_stream_install_request_frame( + &mut framed_tx, + "game", + content_id(), + &cancel_token, + StreamInstallInactivityDeadline::ordinary(), + ) + .await + .expect("request close that completes in the current poll should succeed"); + + assert!( + cancel_token.is_cancelled(), + "test writer must have observed the request close" + ); + } + + #[tokio::test] + async fn incoming_request_carries_the_exact_catalog_content_id() { + let expected_content_id = content_id(); + let cancel_token = CancellationToken::new(); + let (writer, reader) = tokio::io::duplex(4_096); + let mut framed_tx = FramedWrite::new(writer, LengthDelimitedCodec::new()); + + send_stream_install_request_frame( + &mut framed_tx, + "game", + expected_content_id, + &cancel_token, + StreamInstallInactivityDeadline::ordinary(), + ) + .await + .expect("request should send and close"); + + let mut framed_rx = FramedRead::new(reader, LengthDelimitedCodec::new()); + let encoded = framed_rx + .next() + .await + .expect("request frame should exist") + .expect("request frame should decode") + .freeze(); + assert_eq!( + ::decode(encoded) + .expect("request should pass strict control decoding"), + Request::StreamInstall { + game_id: "game".to_owned(), + content_id: expected_content_id, + } + ); + assert!(framed_rx.next().await.is_none(), "request must end at FIN"); + } + #[tokio::test] async fn outbound_cancellation_wins_over_already_queued_frames() { let (frame_tx, mut frame_rx) = mpsc::channel(2); frame_tx .try_send(StreamInstallFrame::Directory { - relative_path: "bin".to_string(), + relative_path: canonical_path("bin"), }) .expect("first frame should fit"); frame_tx @@ -1163,6 +2449,29 @@ mod tests { assert_eq!(frame_rx.len(), 2, "cancelled egress must not drain frames"); } + #[tokio::test] + async fn stalled_provider_cannot_hold_stream_install_authority_forever() { + let (_frame_tx, mut frame_rx) = mpsc::channel(1); + let mut framed_tx = FramedWrite::new(tokio::io::sink(), LengthDelimitedCodec::new()); + + let outcome = forward_stream_install_frames_with_timeout( + &mut framed_tx, + &mut frame_rx, + &CancellationToken::new(), + Duration::from_millis(25), + ) + .await; + + let StreamInstallEgressOutcome::Failed(error) = outcome else { + panic!("stalled producer should hit the application inactivity deadline"); + }; + assert!( + error + .to_string() + .contains("producer timed out after 25ms without a frame") + ); + } + #[tokio::test] async fn outbound_cancellation_interrupts_a_blocked_frame_write() { let (frame_tx, mut frame_rx) = mpsc::channel(1); @@ -1205,6 +2514,38 @@ mod tests { ); } + #[tokio::test] + async fn slow_reader_cannot_hold_stream_install_frame_send_forever() { + let (frame_tx, mut frame_rx) = mpsc::channel(1); + frame_tx + .try_send(StreamInstallFrame::Complete) + .expect("frame should fit"); + let mut framed_tx = FramedWrite::new( + PendingWriter { + write_polls: Arc::new(AtomicUsize::new(0)), + first_write: Arc::new(Notify::new()), + }, + LengthDelimitedCodec::new(), + ); + + let outcome = forward_stream_install_frames_with_timeout( + &mut framed_tx, + &mut frame_rx, + &CancellationToken::new(), + Duration::from_millis(25), + ) + .await; + + let StreamInstallEgressOutcome::Failed(error) = outcome else { + panic!("blocked send should hit the application inactivity deadline"); + }; + assert!( + error + .to_string() + .contains("frame send timed out after 25ms") + ); + } + #[tokio::test] async fn successful_close_is_the_egress_completion_point() { let (frame_tx, mut frame_rx) = mpsc::channel(1); @@ -1253,40 +2594,29 @@ mod tests { assert!(matches!(outcome, StreamInstallEgressOutcome::Cancelled)); } - #[cfg(unix)] #[tokio::test] - async fn unrar_capture_error_kills_and_reaps_child_before_returning() { - let mut child = Command::new("sleep") - .arg("30") - .kill_on_drop(true) - .spawn() - .expect("sleep process should start"); - assert!( - child - .try_wait() - .expect("initial process status should be readable") - .is_none(), - "test child must still be running before capture" + async fn slow_reader_cannot_hold_stream_install_close_forever() { + let (frame_tx, mut frame_rx) = mpsc::channel(1); + drop(frame_tx); + let mut framed_tx = FramedWrite::new( + PendingShutdownWriter { + first_shutdown: Arc::new(Notify::new()), + }, + LengthDelimitedCodec::new(), ); - let err = capture_unrar_output( - &mut child, - &mut FailingReader, - &mut tokio::io::empty(), + let outcome = forward_stream_install_frames_with_timeout( + &mut framed_tx, + &mut frame_rx, &CancellationToken::new(), - Path::new("broken.eti"), + Duration::from_millis(25), ) - .await - .expect_err("synthetic pipe failure should fail capture"); + .await; - assert!(err.to_string().contains("synthetic pipe read failure")); - assert!( - child - .try_wait() - .expect("reaped process status should be readable") - .is_some(), - "capture errors must kill and reap the process before returning" - ); + let StreamInstallEgressOutcome::Failed(error) = outcome else { + panic!("blocked close should hit the application inactivity deadline"); + }; + assert!(error.to_string().contains("close timed out after 25ms")); } #[test] @@ -1296,10 +2626,599 @@ mod tests { std::fs::create_dir_all(&staging).expect("staging should be created"); let staging = std::fs::canonicalize(staging).expect("staging should canonicalize"); - assert!(resolve_stream_path(&staging, "bin/game.exe").is_ok()); - assert!(resolve_stream_path(&staging, "../outside").is_err()); - assert!(resolve_stream_path(&staging, "/absolute").is_err()); - assert!(resolve_stream_path(&staging, "C:/windows").is_err()); + assert!(resolve_stream_path(&staging, &canonical_path("bin/game.exe")).is_ok()); + for invalid in ["../outside", "/absolute", "C:/windows"] { + assert!(CanonicalCatalogPath::new(invalid).is_err()); + } + } + + #[test] + fn catalog_verifier_accepts_exact_output_and_cross_archive_directories() { + let payload = b"catalog payload"; + let manifest = test_catalog_manifest( + &["a.eti", "b.eti"], + vec![ + CatalogExtractedEntry::directory("bin").expect("directory entry should validate"), + CatalogExtractedEntry::file( + "bin/payload.bin", + u64::try_from(payload.len()).expect("payload size should fit"), + Blake3Digest::hash(payload), + ) + .expect("file entry should validate"), + ], + ); + let mut verifier = + CatalogStreamVerifier::new("game", manifest).expect("streamed catalog should verify"); + + verifier + .begin_archive(&canonical_path("a.eti"), 0) + .expect("first archive should begin"); + verifier + .record_directory(&canonical_path("bin")) + .expect("catalog directory should be accepted"); + verifier + .end_archive(&canonical_path("a.eti")) + .expect("first archive should end"); + verifier + .begin_archive( + &canonical_path("b.eti"), + u64::try_from(payload.len()).expect("payload size should fit"), + ) + .expect("second archive should begin"); + verifier + .record_directory(&canonical_path("bin")) + .expect("a directory may recur in another bounded archive"); + assert_eq!( + verifier + .begin_file( + &canonical_path("bin/payload.bin"), + u64::try_from(payload.len()).expect("payload size should fit") + ) + .expect("catalog file should begin"), + Blake3Digest::hash(payload) + ); + verifier + .record_file_chunk(payload.len()) + .expect("nonempty file chunk should be accepted"); + verifier + .end_file(&canonical_path("bin/payload.bin")) + .expect("catalog file should end"); + verifier + .end_archive(&canonical_path("b.eti")) + .expect("second archive should end"); + verifier + .verify_complete() + .expect("the exact catalog output should complete"); + } + + #[test] + fn catalog_verifier_rejects_unknown_shape_size_and_duplicate_files() { + let payload = b"payload"; + let extracted = vec![ + CatalogExtractedEntry::directory("bin").expect("directory should validate"), + CatalogExtractedEntry::file( + "bin/payload.bin", + u64::try_from(payload.len()).expect("payload size should fit"), + Blake3Digest::hash(payload), + ) + .expect("file should validate"), + ]; + + let mut unknown = CatalogStreamVerifier::new( + "game", + test_catalog_manifest(&["a.eti"], extracted.clone()), + ) + .expect("manifest should authorize streaming"); + unknown + .begin_archive(&canonical_path("a.eti"), 0) + .expect("archive should begin"); + assert!( + unknown + .record_directory(&canonical_path("unknown")) + .expect_err("unknown path must fail") + .to_string() + .contains("unknown path") + ); + + let mut wrong_shape = CatalogStreamVerifier::new( + "game", + test_catalog_manifest(&["a.eti"], extracted.clone()), + ) + .expect("manifest should authorize streaming"); + wrong_shape + .begin_archive(&canonical_path("a.eti"), 0) + .expect("archive should begin"); + assert!( + wrong_shape + .record_directory(&canonical_path("bin/payload.bin")) + .expect_err("file-as-directory must fail") + .to_string() + .contains("catalog expects File") + ); + + let mut wrong_size = CatalogStreamVerifier::new( + "game", + test_catalog_manifest(&["a.eti"], extracted.clone()), + ) + .expect("manifest should authorize streaming"); + wrong_size + .begin_archive(&canonical_path("a.eti"), 0) + .expect("archive should begin"); + assert!( + wrong_size + .begin_file(&canonical_path("bin/payload.bin"), 1) + .expect_err("catalog size mismatch must fail") + .to_string() + .contains("catalog expects") + ); + + let mut duplicate = CatalogStreamVerifier::new( + "game", + test_catalog_manifest(&["a.eti", "b.eti"], extracted), + ) + .expect("manifest should authorize streaming"); + duplicate + .begin_archive(&canonical_path("a.eti"), 0) + .expect("first archive should begin"); + duplicate + .begin_file( + &canonical_path("bin/payload.bin"), + u64::try_from(payload.len()).expect("payload size should fit"), + ) + .expect("first file occurrence should begin"); + duplicate + .end_file(&canonical_path("bin/payload.bin")) + .expect("first file occurrence should end"); + duplicate + .end_archive(&canonical_path("a.eti")) + .expect("first archive should end"); + duplicate + .begin_archive(&canonical_path("b.eti"), 0) + .expect("second archive should begin"); + assert!( + duplicate + .begin_file( + &canonical_path("bin/payload.bin"), + u64::try_from(payload.len()).expect("payload size should fit") + ) + .expect_err("duplicate file across archives must fail") + .to_string() + .contains("repeated file") + ); + } + + #[test] + fn catalog_verifier_rejects_missing_and_unbalanced_completion() { + let payload = b"payload"; + let extracted = vec![ + CatalogExtractedEntry::file( + "payload.bin", + u64::try_from(payload.len()).expect("payload size should fit"), + Blake3Digest::hash(payload), + ) + .expect("file should validate"), + ]; + + let mut missing = CatalogStreamVerifier::new( + "game", + test_catalog_manifest(&["a.eti"], extracted.clone()), + ) + .expect("manifest should authorize streaming"); + missing + .begin_archive(&canonical_path("a.eti"), 0) + .expect("archive should begin"); + missing + .end_archive(&canonical_path("a.eti")) + .expect("archive should end"); + assert!( + missing + .verify_complete() + .expect_err("missing catalog file must fail") + .to_string() + .contains("missing catalog entry") + ); + + let mut open_archive = CatalogStreamVerifier::new( + "game", + test_catalog_manifest(&["a.eti"], extracted.clone()), + ) + .expect("manifest should authorize streaming"); + open_archive + .begin_archive(&canonical_path("a.eti"), 0) + .expect("archive should begin"); + assert!( + open_archive + .verify_complete() + .expect_err("Complete before ArchiveEnd must fail") + .to_string() + .contains("before ArchiveEnd") + ); + + let mut open_file = + CatalogStreamVerifier::new("game", test_catalog_manifest(&["a.eti"], extracted)) + .expect("manifest should authorize streaming"); + assert!( + open_file + .begin_archive(&canonical_path("unknown.eti"), 0) + .expect_err("archive outside the catalog set must fail") + .to_string() + .contains("unknown streamed archive") + ); + open_file + .begin_archive(&canonical_path("a.eti"), 0) + .expect("archive should begin"); + assert!( + open_file + .record_file_chunk(1) + .expect_err("chunk before FileBegin must fail") + .to_string() + .contains("without FileBegin") + ); + open_file + .begin_file( + &canonical_path("payload.bin"), + u64::try_from(payload.len()).expect("payload size should fit"), + ) + .expect("file should begin"); + assert!( + open_file + .record_file_chunk(0) + .expect_err("empty chunks must not make an open file unbounded") + .to_string() + .contains("empty FileChunk") + ); + assert!( + open_file + .end_archive(&canonical_path("a.eti")) + .expect_err("ArchiveEnd with an open file must fail") + .to_string() + .contains("while streamed file payload.bin is open") + ); + assert!( + open_file + .verify_complete() + .expect_err("Complete with an open file must fail") + .to_string() + .contains("while streamed file payload.bin is open") + ); + } + + #[test] + fn catalog_verifier_requires_stream_support_and_bounds_entry_frames() { + let unsupported = test_catalog_manifest(&["a.eti"], Vec::new()); + assert!( + CatalogStreamVerifier::new("game", unsupported) + .expect_err("missing extracted manifest must disable streaming") + .to_string() + .contains("does not support") + ); + + let directory_manifest = || { + test_catalog_manifest( + &["a.eti"], + vec![CatalogExtractedEntry::directory("bin").expect("directory should validate")], + ) + }; + let mut repeated = CatalogStreamVerifier::new("game", directory_manifest()) + .expect("manifest should authorize streaming"); + repeated + .begin_archive(&canonical_path("a.eti"), 0) + .expect("archive should begin"); + repeated + .record_directory(&canonical_path("bin")) + .expect("first directory frame should be accepted"); + repeated + .record_directory(&canonical_path("bin")) + .expect("a repeated expected directory should be accepted within the frame bound"); + repeated + .end_archive(&canonical_path("a.eti")) + .expect("archive should end after repeated directories"); + repeated + .verify_complete() + .expect("repeated expected directories should still verify exactly"); + + let mut bounded = CatalogStreamVerifier::new("game", directory_manifest()) + .expect("manifest should authorize streaming"); + bounded + .begin_archive(&canonical_path("a.eti"), 0) + .expect("archive should begin"); + bounded.entry_frames = MAX_CATALOG_ENTRIES; + assert!( + bounded + .record_directory(&canonical_path("bin")) + .expect_err("entry frames beyond the catalog bound must fail") + .to_string() + .contains("entry frame limit") + ); + + let mut telemetry = CatalogStreamVerifier::new( + "game", + test_catalog_manifest( + &["a.eti", "b.eti"], + vec![CatalogExtractedEntry::directory("bin").expect("directory should validate")], + ), + ) + .expect("manifest should authorize streaming"); + telemetry + .begin_archive(&canonical_path("a.eti"), MAX_CATALOG_TOTAL_BYTES) + .expect("reported total at the bound should be accepted"); + telemetry + .end_archive(&canonical_path("a.eti")) + .expect("first archive should end"); + assert!( + telemetry + .begin_archive(&canonical_path("b.eti"), 1) + .expect_err("aggregate sender telemetry must remain bounded") + .to_string() + .contains("reported unpacked limit") + ); + } + + #[test] + fn streamed_install_progress_total_is_catalog_owned() { + let payload = b"catalog-sized payload"; + let manifest = test_catalog_manifest( + &["a.eti"], + vec![ + CatalogExtractedEntry::file( + "payload.bin", + u64::try_from(payload.len()).expect("payload size should fit"), + Blake3Digest::hash(payload), + ) + .expect("file entry should validate"), + ], + ); + let mut verifier = CatalogStreamVerifier::new("game", manifest) + .expect("manifest should authorize streaming"); + verifier + .begin_archive(&canonical_path("a.eti"), 1) + .expect("sender telemetry need not equal the catalog-owned total"); + + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let status = crate::transfer_status::DownloadAttemptStatus::new( + crate::DownloadAttemptKey::next("game".to_owned()), + CancellationToken::new(), + tx, + ); + let progress = + StreamInstallProgress::new(status.reporter(), verifier.expected_file_bytes()); + assert_eq!( + progress.total_bytes, + u64::try_from(payload.len()).expect("payload size should fit") + ); + } + + #[test] + fn receive_boundary_classifies_disconnects_without_quarantining_them() { + assert_eq!( + stream_ended_before_complete("game").kind(), + StreamInstallReceiveErrorKind::Transport + ); + + let cancel_token = CancellationToken::new(); + let read_error = StreamInstallReceiveError::transport_or_cancelled( + std::io::Error::new(std::io::ErrorKind::ConnectionReset, "peer disconnected"), + "game", + &cancel_token, + "while reading its response", + ); + assert_eq!(read_error.kind(), StreamInstallReceiveErrorKind::Transport); + + assert_eq!( + data_after_complete("game").kind(), + StreamInstallReceiveErrorKind::Integrity + ); + + let malformed_path = + Bytes::from_static(b"\0{\"Directory\":{\"relative_path\":\"../escape\"}}"); + assert_eq!( + decode_received_stream_install_frame(malformed_path) + .expect_err("malformed typed path must fail strict decoding") + .kind(), + StreamInstallReceiveErrorKind::Integrity + ); + + let explicit_error = StreamInstallFrame::Error { + message: "sender failed".to_owned(), + }; + assert_eq!( + decode_received_stream_install_frame(explicit_error.encode()) + .expect("explicit sender Error must remain a valid frame"), + explicit_error + ); + } + + #[tokio::test] + async fn oversized_length_delimited_frame_is_an_integrity_failure() { + use tokio::io::AsyncWriteExt as _; + + let (mut writer, reader) = tokio::io::duplex(16); + let oversized = u32::try_from(MAX_STREAM_INSTALL_FRAME_BYTES + 1) + .expect("stream frame bound should fit u32"); + writer + .write_all(&oversized.to_be_bytes()) + .await + .expect("oversized length prefix should write"); + drop(writer); + + let mut framed_rx = FramedRead::new( + reader, + LengthDelimitedCodec::builder() + .max_frame_length(MAX_STREAM_INSTALL_FRAME_BYTES) + .new_codec(), + ); + let codec_error = framed_rx + .next() + .await + .expect("oversized prefix should produce a codec result") + .expect_err("oversized frame must fail before allocation"); + assert_eq!(codec_error.kind(), std::io::ErrorKind::InvalidData); + + let cancellation = CancellationToken::new(); + let classified = classify_stream_install_read_error( + codec_error, + "game", + &cancellation, + "while reading its response", + ); + assert_eq!(classified.kind(), StreamInstallReceiveErrorKind::Integrity); + } + + #[test] + fn incoming_file_requires_catalog_blake3_after_sender_crc_passes() { + let payload = b"payload"; + let endpoint = peer_endpoint(); + let exact_content_id = content_id(); + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut accepted = IncomingFile::new( + canonical_path("accepted.bin"), + PathBuf::from("accepted.bin"), + u64::try_from(payload.len()).expect("payload size should fit"), + crc32_of(payload), + Blake3Digest::hash(payload), + Vec::new(), + ); + accepted + .write_chunk(payload) + .expect("payload write should succeed"); + assert!( + event_rx.try_recv().is_err(), + "unverified streamed bytes must not emit a completion event" + ); + accepted + .finish( + &canonical_path("accepted.bin"), + "game", + endpoint, + exact_content_id, + &event_tx, + ) + .expect("matching catalog hash should finish"); + assert!(matches!( + event_rx.try_recv(), + Ok(PeerEvent::DownloadGameFileChunkFinished { + peer_id, + content_id, + relative_path, + offset: 0, + length, + .. + }) if peer_id == endpoint.peer_id + && content_id == exact_content_id + && relative_path.as_str() == "accepted.bin" + && length == u64::try_from(payload.len()).expect("payload length should fit") + )); + + let mut incoming = IncomingFile::new( + canonical_path("payload.bin"), + PathBuf::from("payload.bin"), + u64::try_from(payload.len()).expect("payload size should fit"), + crc32_of(payload), + Blake3Digest::hash(b"different trusted bytes"), + Vec::new(), + ); + incoming + .write_chunk(payload) + .expect("payload write should succeed"); + + let error = incoming + .finish( + &canonical_path("payload.bin"), + "game", + endpoint, + exact_content_id, + &event_tx, + ) + .expect_err("catalog digest mismatch must fail after matching sender CRC"); + assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Integrity); + assert!(error.to_string().contains("catalog BLAKE3 mismatch")); + assert!( + event_rx.try_recv().is_err(), + "catalog-rejected bytes must never emit completion" + ); + } + + fn test_catalog_manifest( + archives: &[&str], + streamed_install_files: Vec, + ) -> Arc { + let archive_digest = Blake3Digest::hash(b"archive"); + let mut files = archives + .iter() + .map(|archive| { + CatalogFileEntry::file(*archive, 7, archive_digest, vec![archive_digest]) + .expect("archive entry should validate") + }) + .collect::>(); + let version = b"20240101"; + let version_digest = Blake3Digest::hash(version); + files.push( + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("version size should fit"), + version_digest, + vec![version_digest], + ) + .expect("version entry should validate"), + ); + files.sort_by(|left, right| left.canonical_path().cmp(right.canonical_path())); + + Arc::new( + CatalogContentManifest::seal( + CatalogContentManifestBody::new("game", "20240101", files, streamed_install_files) + .expect("manifest body should validate"), + ) + .expect("manifest should seal"), + ) + } + + #[test] + fn sender_uses_only_the_exact_catalog_archive_set() { + let temp = TempDir::new("lanspread-stream-install-sender-archives"); + std::fs::write(temp.path().join("a.eti"), b"archive") + .expect("catalog archive should be written"); + let extracted = vec![ + CatalogExtractedEntry::file("payload.bin", 1, Blake3Digest::hash(b"x")) + .expect("extracted file should validate"), + ]; + let manifest = test_catalog_manifest(&["a.eti"], extracted.clone()); + + assert_eq!( + catalog_stream_archives(temp.path(), "game", &manifest) + .expect("the exact catalog archive set should be admitted"), + vec![temp.path().join("a.eti")] + ); + + std::fs::write(temp.path().join("b.eti"), b"extra") + .expect("extra archive should be written"); + assert!( + catalog_stream_archives(temp.path(), "game", &manifest).is_err(), + "a root archive outside the catalog must never be streamed" + ); + + let unsupported = test_catalog_manifest(&["a.eti", "b.eti"], Vec::new()); + assert!( + catalog_stream_archives(temp.path(), "game", &unsupported).is_err(), + "a game without verified extracted output must not offer Stream Install" + ); + } + + #[test] + fn archive_catalog_name_never_fabricates_a_missing_name() { + let error = archive_catalog_name(Path::new("/")) + .expect_err("a path without a file name must be rejected"); + assert!(error.to_string().contains("has no file name")); + } + + #[cfg(unix)] + #[test] + fn archive_catalog_name_rejects_non_utf8_before_provider_work() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt as _}; + + let archive = PathBuf::from(OsString::from_vec(vec![b'a', 0xff, b'.', b'e', b't', b'i'])); + let error = + archive_catalog_name(&archive).expect_err("a non-UTF-8 archive name must be rejected"); + assert!(error.to_string().contains("not valid UTF-8")); } #[test] @@ -1325,13 +3244,13 @@ Details: RAR 5, solid listing.entries, vec![ RarEntry { - relative_path: "bin/payload.bin".to_string(), + relative_path: canonical_path("bin/payload.bin"), kind: RarEntryKind::File, size: 123, crc32: Some(0x38B4_88A7), }, RarEntry { - relative_path: "bin".to_string(), + relative_path: canonical_path("bin"), kind: RarEntryKind::Directory, size: 0, crc32: None, @@ -1340,6 +3259,14 @@ Details: RAR 5, solid ); } + #[test] + fn truncated_unrar_listing_metadata_fails_closed() { + let error = reject_truncated_unrar_listing(true, false, Path::new("large.eti")) + .expect_err("truncated listing stdout must not authorize a partial manifest"); + + assert!(error.to_string().contains("metadata limit")); + } + #[test] fn rejects_unrar_file_entries_without_crc32() { let err = parse_unrar_listing( @@ -1374,7 +3301,7 @@ Details: RAR 5 assert_eq!( listing.entries, vec![RarEntry { - relative_path: "bin/empty.cfg".to_string(), + relative_path: canonical_path("bin/empty.cfg"), kind: RarEntryKind::File, size: 0, crc32: Some(0), @@ -1389,7 +3316,11 @@ Details: RAR 5 let integrity = SenderArchiveIntegrity::new(byte_len, crc32_of(bytes)); integrity - .verify("bin/payload.bin", byte_len, crc32_of(bytes)) + .verify( + &canonical_path("bin/payload.bin"), + byte_len, + crc32_of(bytes), + ) .expect("matching sender archive metadata should verify"); } @@ -1397,7 +3328,7 @@ Details: RAR 5 fn sender_archive_integrity_rejects_size_mismatch() { let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload")); let err = integrity - .verify("bin/payload.bin", 6, crc32_of(b"payload")) + .verify(&canonical_path("bin/payload.bin"), 6, crc32_of(b"payload")) .expect_err("truncated file should fail sender archive integrity"); assert!(err.to_string().contains("size mismatch")); @@ -1407,7 +3338,7 @@ Details: RAR 5 fn sender_archive_integrity_rejects_crc32_mismatch() { let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload")); let err = integrity - .verify("bin/payload.bin", 7, crc32_of(b"paylord")) + .verify(&canonical_path("bin/payload.bin"), 7, crc32_of(b"paylord")) .expect_err("mutated file should fail sender archive integrity"); assert!(err.to_string().contains("sender RAR CRC32 mismatch")); diff --git a/crates/lanspread-peer/src/test_support.rs b/crates/lanspread-peer/src/test_support.rs index 9b2be80..67eb51a 100644 --- a/crates/lanspread-peer/src/test_support.rs +++ b/crates/lanspread-peer/src/test_support.rs @@ -1,9 +1,20 @@ use std::{ path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, time::{SystemTime, UNIX_EPOCH}, }; +use lanspread_db::content_manifest::{ + Blake3Digest, + CatalogBundle, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, +}; + static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); pub(crate) struct TempDir(PathBuf); @@ -39,3 +50,45 @@ impl Drop for TempDir { let _ = std::fs::remove_dir_all(&self.0); } } + +pub(crate) fn empty_catalog_bundle() -> Arc { + catalog_bundle(std::iter::empty::<(String, String)>()) +} + +pub(crate) fn catalog_bundle(entries: I) -> Arc +where + I: IntoIterator, + G: Into, + V: Into, +{ + let manifests = entries + .into_iter() + .map(|(game_id, game_version)| { + let game_version = game_version.into(); + let version_digest = Blake3Digest::hash(game_version.as_bytes()); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + game_id, + &game_version, + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(game_version.len()) + .expect("test version length should fit u64"), + version_digest, + vec![version_digest], + ) + .expect("test version.ini entry should be valid"), + ], + Vec::new(), + ) + .expect("test catalog manifest body should be valid"), + ) + .expect("test catalog manifest should seal") + }) + .collect::>(); + Arc::new( + CatalogBundle::from_manifests(manifests) + .expect("test catalog bundle should be a complete immutable authority"), + ) +} diff --git a/crates/lanspread-peer/src/tls.rs b/crates/lanspread-peer/src/tls.rs new file mode 100644 index 0000000..02088aa --- /dev/null +++ b/crates/lanspread-peer/src/tls.rs @@ -0,0 +1,382 @@ +//! TLS 1.3 responder identity for QUIC. + +use std::sync::Arc; +#[cfg(test)] +use std::{ + collections::HashMap, + sync::{ + LazyLock, + Mutex, + Weak, + atomic::{AtomicUsize, Ordering}, + }, + thread::{self, ThreadId}, +}; + +use lanspread_proto::{ALPN_PROTOCOL, PeerId}; +#[cfg(test)] +use rustls::sign::{CertifiedKey, SingleCertAndKey}; +use rustls::{ + CertificateError, + ClientConfig, + DigitallySignedStruct, + Error as RustlsError, + ServerConfig, + SignatureScheme, + client::{ + Resumption, + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + }, + crypto::WebPkiSupportedAlgorithms, + pki_types::{CertificateDer, ServerName, UnixTime}, + server::{NoServerSessionStorage, ParsedCertificate}, +}; +use s2n_quic::provider::tls::rustls::{ + client::Client as S2nRustlsClient, + server::Server as S2nRustlsServer, +}; + +use crate::identity::{PeerIdentity, peer_id_from_spki, server_name_for_peer}; + +pub(crate) fn protocol_alpn() -> Vec { + ALPN_PROTOCOL.to_vec() +} + +#[derive(Debug)] +struct PeerIdServerCertVerifier { + supported_algorithms: WebPkiSupportedAlgorithms, +} + +impl ServerCertVerifier for PeerIdServerCertVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + if !intermediates.is_empty() || !ocsp_response.is_empty() { + return Err(application_verification_failure()); + } + + let expected_peer = expected_peer_from_server_name(server_name)?; + let parsed = ParsedCertificate::try_from(end_entity)?; + let spki = parsed.subject_public_key_info(); + let actual_peer = + peer_id_from_spki(spki.as_ref()).map_err(|_| application_verification_failure())?; + if actual_peer != expected_peer { + return Err(application_verification_failure()); + } + + rustls::client::verify_server_name(&parsed, server_name)?; + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Err(RustlsError::General( + "TLS 1.2 CertificateVerify is disabled".to_owned(), + )) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + if dss.scheme != SignatureScheme::ED25519 { + return Err(RustlsError::General( + "unexpected TLS 1.3 CertificateVerify scheme".to_owned(), + )); + } + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported_algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![SignatureScheme::ED25519] + } +} + +pub(crate) fn client_provider() -> Result { + let provider = rustls::crypto::aws_lc_rs::default_provider(); + let verifier: Arc = Arc::new(PeerIdServerCertVerifier { + supported_algorithms: provider.signature_verification_algorithms, + }); + #[cfg(test)] + let verifier = decorate_verifier_for_current_test(verifier); + let mut config = ClientConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&rustls::version::TLS13])? + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + config.alpn_protocols = vec![protocol_alpn()]; + config.check_selected_alpn = true; + config.enable_sni = true; + config.resumption = Resumption::disabled(); + config.enable_early_data = false; + Ok(S2nRustlsClient::from(config)) +} + +pub(crate) fn server_provider(identity: &PeerIdentity) -> Result { + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let mut config = ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13])? + .with_no_client_auth() + .with_single_cert(vec![identity.certificate()], identity.private_key())?; + harden_server_config(&mut config); + Ok(S2nRustlsServer::from(config)) +} + +fn harden_server_config(config: &mut ServerConfig) { + config.alpn_protocols = vec![protocol_alpn()]; + config.session_storage = Arc::new(NoServerSessionStorage {}); + config.send_tls13_tickets = 0; + config.max_tls13_tickets = 0; + config.max_early_data_size = 0; + config.send_half_rtt_data = false; +} + +#[cfg(test)] +pub(crate) fn hostile_mismatched_server_provider( + certificate_identity: &PeerIdentity, + signing_identity: &PeerIdentity, +) -> Result { + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let signer = + rustls::crypto::aws_lc_rs::sign::any_supported_type(&signing_identity.private_key())?; + let certified_key = CertifiedKey::new(vec![certificate_identity.certificate()], signer); + let resolver = Arc::new(SingleCertAndKey::from(certified_key)); + let mut config = ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13])? + .with_no_client_auth() + .with_cert_resolver(resolver); + harden_server_config(&mut config); + Ok(S2nRustlsServer::from(config)) +} + +#[cfg(test)] +#[derive(Debug, Default)] +struct VerificationCounts { + certificate_calls: AtomicUsize, + certificate_accepts: AtomicUsize, + tls12_calls: AtomicUsize, + tls13_calls: AtomicUsize, + tls13_accepts: AtomicUsize, +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct VerificationSnapshot { + pub(crate) certificate_calls: usize, + pub(crate) certificate_accepts: usize, + pub(crate) tls12_calls: usize, + pub(crate) tls13_calls: usize, + pub(crate) tls13_accepts: usize, +} + +#[cfg(test)] +impl VerificationCounts { + fn snapshot(&self) -> VerificationSnapshot { + VerificationSnapshot { + certificate_calls: self.certificate_calls.load(Ordering::SeqCst), + certificate_accepts: self.certificate_accepts.load(Ordering::SeqCst), + tls12_calls: self.tls12_calls.load(Ordering::SeqCst), + tls13_calls: self.tls13_calls.load(Ordering::SeqCst), + tls13_accepts: self.tls13_accepts.load(Ordering::SeqCst), + } + } +} + +#[cfg(test)] +#[derive(Debug)] +struct CountingVerifier { + inner: Arc, + counts: Arc, +} + +#[cfg(test)] +impl ServerCertVerifier for CountingVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + self.counts.certificate_calls.fetch_add(1, Ordering::SeqCst); + let result = self.inner.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + ); + if result.is_ok() { + self.counts + .certificate_accepts + .fetch_add(1, Ordering::SeqCst); + } + result + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.counts.tls12_calls.fetch_add(1, Ordering::SeqCst); + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.counts.tls13_calls.fetch_add(1, Ordering::SeqCst); + let result = self.inner.verify_tls13_signature(message, cert, dss); + if result.is_ok() { + self.counts.tls13_accepts.fetch_add(1, Ordering::SeqCst); + } + result + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +#[cfg(test)] +static TEST_VERIFICATION_COUNTERS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Lexically scopes a verifier decorator to the client provider constructed on +/// the current test thread. The verifier retains its own counter after QUIC +/// starts; unrelated parallel test threads continue to use the undecorated +/// production verifier. +#[cfg(test)] +pub(crate) struct TestVerificationCounter { + thread_id: ThreadId, + counts: Arc, +} + +#[cfg(test)] +impl TestVerificationCounter { + pub(crate) fn install() -> eyre::Result { + let thread_id = thread::current().id(); + let counts = Arc::new(VerificationCounts::default()); + let mut installed = TEST_VERIFICATION_COUNTERS + .lock() + .map_err(|_| eyre::eyre!("TLS verification counter registry was poisoned"))?; + installed.retain(|_, counter| counter.strong_count() != 0); + eyre::ensure!( + !installed.contains_key(&thread_id), + "TLS verification counter is already installed on this test thread" + ); + installed.insert(thread_id, Arc::downgrade(&counts)); + drop(installed); + Ok(Self { thread_id, counts }) + } + + pub(crate) fn snapshot(&self) -> VerificationSnapshot { + self.counts.snapshot() + } +} + +#[cfg(test)] +impl Drop for TestVerificationCounter { + fn drop(&mut self) { + let Ok(mut installed) = TEST_VERIFICATION_COUNTERS.lock() else { + return; + }; + let should_remove = installed + .get(&self.thread_id) + .and_then(Weak::upgrade) + .is_some_and(|counts| Arc::ptr_eq(&counts, &self.counts)); + if should_remove { + installed.remove(&self.thread_id); + } + } +} + +#[cfg(test)] +fn decorate_verifier_for_current_test( + verifier: Arc, +) -> Arc { + let counts = TEST_VERIFICATION_COUNTERS + .lock() + .ok() + .and_then(|installed| { + installed + .get(&thread::current().id()) + .and_then(Weak::upgrade) + }); + counts.map_or(verifier.clone(), |counts| { + Arc::new(CountingVerifier { + inner: verifier, + counts, + }) + }) +} + +pub(crate) fn sni_for_peer(peer_id: PeerId) -> Result { + server_name_for_peer(peer_id).map_err(|_| application_verification_failure()) +} + +fn expected_peer_from_server_name(server_name: &ServerName<'_>) -> Result { + let ServerName::DnsName(dns_name) = server_name else { + return Err(application_verification_failure()); + }; + let encoded = dns_name.as_ref(); + let suffix = crate::identity::PEER_SNI_SUFFIX; + let raw_peer_id = encoded + .strip_suffix(suffix) + .ok_or_else(application_verification_failure)?; + let peer_id = raw_peer_id + .parse::() + .map_err(|_| application_verification_failure())?; + if server_name_for_peer(peer_id).map_err(|_| application_verification_failure())? != encoded { + return Err(application_verification_failure()); + } + Ok(peer_id) +} + +fn application_verification_failure() -> RustlsError { + RustlsError::InvalidCertificate(CertificateError::ApplicationVerificationFailure) +} + +#[cfg(test)] +mod tests { + use eyre::ensure; + use lanspread_proto::PeerId; + use rustls::pki_types::ServerName; + + use super::{expected_peer_from_server_name, protocol_alpn, sni_for_peer}; + + #[test] + fn alpn_is_bound_to_the_current_wire_version() { + assert_eq!(protocol_alpn(), lanspread_proto::ALPN_PROTOCOL); + assert_ne!(protocol_alpn(), b"lanspread/7"); + } + + #[test] + fn expected_peer_round_trips_only_through_exact_sni() -> eyre::Result<()> { + let peer_id = PeerId::from_bytes([0x5a; 32]); + let encoded = sni_for_peer(peer_id)?; + let server_name = ServerName::try_from(encoded)?; + ensure!(expected_peer_from_server_name(&server_name)? == peer_id); + + let wrong_suffix = ServerName::try_from(format!("{peer_id}.example.invalid"))?; + ensure!(expected_peer_from_server_name(&wrong_suffix).is_err()); + Ok(()) + } +} diff --git a/crates/lanspread-peer/src/tls_identity_spike.rs b/crates/lanspread-peer/src/tls_identity_spike.rs new file mode 100644 index 0000000..4b91100 --- /dev/null +++ b/crates/lanspread-peer/src/tls_identity_spike.rs @@ -0,0 +1,555 @@ +//! End-to-end responder-identity proofs over the production QUIC/TLS path. +//! +//! Only the deliberately hostile certificate-A/key-B resolver is test-only. +//! Identities, peer IDs, TLS policy, client startup, SNI construction, and +//! connection establishment all come from the production implementation. + +use std::{ + future::Future, + net::SocketAddr, + ops::{Deref, DerefMut}, + panic::AssertUnwindSafe, + sync::Arc, + time::Duration, +}; + +use bytes::Bytes; +use eyre::{WrapErr as _, ensure}; +use futures::FutureExt as _; +use lanspread_proto::{PeerEndpoint, PeerId}; +use s2n_quic::{ + Connection, + Server as QuicServer, + provider::tls::rustls::server::Server as S2nRustlsServer, +}; +use tokio_util::sync::CancellationToken; + +use crate::{ + identity::PeerIdentity, + quic_runtime::{ + EndpointTask, + QuicClientRuntime, + QuicConnector, + start_quic_client, + tracked_quic_io, + }, + tls::{self, TestVerificationCounter, hostile_mismatched_server_provider}, +}; + +const TEST_TIMEOUT: Duration = Duration::from_secs(5); +const LOOPBACK_EPHEMERAL: SocketAddr = + SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 0); + +struct CloseOnDrop(Connection); + +impl Deref for CloseOnDrop { + type Target = Connection; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for CloseOnDrop { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Drop for CloseOnDrop { + fn drop(&mut self) { + self.0.close(0_u32.into()); + } +} + +struct TestServer { + server: Option, + endpoint: Option, + addr: SocketAddr, +} + +impl TestServer { + async fn start(bind_addr: SocketAddr, tls: S2nRustlsServer) -> eyre::Result { + let (io, control) = tracked_quic_io(bind_addr)?; + let server = QuicServer::builder().with_tls(tls)?.with_io(io)?.start()?; + let endpoint = control.take_started()?; + let addr_result = server + .local_addr() + .map_err(eyre::Report::from) + .and_then(|addr| { + ensure!( + addr.port() != 0, + "test server did not resolve an ephemeral port" + ); + Ok(addr) + }); + match addr_result { + Ok(addr) => Ok(Self { + server: Some(server), + endpoint: Some(endpoint), + addr, + }), + Err(error) => { + drop(server); + let cleanup = endpoint.shutdown_and_join().await; + merge_results(Err(error), cleanup) + } + } + } + + fn server_mut(&mut self) -> eyre::Result<&mut QuicServer> { + self.server + .as_mut() + .ok_or_else(|| eyre::eyre!("test server was already stopped")) + } + + async fn shutdown(mut self) -> eyre::Result<()> { + drop(self.server.take()); + let endpoint = self + .endpoint + .take() + .ok_or_else(|| eyre::eyre!("test server endpoint was already joined"))?; + endpoint.shutdown_and_join().await + } +} + +struct TestClient { + runtime: Option, + connector: Option, +} + +impl TestClient { + fn start() -> eyre::Result { + let (runtime, connector) = start_quic_client()?; + Ok(Self { + runtime: Some(runtime), + connector: Some(connector), + }) + } + + fn connector(&self) -> eyre::Result<&QuicConnector> { + self.connector + .as_ref() + .ok_or_else(|| eyre::eyre!("test client was already stopped")) + } + + async fn shutdown(mut self) -> eyre::Result<()> { + drop(self.connector.take()); + self.runtime + .take() + .ok_or_else(|| eyre::eyre!("test client runtime was already joined"))? + .shutdown() + .await + } + + async fn shutdown_rejected_handshake_fixture(mut self) -> eyre::Result<()> { + drop(self.connector.take()); + self.runtime + .take() + .ok_or_else(|| eyre::eyre!("test client runtime was already joined"))? + .shutdown_rejected_handshake_fixture() + .await + } +} + +struct TestPair { + server: TestServer, + client: TestClient, +} + +impl TestPair { + async fn start(server_tls: S2nRustlsServer) -> eyre::Result { + let server = TestServer::start(LOOPBACK_EPHEMERAL, server_tls).await?; + match TestClient::start() { + Ok(client) => Ok(Self { server, client }), + Err(error) => { + let cleanup = server.shutdown().await; + merge_results(Err(error), cleanup) + } + } + } + + async fn shutdown(self) -> eyre::Result<()> { + let (server_result, client_result) = + tokio::join!(self.server.shutdown(), self.client.shutdown()); + merge_results(server_result, client_result) + } + + async fn shutdown_rejected_handshake_fixture(self) -> eyre::Result<()> { + let server_result = self.server.shutdown().await; + let client_result = self.client.shutdown_rejected_handshake_fixture().await; + merge_results(server_result, client_result) + } +} + +fn merge_results(primary: eyre::Result, cleanup: eyre::Result<()>) -> eyre::Result { + match (primary, cleanup) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), Ok(())) | (Ok(_), Err(error)) => Err(error), + (Err(error), Err(cleanup_error)) => Err(eyre::eyre!( + "operation failed: {error:#}; cleanup also failed: {cleanup_error:#}" + )), + } +} + +async fn bounded( + cancellation: &CancellationToken, + label: &'static str, + operation: impl Future>, +) -> eyre::Result { + tokio::select! { + biased; + () = cancellation.cancelled() => Err(eyre::eyre!("{label} cancelled")), + result = tokio::time::timeout(TEST_TIMEOUT, operation) => { + result.wrap_err_with(|| format!("{label} timed out"))? + } + } +} + +async fn supervise( + future: impl Future>, + sibling_stop: CancellationToken, +) -> eyre::Result { + match AssertUnwindSafe(future).catch_unwind().await { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => { + sibling_stop.cancel(); + Err(error) + } + Err(_) => { + sibling_stop.cancel(); + Err(eyre::eyre!("QUIC responder-identity proof branch panicked")) + } + } +} + +async fn serve_one( + server: &mut QuicServer, + cancellation: CancellationToken, + exchange_finished: Arc, +) -> eyre::Result<()> { + let connection = bounded(&cancellation, "server accept", async { + server + .accept() + .await + .ok_or_else(|| eyre::eyre!("test server closed before accepting a connection")) + }) + .await?; + let mut connection = CloseOnDrop(connection); + let expected_alpn = tls::protocol_alpn(); + ensure!( + connection.application_protocol()?.as_ref() == expected_alpn.as_slice(), + "server negotiated the wrong production ALPN" + ); + + let stream = bounded(&cancellation, "server stream accept", async { + connection + .accept_bidirectional_stream() + .await? + .ok_or_else(|| eyre::eyre!("client closed before opening its request stream")) + }) + .await?; + let mut stream = stream; + let mut request = Vec::new(); + while let Some(chunk) = bounded(&cancellation, "server request receive", async { + Ok(stream.receive().await?) + }) + .await? + { + request.extend_from_slice(&chunk); + ensure!(request.len() <= 1, "client sent an oversized proof request"); + } + ensure!(request == b"?", "client sent the wrong proof request"); + + bounded(&cancellation, "server response send", async { + stream.send(Bytes::from_static(&[0xa5])).await?; + Ok(()) + }) + .await?; + stream.finish()?; + bounded(&cancellation, "server exchange barrier", async { + exchange_finished.wait().await; + Ok(()) + }) + .await +} + +async fn request_one( + client: &TestClient, + endpoint: &PeerEndpoint, + cancellation: CancellationToken, + exchange_finished: Arc, +) -> eyre::Result<()> { + let connection = bounded(&cancellation, "client connect", async { + client.connector()?.connect(endpoint).await + }) + .await?; + let mut connection = connection; + let expected_alpn = tls::protocol_alpn(); + ensure!( + connection.application_protocol()?.as_ref() == expected_alpn.as_slice(), + "client negotiated the wrong production ALPN" + ); + let stream = bounded(&cancellation, "client stream open", async { + Ok(connection.open_bidirectional_stream().await?) + }) + .await?; + let mut stream = stream; + bounded(&cancellation, "client request send", async { + stream.send(Bytes::from_static(b"?")).await?; + stream.finish()?; + Ok(()) + }) + .await?; + + let mut response = Vec::new(); + while let Some(chunk) = bounded(&cancellation, "client response receive", async { + Ok(stream.receive().await?) + }) + .await? + { + response.extend_from_slice(&chunk); + ensure!( + response.len() <= 1, + "server sent an oversized proof response" + ); + } + ensure!(response == [0xa5], "server sent the wrong proof response"); + bounded(&cancellation, "client exchange barrier", async { + exchange_finished.wait().await; + Ok(()) + }) + .await +} + +async fn run_successful_exchange( + server: &mut TestServer, + client: &TestClient, + endpoint: PeerEndpoint, +) -> eyre::Result<()> { + let cancellation = CancellationToken::new(); + let exchange_finished = Arc::new(tokio::sync::Barrier::new(2)); + let server_future = supervise( + serve_one( + server.server_mut()?, + cancellation.clone(), + exchange_finished.clone(), + ), + cancellation.clone(), + ); + let client_future = supervise( + request_one(client, &endpoint, cancellation.clone(), exchange_finished), + cancellation.clone(), + ); + let (server_result, client_result) = tokio::join!(server_future, client_future); + cancellation.cancel(); + merge_results(server_result, client_result) +} + +async fn expect_rejected_connection( + server: &mut TestServer, + client: &TestClient, + endpoint: PeerEndpoint, +) -> eyre::Result<()> { + let cancellation = CancellationToken::new(); + let server_stop = cancellation.clone(); + let server_future = supervise( + async { + tokio::select! { + biased; + () = server_stop.cancelled() => Ok(()), + accepted = server.server_mut()?.accept() => { + if let Some(connection) = accepted { + connection.close(0_u32.into()); + Err(eyre::eyre!("server accepted a connection that should fail authentication")) + } else { + Err(eyre::eyre!("server endpoint closed during rejected handshake")) + } + } + } + }, + cancellation.clone(), + ); + let client_stop = cancellation.clone(); + let client_future = supervise( + async { + let result = tokio::select! { + biased; + () = client_stop.cancelled() => { + return Err(eyre::eyre!("client connection attempt was cancelled")); + } + result = tokio::time::timeout( + TEST_TIMEOUT, + client.connector()?.connect(&endpoint), + ) => result, + }; + let outcome = match result { + Err(_) => Err(eyre::eyre!("client authentication rejection timed out")), + Ok(Err(_)) => Ok(()), + Ok(Ok(connection)) => { + drop(connection); + Err(eyre::eyre!("client accepted the wrong responder identity")) + } + }; + client_stop.cancel(); + outcome + }, + cancellation.clone(), + ); + + let (server_result, client_result) = tokio::join!(server_future, client_future); + cancellation.cancel(); + merge_results(client_result, server_result) +} + +async fn execute_successful_exchanges(identity: &PeerIdentity, count: usize) -> eyre::Result<()> { + let server_tls = tls::server_provider(identity)?; + let mut pair = TestPair::start(server_tls).await?; + let endpoint = PeerEndpoint { + peer_id: identity.peer_id(), + addr: pair.server.addr, + }; + let mut operation = Ok(()); + for _ in 0..count { + if operation.is_ok() { + operation = run_successful_exchange(&mut pair.server, &pair.client, endpoint).await; + } + } + let cleanup = pair.shutdown().await; + merge_results(operation, cleanup) +} + +async fn execute_rejected_exchange( + server_tls: S2nRustlsServer, + expected_peer: PeerId, + verify_rejection: impl FnOnce() -> eyre::Result<()>, +) -> eyre::Result<()> { + let mut pair = TestPair::start(server_tls).await?; + let endpoint = PeerEndpoint { + peer_id: expected_peer, + addr: pair.server.addr, + }; + let operation = expect_rejected_connection(&mut pair.server, &pair.client, endpoint) + .await + .and_then(|()| verify_rejection()); + let cleanup = pair.shutdown_rejected_handshake_fixture().await; + merge_results(operation, cleanup) +} + +#[test] +fn peer_id_sni_and_alpn_golden_vectors_are_production_values() -> eyre::Result<()> { + let zero_vector = PeerId::from_bytes([0_u8; 32]); + let zero_encoded = "a".repeat(52); + ensure!(zero_vector.to_string() == zero_encoded); + ensure!(zero_encoded.parse::()? == zero_vector); + + let ones_vector = PeerId::from_bytes([u8::MAX; 32]); + let ones_encoded = format!("{}q", "7".repeat(51)); + ensure!(ones_vector.to_string() == ones_encoded); + ensure!(ones_encoded.parse::()? == ones_vector); + + let identity = PeerIdentity::generate()?; + let encoded = identity.peer_id().to_string(); + ensure!(encoded.len() == 52); + ensure!(encoded.parse::()? == identity.peer_id()); + ensure!(encoded.to_uppercase().parse::().is_err()); + ensure!(tls::sni_for_peer(identity.peer_id())?.ends_with(".peer.lanspread.invalid")); + ensure!(tls::protocol_alpn() == lanspread_proto::ALPN_PROTOCOL); + ensure!(tls::protocol_alpn() != b"lanspread/7"); + Ok(()) +} + +#[tokio::test] +async fn correct_certificate_and_key_complete_a_full_production_quic_exchange() -> eyre::Result<()> +{ + let identity = PeerIdentity::generate()?; + execute_successful_exchanges(&identity, 1).await +} + +#[tokio::test] +async fn certificate_a_signed_by_private_key_b_fails_production_certificate_verify() +-> eyre::Result<()> { + let identity_a = PeerIdentity::generate()?; + let identity_b = PeerIdentity::generate()?; + let counter = TestVerificationCounter::install()?; + let server_tls = hostile_mismatched_server_provider(&identity_a, &identity_b)?; + execute_rejected_exchange(server_tls, identity_a.peer_id(), || { + let snapshot = counter.snapshot(); + ensure!(snapshot.certificate_calls == 1); + ensure!(snapshot.certificate_accepts == 1); + ensure!(snapshot.tls12_calls == 0); + ensure!(snapshot.tls13_calls == 1); + ensure!(snapshot.tls13_accepts == 0); + Ok(()) + }) + .await +} + +#[tokio::test] +async fn different_valid_peer_at_reused_address_fails_production_expected_id_pin() +-> eyre::Result<()> { + let identity_a = PeerIdentity::generate()?; + let identity_b = PeerIdentity::generate()?; + let counter = TestVerificationCounter::install()?; + let client = TestClient::start()?; + let mut server_a = + match TestServer::start(LOOPBACK_EPHEMERAL, tls::server_provider(&identity_a)?).await { + Ok(server) => server, + Err(error) => { + let cleanup = client.shutdown().await; + return merge_results(Err(error), cleanup); + } + }; + let reused_addr = server_a.addr; + let endpoint_a = PeerEndpoint { + peer_id: identity_a.peer_id(), + addr: reused_addr, + }; + let first_result = run_successful_exchange(&mut server_a, &client, endpoint_a).await; + let server_a_cleanup = server_a.shutdown().await; + if let Err(error) = merge_results(first_result, server_a_cleanup) { + let cleanup = client.shutdown().await; + return merge_results(Err(error), cleanup); + } + + let mut server_b = + match TestServer::start(reused_addr, tls::server_provider(&identity_b)?).await { + Ok(server) => server, + Err(error) => { + let cleanup = client.shutdown().await; + return merge_results(Err(error), cleanup); + } + }; + let rejection = if server_b.addr == reused_addr { + expect_rejected_connection(&mut server_b, &client, endpoint_a).await + } else { + Err(eyre::eyre!( + "test server did not reuse the exact socket address" + )) + }; + let (server_cleanup, client_cleanup) = tokio::join!(server_b.shutdown(), client.shutdown()); + let cleanup = merge_results(server_cleanup, client_cleanup); + merge_results(rejection, cleanup)?; + + let snapshot = counter.snapshot(); + ensure!(snapshot.certificate_calls == 2); + ensure!(snapshot.certificate_accepts == 1); + ensure!(snapshot.tls12_calls == 0); + ensure!(snapshot.tls13_calls == 1); + ensure!(snapshot.tls13_accepts == 1); + Ok(()) +} + +#[tokio::test] +async fn two_reconnects_repeat_production_certificate_and_signature_checks() -> eyre::Result<()> { + let identity = PeerIdentity::generate()?; + let counter = TestVerificationCounter::install()?; + execute_successful_exchanges(&identity, 2).await?; + let snapshot = counter.snapshot(); + ensure!(snapshot.certificate_calls == 2); + ensure!(snapshot.certificate_accepts == 2); + ensure!(snapshot.tls12_calls == 0); + ensure!(snapshot.tls13_calls == 2); + ensure!(snapshot.tls13_accepts == 2); + Ok(()) +} diff --git a/crates/lanspread-peer/src/transfer_status.rs b/crates/lanspread-peer/src/transfer_status.rs new file mode 100644 index 0000000..d1aea98 --- /dev/null +++ b/crates/lanspread-peer/src/transfer_status.rs @@ -0,0 +1,601 @@ +//! Attempt-keyed download status and cancellation coordination. + +use std::{ + fmt, + sync::{ + Arc, + Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use tokio::sync::mpsc::UnboundedSender; +use tokio_util::sync::CancellationToken; + +use crate::{DownloadProgress, PeerEvent, events}; + +static NEXT_DOWNLOAD_ATTEMPT_ID: AtomicU64 = AtomicU64::new(1); + +/// Process-local monotonic identity for one download command attempt. +/// +/// The integer is deliberately serialized as a decimal string so JavaScript +/// consumers never truncate it through IEEE-754 number conversion. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct DownloadAttemptId(u64); + +impl DownloadAttemptId { + fn next() -> Self { + let value = NEXT_DOWNLOAD_ATTEMPT_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .expect("download attempt ID space exhausted"); + Self(value) + } +} + +impl fmt::Display for DownloadAttemptId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl Serialize for DownloadAttemptId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +struct DownloadAttemptIdVisitor; + +impl de::Visitor<'_> for DownloadAttemptIdVisitor { + type Value = DownloadAttemptId; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an unsigned 64-bit download attempt ID encoded as a decimal string") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + if value.is_empty() + || !value.bytes().all(|byte| byte.is_ascii_digit()) + || (value.len() > 1 && value.starts_with('0')) + { + return Err(E::invalid_value(de::Unexpected::Str(value), &self)); + } + value + .parse::() + .map(DownloadAttemptId) + .map_err(|_| E::invalid_value(de::Unexpected::Str(value), &self)) + } +} + +impl<'de> Deserialize<'de> for DownloadAttemptId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(DownloadAttemptIdVisitor) + } +} + +/// Stable correlation key carried by every non-diagnostic download event. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DownloadAttemptKey { + pub id: String, + pub attempt_id: DownloadAttemptId, +} + +impl DownloadAttemptKey { + pub(crate) fn next(id: String) -> Self { + Self { + id, + attempt_id: DownloadAttemptId::next(), + } + } +} + +/// Nonterminal verification activity shown for one download attempt. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DownloadVerificationActivity { + VerifyingDownloadedChunks, + RetryingInvalidSource, +} + +/// Stable terminal failure classification for one download attempt. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DownloadFailureReason { + VerifiedCatalogSourcesExhausted, + OperationFailed, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum AttemptLifecycle { + #[default] + Open, + SourceClosed, + UserCancelled, + SourcesExhausted, + OwnerDone, +} + +#[derive(Debug, Default)] +struct AttemptRuntimeState { + begin_emitted: bool, + activity: Option, + retry_activity_emitted: bool, + lifecycle: AttemptLifecycle, + terminal_emitted: bool, +} + +struct DownloadAttemptState { + key: DownloadAttemptKey, + cancellation: CancellationToken, + tx_notify_ui: UnboundedSender, + runtime: Mutex, +} + +/// Owner-side status handle retained for the complete structured operation. +pub(crate) struct DownloadAttemptStatus { + state: Arc, +} + +impl DownloadAttemptStatus { + pub(crate) fn new( + key: DownloadAttemptKey, + cancellation: CancellationToken, + tx_notify_ui: UnboundedSender, + ) -> Self { + Self { + state: Arc::new(DownloadAttemptState { + key, + cancellation, + tx_notify_ui, + runtime: Mutex::new(AttemptRuntimeState::default()), + }), + } + } + + pub(crate) fn key(&self) -> &DownloadAttemptKey { + &self.state.key + } + + pub(crate) fn signal(&self) -> ActiveDownloadSignal { + ActiveDownloadSignal { + state: Arc::clone(&self.state), + } + } + + pub(crate) fn reporter(&self) -> DownloadAttemptReporter { + DownloadAttemptReporter { + state: Arc::clone(&self.state), + } + } + + pub(crate) fn emit_begin(&self) -> bool { + let mut runtime = self.lock_runtime(); + if runtime.begin_emitted || runtime.terminal_emitted { + return false; + } + runtime.begin_emitted = true; + events::send( + &self.state.tx_notify_ui, + PeerEvent::DownloadGameFilesBegin { + attempt: self.state.key.clone(), + }, + ); + true + } + + pub(crate) fn set_activity(&self, activity: DownloadVerificationActivity) -> bool { + set_activity(&self.state, activity) + } + + pub(crate) fn clear_activity(&self) -> bool { + let mut runtime = self.lock_runtime(); + if runtime.activity.take().is_none() { + return false; + } + events::send( + &self.state.tx_notify_ui, + PeerEvent::DownloadGameFilesActivityChanged { + attempt: self.state.key.clone(), + activity: None, + }, + ); + true + } + + /// Closes liveness source-loss admission after all receive children drain. + /// User cancellation remains accepted until terminal owner settlement. + pub(crate) fn close_source_admission(&self) -> bool { + let mut runtime = self.lock_runtime(); + match runtime.lifecycle { + AttemptLifecycle::Open if self.state.cancellation.is_cancelled() => { + runtime.lifecycle = AttemptLifecycle::UserCancelled; + false + } + AttemptLifecycle::Open => { + runtime.lifecycle = AttemptLifecycle::SourceClosed; + true + } + AttemptLifecycle::SourceClosed + | AttemptLifecycle::UserCancelled + | AttemptLifecycle::SourcesExhausted + | AttemptLifecycle::OwnerDone => false, + } + } + + pub(crate) fn resolve_failure( + &self, + direct_reason: Option, + ) -> Option { + let runtime = self.lock_runtime(); + match (direct_reason, runtime.lifecycle) { + (Some(DownloadFailureReason::OperationFailed), _) => direct_reason, + (_, AttemptLifecycle::UserCancelled | AttemptLifecycle::OwnerDone) + | (None, AttemptLifecycle::Open | AttemptLifecycle::SourceClosed) => None, + (Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted), _) + | (None, AttemptLifecycle::SourcesExhausted) => { + Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted) + } + } + } + + pub(crate) fn emit_finished(&self) -> bool { + self.emit_terminal(None) + } + + pub(crate) fn emit_failed(&self, reason: DownloadFailureReason) -> bool { + self.emit_terminal(Some(reason)) + } + + fn emit_terminal(&self, reason: Option) -> bool { + let mut runtime = self.lock_runtime(); + if runtime.terminal_emitted { + return false; + } + if runtime.activity.is_some() { + log::error!( + "Download attempt {} for {} reached a terminal state before activity was cleared", + self.state.key.attempt_id, + self.state.key.id + ); + runtime.activity = None; + events::send( + &self.state.tx_notify_ui, + PeerEvent::DownloadGameFilesActivityChanged { + attempt: self.state.key.clone(), + activity: None, + }, + ); + } + runtime.terminal_emitted = true; + runtime.lifecycle = AttemptLifecycle::OwnerDone; + let event = match reason { + Some(reason) => PeerEvent::DownloadGameFilesFailed { + attempt: self.state.key.clone(), + reason, + }, + None => PeerEvent::DownloadGameFilesFinished { + attempt: self.state.key.clone(), + }, + }; + events::send(&self.state.tx_notify_ui, event); + true + } + + fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AttemptRuntimeState> { + self.state + .runtime + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl Drop for DownloadAttemptStatus { + fn drop(&mut self) { + let clear_activity = { + let mut runtime = self.lock_runtime(); + let clear_activity = runtime.activity.take().is_some(); + runtime.lifecycle = AttemptLifecycle::OwnerDone; + clear_activity + }; + if clear_activity { + events::send( + &self.state.tx_notify_ui, + PeerEvent::DownloadGameFilesActivityChanged { + attempt: self.state.key.clone(), + activity: None, + }, + ); + } + } +} + +/// Cloneable, nonterminal-only view used by structured transfer children. +#[derive(Clone)] +pub(crate) struct DownloadAttemptReporter { + state: Arc, +} + +impl DownloadAttemptReporter { + pub(crate) fn key(&self) -> &DownloadAttemptKey { + &self.state.key + } + + pub(crate) fn set_activity(&self, activity: DownloadVerificationActivity) -> bool { + set_activity(&self.state, activity) + } + + pub(crate) fn emit_progress(&self, progress: DownloadProgress) -> bool { + emit_progress(&self.state, progress) + } +} + +fn set_activity(state: &DownloadAttemptState, activity: DownloadVerificationActivity) -> bool { + let mut runtime = state + .runtime + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if runtime.lifecycle != AttemptLifecycle::Open || runtime.terminal_emitted { + return false; + } + if runtime.retry_activity_emitted { + return false; + } + if activity == DownloadVerificationActivity::RetryingInvalidSource { + runtime.retry_activity_emitted = true; + } + if runtime.activity == Some(activity) { + return false; + } + runtime.activity = Some(activity); + events::send( + &state.tx_notify_ui, + PeerEvent::DownloadGameFilesActivityChanged { + attempt: state.key.clone(), + activity: Some(activity), + }, + ); + true +} + +fn emit_progress(state: &DownloadAttemptState, progress: DownloadProgress) -> bool { + let runtime = state + .runtime + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if runtime.lifecycle != AttemptLifecycle::Open + || runtime.terminal_emitted + || progress.attempt != state.key + { + return false; + } + events::send( + &state.tx_notify_ui, + PeerEvent::DownloadGameFilesProgress(progress), + ); + true +} + +/// Restricted signal retained in `Ctx` for cancellation authorities. +/// +/// It deliberately exposes no activity or terminal event methods; the owning +/// operation remains solely responsible for clearing activity and publishing +/// its one terminal outcome after drainage and settlement. +#[derive(Clone)] +pub(crate) struct ActiveDownloadSignal { + state: Arc, +} + +impl ActiveDownloadSignal { + pub(crate) fn key(&self) -> &DownloadAttemptKey { + &self.state.key + } + + #[cfg(test)] + fn cancellation(&self) -> &CancellationToken { + &self.state.cancellation + } + + pub(crate) fn cancel_silently(&self) -> bool { + let mut runtime = self.lock_runtime(); + match runtime.lifecycle { + AttemptLifecycle::Open | AttemptLifecycle::SourceClosed => { + if self.state.cancellation.is_cancelled() { + runtime.lifecycle = AttemptLifecycle::UserCancelled; + return false; + } + runtime.lifecycle = AttemptLifecycle::UserCancelled; + self.state.cancellation.cancel(); + true + } + AttemptLifecycle::UserCancelled + | AttemptLifecycle::SourcesExhausted + | AttemptLifecycle::OwnerDone => false, + } + } + + pub(crate) fn cancel_sources_exhausted(&self) -> bool { + let mut runtime = self.lock_runtime(); + match runtime.lifecycle { + AttemptLifecycle::Open if self.state.cancellation.is_cancelled() => { + runtime.lifecycle = AttemptLifecycle::UserCancelled; + false + } + AttemptLifecycle::Open => { + runtime.lifecycle = AttemptLifecycle::SourcesExhausted; + self.state.cancellation.cancel(); + true + } + AttemptLifecycle::SourceClosed + | AttemptLifecycle::UserCancelled + | AttemptLifecycle::SourcesExhausted + | AttemptLifecycle::OwnerDone => false, + } + } + + fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AttemptRuntimeState> { + self.state + .runtime + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn attempt() -> ( + DownloadAttemptStatus, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + ( + DownloadAttemptStatus::new( + DownloadAttemptKey::next("game".to_owned()), + CancellationToken::new(), + tx, + ), + rx, + ) + } + + #[test] + fn attempt_ids_are_monotonic_and_serialize_only_as_decimal_strings() { + let first = DownloadAttemptKey::next("first".to_owned()).attempt_id; + let second = DownloadAttemptKey::next("second".to_owned()).attempt_id; + + assert!(second > first); + let encoded = serde_json::to_string(&first).expect("attempt ID should serialize"); + assert_eq!(encoded, format!("\"{first}\"")); + assert_eq!( + serde_json::from_str::(&encoded) + .expect("decimal string should deserialize"), + first + ); + assert!(serde_json::from_str::("1").is_err()); + assert!(serde_json::from_str::("\"01\"").is_err()); + } + + #[test] + fn activity_is_deduplicated_cleared_and_never_reopened_after_terminal() { + let (attempt, mut rx) = attempt(); + + assert!(attempt.emit_begin()); + assert!(attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); + assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); + assert!(attempt.set_activity(DownloadVerificationActivity::RetryingInvalidSource)); + assert!(!attempt.set_activity(DownloadVerificationActivity::RetryingInvalidSource)); + assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); + assert!(attempt.clear_activity()); + assert!(!attempt.clear_activity()); + assert!(attempt.emit_finished()); + assert!(!attempt.emit_failed(DownloadFailureReason::OperationFailed)); + assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); + + assert!(matches!( + rx.try_recv(), + Ok(PeerEvent::DownloadGameFilesBegin { .. }) + )); + assert!(matches!( + rx.try_recv(), + Ok(PeerEvent::DownloadGameFilesActivityChanged { + activity: Some(DownloadVerificationActivity::VerifyingDownloadedChunks), + .. + }) + )); + assert!(matches!( + rx.try_recv(), + Ok(PeerEvent::DownloadGameFilesActivityChanged { + activity: Some(DownloadVerificationActivity::RetryingInvalidSource), + .. + }) + )); + assert!(matches!( + rx.try_recv(), + Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: None, .. }) + )); + assert!(matches!( + rx.try_recv(), + Ok(PeerEvent::DownloadGameFilesFinished { .. }) + )); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn first_cancellation_cause_is_stable_and_operation_failure_dominates() { + let (attempt, _rx) = attempt(); + let signal = attempt.signal(); + + assert!(signal.cancel_sources_exhausted()); + assert!(!signal.cancel_silently()); + assert_eq!( + attempt.resolve_failure(None), + Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted) + ); + assert_eq!( + attempt.resolve_failure(Some(DownloadFailureReason::OperationFailed)), + Some(DownloadFailureReason::OperationFailed) + ); + } + + #[test] + fn silent_cancellation_cannot_be_reclassified_by_liveness() { + let (attempt, _rx) = attempt(); + let signal = attempt.signal(); + + assert!(signal.cancel_silently()); + assert!(!signal.cancel_sources_exhausted()); + assert_eq!(attempt.resolve_failure(None), None); + } + + #[test] + fn source_close_rejects_liveness_but_still_accepts_user_cancellation() { + let (attempt, _rx) = attempt(); + let signal = attempt.signal(); + + assert!(attempt.close_source_admission()); + assert!(!signal.cancel_sources_exhausted()); + assert!(signal.cancel_silently()); + assert!(signal.cancellation().is_cancelled()); + assert_eq!(attempt.resolve_failure(None), None); + } + + #[test] + fn owner_drop_clears_activity_and_makes_stale_signals_inert() { + let (attempt, mut rx) = attempt(); + let signal = attempt.signal(); + let reporter = attempt.reporter(); + assert!(attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks)); + + drop(attempt); + + assert!(!signal.cancel_sources_exhausted()); + assert!(!signal.cancel_silently()); + assert!(!reporter.set_activity(DownloadVerificationActivity::RetryingInvalidSource)); + assert!(matches!( + rx.try_recv(), + Ok(PeerEvent::DownloadGameFilesActivityChanged { + activity: Some(DownloadVerificationActivity::VerifyingDownloadedChunks), + .. + }) + )); + assert!(matches!( + rx.try_recv(), + Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: None, .. }) + )); + assert!(rx.try_recv().is_err()); + } +} diff --git a/crates/lanspread-proto/Cargo.toml b/crates/lanspread-proto/Cargo.toml index 6dd85df..0657e3c 100644 --- a/crates/lanspread-proto/Cargo.toml +++ b/crates/lanspread-proto/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" [lib] doctest = false -test = false [dependencies] # local diff --git a/crates/lanspread-proto/src/lib.rs b/crates/lanspread-proto/src/lib.rs index 040698d..8d89953 100644 --- a/crates/lanspread-proto/src/lib.rs +++ b/crates/lanspread-proto/src/lib.rs @@ -1,56 +1,409 @@ -use std::net::SocketAddr; +use std::{ + fmt::{self, Write as _}, + net::SocketAddr, + str::FromStr, +}; use bytes::Bytes; -use lanspread_db::db::{Game, GameFileDescription}; -use serde::{Deserialize, Serialize}; +pub use lanspread_db::content_manifest::{CanonicalCatalogPath, ContentId}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned}; -pub const PROTOCOL_VERSION: u32 = 7; +pub const PROTOCOL_VERSION: u32 = 8; +pub const ALPN_PROTOCOL: &[u8] = b"lanspread/8"; +pub const MAX_CONTROL_FRAME_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_STREAM_INSTALL_FRAME_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_LIBRARY_GAMES: usize = 4_096; +pub const MAX_GAME_ID_BYTES: usize = 255; +pub const MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR: usize = 4_096; +pub const MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS: usize = 24; +pub const MAX_CALL_TO_PLAY_MESSAGE_CHARS: usize = 500; +pub const MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES: usize = 4 * 1024 * 1024; -pub use lanspread_db::db::Availability; +const PEER_ID_BYTE_LENGTH: usize = 32; +const PEER_ID_ENCODED_LENGTH: usize = 52; +const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct GameSummary { - pub id: String, - pub name: String, - pub size: u64, - pub downloaded: bool, - pub installed: bool, - pub eti_version: Option, - pub manifest_hash: u64, - pub availability: Availability, +/// Stable responder identity encoded as canonical lowercase unpadded RFC 4648 base32. +#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PeerId([u8; PEER_ID_BYTE_LENGTH]); + +impl PeerId { + /// Constructs an identity from its 32-byte value. + #[must_use] + pub const fn from_bytes(bytes: [u8; PEER_ID_BYTE_LENGTH]) -> Self { + Self(bytes) + } + + /// Returns the identity's 32-byte value. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; PEER_ID_BYTE_LENGTH] { + &self.0 + } } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Hello { - pub peer_id: String, - pub proto_ver: u32, - pub listen_addr: SocketAddr, - pub library: LibrarySnapshot, - pub features: Vec, - pub call_to_play_events: Vec, +impl fmt::Debug for PeerId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PeerId(\"")?; + fmt::Display::fmt(self, formatter)?; + formatter.write_str("\")") + } } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct HelloAck { - pub peer_id: String, - pub proto_ver: u32, - pub listen_addr: SocketAddr, - pub library: LibrarySnapshot, - pub features: Vec, - pub call_to_play_events: Vec, +impl fmt::Display for PeerId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in encode_base32(&self.0) { + formatter.write_char(char::from(byte))?; + } + Ok(()) + } } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct CallToPlayEvent { - pub id: String, - pub call_id: String, - pub actor_id: String, - pub actor_name: String, +impl FromStr for PeerId { + type Err = ParsePeerIdError; + + fn from_str(value: &str) -> Result { + decode_base32(value).map(Self).ok_or(ParsePeerIdError) + } +} + +impl Serialize for PeerId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for PeerId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } +} + +/// Error returned when a peer ID is not in canonical string form. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ParsePeerIdError; + +impl fmt::Display for ParsePeerIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str( + "peer ID must be 52 characters of canonical lowercase unpadded RFC 4648 base32", + ) + } +} + +impl std::error::Error for ParsePeerIdError {} + +/// One authenticated peer identity and its current transport address. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PeerEndpoint { + pub peer_id: PeerId, + pub addr: SocketAddr, +} + +impl PeerEndpoint { + #[must_use] + pub const fn new(peer_id: PeerId, addr: SocketAddr) -> Self { + Self { peer_id, addr } + } +} + +fn encode_base32(input: &[u8; PEER_ID_BYTE_LENGTH]) -> [u8; PEER_ID_ENCODED_LENGTH] { + let mut output = [0_u8; PEER_ID_ENCODED_LENGTH]; + let mut written = 0_usize; + let mut accumulator = 0_u16; + let mut bits = 0_u8; + + for byte in input { + accumulator = (accumulator << 8) | u16::from(*byte); + bits += 8; + + while bits >= 5 { + bits -= 5; + let index = usize::from((accumulator >> bits) & 0x1f); + output[written] = BASE32_ALPHABET[index]; + written += 1; + accumulator &= (1_u16 << bits) - 1; + } + } + + if bits != 0 { + let index = usize::from((accumulator << (5 - bits)) & 0x1f); + output[written] = BASE32_ALPHABET[index]; + written += 1; + } + + debug_assert_eq!(written, output.len()); + output +} + +fn decode_base32(value: &str) -> Option<[u8; PEER_ID_BYTE_LENGTH]> { + if value.len() != PEER_ID_ENCODED_LENGTH { + return None; + } + + let mut output = [0_u8; PEER_ID_BYTE_LENGTH]; + let mut written = 0_usize; + let mut accumulator = 0_u16; + let mut bits = 0_u8; + + for byte in value.bytes() { + let digit = match byte { + b'a'..=b'z' => byte - b'a', + b'2'..=b'7' => byte - b'2' + 26, + _ => return None, + }; + + accumulator = (accumulator << 5) | u16::from(digit); + bits += 5; + + if bits >= 8 { + bits -= 8; + if written == output.len() { + return None; + } + output[written] = u8::try_from(accumulator >> bits).ok()?; + written += 1; + accumulator &= (1_u16 << bits) - 1; + } + } + + (written == output.len() && bits == 4 && accumulator == 0).then_some(output) +} + +const NONCE_BYTE_LENGTH: usize = 16; +const NONCE_ENCODED_LENGTH: usize = NONCE_BYTE_LENGTH * 2; + +macro_rules! fixed_hex_id { + ($name:ident, $error:ident, $label:literal) => { + #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub struct $name([u8; NONCE_BYTE_LENGTH]); + + impl $name { + #[must_use] + pub const fn from_bytes(bytes: [u8; NONCE_BYTE_LENGTH]) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; NONCE_BYTE_LENGTH] { + &self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(concat!(stringify!($name), "(\""))?; + fmt::Display::fmt(self, formatter)?; + formatter.write_str("\")") + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } + } + + impl FromStr for $name { + type Err = $error; + + fn from_str(value: &str) -> Result { + decode_fixed_lower_hex(value).map(Self).ok_or($error) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct $error; + + impl fmt::Display for $error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(concat!( + $label, + " must contain exactly 32 lowercase hexadecimal characters" + )) + } + } + + impl std::error::Error for $error {} + }; +} + +fixed_hex_id!( + RuntimeSessionId, + ParseRuntimeSessionIdError, + "runtime session ID" +); +fixed_hex_id!(CallNonce, ParseCallNonceError, "call nonce"); +fixed_hex_id!(EventNonce, ParseEventNonceError, "event nonce"); + +fn decode_fixed_lower_hex(value: &str) -> Option<[u8; NONCE_BYTE_LENGTH]> { + if value.len() != NONCE_ENCODED_LENGTH { + return None; + } + + let mut output = [0_u8; NONCE_BYTE_LENGTH]; + for (decoded, encoded) in output.iter_mut().zip(value.as_bytes().chunks_exact(2)) { + *decoded = + (decode_lower_hex_nibble(encoded[0])? << 4) | decode_lower_hex_nibble(encoded[1])?; + } + Some(output) +} + +const fn decode_lower_hex_nibble(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, + } +} + +#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CallId { + pub creator: PeerId, + pub random_nonce: CallNonce, +} + +impl CallId { + #[must_use] + pub const fn new(creator: PeerId, random_nonce: CallNonce) -> Self { + Self { + creator, + random_nonce, + } + } +} + +impl fmt::Debug for CallId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CallId(\"")?; + fmt::Display::fmt(self, formatter)?; + formatter.write_str("\")") + } +} + +impl fmt::Display for CallId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}.{}", self.creator, self.random_nonce) + } +} + +impl FromStr for CallId { + type Err = ParseCallIdError; + + fn from_str(value: &str) -> Result { + let (creator, random_nonce) = value.split_once('.').ok_or(ParseCallIdError)?; + Ok(Self { + creator: creator.parse().map_err(|_| ParseCallIdError)?, + random_nonce: random_nonce.parse().map_err(|_| ParseCallIdError)?, + }) + } +} + +impl Serialize for CallId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for CallId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ParseCallIdError; + +impl fmt::Display for ParseCallIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str( + "call ID must be a canonical PeerId, '.', and 32 lowercase hexadecimal characters", + ) + } +} + +impl std::error::Error for ParseCallIdError {} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PeerRevisions { + pub runtime_session_id: RuntimeSessionId, + pub library_revision: u64, + pub call_to_play_revision: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GameAvailability { + pub game_id: String, + pub content_id: ContentId, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LibrarySnapshot { + pub revision: u64, + pub games: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ChangeHint { + pub claimed_peer_id: PeerId, + pub runtime_session_id: RuntimeSessionId, + pub revision: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CallToPlayAuthorEvent { + pub id: EventNonce, + pub call_id: CallId, pub at: i64, pub action: CallToPlayAction, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] pub enum CallToPlayAction { Create { game_id: String, @@ -63,7 +416,6 @@ pub enum CallToPlayAction { }, Rsvp, SendMessage { - message_id: String, text: String, }, Leave, @@ -74,78 +426,55 @@ pub enum CallToPlayAction { }, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub enum CallToPlayAck { - Applied, - Duplicate, - NeedHandshake, - NeedHistory, - Obsolete, - Rejected { reason: String }, +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CallToPlayAuthorSnapshot { + pub revision: u64, + pub display_name: String, + pub events: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct LibrarySnapshot { - pub library_rev: u64, - pub games: Vec, +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PeerStateSnapshot { + pub runtime_session_id: RuntimeSessionId, + pub library: LibrarySnapshot, + pub call_to_play: CallToPlayAuthorSnapshot, } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct LibraryDelta { - pub from_rev: u64, - pub to_rev: u64, - pub added: Vec, - pub updated: Vec, - pub removed: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] pub enum Request { Ping, - ListGames, - GetGame { - id: String, - }, - GetGameFileData(GameFileDescription), + Hello, + LibraryChanged(ChangeHint), + CallToPlayChanged(ChangeHint), GetGameFileChunk { game_id: String, - relative_path: String, + content_id: ContentId, + relative_path: CanonicalCatalogPath, offset: u64, length: u64, }, StreamInstall { game_id: String, + content_id: ContentId, }, - Hello(Hello), - LibraryDelta { - peer_id: String, - delta: LibraryDelta, - }, - CallToPlayEvents { - peer_id: String, - events: Vec, - }, - Goodbye { - peer_id: String, - }, - Invalid(Bytes, String), } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum ControlErrorCode { + InvalidRequest, + Internal, + Unavailable, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] pub enum Response { - Pong, - ListGames(Vec), - GetGame { - id: String, - file_descriptions: Vec, - }, - HelloAck(HelloAck), - CallToPlayAck(CallToPlayAck), - GameNotFound(String), - InvalidRequest(Bytes, String), - EncodingError(String), - DecodingError(Bytes, String), - InternalPeerError(String), + Pong(PeerRevisions), + HelloSnapshot(PeerStateSnapshot), + Error(ControlErrorCode), } const STREAM_INSTALL_CONTROL_FRAME_TAG: u8 = 0; @@ -154,17 +483,18 @@ const STREAM_INSTALL_ENCODE_ERROR_FRAME: &[u8] = b"\0{\"Error\":{\"message\":\"stream install frame encoding error\"}}"; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub enum StreamInstallFrame { ArchiveBegin { - archive_name: String, + archive_name: CanonicalCatalogPath, solid: bool, unpacked_size: u64, }, Directory { - relative_path: String, + relative_path: CanonicalCatalogPath, }, FileBegin { - relative_path: String, + relative_path: CanonicalCatalogPath, size: u64, crc32: u32, }, @@ -172,10 +502,10 @@ pub enum StreamInstallFrame { bytes: Bytes, }, FileEnd { - relative_path: String, + relative_path: CanonicalCatalogPath, }, ArchiveEnd { - archive_name: String, + archive_name: CanonicalCatalogPath, }, Complete, Error { @@ -183,70 +513,437 @@ pub enum StreamInstallFrame { }, } -// Add Message trait +#[derive(Debug)] +pub enum StreamInstallFrameDecodeError { + Empty, + FrameTooLarge { actual: usize, maximum: usize }, + UnknownTag(u8), + InvalidControl(serde_json::Error), + FileChunkInControl, +} + +impl fmt::Display for StreamInstallFrameDecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("stream install frame is empty"), + Self::FrameTooLarge { actual, maximum } => write!( + formatter, + "stream install frame is {actual} bytes; maximum is {maximum}" + ), + Self::UnknownTag(tag) => write!(formatter, "unknown stream install frame tag {tag}"), + Self::InvalidControl(error) => { + write!(formatter, "invalid stream install control frame: {error}") + } + Self::FileChunkInControl => { + formatter.write_str("stream install control frame cannot contain file bytes") + } + } + } +} + +impl std::error::Error for StreamInstallFrameDecodeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidControl(error) => Some(error), + Self::Empty + | Self::FrameTooLarge { .. } + | Self::UnknownTag(_) + | Self::FileChunkInControl => None, + } + } +} + +impl StreamInstallFrame { + /// Strictly decodes one bounded streamed-install frame. + /// + /// An explicit sender [`StreamInstallFrame::Error`] remains a successfully + /// decoded frame. Malformed framing, JSON, or canonical paths return an + /// error so a receiver can classify them as integrity failures. + /// + /// # Errors + /// + /// Returns an error for an empty or oversized frame, an unknown tag, + /// malformed typed control JSON, or file bytes encoded under the control + /// tag. + pub fn decode_checked(mut bytes: Bytes) -> Result { + if bytes.len() > MAX_STREAM_INSTALL_FRAME_BYTES { + return Err(StreamInstallFrameDecodeError::FrameTooLarge { + actual: bytes.len(), + maximum: MAX_STREAM_INSTALL_FRAME_BYTES, + }); + } + if bytes.is_empty() { + return Err(StreamInstallFrameDecodeError::Empty); + } + let tag = bytes.split_to(1)[0]; + let payload = bytes; + match tag { + STREAM_INSTALL_CONTROL_FRAME_TAG => { + let frame = serde_json::from_slice(&payload) + .map_err(StreamInstallFrameDecodeError::InvalidControl)?; + if matches!(frame, Self::FileChunk { .. }) { + return Err(StreamInstallFrameDecodeError::FileChunkInControl); + } + Ok(frame) + } + STREAM_INSTALL_FILE_CHUNK_FRAME_TAG => Ok(Self::FileChunk { bytes: payload }), + _ => Err(StreamInstallFrameDecodeError::UnknownTag(tag)), + } + } +} + +/// Non-fallible framed codec retained for streamed-install data frames. pub trait Message { fn decode(bytes: Bytes) -> Self; fn encode(&self) -> Bytes; } -// Implement for Request -impl Message for Request { - fn decode(bytes: Bytes) -> Self { - match serde_json::from_slice(&bytes) { - Ok(t) => t, - Err(e) => { - tracing::error!(?e, "Request decoding error"); - Request::Invalid(bytes, e.to_string()) - } - } - } +/// Strict, bounded JSON codec for one protocol control frame. +pub trait ControlMessage: Sized { + /// Decodes one bounded, strict control frame. + /// + /// # Errors + /// + /// Returns an error when the frame exceeds its bound, is not the exact + /// current wire shape, or fails request-level semantic validation. + fn decode(bytes: Bytes) -> Result; - fn encode(&self) -> Bytes { - match serde_json::to_vec(self) { - Ok(s) => Bytes::from(s), - Err(e) => { - tracing::error!(?e, "Request encoding error"); - Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#)) + /// Encodes one bounded, semantically valid control frame. + /// + /// # Errors + /// + /// Returns an error when the value fails semantic validation, cannot be + /// serialized, or its encoded frame exceeds the control-frame bound. + fn encode(&self) -> Result; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ControlValidationError { + EmptyField { + field: &'static str, + }, + FieldTooLong { + field: &'static str, + maximum: usize, + }, + EncodedTooLarge { + field: &'static str, + actual: usize, + maximum: usize, + }, + TooManyItems { + field: &'static str, + maximum: usize, + }, + DuplicateGameId, + UnsortedGameIds, +} + +impl fmt::Display for ControlValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyField { field } => write!(formatter, "{field} cannot be blank"), + Self::FieldTooLong { field, maximum } => { + write!(formatter, "{field} exceeds its {maximum}-unit limit") + } + Self::EncodedTooLarge { + field, + actual, + maximum, + } => write!( + formatter, + "encoded {field} is {actual} bytes; maximum is {maximum}" + ), + Self::TooManyItems { field, maximum } => { + write!(formatter, "{field} exceeds its {maximum}-item limit") + } + Self::DuplicateGameId => formatter.write_str("library contains a duplicate game ID"), + Self::UnsortedGameIds => { + formatter.write_str("library game IDs are not strictly sorted") } } } } -// Implement for Response -impl Message for Response { - fn decode(bytes: Bytes) -> Self { - match serde_json::from_slice(&bytes) { - Ok(t) => t, - Err(e) => { - tracing::error!(?e, "Response decoding error"); - Response::DecodingError(bytes, e.to_string()) +impl std::error::Error for ControlValidationError {} + +#[derive(Debug)] +pub enum ControlCodecError { + FrameTooLarge { actual: usize, maximum: usize }, + Invalid(ControlValidationError), + Encode(serde_json::Error), + Decode(serde_json::Error), +} + +impl fmt::Display for ControlCodecError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FrameTooLarge { actual, maximum } => { + write!( + formatter, + "control frame is {actual} bytes; maximum is {maximum}" + ) } + Self::Invalid(error) => write!(formatter, "invalid control message: {error}"), + Self::Encode(error) => write!(formatter, "failed to encode control message: {error}"), + Self::Decode(error) => write!(formatter, "failed to decode control message: {error}"), } } +} + +impl std::error::Error for ControlCodecError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Invalid(error) => Some(error), + Self::Encode(error) | Self::Decode(error) => Some(error), + Self::FrameTooLarge { .. } => None, + } + } +} + +trait ValidateControlMessage { + fn validate_control(&self) -> Result<(), ControlValidationError>; +} + +impl ControlMessage for Request { + fn decode(bytes: Bytes) -> Result { + decode_control_message(&bytes) + } - fn encode(&self) -> Bytes { - match serde_json::to_vec(self) { - Ok(s) => Bytes::from(s), - Err(e) => { - tracing::error!(?e, "Response encoding error"); - Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#)) + fn encode(&self) -> Result { + encode_control_message(self) + } +} + +impl ControlMessage for Response { + fn decode(bytes: Bytes) -> Result { + decode_control_message_unvalidated(&bytes) + } + + fn encode(&self) -> Result { + encode_control_message(self) + } +} + +fn decode_control_message(bytes: &[u8]) -> Result +where + T: DeserializeOwned + ValidateControlMessage, +{ + let message = decode_control_message_unvalidated::(bytes)?; + message + .validate_control() + .map_err(ControlCodecError::Invalid)?; + Ok(message) +} + +fn decode_control_message_unvalidated(bytes: &[u8]) -> Result +where + T: DeserializeOwned, +{ + check_control_frame_length(bytes.len())?; + serde_json::from_slice::(bytes).map_err(ControlCodecError::Decode) +} + +fn encode_control_message(message: &T) -> Result +where + T: Serialize + ValidateControlMessage, +{ + message + .validate_control() + .map_err(ControlCodecError::Invalid)?; + let bytes = serde_json::to_vec(message).map_err(ControlCodecError::Encode)?; + check_control_frame_length(bytes.len())?; + Ok(Bytes::from(bytes)) +} + +const fn check_control_frame_length(length: usize) -> Result<(), ControlCodecError> { + if length > MAX_CONTROL_FRAME_BYTES { + return Err(ControlCodecError::FrameTooLarge { + actual: length, + maximum: MAX_CONTROL_FRAME_BYTES, + }); + } + Ok(()) +} + +impl ValidateControlMessage for Request { + fn validate_control(&self) -> Result<(), ControlValidationError> { + match self { + Self::Ping | Self::Hello | Self::LibraryChanged(_) | Self::CallToPlayChanged(_) => { + Ok(()) + } + Self::GetGameFileChunk { game_id, .. } | Self::StreamInstall { game_id, .. } => { + validate_game_id(game_id) } } } } +impl ValidateControlMessage for Response { + fn validate_control(&self) -> Result<(), ControlValidationError> { + match self { + Self::Pong(_) | Self::Error(_) => Ok(()), + Self::HelloSnapshot(snapshot) => snapshot.validate_control(), + } + } +} + +impl ValidateControlMessage for PeerStateSnapshot { + fn validate_control(&self) -> Result<(), ControlValidationError> { + self.library.validate_control()?; + self.call_to_play.validate_control() + } +} + +impl ValidateControlMessage for LibrarySnapshot { + fn validate_control(&self) -> Result<(), ControlValidationError> { + self.validate() + } +} + +impl LibrarySnapshot { + /// Validates the bounded, canonical library domain independently of the + /// other domains in a peer-state snapshot. + /// + /// # Errors + /// + /// Returns the first resource-bound or canonical-ordering violation. + pub fn validate(&self) -> Result<(), ControlValidationError> { + if self.games.len() > MAX_LIBRARY_GAMES { + return Err(ControlValidationError::TooManyItems { + field: "library games", + maximum: MAX_LIBRARY_GAMES, + }); + } + + for game in &self.games { + validate_game_id(&game.game_id)?; + } + for games in self.games.windows(2) { + match games[0].game_id.cmp(&games[1].game_id) { + std::cmp::Ordering::Less => {} + std::cmp::Ordering::Equal => { + return Err(ControlValidationError::DuplicateGameId); + } + std::cmp::Ordering::Greater => { + return Err(ControlValidationError::UnsortedGameIds); + } + } + } + Ok(()) + } +} + +impl ValidateControlMessage for CallToPlayAuthorSnapshot { + fn validate_control(&self) -> Result<(), ControlValidationError> { + self.validate() + } +} + +impl CallToPlayAuthorSnapshot { + /// Validates the bounded Call-to-Play domain independently of the other + /// domains in a peer-state snapshot. + /// + /// # Errors + /// + /// Returns the first display-name, event-count, or event resource-bound + /// violation. + pub fn validate(&self) -> Result<(), ControlValidationError> { + validate_bounded_chars( + &self.display_name, + MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS, + "Call to Play display name", + )?; + if self.events.len() > MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR { + return Err(ControlValidationError::TooManyItems { + field: "Call to Play author events", + maximum: MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR, + }); + } + for event in &self.events { + event.validate()?; + } + let actual = serde_json::to_vec(self) + .map_err(|_| ControlValidationError::EncodedTooLarge { + field: "Call to Play author snapshot", + actual: usize::MAX, + maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, + })? + .len(); + if actual > MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES { + return Err(ControlValidationError::EncodedTooLarge { + field: "Call to Play author snapshot", + actual, + maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, + }); + } + Ok(()) + } +} + +impl ValidateControlMessage for CallToPlayAuthorEvent { + fn validate_control(&self) -> Result<(), ControlValidationError> { + self.validate() + } +} + +impl CallToPlayAuthorEvent { + /// Validates the wire-level resource bounds of one author event. + /// + /// # Errors + /// + /// Returns an error when a game ID or message violates its wire bound. + pub fn validate(&self) -> Result<(), ControlValidationError> { + match &self.action { + CallToPlayAction::Create { game_id, .. } => validate_game_id(game_id), + CallToPlayAction::SendMessage { text } => { + validate_bounded_chars(text, MAX_CALL_TO_PLAY_MESSAGE_CHARS, "Call to Play message") + } + CallToPlayAction::Respond { .. } + | CallToPlayAction::Rsvp + | CallToPlayAction::Leave + | CallToPlayAction::Cancel + | CallToPlayAction::Start + | CallToPlayAction::AddTime { .. } => Ok(()), + } + } +} + +fn validate_game_id(game_id: &str) -> Result<(), ControlValidationError> { + if game_id.trim().is_empty() { + return Err(ControlValidationError::EmptyField { field: "game ID" }); + } + if game_id.len() > MAX_GAME_ID_BYTES { + return Err(ControlValidationError::FieldTooLong { + field: "game ID", + maximum: MAX_GAME_ID_BYTES, + }); + } + Ok(()) +} + +fn validate_bounded_chars( + value: &str, + maximum: usize, + field: &'static str, +) -> Result<(), ControlValidationError> { + if value.trim().is_empty() { + return Err(ControlValidationError::EmptyField { field }); + } + if value.chars().count() > maximum { + return Err(ControlValidationError::FieldTooLong { field, maximum }); + } + Ok(()) +} + impl Message for StreamInstallFrame { fn decode(bytes: Bytes) -> Self { - if bytes.is_empty() { - return stream_install_decode_error("stream install frame is empty"); - } - - let tag = bytes[0]; - let payload = bytes.slice(1..); - match tag { - STREAM_INSTALL_CONTROL_FRAME_TAG => decode_stream_install_control_frame(&payload), - STREAM_INSTALL_FILE_CHUNK_FRAME_TAG => StreamInstallFrame::FileChunk { bytes: payload }, - _ => stream_install_decode_error(format!("unknown stream install frame tag {tag}")), + match Self::decode_checked(bytes) { + Ok(frame) => frame, + Err(error) => { + tracing::error!(?error, "StreamInstallFrame decoding error"); + stream_install_decode_error(error.to_string()) + } } } @@ -268,19 +965,6 @@ impl Message for StreamInstallFrame { } } -fn decode_stream_install_control_frame(payload: &[u8]) -> StreamInstallFrame { - match serde_json::from_slice(payload) { - Ok(StreamInstallFrame::FileChunk { .. }) => { - stream_install_decode_error("stream install control frame cannot contain file bytes") - } - Ok(frame) => frame, - Err(e) => { - tracing::error!(?e, "StreamInstallFrame decoding error"); - stream_install_decode_error(format!("stream install frame decoding error: {e}")) - } - } -} - fn tagged_stream_install_frame(tag: u8, payload: &[u8]) -> Bytes { let mut frame = Vec::with_capacity(1 + payload.len()); frame.push(tag); @@ -293,3 +977,870 @@ fn stream_install_decode_error(message: impl Into) -> StreamInstallFrame message: message.into(), } } + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + + use serde_json::{Value, json}; + + use super::*; + + fn path(value: &str) -> CanonicalCatalogPath { + CanonicalCatalogPath::new(value).expect("test path should be canonical") + } + + fn peer(seed: u8) -> PeerId { + PeerId::from_bytes([seed; 32]) + } + + fn session(seed: u8) -> RuntimeSessionId { + RuntimeSessionId::from_bytes([seed; 16]) + } + + fn call_nonce(seed: u8) -> CallNonce { + CallNonce::from_bytes([seed; 16]) + } + + fn event_nonce(seed: u8) -> EventNonce { + EventNonce::from_bytes([seed; 16]) + } + + fn content(seed: u8) -> ContentId { + ContentId::from_bytes([seed; 32]) + } + + fn event(action: CallToPlayAction) -> CallToPlayAuthorEvent { + CallToPlayAuthorEvent { + id: event_nonce(4), + call_id: CallId::new(peer(5), call_nonce(6)), + at: 1_700_000_000_000, + action, + } + } + + fn state_snapshot(events: Vec) -> PeerStateSnapshot { + PeerStateSnapshot { + runtime_session_id: session(1), + library: LibrarySnapshot { + revision: 2, + games: vec![ + GameAvailability { + game_id: "alpha".to_owned(), + content_id: content(2), + }, + GameAvailability { + game_id: "bravo".to_owned(), + content_id: content(3), + }, + ], + }, + call_to_play: CallToPlayAuthorSnapshot { + revision: 3, + display_name: "Alice".to_owned(), + events, + }, + } + } + + fn encode_text(message: &impl ControlMessage) -> String { + String::from_utf8( + message + .encode() + .expect("test control message should encode") + .to_vec(), + ) + .expect("control JSON should be UTF-8") + } + + #[test] + fn peer_id_uses_canonical_lowercase_unpadded_base32() { + let zero = PeerId::from_bytes([0_u8; 32]); + assert_eq!(zero.to_string(), "a".repeat(52)); + + let ones = PeerId::from_bytes([u8::MAX; 32]); + assert_eq!(ones.to_string(), format!("{}q", "7".repeat(51))); + assert_eq!( + ones.to_string() + .parse::() + .expect("canonical peer ID should parse"), + ones + ); + assert_eq!(ones.as_bytes(), &[u8::MAX; 32]); + } + + #[test] + fn peer_id_round_trips_every_byte() { + let mut bytes = [0_u8; 32]; + for (value, byte) in (0_u8..32).zip(&mut bytes) { + *byte = value; + } + + let peer_id = PeerId::from_bytes(bytes); + let encoded = peer_id.to_string(); + + assert_eq!(encoded.len(), 52); + assert_eq!( + encoded + .parse::() + .expect("encoded peer ID should parse"), + peer_id + ); + } + + #[test] + fn peer_id_rejects_every_noncanonical_shape() { + let canonical = PeerId::from_bytes([0_u8; 32]).to_string(); + let mut nonzero_trailing_bits = canonical.clone(); + nonzero_trailing_bits.replace_range(51..52, "b"); + + for invalid in [ + canonical[..51].to_owned(), + format!("{canonical}a"), + canonical.to_ascii_uppercase(), + format!("{canonical}="), + canonical.replacen('a', "0", 1), + nonzero_trailing_bits, + format!(" {canonical}"), + ] { + assert!( + invalid.parse::().is_err(), + "accepted noncanonical peer ID {invalid:?}" + ); + } + } + + #[test] + fn peer_id_serde_is_exactly_one_canonical_string() { + let peer_id = PeerId::from_bytes([0x5a; 32]); + let encoded = peer_id.to_string(); + let json = serde_json::to_string(&peer_id).expect("peer ID should serialize"); + + assert_eq!(json, format!(r#""{encoded}""#)); + assert_eq!( + serde_json::from_str::(&json).expect("peer ID should deserialize"), + peer_id + ); + assert!(serde_json::from_str::("42").is_err()); + assert!( + serde_json::from_str::( + r#""AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA""#, + ) + .is_err() + ); + } + + #[test] + fn peer_endpoint_carries_strict_identity_and_address() { + let peer_id = PeerId::from_bytes([0x3c; 32]); + let addr = SocketAddr::from(([127, 0, 0, 1], 42_424)); + let endpoint = PeerEndpoint::new(peer_id, addr); + let endpoint_json = serde_json::to_value(endpoint).expect("peer endpoint should serialize"); + let encoded_peer_id = peer_id.to_string(); + + assert_eq!(endpoint.peer_id, peer_id); + assert_eq!(endpoint.addr, addr); + assert_eq!( + endpoint_json.get("peer_id").and_then(Value::as_str), + Some(encoded_peer_id.as_str()) + ); + assert_eq!( + endpoint_json.get("addr").and_then(Value::as_str), + Some("127.0.0.1:42424") + ); + assert!( + serde_json::from_value::(json!({ + "peer_id": encoded_peer_id, + "addr": "127.0.0.1:42424", + "extra": true, + })) + .is_err() + ); + } + + #[test] + fn protocol_version_and_alpn_are_one_v8_cutover() { + assert_eq!(PROTOCOL_VERSION, 8); + assert_eq!(ALPN_PROTOCOL, b"lanspread/8"); + assert_eq!(MAX_CONTROL_FRAME_BYTES, 8 * 1024 * 1024); + assert_eq!(MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, 4 * 1024 * 1024); + } + + #[test] + fn fixed_hex_ids_are_canonical_and_distinct() { + let runtime = session(0xab); + let call = call_nonce(0xcd); + let event = event_nonce(0xef); + assert_eq!(runtime.to_string(), "ab".repeat(16)); + assert_eq!(call.to_string(), "cd".repeat(16)); + assert_eq!(event.to_string(), "ef".repeat(16)); + assert_eq!(runtime.as_bytes(), &[0xab; 16]); + assert_eq!(call.as_bytes(), &[0xcd; 16]); + assert_eq!(event.as_bytes(), &[0xef; 16]); + assert_eq!(runtime.to_string().parse::(), Ok(runtime)); + assert_eq!(call.to_string().parse::(), Ok(call)); + assert_eq!(event.to_string().parse::(), Ok(event)); + + for invalid in [ + "a".repeat(31), + "a".repeat(33), + "AB".repeat(16), + format!("{}g", "a".repeat(31)), + ] { + assert!(invalid.parse::().is_err()); + assert!(invalid.parse::().is_err()); + assert!(invalid.parse::().is_err()); + } + } + + #[test] + fn call_id_is_one_canonical_string() { + let call_id = CallId::new(peer(0), call_nonce(0xab)); + let expected = format!("{}.{}", "a".repeat(52), "ab".repeat(16)); + assert_eq!(call_id.to_string(), expected); + assert_eq!( + serde_json::to_string(&call_id).expect("serialize"), + format!(r#""{expected}""#) + ); + assert_eq!(expected.parse::(), Ok(call_id)); + for invalid in [ + "a".repeat(52), + format!("{}.{}", "A".repeat(52), "ab".repeat(16)), + format!("{}.{}", "a".repeat(52), "AB".repeat(16)), + format!("{}.{}.x", "a".repeat(52), "ab".repeat(16)), + ] { + assert!(invalid.parse::().is_err(), "accepted {invalid:?}"); + } + assert!( + serde_json::from_value::(json!({ + "creator": peer(0).to_string(), + "random_nonce": call_nonce(0xab).to_string(), + })) + .is_err() + ); + } + + #[test] + fn owned_json_values_deserialize_typed_ids() { + let runtime_session_id = session(0xab); + let call_nonce = call_nonce(0xcd); + let event_nonce = event_nonce(0xef); + let call_id = CallId::new(peer(0), call_nonce); + + assert_eq!( + serde_json::from_value::(json!(runtime_session_id.to_string())) + .expect("owned runtime-session JSON value should deserialize"), + runtime_session_id + ); + assert_eq!( + serde_json::from_value::(json!(call_nonce.to_string())) + .expect("owned call-nonce JSON value should deserialize"), + call_nonce + ); + assert_eq!( + serde_json::from_value::(json!(event_nonce.to_string())) + .expect("owned event-nonce JSON value should deserialize"), + event_nonce + ); + assert_eq!( + serde_json::from_value::(json!(call_id.to_string())) + .expect("owned call-ID JSON value should deserialize"), + call_id + ); + } + + #[test] + fn request_and_response_json_are_golden() { + let peer_id = peer(0).to_string(); + let runtime = session(1).to_string(); + let content_id = content(2).to_string(); + assert_eq!(encode_text(&Request::Ping), r#""Ping""#); + assert_eq!(encode_text(&Request::Hello), r#""Hello""#); + assert_eq!( + encode_text(&Request::LibraryChanged(ChangeHint { + claimed_peer_id: peer(0), + runtime_session_id: session(1), + revision: 7, + })), + format!( + r#"{{"LibraryChanged":{{"claimed_peer_id":"{peer_id}","runtime_session_id":"{runtime}","revision":7}}}}"# + ), + ); + assert_eq!( + encode_text(&Request::CallToPlayChanged(ChangeHint { + claimed_peer_id: peer(0), + runtime_session_id: session(1), + revision: 8, + })), + format!( + r#"{{"CallToPlayChanged":{{"claimed_peer_id":"{peer_id}","runtime_session_id":"{runtime}","revision":8}}}}"# + ), + ); + assert_eq!( + encode_text(&Request::GetGameFileChunk { + game_id: "game".to_owned(), + content_id: content(2), + relative_path: path("bin/game.exe"), + offset: 7, + length: 9, + }), + format!( + r#"{{"GetGameFileChunk":{{"game_id":"game","content_id":"{content_id}","relative_path":"bin/game.exe","offset":7,"length":9}}}}"# + ), + ); + assert_eq!( + encode_text(&Request::StreamInstall { + game_id: "game".to_owned(), + content_id: content(2), + }), + format!(r#"{{"StreamInstall":{{"game_id":"game","content_id":"{content_id}"}}}}"#), + ); + assert_eq!( + encode_text(&Response::Pong(PeerRevisions { + runtime_session_id: session(1), + library_revision: 2, + call_to_play_revision: 3, + })), + format!( + r#"{{"Pong":{{"runtime_session_id":"{runtime}","library_revision":2,"call_to_play_revision":3}}}}"# + ), + ); + assert_eq!( + encode_text(&Response::Error(ControlErrorCode::Unavailable)), + r#"{"Error":"Unavailable"}"#, + ); + + let empty = PeerStateSnapshot { + runtime_session_id: session(1), + library: LibrarySnapshot { + revision: 2, + games: Vec::new(), + }, + call_to_play: CallToPlayAuthorSnapshot { + revision: 3, + display_name: "Alice".to_owned(), + events: Vec::new(), + }, + }; + assert_eq!( + encode_text(&Response::HelloSnapshot(empty)), + format!( + r#"{{"HelloSnapshot":{{"runtime_session_id":"{runtime}","library":{{"revision":2,"games":[]}},"call_to_play":{{"revision":3,"display_name":"Alice","events":[]}}}}}}"# + ), + ); + } + + #[test] + fn call_to_play_actions_have_golden_json() { + let cases = [ + ( + CallToPlayAction::Create { + game_id: "game".to_owned(), + max_players: 4, + scheduled_for: None, + deadline: 42, + }, + r#"{"Create":{"game_id":"game","max_players":4,"scheduled_for":null,"deadline":42}}"#, + ), + ( + CallToPlayAction::Respond { ready_at: Some(12) }, + r#"{"Respond":{"ready_at":12}}"#, + ), + (CallToPlayAction::Rsvp, r#""Rsvp""#), + ( + CallToPlayAction::SendMessage { + text: "hello".to_owned(), + }, + r#"{"SendMessage":{"text":"hello"}}"#, + ), + (CallToPlayAction::Leave, r#""Leave""#), + (CallToPlayAction::Cancel, r#""Cancel""#), + (CallToPlayAction::Start, r#""Start""#), + ( + CallToPlayAction::AddTime { deadline: 99 }, + r#"{"AddTime":{"deadline":99}}"#, + ), + ]; + for (action, expected) in cases { + assert_eq!( + serde_json::to_string(&action).expect("serialize action"), + expected + ); + } + } + + #[test] + fn all_control_variants_round_trip() { + let hint = ChangeHint { + claimed_peer_id: peer(7), + runtime_session_id: session(8), + revision: 9, + }; + let requests = [ + Request::Ping, + Request::Hello, + Request::LibraryChanged(hint), + Request::CallToPlayChanged(hint), + Request::GetGameFileChunk { + game_id: "game".to_owned(), + content_id: content(9), + relative_path: path("data/file.bin"), + offset: 10, + length: 11, + }, + Request::StreamInstall { + game_id: "game".to_owned(), + content_id: content(9), + }, + ]; + for request in requests { + let encoded = request.encode().expect("encode request"); + assert_eq!(Request::decode(encoded).expect("decode request"), request); + } + + let responses = [ + Response::Pong(PeerRevisions { + runtime_session_id: session(1), + library_revision: 2, + call_to_play_revision: 3, + }), + Response::HelloSnapshot(state_snapshot(vec![event(CallToPlayAction::Rsvp)])), + Response::Error(ControlErrorCode::InvalidRequest), + Response::Error(ControlErrorCode::Internal), + Response::Error(ControlErrorCode::Unavailable), + ]; + for response in responses { + let encoded = response.encode().expect("encode response"); + assert_eq!( + Response::decode(encoded).expect("decode response"), + response + ); + } + } + + #[test] + fn strict_serde_rejects_unknown_missing_duplicate_and_identity_fields() { + let content_id = content(1); + for invalid in [ + format!( + r#"{{"StreamInstall":{{"game_id":"game","content_id":"{content_id}","extra":true}}}}"# + ), + r#"{"StreamInstall":{"game_id":"game"}}"#.to_owned(), + format!( + r#"{{"StreamInstall":{{"game_id":"game","game_id":"other","content_id":"{content_id}"}}}}"# + ), + r#"{"Hello":{"peer_id":"legacy","proto_ver":7}}"#.to_owned(), + ] { + assert!(Request::decode(Bytes::from(invalid)).is_err()); + } + + let mut value = serde_json::to_value(Response::HelloSnapshot(state_snapshot(vec![event( + CallToPlayAction::Rsvp, + )]))) + .expect("serialize snapshot"); + value["HelloSnapshot"]["call_to_play"]["events"][0]["actor_id"] = json!(peer(8)); + value["HelloSnapshot"]["call_to_play"]["events"][0]["actor_name"] = json!("Mallory"); + assert!(Response::decode(Bytes::from(serde_json::to_vec(&value).expect("json"))).is_err()); + } + + #[test] + fn response_decode_isolates_domain_validation() { + let invalid_call_to_play = state_snapshot(vec![ + event(CallToPlayAction::Rsvp); + MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR + 1 + ]); + let decoded = Response::decode(Bytes::from( + serde_json::to_vec(&Response::HelloSnapshot(invalid_call_to_play.clone())) + .expect("structural response should serialize"), + )) + .expect("semantic domain validation must not reject the response frame"); + let Response::HelloSnapshot(decoded) = decoded else { + panic!("decoded the wrong response variant"); + }; + decoded.library.validate().expect("library is valid"); + assert!(decoded.call_to_play.validate().is_err()); + assert!( + Response::HelloSnapshot(invalid_call_to_play) + .encode() + .is_err() + ); + + let mut invalid_library = state_snapshot(Vec::new()); + invalid_library.library.games.swap(0, 1); + let decoded = Response::decode(Bytes::from( + serde_json::to_vec(&Response::HelloSnapshot(invalid_library.clone())) + .expect("structural response should serialize"), + )) + .expect("semantic domain validation must not reject the response frame"); + let Response::HelloSnapshot(decoded) = decoded else { + panic!("decoded the wrong response variant"); + }; + assert!(decoded.library.validate().is_err()); + decoded + .call_to_play + .validate() + .expect("Call to Play slice is valid"); + assert!(Response::HelloSnapshot(invalid_library).encode().is_err()); + } + + #[test] + fn canonical_wire_scalars_are_enforced_during_decode() { + let peer_id = peer(1).to_string(); + let runtime = session(0xab).to_string(); + for invalid in [ + format!( + r#"{{"LibraryChanged":{{"claimed_peer_id":"{}","runtime_session_id":"{runtime}","revision":1}}}}"#, + peer_id.to_ascii_uppercase() + ), + format!( + r#"{{"LibraryChanged":{{"claimed_peer_id":"{peer_id}","runtime_session_id":"{}","revision":1}}}}"#, + runtime.to_ascii_uppercase() + ), + format!( + r#"{{"GetGameFileChunk":{{"game_id":"game","content_id":"{}","relative_path":"file","offset":0,"length":1}}}}"#, + content(0xab).to_string().to_ascii_uppercase() + ), + format!( + r#"{{"GetGameFileChunk":{{"game_id":"game","content_id":"{}","relative_path":"../file","offset":0,"length":1}}}}"#, + content(3) + ), + ] { + assert!(Request::decode(Bytes::from(invalid)).is_err()); + } + } + + #[test] + fn established_resource_boundaries_are_exact() { + for game_id in [ + "g".repeat(MAX_GAME_ID_BYTES), + "é".repeat(MAX_GAME_ID_BYTES / 2), + ] { + Request::StreamInstall { + game_id, + content_id: content(1), + } + .encode() + .expect("bounded game ID should encode"); + } + assert!( + Request::StreamInstall { + game_id: "g".repeat(MAX_GAME_ID_BYTES + 1), + content_id: content(1), + } + .encode() + .is_err() + ); + + let mut snapshot = state_snapshot(Vec::new()); + snapshot.call_to_play.display_name = "é".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS); + Response::HelloSnapshot(snapshot.clone()) + .encode() + .expect("bounded name"); + snapshot.call_to_play.display_name = "x".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS + 1); + assert!(Response::HelloSnapshot(snapshot).encode().is_err()); + + let bounded_message = "x".repeat(MAX_CALL_TO_PLAY_MESSAGE_CHARS); + Response::HelloSnapshot(state_snapshot(vec![event(CallToPlayAction::SendMessage { + text: bounded_message, + })])) + .encode() + .expect("bounded message"); + assert!( + Response::HelloSnapshot(state_snapshot(vec![event(CallToPlayAction::SendMessage { + text: "x".repeat(MAX_CALL_TO_PLAY_MESSAGE_CHARS + 1), + },)])) + .encode() + .is_err() + ); + + let bounded_events = + vec![event(CallToPlayAction::Rsvp); MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR]; + Response::HelloSnapshot(state_snapshot(bounded_events.clone())) + .encode() + .expect("bounded events"); + let mut oversized_events = bounded_events; + oversized_events.push(event(CallToPlayAction::Rsvp)); + assert!( + Response::HelloSnapshot(state_snapshot(oversized_events)) + .encode() + .is_err() + ); + } + + #[test] + fn library_is_bounded_unique_and_strictly_sorted() { + let games = (0..MAX_LIBRARY_GAMES) + .map(|index| GameAvailability { + game_id: format!("game-{index:04}"), + content_id: content(1), + }) + .collect::>(); + let mut snapshot = state_snapshot(Vec::new()); + snapshot.library.games = games.clone(); + Response::HelloSnapshot(snapshot) + .encode() + .expect("bounded sorted library"); + + let mut oversized = games.clone(); + oversized.push(GameAvailability { + game_id: "zzzz".to_owned(), + content_id: content(1), + }); + let mut snapshot = state_snapshot(Vec::new()); + snapshot.library.games = oversized; + assert!(matches!( + Response::HelloSnapshot(snapshot).encode(), + Err(ControlCodecError::Invalid( + ControlValidationError::TooManyItems { .. } + )) + )); + + for games in [ + vec![ + GameAvailability { + game_id: "same".to_owned(), + content_id: content(1), + }, + GameAvailability { + game_id: "same".to_owned(), + content_id: content(2), + }, + ], + vec![ + GameAvailability { + game_id: "z".to_owned(), + content_id: content(1), + }, + GameAvailability { + game_id: "a".to_owned(), + content_id: content(2), + }, + ], + ] { + let mut snapshot = state_snapshot(Vec::new()); + snapshot.library.games = games; + assert!(Response::HelloSnapshot(snapshot).encode().is_err()); + } + } + + #[test] + fn control_frame_limit_and_single_document_are_enforced() { + assert!(matches!( + Request::decode(Bytes::from(vec![b' '; MAX_CONTROL_FRAME_BYTES])), + Err(ControlCodecError::Decode(_)) + )); + assert!(matches!( + Request::decode(Bytes::from(vec![b' '; MAX_CONTROL_FRAME_BYTES + 1])), + Err(ControlCodecError::FrameTooLarge { actual, maximum }) + if actual == MAX_CONTROL_FRAME_BYTES + 1 && maximum == MAX_CONTROL_FRAME_BYTES + )); + assert!(matches!( + Request::decode(Bytes::from_static(br#""Ping""Hello""#)), + Err(ControlCodecError::Decode(_)) + )); + + let escaped = format!("x{}", "\0".repeat(MAX_CALL_TO_PLAY_MESSAGE_CHARS - 1)); + let events = vec![ + event(CallToPlayAction::SendMessage { text: escaped }); + MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR + ]; + assert!(matches!( + Response::HelloSnapshot(state_snapshot(events)).encode(), + Err(ControlCodecError::Invalid( + ControlValidationError::EncodedTooLarge { .. } + )) + )); + } + + #[test] + fn maximal_author_budget_still_fits_with_a_bounded_library() { + fn author_with_message_chars(char_count: usize) -> CallToPlayAuthorSnapshot { + let text = "😀".repeat(char_count); + let events = (0..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR) + .map(|index| { + let mut event = event(CallToPlayAction::SendMessage { text: text.clone() }); + event.id = EventNonce::from_bytes((index as u128).to_be_bytes()); + event + }) + .collect(); + state_snapshot(events).call_to_play + } + + let mut accepted_chars = 0; + let mut rejected_chars = MAX_CALL_TO_PLAY_MESSAGE_CHARS + 1; + while accepted_chars + 1 < rejected_chars { + let candidate = usize::midpoint(accepted_chars, rejected_chars); + if author_with_message_chars(candidate).validate().is_ok() { + accepted_chars = candidate; + } else { + rejected_chars = candidate; + } + } + assert!(accepted_chars > 0); + assert!(rejected_chars <= MAX_CALL_TO_PLAY_MESSAGE_CHARS); + + let accepted_author = author_with_message_chars(accepted_chars); + accepted_author.validate().expect("author at byte budget"); + let rejected_author = author_with_message_chars(rejected_chars); + assert!(matches!( + rejected_author.validate(), + Err(ControlValidationError::EncodedTooLarge { .. }) + )); + + let games = (0..MAX_LIBRARY_GAMES) + .map(|index| GameAvailability { + game_id: format!("{index:04}-{}", "g".repeat(MAX_GAME_ID_BYTES - 5)), + content_id: content(1), + }) + .collect(); + let response = Response::HelloSnapshot(PeerStateSnapshot { + runtime_session_id: session(1), + library: LibrarySnapshot { revision: 1, games }, + call_to_play: accepted_author, + }); + let encoded = response + .encode() + .expect("a maximally accepted author plus bounded library must fit"); + assert!(encoded.len() <= MAX_CONTROL_FRAME_BYTES); + + let isolated = PeerStateSnapshot { + runtime_session_id: session(1), + library: LibrarySnapshot { + revision: 1, + games: Vec::new(), + }, + call_to_play: rejected_author, + }; + let decoded = Response::decode(Bytes::from( + serde_json::to_vec(&Response::HelloSnapshot(isolated)) + .expect("structural response should serialize"), + )) + .expect("invalid author remains domain-isolated during response decode"); + let Response::HelloSnapshot(decoded) = decoded else { + panic!("wrong response variant"); + }; + decoded.library.validate().expect("library remains valid"); + assert!(matches!( + decoded.call_to_play.validate(), + Err(ControlValidationError::EncodedTooLarge { .. }) + )); + } + + #[test] + fn removed_v7_control_surface_has_no_decode_fallback() { + for variant in [ + "ListGames", + "GetGame", + "GetGameFileData", + "LibraryDelta", + "CallToPlayEvents", + "Goodbye", + "Invalid", + ] { + let old = format!(r#"{{"{variant}":{{}}}}"#); + assert!( + Request::decode(Bytes::from(old)).is_err(), + "accepted v7 {variant}" + ); + } + for variant in [ + "ListGames", + "GetGame", + "HelloAck", + "CallToPlayAck", + "GameNotFound", + "InvalidRequest", + "EncodingError", + "DecodingError", + "InternalPeerError", + ] { + let old = format!(r#"{{"{variant}":{{}}}}"#); + assert!( + Response::decode(Bytes::from(old)).is_err(), + "accepted v7 {variant}" + ); + } + assert!(Response::decode(Bytes::from_static(br#""Pong""#)).is_err()); + assert!(Request::decode(Bytes::from_static( + br#"{"GetGameFileChunk":{"game_id":"game","relative_path":"file","offset":0,"length":1}}"#, + )).is_err()); + assert!( + Request::decode(Bytes::from_static( + br#"{"StreamInstall":{"game_id":"game"}}"#, + )) + .is_err() + ); + + // Ping is intentionally a unit value in both schemas; version-bound ALPN + // rejects a protocol-7 connection before any control JSON is decoded. + assert_eq!( + Request::decode(Bytes::from_static(br#""Ping""#)).expect("v8 Ping should decode"), + Request::Ping + ); + } + + #[test] + fn stream_install_paths_are_canonical_but_codec_semantics_stay_in_band() { + let frame = StreamInstallFrame::FileBegin { + relative_path: path("bin/game.exe"), + size: 42, + crc32: 7, + }; + assert_eq!(StreamInstallFrame::decode(frame.encode()), frame); + match StreamInstallFrame::decode(Bytes::new()) { + StreamInstallFrame::Error { message } => assert!(message.contains("empty")), + other => panic!("expected error frame, got {other:?}"), + } + let invalid = Bytes::from_static(b"\0{\"Directory\":{\"relative_path\":\"../escape\"}}"); + assert!(matches!( + StreamInstallFrame::decode(invalid), + StreamInstallFrame::Error { .. } + )); + } + + #[test] + fn checked_stream_decode_distinguishes_wire_errors_from_sender_errors() { + let explicit = StreamInstallFrame::Error { + message: "provider unavailable".to_owned(), + }; + assert_eq!( + StreamInstallFrame::decode_checked(explicit.encode()).expect("explicit Error is data"), + explicit + ); + assert!(matches!( + StreamInstallFrame::decode_checked(Bytes::new()), + Err(StreamInstallFrameDecodeError::Empty) + )); + assert!(matches!( + StreamInstallFrame::decode_checked(Bytes::from_static(b"\x7f{}")), + Err(StreamInstallFrameDecodeError::UnknownTag(0x7f)) + )); + assert!(matches!( + StreamInstallFrame::decode_checked(Bytes::from_static( + b"\0{\"Directory\":{\"relative_path\":\"../escape\"}}" + )), + Err(StreamInstallFrameDecodeError::InvalidControl(_)) + )); + + let file_chunk_under_control = tagged_stream_install_frame( + STREAM_INSTALL_CONTROL_FRAME_TAG, + &serde_json::to_vec(&StreamInstallFrame::FileChunk { + bytes: Bytes::from_static(b"bytes"), + }) + .expect("serialize test frame"), + ); + assert!(matches!( + StreamInstallFrame::decode_checked(file_chunk_under_control), + Err(StreamInstallFrameDecodeError::FileChunkInControl) + )); + assert!(matches!( + StreamInstallFrame::decode_checked(Bytes::from(vec![ + STREAM_INSTALL_FILE_CHUNK_FRAME_TAG; + MAX_STREAM_INSTALL_FRAME_BYTES + 1 + ])), + Err(StreamInstallFrameDecodeError::FrameTooLarge { actual, maximum }) + if actual == MAX_STREAM_INSTALL_FRAME_BYTES + 1 + && maximum == MAX_STREAM_INSTALL_FRAME_BYTES + )); + } +} diff --git a/crates/lanspread-proto/tests/stream_install_frame.rs b/crates/lanspread-proto/tests/stream_install_frame.rs index ed44eb7..1a7d655 100644 --- a/crates/lanspread-proto/tests/stream_install_frame.rs +++ b/crates/lanspread-proto/tests/stream_install_frame.rs @@ -1,4 +1,5 @@ use bytes::Bytes; +use lanspread_db::content_manifest::CanonicalCatalogPath; use lanspread_proto::{Message, StreamInstallFrame}; #[test] @@ -19,7 +20,8 @@ fn file_chunks_encode_raw_bytes() { #[test] fn control_frames_are_tagged_json() { let frame = StreamInstallFrame::FileBegin { - relative_path: "bin/game.exe".to_string(), + relative_path: CanonicalCatalogPath::new("bin/game.exe") + .expect("test path should be canonical"), size: 42, crc32: 0x38B4_88A7, }; diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml index ea3496a..71b35f3 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml +++ b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml @@ -24,6 +24,8 @@ lanspread-peer = { path = "../../lanspread-peer" } # external base64 = { workspace = true } +cap-fs-ext = { workspace = true } +cap-primitives = { workspace = true } eyre = { workspace = true } log = { workspace = true } mimalloc = { workspace = true } @@ -41,11 +43,17 @@ tracing-log = { workspace = true } tracing-subscriber = { workspace = true } walkdir = { workspace = true } +[dev-dependencies] +sqlx = { workspace = true } + [build-dependencies] +lanspread-compat = { path = "../../lanspread-compat" } +serde_json = { workspace = true } tauri-build = { version = "2", features = [] } +tokio = { workspace = true } [target."cfg(windows)".dependencies] -windows = { workspace = true } +windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } [lints.clippy] needless_pass_by_value = "allow" diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/build.rs b/crates/lanspread-tauri-deno-ts/src-tauri/build.rs index 261851f..b0f0c17 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/build.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/build.rs @@ -1,3 +1,62 @@ +use std::{env, fs}; + +use build_support::catalog_gate::{ + CatalogBuildMode, + CatalogGateInput, + select_catalog_build_mode, + validate_production_catalog, +}; + +mod build_support { + #[path = "catalog_gate.rs"] + pub(crate) mod catalog_gate; +} + +const FIXTURE_DEVELOPMENT_ENV: &str = "LANSPREAD_USE_FIXTURE_CATALOG"; + fn main() { + println!("cargo:rerun-if-env-changed=TAURI_CONFIG"); + println!("cargo:rerun-if-env-changed={FIXTURE_DEVELOPMENT_ENV}"); + println!("cargo:rerun-if-changed=tauri.conf.json"); + println!("cargo:rerun-if-changed=game.db"); + println!("cargo:rerun-if-changed=manifests"); + + let mode = catalog_build_mode().unwrap_or_else(|error| { + panic!("catalog packaging policy failed: {error}"); + }); + if mode == CatalogBuildMode::Production + && let Err(error) = validate_production_catalog("game.db", "manifests") + { + panic!("production catalog authority gate failed: {error}"); + } tauri_build::build(); } + +fn catalog_build_mode() -> Result> { + let base_config = fs::read_to_string("tauri.conf.json")?; + let config_override = env::var_os("TAURI_CONFIG") + .map(|value| { + value + .into_string() + .map_err(|_| "TAURI_CONFIG is not valid UTF-8".to_owned()) + }) + .transpose()?; + let fixture_development_opt_in = match env::var_os(FIXTURE_DEVELOPMENT_ENV) { + None => false, + Some(value) if value == "1" => true, + Some(_) => { + return Err(format!("{FIXTURE_DEVELOPMENT_ENV} must be exactly 1 when set").into()); + } + }; + let cargo_profile = env::var("PROFILE").ok(); + let out_dir = env::var_os("OUT_DIR").map(std::path::PathBuf::from); + + select_catalog_build_mode(CatalogGateInput { + base_config: &base_config, + config_override: config_override.as_deref(), + fixture_development_opt_in, + cargo_profile: cargo_profile.as_deref(), + out_dir: out_dir.as_deref(), + }) + .map_err(Into::into) +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/build_support/catalog_gate.rs b/crates/lanspread-tauri-deno-ts/src-tauri/build_support/catalog_gate.rs new file mode 100644 index 0000000..163e511 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src-tauri/build_support/catalog_gate.rs @@ -0,0 +1,431 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, +}; + +use lanspread_compat::catalog_bundle::load_catalog_bundle; + +const PRODUCTION_RESOURCES: [&str; 3] = ["assets/*", "game.db", "manifests/*"]; +const DEVELOPMENT_RESOURCES: [(&str, &str); 3] = [ + ( + "../../lanspread-peer-cli/catalogs/default/game.db", + "game.db", + ), + ( + "../../lanspread-peer-cli/catalogs/default/manifests/", + "manifests/", + ), + ("assets/*", "assets/"), +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CatalogBuildMode { + FixtureDevelopment, + Production, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct CatalogGateInput<'a> { + pub(crate) base_config: &'a str, + pub(crate) config_override: Option<&'a str>, + pub(crate) fixture_development_opt_in: bool, + pub(crate) cargo_profile: Option<&'a str>, + pub(crate) out_dir: Option<&'a Path>, +} + +/// Chooses whether the build may use fixture authority or must validate the +/// production catalog corpus. +/// +/// Production is the default for every invocation, including Cargo's ordinary +/// `release` profile. Fixture authority requires both the exact checked-in +/// resource map and an explicit repository-development opt-in. A custom +/// `production` profile can never be downgraded by that opt-in. +pub(crate) fn select_catalog_build_mode( + input: CatalogGateInput<'_>, +) -> Result { + let resources = effective_resources(input.base_config, input.config_override)?; + let forced_production = input.cargo_profile == Some("production") + || input.out_dir.is_some_and(|out_dir| { + out_dir + .components() + .any(|component| component.as_os_str() == "production") + }); + + if input.fixture_development_opt_in && !forced_production { + if resources != ResourceAuthority::FixtureDevelopment { + return Err( + "fixture catalog opt-in requires the exact development resource map".to_owned(), + ); + } + return Ok(CatalogBuildMode::FixtureDevelopment); + } + + if resources != ResourceAuthority::Production { + let reason = if forced_production { + "the production Cargo profile" + } else { + "a build without the fixture catalog opt-in" + }; + return Err(format!( + "{reason} requires exactly the production catalog resources" + )); + } + Ok(CatalogBuildMode::Production) +} + +/// Validates the exact catalog authority shipped by a production bundle. +/// +/// This deliberately uses the same coherent database loader as the +/// application runtime before eagerly validating every manifest body. The +/// runtime loader catches database identity and join ambiguity; the final +/// pass is the release-time integrity gate for the complete manifest corpus. +pub(crate) fn validate_production_catalog( + game_db: impl AsRef, + manifests_root: impl AsRef, +) -> Result<(), String> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("failed to create catalog validation runtime: {error}"))?; + let catalog = runtime + .block_on(load_catalog_bundle( + game_db.as_ref(), + manifests_root.as_ref(), + )) + .map_err(|error| error.to_string())?; + catalog + .bundle() + .validate_all() + .map_err(|error| error.to_string()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ResourceAuthority { + FixtureDevelopment, + Production, +} + +fn effective_resources( + base_config: &str, + config_override: Option<&str>, +) -> Result { + let base = parse_config(base_config, "base Tauri config")?; + let base_resources = base + .pointer("/bundle/resources") + .ok_or_else(|| "base Tauri config does not declare bundle.resources".to_owned())?; + if let Some(raw_override) = config_override { + let config_override = parse_config(raw_override, "TAURI_CONFIG override")?; + if let Some(resources) = config_override.pointer("/bundle/resources") { + return classify_resources(resources); + } + } + classify_resources(base_resources) +} + +fn parse_config(raw: &str, label: &str) -> Result { + serde_json::from_str(raw).map_err(|error| format!("failed to parse {label}: {error}")) +} + +fn classify_resources(resources: &serde_json::Value) -> Result { + if let Some(resources) = resources.as_array() { + let actual = resources + .iter() + .map(|resource| { + resource + .as_str() + .ok_or_else(|| "production resource entries must be strings".to_owned()) + }) + .collect::, _>>()?; + let unique = actual.iter().copied().collect::>(); + let expected = PRODUCTION_RESOURCES.into_iter().collect::>(); + if actual.len() == PRODUCTION_RESOURCES.len() && unique == expected { + return Ok(ResourceAuthority::Production); + } + return Err(format!( + "production resource list must be exactly {expected:?}, got {actual:?}" + )); + } + + if let Some(resources) = resources.as_object() { + let actual = resources + .iter() + .map(|(source, destination)| { + destination + .as_str() + .map(|destination| (source.as_str(), destination)) + .ok_or_else(|| "development resource destinations must be strings".to_owned()) + }) + .collect::, _>>()?; + let expected = DEVELOPMENT_RESOURCES + .into_iter() + .collect::>(); + if actual == expected { + return Ok(ResourceAuthority::FixtureDevelopment); + } + return Err(format!( + "development resource map must be exactly {expected:?}, got {actual:?}" + )); + } + + Err("bundle.resources must be an exact production list or development map".to_owned()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + + use super::*; + + static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + const PRODUCTION: &str = r#"{ + "bundle": {"resources": ["game.db", "manifests/*", "assets/*"]} + }"#; + const DEVELOPMENT: &str = r#"{ + "bundle": {"resources": { + "../../lanspread-peer-cli/catalogs/default/game.db": "game.db", + "../../lanspread-peer-cli/catalogs/default/manifests/": "manifests/", + "assets/*": "assets/" + }} + }"#; + + fn input( + config_override: Option<&str>, + fixture_development_opt_in: bool, + ) -> CatalogGateInput<'_> { + CatalogGateInput { + base_config: PRODUCTION, + config_override, + fixture_development_opt_in, + cargo_profile: Some("release"), + out_dir: Some(Path::new("target/release/build/app/out")), + } + } + + #[test] + fn ordinary_release_and_production_overrides_are_production_gated() { + assert_eq!( + select_catalog_build_mode(input(None, false)), + Ok(CatalogBuildMode::Production) + ); + assert_eq!( + select_catalog_build_mode(input(Some(PRODUCTION), false)), + Ok(CatalogBuildMode::Production) + ); + assert_eq!( + select_catalog_build_mode(input(Some(r#"{"build": {}}"#), false)), + Ok(CatalogBuildMode::Production) + ); + } + + #[test] + fn exact_development_map_requires_the_explicit_opt_in() { + assert_eq!( + select_catalog_build_mode(input(Some(DEVELOPMENT), true)), + Ok(CatalogBuildMode::FixtureDevelopment) + ); + assert!(select_catalog_build_mode(input(Some(DEVELOPMENT), false)).is_err()); + assert!(select_catalog_build_mode(input(None, true)).is_err()); + } + + #[test] + fn production_profile_cannot_be_downgraded_to_fixture_authority() { + for mut input in [ + CatalogGateInput { + cargo_profile: Some("production"), + ..input(Some(DEVELOPMENT), true) + }, + CatalogGateInput { + out_dir: Some(Path::new("target/production/build/app/out")), + ..input(Some(DEVELOPMENT), true) + }, + ] { + assert!(select_catalog_build_mode(input).is_err()); + input.config_override = Some(PRODUCTION); + assert_eq!( + select_catalog_build_mode(input), + Ok(CatalogBuildMode::Production) + ); + } + } + + #[test] + fn incomplete_duplicated_or_unknown_resource_shapes_fail_closed() { + for config in [ + r#"{"bundle":{"resources":["game.db","manifests/*"]}}"#, + r#"{"bundle":{"resources":["game.db","manifests/*","assets/*","assets/*"]}}"#, + r#"{"bundle":{"resources":{"fixture.db":"game.db"}}}"#, + r#"{"bundle":{"resources":true}}"#, + "not JSON", + ] { + assert!( + select_catalog_build_mode(input(Some(config), false)).is_err(), + "accepted resource config: {config}" + ); + } + } + + #[test] + fn invalid_base_resources_fail_even_with_an_unrelated_override() { + let mut input = input(Some(r#"{"build": {}}"#), false); + input.base_config = r#"{"bundle":{"resources":["game.db"]}}"#; + assert!(select_catalog_build_mode(input).is_err()); + } + + #[derive(Clone, Copy)] + enum DatabaseCorruption { + DuplicateDbId, + MissingGenre, + DuplicateGenre, + } + + impl DatabaseCorruption { + const fn expected_error(self) -> &'static str { + match self { + Self::DuplicateDbId => "duplicate raw game db_id", + Self::MissingGenre => "missing genre join expansion", + Self::DuplicateGenre => "duplicate genre join expansion", + } + } + } + + #[test] + fn production_gate_rejects_malformed_runtime_database_authority() { + for corruption in [ + DatabaseCorruption::DuplicateDbId, + DatabaseCorruption::MissingGenre, + DatabaseCorruption::DuplicateGenre, + ] { + let fixture = MalformedCatalogFixture::new(corruption); + let error = validate_production_catalog(&fixture.game_db, &fixture.manifests) + .expect_err("the production gate must use the strict application loader"); + assert!( + error.contains(corruption.expected_error()), + "unexpected error for {}: {error}", + corruption.expected_error() + ); + } + } + + struct MalformedCatalogFixture { + root: PathBuf, + game_db: PathBuf, + manifests: PathBuf, + } + + impl MalformedCatalogFixture { + fn new(corruption: DatabaseCorruption) -> Self { + let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should follow the epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lanspread-tauri-catalog-gate-{}-{nanos}-{sequence}", + std::process::id() + )); + let game_db = root.join("game.db"); + let manifests = root.join("manifests"); + fs::create_dir(&root).expect("test root should be created"); + fs::create_dir(&manifests).expect("manifest root should be created"); + create_malformed_database(&game_db, corruption); + for game_id in ["g", "h"] { + fs::write(manifests.join(format!("{game_id}.json")), b"not parsed\n") + .expect("placeholder manifest should be created"); + } + Self { + root, + game_db, + manifests, + } + } + } + + impl Drop for MalformedCatalogFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn create_malformed_database(path: &Path, corruption: DatabaseCorruption) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime should be created"); + runtime.block_on(async { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("fixture database should open"); + let result = async { + sqlx::query( + "CREATE TABLE games ( + game_id TEXT NOT NULL, db_id INTEGER NOT NULL, + game_title TEXT NOT NULL, game_key TEXT NOT NULL, + game_release TEXT NOT NULL, game_publisher TEXT NOT NULL, + game_size REAL NOT NULL, game_readme_de TEXT NOT NULL, + game_readme_en TEXT NOT NULL, game_readme_fr TEXT NOT NULL, + game_maxplayers INTEGER NOT NULL, game_master_req INTEGER NOT NULL, + genre_id INTEGER NOT NULL, game_version TEXT NOT NULL + )", + ) + .execute(&pool) + .await?; + sqlx::query( + "CREATE TABLE genre (genre_id INTEGER NOT NULL, genre_de TEXT NOT NULL)", + ) + .execute(&pool) + .await?; + + if !matches!(corruption, DatabaseCorruption::MissingGenre) { + sqlx::query("INSERT INTO genre VALUES (10, 'Strategy')") + .execute(&pool) + .await?; + } + if matches!(corruption, DatabaseCorruption::DuplicateGenre) { + sqlx::query("INSERT INTO genre VALUES (10, 'Duplicate')") + .execute(&pool) + .await?; + } + + insert_game(&pool, 1, "g").await?; + if matches!(corruption, DatabaseCorruption::DuplicateDbId) { + insert_game(&pool, 1, "h").await?; + } + Ok::<(), sqlx::Error>(()) + } + .await; + pool.close().await; + result.expect("malformed fixture database should be written"); + }); + } + + async fn insert_game( + pool: &sqlx::SqlitePool, + db_id: i64, + game_id: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO games VALUES ( + ?, ?, 'Game', 'key', '2024', 'publisher', 1.0, + 'de', 'en', 'fr', 4, 0, 10, '20240101' + )", + ) + .bind(game_id) + .bind(db_id) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index e64aadc..3d51eec 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, + fmt::Write as _, fs::{self, OpenOptions}, io::{self, Read as _, Seek as _, SeekFrom, Write as _}, path::{Component, Path, PathBuf}, @@ -7,40 +8,59 @@ use std::{ Arc, Mutex, OnceLock, + Weak, atomic::{AtomicU64, Ordering}, }, time::{Duration, SystemTime, UNIX_EPOCH}, }; use eyre::bail; -use lanspread_compat::eti::get_games; -use lanspread_db::db::{Availability, Game, GameCatalog, GameDB}; +use lanspread_compat::catalog_bundle::{LoadedCatalog, load_catalog_bundle}; +use lanspread_db::{ + content_manifest::CatalogBundle, + db::{Availability, Game, GameDB}, +}; use lanspread_peer::{ ActiveOperation, ActiveOperationKind, - CallToPlayEvent, + CallToPlayLocalIntent, + CallToPlayReceipt, + DownloadAttemptId, + DownloadAttemptKey, + DownloadFailureReason, + DownloadProgress, + DownloadVerificationActivity, ExternalUnrarStreamProvider, + LocalNetworkSharingState, NoopStreamInstallProvider, PeerCommand, PeerEvent, PeerGameDB, + PeerIdentity, + PeerIdentityDurability, PeerRuntimeHandle, PeerStartOptions, + RemoteLibraryView, + ScopedProcess, StreamInstallProvider, + StreamInstallSettings, UnpackFuture, Unpacker, migrate_legacy_state, + scoped_blocking, start_peer_with_options, }; use tauri::{AppHandle, Emitter as _, Manager}; -use tauri_plugin_shell::{ - ShellExt, - process::{Command, CommandChild, CommandEvent}, -}; +use tauri_plugin_shell::ShellExt; use tokio::sync::{ RwLock, mpsc::{UnboundedReceiver, UnboundedSender}, oneshot, + watch, +}; +use tokio_util::{ + sync::CancellationToken, + task::{TaskTracker, task_tracker::TaskTrackerToken}, }; use tracing::{Event, Level, Metadata, Subscriber, field::Visit}; use tracing_subscriber::{ @@ -49,6 +69,8 @@ use tracing_subscriber::{ registry::LookupSpan, }; +mod sharing_policy; + // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ type OutboundTransfers = @@ -62,6 +84,135 @@ struct OutboundTransferEmitState { generation: u64, } +#[derive(Clone, Default)] +struct AppTaskScope { + cancel_token: CancellationToken, + tasks: TaskTracker, + admission: Arc>, +} + +#[derive(Default)] +struct AppTaskAdmission { + closed: bool, +} + +impl AppTaskScope { + fn cancel_token(&self) -> CancellationToken { + self.cancel_token.clone() + } + + fn spawn(&self, task: F) + where + F: std::future::Future + Send + 'static, + { + let admission = self + .admission + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if admission.closed { + drop(admission); + drop(task); + return; + } + let runtime = tauri::async_runtime::handle(); + drop(self.tasks.spawn_on(task, runtime.inner())); + drop(admission); + } + + async fn shutdown(&self) { + { + let mut admission = self + .admission + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + admission.closed = true; + self.cancel_token.cancel(); + self.tasks.close(); + } + self.tasks.wait().await; + } +} + +/// Admission and drain scope for application-owned Tauri invokes. +/// +/// Tauri owns the invoke futures, so they cannot be spawned in our task scope. +/// A tracker token instead makes each admitted future part of the application +/// shutdown boundary: shutdown closes admission, waits for every token to be +/// dropped, and only then takes ownership of the peer runtime to stop it. The +/// separate serial lock orders game-directory startup, sharing transitions, +/// and sharing-gated Call-to-Play publication through their acknowledgements +/// and UI commits without participating in shutdown locking. +#[derive(Clone, Default)] +struct AppInvokeScope { + invokes: TaskTracker, + admission: Arc>, + peer_startup_serial: Arc>, +} + +#[derive(Default)] +struct AppInvokeAdmission { + closed: bool, +} + +#[must_use = "the guard keeps an application invoke inside the shutdown scope"] +struct AppInvokeGuard { + _token: TaskTrackerToken, +} + +impl AppInvokeScope { + fn try_enter(&self) -> Option { + let admission = self + .admission + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if admission.closed { + return None; + } + + // The admission mutex makes token creation atomic with respect to + // `close_admission`: a token is either visible to its wait or rejected. + let guard = AppInvokeGuard { + _token: self.invokes.token(), + }; + drop(admission); + Some(guard) + } + + async fn serialize_peer_startup(&self) -> tokio::sync::OwnedMutexGuard<()> { + Arc::clone(&self.peer_startup_serial).lock_owned().await + } + + fn close_admission(&self) { + let mut admission = self + .admission + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + admission.closed = true; + self.invokes.close(); + } + + async fn wait_closed(&self) { + self.invokes.wait().await; + } + + #[cfg(test)] + async fn close_and_wait(&self) { + self.close_admission(); + self.wait_closed().await; + } +} + +const APP_SHUTDOWN_STARTED: &str = "application shutdown has started"; + +fn enter_app_invoke(state: &LanSpreadState) -> tauri::Result { + state.app_invokes.try_enter().ok_or_else(|| { + tauri::Error::from(io::Error::new( + io::ErrorKind::Interrupted, + APP_SHUTDOWN_STARTED, + )) + }) +} + impl OutboundTransferEmitState { fn record_change(&mut self) -> bool { self.generation = self.generation.saturating_add(1); @@ -87,43 +238,444 @@ impl OutboundTransferEmitState { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +enum LocalNetworkSharingPhase { + WaitingForGameDirectory, + Disabled, + Enabling, + Enabled, + Disabling, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +enum SharingPersistenceProblem { + Load, + Save, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalNetworkSharingSnapshot { + revision: u64, + enabled: bool, + pending_target: Option, + phase: LocalNetworkSharingPhase, + persistence_problem: Option, +} + +impl LocalNetworkSharingSnapshot { + const fn initial( + enabled: bool, + persistence_problem: Option, + ) -> Self { + Self { + revision: 1, + enabled, + pending_target: None, + phase: if enabled { + LocalNetworkSharingPhase::WaitingForGameDirectory + } else { + LocalNetworkSharingPhase::Disabled + }, + persistence_problem, + } + } + + const fn is_stable_at(self, enabled: bool) -> bool { + self.pending_target.is_none() + && matches!( + (enabled, self.phase), + (true, LocalNetworkSharingPhase::Enabled) + | (false, LocalNetworkSharingPhase::Disabled) + ) + } + + fn admits_network_actions(self) -> bool { + self.phase == LocalNetworkSharingPhase::Enabled && self.pending_target != Some(false) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +enum IdentityDiagnostic { + Ephemeral, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct IdentityDiagnosticSnapshot { + revision: u64, + diagnostic: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +enum GameTransferStatus { + Verifying, + Retrying, + Exhausted, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct GameTransferStatusSnapshot { + revision: u64, + statuses: BTreeMap, + open_attempts: BTreeMap, +} + +impl Default for GameTransferStatusSnapshot { + fn default() -> Self { + Self { + revision: 1, + statuses: BTreeMap::new(), + open_attempts: BTreeMap::new(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GameTransferAttemptReceipt { + attempt_id: DownloadAttemptId, + terminal: bool, +} + +#[derive(Debug, Default)] +struct GameTransferStatusStore { + snapshot: GameTransferStatusSnapshot, + attempts: HashMap, +} + +impl GameTransferStatusStore { + fn snapshot(&self) -> GameTransferStatusSnapshot { + self.snapshot.clone() + } + + fn begin( + &mut self, + attempt: &DownloadAttemptKey, + ) -> Result, String> { + if self + .attempts + .get(&attempt.id) + .is_some_and(|current| current.attempt_id >= attempt.attempt_id) + { + return Ok(None); + } + + let revision = self.next_revision()?; + self.attempts.insert( + attempt.id.clone(), + GameTransferAttemptReceipt { + attempt_id: attempt.attempt_id, + terminal: false, + }, + ); + self.snapshot + .open_attempts + .insert(attempt.id.clone(), attempt.attempt_id); + self.snapshot.statuses.remove(&attempt.id); + self.snapshot.revision = revision; + Ok(Some(self.snapshot())) + } + + fn activity( + &mut self, + attempt: &DownloadAttemptKey, + activity: Option, + ) -> Result, String> { + if !self.is_current_open_attempt(attempt) { + return Ok(None); + } + let status = activity.map(|activity| match activity { + DownloadVerificationActivity::VerifyingDownloadedChunks => { + GameTransferStatus::Verifying + } + DownloadVerificationActivity::RetryingInvalidSource => GameTransferStatus::Retrying, + }); + if self.snapshot.statuses.get(&attempt.id).copied() == status { + return Ok(None); + } + + let revision = self.next_revision()?; + match status { + Some(status) => { + self.snapshot.statuses.insert(attempt.id.clone(), status); + } + None => { + self.snapshot.statuses.remove(&attempt.id); + } + } + self.snapshot.revision = revision; + Ok(Some(self.snapshot())) + } + + fn finished( + &mut self, + attempt: &DownloadAttemptKey, + ) -> Result, String> { + self.settle(attempt, None) + } + + fn failed( + &mut self, + attempt: &DownloadAttemptKey, + reason: DownloadFailureReason, + ) -> Result, String> { + let status = match reason { + DownloadFailureReason::VerifiedCatalogSourcesExhausted => { + Some(GameTransferStatus::Exhausted) + } + DownloadFailureReason::OperationFailed + if !self.is_current_open_attempt(attempt) + && self.snapshot.statuses.get(&attempt.id) + == Some(&GameTransferStatus::Exhausted) => + { + // A newer preflight failure can be terminal-only. It adopts + // the monotonic receipt and emits the generic failure, but it + // is not one of the explicit authorities that clears an + // earlier verified-source exhaustion diagnostic. + Some(GameTransferStatus::Exhausted) + } + DownloadFailureReason::OperationFailed => None, + }; + self.settle(attempt, status) + } + + fn accepts_progress(&self, progress: &DownloadProgress) -> bool { + self.is_current_open_attempt(&progress.attempt) + } + + fn clear_settled_for_local_generation( + &mut self, + ) -> Result, String> { + let visible_settled = self + .attempts + .iter() + .filter_map(|(id, receipt)| { + (receipt.terminal && self.snapshot.statuses.contains_key(id)).then_some(id.clone()) + }) + .collect::>(); + if visible_settled.is_empty() { + return Ok(None); + } + + let revision = self.next_revision()?; + for id in visible_settled { + self.snapshot.statuses.remove(&id); + } + self.snapshot.revision = revision; + Ok(Some(self.snapshot())) + } + + fn clear_for_root_change(&mut self) -> Result, String> { + if self.attempts.is_empty() && self.snapshot.statuses.is_empty() { + return Ok(None); + } + + let revision = self.next_revision()?; + for receipt in self.attempts.values_mut() { + receipt.terminal = true; + } + self.snapshot.open_attempts.clear(); + self.snapshot.statuses.clear(); + self.snapshot.revision = revision; + Ok(Some(self.snapshot())) + } + + fn settle( + &mut self, + attempt: &DownloadAttemptKey, + status: Option, + ) -> Result, String> { + let accepts_terminal = self.attempts.get(&attempt.id).is_none_or(|current| { + current.attempt_id < attempt.attempt_id + || (current.attempt_id == attempt.attempt_id && !current.terminal) + }); + if !accepts_terminal { + return Ok(None); + } + + let revision = self.next_revision()?; + self.attempts.insert( + attempt.id.clone(), + GameTransferAttemptReceipt { + attempt_id: attempt.attempt_id, + terminal: true, + }, + ); + self.snapshot.open_attempts.remove(&attempt.id); + match status { + Some(status) => { + self.snapshot.statuses.insert(attempt.id.clone(), status); + } + None => { + self.snapshot.statuses.remove(&attempt.id); + } + } + self.snapshot.revision = revision; + Ok(Some(self.snapshot())) + } + + fn is_current_open_attempt(&self, attempt: &DownloadAttemptKey) -> bool { + self.attempts + .get(&attempt.id) + .is_some_and(|current| current.attempt_id == attempt.attempt_id && !current.terminal) + } + + fn next_revision(&self) -> Result { + self.snapshot + .revision + .checked_add(1) + .ok_or_else(|| "game transfer status revision overflow".to_owned()) + } +} + +#[derive(Clone, Debug, serde::Serialize)] +struct UiDownloadProgress { + id: String, + #[serde(rename = "attemptId")] + attempt_id: DownloadAttemptId, + downloaded_bytes: u64, + total_bytes: u64, + bytes_per_second: u64, + active_peer_count: usize, +} + +impl From<&DownloadProgress> for UiDownloadProgress { + fn from(progress: &DownloadProgress) -> Self { + Self { + id: progress.attempt.id.clone(), + attempt_id: progress.attempt.attempt_id, + downloaded_bytes: progress.downloaded_bytes, + total_bytes: progress.total_bytes, + bytes_per_second: progress.bytes_per_second, + active_peer_count: progress.active_peer_count, + } + } +} + +impl IdentityDiagnosticSnapshot { + const INITIAL: Self = Self { + revision: 1, + diagnostic: None, + }; +} + +enum SharingSnapshotMutation { + Begin { + target: bool, + }, + Commit { + enabled: bool, + phase: Option, + persistence_problem: Option, + }, +} + +enum UiStateCommand { + MutateSharing { + mutation: SharingSnapshotMutation, + reply: oneshot::Sender>, + }, + SetIdentityDiagnostic { + diagnostic: Option, + reply: oneshot::Sender>, + }, + FencePeerEvents { + reply: oneshot::Sender>, + }, + ResetGameTransferStatus { + reply: oneshot::Sender>, + }, +} + +#[derive(Clone)] +struct UiStateTx(UnboundedSender); + +struct InstallationIdentity { + identity: Arc, + durability: PeerIdentityDurability, +} + +fn retain_installation_identity( + slot: &mut Option>, + observed_identity: Arc, + observed_durability: PeerIdentityDurability, +) -> PeerIdentityDurability { + if slot.is_none() { + *slot = Some(InstallationIdentity { + identity: observed_identity, + durability: observed_durability, + }); + } + slot.as_ref() + .map_or(observed_durability, |cached| cached.durability) +} + /// Tauri-managed runtime state shared by commands and setup tasks. #[derive(Default)] struct LanSpreadState { peer_ctrl: Arc>>>, peer_runtime: Arc>>, local_peer_id: Arc>>, + /// Exact installation identity retained for the entire application + /// process. In particular, an ephemeral identity is reused across forced + /// runtime restarts and changes only when the application itself restarts. + installation_identity: Arc>>, games: Arc>, active_operations: Arc>>, games_folder: Arc>, peer_game_db: Arc>, - catalog: Arc>, + /// Immutable catalog authority loaded before setup admits commands or + /// starts application-owned background work. + catalog_bundle: OnceLock>, unpack_logs: Arc>>, state_dir: OnceLock, main_log_sink: OnceLock, active_outbound_transfers: OutboundTransfers, outbound_transfer_emit: Arc>, - /// Live unrar sidecar processes, so they can be killed if the launcher exits - /// mid-unpack. The shell plugin does not kill spawned children on app exit, - /// and the OS does not cascade-kill child processes, so without this an - /// in-progress unrar keeps running after the launcher closes. + /// Latest incompatibility diagnostic for the current peer-runtime + /// generation. Frontends query this after registering their listener so a + /// startup event cannot be lost. + protocol_mismatch: Arc>, + /// Backend-owned, revisioned sharing state. Setup installs revision one + /// before commands are admitted; the single peer-event loop is its only + /// writer afterward. + local_network_sharing: OnceLock>, + /// Redacted installation-identity diagnostic, also revisioned so a + /// listener-first frontend query cannot lose runtime-startup publication. + identity_diagnostic: OnceLock>, + /// Catalog-bounded transfer UI state. Peer events and root-reset commands + /// are serialized through the one peer-event loop; attempt receipts remain + /// backend-only after terminal outcomes so stale events cannot affect a + /// successor attempt. + game_transfer_status: Arc>, + background_tasks: AppTaskScope, + app_invokes: AppInvokeScope, + /// Cancellation controls for lexically owned unrar process workers. + /// Workers themselves synchronously join their child and pipe readers. active_unrar_children: Arc>, } -/// Live unrar sidecar children plus a shutdown latch. +/// Live unrar process-worker controls plus a shutdown latch. /// /// Children are keyed by a monotonic id (not pid) so a finishing install never -/// deregisters another install's child after a pid is recycled. The -/// `shutting_down` latch closes a time-of-check/time-of-use hole: the exit kill -/// sweep drains the map only once, so a child registered *after* the sweep (an -/// install task caught between `spawn()` and registration, or a later archive in -/// a multi-archive install — `unpack_archives` does not observe the shutdown -/// token) would be orphaned. Once the latch is set under this mutex, registration -/// kills the child immediately instead of inserting it where nothing will reap it. +/// deregisters another install's child after a pid is recycled. The registry +/// deliberately owns only weak references: the unpack future is the lexical +/// owner responsible for killing on drop and draining the event stream before a +/// normal return. The shutdown latch closes the spawn/exit-sweep race by killing +/// late registrations immediately. #[derive(Default)] struct UnrarChildRegistry { shutting_down: bool, - children: HashMap, + children: HashMap>, +} + +struct UnrarWorkerControl { + cancel_token: CancellationToken, } /// Monotonic id source for [`UnrarChildRegistry`] entries. @@ -156,6 +708,7 @@ struct UiActiveOperation { struct GamesListPayload { games: Vec, active_operations: Vec, + transfer_status: GameTransferStatusSnapshot, } #[derive(Clone, Debug, serde::Serialize)] @@ -198,17 +751,26 @@ struct SidecarUnpacker { } const MAX_UNPACK_LOGS: usize = 20; +const UNRAR_LOG_CAPTURE_LIMIT: usize = 1024 * 1024; const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json"; const MAIN_LOG_FILE_NAME: &str = "lanspread.log"; const MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024; const MAIN_LOG_TRIM_SLACK_BYTES: u64 = 64 * 1024; impl Unpacker for SidecarUnpacker { - fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { + fn unpack<'a>( + &'a self, + archive: &'a Path, + dest: &'a Path, + cancel_token: CancellationToken, + ) -> UnpackFuture<'a> { Box::pin(async move { + if cancel_token.is_cancelled() { + bail!("unrar extraction for {} was cancelled", archive.display()); + } let app_handle = self.app_handle.clone(); - let sidecar = app_handle.shell().sidecar("unrar")?; - do_unrar(&app_handle, sidecar, archive, dest).await + let program = resolve_unrar_sidecar_program(&app_handle)?; + do_unrar(&app_handle, &program, archive, dest, cancel_token).await }) } } @@ -217,6 +779,7 @@ impl Unpacker for SidecarUnpacker { async fn get_unpack_logs( state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result> { + let _app_invoke = enter_app_invoke(state.inner())?; Ok(state.inner().unpack_logs.read().await.clone()) } @@ -225,6 +788,7 @@ async fn get_main_logs( app_handle: tauri::AppHandle, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; if let Some(sink) = state.inner().main_log_sink.get() { return Ok(sink.read_history()?); } @@ -260,6 +824,7 @@ const MAX_USERNAME_CHARS: usize = 24; #[tauri::command] async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> { + let _app_invoke = enter_app_invoke(state.inner())?; log::debug!("request_games"); let peer_ctrl_arc = state.inner().peer_ctrl.clone(); @@ -277,9 +842,166 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result } #[tauri::command] -async fn request_call_to_play_events( +async fn get_protocol_mismatch( + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; + Ok(current_protocol_mismatch(state.inner()).await) +} + +async fn current_protocol_mismatch(state: &LanSpreadState) -> ProtocolMismatchSnapshot { + *state.protocol_mismatch.read().await +} + +async fn record_protocol_mismatch( + state: &LanSpreadState, + mismatch: ProtocolMismatch, +) -> ProtocolMismatchSnapshot { + let mut diagnostic = state.protocol_mismatch.write().await; + diagnostic.revision = diagnostic.revision.saturating_add(1); + diagnostic.mismatch = Some(mismatch); + *diagnostic +} + +async fn clear_protocol_mismatch(state: &LanSpreadState) -> ProtocolMismatchSnapshot { + let mut diagnostic = state.protocol_mismatch.write().await; + diagnostic.revision = diagnostic.revision.saturating_add(1); + diagnostic.mismatch = None; + *diagnostic +} + +fn local_network_sharing_watch( + state: &LanSpreadState, +) -> Result<&watch::Sender, String> { + state + .local_network_sharing + .get() + .ok_or_else(|| "Local network sharing state is not initialized".to_owned()) +} + +fn current_local_network_sharing( + state: &LanSpreadState, +) -> Result { + let snapshot = *local_network_sharing_watch(state)?.borrow(); + Ok(snapshot) +} + +#[tauri::command] +async fn get_local_network_sharing( + state: tauri::State<'_, LanSpreadState>, +) -> Result { + let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; + current_local_network_sharing(state.inner()) +} + +fn current_identity_diagnostic( + state: &LanSpreadState, +) -> Result { + let diagnostic = state + .identity_diagnostic + .get() + .ok_or_else(|| "identity diagnostic state is not initialized".to_owned())?; + let snapshot = *diagnostic.borrow(); + Ok(snapshot) +} + +#[tauri::command] +async fn get_identity_diagnostic( + state: tauri::State<'_, LanSpreadState>, +) -> Result { + let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; + current_identity_diagnostic(state.inner()) +} + +async fn mutate_local_network_sharing_snapshot( + app_handle: &AppHandle, + mutation: SharingSnapshotMutation, +) -> Result { + let (reply, result) = oneshot::channel(); + app_handle + .state::() + .inner() + .0 + .send(UiStateCommand::MutateSharing { mutation, reply }) + .map_err(|error| format!("Local network sharing state loop is unavailable: {error}"))?; + result + .await + .map_err(|error| format!("Local network sharing state reply was dropped: {error}"))? +} + +async fn publish_identity_diagnostic( + app_handle: &AppHandle, + durability: PeerIdentityDurability, +) -> Result { + let diagnostic = identity_diagnostic_for_durability(durability); + let (reply, result) = oneshot::channel(); + app_handle + .state::() + .inner() + .0 + .send(UiStateCommand::SetIdentityDiagnostic { diagnostic, reply }) + .map_err(|error| format!("identity diagnostic state loop is unavailable: {error}"))?; + result + .await + .map_err(|error| format!("identity diagnostic state reply was dropped: {error}"))? +} + +const fn identity_diagnostic_for_durability( + durability: PeerIdentityDurability, +) -> Option { + match durability { + PeerIdentityDurability::Ephemeral => Some(IdentityDiagnostic::Ephemeral), + PeerIdentityDurability::Persistent | PeerIdentityDurability::CallerProvided => None, + } +} + +async fn fence_peer_events(app_handle: &AppHandle) -> Result { + let (reply, result) = oneshot::channel(); + app_handle + .state::() + .inner() + .0 + .send(UiStateCommand::FencePeerEvents { reply }) + .map_err(|error| format!("peer-event fence loop is unavailable: {error}"))?; + result + .await + .map_err(|error| format!("peer-event fence reply was dropped: {error}"))? +} + +async fn reset_game_transfer_status_for_root( + app_handle: &AppHandle, +) -> Result { + let (reply, result) = oneshot::channel(); + app_handle + .state::() + .inner() + .0 + .send(UiStateCommand::ResetGameTransferStatus { reply }) + .map_err(|error| format!("game transfer status loop is unavailable: {error}"))?; + result + .await + .map_err(|error| format!("game transfer status reset reply was dropped: {error}"))? +} + +async fn fence_local_network_sharing_phase( + app_handle: &AppHandle, + expected: LocalNetworkSharingPhase, +) -> Result { + let snapshot = fence_peer_events(app_handle).await?; + if snapshot.phase != expected { + return Err(format!( + "Local network sharing settled as {:?}, expected {expected:?}", + snapshot.phase + )); + } + Ok(snapshot) +} + +#[tauri::command] +async fn request_call_to_play_view( state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result> { + let _app_invoke = enter_app_invoke(state.inner())?; let peer_ctrl = state.inner().peer_ctrl.read().await.clone(); let Some(peer_ctrl) = peer_ctrl else { log::warn!("Peer system not initialized yet"); @@ -287,7 +1009,7 @@ async fn request_call_to_play_events( }; if peer_ctrl - .send(PeerCommand::GetCallToPlayEvents { reply: None }) + .send(PeerCommand::GetCallToPlayView { reply: None }) .is_err() { return Ok(None); @@ -297,20 +1019,54 @@ async fn request_call_to_play_events( #[tauri::command] async fn publish_call_to_play( - event: CallToPlayEvent, + intent: CallToPlayLocalIntent, + display_name: String, state: tauri::State<'_, LanSpreadState>, -) -> Result { +) -> Result { + let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; + // The same lifecycle turn orders sharing Begin/Commit. Holding it from + // the admission check through the core reply means a publication either + // settles wholly before an off transition begins or observes the closed + // revisioned state afterward; it cannot slip between those boundaries. + let _serial_peer_lifecycle = state.app_invokes.serialize_peer_startup().await; + if !current_local_network_sharing(state.inner())?.admits_network_actions() { + return Err("Local network sharing is not enabled".to_owned()); + } let peer_ctrl = state.inner().peer_ctrl.read().await.clone(); let Some(peer_ctrl) = peer_ctrl else { log::warn!("Peer system not initialized yet"); - return Ok(false); + return Err("peer system is not initialized".to_owned()); }; let (reply, result) = oneshot::channel(); peer_ctrl - .send(PeerCommand::PublishCallToPlay { event, reply }) + .send(PeerCommand::ApplyCallToPlayIntent { + intent, + display_name: sanitize_username(&display_name), + reply, + }) .map_err(|err| err.to_string())?; - result.await.map_err(|err| err.to_string())?.map(|()| true) + result.await.map_err(|err| err.to_string())? +} + +#[tauri::command] +async fn set_call_to_play_display_name( + display_name: String, + state: tauri::State<'_, LanSpreadState>, +) -> Result { + let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; + let peer_ctrl = state.inner().peer_ctrl.read().await.clone(); + let Some(peer_ctrl) = peer_ctrl else { + return Err("peer system is not initialized".to_owned()); + }; + let (reply, result) = oneshot::channel(); + peer_ctrl + .send(PeerCommand::SetCallToPlayDisplayName { + display_name: sanitize_username(&display_name), + reply, + }) + .map_err(|error| error.to_string())?; + result.await.map_err(|error| error.to_string())? } #[tauri::command] @@ -320,6 +1076,7 @@ async fn install_game( username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; if state .inner() .active_operations @@ -348,7 +1105,7 @@ async fn install_game( let _ = (language, username); let handled = if let Some(peer_ctrl) = peer_ctrl { let command = if !downloaded { - PeerCommand::GetGame(id.clone()) + PeerCommand::DownloadGameFiles { id: id.clone() } } else if !installed { PeerCommand::InstallGame { id: id.clone() } } else { @@ -369,11 +1126,41 @@ async fn install_game( Ok(handled) } +fn catalog_supports_streamed_install(state: &LanSpreadState, id: &str) -> Result { + let catalog = state + .catalog_bundle + .get() + .cloned() + .ok_or_else(|| "catalog authority is not initialized".to_string())?; + let id = id.to_string(); + scoped_blocking(move || { + catalog + .manifest(&id) + .map(|manifest| manifest.supports_streamed_install()) + .map_err(|error| format!("failed to load catalog manifest for {id}: {error}")) + }) +} + +/// Lazily resolves Stream Install support from the selected game's exact +/// catalog manifest. The frontend calls this only while the detail modal owns +/// that game selection. +#[tauri::command] +async fn supports_streamed_install( + id: String, + state: tauri::State<'_, LanSpreadState>, +) -> Result { + let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; + catalog_supports_streamed_install(state.inner(), &id) +} + #[tauri::command] async fn stream_install_game( id: String, + language: String, + username: String, state: tauri::State<'_, LanSpreadState>, -) -> tauri::Result { +) -> Result { + let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; if state .inner() .active_operations @@ -403,6 +1190,10 @@ async fn stream_install_game( ); return Ok(false); } + if !catalog_supports_streamed_install(state.inner(), &id)? { + log::warn!("Ignoring streamed install request for unsupported game: {id}"); + return Ok(false); + } let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); @@ -410,8 +1201,10 @@ async fn stream_install_game( log::warn!("Peer system not initialized yet"); return Ok(false); }; + let settings = + StreamInstallSettings::sanitized(Some(&username), Some(&language), Some(&username)); - if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id }) { + if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id, settings }) { log::error!("Failed to send PeerCommand::StreamInstallGame: {e:?}"); return Ok(false); } @@ -426,6 +1219,7 @@ async fn update_game( username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; if state .inner() .active_operations @@ -443,7 +1237,7 @@ async fn update_game( let _ = (language, username); if let Some(peer_ctrl) = peer_ctrl { - if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) { + if let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles { id: id.clone() }) { log::error!("Failed to send message to peer: {e:?}"); return Ok(false); } @@ -459,6 +1253,7 @@ async fn uninstall_game( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; if state .inner() .active_operations @@ -489,6 +1284,7 @@ async fn remove_downloaded_game( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; if state .inner() .active_operations @@ -537,6 +1333,7 @@ async fn cancel_download( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; let is_active_download = { let active_operations = state.inner().active_operations.read().await; matches!( @@ -569,6 +1366,7 @@ async fn open_game_files( app_handle: AppHandle, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; let Some(target) = resolve_game_root_for_open(&id, &state).await else { return Ok(false); }; @@ -716,6 +1514,7 @@ fn script_params_with_mode( #[tauri::command] async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); @@ -735,9 +1534,11 @@ async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Resul async fn get_game_thumbnail( game_id: String, app_handle: tauri::AppHandle, + state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { use base64::Engine; + let _app_invoke = enter_app_invoke(state.inner())?; let resource_path = app_handle.path().resolve( format!("assets/{game_id}.jpg"), tauri::path::BaseDirectory::Resource, @@ -745,13 +1546,13 @@ async fn get_game_thumbnail( dbg!(&resource_path); - let image_data = std::fs::read(&resource_path)?; + let image_data = scoped_blocking(|| std::fs::read(&resource_path))?; let base64_data = base64::engine::general_purpose::STANDARD.encode(&image_data); Ok(format!("data:image/jpeg;base64,{base64_data}")) } #[cfg(target_os = "windows")] -fn run_as_admin( +fn run_as_admin_detached( file: &str, params: &str, dir: &str, @@ -780,6 +1581,288 @@ fn run_as_admin( (result.0 as usize) > 32 // Success if greater than 32 } +#[cfg(any(test, target_os = "windows"))] +fn setup_process_exit_succeeded(exit_code: u32) -> bool { + exit_code == 0 +} + +#[derive(Debug, PartialEq, Eq)] +#[cfg(any(test, target_os = "windows"))] +enum SetupLaunchOutcome { + Owned(Process), + SettledWithoutProcess, + ContractViolation, +} + +/// Converts the documented `ShellExecuteExW` postcondition into an explicit +/// ownership boundary. With `SEE_MASK_NOCLOSEPROCESS`, a null `hProcess` means +/// that no process was launched; every returned process handle must instead be +/// moved immediately into the caller's process owner. A non-null invalid value +/// violates that Win32 contract and is kept out of the owner entirely. +#[cfg(any(test, target_os = "windows"))] +fn setup_launch_outcome( + process: Process, + process_is_null: bool, + process_is_invalid: bool, +) -> SetupLaunchOutcome { + if process_is_null { + SetupLaunchOutcome::SettledWithoutProcess + } else if process_is_invalid { + SetupLaunchOutcome::ContractViolation + } else { + SetupLaunchOutcome::Owned(process) + } +} + +#[derive(Debug, PartialEq, Eq)] +#[cfg(any(test, target_os = "windows"))] +enum SetupProcessObservation { + Settled, + SettledWithError(String), + NotSettled(String), +} + +/// Drives an owned setup process to a proven settled state after a lifecycle +/// error. This function deliberately has no fallible early return: termination +/// and observation failures keep the caller inside the ownership boundary. +#[cfg(any(test, target_os = "windows"))] +fn settle_setup_after_wait_error( + initial_error: String, + mut terminate: Terminate, + mut observe: Observe, + mut retry: Retry, +) -> String +where + Terminate: FnMut() -> Result<(), String>, + Observe: FnMut() -> SetupProcessObservation, + Retry: FnMut(), +{ + let mut attempts = 0_u64; + let mut first_termination_error = None; + let mut first_observation_error = None; + + loop { + attempts = attempts.saturating_add(1); + if let Err(error) = terminate() + && first_termination_error.is_none() + { + first_termination_error = Some(error); + } + + match observe() { + SetupProcessObservation::Settled => break, + SetupProcessObservation::SettledWithError(error) => { + if first_observation_error.is_none() { + first_observation_error = Some(error); + } + break; + } + SetupProcessObservation::NotSettled(error) => { + if first_observation_error.is_none() { + first_observation_error = Some(error); + } + retry(); + } + } + } + + let termination = first_termination_error.map_or_else( + || "termination attempts succeeded".to_string(), + |error| format!("a termination attempt failed ({error})"), + ); + let observation = first_observation_error.map_or_else( + || "settlement observation succeeded".to_string(), + |error| format!("a settlement observation failed ({error})"), + ); + format!( + "{initial_error}; process settlement required {attempts} attempt(s); {termination}; \ + {observation}; elevated setup is now settled" + ) +} + +#[cfg(target_os = "windows")] +fn run_as_admin_and_wait( + file: &str, + params: &str, + dir: &str, + show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD, +) -> Result<(), String> { + use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; + + use windows::{ + Win32::{ + Foundation::{CloseHandle, HANDLE, WAIT_EVENT, WAIT_FAILED, WAIT_OBJECT_0}, + System::Threading::{ + GetExitCodeProcess, + INFINITE, + TerminateProcess, + WaitForSingleObject, + }, + UI::Shell::{ + SEE_MASK_NOASYNC, + SEE_MASK_NOCLOSEPROCESS, + SHELLEXECUTEINFOW, + ShellExecuteExW, + }, + }, + core::PCWSTR, + }; + + const STILL_ACTIVE_EXIT_CODE: u32 = 259; + + fn wait_failure_message(wait_result: WAIT_EVENT) -> String { + if wait_result == WAIT_FAILED { + let error = windows::core::Error::from_win32(); + format!("WaitForSingleObject failed: {error}") + } else { + format!("WaitForSingleObject returned unexpected status {wait_result:?}") + } + } + + fn observe_process_settlement(handle: HANDLE) -> SetupProcessObservation { + let wait_result = unsafe { WaitForSingleObject(handle, INFINITE) }; + if wait_result == WAIT_OBJECT_0 { + return SetupProcessObservation::Settled; + } + let wait_error = wait_failure_message(wait_result); + + let mut exit_code = STILL_ACTIVE_EXIT_CODE; + match unsafe { GetExitCodeProcess(handle, &raw mut exit_code) } { + Ok(()) if exit_code != STILL_ACTIVE_EXIT_CODE => { + SetupProcessObservation::SettledWithError(format!( + "{wait_error}; exit-status query proved termination with status {exit_code}" + )) + } + Ok(()) => SetupProcessObservation::NotSettled(format!( + "{wait_error}; exit-status query still reports an active process" + )), + Err(error) => SetupProcessObservation::NotSettled(format!( + "{wait_error}; exit-status query also failed: {error}" + )), + } + } + + struct OwnedProcessHandle { + handle: HANDLE, + settled: bool, + } + + impl OwnedProcessHandle { + fn new(handle: HANDLE) -> Self { + Self { + handle, + settled: false, + } + } + + fn force_settle_after_error(&mut self, initial_error: String) -> String { + let handle = self.handle; + let report = settle_setup_after_wait_error( + initial_error, + || { + unsafe { TerminateProcess(handle, 1) } + .map_err(|error| format!("failed to terminate elevated setup: {error}")) + }, + || observe_process_settlement(handle), + || std::thread::sleep(Duration::from_millis(10)), + ); + self.settled = true; + report + } + + fn wait_for_exit(&mut self) -> Result { + let wait_result = unsafe { WaitForSingleObject(self.handle, INFINITE) }; + if wait_result != WAIT_OBJECT_0 { + let initial_error = wait_failure_message(wait_result); + return Err(self.force_settle_after_error(initial_error)); + } + self.settled = true; + + let mut exit_code = 0; + unsafe { GetExitCodeProcess(self.handle, &raw mut exit_code) } + .map_err(|error| format!("failed to read elevated setup exit status: {error}"))?; + Ok(exit_code) + } + } + + impl Drop for OwnedProcessHandle { + fn drop(&mut self) { + if !self.settled { + let report = self.force_settle_after_error( + "elevated setup handle reached Drop before process settlement".to_string(), + ); + log::error!("{report}"); + } + if let Err(err) = unsafe { CloseHandle(self.handle) } { + log::warn!("Failed to close elevated setup process handle: {err}"); + } + } + } + + let file_wide = OsStr::new(file) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let params_wide = OsStr::new(params) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let dir_wide = OsStr::new(dir) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let runas_wide = OsStr::new("runas") + .encode_wide() + .chain(Some(0)) + .collect::>(); + let mut execute_info = SHELLEXECUTEINFOW { + cbSize: u32::try_from(std::mem::size_of::()) + .map_err(|err| format!("invalid ShellExecuteExW structure size: {err}"))?, + // This invoke runs on a scoped blocking worker without a message loop. + // `NOASYNC` keeps shell activation inside this call; `NOCLOSEPROCESS` + // makes any newly launched process ours to join and close. + fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC, + lpVerb: PCWSTR::from_raw(runas_wide.as_ptr()), + lpFile: PCWSTR::from_raw(file_wide.as_ptr()), + lpParameters: PCWSTR::from_raw(params_wide.as_ptr()), + lpDirectory: PCWSTR::from_raw(dir_wide.as_ptr()), + nShow: show_cmd.0, + ..Default::default() + }; + unsafe { ShellExecuteExW(&raw mut execute_info) } + .map_err(|err| format!("failed to launch elevated setup: {err}"))?; + // Do not use `HANDLE::is_invalid` here: it folds null and -1 together, + // while ShellExecuteExW documents null specifically as proof that no + // process was launched. Without `SEE_MASK_INVOKEIDLIST`, every documented + // non-null, valid result is the newly launched process handle and is owned + // here. INVALID_HANDLE_VALUE is outside that API contract and must never + // reach the owner, whose Drop implementation requires a waitable handle. + let process_handle = execute_info.hProcess; + let mut process = match setup_launch_outcome( + process_handle, + process_handle.0.is_null(), + process_handle.is_invalid(), + ) { + SetupLaunchOutcome::Owned(handle) => OwnedProcessHandle::new(handle), + SetupLaunchOutcome::SettledWithoutProcess => { + return Err("elevated setup completed without launching a process".to_string()); + } + SetupLaunchOutcome::ContractViolation => { + return Err( + "ShellExecuteExW succeeded but returned INVALID_HANDLE_VALUE; no process handle \ + can be owned" + .to_string(), + ); + } + }; + let exit_code = process.wait_for_exit()?; + if !setup_process_exit_succeeded(exit_code) { + return Err(format!("elevated setup exited with status {exit_code}")); + } + + Ok(()) +} + #[cfg(target_os = "windows")] async fn run_game_windows( id: String, @@ -824,15 +1907,17 @@ async fn run_game_windows( return Ok(()); } - let result = run_as_admin( - "cmd.exe", - &script_params(&game_setup_bin, &id, &settings), - &game_path.display().to_string(), - windows::Win32::UI::WindowsAndMessaging::SW_HIDE, - ); - - if !result { - log::error!("failed to run {GAME_SETUP_SCRIPT}"); + let setup_params = script_params(&game_setup_bin, &id, &settings); + let game_dir = game_path.display().to_string(); + if let Err(err) = scoped_blocking(|| { + run_as_admin_and_wait( + "cmd.exe", + &setup_params, + &game_dir, + windows::Win32::UI::WindowsAndMessaging::SW_HIDE, + ) + }) { + log::error!("failed to complete {GAME_SETUP_SCRIPT}: {err}"); return Ok(()); } @@ -853,10 +1938,12 @@ async fn run_game_windows( } } - apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await; + apply_launch_settings(&state_dir, &game_path, &id, &language, &username); if game_start_bin.exists() { - let result = run_as_admin( + // Game processes are intentionally user-owned: unlike setup, their + // lifetime is not an install transaction or a launcher state boundary. + let result = run_as_admin_detached( "cmd.exe", &script_params(&game_start_bin, &id, &settings), &game_path.display().to_string(), @@ -875,7 +1962,7 @@ async fn run_game_windows( /// files the first time it is played. Uses the same processed values the install /// transaction used to write before this step moved to play time. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -async fn apply_launch_settings( +fn apply_launch_settings( state_dir: &Path, game_path: &Path, id: &str, @@ -889,9 +1976,7 @@ async fn apply_launch_settings( id, Some(&settings.account_name), Some(&settings.language), - ) - .await - { + ) { Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"), Err(e) => log::error!("failed to apply launch settings for {id}: {e}"), } @@ -904,6 +1989,7 @@ async fn run_game( username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result<()> { + let _app_invoke = enter_app_invoke(state.inner())?; #[cfg(target_os = "windows")] { run_game_windows(id, language, username, state).await?; @@ -960,9 +2046,10 @@ async fn start_server_windows( log::error!("app state directory is not initialized; cannot start server"); return Ok(false); }; - apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await; + apply_launch_settings(&state_dir, &game_path, &id, &language, &username); - let result = run_as_admin( + // Hosted servers are intentionally user-owned and may outlive the launcher. + let result = run_as_admin_detached( "cmd.exe", &server_script_params(&server_start_bin, &id, &settings), &game_path.display().to_string(), @@ -983,6 +2070,7 @@ async fn start_server( username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; #[cfg(target_os = "windows")] { start_server_windows(id, language, username, state).await @@ -1045,19 +2133,39 @@ fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) { } } -fn apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec) { - // Peer events update availability, but catalog metadata stays anchored to game.db. +fn apply_peer_remote_view( + game_db: &mut GameDB, + remote_view: &RemoteLibraryView, + catalog_bundle: &CatalogBundle, +) { + // Remote state supplies only exact content identity and counts. All display + // metadata stays anchored to the bundled game.db. for game in game_db.games.values_mut() { game.peer_count = 0; } - for peer_game in peer_games { - if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) { - existing.peer_count = peer_game.peer_count; + for availability in &remote_view.games { + let Some(identity) = catalog_bundle.content_identity(&availability.game_id) else { + log::debug!( + "Ignoring availability for unknown catalog game {}", + availability.game_id + ); + continue; + }; + if identity.content_id != availability.content_id { + log::debug!( + "Ignoring non-catalog content {} ({})", + availability.game_id, + availability.content_id + ); + continue; + } + if let Some(existing) = game_db.get_mut_game_by_id(&availability.game_id) { + existing.peer_count = availability.peer_count; } else { log::debug!( "Peer advertised unknown game {id}; ignoring because game.db is ground truth", - id = peer_game.id + id = availability.game_id ); } } @@ -1069,21 +2177,83 @@ fn clear_all_local_game_states(game_db: &mut GameDB) { } } -async fn emit_games_list(app_handle: &AppHandle) { - let state = app_handle.state::(); +fn emit_game_transfer_status_snapshot( + app_handle: &AppHandle, + snapshot: &GameTransferStatusSnapshot, +) { + if let Err(error) = app_handle.emit("game-transfer-status-updated", Some(snapshot.clone())) { + log::error!("Failed to emit game-transfer-status-updated event: {error}"); + } +} - let installed_peer_counts = state - .peer_game_db +async fn mutate_catalog_game_transfer_status( + app_handle: &AppHandle, + game_id: &str, + mutation: F, +) -> Result, String> +where + F: FnOnce(&mut GameTransferStatusStore) -> Result, String>, +{ + let state = app_handle.state::(); + let Some(catalog_bundle) = state.catalog_bundle.get() else { + return Err("bundled catalog authority is not initialized".to_owned()); + }; + if !catalog_bundle.catalog().contains(game_id) { + log::warn!("Ignoring transfer status for unknown catalog game {game_id}"); + return Ok(None); + } + + let snapshot = { + let mut store = state.game_transfer_status.write().await; + mutation(&mut store)? + }; + if let Some(snapshot) = &snapshot { + emit_game_transfer_status_snapshot(app_handle, snapshot); + } + Ok(snapshot) +} + +async fn accepts_game_transfer_progress( + app_handle: &AppHandle, + progress: &DownloadProgress, +) -> bool { + let state = app_handle.state::(); + let Some(catalog_bundle) = state.catalog_bundle.get() else { + log::error!("Ignoring download progress before catalog authority initialization"); + return false; + }; + if !catalog_bundle.catalog().contains(&progress.attempt.id) { + log::warn!( + "Ignoring download progress for unknown catalog game {}", + progress.attempt.id + ); + return false; + } + state + .game_transfer_status .read() .await - .peer_snapshots() - .into_iter() - .flat_map(|peer| peer.games) - .filter(|game| game.installed) - .fold(HashMap::::new(), |mut counts, game| { - *counts.entry(game.id).or_default() += 1; - counts - }); + .accepts_progress(progress) +} + +async fn clear_settled_game_transfer_statuses_for_local_generation( + app_handle: &AppHandle, +) -> Result { + let state = app_handle.state::(); + let (changed, current) = { + let mut store = state.game_transfer_status.write().await; + let changed = store.clear_settled_for_local_generation()?; + let current = changed.clone().unwrap_or_else(|| store.snapshot()); + (changed, current) + }; + if let Some(snapshot) = &changed { + emit_game_transfer_status_snapshot(app_handle, snapshot); + } + Ok(current) +} + +async fn emit_games_list(app_handle: &AppHandle) { + let state = app_handle.state::(); let games_db_lock = state.games.clone(); let game_db = games_db_lock.read().await; @@ -1105,7 +2275,7 @@ async fn emit_games_list(app_handle: &AppHandle) { LauncherGame { can_host_server: game_can_host_server(&games_folder, &game), active_outbound_transfers, - installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0), + installed_peer_count: game.peer_count, game, } }) @@ -1118,10 +2288,12 @@ async fn emit_games_list(app_handle: &AppHandle) { let active_operations = state.active_operations.read().await; ui_active_operations_from_map(&active_operations) }; + let transfer_status = state.game_transfer_status.read().await.snapshot(); let payload = GamesListPayload { games: games_to_emit, active_operations, + transfer_status, }; if let Err(e) = app_handle.emit("games-list-updated", Some(payload)) { @@ -1180,21 +2352,592 @@ fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind { } #[tauri::command] -fn game_directory_exists(path: String) -> bool { - PathBuf::from(path).is_dir() +async fn game_directory_exists( + path: String, + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { + let _app_invoke = enter_app_invoke(state.inner())?; + Ok(scoped_blocking(|| PathBuf::from(path).is_dir())) +} + +fn persist_local_network_sharing_policy( + state: &LanSpreadState, + enabled: bool, +) -> Result<(), String> { + let state_dir = state + .state_dir + .get() + .ok_or_else(|| "app state directory is not initialized".to_owned())?; + let policy_path = state_dir.join(sharing_policy::POLICY_FILE_NAME); + scoped_blocking(|| sharing_policy::save(&policy_path, enabled)) + .map_err(|error| error.to_string()) +} + +#[derive(Clone, Copy)] +struct NetworkAdmissionClosed; + +struct DisabledRuntimeTransition { + command_result: Result, + persistence: Result<(), String>, + force_stopped: bool, +} + +fn network_admission_closed_after( + snapshot: LocalNetworkSharingSnapshot, + baseline_revision: u64, +) -> Option { + (snapshot.revision > baseline_revision + && matches!( + snapshot.phase, + LocalNetworkSharingPhase::Disabling | LocalNetworkSharingPhase::Disabled + )) + .then_some(NetworkAdmissionClosed) +} + +fn after_network_admission_closed( + _closed: NetworkAdmissionClosed, + operation: impl FnOnce() -> R, +) -> R { + operation() +} + +fn persist_disabled_runtime_policy( + state: &LanSpreadState, + closed: NetworkAdmissionClosed, +) -> Result<(), String> { + after_network_admission_closed(closed, || { + persist_local_network_sharing_policy(state, false) + }) +} + +async fn disable_runtime_then_persist( + baseline_revision: u64, + mut changes: watch::Receiver, + command: C, + force_stop: S, + persist: P, +) -> DisabledRuntimeTransition +where + C: std::future::Future>, + S: FnOnce() -> SF, + SF: std::future::Future, + P: FnOnce(NetworkAdmissionClosed) -> Result<(), String>, +{ + tokio::pin!(command); + let mut command_result = None; + let admission_closed = loop { + tokio::select! { + result = &mut command => { + let closed = matches!(&result, Ok(false)); + command_result = Some(result); + break closed; + } + watch_update = changes.changed() => { + if watch_update.is_err() { + break false; + } + let snapshot = *changes.borrow_and_update(); + if network_admission_closed_after(snapshot, baseline_revision).is_some() { + break true; + } + } + } + }; + + let force_stopped = !admission_closed; + if force_stopped { + force_stop().await; + } + + // Either the current core generation published its synchronous admission + // closure or the complete runtime has now stopped. This helper is the only + // production path that begins disabled-policy I/O for a live-runtime + // transition, which keeps that privacy ordering directly testable. + let persistence = persist(NetworkAdmissionClosed); + let command_result = match command_result { + Some(result) => result, + None => command.await, + }; + DisabledRuntimeTransition { + command_result, + persistence, + force_stopped, + } +} + +async fn set_core_local_network_sharing( + peer_ctrl: &UnboundedSender, + enabled: bool, +) -> Result { + let (reply, result) = oneshot::channel(); + peer_ctrl + .send(PeerCommand::SetLocalNetworkSharing { enabled, reply }) + .map_err(|error| format!("failed to send Local network sharing command: {error}"))?; + result + .await + .map_err(|error| format!("Local network sharing reply was dropped: {error}"))? +} + +async fn force_stop_peer_runtime(state: &LanSpreadState) { + *state.peer_ctrl.write().await = None; + let handle = { state.peer_runtime.write().await.take() }; + if let Some(mut handle) = handle { + handle.shutdown(); + handle.wait_stopped().await; + } +} + +async fn force_stop_peer_runtime_and_fence_disabled(app_handle: &AppHandle) -> Result<(), String> { + let state = app_handle.state::(); + force_stop_peer_runtime(state.inner()).await; + fence_local_network_sharing_phase(app_handle, LocalNetworkSharingPhase::Disabled).await?; + Ok(()) +} + +async fn finish_sharing_snapshot( + app_handle: &AppHandle, + enabled: bool, + phase: Option, + persistence_problem: Option, +) -> Result { + mutate_local_network_sharing_snapshot( + app_handle, + SharingSnapshotMutation::Commit { + enabled, + phase, + persistence_problem, + }, + ) + .await +} + +async fn set_local_network_sharing_without_runtime( + app_handle: &AppHandle, + app_invoke: &AppInvokeGuard, + requested: bool, + before: LocalNetworkSharingSnapshot, +) -> Result { + let state = app_handle.state::(); + let expected_phase = if requested { + LocalNetworkSharingPhase::WaitingForGameDirectory + } else { + LocalNetworkSharingPhase::Disabled + }; + if before.enabled == requested + && before.pending_target.is_none() + && before.phase == expected_phase + && before.persistence_problem.is_none() + { + return Ok(before); + } + + mutate_local_network_sharing_snapshot( + app_handle, + SharingSnapshotMutation::Begin { target: requested }, + ) + .await?; + if requested && !state.games_folder.read().await.is_empty() { + return restart_enabled_peer_from_retained_game_directory(app_handle, app_invoke).await; + } + + let persistence = persist_local_network_sharing_policy(state.inner(), requested); + match (requested, persistence) { + (true, Ok(())) => { + finish_sharing_snapshot(app_handle, true, Some(expected_phase), None).await + } + (false, Ok(())) => { + finish_sharing_snapshot(app_handle, false, Some(expected_phase), None).await + } + (true, Err(error)) => { + log::error!("Failed to save enabled Local network sharing policy: {error}"); + // The atomic replacement may have completed before a later + // directory-sync error. Repair false so an unacknowledged opt-in + // cannot silently become enabled on the next launch. + let repair_problem = persist_local_network_sharing_policy(state.inner(), false) + .err() + .map(|repair_error| { + log::error!( + "Failed to repair disabled Local network sharing policy: {repair_error}" + ); + SharingPersistenceProblem::Save + }); + finish_sharing_snapshot( + app_handle, + false, + Some(LocalNetworkSharingPhase::Disabled), + repair_problem, + ) + .await?; + Err("Local network sharing setting could not be saved".to_owned()) + } + (false, Err(error)) => { + log::error!("Failed to save disabled Local network sharing policy: {error}"); + // Privacy is fail-closed for this process even when the disk update + // cannot be proven. A later game-directory restore therefore starts + // the local-only core; the UI explicitly warns that restart state is + // uncertain. + finish_sharing_snapshot( + app_handle, + false, + Some(LocalNetworkSharingPhase::Disabled), + Some(SharingPersistenceProblem::Save), + ) + .await?; + Err("Local network sharing setting could not be saved".to_owned()) + } + } +} + +async fn restart_enabled_peer_from_retained_game_directory( + app_handle: &AppHandle, + app_invoke: &AppInvokeGuard, +) -> Result { + let state = app_handle.state::(); + let retained = state.games_folder.read().await.clone(); + if let Err(error) = + ensure_peer_started(app_handle, Path::new(&retained), app_invoke, false).await + { + return compensate_failed_enabled_restart(app_handle, error).await; + } + let peer_ctrl = state.peer_ctrl.read().await.clone(); + let Some(peer_ctrl) = peer_ctrl else { + return compensate_failed_enabled_restart( + app_handle, + "peer restart did not publish its control channel".to_owned(), + ) + .await; + }; + let enable_result = set_core_local_network_sharing(&peer_ctrl, true).await; + let fenced = fence_peer_events(app_handle).await; + match (&enable_result, &fenced) { + (Ok(true), Ok(snapshot)) if snapshot.phase == LocalNetworkSharingPhase::Enabled => { + match persist_local_network_sharing_policy(state.inner(), true) { + Ok(()) => { + // The peer-event loop owns effective phase. In particular, + // an automatic Disabled consumed while the save blocks must + // not be resurrected by this policy commit. + return finish_sharing_snapshot(app_handle, true, None, None).await; + } + Err(error) => { + log::error!( + "Failed to save enabled Local network sharing policy after restart: {error}" + ); + return compensate_failed_enabled_restart( + app_handle, + "Local network sharing setting could not be saved".to_owned(), + ) + .await; + } + } + } + (Ok(true), Ok(snapshot)) => { + log::error!( + "Restarted peer acknowledged enabled but settled as {:?}", + snapshot.phase + ); + } + (Ok(false), _) => log::error!("Restarted peer remained disabled after enable request"), + (Err(error), _) => { + log::error!("Restarted peer could not enable Local network sharing: {error}"); + } + (_, Err(error)) => log::error!("Restarted peer could not fence enable events: {error}"), + } + + compensate_failed_enabled_restart( + app_handle, + "Local network sharing could not be enabled".to_owned(), + ) + .await +} + +async fn compensate_failed_enabled_restart( + app_handle: &AppHandle, + cause: String, +) -> Result { + let state = app_handle.state::(); + force_stop_peer_runtime(state.inner()).await; + if let Err(error) = fence_peer_events(app_handle).await { + log::error!("Failed to fence peer events after restart compensation: {error}"); + } + let repair_problem = persist_local_network_sharing_policy(state.inner(), false) + .err() + .map(|error| { + log::error!("Failed to repair disabled sharing policy after restart: {error}"); + SharingPersistenceProblem::Save + }); + finish_sharing_snapshot( + app_handle, + false, + Some(LocalNetworkSharingPhase::Disabled), + repair_problem, + ) + .await?; + Err(cause) +} + +#[allow(clippy::too_many_lines)] +async fn set_local_network_sharing_with_runtime( + app_handle: &AppHandle, + peer_ctrl: UnboundedSender, + requested: bool, + _before: LocalNetworkSharingSnapshot, +) -> Result { + let state = app_handle.state::(); + // Remove every lifecycle event queued before this serialized transition. + // Begin then provides a clean revision boundary for observing the current + // command's admission-closing Disabling event. + let before = fence_peer_events(app_handle).await?; + if before.enabled == requested + && before.is_stable_at(requested) + && before.persistence_problem.is_none() + { + // Core intentionally emits no duplicate lifecycle event for an + // idempotent Set. Returning the already stable snapshot avoids waiting + // forever for a revision that cannot exist. + return Ok(before); + } + + let begun = mutate_local_network_sharing_snapshot( + app_handle, + SharingSnapshotMutation::Begin { target: requested }, + ) + .await?; + let effective_already_stable = before.is_stable_at(requested); + + if !requested { + if !effective_already_stable { + let transition = disable_runtime_then_persist( + begun.revision, + local_network_sharing_watch(state.inner())?.subscribe(), + set_core_local_network_sharing(&peer_ctrl, false), + || force_stop_peer_runtime(state.inner()), + |closed| persist_disabled_runtime_policy(state.inner(), closed), + ) + .await; + let force_stopped = transition.force_stopped; + let persistence = transition.persistence; + let result = transition.command_result; + let mut fenced = fence_peer_events(app_handle).await?; + if !matches!(&result, Ok(false)) || fenced.phase != LocalNetworkSharingPhase::Disabled { + match &result { + Ok(true) => log::error!("Peer acknowledged enabled after a disable request"), + Ok(false) => log::error!( + "Peer acknowledged disabled but settled as {:?}", + fenced.phase + ), + Err(error) => { + log::error!("Failed to disable Local network sharing cleanly: {error}"); + } + } + if !force_stopped { + force_stop_peer_runtime(state.inner()).await; + } + fenced = fence_peer_events(app_handle).await?; + if fenced.phase != LocalNetworkSharingPhase::Disabled { + return Err(format!( + "Local network sharing stopped but settled as {:?}", + fenced.phase + )); + } + } + + let problem = persistence + .as_ref() + .err() + .map(|_| SharingPersistenceProblem::Save); + if let Err(error) = &persistence { + log::error!("Failed to save disabled Local network sharing policy: {error}"); + } + let snapshot = finish_sharing_snapshot( + app_handle, + false, + Some(LocalNetworkSharingPhase::Disabled), + problem, + ) + .await?; + return persistence.map(|()| snapshot).map_err(|_| { + "Local network sharing is off, but its setting could not be saved".to_owned() + }); + } + + let persistence = persist_disabled_runtime_policy(state.inner(), NetworkAdmissionClosed); + let problem = persistence + .as_ref() + .err() + .map(|_| SharingPersistenceProblem::Save); + if let Err(error) = &persistence { + log::error!("Failed to save disabled Local network sharing policy: {error}"); + } + let snapshot = finish_sharing_snapshot( + app_handle, + false, + Some(LocalNetworkSharingPhase::Disabled), + problem, + ) + .await?; + return persistence.map(|()| snapshot).map_err(|_| { + "Local network sharing is off, but its setting could not be saved".to_owned() + }); + } + + if !effective_already_stable { + let enable_result = set_core_local_network_sharing(&peer_ctrl, true).await; + let fenced = fence_peer_events(app_handle).await?; + match enable_result { + Ok(true) if fenced.phase == LocalNetworkSharingPhase::Enabled => {} + Ok(true) => { + log::error!( + "Peer acknowledged enabled but settled as {:?}", + fenced.phase + ); + force_stop_peer_runtime_and_fence_disabled(app_handle).await?; + finish_sharing_snapshot( + app_handle, + before.enabled, + Some(LocalNetworkSharingPhase::Disabled), + before.persistence_problem, + ) + .await?; + return Err("Local network sharing could not be enabled".to_owned()); + } + Ok(false) => { + log::error!("Peer acknowledged disabled after an enable request"); + if fenced.phase != LocalNetworkSharingPhase::Disabled { + force_stop_peer_runtime_and_fence_disabled(app_handle).await?; + } + finish_sharing_snapshot( + app_handle, + before.enabled, + Some(LocalNetworkSharingPhase::Disabled), + before.persistence_problem, + ) + .await?; + return Err("Local network sharing could not be enabled".to_owned()); + } + Err(error) => { + log::error!("Failed to enable Local network sharing: {error}"); + if fenced.phase != LocalNetworkSharingPhase::Disabled { + force_stop_peer_runtime_and_fence_disabled(app_handle).await?; + } + finish_sharing_snapshot( + app_handle, + before.enabled, + Some(LocalNetworkSharingPhase::Disabled), + before.persistence_problem, + ) + .await?; + return Err("Local network sharing could not be enabled".to_owned()); + } + } + } + + match persist_local_network_sharing_policy(state.inner(), true) { + Ok(()) => finish_sharing_snapshot(app_handle, true, None, None).await, + Err(error) => { + log::error!("Failed to save enabled Local network sharing policy: {error}"); + let disable_result = set_core_local_network_sharing(&peer_ctrl, false).await; + let fenced = fence_peer_events(app_handle).await?; + match disable_result { + Ok(false) if fenced.phase == LocalNetworkSharingPhase::Disabled => {} + Ok(false) => { + log::error!( + "Peer acknowledged compensation but settled as {:?}", + fenced.phase + ); + force_stop_peer_runtime_and_fence_disabled(app_handle).await?; + } + Ok(true) => { + log::error!("Peer stayed enabled during persistence compensation"); + force_stop_peer_runtime_and_fence_disabled(app_handle).await?; + } + Err(disable_error) => { + log::error!( + "Failed to compensate enabled Local network sharing: {disable_error}" + ); + force_stop_peer_runtime_and_fence_disabled(app_handle).await?; + } + } + // Publication can become durable before a later directory-sync + // error. Repair false after the core is stopped rather than + // assuming the failed write preserved the previous bytes. + let repair_problem = persist_local_network_sharing_policy(state.inner(), false) + .err() + .map(|repair_error| { + log::error!( + "Failed to repair disabled Local network sharing policy: {repair_error}" + ); + SharingPersistenceProblem::Save + }); + finish_sharing_snapshot( + app_handle, + false, + Some(LocalNetworkSharingPhase::Disabled), + repair_problem, + ) + .await?; + Err("Local network sharing setting could not be saved".to_owned()) + } + } } #[tauri::command] -async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> { +async fn set_local_network_sharing( + app_handle: tauri::AppHandle, + enabled: bool, +) -> Result { + let state = app_handle.state::(); + let app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; + let _serial_peer_lifecycle = state.app_invokes.serialize_peer_startup().await; + let before = current_local_network_sharing(state.inner())?; + let peer_ctrl = state.peer_ctrl.read().await.clone(); + match peer_ctrl { + Some(peer_ctrl) => { + set_local_network_sharing_with_runtime(&app_handle, peer_ctrl, enabled, before).await + } + None => { + set_local_network_sharing_without_runtime(&app_handle, &app_invoke, enabled, before) + .await + } + } +} + +#[tauri::command] +async fn update_game_directory( + app_handle: tauri::AppHandle, + path: String, +) -> Result { + let state = app_handle.state::(); + let app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; + let _serial_startup_invoke = state.app_invokes.serialize_peer_startup().await; log::info!("update_game_directory: {path}"); - let games_folder = PathBuf::from(&path); - if !games_folder.is_dir() { - log::error!("game dir {} does not exist", games_folder.display()); - return Ok(()); + let requested_games_folder = PathBuf::from(&path); + let games_folder = + scoped_blocking(|| requested_games_folder.canonicalize()).map_err(|err| { + let error = format!( + "game directory {} is unavailable: {err}", + requested_games_folder.display() + ); + log::error!("{error}"); + error + })?; + if !scoped_blocking(|| games_folder.is_dir()) { + let error = format!( + "game directory {} is not a directory", + games_folder.display() + ); + log::error!("{error}"); + return Err(error); } + let Some(requested_canonical_path) = games_folder.to_str() else { + let error = format!( + "game directory {} cannot be represented as UTF-8", + games_folder.display() + ); + log::error!("{error}"); + return Err(error); + }; - let state = app_handle.state::(); let current_path = state.games_folder.read().await.clone(); let active_ids = state .active_operations @@ -1203,19 +2946,21 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta .keys() .cloned() .collect::>(); - if current_path != path && !active_ids.is_empty() { - log::warn!( + if current_path != requested_canonical_path && !active_ids.is_empty() { + let error = format!( "Rejecting game directory change to {} while UI operations are active for: {}", games_folder.display(), active_ids.join(", ") ); - return Ok(()); + log::warn!("{error}"); + return Err(error); } - let path_changed = current_path != path; + let path_changed = current_path != requested_canonical_path; let Some(state_dir) = state.state_dir.get().cloned() else { - log::error!("app state directory is not initialized; cannot update game directory"); - return Ok(()); + let error = "app state directory is not initialized; cannot update game directory"; + log::error!("{error}"); + return Err(error.to_string()); }; if path_changed || state.peer_ctrl.read().await.is_none() { @@ -1228,30 +2973,48 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta } } - *state.games_folder.write().await = path; + let initial_local_network_sharing = current_local_network_sharing(state.inner())?.enabled; + let accepted_games_folder = ensure_peer_started( + &app_handle, + &games_folder, + &app_invoke, + initial_local_network_sharing, + ) + .await?; + let accepted_path = accepted_games_folder.to_string_lossy().into_owned(); + let accepted_path_changed = current_path != accepted_path; - ensure_bundled_game_db_loaded(&app_handle).await; - if path_changed { + // The peer acknowledgement is the commit point for UI state. A rejected + // root leaves both the previous path and its local-game flags untouched. + if accepted_path_changed { + reset_game_transfer_status_for_root(&app_handle).await?; + } + *state.games_folder.write().await = accepted_path.clone(); + if accepted_path_changed { let mut game_db = state.games.write().await; clear_all_local_game_states(&mut game_db); } emit_games_list(&app_handle).await; - ensure_peer_started(&app_handle, &games_folder).await; - - Ok(()) -} - -async fn update_game_db(games: Vec, app: AppHandle) { - for game in &games { - log::trace!("peer event ListGames iter: {game:?}"); + if let Some(peer_ctrl) = state.peer_ctrl.read().await.as_ref() + && let Err(error) = peer_ctrl.send(PeerCommand::ListGames) + { + log::error!("Failed to request post-commit game list: {error}"); } + Ok(accepted_path) +} + +async fn update_remote_library_view(view: RemoteLibraryView, app: AppHandle) { let state = app.state::(); + let Some(catalog_bundle) = state.catalog_bundle.get() else { + log::error!("Ignoring remote library view before catalog authority initialization"); + return; + }; { let mut game_db = state.games.write().await; - apply_peer_remote_games(&mut game_db, games); + apply_peer_remote_view(&mut game_db, &view, catalog_bundle); } emit_games_list(&app).await; @@ -1260,6 +3023,14 @@ async fn update_game_db(games: Vec, app: AppHandle) { async fn update_local_games_in_db(local_games: Vec, app: AppHandle) { let state = app.state::(); + // Clear the prior settled diagnostic before publishing any part of the new + // local generation. A concurrent debounced GamesList emit may then see the + // old games with the newer status fence, but never new games with stale + // exhaustion. + if let Err(error) = clear_settled_game_transfer_statuses_for_local_generation(&app).await { + log::error!("Failed to clear settled transfer status for local generation: {error}"); + } + { let mut game_db = state.games.write().await; apply_peer_local_games(&mut game_db, &local_games); @@ -1284,9 +3055,10 @@ fn add_final_slash(path: &str) -> String { async fn do_unrar( app_handle: &AppHandle, - sidecar: Command, + program: &Path, rar_file: &Path, dest_dir: &Path, + cancel_token: CancellationToken, ) -> eyre::Result<()> { let started_at_ms = now_millis(); let paths = prepare_unrar_paths(app_handle, rar_file, dest_dir, started_at_ms).await?; @@ -1297,7 +3069,7 @@ async fn do_unrar( paths.destination.display() ); - run_unrar_sidecar(app_handle, sidecar, &paths, started_at_ms).await + run_unrar_sidecar(app_handle, program, &paths, started_at_ms, cancel_token).await } struct UnrarPaths { @@ -1386,25 +3158,50 @@ async fn prepare_unrar_paths( async fn run_unrar_sidecar( app_handle: &AppHandle, - sidecar: Command, + program: &Path, paths: &UnrarPaths, started_at_ms: u64, + cancel_token: CancellationToken, ) -> eyre::Result<()> { - // Spawn (instead of `.output()`) so we keep a killable handle. The shell - // plugin's `output()` drops the `CommandChild` immediately and only drains - // the event channel, leaving the unrar process orphaned if the launcher - // exits before extraction finishes. - let (mut events, child) = match sidecar - .arg("x") // extract files - .arg(&paths.archive) - .arg("-y") // Assume Yes on all queries - .arg("-o") // Set overwrite mode - .arg(&paths.destination_arg) - .spawn() - { - Ok(spawned) => spawned, + if cancel_token.is_cancelled() { + let stderr = format!( + "unrar extraction for {} was cancelled", + paths.archive.display() + ); + record_unpack_failure( + app_handle, + paths.archive.display().to_string(), + paths.destination.display().to_string(), + started_at_ms, + stderr.clone(), + ) + .await; + bail!("{stderr}"); + } + + let registry = app_handle + .state::() + .active_unrar_children + .clone(); + let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed); + let registration = RegisteredUnrarWorker::new(registry, child_id, cancel_token); + let process = match ScopedProcess::spawn( + program, + [ + std::ffi::OsString::from("x"), + std::ffi::OsString::from("-p-"), + paths.archive.as_os_str().to_owned(), + std::ffi::OsString::from("-y"), + std::ffi::OsString::from("-o"), + std::ffi::OsString::from(&paths.destination_arg), + ], + ®istration.cancel_token(), + UNRAR_LOG_CAPTURE_LIMIT, + ) { + Ok(process) => process, Err(err) => { - let stderr = format!("failed to run unrar sidecar: {err}"); + let stderr = format!("failed to start unrar sidecar supervisor: {err}"); + registration.complete(); record_unpack_failure( app_handle, paths.archive.display().to_string(), @@ -1416,43 +3213,28 @@ async fn run_unrar_sidecar( bail!("{stderr}"); } }; - - // Register the live child so a launcher exit can kill it, and deregister it - // automatically on every exit path via the RAII guard. - let registry = app_handle - .state::() - .active_unrar_children - .clone(); - let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed); - register_unrar_child(®istry, child_id, child); - let _child_guard = UnrarChildGuard { registry, child_id }; - - let mut stdout_bytes = Vec::new(); - let mut stderr_bytes = Vec::new(); - let mut status_code = None; - while let Some(event) = events.recv().await { - match event { - CommandEvent::Stdout(line) => { - stdout_bytes.extend(line); - stdout_bytes.push(b'\n'); - } - CommandEvent::Stderr(line) => { - stderr_bytes.extend(line); - stderr_bytes.push(b'\n'); - } - CommandEvent::Terminated(payload) => { - status_code = payload.code; - } - CommandEvent::Error(err) => { - log::warn!("unrar sidecar event error: {err}"); - } - _ => {} + let output = match process.wait().await { + Ok(output) => output, + Err(err) => { + let stderr = format!("unrar sidecar failed: {err}"); + registration.complete(); + record_unpack_failure( + app_handle, + paths.archive.display().to_string(), + paths.destination.display().to_string(), + started_at_ms, + stderr.clone(), + ) + .await; + bail!("{stderr}"); } - } + }; + registration.complete(); - let stdout = clean_terminal_log(&String::from_utf8_lossy(&stdout_bytes)); - let stderr = clean_terminal_log(&String::from_utf8_lossy(&stderr_bytes)); - let success = status_code == Some(0); + let stdout = format_unrar_log_stream(&output.stdout, output.stdout_truncated, "stdout"); + let stderr = format_unrar_log_stream(&output.stderr, output.stderr_truncated, "stderr"); + let status_code = output.status.code(); + let success = output.status.success(); record_unpack_log( app_handle, @@ -1482,76 +3264,117 @@ async fn run_unrar_sidecar( Ok(()) } -/// Tracks a spawned unrar sidecar so the launcher can kill it on shutdown. -/// -/// If shutdown has already begun (or the registry is poisoned), the child is -/// killed immediately instead of inserted, since the exit kill sweep has already -/// run and would never reap a late registration. -fn register_unrar_child( - registry: &Arc>, - child_id: u64, - child: CommandChild, -) { - let Ok(mut guard) = registry.lock() else { - // A poisoned registry means we can no longer guarantee the child is - // killed on exit, so kill it now rather than risk orphaning it. - let pid = child.pid(); - log::warn!("unrar child registry is poisoned; killing pid {pid} immediately"); - if let Err(err) = child.kill() { - log::warn!("Failed to kill untracked unrar child (pid {pid}): {err}"); +fn format_unrar_log_stream(bytes: &[u8], truncated: bool, stream: &str) -> String { + let mut output = clean_terminal_log(&String::from_utf8_lossy(bytes)); + if truncated { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); } - return; - }; - - if guard.shutting_down { - drop(guard); - let pid = child.pid(); - log::info!("Killing unrar child (pid {pid}) that spawned during shutdown"); - if let Err(err) = child.kill() { - log::warn!("Failed to kill unrar child (pid {pid}) spawned during shutdown: {err}"); - } - return; + let _ = write!( + output, + "[{stream} truncated after {UNRAR_LOG_CAPTURE_LIMIT} bytes]" + ); } - - guard.children.insert(child_id, child); + output } -/// Removes an unrar sidecar from the registry when its `run_unrar_sidecar` call -/// returns, regardless of success, error, or early bail. -struct UnrarChildGuard { +/// Registers one lexical process worker with the application shutdown sweep. +struct RegisteredUnrarWorker { registry: Arc>, child_id: u64, + control: Arc, + armed: bool, } -impl Drop for UnrarChildGuard { - fn drop(&mut self) { - if let Ok(mut guard) = self.registry.lock() { - guard.children.remove(&self.child_id); +impl RegisteredUnrarWorker { + fn new( + registry: Arc>, + child_id: u64, + operation_cancel: CancellationToken, + ) -> Self { + let control = Arc::new(UnrarWorkerControl { + cancel_token: operation_cancel.child_token(), + }); + let cancel_immediately = { + let mut registry_guard = lock_unrar_mutex(®istry, "child registry"); + if registry_guard.shutting_down { + true + } else { + registry_guard + .children + .insert(child_id, Arc::downgrade(&control)); + false + } + }; + let registered = Self { + registry, + child_id, + control, + armed: true, + }; + + if cancel_immediately { + registered.control.cancel_token.cancel(); } + + registered + } + + fn cancel_token(&self) -> CancellationToken { + self.control.cancel_token.clone() + } + + fn complete(mut self) { + self.armed = false; + self.deregister(); + } + + fn deregister(&self) { + lock_unrar_mutex(&self.registry, "child registry") + .children + .remove(&self.child_id); } } -/// Kills every in-progress unrar sidecar and latches the registry into shutdown -/// so any install task that spawns unrar after this point kills it on -/// registration. Called on app exit so a game install that is mid-extraction does -/// not leave `unrar` running after the launcher closes. -fn kill_active_unrar_children(app_handle: &AppHandle) { - let state = app_handle.state::(); - let children = { - let Ok(mut guard) = state.active_unrar_children.lock() else { - log::warn!("unrar child registry is poisoned; cannot kill children on shutdown"); +impl Drop for RegisteredUnrarWorker { + fn drop(&mut self) { + if !self.armed { return; - }; + } + + self.control.cancel_token.cancel(); + self.deregister(); + } +} + +fn lock_unrar_mutex<'a, T>(mutex: &'a Mutex, label: &str) -> std::sync::MutexGuard<'a, T> { + mutex.lock().unwrap_or_else(|poisoned| { + log::warn!("unrar {label} is poisoned; recovering it for process cleanup"); + poisoned.into_inner() + }) +} + +/// Cancels every in-progress unrar worker and latches the registry so late +/// registrations start cancelled. Lexical [`ScopedProcess`] owners perform the +/// actual kill, wait, and reader joins before their install futures return. +fn cancel_active_unrar_workers(app_handle: &AppHandle) { + let state = app_handle.state::(); + begin_unrar_shutdown(&state.active_unrar_children); +} + +fn begin_unrar_shutdown(registry: &Arc>) { + let children = { + let mut guard = lock_unrar_mutex(registry, "child registry"); guard.shutting_down = true; - guard.children.drain().collect::>() + guard + .children + .values() + .filter_map(Weak::upgrade) + .collect::>() }; - for (_child_id, child) in children { - let pid = child.pid(); - match child.kill() { - Ok(()) => log::info!("Killed in-progress unrar child (pid {pid}) on shutdown"), - Err(err) => log::warn!("Failed to kill unrar child (pid {pid}) on shutdown: {err}"), - } + for control in children { + control.cancel_token.cancel(); } } @@ -1582,14 +3405,19 @@ async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) { let state = app_handle.state::(); let mut entry = entry; clean_unpack_log_entry(&mut entry); - let logs = { + let state_dir = state.state_dir.get().cloned(); + { let mut logs = state.inner().unpack_logs.write().await; logs.push(entry); trim_unpack_logs(&mut logs); - logs.clone() - }; - - persist_unpack_logs(app_handle, &logs).await; + if let Some(state_dir) = state_dir { + if let Err(err) = persist_unpack_logs(&state_dir, &logs) { + log::warn!("Failed to persist unpack logs: {err}"); + } + } else { + log::warn!("Cannot persist unpack logs before app state directory is initialized"); + } + } if let Err(err) = app_handle.emit("unpack-logs-updated", ()) { log::warn!("Failed to emit unpack-logs-updated event: {err}"); @@ -2027,27 +3855,11 @@ fn load_unpack_logs(state_dir: &Path) -> Vec { logs } -async fn persist_unpack_logs(app_handle: &AppHandle, logs: &[UnpackLogEntry]) { - let state = app_handle.state::(); - let Some(state_dir) = state.state_dir.get().cloned() else { - log::warn!("Cannot persist unpack logs before app state directory is initialized"); - return; - }; - let path = unpack_logs_path(&state_dir); - let contents = match serde_json::to_vec_pretty(logs) { - Ok(contents) => contents, - Err(err) => { - log::warn!( - "Failed to serialize unpack logs for {}: {err}", - path.display() - ); - return; - } - }; - - if let Err(err) = tokio::fs::write(&path, contents).await { - log::warn!("Failed to persist unpack logs to {}: {err}", path.display()); - } +fn persist_unpack_logs(state_dir: &Path, logs: &[UnpackLogEntry]) -> eyre::Result<()> { + let path = unpack_logs_path(state_dir); + let contents = serde_json::to_vec_pretty(logs)?; + scoped_blocking(|| std::fs::write(&path, contents))?; + Ok(()) } fn now_millis() -> u64 { @@ -2058,88 +3870,157 @@ fn now_millis() -> u64 { }) } -/// Resolve the bundled catalog database packaged with the Tauri application. -fn resolve_bundled_game_db_path(app_handle: &AppHandle) -> PathBuf { - app_handle +/// Resolve the catalog authority packaged with the Tauri application. +fn resolve_bundled_catalog_paths(app_handle: &AppHandle) -> eyre::Result<(PathBuf, PathBuf)> { + let game_db_path = app_handle .path() .resolve("game.db", tauri::path::BaseDirectory::Resource) - .unwrap_or_else(|e| { - log::error!("Failed to resolve game.db resource: {e}"); - panic!("game.db resource is required - cannot continue"); + .map_err(|error| eyre::eyre!("failed to resolve game.db resource: {error}"))?; + let manifests_root = app_handle + .path() + .resolve("manifests", tauri::path::BaseDirectory::Resource) + .map_err(|error| eyre::eyre!("failed to resolve manifests resource: {error}"))?; + Ok((game_db_path, manifests_root)) +} + +/// Load the complete bundled catalog authority exactly once during setup. +async fn load_bundled_catalog(app_handle: &AppHandle) -> eyre::Result { + let (game_db_path, manifests_root) = resolve_bundled_catalog_paths(app_handle)?; + load_catalog_bundle(&game_db_path, &manifests_root) + .await + .map_err(|error| { + eyre::eyre!( + "bundled catalog authority is missing or invalid (database {}, manifests {}): {error}", + game_db_path.display(), + manifests_root.display() + ) }) } -/// Load the bundled catalog into the in-memory game database used by the UI. -async fn load_bundled_game_db(app_handle: &AppHandle) -> GameDB { - let game_db_path = resolve_bundled_game_db_path(app_handle); - let eti_games = get_games(&game_db_path).await.unwrap_or_else(|e| { - log::error!("Failed to load ETI games: {e}"); - panic!("game.db resource is required - cannot continue"); - }); - - log::info!("Loaded {} ETI games from game.db", eti_games.len()); - - let games: Vec = eti_games.into_iter().map(Into::into).collect(); - GameDB::from(games) +async fn install_bundled_catalog( + state: &LanSpreadState, + loaded_catalog: LoadedCatalog, +) -> eyre::Result<()> { + let (game_db, catalog_bundle) = loaded_catalog.into_parts(); + install_bundled_catalog_parts(state, game_db, catalog_bundle).await } -async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) { - let state = app_handle.state::(); - let needs_load = { state.games.read().await.games.is_empty() }; +async fn install_bundled_catalog_parts( + state: &LanSpreadState, + game_db: GameDB, + catalog_bundle: Arc, +) -> eyre::Result<()> { + let game_count = game_db.games.len(); + let mut games = state.games.write().await; + state + .catalog_bundle + .set(catalog_bundle) + .map_err(|_| eyre::eyre!("bundled catalog authority was initialized more than once"))?; + *games = game_db; + log::info!("Loaded {game_count} games and their content authority"); + Ok(()) +} - if needs_load { - let game_db = load_bundled_game_db(app_handle).await; - let catalog = GameCatalog::from_game_db(&game_db); - *state.games.write().await = game_db; - *state.catalog.write().await = catalog; +/// Acquires the runtime ownership slot before creating a runtime and publishes +/// the returned owner without another await. Cancelling this future while the +/// slot is contended therefore cannot create an untracked runtime. +async fn start_runtime_in_slot( + slot: &RwLock>, + start: Start, +) -> Result>, String> +where + Start: FnOnce() -> Result, +{ + let mut slot = slot.write().await; + if slot.is_some() { + return Err("peer runtime ownership slot is already occupied".to_string()); } + + *slot = Some(start()?); + Ok(slot) } -async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) { +async fn ensure_peer_started( + app_handle: &AppHandle, + games_folder: &Path, + _app_invoke: &AppInvokeGuard, + initial_local_network_sharing: bool, +) -> Result { let state = app_handle.state::(); let mut peer_ctrl = state.peer_ctrl.write().await; if let Some(peer_ctrl) = peer_ctrl.as_ref() { - if let Err(e) = peer_ctrl.send(PeerCommand::SetGameDir(games_folder.to_path_buf())) { - log::error!("Failed to send PeerCommand::SetGameDir: {e}"); - } - return; + let (reply, result) = oneshot::channel(); + peer_ctrl + .send(PeerCommand::SetGameDir { + path: games_folder.to_path_buf(), + reply, + }) + .map_err(|error| format!("Failed to send PeerCommand::SetGameDir: {error}"))?; + return result + .await + .map_err(|error| format!("PeerCommand::SetGameDir reply was dropped: {error}"))?; } let Some(state_dir) = state.state_dir.get().cloned() else { - log::error!("app state directory is not initialized; cannot start peer"); - return; + return Err("app state directory is not initialized; cannot start peer".to_string()); }; let tx_peer_event = app_handle.state::().inner().0.clone(); let unpacker = Arc::new(SidecarUnpacker { app_handle: app_handle.clone(), }); let stream_install_provider = stream_install_provider_for_app(app_handle); - match start_peer_with_options( - games_folder.to_path_buf(), - tx_peer_event, - state.peer_game_db.clone(), - unpacker, - state.catalog.clone(), - PeerStartOptions { - state_dir: Some(state_dir), - active_outbound_transfers: Some(state.active_outbound_transfers.clone()), - stream_install_provider: Some(stream_install_provider), - }, - ) { - Ok(handle) => { - let sender = handle.sender(); - *peer_ctrl = Some(sender.clone()); - *state.peer_runtime.write().await = Some(handle); - if let Err(e) = sender.send(PeerCommand::ListGames) { - log::error!("Failed to send initial PeerCommand::ListGames: {e}"); - } - log::info!("Peer system initialized successfully with games directory"); - } - Err(e) => { - log::error!("Failed to initialize peer system: {e}"); - } + let catalog_bundle = state + .catalog_bundle + .get() + .cloned() + .ok_or_else(|| "bundled catalog authority is not initialized".to_string())?; + // Acquire the identity publication slot before creating the runtime. Once + // `start_peer_with_options` returns, both control and identity ownership are + // published without another cancellation point. + let mut local_peer_id_slot = state.local_peer_id.write().await; + let mut installation_identity_slot = state.installation_identity.write().await; + let startup_identity = installation_identity_slot + .as_ref() + .map(|cached| Arc::clone(&cached.identity)); + let peer_runtime = start_runtime_in_slot(&state.peer_runtime, || { + start_peer_with_options( + games_folder.to_path_buf(), + tx_peer_event, + state.peer_game_db.clone(), + unpacker, + catalog_bundle, + PeerStartOptions { + state_dir: Some(state_dir), + identity: startup_identity, + active_outbound_transfers: Some(state.active_outbound_transfers.clone()), + stream_install_provider: Some(stream_install_provider), + local_network_sharing: initial_local_network_sharing, + }, + ) + .map_err(|error| format!("Failed to initialize peer system: {error}")) + }) + .await?; + let Some(handle) = peer_runtime.as_ref() else { + return Err("peer runtime ownership handoff did not publish its handle".to_string()); + }; + let accepted_games_folder = handle.accepted_game_dir().to_path_buf(); + let local_peer_id = handle.peer_id().to_string(); + let identity_durability = retain_installation_identity( + &mut installation_identity_slot, + handle.identity(), + handle.identity_durability(), + ); + let sender = handle.sender(); + *peer_ctrl = Some(sender); + *local_peer_id_slot = Some(local_peer_id); + drop(installation_identity_slot); + drop(local_peer_id_slot); + if let Err(error) = publish_identity_diagnostic(app_handle, identity_durability).await { + log::error!("Failed to publish identity persistence diagnostic: {error}"); } + log::info!("Peer system initialized successfully with games directory"); + Ok(accepted_games_folder) } fn stream_install_provider_for_app(app_handle: &AppHandle) -> Arc { @@ -2164,14 +4045,230 @@ fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str } } -fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedReceiver) { - tauri::async_runtime::spawn(async move { - while let Some(event) = rx_peer_event.recv().await { - handle_peer_event(&app_handle, event).await; +fn spawn_peer_event_loop( + app_handle: AppHandle, + mut rx_peer_event: UnboundedReceiver, + mut rx_ui_state: UnboundedReceiver, +) { + let tasks = app_handle + .state::() + .background_tasks + .clone(); + let cancel_token = tasks.cancel_token(); + tasks.spawn(async move { + let mut peer_events_open = true; + let mut ui_state_open = true; + loop { + tokio::select! { + () = cancel_token.cancelled() => break, + event = rx_peer_event.recv(), if peer_events_open => { + match event { + Some(event) => handle_peer_event(&app_handle, event).await, + None => peer_events_open = false, + } + } + command = rx_ui_state.recv(), if ui_state_open => { + match command { + Some(command) => { + handle_ui_state_command( + &app_handle, + command, + &mut rx_peer_event, + ).await; + } + None => ui_state_open = false, + } + } + else => break, + } } }); } +fn publish_local_network_sharing_snapshot( + app_handle: &AppHandle, + mut next: LocalNetworkSharingSnapshot, +) -> Result { + let state = app_handle.state::(); + let watch = local_network_sharing_watch(state.inner())?; + let current = *watch.borrow(); + if (LocalNetworkSharingSnapshot { + revision: current.revision, + ..next + }) == current + { + return Ok(current); + } + next.revision = current + .revision + .checked_add(1) + .ok_or_else(|| "Local network sharing revision overflow".to_owned())?; + let _previous = watch.send_replace(next); + if let Err(error) = app_handle.emit("local-network-sharing-updated", Some(next)) { + log::error!("Failed to emit local-network-sharing-updated event: {error}"); + } + Ok(next) +} + +fn record_core_local_network_sharing_state( + app_handle: &AppHandle, + state: LocalNetworkSharingState, +) -> Result { + let current = current_local_network_sharing(app_handle.state::().inner())?; + let phase = local_network_sharing_phase(state); + publish_local_network_sharing_snapshot( + app_handle, + LocalNetworkSharingSnapshot { phase, ..current }, + ) +} + +fn local_network_sharing_phase(state: LocalNetworkSharingState) -> LocalNetworkSharingPhase { + match state { + LocalNetworkSharingState::Disabled => LocalNetworkSharingPhase::Disabled, + LocalNetworkSharingState::Enabling => LocalNetworkSharingPhase::Enabling, + LocalNetworkSharingState::Enabled { .. } => LocalNetworkSharingPhase::Enabled, + LocalNetworkSharingState::Disabling => LocalNetworkSharingPhase::Disabling, + } +} + +fn mutate_local_network_sharing_in_loop( + app_handle: &AppHandle, + mutation: SharingSnapshotMutation, +) -> Result { + let current = current_local_network_sharing(app_handle.state::().inner())?; + publish_local_network_sharing_snapshot(app_handle, sharing_snapshot_after(current, mutation)) +} + +fn sharing_snapshot_after( + current: LocalNetworkSharingSnapshot, + mutation: SharingSnapshotMutation, +) -> LocalNetworkSharingSnapshot { + match mutation { + SharingSnapshotMutation::Begin { target } => LocalNetworkSharingSnapshot { + pending_target: Some(target), + ..current + }, + SharingSnapshotMutation::Commit { + enabled, + phase, + persistence_problem, + } => LocalNetworkSharingSnapshot { + enabled, + pending_target: None, + phase: phase.unwrap_or(current.phase), + persistence_problem, + ..current + }, + } +} + +fn set_identity_diagnostic_in_loop( + app_handle: &AppHandle, + diagnostic: Option, +) -> Result { + let state = app_handle.state::(); + let watch = state + .identity_diagnostic + .get() + .ok_or_else(|| "identity diagnostic state is not initialized".to_owned())?; + let current = *watch.borrow(); + if current.diagnostic == diagnostic { + return Ok(current); + } + let next = IdentityDiagnosticSnapshot { + revision: current + .revision + .checked_add(1) + .ok_or_else(|| "identity diagnostic revision overflow".to_owned())?, + diagnostic, + }; + let _previous = watch.send_replace(next); + if let Err(error) = app_handle.emit("identity-diagnostic-updated", Some(next)) { + log::error!("Failed to emit identity-diagnostic-updated event: {error}"); + } + Ok(next) +} + +fn take_exactly_queued( + receiver: &mut UnboundedReceiver, + count: usize, +) -> Result, String> { + let mut queued = Vec::with_capacity(count); + for _ in 0..count { + queued.push(receiver.try_recv().map_err(|error| { + format!("peer-event queue changed while applying its fence: {error}") + })?); + } + Ok(queued) +} + +async fn drain_queued_peer_events( + app_handle: &AppHandle, + receiver: &mut UnboundedReceiver, +) -> Result<(), String> { + // The core replies only after all events for the requested transition have + // been enqueued. Once this UI command wins the fair select, snapshot the + // peer queue and process that exact FIFO prefix. Later autonomous traffic + // remains for the normal event-loop turn and cannot contaminate this + // transition's acknowledgement boundary. + let count = receiver.len(); + let queued = take_exactly_queued(receiver, count)?; + for event in queued { + handle_peer_event(app_handle, event).await; + } + Ok(()) +} + +async fn fence_queued_peer_events( + app_handle: &AppHandle, + receiver: &mut UnboundedReceiver, +) -> Result { + drain_queued_peer_events(app_handle, receiver).await?; + current_local_network_sharing(app_handle.state::().inner()) +} + +async fn reset_game_transfer_status_in_loop( + app_handle: &AppHandle, + receiver: &mut UnboundedReceiver, +) -> Result { + // A successful game-root command has already caused core to enqueue its + // preceding events. Drain that bounded prefix before terminalizing the old + // root's receipts; later events without a strictly newer Begin stay inert. + drain_queued_peer_events(app_handle, receiver).await?; + let state = app_handle.state::(); + let (changed, current) = { + let mut store = state.game_transfer_status.write().await; + let changed = store.clear_for_root_change()?; + let current = changed.clone().unwrap_or_else(|| store.snapshot()); + (changed, current) + }; + if let Some(snapshot) = &changed { + emit_game_transfer_status_snapshot(app_handle, snapshot); + } + Ok(current) +} + +async fn handle_ui_state_command( + app_handle: &AppHandle, + command: UiStateCommand, + peer_events: &mut UnboundedReceiver, +) { + match command { + UiStateCommand::MutateSharing { mutation, reply } => { + let _ = reply.send(mutate_local_network_sharing_in_loop(app_handle, mutation)); + } + UiStateCommand::SetIdentityDiagnostic { diagnostic, reply } => { + let _ = reply.send(set_identity_diagnostic_in_loop(app_handle, diagnostic)); + } + UiStateCommand::FencePeerEvents { reply } => { + let _ = reply.send(fence_queued_peer_events(app_handle, peer_events).await); + } + UiStateCommand::ResetGameTransferStatus { reply } => { + let _ = reply.send(reset_game_transfer_status_in_loop(app_handle, peer_events).await); + } + } +} + async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) { let state = app_handle.state::(); let should_spawn = { @@ -2183,10 +4280,16 @@ async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) { return; } + let tasks = state.background_tasks.clone(); + let cancel_token = tasks.cancel_token(); let app_handle = app_handle.clone(); - tauri::async_runtime::spawn(async move { + tasks.spawn(async move { loop { - tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE).await; + tokio::select! { + biased; + () = cancel_token.cancelled() => break, + () = tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE) => {} + } let observed_generation = { let state = app_handle.state::(); @@ -2215,15 +4318,49 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { match event { PeerEvent::LocalPeerReady { peer_id, addr } => { log::info!("Local peer ready: {peer_id} at {addr}"); - *app_handle + let authoritative_peer_id = app_handle .state::() .local_peer_id - .write() - .await = Some(peer_id); + .read() + .await + .clone(); + match authoritative_peer_id { + Some(authoritative_peer_id) if authoritative_peer_id != peer_id => { + log::error!( + "LocalPeerReady identity {peer_id} did not match runtime handle identity {authoritative_peer_id}" + ); + } + Some(_) => {} + None => { + log::debug!( + "LocalPeerReady preceded publication of the authoritative runtime handle identity" + ); + } + } } - PeerEvent::ListGames(games) => { - log::info!("PeerEvent::ListGames received"); - update_game_db(games, app_handle.clone()).await; + PeerEvent::LocalNetworkSharingStateChanged(sharing_state) => { + if matches!(sharing_state, LocalNetworkSharingState::Disabled) { + let state = app_handle.state::(); + if current_protocol_mismatch(state.inner()) + .await + .mismatch + .is_some() + { + let diagnostic = clear_protocol_mismatch(state.inner()).await; + if let Err(error) = + app_handle.emit("protocol-mismatch-updated", Some(diagnostic)) + { + log::error!("Failed to emit protocol-mismatch-updated event: {error}"); + } + } + } + if let Err(error) = record_core_local_network_sharing_state(app_handle, sharing_state) { + log::error!("Failed to record Local network sharing state: {error}"); + } + } + PeerEvent::RemoteLibraryView(view) => { + log::info!("PeerEvent::RemoteLibraryView received"); + update_remote_library_view(view, app_handle.clone()).await; } PeerEvent::LocalLibraryChanged { games: local_games } => { log::info!("PeerEvent::LocalLibraryChanged received"); @@ -2238,70 +4375,110 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } emit_games_list(app_handle).await; } - PeerEvent::CallToPlayEvents(events) => { - if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) { - log::error!("Failed to emit call-to-play-events event: {err}"); + PeerEvent::CallToPlayView(view) => { + if let Err(err) = app_handle.emit("call-to-play-view", Some(view)) { + log::error!("Failed to emit call-to-play-view event: {err}"); } } - PeerEvent::OutboundTransferCountChanged => { + PeerEvent::OutboundTransferCountChanged(change) => { log::info!("PeerEvent::OutboundTransferCountChanged received"); schedule_outbound_transfer_emit(app_handle).await; + drop(change); } - PeerEvent::GotGameFiles { - id, - file_descriptions: _, - } => { - handle_got_game_files(app_handle, id).await; - } - PeerEvent::NoPeersHaveGame { id } => { - log::warn!("PeerEvent::NoPeersHaveGame received for {id}"); - emit_game_id_event( - app_handle, - "game-no-peers", - &id, - "PeerEvent::NoPeersHaveGame", + PeerEvent::DownloadGameFilesBegin { attempt } => { + log::info!( + "PeerEvent::DownloadGameFilesBegin received for {} attempt {}", + attempt.id, + attempt.attempt_id ); - } - PeerEvent::DownloadGameFilesBegin { id } => { - log::info!("PeerEvent::DownloadGameFilesBegin received for {id}"); + let id = attempt.id.clone(); + if let Err(error) = + mutate_catalog_game_transfer_status(app_handle, &id, |store| store.begin(&attempt)) + .await + { + log::error!("Failed to record game transfer begin: {error}"); + } } PeerEvent::DownloadGameFileChunkFinished { id, + peer_id, peer_addr, + content_id, relative_path, offset, length, } => { log::debug!( "PeerEvent::DownloadGameFileChunkFinished received for {id}: \ - {relative_path} offset {offset} length {length} from {peer_addr}" + {} offset {offset} length {length} for content {content_id} \ + from authenticated peer {peer_id} at {peer_addr}", + relative_path.as_str() ); } PeerEvent::DownloadGameFilesProgress(progress) => { - if let Err(e) = app_handle.emit("game-download-progress", Some(progress)) { - log::error!("Failed to emit game-download-progress event: {e}"); + if accepts_game_transfer_progress(app_handle, &progress).await { + if let Err(error) = app_handle.emit( + "game-download-progress", + Some(UiDownloadProgress::from(&progress)), + ) { + log::error!("Failed to emit game-download-progress event: {error}"); + } + } else { + log::debug!( + "Ignoring stale download progress for {} attempt {}", + progress.attempt.id, + progress.attempt.attempt_id + ); } } - PeerEvent::DownloadGameFilesFinished { id } => { - log::info!("PeerEvent::DownloadGameFilesFinished received for {id}"); + PeerEvent::DownloadGameFilesActivityChanged { attempt, activity } => { + let id = attempt.id.clone(); + if let Err(error) = mutate_catalog_game_transfer_status(app_handle, &id, |store| { + store.activity(&attempt, activity) + }) + .await + { + log::error!("Failed to record game transfer activity: {error}"); + } } - PeerEvent::DownloadGameFilesFailed { id } => { - log::warn!("PeerEvent::DownloadGameFilesFailed received"); - emit_game_id_event( - app_handle, - "game-download-failed", - &id, - "PeerEvent::DownloadGameFilesFailed", + PeerEvent::DownloadGameFilesFinished { attempt } => { + log::info!( + "PeerEvent::DownloadGameFilesFinished received for {} attempt {}", + attempt.id, + attempt.attempt_id ); + let id = attempt.id.clone(); + if let Err(error) = mutate_catalog_game_transfer_status(app_handle, &id, |store| { + store.finished(&attempt) + }) + .await + { + log::error!("Failed to record game transfer completion: {error}"); + } } - PeerEvent::DownloadGameFilesAllPeersGone { id } => { - log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}"); - emit_game_id_event( - app_handle, - "game-download-peers-gone", - &id, - "PeerEvent::DownloadGameFilesAllPeersGone", + PeerEvent::DownloadGameFilesFailed { attempt, reason } => { + log::warn!( + "PeerEvent::DownloadGameFilesFailed received for {} attempt {}: {reason:?}", + attempt.id, + attempt.attempt_id ); + let id = attempt.id.clone(); + match mutate_catalog_game_transfer_status(app_handle, &id, |store| { + store.failed(&attempt, reason) + }) + .await + { + Ok(Some(_)) if reason == DownloadFailureReason::OperationFailed => { + emit_game_id_event( + app_handle, + "game-download-failed", + &id, + "PeerEvent::DownloadGameFilesFailed", + ); + } + Ok(_) => {} + Err(error) => log::error!("Failed to record game transfer failure: {error}"), + } } PeerEvent::InstallGameFinished { id } => { log::info!("PeerEvent::InstallGameFinished received for {id}"); @@ -2351,17 +4528,19 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { "PeerEvent::RemoveDownloadedGameFailed", ); } - PeerEvent::PeerConnected(addr) => { - log::info!("Peer connected: {addr}"); + PeerEvent::PeerDiscovered(endpoint) => { + log::info!( + "Peer discovered: authenticated peer {} at {}", + endpoint.peer_id, + endpoint.addr + ); } - PeerEvent::PeerDisconnected(addr) => { - log::info!("Peer disconnected: {addr}"); - } - PeerEvent::PeerDiscovered(addr) => { - log::info!("Peer discovered: {addr}"); - } - PeerEvent::PeerLost(addr) => { - log::info!("Peer lost: {addr}"); + PeerEvent::PeerLost(endpoint) => { + log::info!( + "Peer lost: authenticated peer {} at {}", + endpoint.peer_id, + endpoint.addr + ); } PeerEvent::PeerCountUpdated(count) => { log::info!("Peer count updated: {count}"); @@ -2369,6 +4548,19 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { log::error!("Failed to emit peer-count-updated event: {e}"); } } + PeerEvent::IncompatibleProtocolDetected { observed, expected } => { + log::info!( + "Nearby installation uses incompatible protocol {observed:?}; expected {expected}" + ); + let diagnostic = record_protocol_mismatch( + app_handle.state::().inner(), + ProtocolMismatch { observed, expected }, + ) + .await; + if let Err(e) = app_handle.emit("protocol-mismatch-updated", Some(diagnostic)) { + log::error!("Failed to emit protocol-mismatch-updated event: {e}"); + } + } PeerEvent::RuntimeFailed { component, error } => { let component_name: &'static str = (&component).into(); log::error!("Peer runtime component {component_name} failed: {error}"); @@ -2382,16 +4574,16 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } } -async fn handle_got_game_files(app_handle: &AppHandle, id: String) { - log::info!("PeerEvent::GotGameFiles received"); +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +struct ProtocolMismatch { + observed: Option, + expected: u32, +} - let state = app_handle.state::(); - let peer_ctrl = state.peer_ctrl.read().await.clone(); - if let Some(peer_ctrl) = peer_ctrl - && let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles { id }) - { - log::error!("Failed to continue queued game transfer: {e}"); - } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +struct ProtocolMismatchSnapshot { + revision: u64, + mismatch: Option, } #[allow(clippy::missing_panics_doc)] @@ -2399,6 +4591,7 @@ async fn handle_got_game_files(app_handle: &AppHandle, id: String) { pub fn run() { // channel to receive events from the peer let (tx_peer_event, rx_peer_event) = tokio::sync::mpsc::unbounded_channel::(); + let (tx_ui_state, rx_ui_state) = tokio::sync::mpsc::unbounded_channel::(); tauri::Builder::default() .plugin(tauri_plugin_store::Builder::new().build()) @@ -2406,9 +4599,15 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ request_games, - request_call_to_play_events, + get_protocol_mismatch, + get_local_network_sharing, + set_local_network_sharing, + get_identity_diagnostic, + request_call_to_play_view, publish_call_to_play, + set_call_to_play_display_name, install_game, + supports_streamed_install, stream_install_game, run_game, start_server, @@ -2426,6 +4625,7 @@ pub fn run() { ]) .manage(LanSpreadState::default()) .manage(PeerEventTx(tx_peer_event)) + .manage(UiStateTx(tx_ui_state)) .setup(move |app| { let state_dir = app.path().app_data_dir()?; std::fs::create_dir_all(&state_dir)?; @@ -2435,51 +4635,1003 @@ pub fn run() { log::warn!("main log sink was already initialized"); } init_main_logging(main_log_sink)?; + if state.state_dir.set(state_dir.clone()).is_err() { + log::warn!("app state directory was already initialized"); + } + let policy_path = state_dir.join(sharing_policy::POLICY_FILE_NAME); + let (sharing_enabled, persistence_problem) = match scoped_blocking(|| { + sharing_policy::load(&policy_path) + }) { + Ok(enabled) => (enabled, None), + Err(error) => { + log::error!( + "Failed to load Local network sharing policy; starting disabled: {error:#}" + ); + (false, Some(SharingPersistenceProblem::Load)) + } + }; + let (sharing_watch, _) = watch::channel(LocalNetworkSharingSnapshot::initial( + sharing_enabled, + persistence_problem, + )); + if state.local_network_sharing.set(sharing_watch).is_err() { + log::warn!("Local network sharing state was already initialized"); + } + let (identity_watch, _) = watch::channel(IdentityDiagnosticSnapshot::INITIAL); + if state.identity_diagnostic.set(identity_watch).is_err() { + log::warn!("identity diagnostic state was already initialized"); + } + let loaded_catalog = tauri::async_runtime::block_on(load_bundled_catalog(app.handle())) + .map_err(|error| io::Error::other(error.to_string()))?; + tauri::async_runtime::block_on(install_bundled_catalog(state.inner(), loaded_catalog)) + .map_err(|error| io::Error::other(error.to_string()))?; let unpack_logs = load_unpack_logs(&state_dir); tauri::async_runtime::block_on(async { *state.unpack_logs.write().await = unpack_logs; }); - if state.state_dir.set(state_dir).is_err() { - log::warn!("app state directory was already initialized"); - } - spawn_peer_event_loop(app.handle().clone(), rx_peer_event); + spawn_peer_event_loop(app.handle().clone(), rx_peer_event, rx_ui_state); Ok(()) }) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { if matches!(event, tauri::RunEvent::Exit) { - // Kill unrar first: an in-progress extraction would otherwise keep - // running after the launcher closes, and killing it lets the - // install task unwind so the peer runtime can stop promptly. - kill_active_unrar_children(app_handle); - shutdown_peer_runtime(app_handle); + shutdown_application(app_handle); } }); } -fn shutdown_peer_runtime(app_handle: &AppHandle) { +fn shutdown_application(app_handle: &AppHandle) { let state = app_handle.state::(); let peer_runtime = state.peer_runtime.clone(); + let app_invokes = state.app_invokes.clone(); + let background_tasks = state.background_tasks.clone(); + + // Close every invoke entry point before any shutdown mutation. Unrar is + // cancelled next; its lexical process worker settles before the peer + // runtime can report that the install task has stopped. + app_invokes.close_admission(); + cancel_active_unrar_workers(app_handle); tauri::async_runtime::block_on(async move { - let Some(mut handle) = peer_runtime.write().await.take() else { - return; - }; - handle.shutdown(); - if tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait_stopped()) - .await - .is_err() - { - log::warn!("Peer runtime did not stop within 2s of shutdown request"); + // Once admission is closed, every earlier application invoke must + // return before the runtime can be taken. This both owns setup-process + // settlement and prevents a late invoke from touching or replacing the + // runtime behind this shutdown sweep. + app_invokes.wait_closed().await; + let handle = { peer_runtime.write().await.take() }; + if let Some(mut handle) = handle { + handle.shutdown(); + await_with_slow_warning( + handle.wait_stopped(), + Duration::from_secs(2), + "Peer runtime did not stop within 2s of shutdown request; continuing to wait", + ) + .await; } + background_tasks.shutdown().await; }); } +async fn await_with_slow_warning(future: F, warn_after: Duration, warning: &str) -> F::Output +where + F: std::future::Future, +{ + tokio::pin!(future); + tokio::select! { + output = &mut future => output, + () = tokio::time::sleep(warn_after) => { + log::warn!("{warning}"); + future.await + } + } +} + #[cfg(test)] mod tests { use super::*; + fn download_attempt(id: &str, attempt_id: u64) -> DownloadAttemptKey { + serde_json::from_value(serde_json::json!({ + "id": id, + "attempt_id": attempt_id.to_string(), + })) + .expect("test attempt key should deserialize") + } + + fn download_progress(attempt: DownloadAttemptKey) -> DownloadProgress { + DownloadProgress { + attempt, + downloaded_bytes: 10, + total_bytes: 100, + bytes_per_second: 5, + active_peer_count: 1, + } + } + + #[test] + fn game_transfer_attempt_fence_rejects_stale_equal_and_terminal_replay() { + let mut store = GameTransferStatusStore::default(); + let current = download_attempt("alpha", 20); + let stale = download_attempt("alpha", 19); + let successor = download_attempt("alpha", 21); + + assert_eq!( + store + .begin(¤t) + .expect("begin should apply") + .expect("new attempt should publish") + .revision, + 2 + ); + assert_eq!( + store.snapshot.open_attempts.get("alpha"), + Some(¤t.attempt_id), + "the full snapshot must expose the current open attempt" + ); + assert!( + store + .activity( + ¤t, + Some(DownloadVerificationActivity::RetryingInvalidSource), + ) + .expect("activity should apply") + .is_some() + ); + assert!( + store + .begin(¤t) + .expect("replay should parse") + .is_none() + ); + assert!(store.begin(&stale).expect("stale should parse").is_none()); + assert!( + store + .finished(&stale) + .expect("stale terminal should parse") + .is_none() + ); + assert_eq!( + store.snapshot.statuses.get("alpha"), + Some(&GameTransferStatus::Retrying), + "equal Begin and stale terminal must not erase current activity" + ); + + assert!( + store + .begin(&successor) + .expect("successor should apply") + .is_some() + ); + assert!(store.snapshot.statuses.is_empty()); + assert_eq!( + store.snapshot.open_attempts.get("alpha"), + Some(&successor.attempt_id), + "a successor Begin must replace the frontend progress fence" + ); + assert!( + !store.accepts_progress(&download_progress(current.clone())), + "old progress must not cross the successor fence" + ); + assert!(store.accepts_progress(&download_progress(successor.clone()))); + assert!( + store + .finished(&successor) + .expect("finish should apply") + .is_some() + ); + assert!( + store.snapshot.open_attempts.is_empty(), + "terminal settlement must remove the open attempt fence" + ); + assert!( + store + .failed( + &successor, + DownloadFailureReason::VerifiedCatalogSourcesExhausted + ) + .expect("terminal replay should parse") + .is_none(), + "an equal terminal replay must remain inert" + ); + } + + #[test] + fn terminal_only_failures_are_fenced_and_exhaustion_is_sticky() { + let mut store = GameTransferStatusStore::default(); + let exhausted = download_attempt("alpha", 30); + let preflight_failure = download_attempt("alpha", 31); + let explicit_retry = download_attempt("alpha", 32); + + assert!( + store + .failed( + &exhausted, + DownloadFailureReason::VerifiedCatalogSourcesExhausted, + ) + .expect("terminal-only exhaustion should apply") + .is_some() + ); + assert_eq!( + store.snapshot.statuses.get("alpha"), + Some(&GameTransferStatus::Exhausted) + ); + assert!( + store + .failed(&preflight_failure, DownloadFailureReason::OperationFailed,) + .expect("newer preflight failure should apply") + .is_some(), + "an accepted terminal-only operation failure still drives its generic event" + ); + assert_eq!( + store.snapshot.statuses.get("alpha"), + Some(&GameTransferStatus::Exhausted), + "a preflight failure without a clearing Begin must preserve sticky exhaustion" + ); + assert!( + store + .failed(&preflight_failure, DownloadFailureReason::OperationFailed,) + .expect("replay should parse") + .is_none(), + "the retained invisible receipt must suppress duplicate generic failure" + ); + + store + .begin(&explicit_retry) + .expect("explicit retry should apply") + .expect("newer Begin should publish the clear"); + assert!(store.snapshot.statuses.is_empty()); + store + .activity( + &explicit_retry, + Some(DownloadVerificationActivity::VerifyingDownloadedChunks), + ) + .expect("verification should apply") + .expect("verification should publish"); + store + .failed(&explicit_retry, DownloadFailureReason::OperationFailed) + .expect("current operation failure should apply") + .expect("current operation failure should publish None"); + assert!(store.snapshot.statuses.is_empty()); + assert!( + store + .activity( + &explicit_retry, + Some(DownloadVerificationActivity::RetryingInvalidSource), + ) + .expect("late activity should parse") + .is_none() + ); + } + + #[test] + fn local_generation_clears_only_settled_status_and_root_terminalizes_open_receipts() { + let mut store = GameTransferStatusStore::default(); + let exhausted = download_attempt("alpha", 40); + let open = download_attempt("bravo", 41); + store + .failed( + &exhausted, + DownloadFailureReason::VerifiedCatalogSourcesExhausted, + ) + .expect("exhaustion should apply") + .expect("exhaustion should publish"); + store + .begin(&open) + .expect("open attempt should apply") + .expect("open attempt should publish"); + store + .activity( + &open, + Some(DownloadVerificationActivity::VerifyingDownloadedChunks), + ) + .expect("open activity should apply") + .expect("open activity should publish"); + + store + .clear_settled_for_local_generation() + .expect("local generation should clear") + .expect("visible exhaustion should publish a replacement"); + assert_eq!( + store.snapshot.statuses, + BTreeMap::from([("bravo".to_owned(), GameTransferStatus::Verifying)]), + "a local generation clears settled exhaustion but preserves an open attempt" + ); + assert_eq!( + store.snapshot.open_attempts.get("bravo"), + Some(&open.attempt_id), + "local generation must preserve the exact open progress fence" + ); + assert!( + store + .failed( + &exhausted, + DownloadFailureReason::VerifiedCatalogSourcesExhausted, + ) + .expect("old terminal replay should parse") + .is_none(), + "local generation must retain the invisible terminal receipt" + ); + + store + .clear_for_root_change() + .expect("root reset should apply") + .expect("open activity should be visibly cleared"); + assert!(store.snapshot.statuses.is_empty()); + assert!( + store.snapshot.open_attempts.is_empty(), + "root replacement must clear every open progress fence" + ); + assert!( + store + .activity( + &open, + Some(DownloadVerificationActivity::RetryingInvalidSource), + ) + .expect("old root activity should parse") + .is_none(), + "root reset must make the old open receipt inert" + ); + assert!( + store + .finished(&open) + .expect("old root terminal should parse") + .is_none() + ); + assert!( + store + .begin(&download_attempt("bravo", 42)) + .expect("new root attempt should apply") + .is_some() + ); + } + + #[test] + fn game_transfer_snapshot_is_full_replacement_and_revision_overflow_is_atomic() { + let mut store = GameTransferStatusStore::default(); + let exhausted = download_attempt("alpha", 50); + store + .failed( + &exhausted, + DownloadFailureReason::VerifiedCatalogSourcesExhausted, + ) + .expect("exhaustion should apply") + .expect("exhaustion should publish"); + assert_eq!( + serde_json::to_value(store.snapshot()).expect("transfer snapshot should serialize"), + serde_json::json!({ + "revision": 2, + "statuses": { "alpha": "exhausted" }, + "openAttempts": {}, + }) + ); + + let before = store.snapshot(); + store.snapshot.revision = u64::MAX; + assert!(store.begin(&download_attempt("bravo", 51)).is_err()); + assert!(!store.attempts.contains_key("bravo")); + assert_eq!(store.snapshot.statuses, before.statuses); + assert_eq!(store.snapshot.open_attempts, before.open_attempts); + } + + #[test] + fn transfer_progress_and_open_attempts_serialize_exact_decimal_ids() { + let mut store = GameTransferStatusStore::default(); + let attempt = download_attempt("alpha", u64::MAX); + let snapshot = store + .begin(&attempt) + .expect("begin should apply") + .expect("new attempt should publish"); + + assert_eq!( + serde_json::to_value(snapshot).expect("transfer snapshot should serialize"), + serde_json::json!({ + "revision": 2, + "statuses": {}, + "openAttempts": { "alpha": "18446744073709551615" }, + }) + ); + assert_eq!( + serde_json::to_value(UiDownloadProgress::from(&download_progress(attempt))) + .expect("progress should serialize"), + serde_json::json!({ + "id": "alpha", + "attemptId": "18446744073709551615", + "downloaded_bytes": 10, + "total_bytes": 100, + "bytes_per_second": 5, + "active_peer_count": 1, + }) + ); + } + + #[test] + fn sharing_snapshot_distinguishes_policy_from_effective_network_state() { + let waiting = LocalNetworkSharingSnapshot::initial(true, None); + assert!(waiting.enabled); + assert_eq!( + waiting.phase, + LocalNetworkSharingPhase::WaitingForGameDirectory + ); + assert!(!waiting.is_stable_at(true)); + assert!(!waiting.admits_network_actions()); + + let enabled = LocalNetworkSharingSnapshot { + phase: LocalNetworkSharingPhase::Enabled, + ..waiting + }; + assert!(enabled.admits_network_actions()); + assert!( + !LocalNetworkSharingSnapshot { + pending_target: Some(false), + ..enabled + } + .admits_network_actions() + ); + + let disabled = + LocalNetworkSharingSnapshot::initial(false, Some(SharingPersistenceProblem::Load)); + assert!(!disabled.enabled); + assert_eq!(disabled.phase, LocalNetworkSharingPhase::Disabled); + assert!(disabled.is_stable_at(false)); + assert_eq!( + serde_json::to_value(disabled).expect("snapshot should serialize"), + serde_json::json!({ + "revision": 1, + "enabled": false, + "pendingTarget": null, + "phase": "disabled", + "persistenceProblem": "load", + }) + ); + } + + #[test] + fn peer_event_fence_drains_the_captured_transition_prefix_before_commit() { + let enabled = LocalNetworkSharingSnapshot { + revision: 7, + enabled: true, + pending_target: None, + phase: LocalNetworkSharingPhase::Enabled, + persistence_problem: None, + }; + let begun = + sharing_snapshot_after(enabled, SharingSnapshotMutation::Begin { target: false }); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + for state in [ + LocalNetworkSharingState::Disabled, + LocalNetworkSharingState::Enabling, + LocalNetworkSharingState::Disabling, + LocalNetworkSharingState::Disabled, + ] { + tx.send(state).expect("captured transition should enqueue"); + } + let captured = rx.len(); + tx.send(LocalNetworkSharingState::Enabling) + .expect("later generation should enqueue"); + + let fenced = take_exactly_queued(&mut rx, captured) + .expect("captured transition prefix should remain available") + .into_iter() + .fold(begun, |current, state| LocalNetworkSharingSnapshot { + phase: local_network_sharing_phase(state), + ..current + }); + assert_eq!(fenced.phase, LocalNetworkSharingPhase::Disabled); + assert_eq!(fenced.pending_target, Some(false)); + assert_eq!( + local_network_sharing_phase( + rx.try_recv() + .expect("post-fence traffic must remain queued") + ), + LocalNetworkSharingPhase::Enabling + ); + + let committed = sharing_snapshot_after( + fenced, + SharingSnapshotMutation::Commit { + enabled: false, + phase: Some(LocalNetworkSharingPhase::Disabled), + persistence_problem: None, + }, + ); + assert!(!committed.enabled); + assert_eq!(committed.pending_target, None); + assert_eq!(committed.phase, LocalNetworkSharingPhase::Disabled); + } + + #[test] + fn policy_commit_cannot_resurrect_enabled_after_automatic_disable() { + let fenced_enabled = LocalNetworkSharingSnapshot { + revision: 11, + enabled: false, + pending_target: Some(true), + phase: LocalNetworkSharingPhase::Enabled, + persistence_problem: None, + }; + let automatically_disabled = LocalNetworkSharingSnapshot { + revision: 12, + phase: LocalNetworkSharingPhase::Disabled, + ..fenced_enabled + }; + let committed = sharing_snapshot_after( + automatically_disabled, + SharingSnapshotMutation::Commit { + enabled: true, + phase: None, + persistence_problem: None, + }, + ); + + assert!(committed.enabled, "desired policy should still commit"); + assert_eq!(committed.pending_target, None); + assert_eq!(committed.phase, LocalNetworkSharingPhase::Disabled); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocked_disable_save_starts_only_after_current_admission_closed_event() { + use std::sync::atomic::AtomicBool; + + let begun_revision = 20; + let initial = LocalNetworkSharingSnapshot { + revision: begun_revision, + enabled: true, + pending_target: Some(false), + phase: LocalNetworkSharingPhase::Enabled, + persistence_problem: None, + }; + assert!(network_admission_closed_after(initial, begun_revision).is_none()); + + let (sharing_tx, sharing_rx) = watch::channel(initial); + let (command_tx, command_rx) = oneshot::channel(); + let network_accepts = Arc::new(AtomicBool::new(true)); + let accepts_during_save = Arc::clone(&network_accepts); + let (save_entered, entered) = std::sync::mpsc::channel(); + let (release_save, release) = std::sync::mpsc::channel(); + let transition = tokio::spawn(disable_runtime_then_persist( + begun_revision, + sharing_rx, + async move { + command_rx + .await + .map_err(|error| format!("test command reply dropped: {error}"))? + }, + || async { + panic!("a current Disabling event should avoid force-stop fallback"); + }, + move |closed| { + after_network_admission_closed(closed, || { + assert!( + !accepts_during_save.load(Ordering::SeqCst), + "blocked persistence must not overlap open network admission" + ); + save_entered + .send(()) + .expect("test save should report blocking"); + release.recv().expect("test should release blocked save"); + }); + Ok(()) + }, + )); + + tokio::task::yield_now().await; + assert!( + entered.try_recv().is_err(), + "persistence must not start before a current closure event" + ); + // Core closes admission before emitting the lifecycle state that + // crosses the Tauri watch. + network_accepts.store(false, Ordering::SeqCst); + sharing_tx.send_replace(LocalNetworkSharingSnapshot { + revision: begun_revision + 1, + phase: LocalNetworkSharingPhase::Disabling, + ..initial + }); + + tokio::task::spawn_blocking(move || { + entered + .recv_timeout(Duration::from_secs(1)) + .expect("production persistence closure should start after Disabling"); + }) + .await + .expect("save-entered waiter should join"); + assert!(!network_accepts.load(Ordering::SeqCst)); + release_save + .send(()) + .expect("blocked save should be released"); + command_tx + .send(Ok(false)) + .expect("test command should settle disabled"); + + let outcome = transition.await.expect("production helper should settle"); + assert_eq!(outcome.command_result, Ok(false)); + assert_eq!(outcome.persistence, Ok(())); + assert!(!outcome.force_stopped); + } + + #[test] + fn identity_diagnostic_exposes_only_ephemeral_durability() { + assert_eq!( + identity_diagnostic_for_durability(PeerIdentityDurability::Ephemeral), + Some(IdentityDiagnostic::Ephemeral) + ); + assert_eq!( + identity_diagnostic_for_durability(PeerIdentityDurability::Persistent), + None + ); + assert_eq!( + identity_diagnostic_for_durability(PeerIdentityDurability::CallerProvided), + None + ); + assert_eq!( + serde_json::to_value(IdentityDiagnosticSnapshot { + revision: 2, + diagnostic: Some(IdentityDiagnostic::Ephemeral), + }) + .expect("diagnostic should serialize"), + serde_json::json!({ + "revision": 2, + "diagnostic": "ephemeral", + }) + ); + } + + #[test] + fn forced_runtime_restart_reuses_process_identity_and_original_durability() { + let first_runtime_identity = Arc::new("ephemeral-one"); + let mut retained = None; + assert_eq!( + retain_installation_identity( + &mut retained, + Arc::clone(&first_runtime_identity), + PeerIdentityDurability::Ephemeral, + ), + PeerIdentityDurability::Ephemeral + ); + + let regenerated_identity = Arc::new("must-not-replace"); + assert_eq!( + retain_installation_identity( + &mut retained, + regenerated_identity, + PeerIdentityDurability::CallerProvided, + ), + PeerIdentityDurability::Ephemeral, + "explicit restart injection must not clear the original diagnostic" + ); + assert!(Arc::ptr_eq( + &retained + .as_ref() + .expect("first runtime identity should be retained") + .identity, + &first_runtime_identity + )); + } + + #[tokio::test] + async fn protocol_mismatch_is_replayed_when_it_precedes_frontend_registration() { + let state = LanSpreadState::default(); + let mismatch = ProtocolMismatch { + observed: Some(7), + expected: 8, + }; + + // Capture the snapshot query first, then model an event arriving while + // that delayed result is still in flight. Its lower revision lets the + // frontend discard it deterministically. + let delayed_snapshot = current_protocol_mismatch(&state).await; + let event_snapshot = record_protocol_mismatch(&state, mismatch).await; + + assert_eq!(delayed_snapshot.revision, 0); + assert_eq!(event_snapshot.revision, 1); + assert_eq!(event_snapshot.mismatch, Some(mismatch)); + assert_eq!(current_protocol_mismatch(&state).await, event_snapshot); + + let cleared = clear_protocol_mismatch(&state).await; + assert_eq!(cleared.revision, 2); + assert_eq!(cleared.mismatch, None); + } + + #[tokio::test] + async fn app_task_scope_shutdown_cancels_and_joins_tracked_tasks() { + let scope = AppTaskScope::default(); + let cancel_token = scope.cancel_token(); + let (settled_tx, settled_rx) = tokio::sync::oneshot::channel(); + scope.spawn(async move { + cancel_token.cancelled().await; + let _ = settled_tx.send(()); + }); + + scope.shutdown().await; + + settled_rx + .await + .expect("tracked task should settle before shutdown returns"); + } + + #[tokio::test] + async fn closed_app_task_scope_rejects_late_tasks() { + let scope = AppTaskScope::default(); + scope.shutdown().await; + let (ran_tx, ran_rx) = tokio::sync::oneshot::channel(); + + scope.spawn(async move { + let _ = ran_tx.send(()); + }); + + assert!( + ran_rx.await.is_err(), + "late task future should be dropped instead of detached" + ); + } + + #[tokio::test] + async fn app_invoke_shutdown_closes_admission_before_draining_every_invoke() { + let scope = AppInvokeScope::default(); + let first_guard = scope + .try_enter() + .expect("first application invoke should be admitted before shutdown"); + let second_guard = scope + .try_enter() + .expect("second application invoke should be admitted before shutdown"); + let mut shutdown = Box::pin(scope.close_and_wait()); + + assert!( + tokio::time::timeout(Duration::from_millis(10), shutdown.as_mut()) + .await + .is_err(), + "shutdown must wait for every admitted invoke guard" + ); + assert!( + scope.try_enter().is_none(), + "polling shutdown must close admission before waiting" + ); + + drop(first_guard); + assert!( + tokio::time::timeout(Duration::from_millis(10), shutdown.as_mut()) + .await + .is_err(), + "one settled invoke must not hide another live invoke" + ); + drop(second_guard); + tokio::time::timeout(Duration::from_secs(1), shutdown) + .await + .expect("shutdown should finish after the admitted invoke returns"); + } + + #[tokio::test] + async fn peer_startup_invokes_are_serialized_through_ui_commit() { + let scope = AppInvokeScope::default(); + let _first_invoke = scope + .try_enter() + .expect("first startup invoke should be admitted"); + let first_turn = scope.serialize_peer_startup().await; + let _second_invoke = scope + .try_enter() + .expect("second startup invoke should be admitted before shutdown"); + let mut second_turn = Box::pin(scope.serialize_peer_startup()); + + assert!( + tokio::time::timeout(Duration::from_millis(10), second_turn.as_mut()) + .await + .is_err(), + "a later invoke must not overtake the first invoke's UI commit" + ); + + drop(first_turn); + tokio::time::timeout(Duration::from_secs(1), second_turn) + .await + .expect("the next invoke should run after the prior commit releases its turn"); + } + + #[tokio::test] + async fn cancelled_runtime_slot_wait_never_creates_an_untracked_runtime() { + let slot = RwLock::new(None::<&'static str>); + let occupied_slot = slot.write().await; + let starts = AtomicU64::new(0); + let mut start = Box::pin(start_runtime_in_slot(&slot, || { + starts.fetch_add(1, Ordering::Relaxed); + Ok("runtime") + })); + + assert!( + tokio::time::timeout(Duration::from_millis(10), start.as_mut()) + .await + .is_err(), + "runtime start should wait for ownership-slot admission" + ); + drop(start); + assert_eq!( + starts.load(Ordering::Relaxed), + 0, + "cancelling the slot wait must happen before runtime creation" + ); + + drop(occupied_slot); + assert!(slot.read().await.is_none()); + + let published = start_runtime_in_slot(&slot, || { + starts.fetch_add(1, Ordering::Relaxed); + Ok("runtime") + }) + .await + .expect("an admitted runtime should be published synchronously"); + assert_eq!(published.as_ref().copied(), Some("runtime")); + assert_eq!(starts.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn slow_warning_keeps_waiting_for_the_owned_future() { + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let mut wait = tokio::spawn(async move { + await_with_slow_warning( + release_rx, + Duration::from_millis(5), + "controlled slow future", + ) + .await + }); + + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut wait) + .await + .is_err(), + "warning deadline must not drop the owned future" + ); + release_tx + .send(()) + .expect("controlled future should still be waiting"); + wait.await + .expect("wait task should not panic") + .expect("controlled future should complete"); + } + + #[test] + fn setup_marker_boundary_accepts_only_zero_process_exit() { + assert!(setup_process_exit_succeeded(0)); + assert!(!setup_process_exit_succeeded(1)); + assert!(!setup_process_exit_succeeded(u32::MAX)); + } + + #[test] + fn setup_launch_classification_never_owns_missing_or_invalid_handle() { + assert_eq!( + setup_launch_outcome("process handle", false, false), + SetupLaunchOutcome::Owned("process handle") + ); + assert_eq!( + setup_launch_outcome("null handle", true, true), + SetupLaunchOutcome::SettledWithoutProcess + ); + assert_eq!( + setup_launch_outcome("invalid handle", false, true), + SetupLaunchOutcome::ContractViolation + ); + } + + #[test] + fn setup_wait_error_retries_until_settlement_is_proven() { + let calls = Mutex::new(Vec::new()); + let termination_attempt = std::cell::Cell::new(0_u8); + let observation_attempt = std::cell::Cell::new(0_u8); + let error = settle_setup_after_wait_error( + "initial wait failed".to_string(), + || { + calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push("terminate"); + let attempt = termination_attempt.get(); + termination_attempt.set(attempt.saturating_add(1)); + if attempt == 0 { + Err("controlled termination failure".to_string()) + } else { + Ok(()) + } + }, + || { + calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push("observe"); + let attempt = observation_attempt.get(); + observation_attempt.set(attempt.saturating_add(1)); + if attempt == 0 { + SetupProcessObservation::NotSettled( + "controlled settlement wait failure".to_string(), + ) + } else { + SetupProcessObservation::Settled + } + }, + || { + calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push("retry"); + }, + ); + + assert_eq!( + *calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ["terminate", "observe", "retry", "terminate", "observe"] + ); + assert!(error.contains("initial wait failed")); + assert!(error.contains("controlled termination failure")); + assert!(error.contains("controlled settlement wait failure")); + assert!(error.contains("2 attempt(s)")); + assert!(error.contains("now settled")); + } + + #[test] + fn setup_wait_error_keeps_exit_status_proof_as_a_hard_error() { + let error = settle_setup_after_wait_error( + "initial wait failed".to_string(), + || Ok(()), + || { + SetupProcessObservation::SettledWithError( + "wait failed but exit status proved termination".to_string(), + ) + }, + || panic!("a proven-settled process must not retry"), + ); + + assert!(error.contains("initial wait failed")); + assert!(error.contains("exit status proved termination")); + assert!(error.contains("now settled")); + } + + #[test] + fn unrar_log_stream_marks_bounded_capture_truncation() { + assert_eq!( + format_unrar_log_stream(b"partial", true, "stdout"), + format!("partial\n[stdout truncated after {UNRAR_LOG_CAPTURE_LIMIT} bytes]") + ); + } + + fn registered_worker(registry: Arc>) -> RegisteredUnrarWorker { + RegisteredUnrarWorker::new(registry, 1, CancellationToken::new()) + } + + fn registered_unrar_count(registry: &Arc>) -> usize { + lock_unrar_mutex(registry, "test child registry") + .children + .len() + } + + #[test] + fn dropping_registered_unrar_worker_cancels_and_deregisters_it() { + let registry = Arc::new(Mutex::new(UnrarChildRegistry::default())); + let worker = registered_worker(registry.clone()); + let cancel_token = worker.cancel_token(); + + drop(worker); + + assert!(cancel_token.is_cancelled()); + assert_eq!(registered_unrar_count(®istry), 0); + } + + #[test] + fn unrar_shutdown_cancels_existing_and_late_registrations() { + let existing_registry = Arc::new(Mutex::new(UnrarChildRegistry::default())); + let existing = registered_worker(existing_registry.clone()); + let existing_cancel = existing.cancel_token(); + begin_unrar_shutdown(&existing_registry); + assert!(existing_cancel.is_cancelled()); + drop(existing); + + let late_registry = Arc::new(Mutex::new(UnrarChildRegistry::default())); + begin_unrar_shutdown(&late_registry); + let late = registered_worker(late_registry.clone()); + let late_cancel = late.cancel_token(); + assert!(late_cancel.is_cancelled()); + drop(late); + assert_eq!(registered_unrar_count(&late_registry), 0); + } + fn unpack_log_fixture(index: usize) -> UnpackLogEntry { let timestamp = u64::try_from(index).unwrap_or(u64::MAX); UnpackLogEntry { @@ -2514,6 +5666,72 @@ mod tests { } } + fn disk_catalog_bundle_fixture( + game_id: &str, + version: &str, + ) -> ( + PathBuf, + PathBuf, + CatalogBundle, + lanspread_db::content_manifest::ContentId, + ) { + use std::collections::BTreeMap; + + use lanspread_db::content_manifest::{ + Blake3Digest, + CATALOG_CONTENT_INDEX_NAME, + CatalogContentIndex, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + write_canonical_content_index_atomic, + write_canonical_manifest_atomic, + }; + + let digest = Blake3Digest::hash(version.as_bytes()); + let manifest = CatalogContentManifest::seal( + CatalogContentManifestBody::new( + game_id, + version, + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("version length should fit"), + digest, + vec![digest], + ) + .expect("catalog file should validate"), + ], + Vec::new(), + ) + .expect("catalog body should validate"), + ) + .expect("catalog manifest should seal"); + let root = std::env::temp_dir().join(format!( + "lanspread-tauri-remote-view-index-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )); + std::fs::create_dir(&root).expect("manifest root should be created"); + let manifest_path = root.join(format!("{game_id}.json")); + write_canonical_manifest_atomic(&manifest_path, &manifest) + .expect("fixture manifest should be written canonically"); + let index = CatalogContentIndex::from_manifests([&manifest]) + .expect("fixture content index should validate"); + write_canonical_content_index_atomic(&root.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("fixture content index should be written canonically"); + let content_id = manifest.content_id(); + let bundle = CatalogBundle::new( + &root, + BTreeMap::from([(game_id.to_owned(), version.to_owned())]), + ) + .expect("disk catalog bundle should validate index coverage"); + (root, manifest_path, bundle, content_id) + } + fn eti_game_fixture(game_id: &str, game_version: &str) -> lanspread_compat::eti::EtiGame { lanspread_compat::eti::EtiGame { game_id: game_id.to_string(), @@ -2532,6 +5750,147 @@ mod tests { } } + #[tokio::test] + async fn bundled_catalog_is_published_once_without_partial_replacement() { + use lanspread_db::content_manifest::{ + CATALOG_CONTENT_INDEX_NAME, + CatalogContentIdentity, + CatalogContentIndex, + CatalogContentIndexEntry, + ContentId, + write_canonical_content_index_atomic, + }; + + let root = std::env::temp_dir().join(format!( + "lanspread-tauri-catalog-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )); + std::fs::create_dir(&root).expect("manifest root should be created"); + std::fs::write(root.join("alpha.json"), b"opaque fixture\n") + .expect("manifest fixture should be written"); + let index = CatalogContentIndex::from_entries([CatalogContentIndexEntry { + game_id: "alpha".to_string(), + game_version: "20200721".to_string(), + identity: CatalogContentIdentity { + content_id: ContentId::from_bytes([1; 32]), + supports_streamed_install: false, + }, + }]) + .expect("catalog fixture index should validate"); + write_canonical_content_index_atomic(&root.join(CATALOG_CONTENT_INDEX_NAME), &index) + .expect("catalog fixture index should be written"); + + let make_bundle = || { + Arc::new( + CatalogBundle::new( + &root, + std::collections::BTreeMap::from([( + "alpha".to_string(), + "20200721".to_string(), + )]), + ) + .expect("catalog bundle should validate fixture coverage"), + ) + }; + let state = LanSpreadState::default(); + install_bundled_catalog_parts( + &state, + GameDB::from(vec![game_fixture("alpha", "Catalog Alpha")]), + make_bundle(), + ) + .await + .expect("first setup publication should succeed"); + + let error = install_bundled_catalog_parts( + &state, + GameDB::from(vec![game_fixture("beta", "Replacement Beta")]), + make_bundle(), + ) + .await + .expect_err("catalog setup must reject a second authority"); + + assert!(error.to_string().contains("initialized more than once")); + assert!(state.games.read().await.get_game_by_id("alpha").is_some()); + assert!(state.games.read().await.get_game_by_id("beta").is_none()); + assert_eq!( + state + .catalog_bundle + .get() + .expect("first authority should remain installed") + .catalog() + .expected_version("alpha"), + Some("20200721") + ); + + std::fs::remove_dir_all(root).expect("catalog fixture should be removed"); + } + + #[test] + fn streamed_install_capability_comes_from_the_exact_catalog_manifest() { + use lanspread_db::content_manifest::{ + Blake3Digest, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogExtractedEntry, + CatalogFileEntry, + }; + + fn manifest(id: &str, supports_streamed_install: bool) -> CatalogContentManifest { + let version = "20200721"; + let version_digest = Blake3Digest::hash(version.as_bytes()); + let streamed_install_files = supports_streamed_install + .then(|| { + CatalogExtractedEntry::file("account_name.txt", 4, Blake3Digest::hash(b"stub")) + .expect("streamed install fixture should validate") + }) + .into_iter() + .collect(); + CatalogContentManifest::seal( + CatalogContentManifestBody::new( + id, + version, + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("version length should fit u64"), + version_digest, + vec![version_digest], + ) + .expect("version fixture should validate"), + ], + streamed_install_files, + ) + .expect("catalog body should validate"), + ) + .expect("catalog manifest should seal") + } + + let state = LanSpreadState::default(); + state + .catalog_bundle + .set(Arc::new( + CatalogBundle::from_manifests([ + manifest("supported", true), + manifest("unsupported", false), + ]) + .expect("catalog fixture should validate"), + )) + .expect("catalog fixture should initialize once"); + + assert_eq!( + catalog_supports_streamed_install(&state, "supported"), + Ok(true) + ); + assert_eq!( + catalog_supports_streamed_install(&state, "unsupported"), + Ok(false) + ); + assert!(catalog_supports_streamed_install(&state, "unknown").is_err()); + } + #[test] fn eti_game_conversion_uses_catalog_version_as_authoritative_eti_version() { let game = Game::from(eti_game_fixture("alpha", "20200721")); @@ -2910,7 +6269,39 @@ mod tests { } #[test] - fn peer_remote_snapshot_updates_counts_without_overwriting_catalog_version() { + fn peer_remote_view_joins_only_exact_catalog_content() { + use lanspread_db::content_manifest::{ + Blake3Digest, + CatalogContentManifest, + CatalogContentManifestBody, + CatalogFileEntry, + ContentId, + }; + + let version = "20200721"; + let digest = Blake3Digest::hash(version.as_bytes()); + let manifest = CatalogContentManifest::seal( + CatalogContentManifestBody::new( + "alpha", + version, + vec![ + CatalogFileEntry::file( + "version.ini", + u64::try_from(version.len()).expect("version length should fit"), + digest, + vec![digest], + ) + .expect("catalog file should validate"), + ], + Vec::new(), + ) + .expect("catalog body should validate"), + ) + .expect("catalog manifest should seal"); + let content_id = manifest.content_id(); + let catalog_bundle = + CatalogBundle::from_manifests([manifest]).expect("catalog fixture should validate"); + let mut alpha = game_fixture("alpha", "Catalog Alpha"); alpha.size = 999; alpha.eti_game_version = Some("20200721".to_string()); @@ -2921,16 +6312,29 @@ mod tests { let mut game_db = GameDB::from(vec![alpha, beta]); - let mut peer_alpha = game_fixture("alpha", "Peer Alpha"); - peer_alpha.size = 42; - peer_alpha.peer_count = 3; - peer_alpha.eti_game_version = Some("20990101".to_string()); - - let mut unknown = game_fixture("unknown", "Unknown"); - unknown.peer_count = 1; - unknown.eti_game_version = Some("20990101".to_string()); - - apply_peer_remote_games(&mut game_db, vec![peer_alpha, unknown]); + apply_peer_remote_view( + &mut game_db, + &RemoteLibraryView { + games: vec![ + lanspread_peer::RemoteGameAvailability { + game_id: "alpha".to_owned(), + content_id, + peer_count: 3, + }, + lanspread_peer::RemoteGameAvailability { + game_id: "beta".to_owned(), + content_id: ContentId::from_bytes([7; 32]), + peer_count: 8, + }, + lanspread_peer::RemoteGameAvailability { + game_id: "unknown".to_owned(), + content_id: ContentId::from_bytes([9; 32]), + peer_count: 1, + }, + ], + }, + &catalog_bundle, + ); let alpha = game_db.get_game_by_id("alpha").expect("alpha remains"); assert_eq!(alpha.name, "Catalog Alpha"); @@ -2944,4 +6348,70 @@ mod tests { assert!(game_db.get_game_by_id("unknown").is_none()); } + + #[test] + fn peer_remote_view_uses_disk_index_without_loading_manifest_body() { + use lanspread_db::content_manifest::ContentId; + + let game_id = "alpha"; + let version = "20200721"; + let (root, manifest_path, catalog_bundle, content_id) = + disk_catalog_bundle_fixture(game_id, version); + let identity = catalog_bundle + .content_identity(game_id) + .expect("fixture identity should be available from the compact index"); + assert_eq!(identity.content_id, content_id); + assert_eq!( + catalog_bundle.catalog().expected_version(game_id), + Some(version) + ); + assert!(catalog_bundle.cached_manifest(game_id).is_err()); + std::fs::write(&manifest_path, b"broken after bundle construction\n") + .expect("manifest body should become invalid for the adapter proof"); + + let mut game_db = GameDB::from(vec![game_fixture(game_id, "Catalog Alpha")]); + apply_peer_remote_view( + &mut game_db, + &RemoteLibraryView { + games: vec![lanspread_peer::RemoteGameAvailability { + game_id: game_id.to_owned(), + content_id: identity.content_id, + peer_count: 3, + }], + }, + &catalog_bundle, + ); + assert_eq!( + game_db + .get_game_by_id(game_id) + .expect("catalog game should remain") + .peer_count, + 3 + ); + assert!(catalog_bundle.cached_manifest(game_id).is_err()); + + apply_peer_remote_view( + &mut game_db, + &RemoteLibraryView { + games: vec![lanspread_peer::RemoteGameAvailability { + game_id: game_id.to_owned(), + content_id: ContentId::from_bytes([7; 32]), + peer_count: 9, + }], + }, + &catalog_bundle, + ); + assert_eq!( + game_db + .get_game_by_id(game_id) + .expect("catalog game should remain") + .peer_count, + 0 + ); + assert!(catalog_bundle.cached_manifest(game_id).is_err()); + assert!(catalog_bundle.manifest(game_id).is_err()); + assert!(catalog_bundle.cached_manifest(game_id).is_err()); + + let _ = std::fs::remove_dir_all(root); + } } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/sharing_policy.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/sharing_policy.rs new file mode 100644 index 0000000..d189afd --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/sharing_policy.rs @@ -0,0 +1,483 @@ +use std::{ + fs::{self, File}, + io::{ErrorKind, Read, Write as _}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use cap_fs_ext::{ + FollowSymlinks, + OpenOptionsFollowExt as _, + OpenOptionsMaybeDirExt as _, + OpenOptionsSyncExt as _, +}; +use cap_primitives::{ + ambient_authority, + fs::{self as cap_fs, OpenOptions as CapOpenOptions}, +}; +use eyre::{WrapErr as _, ensure}; +use serde::{Deserialize, Serialize}; + +pub(crate) const POLICY_FILE_NAME: &str = "local-network-sharing.json"; + +const POLICY_VERSION: u32 = 1; +const MAX_POLICY_BYTES: u64 = 1024; +const UNIQUE_PATH_ATTEMPTS: usize = 64; + +static NEXT_POLICY_SIDECAR: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct PolicyRecord { + version: u32, + enabled: bool, +} + +struct PolicyLocation { + parent: File, + path: PathBuf, + file_name: PathBuf, +} + +struct UniquePolicySidecar { + file_name: PathBuf, + file: File, +} + +/// Loads the backend-owned Local network sharing policy. +/// +/// A missing record is the intentional first-run default (`true`). Every other +/// read, safety, size, version, or schema failure is returned to the caller so +/// setup can fail closed to a disabled session and expose only a redacted +/// diagnostic to the frontend. +pub(crate) fn load(path: &Path) -> eyre::Result { + let location = match open_policy_location(path) { + Ok(location) => location, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(true), + Err(error) => { + return Err(error).wrap_err("failed to retain Local network sharing policy directory"); + } + }; + let mut file = match open_regular_file_at(&location, &location.file_name) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(true), + Err(error) => return Err(error).wrap_err("failed to open Local network sharing policy"), + }; + let length = file + .metadata() + .wrap_err("failed to inspect Local network sharing policy")? + .len(); + ensure!( + length <= MAX_POLICY_BYTES, + "Local network sharing policy exceeds its size limit" + ); + + let mut bytes = Vec::with_capacity(usize::try_from(length).unwrap_or(0)); + Read::by_ref(&mut file) + .take(MAX_POLICY_BYTES + 1) + .read_to_end(&mut bytes) + .wrap_err("failed to read Local network sharing policy")?; + ensure!( + u64::try_from(bytes.len()).unwrap_or(u64::MAX) <= MAX_POLICY_BYTES, + "Local network sharing policy exceeds its size limit" + ); + + let record: PolicyRecord = + serde_json::from_slice(&bytes).wrap_err("failed to parse Local network sharing policy")?; + ensure!( + record.version == POLICY_VERSION, + "unsupported Local network sharing policy version" + ); + Ok(record.enabled) +} + +/// Durably replaces the backend-owned sharing policy with one canonical +/// versioned record. Publication never truncates the previously accepted file: +/// a same-directory create-new sidecar is synced, atomically installed, and +/// followed by a directory sync on platforms that support it. +pub(crate) fn save(path: &Path, enabled: bool) -> eyre::Result<()> { + save_with_publish(path, enabled, publish_sidecar) +} + +fn save_with_publish( + path: &Path, + enabled: bool, + publish: impl FnOnce(&PolicyLocation, &Path) -> std::io::Result<()>, +) -> eyre::Result<()> { + let parent = policy_parent_path(path)?; + fs::create_dir_all(parent) + .wrap_err("failed to create Local network sharing policy directory")?; + let location = open_policy_location(path) + .wrap_err("failed to retain Local network sharing policy directory")?; + let mut bytes = serde_json::to_vec(&PolicyRecord { + version: POLICY_VERSION, + enabled, + }) + .wrap_err("failed to encode Local network sharing policy")?; + bytes.push(b'\n'); + ensure!( + u64::try_from(bytes.len()).unwrap_or(u64::MAX) <= MAX_POLICY_BYTES, + "encoded Local network sharing policy exceeds its size limit" + ); + + let UniquePolicySidecar { + file_name, + mut file, + } = create_unique_file(&location)?; + let preparation = file + .write_all(&bytes) + .wrap_err("failed to write temporary Local network sharing policy") + .and_then(|()| { + file.sync_all() + .wrap_err("failed to sync temporary Local network sharing policy") + }); + // Close the sidecar before either publication or cleanup. Windows does not + // permit replacing/removing an open file unless the original handle opted + // into delete sharing, which this capability open deliberately does not + // assume. + drop(file); + if let Err(error) = preparation { + let _ = cap_fs::remove_file(&location.parent, &file_name); + return Err(error); + } + + let publication = publish(&location, &file_name) + .wrap_err("failed to publish Local network sharing policy") + .and_then(|()| sync_parent_directory(&location.parent)); + if publication.is_err() { + let _ = cap_fs::remove_file(&location.parent, &file_name); + } + publication +} + +fn create_unique_file(location: &PolicyLocation) -> eyre::Result { + for _ in 0..UNIQUE_PATH_ATTEMPTS { + let file_name = sidecar_file_name(&location.file_name); + let mut options = CapOpenOptions::new(); + options.write(true).create_new(true); + options.follow(FollowSymlinks::No).nonblock(true); + match cap_fs::open(&location.parent, &file_name, &options) { + Ok(file) => return Ok(UniquePolicySidecar { file_name, file }), + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error) + .wrap_err("failed to create temporary Local network sharing policy"); + } + } + } + eyre::bail!("could not allocate a unique Local network sharing policy sidecar") +} + +fn sidecar_file_name(file_name: &Path) -> PathBuf { + let sequence = NEXT_POLICY_SIDECAR.fetch_add(1, Ordering::Relaxed); + let file_name = file_name.to_str().unwrap_or("local-network-sharing.json"); + PathBuf::from(format!( + ".{file_name}.tmp-{}-{sequence}", + std::process::id() + )) +} + +fn publish_sidecar(location: &PolicyLocation, temporary_file_name: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt as _; + + use windows::{ + Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, + MOVEFILE_WRITE_THROUGH, + MoveFileExW, + }, + core::PCWSTR, + }; + + let temporary_path = location.path.with_file_name(temporary_file_name); + let temporary_wide = temporary_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination_wide = location + .path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: both pointers reference live, NUL-terminated UTF-16 buffers + // for the duration of the call. The sidecar is in the same retained + // directory as the destination. + unsafe { + MoveFileExW( + PCWSTR::from_raw(temporary_wide.as_ptr()), + PCWSTR::from_raw(destination_wide.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } + .map_err(std::io::Error::other) + } + + #[cfg(not(windows))] + { + cap_fs::rename( + &location.parent, + temporary_file_name, + &location.parent, + &location.file_name, + ) + } +} + +fn policy_parent_path(path: &Path) -> eyre::Result<&Path> { + let parent = path + .parent() + .ok_or_else(|| eyre::eyre!("Local network sharing policy path has no parent"))?; + if parent.as_os_str().is_empty() { + Ok(Path::new(".")) + } else { + Ok(parent) + } +} + +fn open_policy_location(path: &Path) -> std::io::Result { + let parent_path = policy_parent_path(path) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidInput, error.to_string()))?; + let file_name = path.file_name().ok_or_else(|| { + std::io::Error::new( + ErrorKind::InvalidInput, + "Local network sharing policy path has no file name", + ) + })?; + let parent = cap_fs::open_ambient(parent_path, &directory_options(), ambient_authority())?; + validate_directory_handle(&parent, parent_path)?; + Ok(PolicyLocation { + parent, + path: path.to_path_buf(), + file_name: file_name.into(), + }) +} + +fn open_regular_file_at(location: &PolicyLocation, file_name: &Path) -> std::io::Result { + let file = cap_fs::open(&location.parent, file_name, ®ular_file_options())?; + validate_regular_file_handle(&file, &location.path)?; + Ok(file) +} + +fn directory_options() -> CapOpenOptions { + let mut options = CapOpenOptions::new(); + options.read(true); + options + .maybe_dir(true) + .follow(FollowSymlinks::No) + .nonblock(true); + options +} + +fn regular_file_options() -> CapOpenOptions { + let mut options = CapOpenOptions::new(); + options.read(true); + options.follow(FollowSymlinks::No).nonblock(true); + options +} + +fn validate_directory_handle(file: &File, display: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_dir() || is_windows_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "Local network sharing policy parent is not a non-reparse directory: {}", + display.display() + ), + )); + } + Ok(()) +} + +fn validate_regular_file_handle(file: &File, display: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_file() || is_windows_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "Local network sharing policy is not a non-reparse regular file: {}", + display.display() + ), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn is_windows_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +const fn is_windows_reparse(_metadata: &fs::Metadata) -> bool { + false +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &File) -> eyre::Result<()> { + parent + .sync_all() + .wrap_err("failed to sync Local network sharing policy directory") +} + +#[cfg(not(unix))] +const fn sync_parent_directory(_parent: &File) -> eyre::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "lanspread-sharing-policy-test-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("test directory should be unique"); + Self(path) + } + + fn policy(&self) -> PathBuf { + self.0.join(POLICY_FILE_NAME) + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn missing_policy_defaults_to_enabled() { + let directory = TestDirectory::new(); + assert!(load(&directory.policy()).expect("missing policy should use first-run default")); + } + + #[test] + fn missing_policy_parent_defaults_to_enabled() { + let directory = TestDirectory::new(); + let policy = directory.0.join("not-created").join(POLICY_FILE_NAME); + assert!(load(&policy).expect("empty app data should use first-run default")); + } + + #[test] + fn exact_boolean_policy_round_trips_canonically() { + let directory = TestDirectory::new(); + let policy = directory.policy(); + + save(&policy, false).expect("false policy should persist"); + assert!(!load(&policy).expect("false policy should load")); + assert_eq!( + fs::read(&policy).expect("policy should be readable"), + b"{\"version\":1,\"enabled\":false}\n" + ); + + save(&policy, true).expect("true policy should replace false"); + assert!(load(&policy).expect("true policy should load")); + assert_eq!( + fs::read(&policy).expect("policy should be readable"), + b"{\"version\":1,\"enabled\":true}\n" + ); + } + + #[test] + fn malformed_unknown_or_oversized_policy_is_rejected() { + let directory = TestDirectory::new(); + let policy = directory.policy(); + let invalid_records = [ + b"not-json".as_slice(), + b"{\"version\":2,\"enabled\":true}", + b"{\"version\":1,\"enabled\":true,\"extra\":false}", + b"{\"version\":1,\"enabled\":\"yes\"}", + ]; + for record in invalid_records { + fs::write(&policy, record).expect("invalid fixture should be written"); + assert!(load(&policy).is_err(), "invalid record should fail closed"); + } + + fs::write( + &policy, + vec![ + b' '; + usize::try_from(MAX_POLICY_BYTES + 1).expect("test policy limit should fit usize") + ], + ) + .expect("oversized fixture should be written"); + assert!( + load(&policy).is_err(), + "oversized record should fail closed" + ); + } + + #[test] + fn non_regular_policy_is_rejected() { + let directory = TestDirectory::new(); + fs::create_dir(directory.policy()).expect("directory fixture should be created"); + assert!(load(&directory.policy()).is_err()); + } + + #[cfg(unix)] + #[test] + fn symlink_policy_is_rejected_without_following_it() { + use std::os::unix::fs::symlink; + + let directory = TestDirectory::new(); + let outside = directory.0.join("outside.json"); + fs::write(&outside, b"{\"version\":1,\"enabled\":true}\n") + .expect("target fixture should be written"); + symlink(&outside, directory.policy()).expect("symlink fixture should be created"); + + assert!(load(&directory.policy()).is_err()); + } + + #[test] + fn failed_publication_preserves_the_previous_policy() { + let directory = TestDirectory::new(); + let policy = directory.policy(); + save(&policy, false).expect("baseline policy should persist"); + + let error = save_with_publish(&policy, true, |_location, _sidecar| { + Err(std::io::Error::other("injected publication failure")) + }) + .expect_err("injected publication failure must surface"); + assert!(error.to_string().contains("publish")); + assert!(!load(&policy).expect("baseline policy should remain readable")); + } + + #[test] + fn post_publication_error_requires_an_explicit_false_repair() { + let directory = TestDirectory::new(); + let policy = directory.policy(); + save(&policy, false).expect("baseline policy should persist"); + + let _error = save_with_publish(&policy, true, |location, sidecar| { + publish_sidecar(location, sidecar)?; + Err(std::io::Error::other("injected post-publication failure")) + }) + .expect_err("post-publication failure must surface"); + assert!( + load(&policy).expect("published bytes should demonstrate ambiguous failure"), + "an error after replacement need not preserve the old policy" + ); + + save(&policy, false).expect("privacy repair should durably restore false"); + assert!(!load(&policy).expect("repaired policy should load disabled")); + } +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/tauri.conf.json b/crates/lanspread-tauri-deno-ts/src-tauri/tauri.conf.json index d75dd70..918d3ce 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/tauri.conf.json +++ b/crates/lanspread-tauri-deno-ts/src-tauri/tauri.conf.json @@ -36,6 +36,7 @@ ], "resources": [ "game.db", + "manifests/*", "assets/*" ] } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/tauri.dev.conf.json b/crates/lanspread-tauri-deno-ts/src-tauri/tauri.dev.conf.json new file mode 100644 index 0000000..26db703 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src-tauri/tauri.dev.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": { + "../../lanspread-peer-cli/catalogs/default/game.db": "game.db", + "../../lanspread-peer-cli/catalogs/default/manifests/": "manifests/", + "assets/*": "assets/" + } + } +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/tauri.production.conf.json b/crates/lanspread-tauri-deno-ts/src-tauri/tauri.production.conf.json new file mode 100644 index 0000000..ff43471 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src-tauri/tauri.production.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": [ + "game.db", + "manifests/*", + "assets/*" + ] + } +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/tests/catalog_build_gate.rs b/crates/lanspread-tauri-deno-ts/src-tauri/tests/catalog_build_gate.rs new file mode 100644 index 0000000..cc0e5b4 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src-tauri/tests/catalog_build_gate.rs @@ -0,0 +1,2 @@ +#[path = "../build_support/catalog_gate.rs"] +mod catalog_gate; diff --git a/justfile b/justfile index 98d4795..8aca98a 100644 --- a/justfile +++ b/justfile @@ -4,18 +4,24 @@ export DOCKER_CONFIG := env_var_or_default("DOCKER_CONFIG", ".lanspread-peer-cli default: run +FIXTURE_CATALOG_SOURCE := "crates/lanspread-tauri-deno-ts/src-tauri/game.db" +FIXTURE_CATALOG_ROOT := "crates/lanspread-peer-cli/catalogs" +FIXTURE_UNRAR := "crates/lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu" +TAURI_DEV_CONFIG := '{"bundle":{"resources":{"../../lanspread-peer-cli/catalogs/default/game.db":"game.db","../../lanspread-peer-cli/catalogs/default/manifests/":"manifests/","assets/*":"assets/"}}}' +TAURI_FIXTURE_ENV := "LANSPREAD_USE_FIXTURE_CATALOG=1" + setup: cargo install tauri-cli cd crates/lanspread-tauri-deno-ts && deno install --frozen=true -run: - cargo tauri dev --release +run: fixture-catalogs-check + {{ TAURI_FIXTURE_ENV }} cargo tauri dev --config crates/lanspread-tauri-deno-ts/src-tauri/tauri.dev.conf.json --release -build: - cargo tauri build --no-bundle #-- --profile dev +build: fixture-catalogs-check + {{ TAURI_FIXTURE_ENV }} cargo tauri build --config crates/lanspread-tauri-deno-ts/src-tauri/tauri.dev.conf.json --no-bundle #-- --profile dev -bundle: - cargo tauri build -- --profile production +bundle: catalog-check-production + cargo tauri build --config crates/lanspread-tauri-deno-ts/src-tauri/tauri.production.conf.json -- --profile production fmt: cargo +nightly fmt @@ -25,16 +31,88 @@ fmt: just --fmt _fix: - cargo fix --workspace --all-targets --all-features - cargo clippy --workspace --all-targets --all-features --fix + {{ TAURI_FIXTURE_ENV }} TAURI_CONFIG='{{ TAURI_DEV_CONFIG }}' cargo fix --workspace --all-targets --all-features + {{ TAURI_FIXTURE_ENV }} TAURI_CONFIG='{{ TAURI_DEV_CONFIG }}' cargo clippy --workspace --all-targets --all-features --fix fix: _fix fmt clippy: - cargo clippy --workspace --all-targets --all-features -- -D warnings + {{ TAURI_FIXTURE_ENV }} TAURI_CONFIG='{{ TAURI_DEV_CONFIG }}' cargo clippy --workspace --all-targets --all-features -- -D warnings test: - cargo test --workspace --all-targets --all-features + {{ TAURI_FIXTURE_ENV }} TAURI_CONFIG='{{ TAURI_DEV_CONFIG }}' cargo test --workspace --all-targets --all-features + +# Acceptance catalogs are derived only by the Rust publisher. The static +# profiles are committed artifacts; generation is an explicit maintainer task, +# while normal development and image builds run the package-free check. +fixture-catalogs: + cargo run -p lanspread-compat --bin lanspread-fixture-catalog -- \ + --source-catalog-db {{ FIXTURE_CATALOG_SOURCE }} \ + --output-dir {{ FIXTURE_CATALOG_ROOT }}/default \ + --unrar {{ FIXTURE_UNRAR }} \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-alpha/alienswarm \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-alpha/bf1942 \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-alpha/ggoo \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-bravo/bfbc2 \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-bravo/cnc4 \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-bravo/cnctw \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-charlie/cod5 \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-charlie/cod6 \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-charlie/coh \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-persona/css + cargo run -p lanspread-compat --bin lanspread-fixture-catalog -- \ + --source-catalog-db {{ FIXTURE_CATALOG_SOURCE }} \ + --output-dir {{ FIXTURE_CATALOG_ROOT }}/solid \ + --unrar {{ FIXTURE_UNRAR }} \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-solid/cnctw + cargo run -p lanspread-compat --bin lanspread-fixture-catalog -- \ + --source-catalog-db {{ FIXTURE_CATALOG_SOURCE }} \ + --output-dir {{ FIXTURE_CATALOG_ROOT }}/multi \ + --unrar {{ FIXTURE_UNRAR }} \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-multi/cnctw + cargo run -p lanspread-compat --bin lanspread-fixture-catalog -- \ + --source-catalog-db {{ FIXTURE_CATALOG_SOURCE }} \ + --output-dir {{ FIXTURE_CATALOG_ROOT }}/unknown \ + --unrar {{ FIXTURE_UNRAR }} \ + --game-root crates/lanspread-peer-cli/fixtures/fixture-unknown/cod2 + +fixture-catalog OUTPUT GAME_ROOT: + cargo run -p lanspread-compat --bin lanspread-fixture-catalog -- \ + --source-catalog-db {{ FIXTURE_CATALOG_SOURCE }} \ + --output-dir "{{ OUTPUT }}" \ + --unrar {{ FIXTURE_UNRAR }} \ + --game-root "{{ GAME_ROOT }}" + +fixture-download-only-catalog OUTPUT GAME_ID GAME_ROOT: + cargo run -p lanspread-compat --bin lanspread-fixture-catalog -- \ + --source-catalog-db {{ FIXTURE_CATALOG_SOURCE }} \ + --output-dir "{{ OUTPUT }}" \ + --unrar {{ FIXTURE_UNRAR }} \ + --game-root "{{ GAME_ROOT }}" \ + --no-stream-install "{{ GAME_ID }}" + +fixture-catalogs-check: + cargo run -p lanspread-compat --bin lanspread-catalog-publisher -- check \ + --catalog-db {{ FIXTURE_CATALOG_ROOT }}/default/game.db --all + cargo run -p lanspread-compat --bin lanspread-catalog-publisher -- check \ + --catalog-db {{ FIXTURE_CATALOG_ROOT }}/solid/game.db --all + cargo run -p lanspread-compat --bin lanspread-catalog-publisher -- check \ + --catalog-db {{ FIXTURE_CATALOG_ROOT }}/multi/game.db --all + cargo run -p lanspread-compat --bin lanspread-catalog-publisher -- check \ + --catalog-db {{ FIXTURE_CATALOG_ROOT }}/unknown/game.db --all + +catalog-check-production: + cargo run -p lanspread-compat --bin lanspread-catalog-publisher -- check \ + --catalog-db crates/lanspread-tauri-deno-ts/src-tauri/game.db --all + +catalog-publisher-test: + cargo test -p lanspread-compat --all-targets --all-features + +catalog-publisher-clippy: + cargo clippy -p lanspread-compat --all-targets --all-features -- -D warnings + +catalog-publisher-fmt: + cargo +nightly fmt --package lanspread-compat frontend-test: cd crates/lanspread-tauri-deno-ts && deno test --unstable-sloppy-imports tests @@ -45,7 +123,7 @@ clean: peer-cli-build: cargo build -p lanspread-peer-cli -peer-cli-image: +peer-cli-image: fixture-catalogs-check mkdir -p "$DOCKER_CONFIG" docker build -f crates/lanspread-peer-cli/Dockerfile -t lanspread-peer-cli:dev . @@ -78,7 +156,8 @@ peer-cli-run NAME: peer-cli-net --name "{{ NAME }}" \ --games-dir /games \ --state-dir /state \ - --catalog-db /app/game.db + --catalog-db /app/game.db \ + --manifests-dir /app/manifests peer-cli-alpha: peer-cli-net mkdir -p ".lanspread-peer-cli/alpha/state" @@ -89,7 +168,8 @@ peer-cli-alpha: peer-cli-net --name "alpha" \ --games-dir /games \ --state-dir /state \ - --catalog-db /app/game.db + --catalog-db /app/game.db \ + --manifests-dir /app/manifests peer-cli-bravo: peer-cli-net mkdir -p ".lanspread-peer-cli/bravo/state" @@ -100,7 +180,8 @@ peer-cli-bravo: peer-cli-net --name "bravo" \ --games-dir /games \ --state-dir /state \ - --catalog-db /app/game.db + --catalog-db /app/game.db \ + --manifests-dir /app/manifests peer-cli-charlie: peer-cli-net mkdir -p ".lanspread-peer-cli/charlie/state" @@ -111,4 +192,5 @@ peer-cli-charlie: peer-cli-net --name "charlie" \ --games-dir /games \ --state-dir /state \ - --catalog-db /app/game.db + --catalog-db /app/game.db \ + --manifests-dir /app/manifests diff --git a/key.pem b/key.pem deleted file mode 100644 index e27544c..0000000 --- a/key.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDJRaZ3P386Sf0O -69KHm8UX8gBpyRObFDkXCjhSmv9vzdjcv7hCq1ZT2gdbmnEmFBi0OWp4FJt6Yn50 -ySdN9diCod3zgwa889p/eRxyy2PrQ2DA87/TsN4FudeHMRqQqKhLRdg0QSZfmHYF -XVYIoMEKn0thqzQzMiv1DSZWqSg2iow7BSC7brRb8vSJq4z/ziXrbUODFWqjvej1 -Z2puS3rWPQs07teWYWQzKkj8Yzk8nt2PtbS4gy0PqlSXiPAvNJ96KyT8kECFFxf6 -JBpavLN5ny6fLZbvxQja0LpSlZsAGT1aM0XAiMM1J2EOQWt2I50Ho75jxnxfLSbV -qKW4Ev0clRa4avUgxCUG0SiHXqEKuOdh4oCaOrxSNnAY+OUm3bepkFaxcyXa3a8v -cyhJhRjdQH05bAPQJd1PW+6VvEzs5YLy4PFThc+0aMsjZZWRFVOLJMDySsJitllQ -NewbvSW8tRN1mh/D/acR8TN3IzNJUCw75pFdt0NSr0vCS0BaLyoQPrVb8+DUlvFb -JWlDbzhkMLeJw1M64jInHc4gSXFiNYzJGuDQDnz2A6ZhQvp6zh0/+7uW9y9oCt+2 -QfUwJCkLnIC7R0nbRVtnysHmouX6tcAkilZcG+eA6FHw+h9sEtiCh0ZsZZfpT0A7 -i7WzZMWmAe/4RhT/qfA+DlDyAp4tqwIDAQABAoICAC0p9eq4UuJPi/t3K2zGpXl0 -EmeqeT3JUe47mtvecAc0l78hPkWnkN2MBS6m/1DeHZUDdUKwzRqvU1T9dlZmHklh -7R4hfreTuKn2EU4pGajHG8TwbVEhVRDP3O83M5DWZ71MVPGU1PmKiqE1ioKxH+A0 -UoP+GN6MGZUJeFrl5mImwMTVp7ynYM1pPTpPRnp4VcX4ZLfbbGyFxuqaZCWPxmag -mA0usy3JeOSKTopj8YoK1AjVa1IkDU6AmC7QyaSuGEMfv1L1Q6UZw4Wb6FYIFfXq -pFmPr1jqS8xeHhE+BOQGxd+htbs4dSVOStcaEvUVlL0MH0LgXTQ2O4qiJ12g1S7Y -ash1Q/uU+zOPfEcJHyEKn1Rc/PpgyWFefff909YvMn4gjopDXP+BK+xWcH3HqVFj -MaY32Ivy2UQmWjEOzgvK0YX1vMZvMwUcjHzI/p3gXve3B8BDDimvY+WiLu3LuvJ0 -QDG24XDPMPApU9jkclNScdz5UgJpqOSmyFgCd3zgeXhhJjIBNBS7onDsI1Ismtpp -40/wEnBZv8PptUAXkaLjQA5NczyzHmoIneUEtWTDecl83FIiZ+SZWmDY8Cd+hShW -1l0ZEUX3TV27V5xfV1Nw7xAsOgYQrhF8fSuT8FmgMz3b9OqrEaCXQGtdISa/Ks3J -bQT+fois2xHogiVqKgBRAoIBAQD5YnjOCr4I5fr3nSHoS8wMuKLKEE6r32EqO5Os -m/2lLPbG/FMGOQckk+mmWP3CwP2KRpYRixFrHzkcWEDICdAKPrZc4Y1+zBACAhzw -ANpNoIaV3zSVU4rG+FbJ627Ay+f0fbTwJpAH3qDjXaC82kYo0DM/58EycsOi816T -OFLpd/bt0pSsVYZhAjlG3jeeqceeDZTUYYPgySHNlouM0QjcvECXkVzmp3y/FBxM -RQ0qTXlmv8X9MMU2wFYPqZQ1xV9aPKudYUGZo6xJBMmNigzcV/klfCPQwjdd5j2l -Q9Yy+22vS4Y/PYe8rN+tLwaLhUZ0VXmh10BdV/mW1py1SQY9AoIBAQDOnHRA49NX -8C3jOeI3qW0n6UWeLWPu45az/l+zV+NK+3pvucjS8rIfeYcA6hmoG5/Zz9Zm7CC/ -Cd0Emnw1fNOoqAodlg4G2YJ/nagMTv9sXOrCfO1SlXD1PDDqr/A8/aLFV+ukhaet -MlPWyp99g4a+Z3+PmPB3G//Amo931wTwtFjYB81h98Hxbl/C9wEXvMJwg7KEBMBv -8rkKkL9fL4nCmmcQSYdOxfXszVunLrNcU0rwqCMGSL3BGJ43FR1vF3srNKOSH3yW -8cCT2rZBi6XYhxn2sSrF1XybDRrjkZMmNgjYoMj5eG7bW5LID5uKKHAw6PZG36ei -mBR/fApfjSoHAoIBAHa0a6JPtLMRnUsdzVUAN0la3YnnBzuCYYKzxxAjVDG6XGDN -HEva+05q5Y2b0HZSXJzkCmyhUJI303xtebB+Ezu9LBq79hkD0x7RmKqZDVBj94wB -Kctmb6lT7iPA3///TxKuf2DMSkSksNpo56jdEQY+TBbAHtL/k6XNyBe1eKnOw4fi -c4gwUX19jHvyLHFmiTDvcAdDv31Q37k3ToehvGEtbmV2+MyFrrhZPzsCp0iahg3l -fL2O6GCuXoioBHcv0rpmJJ4N/CbApLJBCBtKOeLsMRippXap66bTgEZZKu0rhsMo -5ObXR/QEKZgKyUfCEY4wXWwuxGFqDdDBqOgVsVUCggEBAIiF10edg2RjP18bO4C+ -QnOR77+ijJso5ccP+dq+42EDpRa4c5v4ZHzpx6xyA/wSLOE3NZwSwVyavuGw1wxc -7FtQlkaQhbo+9b0vxwBDHwJSr4lOFV9xgg9583TQRvV2P50ZCItRCcgnLkEK6LJ+ -O4YLS73uOE7sQGXbe5ubiBcphF9TYIIwvYW0AjEJGD7AKtdAHrCfly0h/OWfWeUi -u2vMrPeVLoR9yI/t7ncdI8WJAEbfQy1+2WwDwV/yYt2qbWfSQ5dlmOUA39iTN8U9 -6puWQjDBRtssw0aNNUxUQCCKqfPC4qn9y3rFGst5jLSRHfkjpsCUPufwh/LpWs1i -sPcCggEBAOZ7qxKGhRrNZs/3aZYOB8SwsvUKZiXXCjBbP5HRRF0Ki/CZfk1tKNAR -tdEzkSKlW8jC88ibel/5WMRPOrKc8g72fecXHDZnfmEK92fHJrOcPgUz+wFjegHJ -Bmy0UPKaEWurXjsCRxg/mgxePgtE8+Qwf88WTuS1WLgeQMkUixj+uT9rfzo7FzJ5 -6+pBALH/2IcspgTwTDaHn5F2gymADbvonMCu1q1HeU+u12OLf22fNzav4IoajWGE -Ooa8rve8XgOnedveI27MDFl1wRuSBlLTsv32KjijiNvDWM5dzkryqZ+oclmzJ2gz -SgC26YXtqG5e9iICQGtZ4NFIQ+7mciY= ------END PRIVATE KEY-----