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
This commit is contained in:
2026-08-10 13:59:18 +02:00
parent 36c4785775
commit 60fd7ba0c2
128 changed files with 51759 additions and 10784 deletions
+7
View File
@@ -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"
+3 -2
View File
@@ -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"]
Binary file not shown.
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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}}}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
Binary file not shown.
@@ -0,0 +1 @@
{"schema_version":1,"games":{"cnctw":{"game_version":"20160128","content_id":"629012cd3c4a2f7bcbc3d1f40bf111381cd08b98ffb571747623e3912b586344","supports_streamed_install":true}}}
@@ -0,0 +1,50 @@
{
"schema_version": 1,
"game_id": "cnctw",
"game_version": "20160128",
"chunk_size": 134217728,
"files": [
{
"canonical_path": "a-first.eti",
"kind": "file",
"size": 103,
"file_blake3": "a67333b9f370ff284a41e8c4af1a74881a46c32fe7bee77c70230c6b0fb53a6e",
"chunk_blake3": [
"a67333b9f370ff284a41e8c4af1a74881a46c32fe7bee77c70230c6b0fb53a6e"
]
},
{
"canonical_path": "version.ini",
"kind": "file",
"size": 8,
"file_blake3": "995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a",
"chunk_blake3": [
"995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a"
]
},
{
"canonical_path": "z-second.eti",
"kind": "file",
"size": 105,
"file_blake3": "465e3bf1e077a114422a75095271072627b9a9aaf7e72ae7c9661f9ac972ccaf",
"chunk_blake3": [
"465e3bf1e077a114422a75095271072627b9a9aaf7e72ae7c9661f9ac972ccaf"
]
}
],
"streamed_install_files": [
{
"canonical_path": "order/first.txt",
"kind": "file",
"size": 22,
"file_blake3": "f0440582fb0af9b05a9755bf5764127ecc09a0f57aa267599770cbec189ebb54"
},
{
"canonical_path": "order/second.txt",
"kind": "file",
"size": 23,
"file_blake3": "b931fd872258655d87983bdc7d56bc94d30b31046c4184487121bc3e012330e5"
}
],
"content_id": "629012cd3c4a2f7bcbc3d1f40bf111381cd08b98ffb571747623e3912b586344"
}
Binary file not shown.
@@ -0,0 +1 @@
{"schema_version":1,"games":{"cnctw":{"game_version":"20160128","content_id":"c572c586174ceb5c40bd15e8b02784b3159464c4f8c87a9b62a4d6ed801be4d5","supports_streamed_install":true}}}
@@ -0,0 +1,53 @@
{
"schema_version": 1,
"game_id": "cnctw",
"game_version": "20160128",
"chunk_size": 134217728,
"files": [
{
"canonical_path": "cnctw.eti",
"kind": "file",
"size": 311,
"file_blake3": "a6e31754a470b0d8001040b2eece106c0becd84d50a18b03faae1bc7a8df4e43",
"chunk_blake3": [
"a6e31754a470b0d8001040b2eece106c0becd84d50a18b03faae1bc7a8df4e43"
]
},
{
"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-solid-payload.bin",
"kind": "file",
"size": 40,
"file_blake3": "5ed7b587a848236aa68465d2729358beaec595be9edfaea6a20f66fcb03b9a7c"
},
{
"canonical_path": "data",
"kind": "directory",
"size": 0,
"file_blake3": null
},
{
"canonical_path": "data/cnctw-solid-assets.dat",
"kind": "file",
"size": 78,
"file_blake3": "183868e423478db1e256fd1c3dd3b1b767acb747b4fde1985db56387e418185c"
}
],
"content_id": "c572c586174ceb5c40bd15e8b02784b3159464c4f8c87a9b62a4d6ed801be4d5"
}
Binary file not shown.
@@ -0,0 +1 @@
{"schema_version":1,"games":{"cod2":{"game_version":"20160922","content_id":"6f60950ccdb7ec12e8e9b68210bb345994490dd5ca761c063ac27a6caf250071","supports_streamed_install":false}}}
@@ -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"
}
@@ -0,0 +1 @@
This package is known only to the source peer's fixture catalog profile.
@@ -0,0 +1 @@
20160922
+368 -35
View File
@@ -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<String>,
language: Option<String>,
persona_name: Option<String>,
},
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<CommandEnvelope> {
"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<CommandEnvelope> {
},
"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<CommandEnvelope> {
.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::<PeerId>()
.wrap_err("peer_id is not a canonical peer ID")?;
let addr = required_str(object, "addr")?
.parse::<SocketAddr>()
.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<String, Value>,
field: &'static str,
) -> eyre::Result<Option<String>> {
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<String, Value>, field: &'static str) -> eyre::Result<u64> {
object
.get(field)
@@ -241,11 +285,28 @@ pub fn seed_fixture_game(game_dir: &Path, fixture_name: &str) -> eyre::Result<Fi
pub struct FixtureUnpacker;
impl Unpacker for FixtureUnpacker {
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 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<Value>, command: &str, data: Value) -> eyre::Result<String> {
output_line(json!({
"type": "result",
@@ -317,6 +412,8 @@ fn output_line(value: Value) -> eyre::Result<String> {
#[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<ExternalUnrarUnpacker>, 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());
}
}
+1608 -268
View File
@@ -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<PathBuf>,
identity_file: Option<PathBuf>,
catalog_db: PathBuf,
manifests_dir: PathBuf,
fixtures: Vec<String>,
unrar: Option<PathBuf>,
}
@@ -99,10 +117,8 @@ struct CliState {
local_games: Vec<Game>,
remote_games: Vec<Game>,
active_operations: Vec<ActiveOperation>,
game_files: HashMap<String, Vec<GameFileDescription>>,
unavailable_games: HashSet<String>,
downloads: HashMap<String, DownloadMeasurement>,
call_to_play_events: Vec<CallToPlayEvent>,
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<CliState>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<GameCatalog>>,
catalog_game_db: Arc<GameDB>,
catalog_bundle: Option<Arc<CatalogBundle>>,
call_to_play_display_name: RwLock<String>,
active_outbound_transfers: OutboundTransfers,
notify: Notify,
games_dir: PathBuf,
games_dir: RwLock<PathBuf>,
state_dir: PathBuf,
}
#[derive(Debug)]
enum CommandLoopExit {
Eof,
Shutdown { request_id: Option<Value> },
Signal,
}
enum CommandInput {
#[cfg(unix)]
Pollable(UnixStdin),
#[cfg(unix)]
Buffered(Cursor<Vec<u8>>),
#[cfg(not(unix))]
Blocking(tokio::io::Stdin),
}
impl CommandInput {
#[cfg(unix)]
fn open() -> io::Result<Self> {
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<Self> {
// 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<Self> {
Ok(Self::Blocking(tokio::io::stdin()))
}
}
impl AsyncRead for CommandInput {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut TaskContext<'_>,
buffer: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
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<std::fs::File>,
#[cfg(test)]
poll_started: Option<Arc<Notify>>,
}
#[cfg(unix)]
impl UnixStdin {
fn new(file: std::fs::File) -> io::Result<Self> {
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<Notify>) -> 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<io::Result<()>> {
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<dyn lanspread_peer::Unpacker> = 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<Option<Arc<PeerIdentity>>> {
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<PeerCommand>,
handle: &mut PeerRuntimeHandle,
input: CommandInput,
sender: mpsc::UnboundedSender<PeerCommand>,
shared: Arc<SharedState>,
writer: JsonlWriter,
) -> eyre::Result<()> {
let stdin = BufReader::new(tokio::io::stdin());
let mut lines = stdin.lines();
) -> eyre::Result<CommandLoopExit> {
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<PeerCommand>,
handle: &mut PeerRuntimeHandle,
shared: &Arc<SharedState>,
) -> eyre::Result<Value> {
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<Signal, Shutdown>(
mut command_task: JoinHandle<eyre::Result<CommandLoopExit>>,
mut event_task: JoinHandle<()>,
signal: Signal,
shutdown: Shutdown,
) -> eyre::Result<CliScopeOutcome>
where
Signal: Future<Output = eyre::Result<()>>,
Shutdown: Future<Output = ()>,
{
enum FirstTask {
Command(Result<eyre::Result<CommandLoopExit>, 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<Terminate>(
outcome: CliScopeOutcome,
input_cancellation_is_quiescent: bool,
terminate_process: Terminate,
) -> eyre::Result<CommandLoopExit>
where
Terminate: FnOnce(i32, Option<&eyre::Report>) -> eyre::Result<CommandLoopExit>,
{
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<PeerCommand>,
shared: &SharedState,
path: &Path,
) -> eyre::Result<Value> {
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<Value> {
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<Value> {
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
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<Value> {
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<PeerCommand>,
shared: &SharedState,
game_id: &str,
) -> eyre::Result<Vec<GameFileDescription>> {
{
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<PeerEvent>,
shared: Arc<SharedState>,
@@ -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::<Vec<_>>();
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::<HashSet<_>>();
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<Game> {
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::<Vec<_>>();
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<DownloadFailureReason>,
) -> (&'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<Value> {
@@ -700,11 +1161,12 @@ fn peer_snapshots_json(peers: &[PeerSnapshot]) -> Vec<Value> {
.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<Vec<Fixtu
.collect()
}
async fn load_catalog(catalog_db: Option<&Path>, 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<CatalogBundle>)> {
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<Args> {
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<Item = OsString>) -> eyre::Result<Args> {
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<Args> {
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> {
[
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<PathBuf> {
@@ -811,10 +1320,841 @@ fn next_path(args: &mut impl Iterator<Item = OsString>, 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::<Result<Vec<_>, _>>()
.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::Result<CommandLoopExit>>,
) -> 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::<eyre::Result<()>>();
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<CommandLoopExit> {
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::<eyre::Result<CommandLoopExit>>());
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::<eyre::Result<()>>(),
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<AtomicBool>);
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");
}
}