diff --git a/Cargo.lock b/Cargo.lock index baf943e..d7a2ef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -427,23 +427,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chrono" version = "0.4.45" @@ -571,15 +554,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -1008,10 +982,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -1035,11 +1007,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -1260,22 +1229,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1560,7 +1513,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -1679,12 +1632,6 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lzma-sys" version = "0.1.20" @@ -2195,62 +2142,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.46" @@ -2279,18 +2170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -2300,7 +2180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -2312,21 +2192,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "rcgen" version = "0.13.2" @@ -2432,22 +2297,17 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-util", "js-sys", "log", "mime_guess", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2457,7 +2317,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] @@ -2554,12 +2413,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2603,7 +2456,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ - "web-time", "zeroize", ] @@ -2727,7 +2579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -2738,7 +2590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -3164,21 +3016,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.52.4" @@ -3416,7 +3253,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.5", + "rand", "rustls", "rustls-pki-types", "sha1", @@ -3722,16 +3559,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-roots" version = "1.0.8" diff --git a/INSTALL.md b/INSTALL.md index b097d77..4d7a7a8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,8 +1,12 @@ # installing solstone-linux -these instructions are for a coding agent and human working together. solstone-linux is a standalone observer that experiences your screen and audio along with you on Linux desktops using PipeWire and GStreamer, and syncs to your solstone journal. +these instructions are for a coding agent and human working together. sol for +Linux experiences your screen and audio along with you using PipeWire and +GStreamer, and syncs segments to your journal. -solstone must already be installed and running. if it isn't, start there: https://solstone.app/install +your journal must already be available. if it is not, start there: +https://solstone.app/install. create a pair link for this device in your journal +and save it as `pair-link.txt`. ## system dependencies @@ -35,7 +39,9 @@ the archive: ```bash scripts/install.sh solstone-linux--linux-x86_64.tar.gz solstone-linux install-service -solstone-linux setup +systemctl --user stop solstone-linux +solstone-linux setup < pair-link.txt +systemctl --user start solstone-linux ``` If you downloaded only the tarball, obtain `scripts/install.sh` from the @@ -44,9 +50,21 @@ matching release source. Alternatively, extract the archive, copy beneath the same installation prefix before running the service and setup commands. -the archive includes `INSTALL-NOTES`, which is the canonical cross-distribution runtime dependency list. Native packages install the same observer binary and icon set. The service command writes the systemd user unit and desktop autostart entry, enables the unit, and starts sol. +the archive includes `INSTALL-NOTES`, which is the canonical cross-distribution +runtime dependency list. Native packages install the same binary and icon set. +The service command writes the systemd user unit and desktop autostart entry, +enables the unit, and starts sol. -`setup` registers the observer through the local `http://localhost:5015` journal link by default. For a journal reached directly, use `solstone-linux setup --server-url `. +pairing is the only setup path: + +```bash +solstone-linux setup < pair-link.txt +``` + +the pair link comes from your journal. a journal on the same machine uses the +same private link as any other journal. there is no URL, key, local installation +of the journal or Python, or direct fallback to configure. sol can continue +capturing while unpaired or offline and saves segments locally. ## build from source @@ -58,19 +76,29 @@ cd solstone-linux make bootstrap make ci make install-service -solstone-linux setup +systemctl --user stop solstone-linux +solstone-linux setup < pair-link.txt +systemctl --user start solstone-linux ``` -`rust-toolchain.toml` selects the exact compiler, components, and target. `make install` explicitly establishes them and cargo-deny before installing the observer. +`rust-toolchain.toml` selects the exact compiler, components, and target. `make +install` explicitly establishes them and cargo-deny before installing sol. ## update from source ```bash +systemctl --user stop solstone-linux git pull make ci make install-service +solstone-linux setup < pair-link.txt +systemctl --user start solstone-linux ``` +setup and runtime deliberately share one private-state lock. stop sol before +pairing. if sol is running, setup exits before consuming any input and leaves +capture, config, and private state unchanged. + ## verify ```bash @@ -82,11 +110,11 @@ solstone-linux status Activity detection uses screen-lock and power-save signals to notice when you step away. GNOME provides both signals; KDE Wayland provides screen lock; X11 can also provide DPMS power save. Where neither signal is available, solstone-linux still experiences your screen and audio, but activity-based segment boundaries do not trigger. -The tray uses the StatusNotifierItem D-Bus protocol. KDE supports it directly. GNOME requires an AppIndicator extension; without an SNI host, the observer continues normally without a tray icon. +The tray uses the StatusNotifierItem D-Bus protocol. KDE supports it directly. +GNOME requires an AppIndicator extension; without an SNI host, sol continues +normally without a tray icon. -## PyPI history +## historical note: version 0.4.5 -Previously published PyPI versions remain available at version 0.4.5 for legacy -availability. They contain the retired pre-native Python implementation; -current releases are the native Debian, RPM, and portable packages described -above. +version 0.4.5 was the final pre-native Python release. current installation uses +the native Debian, RPM, or portable package described above. diff --git a/Makefile b/Makefile index 31b1ffc..6a66df2 100644 --- a/Makefile +++ b/Makefile @@ -87,6 +87,17 @@ check-observer-contract: rust-preflight tail -50 "$$output"; \ grep -Eq 'test result: ok\. 1 passed; 0 failed' "$$output" || { echo "error: observer contract named test did not execute" >&2; exit 1; } +check-package-audit: rust-preflight + @test -n "$(strip $(TAR))" || { echo "error: TAR is required" >&2; exit 1; } + @test -n "$(strip $(DEB))" || { echo "error: DEB is required" >&2; exit 1; } + @test -n "$(strip $(RPM))" || { echo "error: RPM is required" >&2; exit 1; } + @test -n "$(strip $(EXPECTED_EXECUTABLE_SHA256))" || { echo "error: EXPECTED_EXECUTABLE_SHA256 is required" >&2; exit 1; } + CARGO_NET_OFFLINE=true $(CARGO) run $(CARGO_LOCKED) -p rust-release-manifest -- audit-packages \ + --tar "$(TAR)" \ + --deb "$(DEB)" \ + --rpm "$(RPM)" \ + --expected-executable-sha256 "$(EXPECTED_EXECUTABLE_SHA256)" + check-rust-release-manifest: rust-preflight @echo "Rust release manifest schema: 1" @echo "Rust release manifest schema SHA-256: d4eabf52bcc68b56945912d351f818e5444fe8c6461cb5c48b096f87b17a875c" diff --git a/README.md b/README.md index 35a3fb2..bee6650 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # solstone-linux -Standalone Linux desktop observer for [solstone](https://solpbc.org). Experiences your screen and audio along with you on a GNOME Wayland session, stores segments locally, and syncs to your solstone journal. +sol for Linux experiences your screen and audio along with you on a GNOME +Wayland session, stores segments locally, and syncs them to your journal on +[solstone](https://solpbc.org). **Note:** Activity detection uses screen-lock and power-save signals to notice when you step away. Coverage varies by desktop: GNOME provides both signals; KDE (Wayland) provides screen lock only; any X11 session also provides DPMS power save; other Wayland desktops provide screen lock where the compositor exposes it. Where neither signal is available, solstone-linux still experiences your screen and audio, but activity-based segment boundaries won't trigger. @@ -28,7 +30,9 @@ sudo zypper install libpulse0 gstreamer gstreamer-plugins-base gstreamer-plugins ## Install -solstone (the journal) must already be installed and running on the host this observer reports to. If it isn't, start with the [journal install](https://solstone.app/install). +Your journal must already be available. If it is not, start with the +[solstone install](https://solstone.app/install). In your journal, create a pair +link for this device and save it as `pair-link.txt`. Install a native Debian/RPM package from the release. From a matching source checkout, the portable archive installer is: @@ -36,7 +40,9 @@ checkout, the portable archive installer is: ```bash scripts/install.sh solstone-linux--linux-x86_64.tar.gz solstone-linux install-service -solstone-linux setup +systemctl --user stop solstone-linux +solstone-linux setup < pair-link.txt +systemctl --user start solstone-linux ``` The installer is distributed in the source repository, not inside the @@ -46,7 +52,11 @@ to a directory on `PATH` and `share/icons/hicolor` beneath the same prefix. The archive includes `packaging/INSTALL-NOTES`, the canonical runtime-dependency list. See `INSTALL.md` for package installation, tray notes, and troubleshooting. -`setup` registers the observer against your journal over the local `http://localhost:5015` link, so there's no URL to type. If this machine reaches your solstone host directly instead, run `solstone-linux setup --server-url `. (Legacy fallback: mint a key on the journal host with `journal observer create ` and paste it during setup.) +Pairing is the only setup path. The pair link comes from your journal. A journal +on the same machine connects through the same private link as any other journal; +there is no URL, key, local Python installation, or direct fallback to configure. +Sol can continue capturing while unpaired or offline and will save segments +locally. ### Developers building from source @@ -54,13 +64,27 @@ The archive includes `packaging/INSTALL-NOTES`, the canonical runtime-dependency git clone https://github.com/solpbc/solstone-linux.git cd solstone-linux make install-service -solstone-linux setup +systemctl --user stop solstone-linux +solstone-linux setup < pair-link.txt +systemctl --user start solstone-linux ``` ## Setup ```bash -solstone-linux setup +solstone-linux setup < pair-link.txt +``` + +Setup and runtime deliberately share one private-state lock. Stop sol before +pairing. If sol is running, setup exits before consuming the pair link and leaves +capture, config, and private state unchanged. + +For an upgrade that needs a new pair link: + +```bash +systemctl --user stop solstone-linux +solstone-linux setup < pair-link.txt +systemctl --user start solstone-linux ``` ## Run @@ -76,9 +100,9 @@ solstone-linux run solstone-linux status ``` -Registered observers also include a diagnostics-only status beacon in the -journal: identity, version, uptime, and sync liveness counts only, with no -captured or experienced content. +Paired devices also include a diagnostics-only status beacon in your journal: +identity, version, uptime, and sync liveness counts only, with none of the +screen or audio sol experiences with you. ## Observer contract diff --git a/crates/rust-release-manifest/src/elf64.rs b/crates/rust-release-manifest/src/elf64.rs new file mode 100644 index 0000000..6abc49f --- /dev/null +++ b/crates/rust-release-manifest/src/elf64.rs @@ -0,0 +1,1049 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::{error::Error, fmt}; + +const MAX_INPUT: usize = 256 * 1024 * 1024; +const MAX_PROGRAM_HEADERS: usize = 4096; +const MAX_DYNAMIC_ENTRIES: usize = 65_536; +const MAX_STRING_TABLE: usize = 16 * 1024 * 1024; +const MAX_STRING: usize = 4096; +const MAX_NEEDED: usize = 128; + +const PT_LOAD: u32 = 1; +const PT_DYNAMIC: u32 = 2; +const PT_INTERP: u32 = 3; +const DT_NULL: u64 = 0; +const DT_NEEDED: u64 = 1; +const DT_STRTAB: u64 = 5; +const DT_STRSZ: u64 = 10; +const DT_SONAME: u64 = 14; +const DT_RPATH: u64 = 15; +const DT_RUNPATH: u64 = 29; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Elf64Linkage { + pub(crate) elf_type: u16, + pub(crate) machine: u16, + pub(crate) interpreter: String, + pub(crate) needed: Vec, + pub(crate) soname: Option, + pub(crate) rpath: Option, + pub(crate) runpath: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Elf64Error { + TooLarge { + actual: usize, + limit: usize, + }, + Truncated { + region: &'static str, + offset: u64, + }, + IntegerOverflow { + region: &'static str, + index: usize, + offset: u64, + }, + BadMagic, + UnsupportedClass { + actual: u8, + }, + UnsupportedEndian { + actual: u8, + }, + UnsupportedIdentVersion { + actual: u8, + }, + UnsupportedType { + actual: u16, + }, + UnsupportedMachine { + actual: u16, + }, + InvalidHeaderSize { + actual: u16, + }, + InvalidProgramHeaderSize { + actual: u16, + }, + ProgramHeaderCountExceeded { + actual: usize, + limit: usize, + }, + ProgramHeaderOutOfBounds { + index: usize, + offset: u64, + }, + SegmentOutOfBounds { + region: &'static str, + index: usize, + offset: u64, + }, + MissingLoadSegment, + MissingInterpreter, + DuplicateInterpreter { + index: usize, + }, + InterpreterNotTerminated { + offset: u64, + }, + InterpreterNotUtf8 { + offset: u64, + }, + MissingDynamic, + DuplicateDynamic { + index: usize, + }, + DynamicEntryCountExceeded { + actual: usize, + limit: usize, + }, + DynamicNotTerminated { + offset: u64, + }, + MissingStringTable, + DuplicateStringTable { + index: usize, + }, + MissingStringTableSize, + DuplicateStringTableSize { + index: usize, + }, + StringTableAddressUnmapped { + offset: u64, + }, + StringTableOutOfBounds { + offset: u64, + }, + StringOffsetOutOfBounds { + tag: u64, + index: usize, + offset: u64, + }, + StringNotTerminated { + tag: u64, + index: usize, + offset: u64, + }, + StringNotUtf8 { + tag: u64, + index: usize, + offset: u64, + }, + StringTooLong { + tag: u64, + index: usize, + offset: u64, + }, + NeededCountExceeded { + actual: usize, + limit: usize, + }, + DuplicateSingletonTag { + tag: u64, + index: usize, + }, +} + +impl fmt::Display for Elf64Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "ELF64 parse failed: {self:?}") + } +} + +impl Error for Elf64Error {} + +#[derive(Clone, Copy)] +struct LoadSegment { + file_offset: u64, + virtual_address: u64, + file_size: u64, +} + +#[derive(Clone, Copy)] +struct Segment { + index: usize, + offset: u64, + size: u64, +} + +fn bytes<'a>( + input: &'a [u8], + offset: u64, + size: usize, + region: &'static str, +) -> Result<&'a [u8], Elf64Error> { + let start = usize::try_from(offset).map_err(|_| Elf64Error::IntegerOverflow { + region, + index: 0, + offset, + })?; + let end = start.checked_add(size).ok_or(Elf64Error::IntegerOverflow { + region, + index: 0, + offset, + })?; + input + .get(start..end) + .ok_or(Elf64Error::Truncated { region, offset }) +} + +fn u16_at(input: &[u8], offset: u64, region: &'static str) -> Result { + let value: [u8; 2] = bytes(input, offset, 2, region)? + .try_into() + .map_err(|_| Elf64Error::Truncated { region, offset })?; + Ok(u16::from_le_bytes(value)) +} + +fn u32_at(input: &[u8], offset: u64, region: &'static str) -> Result { + let value: [u8; 4] = bytes(input, offset, 4, region)? + .try_into() + .map_err(|_| Elf64Error::Truncated { region, offset })?; + Ok(u32::from_le_bytes(value)) +} + +fn u64_at(input: &[u8], offset: u64, region: &'static str) -> Result { + let value: [u8; 8] = bytes(input, offset, 8, region)? + .try_into() + .map_err(|_| Elf64Error::Truncated { region, offset })?; + Ok(u64::from_le_bytes(value)) +} + +fn segment_slice<'a>( + input: &'a [u8], + segment: Segment, + region: &'static str, +) -> Result<&'a [u8], Elf64Error> { + let size = usize::try_from(segment.size).map_err(|_| Elf64Error::IntegerOverflow { + region, + index: segment.index, + offset: segment.offset, + })?; + bytes(input, segment.offset, size, region).map_err(|error| match error { + Elf64Error::Truncated { .. } => Elf64Error::SegmentOutOfBounds { + region, + index: segment.index, + offset: segment.offset, + }, + other => other, + }) +} + +pub(crate) fn parse_elf64(input: &[u8]) -> Result { + if input.len() > MAX_INPUT { + return Err(Elf64Error::TooLarge { + actual: input.len(), + limit: MAX_INPUT, + }); + } + let ident = bytes(input, 0, 16, "ident")?; + if ident.get(0..4) != Some(b"\x7fELF") { + return Err(Elf64Error::BadMagic); + } + if ident[4] != 2 { + return Err(Elf64Error::UnsupportedClass { actual: ident[4] }); + } + if ident[5] != 1 { + return Err(Elf64Error::UnsupportedEndian { actual: ident[5] }); + } + if ident[6] != 1 { + return Err(Elf64Error::UnsupportedIdentVersion { actual: ident[6] }); + } + let elf_type = u16_at(input, 16, "header")?; + if elf_type != 3 { + return Err(Elf64Error::UnsupportedType { actual: elf_type }); + } + let machine = u16_at(input, 18, "header")?; + if machine != 62 { + return Err(Elf64Error::UnsupportedMachine { actual: machine }); + } + let header_size = u16_at(input, 52, "header")?; + if header_size != 64 { + return Err(Elf64Error::InvalidHeaderSize { + actual: header_size, + }); + } + let program_offset = u64_at(input, 32, "header")?; + let program_size = u16_at(input, 54, "header")?; + if program_size != 56 { + return Err(Elf64Error::InvalidProgramHeaderSize { + actual: program_size, + }); + } + let program_count = usize::from(u16_at(input, 56, "header")?); + if program_count > MAX_PROGRAM_HEADERS { + return Err(Elf64Error::ProgramHeaderCountExceeded { + actual: program_count, + limit: MAX_PROGRAM_HEADERS, + }); + } + let table_size = program_count.checked_mul(usize::from(program_size)).ok_or( + Elf64Error::IntegerOverflow { + region: "program-headers", + index: program_count, + offset: program_offset, + }, + )?; + bytes(input, program_offset, table_size, "program-headers").map_err(|_| { + Elf64Error::ProgramHeaderOutOfBounds { + index: program_count, + offset: program_offset, + } + })?; + + let mut loads = Vec::new(); + let mut interpreter = None; + let mut dynamic = None; + for index in 0..program_count { + let relative = index.checked_mul(56).ok_or(Elf64Error::IntegerOverflow { + region: "program-header", + index, + offset: program_offset, + })?; + let offset = program_offset + .checked_add( + u64::try_from(relative).map_err(|_| Elf64Error::IntegerOverflow { + region: "program-header", + index, + offset: program_offset, + })?, + ) + .ok_or(Elf64Error::IntegerOverflow { + region: "program-header", + index, + offset: program_offset, + })?; + let kind = u32_at(input, offset, "program-header")?; + let file_offset = u64_at(input, offset + 8, "program-header")?; + let virtual_address = u64_at(input, offset + 16, "program-header")?; + let file_size = u64_at(input, offset + 32, "program-header")?; + let segment = Segment { + index, + offset: file_offset, + size: file_size, + }; + match kind { + PT_LOAD => { + segment_slice(input, segment, "load")?; + loads.push(LoadSegment { + file_offset, + virtual_address, + file_size, + }); + } + PT_INTERP => { + if interpreter.is_some() { + return Err(Elf64Error::DuplicateInterpreter { index }); + } + let value = segment_slice(input, segment, "interpreter")?; + let Some(body) = value.strip_suffix(&[0]) else { + return Err(Elf64Error::InterpreterNotTerminated { + offset: file_offset, + }); + }; + if body.contains(&0) { + return Err(Elf64Error::InterpreterNotTerminated { + offset: file_offset, + }); + } + interpreter = Some( + std::str::from_utf8(body) + .map_err(|_| Elf64Error::InterpreterNotUtf8 { + offset: file_offset, + })? + .to_owned(), + ); + } + PT_DYNAMIC => { + if dynamic.is_some() { + return Err(Elf64Error::DuplicateDynamic { index }); + } + segment_slice(input, segment, "dynamic")?; + dynamic = Some(segment); + } + _ => {} + } + } + if loads.is_empty() { + return Err(Elf64Error::MissingLoadSegment); + } + let interpreter = interpreter.ok_or(Elf64Error::MissingInterpreter)?; + let dynamic = dynamic.ok_or(Elf64Error::MissingDynamic)?; + let dynamic_bytes = segment_slice(input, dynamic, "dynamic")?; + let entry_count = dynamic_bytes.len() / 16; + if entry_count > MAX_DYNAMIC_ENTRIES { + return Err(Elf64Error::DynamicEntryCountExceeded { + actual: entry_count, + limit: MAX_DYNAMIC_ENTRIES, + }); + } + + let mut string_address = None; + let mut string_size = None; + let mut strings = Vec::new(); + let mut terminated = false; + for index in 0..entry_count { + let offset = u64::try_from(index * 16).map_err(|_| Elf64Error::IntegerOverflow { + region: "dynamic", + index, + offset: dynamic.offset, + })?; + let tag = u64_at(dynamic_bytes, offset, "dynamic-entry")?; + let value = u64_at(dynamic_bytes, offset + 8, "dynamic-entry")?; + if tag == DT_NULL { + terminated = true; + break; + } + match tag { + DT_STRTAB => { + if string_address.replace(value).is_some() { + return Err(Elf64Error::DuplicateStringTable { index }); + } + } + DT_STRSZ => { + if string_size.replace(value).is_some() { + return Err(Elf64Error::DuplicateStringTableSize { index }); + } + } + DT_NEEDED | DT_SONAME | DT_RPATH | DT_RUNPATH => { + if tag != DT_NEEDED && strings.iter().any(|(prior, _, _)| *prior == tag) { + return Err(Elf64Error::DuplicateSingletonTag { tag, index }); + } + strings.push((tag, index, value)); + } + _ => {} + } + } + if !terminated { + return Err(Elf64Error::DynamicNotTerminated { + offset: dynamic.offset, + }); + } + let string_address = string_address.ok_or(Elf64Error::MissingStringTable)?; + let string_size = string_size.ok_or(Elf64Error::MissingStringTableSize)?; + let string_size_usize = + usize::try_from(string_size).map_err(|_| Elf64Error::StringTableOutOfBounds { + offset: string_address, + })?; + if string_size_usize > MAX_STRING_TABLE { + return Err(Elf64Error::StringTableOutOfBounds { + offset: string_address, + }); + } + let string_offset = loads.iter().find_map(|load| { + let end = load.virtual_address.checked_add(load.file_size)?; + if string_address < load.virtual_address || string_address >= end { + return None; + } + load.file_offset + .checked_add(string_address - load.virtual_address) + }); + let string_offset = string_offset.ok_or(Elf64Error::StringTableAddressUnmapped { + offset: string_address, + })?; + let string_table = + bytes(input, string_offset, string_size_usize, "string-table").map_err(|_| { + Elf64Error::StringTableOutOfBounds { + offset: string_offset, + } + })?; + let needed_count = strings + .iter() + .filter(|(tag, _, _)| *tag == DT_NEEDED) + .count(); + if needed_count > MAX_NEEDED { + return Err(Elf64Error::NeededCountExceeded { + actual: needed_count, + limit: MAX_NEEDED, + }); + } + + let mut needed = Vec::new(); + let mut soname = None; + let mut rpath = None; + let mut runpath = None; + for (tag, index, offset) in strings { + let start = usize::try_from(offset).map_err(|_| Elf64Error::StringOffsetOutOfBounds { + tag, + index, + offset, + })?; + let tail = string_table + .get(start..) + .ok_or(Elf64Error::StringOffsetOutOfBounds { tag, index, offset })?; + let end = tail + .iter() + .position(|byte| *byte == 0) + .ok_or(Elf64Error::StringNotTerminated { tag, index, offset })?; + if end > MAX_STRING { + return Err(Elf64Error::StringTooLong { tag, index, offset }); + } + let value = std::str::from_utf8(&tail[..end]) + .map_err(|_| Elf64Error::StringNotUtf8 { tag, index, offset })? + .to_owned(); + match tag { + DT_NEEDED => needed.push(value), + DT_SONAME => soname = Some(value), + DT_RPATH => rpath = Some(value), + DT_RUNPATH => runpath = Some(value), + _ => unreachable!(), + } + } + Ok(Elf64Linkage { + elf_type, + machine, + interpreter, + needed, + soname, + rpath, + runpath, + }) +} + +#[cfg(test)] +pub(crate) fn pinned_elf64_for_test() -> Vec { + linkage_elf64_for_test( + "/lib64/ld-linux-x86-64.so.2", + &[ + "libgstreamer-1.0.so.0", + "libgobject-2.0.so.0", + "libglib-2.0.so.0", + "libgio-2.0.so.0", + "libpulse.so.0", + "libgcc_s.so.1", + "libm.so.6", + "libc.so.6", + ], + None, + None, + None, + ) +} + +#[cfg(test)] +pub(crate) fn linkage_elf64_for_test( + interpreter_value: &str, + libraries: &[&str], + soname: Option<&str>, + rpath: Option<&str>, + runpath: Option<&str>, +) -> Vec { + const PH: usize = 64; + const INTERP: usize = 256; + const DYNAMIC: usize = 320; + const STRINGS: usize = 600; + const BASE: u64 = 0x400000; + let mut interpreter = interpreter_value.as_bytes().to_vec(); + interpreter.push(0); + let mut table = vec![0]; + let mut offsets = Vec::new(); + for library in libraries { + offsets.push(table.len() as u64); + table.extend_from_slice(library.as_bytes()); + table.push(0); + } + let mut singleton = Vec::new(); + for (tag, value) in [ + (DT_SONAME, soname), + (DT_RPATH, rpath), + (DT_RUNPATH, runpath), + ] { + if let Some(value) = value { + let offset = table.len() as u64; + table.extend_from_slice(value.as_bytes()); + table.push(0); + singleton.push((tag, offset)); + } + } + let mut bytes = vec![0_u8; STRINGS + table.len()]; + bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01"); + bytes[16..18].copy_from_slice(&3_u16.to_le_bytes()); + bytes[18..20].copy_from_slice(&62_u16.to_le_bytes()); + bytes[20..24].copy_from_slice(&1_u32.to_le_bytes()); + bytes[32..40].copy_from_slice(&(PH as u64).to_le_bytes()); + bytes[52..54].copy_from_slice(&64_u16.to_le_bytes()); + bytes[54..56].copy_from_slice(&56_u16.to_le_bytes()); + bytes[56..58].copy_from_slice(&3_u16.to_le_bytes()); + let put_u32 = |bytes: &mut [u8], offset: usize, value: u32| { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + }; + let put_u64 = |bytes: &mut [u8], offset: usize, value: u64| { + bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); + }; + put_u32(&mut bytes, PH, PT_LOAD); + put_u64(&mut bytes, PH + 16, BASE); + let total = bytes.len() as u64; + put_u64(&mut bytes, PH + 32, total); + put_u64(&mut bytes, PH + 40, total); + put_u32(&mut bytes, PH + 56, PT_INTERP); + put_u64(&mut bytes, PH + 64, INTERP as u64); + put_u64(&mut bytes, PH + 88, interpreter.len() as u64); + put_u32(&mut bytes, PH + 112, PT_DYNAMIC); + put_u64(&mut bytes, PH + 120, DYNAMIC as u64); + put_u64( + &mut bytes, + PH + 144, + ((offsets.len() + singleton.len() + 3) * 16) as u64, + ); + bytes[INTERP..INTERP + interpreter.len()].copy_from_slice(&interpreter); + let mut entries = vec![ + (DT_STRTAB, BASE + STRINGS as u64), + (DT_STRSZ, table.len() as u64), + ]; + entries.extend(offsets.into_iter().map(|offset| (DT_NEEDED, offset))); + entries.extend(singleton); + entries.push((DT_NULL, 0)); + for (index, (tag, value)) in entries.into_iter().enumerate() { + put_u64(&mut bytes, DYNAMIC + index * 16, tag); + put_u64(&mut bytes, DYNAMIC + index * 16 + 8, value); + } + bytes[STRINGS..].copy_from_slice(&table); + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn put_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + fn put_u64(bytes: &mut [u8], offset: usize, value: u64) { + bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); + } + + #[derive(Clone, Copy, Default)] + struct MinimalElf64Options; + + fn minimal_elf64(_options: MinimalElf64Options) -> Vec { + const PH: usize = 64; + const INTERP: usize = 512; + const DYNAMIC: usize = 600; + const STRINGS: usize = 800; + const BASE: u64 = 0x400000; + let interpreter = b"/lib64/ld-linux-x86-64.so.2\0"; + let table = b"\0libc.so.6\0fixture.so\0/rpath\0/runpath\0"; + let mut bytes = vec![0_u8; STRINGS + table.len()]; + bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01"); + put_u16(&mut bytes, 16, 3); + put_u16(&mut bytes, 18, 62); + put_u32(&mut bytes, 20, 1); + put_u64(&mut bytes, 32, PH as u64); + put_u16(&mut bytes, 52, 64); + put_u16(&mut bytes, 54, 56); + put_u16(&mut bytes, 56, 3); + put_u32(&mut bytes, PH, PT_LOAD); + put_u64(&mut bytes, PH + 8, 0); + put_u64(&mut bytes, PH + 16, BASE); + let total = bytes.len() as u64; + put_u64(&mut bytes, PH + 32, total); + put_u64(&mut bytes, PH + 40, total); + put_u32(&mut bytes, PH + 56, PT_INTERP); + put_u64(&mut bytes, PH + 64, INTERP as u64); + put_u64(&mut bytes, PH + 88, interpreter.len() as u64); + put_u32(&mut bytes, PH + 112, PT_DYNAMIC); + put_u64(&mut bytes, PH + 120, DYNAMIC as u64); + put_u64(&mut bytes, PH + 144, 6 * 16); + bytes[INTERP..INTERP + interpreter.len()].copy_from_slice(interpreter); + for (index, (tag, value)) in [ + (DT_STRTAB, BASE + STRINGS as u64), + (DT_STRSZ, table.len() as u64), + (DT_NEEDED, 1), + (DT_SONAME, 11), + (DT_RPATH, 22), + (DT_NULL, 0), + ] + .into_iter() + .enumerate() + { + put_u64(&mut bytes, DYNAMIC + index * 16, tag); + put_u64(&mut bytes, DYNAMIC + index * 16 + 8, value); + } + bytes[STRINGS..].copy_from_slice(table); + bytes + } + + #[test] + fn parses_byte_exact_minimal_image() { + let parsed = parse_elf64(&minimal_elf64(MinimalElf64Options)).unwrap(); + assert_eq!(parsed.elf_type, 3); + assert_eq!(parsed.machine, 62); + assert_eq!(parsed.interpreter, "/lib64/ld-linux-x86-64.so.2"); + assert_eq!(parsed.needed, ["libc.so.6"]); + assert_eq!(parsed.soname.as_deref(), Some("fixture.so")); + assert_eq!(parsed.rpath.as_deref(), Some("/rpath")); + assert_eq!(parsed.runpath, None); + } + + #[test] + fn error_taxonomy_has_a_byte_exact_mutation_per_variant() { + const PH: usize = 64; + const INTERP: usize = 512; + const DYNAMIC: usize = 600; + const STRINGS: usize = 800; + const BASE: u64 = 0x400000; + + let clean = minimal_elf64(MinimalElf64Options); + let check = |name: &str, bytes: Vec, expected: Elf64Error| { + assert_eq!(parse_elf64(&bytes), Err(expected), "{name}"); + }; + + check( + "input limit", + vec![0; MAX_INPUT + 1], + Elf64Error::TooLarge { + actual: MAX_INPUT + 1, + limit: MAX_INPUT, + }, + ); + check( + "ident truncation", + clean[..15].to_vec(), + Elf64Error::Truncated { + region: "ident", + offset: 0, + }, + ); + check( + "header truncation", + clean[..17].to_vec(), + Elf64Error::Truncated { + region: "header", + offset: 16, + }, + ); + + let mut bytes = clean.clone(); + put_u64(&mut bytes, PH + 56 + 8, u64::MAX); + put_u64(&mut bytes, PH + 56 + 32, 2); + check( + "checked segment offset overflow", + bytes, + Elf64Error::IntegerOverflow { + region: "interpreter", + index: 0, + offset: u64::MAX, + }, + ); + + for (name, offset, value, expected) in [ + ("magic", 0, 0, Elf64Error::BadMagic), + ("class", 4, 1, Elf64Error::UnsupportedClass { actual: 1 }), + ("endian", 5, 2, Elf64Error::UnsupportedEndian { actual: 2 }), + ( + "ident version", + 6, + 2, + Elf64Error::UnsupportedIdentVersion { actual: 2 }, + ), + ] { + let mut bytes = clean.clone(); + bytes[offset] = value; + check(name, bytes, expected); + } + for (name, offset, value, expected) in [ + ("ET_EXEC", 16, 2, Elf64Error::UnsupportedType { actual: 2 }), + ( + "machine", + 18, + 3, + Elf64Error::UnsupportedMachine { actual: 3 }, + ), + ( + "header size", + 52, + 63, + Elf64Error::InvalidHeaderSize { actual: 63 }, + ), + ( + "program header size", + 54, + 55, + Elf64Error::InvalidProgramHeaderSize { actual: 55 }, + ), + ] { + let mut bytes = clean.clone(); + put_u16(&mut bytes, offset, value); + check(name, bytes, expected); + } + + let mut bytes = clean.clone(); + put_u16(&mut bytes, 56, 4097); + check( + "program header count", + bytes, + Elf64Error::ProgramHeaderCountExceeded { + actual: 4097, + limit: MAX_PROGRAM_HEADERS, + }, + ); + let mut bytes = clean.clone(); + put_u64(&mut bytes, 32, u64::MAX); + check( + "program header table offset", + bytes, + Elf64Error::ProgramHeaderOutOfBounds { + index: 3, + offset: u64::MAX, + }, + ); + let mut bytes = clean.clone(); + let beyond_file = bytes.len() as u64 + 1; + put_u64(&mut bytes, PH + 32, beyond_file); + check( + "load segment boundary", + bytes, + Elf64Error::SegmentOutOfBounds { + region: "load", + index: 0, + offset: 0, + }, + ); + + let mut bytes = clean.clone(); + put_u32(&mut bytes, PH, 0); + check("missing load", bytes, Elf64Error::MissingLoadSegment); + let mut bytes = clean.clone(); + put_u32(&mut bytes, PH + 56, 0); + check("missing interpreter", bytes, Elf64Error::MissingInterpreter); + let mut bytes = clean.clone(); + put_u16(&mut bytes, 56, 4); + bytes.copy_within(PH + 56..PH + 112, PH + 168); + check( + "duplicate interpreter", + bytes, + Elf64Error::DuplicateInterpreter { index: 3 }, + ); + let mut bytes = clean.clone(); + let interpreter_size = + usize::try_from(u64_at(&bytes, (PH + 56 + 32) as u64, "test").unwrap()).unwrap(); + bytes[INTERP + interpreter_size - 1] = b'x'; + check( + "interpreter terminator", + bytes, + Elf64Error::InterpreterNotTerminated { + offset: INTERP as u64, + }, + ); + let mut bytes = clean.clone(); + bytes[INTERP] = 0xff; + check( + "interpreter UTF-8", + bytes, + Elf64Error::InterpreterNotUtf8 { + offset: INTERP as u64, + }, + ); + + let mut bytes = clean.clone(); + put_u32(&mut bytes, PH + 112, 0); + check("missing dynamic", bytes, Elf64Error::MissingDynamic); + let mut bytes = clean.clone(); + put_u16(&mut bytes, 56, 4); + bytes.copy_within(PH + 112..PH + 168, PH + 168); + check( + "duplicate dynamic", + bytes, + Elf64Error::DuplicateDynamic { index: 3 }, + ); + let mut bytes = clean.clone(); + bytes.resize(DYNAMIC + (MAX_DYNAMIC_ENTRIES + 1) * 16, 0); + let total = bytes.len() as u64; + put_u64(&mut bytes, PH + 32, total); + put_u64( + &mut bytes, + PH + 112 + 32, + ((MAX_DYNAMIC_ENTRIES + 1) * 16) as u64, + ); + check( + "dynamic entry count", + bytes, + Elf64Error::DynamicEntryCountExceeded { + actual: MAX_DYNAMIC_ENTRIES + 1, + limit: MAX_DYNAMIC_ENTRIES, + }, + ); + let mut bytes = clean.clone(); + put_u64(&mut bytes, DYNAMIC + 5 * 16, 0xfeed); + check( + "dynamic terminator", + bytes, + Elf64Error::DynamicNotTerminated { + offset: DYNAMIC as u64, + }, + ); + + let mut bytes = clean.clone(); + put_u64(&mut bytes, DYNAMIC, 0xfeed); + check( + "missing string table", + bytes, + Elf64Error::MissingStringTable, + ); + let mut bytes = clean.clone(); + put_u64(&mut bytes, DYNAMIC + 4 * 16, DT_STRTAB); + check( + "duplicate string table", + bytes, + Elf64Error::DuplicateStringTable { index: 4 }, + ); + let mut bytes = clean.clone(); + put_u64(&mut bytes, DYNAMIC + 16, 0xfeed); + check( + "missing string table size", + bytes, + Elf64Error::MissingStringTableSize, + ); + let mut bytes = clean.clone(); + put_u64(&mut bytes, DYNAMIC + 4 * 16, DT_STRSZ); + check( + "duplicate string table size", + bytes, + Elf64Error::DuplicateStringTableSize { index: 4 }, + ); + let mut bytes = clean.clone(); + let unmapped = BASE + bytes.len() as u64 + 1; + put_u64(&mut bytes, DYNAMIC + 8, unmapped); + check( + "unmapped string table", + bytes, + Elf64Error::StringTableAddressUnmapped { + offset: BASE + clean.len() as u64 + 1, + }, + ); + let mut bytes = clean.clone(); + put_u64(&mut bytes, DYNAMIC + 16 + 8, (MAX_STRING_TABLE + 1) as u64); + check( + "string table limit", + bytes, + Elf64Error::StringTableOutOfBounds { + offset: BASE + STRINGS as u64, + }, + ); + let mut bytes = clean.clone(); + put_u64( + &mut bytes, + DYNAMIC + 16 + 8, + (clean.len() - STRINGS + 1) as u64, + ); + check( + "string table file boundary", + bytes, + Elf64Error::StringTableOutOfBounds { + offset: STRINGS as u64, + }, + ); + let mut bytes = clean.clone(); + put_u64( + &mut bytes, + DYNAMIC + 2 * 16 + 8, + (clean.len() - STRINGS + 1) as u64, + ); + check( + "dynamic string offset", + bytes, + Elf64Error::StringOffsetOutOfBounds { + tag: DT_NEEDED, + index: 2, + offset: (clean.len() - STRINGS + 1) as u64, + }, + ); + let mut bytes = clean.clone(); + let table_len = clean.len() - STRINGS; + put_u64(&mut bytes, DYNAMIC + 2 * 16 + 8, (table_len - 1) as u64); + bytes[clean.len() - 1] = b'x'; + check( + "dynamic string terminator", + bytes, + Elf64Error::StringNotTerminated { + tag: DT_NEEDED, + index: 2, + offset: (table_len - 1) as u64, + }, + ); + let mut bytes = clean.clone(); + bytes[STRINGS + 1] = 0xff; + check( + "dynamic string UTF-8", + bytes, + Elf64Error::StringNotUtf8 { + tag: DT_NEEDED, + index: 2, + offset: 1, + }, + ); + let mut bytes = clean.clone(); + bytes.resize(STRINGS + MAX_STRING + 3, 0); + bytes[STRINGS + 1..STRINGS + MAX_STRING + 2].fill(b'a'); + let total = bytes.len() as u64; + put_u64(&mut bytes, PH + 32, total); + put_u64(&mut bytes, DYNAMIC + 16 + 8, (MAX_STRING + 3) as u64); + check( + "dynamic string length", + bytes, + Elf64Error::StringTooLong { + tag: DT_NEEDED, + index: 2, + offset: 1, + }, + ); + + let mut bytes = clean.clone(); + const LARGE_STRINGS: usize = 3000; + bytes.resize(LARGE_STRINGS + 2, 0); + let total = bytes.len() as u64; + put_u64(&mut bytes, PH + 32, total); + put_u64(&mut bytes, DYNAMIC + 8, BASE + LARGE_STRINGS as u64); + put_u64(&mut bytes, DYNAMIC + 16 + 8, 2); + for index in 0..=MAX_NEEDED { + put_u64(&mut bytes, DYNAMIC + (index + 2) * 16, DT_NEEDED); + put_u64(&mut bytes, DYNAMIC + (index + 2) * 16 + 8, 0); + } + put_u64(&mut bytes, DYNAMIC + (MAX_NEEDED + 3) * 16, DT_NULL); + put_u64(&mut bytes, PH + 112 + 32, ((MAX_NEEDED + 4) * 16) as u64); + check( + "needed library count", + bytes, + Elf64Error::NeededCountExceeded { + actual: MAX_NEEDED + 1, + limit: MAX_NEEDED, + }, + ); + let mut bytes = clean; + put_u64(&mut bytes, DYNAMIC + 4 * 16, DT_SONAME); + check( + "duplicate singleton dynamic tag", + bytes, + Elf64Error::DuplicateSingletonTag { + tag: DT_SONAME, + index: 4, + }, + ); + } + + #[test] + fn parses_the_real_rustc_test_pie() { + let bytes = fs::read(std::env::current_exe().unwrap()).unwrap(); + let parsed = parse_elf64(&bytes).unwrap(); + assert_eq!(parsed.elf_type, 3); + assert_eq!(parsed.machine, 62); + assert!(!parsed.interpreter.is_empty()); + assert!(!parsed.needed.is_empty()); + } + + #[test] + fn derive_requested_release_binary() { + let Ok(path) = std::env::var("SOLSTONE_ELF_DERIVE") else { + return; + }; + let parsed = parse_elf64(&fs::read(path).unwrap()).unwrap(); + eprintln!("DERIVED_ELF64={parsed:?}"); + } +} diff --git a/crates/rust-release-manifest/src/lib.rs b/crates/rust-release-manifest/src/lib.rs index 1e5f6b9..b096d27 100644 --- a/crates/rust-release-manifest/src/lib.rs +++ b/crates/rust-release-manifest/src/lib.rs @@ -24,7 +24,10 @@ use xz2::read::XzDecoder; mod candidate; pub use candidate::*; mod audit; +mod elf64; +mod package_audit; pub use audit::*; +pub use package_audit::audit_packages; mod transaction; pub use transaction::*; mod transparency; diff --git a/crates/rust-release-manifest/src/main.rs b/crates/rust-release-manifest/src/main.rs index ed15ba0..35bb889 100644 --- a/crates/rust-release-manifest/src/main.rs +++ b/crates/rust-release-manifest/src/main.rs @@ -4,8 +4,8 @@ use clap::{Args, Parser, Subcommand}; use rust_release_manifest::{ AuditRequest, Lane, LaneEmitRequest, MANIFEST_OK_MESSAGE, ProcessEnvironment, - ProofHandoffInput, RELEASE_DIR_OK_MESSAGE, RepoRoot, classify_release_dir, create_candidate, - emit_lane_handoff, emit_proof_handoff, prove_candidate, publish_transparency, + ProofHandoffInput, RELEASE_DIR_OK_MESSAGE, RepoRoot, audit_packages, classify_release_dir, + create_candidate, emit_lane_handoff, emit_proof_handoff, prove_candidate, publish_transparency, recover_candidate, resign_transparency_pointer, run_audit, verify_manifest_mode, }; use std::path::PathBuf; @@ -18,6 +18,16 @@ struct Cli { #[derive(Subcommand)] enum Command { + AuditPackages { + #[arg(long)] + tar: PathBuf, + #[arg(long)] + deb: PathBuf, + #[arg(long)] + rpm: PathBuf, + #[arg(long)] + expected_executable_sha256: String, + }, Audit { #[arg(long)] bundle: PathBuf, @@ -132,6 +142,12 @@ struct ProofHandoffArgs { fn run() -> Result<(), Box> { let command = Cli::parse().command; match command { + Command::AuditPackages { + tar, + deb, + rpm, + expected_executable_sha256, + } => audit_packages(&tar, &deb, &rpm, &expected_executable_sha256)?, Command::Audit { bundle, receipt, diff --git a/crates/rust-release-manifest/src/package_audit.rs b/crates/rust-release-manifest/src/package_audit.rs new file mode 100644 index 0000000..eb0b000 --- /dev/null +++ b/crates/rust-release-manifest/src/package_audit.rs @@ -0,0 +1,1200 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use crate::{ + Error, Result, digest, + elf64::{Elf64Linkage, parse_elf64}, +}; +use flate2::read::GzDecoder; +use std::{ + collections::{BTreeMap, BTreeSet}, + ffi::OsStr, + fs::{self, File}, + io::{Cursor, Read}, + os::unix::fs::MetadataExt, + path::{Component, Path}, +}; +use tar::{Archive, EntryType}; +use xz2::read::XzDecoder; + +const MAX_MEMBER_BYTES: u64 = 256 * 1024 * 1024; +const INSTALL_NOTES: &[u8] = include_bytes!("../../../packaging/INSTALL-NOTES"); + +// Derived with: +// cargo build --locked --release -p solstone-linux +// SOLSTONE_ELF_DERIVE=/target/release/solstone-linux \ +// cargo test --locked -p rust-release-manifest elf64::tests::derive_requested_release_binary -- --nocapture +// Source commit: 95ea20d9ae5a726f1b4058eab3122f07fbd86fbf. +const EXPECTED_ELF_TYPE: u16 = 3; +const EXPECTED_MACHINE: u16 = 62; +const EXPECTED_INTERPRETER: &str = "/lib64/ld-linux-x86-64.so.2"; +const EXPECTED_NEEDED: [&str; 8] = [ + "libgstreamer-1.0.so.0", + "libgobject-2.0.so.0", + "libglib-2.0.so.0", + "libgio-2.0.so.0", + "libpulse.so.0", + "libgcc_s.so.1", + "libm.so.6", + "libc.so.6", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PayloadRole { + Executable, + License, + InstallNotes, + Icon, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PayloadAuthority { + pub(crate) source: &'static str, + pub(crate) installed: &'static str, + pub(crate) mode: u32, + pub(crate) role: PayloadRole, +} + +pub(crate) const PAYLOAD_AUTHORITY: [PayloadAuthority; 16] = [ + PayloadAuthority { + source: "target/release/solstone-linux", + installed: "/usr/bin/solstone-linux", + mode: 0o755, + role: PayloadRole::Executable, + }, + PayloadAuthority { + source: "LICENSE", + installed: "/usr/share/doc/solstone-linux/LICENSE", + mode: 0o644, + role: PayloadRole::License, + }, + PayloadAuthority { + source: "packaging/INSTALL-NOTES", + installed: "/usr/share/doc/solstone-linux/INSTALL-NOTES", + mode: 0o644, + role: PayloadRole::InstallNotes, + }, + icon( + "contrib/icons/hicolor/16x16/apps/solstone-observer.png", + "/usr/share/icons/hicolor/16x16/apps/solstone-observer.png", + ), + icon( + "contrib/icons/hicolor/24x24/apps/solstone-observer.png", + "/usr/share/icons/hicolor/24x24/apps/solstone-observer.png", + ), + icon( + "contrib/icons/hicolor/32x32/apps/solstone-observer.png", + "/usr/share/icons/hicolor/32x32/apps/solstone-observer.png", + ), + icon( + "contrib/icons/hicolor/48x48/apps/solstone-observer.png", + "/usr/share/icons/hicolor/48x48/apps/solstone-observer.png", + ), + icon( + "contrib/icons/hicolor/64x64/apps/solstone-observer.png", + "/usr/share/icons/hicolor/64x64/apps/solstone-observer.png", + ), + icon( + "contrib/icons/hicolor/128x128/apps/solstone-observer.png", + "/usr/share/icons/hicolor/128x128/apps/solstone-observer.png", + ), + icon( + "contrib/icons/hicolor/256x256/apps/solstone-observer.png", + "/usr/share/icons/hicolor/256x256/apps/solstone-observer.png", + ), + icon( + "contrib/icons/hicolor/512x512/apps/solstone-observer.png", + "/usr/share/icons/hicolor/512x512/apps/solstone-observer.png", + ), + icon( + "contrib/icons/hicolor/scalable/apps/solstone-observer.svg", + "/usr/share/icons/hicolor/scalable/apps/solstone-observer.svg", + ), + icon( + "contrib/icons/hicolor/scalable/status/solstone-error.svg", + "/usr/share/icons/hicolor/scalable/status/solstone-error.svg", + ), + icon( + "contrib/icons/hicolor/scalable/status/solstone-paused.svg", + "/usr/share/icons/hicolor/scalable/status/solstone-paused.svg", + ), + icon( + "contrib/icons/hicolor/scalable/status/solstone-recording.svg", + "/usr/share/icons/hicolor/scalable/status/solstone-recording.svg", + ), + icon( + "contrib/icons/hicolor/scalable/status/solstone-syncing.svg", + "/usr/share/icons/hicolor/scalable/status/solstone-syncing.svg", + ), +]; + +const fn icon(source: &'static str, installed: &'static str) -> PayloadAuthority { + PayloadAuthority { + source, + installed, + mode: 0o644, + role: PayloadRole::Icon, + } +} + +#[derive(Clone, Debug)] +struct Member { + path: String, + mode: u32, + bytes: Vec, +} + +#[derive(Clone, Copy)] +enum Format { + Tar, + Deb, + Rpm, +} + +impl Format { + fn name(self) -> &'static str { + match self { + Self::Tar => "tar", + Self::Deb => "deb", + Self::Rpm => "rpm", + } + } +} + +fn audit_error(artifact: &Path, class: &str, token: &str, member: &str) -> Error { + let artifact = artifact + .file_name() + .and_then(OsStr::to_str) + .unwrap_or(""); + let escape = |value: &str| { + value + .chars() + .flat_map(char::escape_default) + .collect::() + }; + Error::new(format!( + "package audit: artifact={} class={} token={} member={} tool=rust-release-manifest", + escape(artifact), + escape(class), + escape(token), + escape(member) + )) +} + +fn regular_artifact(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .map_err(|_| audit_error(path, "UnreadableArtifact", "metadata", "artifact"))?; + if !metadata.file_type().is_file() || metadata.nlink() != 1 { + return Err(audit_error( + path, + "InvalidArtifact", + "regular-file-required", + "artifact", + )); + } + Ok(()) +} + +fn normalized_path(path: &Path) -> Option { + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return None; + } + path.to_str().map(str::to_owned) +} + +fn tar_inventory(artifact: &Path, reader: R) -> Result> { + let mut archive = Archive::new(reader); + let mut seen = BTreeSet::new(); + let mut members = Vec::new(); + for entry in archive + .entries() + .map_err(|error| audit_error(artifact, "MalformedContainer", &error.to_string(), "tar"))? + { + let mut entry = entry.map_err(|error| { + audit_error(artifact, "MalformedContainer", &error.to_string(), "tar") + })?; + let kind = entry.header().entry_type(); + if kind == EntryType::Directory { + continue; + } + if kind != EntryType::Regular { + return Err(audit_error( + artifact, + "UnsupportedMember", + "non-regular", + "tar", + )); + } + let path = entry + .path() + .map_err(|_| audit_error(artifact, "MalformedContainer", "invalid-path", "tar"))?; + let path = normalized_path(&path) + .ok_or_else(|| audit_error(artifact, "PayloadClosure", "path-traversal", "tar"))?; + if !seen.insert(path.clone()) { + return Err(audit_error(artifact, "PayloadClosure", "duplicate", &path)); + } + let mode = entry + .header() + .mode() + .map_err(|_| audit_error(artifact, "MalformedContainer", "mode", &path))?; + if entry.size() > MAX_MEMBER_BYTES { + return Err(audit_error(artifact, "LimitExceeded", "member-size", &path)); + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(|error| { + audit_error(artifact, "MalformedContainer", &error.to_string(), &path) + })?; + members.push(Member { path, mode, bytes }); + } + Ok(members) +} + +fn tar_members(path: &Path) -> Result> { + let reader = GzDecoder::new( + File::open(path) + .map_err(|error| audit_error(path, "UnreadableArtifact", &error.to_string(), "tar"))?, + ); + let mut members = tar_inventory(path, reader)?; + let root = members + .first() + .and_then(|member| member.path.split('/').next()) + .ok_or_else(|| audit_error(path, "PayloadClosure", "missing-root", "tar"))? + .to_owned(); + for member in &mut members { + member.path = member + .path + .strip_prefix(&format!("{root}/")) + .ok_or_else(|| audit_error(path, "PayloadClosure", "multiple-roots", &member.path))? + .to_owned(); + } + Ok(members) +} + +fn compressed_tar(path: &Path, name: &str, bytes: Vec) -> Result> { + let reader: Box = if name.ends_with(".gz") { + Box::new(GzDecoder::new(Cursor::new(bytes))) + } else if name.ends_with(".xz") { + Box::new(XzDecoder::new(Cursor::new(bytes))) + } else if name.ends_with(".zst") { + Box::new( + zstd::stream::read::Decoder::new(Cursor::new(bytes)).map_err(|error| { + audit_error(path, "MalformedContainer", &error.to_string(), name) + })?, + ) + } else { + return Err(audit_error(path, "UnsupportedCompression", name, "deb")); + }; + tar_inventory(path, reader) +} + +fn forbidden_dependency(value: &str) -> Option<&str> { + value + .split(|character: char| !(character.is_ascii_alphanumeric() || ".+-".contains(character))) + .find(|token| { + matches!( + token.to_ascii_lowercase().as_str(), + "python" | "python2" | "python3" | "pip" | "pip3" | "pipx" | "sol" | "journal" + ) + }) +} + +fn valid_deb_dependency_field(value: &str) -> bool { + !value.is_empty() + && !value.chars().any(char::is_control) + && value.split(',').all(|group| { + !group.trim().is_empty() + && group.split('|').all(|alternative| { + let alternative = alternative.trim(); + let name = alternative + .split_once(char::is_whitespace) + .map_or(alternative, |(name, _)| name); + !name.is_empty() + && !name.starts_with('-') + && name.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '+' | '-' | '.' | ':' | '_') + }) + && alternative.matches('(').count() == alternative.matches(')').count() + }) + }) +} + +fn deb_members(path: &Path) -> Result> { + let mut archive = ar::Archive::new( + File::open(path) + .map_err(|error| audit_error(path, "UnreadableArtifact", &error.to_string(), "deb"))?, + ); + let mut names = BTreeSet::new(); + let mut marker = None; + let mut control = None; + let mut data = None; + while let Some(entry) = archive.next_entry() { + let mut entry = entry + .map_err(|error| audit_error(path, "MalformedContainer", &error.to_string(), "ar"))?; + let name = std::str::from_utf8(entry.header().identifier()) + .map_err(|_| audit_error(path, "MalformedContainer", "non-utf8-name", "ar"))? + .trim_end_matches('/') + .to_owned(); + if !names.insert(name.clone()) { + return Err(audit_error(path, "MalformedContainer", "duplicate", &name)); + } + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .map_err(|error| audit_error(path, "MalformedContainer", &error.to_string(), &name))?; + if name == "debian-binary" { + marker = Some(bytes); + } else if name.starts_with("control.tar.") { + control = Some((name, bytes)); + } else if name.starts_with("data.tar.") { + data = Some((name, bytes)); + } else { + return Err(audit_error( + path, + "MalformedContainer", + "extra-ar-member", + &name, + )); + } + } + if marker.as_deref() != Some(b"2.0\n") || names.len() != 3 { + return Err(audit_error(path, "MalformedContainer", "ar-closure", "deb")); + } + let (control_name, control_bytes) = + control.ok_or_else(|| audit_error(path, "MalformedContainer", "missing-control", "deb"))?; + let control = compressed_tar(path, &control_name, control_bytes)?; + for member in &control { + let name = member.path.trim_start_matches("./"); + if matches!( + name, + "preinst" | "postinst" | "prerm" | "postrm" | "config" | "triggers" + ) { + return Err(audit_error( + path, + "MaintainerScript", + name, + &format!("deb:control/{name}"), + )); + } + } + let control_member = control + .iter() + .find(|member| member.path.trim_start_matches("./") == "control") + .ok_or_else(|| audit_error(path, "MalformedMetadata", "missing-control", "deb"))?; + if !control + .iter() + .any(|member| member.path.trim_start_matches("./") == "md5sums") + { + return Err(audit_error( + path, + "MalformedMetadata", + "missing-md5sums", + "deb", + )); + } + let control_body = std::str::from_utf8(&control_member.bytes) + .map_err(|_| audit_error(path, "MalformedMetadata", "non-utf8-control", "deb"))?; + let mut fields = BTreeMap::new(); + for line in control_body.lines() { + let (name, value) = line + .split_once(':') + .ok_or_else(|| audit_error(path, "MalformedMetadata", "control-line", "deb"))?; + if fields.insert(name, value.trim()).is_some() { + return Err(audit_error( + path, + "MalformedMetadata", + "duplicate-field", + name, + )); + } + } + for required in ["Package", "Version", "Architecture"] { + if !fields.contains_key(required) { + return Err(audit_error( + path, + "MalformedMetadata", + "missing-field", + required, + )); + } + } + for dependency_field in ["Depends", "Pre-Depends", "Provides"] { + if let Some(value) = fields.get(dependency_field) { + if !valid_deb_dependency_field(value) { + return Err(audit_error( + path, + "MalformedMetadata", + "dependency-grammar", + &format!("deb:control/{dependency_field}"), + )); + } + if let Some(token) = forbidden_dependency(value) { + return Err(audit_error( + path, + "ForbiddenDependency", + token, + &format!("deb:control/{dependency_field}"), + )); + } + } + } + let (data_name, data_bytes) = + data.ok_or_else(|| audit_error(path, "MalformedContainer", "missing-data", "deb"))?; + compressed_tar(path, &data_name, data_bytes) +} + +fn rpm_members(path: &Path) -> Result> { + let package = rpm::Package::open(path) + .map_err(|error| audit_error(path, "MalformedContainer", &error.to_string(), "rpm"))?; + for dependency in package + .metadata + .get_requires() + .map_err(|error| { + audit_error( + path, + "MalformedMetadata", + &error.to_string(), + "rpm:Requires", + ) + })? + .into_iter() + .chain(package.metadata.get_provides().map_err(|error| { + audit_error( + path, + "MalformedMetadata", + &error.to_string(), + "rpm:Provides", + ) + })?) + { + if let Some(token) = forbidden_dependency(&dependency.name) { + return Err(audit_error( + path, + "ForbiddenDependency", + token, + "rpm:Requires/Provides", + )); + } + } + for (name, present) in [ + ("pre", package.metadata.get_pre_install_script().is_ok()), + ("post", package.metadata.get_post_install_script().is_ok()), + ("preun", package.metadata.get_pre_uninstall_script().is_ok()), + ( + "postun", + package.metadata.get_post_uninstall_script().is_ok(), + ), + ("pretrans", package.metadata.get_pre_trans_script().is_ok()), + ( + "posttrans", + package.metadata.get_post_trans_script().is_ok(), + ), + ( + "preuntrans", + package.metadata.get_pre_untrans_script().is_ok(), + ), + ( + "postuntrans", + package.metadata.get_post_untrans_script().is_ok(), + ), + ("verifyscript", package.metadata.get_verify_script().is_ok()), + ] { + if present { + return Err(audit_error(path, "MaintainerScript", name, "rpm:script")); + } + } + if !package + .metadata + .get_triggers() + .map_err(|error| audit_error(path, "MalformedMetadata", &error.to_string(), "rpm:trigger"))? + .is_empty() + || !package + .metadata + .get_file_triggers() + .map_err(|error| { + audit_error( + path, + "MalformedMetadata", + &error.to_string(), + "rpm:file-trigger", + ) + })? + .is_empty() + || !package + .metadata + .get_trans_file_triggers() + .map_err(|error| { + audit_error( + path, + "MalformedMetadata", + &error.to_string(), + "rpm:trans-trigger", + ) + })? + .is_empty() + { + return Err(audit_error( + path, + "MaintainerScript", + "trigger", + "rpm:trigger", + )); + } + let mut seen = BTreeSet::new(); + let mut members = Vec::new(); + for file in package + .files() + .map_err(|error| audit_error(path, "MalformedContainer", &error.to_string(), "rpm:cpio"))? + { + let file = file.map_err(|error| { + audit_error(path, "MalformedContainer", &error.to_string(), "rpm:cpio") + })?; + if file.metadata.mode.file_type() != rpm::FileType::Regular { + return Err(audit_error( + path, + "UnsupportedMember", + "non-regular", + "rpm:cpio", + )); + } + let member = file + .metadata + .path + .to_str() + .ok_or_else(|| audit_error(path, "MalformedContainer", "non-utf8-path", "rpm"))? + .to_owned(); + if !seen.insert(member.clone()) { + return Err(audit_error(path, "PayloadClosure", "duplicate", &member)); + } + members.push(Member { + path: member, + mode: u32::from(file.metadata.mode.permissions()), + bytes: file.content, + }); + } + Ok(members) +} + +fn expected_path(format: Format, authority: PayloadAuthority) -> String { + match format { + Format::Deb | Format::Rpm => authority.installed.trim_start_matches('/').to_owned(), + Format::Tar => match authority.role { + PayloadRole::Executable => "bin/solstone-linux".to_owned(), + PayloadRole::License => "LICENSE".to_owned(), + PayloadRole::InstallNotes => "INSTALL-NOTES".to_owned(), + PayloadRole::Icon => authority + .source + .strip_prefix("contrib/icons/") + .map(|value| format!("share/icons/{value}")) + .unwrap_or_default(), + }, + } +} + +fn inspect_payload( + path: &Path, + format: Format, + members: Vec, +) -> Result<(Vec, BTreeMap)> { + let mut by_path = members + .into_iter() + .map(|member| { + ( + member + .path + .trim_start_matches("./") + .trim_start_matches('/') + .to_owned(), + member, + ) + }) + .collect::>(); + let mut executable = None; + let mut nonbinary = BTreeMap::new(); + for authority in PAYLOAD_AUTHORITY { + let expected = expected_path(format, authority); + let member = by_path + .remove(&expected) + .ok_or_else(|| audit_error(path, "PayloadClosure", "missing", &expected))?; + if authority.role != PayloadRole::Executable && member.mode & 0o111 != 0 { + return Err(audit_error( + path, + "ExtraExecutable", + &format!("mode:{:04o}", member.mode & 0o7777), + &expected, + )); + } + if member.mode & 0o7777 != authority.mode { + return Err(audit_error( + path, + "PayloadClosure", + &format!("mode:{:04o}", member.mode), + &expected, + )); + } + match authority.role { + PayloadRole::Executable => executable = Some(member.bytes), + PayloadRole::InstallNotes => { + let notes = std::str::from_utf8(&member.bytes) + .map_err(|_| audit_error(path, "StaleInstallNotes", "non-utf8", &expected))?; + let normalized = notes.to_ascii_lowercase(); + for token in [ + "--server-url", + "localhost:5015", + "127.0.0.1:5015", + "solstone_token", + "observer key", + "pip install", + "pipx", + ] { + if normalized.contains(token) { + return Err(audit_error(path, "StaleInstallNotes", token, &expected)); + } + } + if member.bytes != INSTALL_NOTES { + return Err(audit_error(path, "StaleInstallNotes", "digest", &expected)); + } + nonbinary.insert(authority.source.to_owned(), digest(&member.bytes)); + } + _ => { + nonbinary.insert(authority.source.to_owned(), digest(&member.bytes)); + } + } + } + if let Some((extra, member)) = by_path.into_iter().next() { + let lower = extra.to_ascii_lowercase(); + let is_interpreter = member.bytes.starts_with(b"#!") + || lower.ends_with(".py") + || lower.contains("python") + || parse_elf64(&member.bytes).is_ok(); + let class = if is_interpreter { + "BundledSidecar" + } else if member.mode & 0o111 != 0 { + "ExtraExecutable" + } else { + "ExtraPayload" + }; + let token = if member.bytes.starts_with(b"#!") { + "shebang" + } else if lower.contains("python") || lower.ends_with(".py") { + "python" + } else if parse_elf64(&member.bytes).is_ok() { + "elf" + } else if member.mode & 0o111 != 0 { + "executable-bit" + } else { + "undeclared" + }; + return Err(audit_error(path, class, token, &extra)); + } + Ok(( + executable.ok_or_else(|| { + audit_error(path, "PayloadClosure", "missing-executable", format.name()) + })?, + nonbinary, + )) +} + +fn inspect_elf(path: &Path, bytes: &[u8]) -> Result { + let parsed = parse_elf64(bytes).map_err(|error| { + audit_error( + path, + "MalformedElf", + &error.to_string(), + "/usr/bin/solstone-linux", + ) + })?; + let expected_needed = EXPECTED_NEEDED.map(str::to_owned).to_vec(); + if parsed.elf_type != EXPECTED_ELF_TYPE + || parsed.machine != EXPECTED_MACHINE + || parsed.interpreter != EXPECTED_INTERPRETER + || parsed.needed != expected_needed + || parsed.soname.is_some() + || parsed.rpath.is_some() + || parsed.runpath.is_some() + { + return Err(audit_error( + path, + "UnexpectedImport", + &format!("{parsed:?}"), + "/usr/bin/solstone-linux", + )); + } + Ok(parsed) +} + +pub fn audit_packages( + tar: &Path, + deb: &Path, + rpm: &Path, + expected_executable_sha256: &str, +) -> Result<()> { + if expected_executable_sha256.len() != 64 + || !expected_executable_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(audit_error( + tar, + "ProvenanceDigest", + "expected-lowercase-sha256", + "command-line", + )); + } + for artifact in [tar, deb, rpm] { + regular_artifact(artifact)?; + } + let tar_version = crate::tar_version(tar).map_err(|error| { + audit_error( + tar, + "PackageIdentity", + &error.to_string(), + "filename-and-metadata", + ) + })?; + let deb_identity = crate::deb_identity(deb) + .map_err(|error| audit_error(deb, "PackageIdentity", &error.to_string(), "control"))?; + let rpm_identity = crate::rpm_identity(rpm) + .map_err(|error| audit_error(rpm, "PackageIdentity", &error.to_string(), "header"))?; + if deb_identity.name != "solstone-linux" + || rpm_identity.name != deb_identity.name + || deb_identity.version != tar_version + || rpm_identity.version != tar_version + || deb_identity.release.as_deref() != Some("1") + || rpm_identity.release != deb_identity.release + || deb_identity.arch != "amd64" + || rpm_identity.arch != "x86_64" + { + return Err(audit_error( + deb, + "PackageIdentity", + "name-version-release-architecture", + "control/header/filename", + )); + } + let inspected = [ + (tar, Format::Tar, tar_members(tar)?), + (deb, Format::Deb, deb_members(deb)?), + (rpm, Format::Rpm, rpm_members(rpm)?), + ] + .into_iter() + .map(|(path, format, members)| { + inspect_payload(path, format, members).map(|result| (path, result)) + }) + .collect::>>()?; + let baseline = digest(&inspected[0].1.0); + if baseline != expected_executable_sha256 { + return Err(audit_error( + inspected[0].0, + "DivergentExecutable", + &format!("sha256:{baseline}"), + "executable", + )); + } + for (path, (binary, nonbinary)) in &inspected { + let binary_digest = digest(binary); + if binary_digest != baseline { + return Err(audit_error( + path, + "DivergentExecutable", + &format!("sha256:{binary_digest}"), + "executable", + )); + } + if nonbinary != &inspected[0].1.1 { + return Err(audit_error( + path, + "DivergentPayload", + "nonbinary-digest", + "payload", + )); + } + inspect_elf(path, binary)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::{Compression, write::GzEncoder}; + use std::io::Write; + use xz2::write::XzEncoder; + + fn artifact(format: Format) -> &'static Path { + match format { + Format::Tar => Path::new("fixture.tar.gz"), + Format::Deb => Path::new("fixture.deb"), + Format::Rpm => Path::new("fixture.rpm"), + } + } + + fn fixture_members(format: Format) -> Vec { + PAYLOAD_AUTHORITY + .into_iter() + .map(|authority| { + let bytes = match authority.role { + PayloadRole::Executable => crate::elf64::pinned_elf64_for_test(), + PayloadRole::InstallNotes => INSTALL_NOTES.to_vec(), + PayloadRole::License => b"license\n".to_vec(), + PayloadRole::Icon => authority.source.as_bytes().to_vec(), + }; + Member { + path: expected_path(format, authority), + mode: authority.mode, + bytes, + } + }) + .collect() + } + + fn exact(format: Format, class: &str, token: &str, member: &str, error: Error) { + assert_eq!( + error.to_string(), + format!( + "package audit: artifact={} class={class} token={token} member={member} tool=rust-release-manifest", + artifact(format).file_name().unwrap().to_str().unwrap() + ) + ); + } + + #[test] + fn clean_payload_control_passes_in_all_formats() { + for format in [Format::Tar, Format::Deb, Format::Rpm] { + let (executable, nonbinary) = + inspect_payload(artifact(format), format, fixture_members(format)).unwrap(); + inspect_elf(artifact(format), &executable).unwrap(); + assert_eq!(nonbinary.len(), PAYLOAD_AUTHORITY.len() - 1); + } + } + + #[test] + fn extra_payload_is_rejected_in_all_formats() { + for format in [Format::Tar, Format::Deb, Format::Rpm] { + let mut members = fixture_members(format); + members.push(Member { + path: "usr/share/solstone-linux/extra".to_owned(), + mode: 0o644, + bytes: b"extra".to_vec(), + }); + let error = inspect_payload(artifact(format), format, members).unwrap_err(); + exact( + format, + "ExtraPayload", + "undeclared", + "usr/share/solstone-linux/extra", + error, + ); + } + } + + #[test] + fn python_shebang_and_elf_sidecars_are_rejected_in_all_formats() { + for (name, bytes, token) in [ + ("usr/libexec/helper.py", b"print('x')".to_vec(), "python"), + ( + "usr/libexec/helper", + b"#!/usr/bin/python3\n".to_vec(), + "shebang", + ), + ( + "usr/libexec/helper-elf", + crate::elf64::pinned_elf64_for_test(), + "elf", + ), + ] { + for format in [Format::Tar, Format::Deb, Format::Rpm] { + let mut members = fixture_members(format); + members.push(Member { + path: name.to_owned(), + mode: 0o644, + bytes: bytes.clone(), + }); + let error = inspect_payload(artifact(format), format, members).unwrap_err(); + exact(format, "BundledSidecar", token, name, error); + } + } + } + + #[test] + fn extra_executable_and_wrong_mode_are_rejected_in_all_formats() { + for format in [Format::Tar, Format::Deb, Format::Rpm] { + let mut members = fixture_members(format); + let icon = members + .iter_mut() + .find(|member| member.path.contains("16x16")) + .unwrap(); + icon.mode = 0o755; + let member = icon.path.clone(); + let error = inspect_payload(artifact(format), format, members).unwrap_err(); + exact(format, "ExtraExecutable", "mode:0755", &member, error); + + let mut members = fixture_members(format); + let license = members + .iter_mut() + .find(|member| member.path.ends_with("LICENSE")) + .unwrap(); + license.mode = 0o600; + let member = license.path.clone(); + let error = inspect_payload(artifact(format), format, members).unwrap_err(); + exact(format, "PayloadClosure", "mode:0600", &member, error); + } + } + + #[test] + fn missing_notes_and_executable_are_rejected_in_all_formats() { + for (role, member) in [ + (PayloadRole::InstallNotes, "missing"), + (PayloadRole::Executable, "missing"), + ] { + for format in [Format::Tar, Format::Deb, Format::Rpm] { + let mut members = fixture_members(format); + let authority = PAYLOAD_AUTHORITY + .iter() + .find(|authority| authority.role == role) + .unwrap(); + let expected = expected_path(format, *authority); + members.retain(|candidate| candidate.path != expected); + let error = inspect_payload(artifact(format), format, members).unwrap_err(); + exact(format, "PayloadClosure", member, &expected, error); + } + } + } + + #[test] + fn stale_notes_bytes_and_each_legacy_token_are_rejected_in_all_formats() { + for mutation in [ + b"different notes".as_slice(), + b"--server-url", + b"localhost:5015", + b"SOLSTONE_TOKEN", + b"observer key", + b"pip install", + b"pipx", + ] { + for format in [Format::Tar, Format::Deb, Format::Rpm] { + let mut members = fixture_members(format); + let notes = members + .iter_mut() + .find(|member| member.path.ends_with("INSTALL-NOTES")) + .unwrap(); + notes.bytes = mutation.to_vec(); + let member = notes.path.clone(); + let error = inspect_payload(artifact(format), format, members).unwrap_err(); + let token = std::str::from_utf8(mutation).unwrap().to_ascii_lowercase(); + exact( + format, + "StaleInstallNotes", + if mutation == b"different notes" { + "digest" + } else { + &token + }, + &member, + error, + ); + } + } + } + + #[test] + fn python_sol_and_journal_dependency_tokens_are_anchored() { + for token in [ + "python", "python2", "python3", "pip", "pip3", "pipx", "sol", "journal", + ] { + assert_eq!(forbidden_dependency(token), Some(token)); + } + for allowed in ["solstone-linux", "libsol", "journald", "libpython-free"] { + assert_eq!(forbidden_dependency(allowed), None, "{allowed}"); + } + } + + #[test] + fn malformed_and_unexpected_elf_diagnostics_name_every_artifact() { + for format in [Format::Tar, Format::Deb, Format::Rpm] { + let error = inspect_elf(artifact(format), b"not ELF").unwrap_err(); + assert!(error.to_string().starts_with(&format!( + "package audit: artifact={} class=MalformedElf token=", + artifact(format).file_name().unwrap().to_str().unwrap() + ))); + assert!( + error + .to_string() + .ends_with(" member=/usr/bin/solstone-linux tool=rust-release-manifest") + ); + + let expected = EXPECTED_NEEDED; + for elf in [ + crate::elf64::linkage_elf64_for_test( + "/unexpected/interpreter", + &expected, + None, + None, + None, + ), + crate::elf64::linkage_elf64_for_test( + EXPECTED_INTERPRETER, + &expected[..expected.len() - 1], + None, + None, + None, + ), + crate::elf64::linkage_elf64_for_test( + EXPECTED_INTERPRETER, + &[ + "libgstreamer-1.0.so.0", + "libgobject-2.0.so.0", + "libglib-2.0.so.0", + "libgio-2.0.so.0", + "libpulse.so.0", + "libgcc_s.so.1", + "libm.so.6", + "libc.so.6", + "libpython3.so", + ], + None, + None, + None, + ), + crate::elf64::linkage_elf64_for_test( + EXPECTED_INTERPRETER, + &expected, + Some("solstone-linux"), + None, + None, + ), + crate::elf64::linkage_elf64_for_test( + EXPECTED_INTERPRETER, + &expected, + None, + Some("/tmp"), + None, + ), + crate::elf64::linkage_elf64_for_test( + EXPECTED_INTERPRETER, + &expected, + None, + None, + Some("/tmp"), + ), + ] { + let error = inspect_elf(artifact(format), &elf).unwrap_err(); + assert!(error.to_string().starts_with(&format!( + "package audit: artifact={} class=UnexpectedImport token=", + artifact(format).file_name().unwrap().to_str().unwrap() + ))); + assert!( + error + .to_string() + .ends_with(" member=/usr/bin/solstone-linux tool=rust-release-manifest") + ); + } + } + } + + fn tar_fixture(entries: &[(&str, &[u8], EntryType, u32)]) -> Vec { + let mut archive = tar::Builder::new(Vec::new()); + for (path, body, kind, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(*mode); + header.set_entry_type(*kind); + header.set_cksum(); + archive + .append_data(&mut header, *path, *body) + .expect("fixture tar member"); + } + archive.into_inner().unwrap() + } + + #[test] + fn tar_inventory_rejects_duplicate_links_devices_and_truncation() { + let artifact = Path::new("fixture.tar.gz"); + let duplicate = tar_fixture(&[ + ("member", b"a", EntryType::Regular, 0o644), + ("member", b"b", EntryType::Regular, 0o644), + ]); + exact( + Format::Tar, + "PayloadClosure", + "duplicate", + "member", + tar_inventory(artifact, Cursor::new(duplicate)).unwrap_err(), + ); + for kind in [ + EntryType::Symlink, + EntryType::Link, + EntryType::Char, + EntryType::Block, + EntryType::Fifo, + ] { + let bytes = tar_fixture(&[("member", b"", kind, 0o644)]); + exact( + Format::Tar, + "UnsupportedMember", + "non-regular", + "tar", + tar_inventory(artifact, Cursor::new(bytes)).unwrap_err(), + ); + } + let mut traversal_builder = tar::Builder::new(Vec::new()); + let mut traversal_header = tar::Header::new_gnu(); + traversal_header.set_size(4); + traversal_header.set_mode(0o644); + traversal_header.as_mut_bytes()[..9].copy_from_slice(b"../escape"); + traversal_header.set_cksum(); + traversal_builder + .append(&traversal_header, b"body".as_slice()) + .unwrap(); + let traversal = traversal_builder.into_inner().unwrap(); + exact( + Format::Tar, + "PayloadClosure", + "path-traversal", + "tar", + tar_inventory(artifact, Cursor::new(traversal)).unwrap_err(), + ); + let mut truncated = tar_fixture(&[("member", b"body", EntryType::Regular, 0o644)]); + truncated.truncate(515); + let error = tar_inventory(artifact, Cursor::new(truncated)).unwrap_err(); + assert_eq!( + error.to_string(), + "package audit: artifact=fixture.tar.gz class=MalformedContainer token=unexpected EOF during skip member=tar tool=rust-release-manifest" + ); + } + + #[test] + fn deb_compression_readers_fail_closed_for_gzip_xz_zstd_and_unknown() { + let artifact = Path::new("fixture.deb"); + let tar = tar_fixture(&[("control", b"body", EntryType::Regular, 0o644)]); + + let mut gzip = GzEncoder::new(Vec::new(), Compression::default()); + gzip.write_all(&tar).unwrap(); + let gzip = gzip.finish().unwrap(); + let mut xz = XzEncoder::new(Vec::new(), 6); + xz.write_all(&tar).unwrap(); + let xz = xz.finish().unwrap(); + let zstd = zstd::stream::encode_all(Cursor::new(&tar), 3).unwrap(); + + for (name, bytes, token) in [ + ("control.tar.gz", gzip, "unexpected end of file"), + ("control.tar.xz", xz, "premature eof"), + ("control.tar.zst", zstd, "incomplete frame"), + ] { + let truncated = bytes[..bytes.len() / 2].to_vec(); + let error = compressed_tar(artifact, name, truncated).unwrap_err(); + exact(Format::Deb, "MalformedContainer", token, "tar", error); + } + exact( + Format::Deb, + "UnsupportedCompression", + "control.tar.bz2", + "deb", + compressed_tar(artifact, "control.tar.bz2", Vec::new()).unwrap_err(), + ); + } +} diff --git a/crates/rust-release-manifest/src/tests.rs b/crates/rust-release-manifest/src/tests.rs index a1e4c4a..3912677 100644 --- a/crates/rust-release-manifest/src/tests.rs +++ b/crates/rust-release-manifest/src/tests.rs @@ -349,6 +349,857 @@ fn rpm_file_with(root: &Path, version: &str, executable: &[u8]) -> PathBuf { path } +#[test] +fn package_payload_authority_matches_both_manifest_dialects() { + use crate::package_audit::PAYLOAD_AUTHORITY; + + let manifest: toml::Value = toml::from_str(include_str!("../../solstone-linux/Cargo.toml")) + .expect("solstone-linux manifest parses"); + let expected = PAYLOAD_AUTHORITY + .iter() + .map(|entry| { + ( + entry.source.to_owned(), + entry.installed.to_owned(), + entry.mode, + ) + }) + .collect::>(); + let deb = manifest["package"]["metadata"]["deb"]["assets"] + .as_array() + .unwrap() + .iter() + .map(|asset| { + let asset = asset.as_array().unwrap(); + let source = asset[0] + .as_str() + .unwrap() + .trim_start_matches("../../") + .to_owned(); + let mut installed = format!("/{}", asset[1].as_str().unwrap().trim_start_matches('/')); + if installed.ends_with('/') { + installed.push_str(Path::new(&source).file_name().unwrap().to_str().unwrap()); + } + let mode = u32::from_str_radix(asset[2].as_str().unwrap(), 8).unwrap(); + (source, installed, mode) + }) + .collect::>(); + let rpm = manifest["package"]["metadata"]["generate-rpm"]["assets"] + .as_array() + .unwrap() + .iter() + .map(|asset| { + let asset = asset.as_table().unwrap(); + ( + asset["source"].as_str().unwrap().to_owned(), + asset["dest"].as_str().unwrap().to_owned(), + u32::from_str_radix(asset["mode"].as_str().unwrap(), 8).unwrap(), + ) + }) + .collect::>(); + assert_eq!(deb, rpm); + assert_eq!(deb, expected); + assert_eq!(expected.len(), 16); +} + +fn audit_fixture_bytes( + authority: crate::package_audit::PayloadAuthority, + executable: &[u8], +) -> Vec { + match authority.role { + crate::package_audit::PayloadRole::Executable => executable.to_vec(), + crate::package_audit::PayloadRole::InstallNotes => { + include_bytes!("../../../packaging/INSTALL-NOTES").to_vec() + } + crate::package_audit::PayloadRole::License => b"audit fixture license\n".to_vec(), + crate::package_audit::PayloadRole::Icon => { + format!("audit fixture {}\n", authority.source).into_bytes() + } + } +} + +#[derive(Default)] +struct AuditPayloadOptions { + omit: Option, + replacement: Option<(crate::package_audit::PayloadRole, Vec, u32)>, + extras: Vec<(String, Vec, u32)>, +} + +fn audit_tar_with_payload( + root: &Path, + executable: &[u8], + options: &AuditPayloadOptions, +) -> PathBuf { + let path = root.join("solstone-linux-1.0.0-linux-x86_64.tar.gz"); + let mut tar = tar::Builder::new(Vec::new()); + for authority in crate::package_audit::PAYLOAD_AUTHORITY { + if options.omit == Some(authority.role) { + continue; + } + let relative = match authority.role { + crate::package_audit::PayloadRole::Executable => "bin/solstone-linux".to_owned(), + crate::package_audit::PayloadRole::License => "LICENSE".to_owned(), + crate::package_audit::PayloadRole::InstallNotes => "INSTALL-NOTES".to_owned(), + crate::package_audit::PayloadRole::Icon => format!( + "share/icons/{}", + authority.source.strip_prefix("contrib/icons/").unwrap() + ), + }; + let member = format!("solstone-linux-1.0.0-linux-x86_64/{relative}"); + let (bytes, mode) = options + .replacement + .as_ref() + .filter(|(role, _, _)| *role == authority.role) + .map(|(_, bytes, mode)| (bytes.clone(), *mode)) + .unwrap_or_else(|| (audit_fixture_bytes(authority, executable), authority.mode)); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(mode); + header.set_cksum(); + tar.append_data(&mut header, member, bytes.as_slice()) + .unwrap(); + } + for (relative, bytes, mode) in &options.extras { + let member = format!("solstone-linux-1.0.0-linux-x86_64/{relative}"); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(*mode); + header.set_cksum(); + tar.append_data(&mut header, member, bytes.as_slice()) + .unwrap(); + } + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&tar.into_inner().unwrap()).unwrap(); + fs::write(&path, encoder.finish().unwrap()).unwrap(); + path +} + +fn audit_tar(root: &Path, executable: &[u8]) -> PathBuf { + audit_tar_with_payload(root, executable, &AuditPayloadOptions::default()) +} + +fn audit_deb_with( + root: &Path, + executable: &[u8], + control_body: &[u8], + scripts: &[(&str, &[u8])], + payload: &AuditPayloadOptions, +) -> PathBuf { + let mut data = tar::Builder::new(Vec::new()); + for authority in crate::package_audit::PAYLOAD_AUTHORITY { + if payload.omit == Some(authority.role) { + continue; + } + let member = format!(".{}", authority.installed); + let (bytes, mode) = payload + .replacement + .as_ref() + .filter(|(role, _, _)| *role == authority.role) + .map(|(_, bytes, mode)| (bytes.clone(), *mode)) + .unwrap_or_else(|| (audit_fixture_bytes(authority, executable), authority.mode)); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(mode); + header.set_cksum(); + data.append_data(&mut header, member, bytes.as_slice()) + .unwrap(); + } + for (installed, bytes, mode) in &payload.extras { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(*mode); + header.set_cksum(); + data.append_data( + &mut header, + format!("./{}", installed.trim_start_matches('/')), + bytes.as_slice(), + ) + .unwrap(); + } + let path = root.join("solstone-linux_1.0.0-1_amd64.deb"); + let marker = b"2.0\n"; + let mut control = tar::Builder::new(Vec::new()); + let mut control_members = vec![ + ("./control", control_body), + ("./md5sums", b"fixture usr/bin/solstone-linux\n".as_slice()), + ]; + control_members.extend_from_slice(scripts); + for (name, body) in control_members { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + control.append_data(&mut header, name, body).unwrap(); + } + let control = gzip(&control.into_inner().unwrap()); + let data = gzip(&data.into_inner().unwrap()); + deb_members( + root, + path.file_name().unwrap().to_str().unwrap(), + &[ + ("debian-binary", marker), + ("control.tar.gz", &control), + ("data.tar.gz", &data), + ], + ) +} + +fn audit_deb(root: &Path, executable: &[u8]) -> PathBuf { + audit_deb_with( + root, + executable, + b"Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6\n", + &[], + &AuditPayloadOptions::default(), + ) +} + +fn audit_rpm_with( + root: &Path, + executable: &[u8], + payload: &AuditPayloadOptions, + configure: impl FnOnce(&mut rpm::PackageBuilder), +) -> PathBuf { + let path = root.join("solstone-linux-1.0.0-1.x86_64.rpm"); + let mut builder = + rpm::PackageBuilder::new(PRODUCT, "1.0.0", "AGPL-3.0-only", "x86_64", "fixture"); + for authority in crate::package_audit::PAYLOAD_AUTHORITY { + if payload.omit == Some(authority.role) { + continue; + } + let (bytes, mode) = payload + .replacement + .as_ref() + .filter(|(role, _, _)| *role == authority.role) + .map(|(_, bytes, mode)| (bytes.clone(), *mode)) + .unwrap_or_else(|| (audit_fixture_bytes(authority, executable), authority.mode)); + builder + .with_file_contents( + bytes, + rpm::FileOptions::new(authority.installed) + .permissions(u16::try_from(mode).unwrap()), + ) + .unwrap(); + } + for (installed, bytes, mode) in &payload.extras { + let installed = format!("/{}", installed.trim_start_matches('/')); + builder + .with_file_contents( + bytes.clone(), + rpm::FileOptions::new(installed).permissions(u16::try_from(*mode).unwrap()), + ) + .unwrap(); + } + configure(&mut builder); + builder + .build() + .unwrap() + .write(&mut File::create(&path).unwrap()) + .unwrap(); + path +} + +fn audit_rpm(root: &Path, executable: &[u8]) -> PathBuf { + audit_rpm_with(root, executable, &AuditPayloadOptions::default(), |_| {}) +} + +#[test] +fn package_audit_accepts_real_clean_containers_and_rejects_divergence() { + let requested = std::env::var_os("PACKAGE_AUDIT_FIXTURE_DIR").map(PathBuf::from); + let temporary = requested.is_none().then(|| tempfile::tempdir().unwrap()); + let root = requested + .as_deref() + .unwrap_or_else(|| temporary.as_ref().unwrap().path()); + fs::create_dir_all(root).unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root, &executable); + let deb = audit_deb(root, &executable); + let rpm = audit_rpm(root, &executable); + let expected = digest(&executable); + audit_packages(&tar, &deb, &rpm, &expected).unwrap(); + if requested.is_some() { + fs::write( + root.join("expected-executable.sha256"), + format!("{expected}\n"), + ) + .unwrap(); + } + let error = audit_packages(&tar, &deb, &rpm, &"0".repeat(64)).unwrap_err(); + assert!(error.to_string().contains("class=DivergentExecutable")); +} + +#[test] +fn package_audit_names_each_divergent_binary_format() { + for divergent_format in ["tar", "deb", "rpm"] { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let mut divergent = executable.clone(); + let last = divergent.len() - 1; + divergent[last] ^= 1; + let tar = audit_tar( + root.path(), + if divergent_format == "tar" { + &divergent + } else { + &executable + }, + ); + let deb = audit_deb( + root.path(), + if divergent_format == "deb" { + &divergent + } else { + &executable + }, + ); + let rpm = audit_rpm( + root.path(), + if divergent_format == "rpm" { + &divergent + } else { + &executable + }, + ); + let artifact = match divergent_format { + "tar" => &tar, + "deb" => &deb, + "rpm" => &rpm, + _ => unreachable!(), + }; + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic( + error, + artifact, + "DivergentExecutable", + &format!("sha256:{}", digest(&divergent)), + "executable", + ); + } +} + +#[test] +fn package_audit_real_artifacts_cover_payload_rejection_matrix() { + use crate::package_audit::PayloadRole; + + enum Mutation { + Extra(&'static str, Vec, u32, &'static str, &'static str), + Replace(PayloadRole, Vec, u32, &'static str, &'static str), + Omit(PayloadRole), + } + let mutations = vec![ + Mutation::Extra( + "usr/share/solstone-linux/extra", + b"extra".to_vec(), + 0o644, + "ExtraPayload", + "undeclared", + ), + Mutation::Extra( + "usr/libexec/helper.py", + b"print('x')".to_vec(), + 0o644, + "BundledSidecar", + "python", + ), + Mutation::Extra( + "usr/libexec/helper", + b"#!/usr/bin/python3\n".to_vec(), + 0o755, + "BundledSidecar", + "shebang", + ), + Mutation::Extra( + "usr/libexec/helper-elf", + crate::elf64::pinned_elf64_for_test(), + 0o755, + "BundledSidecar", + "elf", + ), + Mutation::Extra( + "usr/libexec/extra-executable", + b"binary".to_vec(), + 0o755, + "ExtraExecutable", + "executable-bit", + ), + Mutation::Replace( + PayloadRole::Icon, + b"icon".to_vec(), + 0o755, + "ExtraExecutable", + "mode:0755", + ), + Mutation::Replace( + PayloadRole::License, + b"license".to_vec(), + 0o600, + "PayloadClosure", + "mode:0600", + ), + Mutation::Replace( + PayloadRole::InstallNotes, + b"different notes".to_vec(), + 0o644, + "StaleInstallNotes", + "digest", + ), + Mutation::Replace( + PayloadRole::InstallNotes, + b"--server-url".to_vec(), + 0o644, + "StaleInstallNotes", + "--server-url", + ), + Mutation::Omit(PayloadRole::InstallNotes), + Mutation::Omit(PayloadRole::Executable), + ]; + + for format in ["tar", "deb", "rpm"] { + for mutation in &mutations { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let mut options = AuditPayloadOptions::default(); + let (class, token, member) = match mutation { + Mutation::Extra(path, bytes, mode, class, token) => { + options + .extras + .push(((*path).to_owned(), bytes.clone(), *mode)); + (*class, *token, (*path).to_owned()) + } + Mutation::Replace(role, bytes, mode, class, token) => { + options.replacement = Some((*role, bytes.clone(), *mode)); + let authority = crate::package_audit::PAYLOAD_AUTHORITY + .iter() + .find(|authority| authority.role == *role) + .unwrap(); + let member = match format { + "tar" => match role { + PayloadRole::Executable => "bin/solstone-linux".to_owned(), + PayloadRole::License => "LICENSE".to_owned(), + PayloadRole::InstallNotes => "INSTALL-NOTES".to_owned(), + PayloadRole::Icon => authority + .source + .strip_prefix("contrib/icons/") + .map(|path| format!("share/icons/{path}")) + .unwrap(), + }, + _ => authority.installed.trim_start_matches('/').to_owned(), + }; + (*class, *token, member) + } + Mutation::Omit(role) => { + options.omit = Some(*role); + let authority = crate::package_audit::PAYLOAD_AUTHORITY + .iter() + .find(|authority| authority.role == *role) + .unwrap(); + let member = match format { + "tar" => match role { + PayloadRole::Executable => "bin/solstone-linux".to_owned(), + PayloadRole::License => "LICENSE".to_owned(), + PayloadRole::InstallNotes => "INSTALL-NOTES".to_owned(), + PayloadRole::Icon => unreachable!(), + }, + _ => authority.installed.trim_start_matches('/').to_owned(), + }; + ("PayloadClosure", "missing", member) + } + }; + let tar = if format == "tar" { + audit_tar_with_payload(root.path(), &executable, &options) + } else { + audit_tar(root.path(), &executable) + }; + let deb = if format == "deb" { + audit_deb_with( + root.path(), + &executable, + b"Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6\n", + &[], + &options, + ) + } else { + audit_deb(root.path(), &executable) + }; + let rpm = if format == "rpm" { + audit_rpm_with(root.path(), &executable, &options, |_| {}) + } else { + audit_rpm(root.path(), &executable) + }; + let artifact = match format { + "tar" => &tar, + "deb" => &deb, + "rpm" => &rpm, + _ => unreachable!(), + }; + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic(error, artifact, class, token, &member); + } + } +} + +fn clean_audit_set(root: &Path) -> (PathBuf, PathBuf, PathBuf, String) { + let executable = crate::elf64::pinned_elf64_for_test(); + ( + audit_tar(root, &executable), + audit_deb(root, &executable), + audit_rpm(root, &executable), + digest(&executable), + ) +} + +fn assert_package_audit_diagnostic( + error: Error, + artifact: &Path, + class: &str, + token: &str, + member: &str, +) { + assert_eq!( + error.to_string(), + format!( + "package audit: artifact={} class={class} token={token} member={member} tool=rust-release-manifest", + artifact.file_name().unwrap().to_str().unwrap() + ) + ); +} + +#[test] +fn package_audit_rejects_each_deb_dependency_and_accepts_product_name() { + for forbidden in ["python3", "pip", "pipx", "sol", "journal"] { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let rpm = audit_rpm(root.path(), &executable); + let control = format!( + "Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6, {forbidden}\n" + ); + let deb = audit_deb_with( + root.path(), + &executable, + control.as_bytes(), + &[], + &AuditPayloadOptions::default(), + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic( + error, + &deb, + "ForbiddenDependency", + forbidden, + "deb:control/Depends", + ); + } + + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let rpm = audit_rpm(root.path(), &executable); + let deb = audit_deb_with( + root.path(), + &executable, + b"Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6, solstone-linux\n", + &[], + &AuditPayloadOptions::default(), + ); + audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap(); +} + +#[test] +fn package_audit_rejects_malformed_and_non_utf8_deb_control_metadata() { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let rpm = audit_rpm(root.path(), &executable); + let deb = audit_deb_with( + root.path(), + &executable, + b"Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6,,libpulse0\n", + &[], + &AuditPayloadOptions::default(), + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic( + error, + &deb, + "MalformedMetadata", + "dependency-grammar", + "deb:control/Depends", + ); + + let deb = audit_deb_with( + root.path(), + &executable, + b"Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6\n\xff", + &[], + &AuditPayloadOptions::default(), + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert!(error.to_string().starts_with( + "package audit: artifact=solstone-linux_1.0.0-1_amd64.deb class=PackageIdentity token=" + )); + assert!( + error + .to_string() + .ends_with(" member=control tool=rust-release-manifest") + ); +} + +#[test] +fn package_audit_rejects_each_rpm_dependency_and_accepts_product_name() { + for forbidden in ["python3", "pip", "pipx", "sol", "journal"] { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let deb = audit_deb(root.path(), &executable); + let rpm = audit_rpm_with( + root.path(), + &executable, + &AuditPayloadOptions::default(), + |builder| { + builder.requires(rpm::Dependency::any(forbidden)); + }, + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic( + error, + &rpm, + "ForbiddenDependency", + forbidden, + "rpm:Requires/Provides", + ); + } + + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let deb = audit_deb(root.path(), &executable); + let rpm = audit_rpm_with( + root.path(), + &executable, + &AuditPayloadOptions::default(), + |builder| { + builder.requires(rpm::Dependency::any("solstone-linux")); + }, + ); + audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap(); +} + +#[test] +fn package_audit_rejects_every_deb_control_script_even_when_empty() { + for script in [ + "preinst", "postinst", "prerm", "postrm", "config", "triggers", + ] { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let rpm = audit_rpm(root.path(), &executable); + let name = format!("./{script}"); + let deb = audit_deb_with( + root.path(), + &executable, + b"Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6\n", + &[(name.as_str(), b"")], + &AuditPayloadOptions::default(), + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic( + error, + &deb, + "MaintainerScript", + script, + &format!("deb:control/{script}"), + ); + } +} + +#[test] +fn package_audit_rejects_every_rpm_script_and_trigger_even_when_empty() { + type Configure = fn(&mut rpm::PackageBuilder); + let scriptlets: [(&str, Configure); 9] = [ + ("pre", |builder| { + builder.pre_install_script(""); + }), + ("post", |builder| { + builder.post_install_script(""); + }), + ("preun", |builder| { + builder.pre_uninstall_script(""); + }), + ("postun", |builder| { + builder.post_uninstall_script(""); + }), + ("pretrans", |builder| { + builder.pre_trans_script(""); + }), + ("posttrans", |builder| { + builder.post_trans_script(""); + }), + ("preuntrans", |builder| { + builder.pre_untrans_script(""); + }), + ("postuntrans", |builder| { + builder.post_untrans_script(""); + }), + ("verifyscript", |builder| { + builder.verify_script(""); + }), + ]; + for (token, configure) in scriptlets { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let deb = audit_deb(root.path(), &executable); + let rpm = audit_rpm_with( + root.path(), + &executable, + &AuditPayloadOptions::default(), + configure, + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic(error, &rpm, "MaintainerScript", token, "rpm:script"); + } + + let triggers: [Configure; 3] = [ + |builder| { + builder.trigger_in("bash", None, ""); + }, + |builder| { + builder.file_trigger_in("/usr/lib", None, ""); + }, + |builder| { + builder.trans_file_trigger_in("/usr/lib", None, ""); + }, + ]; + for configure in triggers { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let deb = audit_deb(root.path(), &executable); + let rpm = audit_rpm_with( + root.path(), + &executable, + &AuditPayloadOptions::default(), + configure, + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic(error, &rpm, "MaintainerScript", "trigger", "rpm:trigger"); + } +} + +#[test] +fn package_audit_rejects_real_rpm_symlink_payload() { + let root = tempfile::tempdir().unwrap(); + let executable = crate::elf64::pinned_elf64_for_test(); + let tar = audit_tar(root.path(), &executable); + let deb = audit_deb(root.path(), &executable); + let rpm = audit_rpm_with( + root.path(), + &executable, + &AuditPayloadOptions::default(), + |builder| { + builder + .with_symlink(rpm::FileOptions::symlink( + "/usr/libexec/solstone-sidecar", + "/usr/bin/solstone-linux", + )) + .unwrap(); + }, + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic(error, &rpm, "UnsupportedMember", "non-regular", "rpm:cpio"); +} + +#[test] +fn package_audit_fails_closed_for_unreadable_and_nonregular_artifacts() { + use std::os::unix::net::UnixListener; + + let root = tempfile::tempdir().unwrap(); + let (tar, deb, rpm, expected) = clean_audit_set(root.path()); + let absent = root.path().join("absent.tar.gz"); + let error = audit_packages(&absent, &deb, &rpm, &expected).unwrap_err(); + assert_package_audit_diagnostic(error, &absent, "UnreadableArtifact", "metadata", "artifact"); + + let directory = root.path().join("directory.tar.gz"); + fs::create_dir(&directory).unwrap(); + let error = audit_packages(&directory, &deb, &rpm, &expected).unwrap_err(); + assert_package_audit_diagnostic( + error, + &directory, + "InvalidArtifact", + "regular-file-required", + "artifact", + ); + + let socket = root.path().join("socket.tar.gz"); + let _listener = UnixListener::bind(&socket).unwrap(); + let error = audit_packages(&socket, &deb, &rpm, &expected).unwrap_err(); + assert_package_audit_diagnostic( + error, + &socket, + "InvalidArtifact", + "regular-file-required", + "artifact", + ); + assert!(tar.exists()); +} + +#[test] +fn package_audit_fails_closed_for_wrong_and_truncated_containers() { + let root = tempfile::tempdir().unwrap(); + let (tar, deb, rpm, expected) = clean_audit_set(root.path()); + + fs::write(&deb, fs::read(&tar).unwrap()).unwrap(); + let error = audit_packages(&tar, &deb, &rpm, &expected).unwrap_err(); + assert!(error.to_string().starts_with( + "package audit: artifact=solstone-linux_1.0.0-1_amd64.deb class=PackageIdentity token=" + )); + assert!( + error + .to_string() + .ends_with(" member=control tool=rust-release-manifest") + ); + + let (tar, deb, rpm, expected) = clean_audit_set(root.path()); + for (artifact, keep, member) in [ + (&tar, 12_usize, "filename-and-metadata"), + (&deb, 12_usize, "control"), + (&rpm, 12_usize, "header"), + ] { + let bytes = fs::read(artifact).unwrap(); + fs::write(artifact, &bytes[..keep.min(bytes.len())]).unwrap(); + let error = audit_packages(&tar, &deb, &rpm, &expected).unwrap_err(); + assert!(error.to_string().contains(&format!( + "artifact={} class=PackageIdentity", + artifact.file_name().unwrap().to_str().unwrap() + ))); + assert!( + error + .to_string() + .ends_with(&format!(" member={member} tool=rust-release-manifest")) + ); + let rebuilt = clean_audit_set(root.path()); + drop(rebuilt); + } +} + +#[test] +fn package_audit_fails_closed_when_rpm_cpio_payload_is_truncated() { + let root = tempfile::tempdir().unwrap(); + let (tar, deb, rpm, expected) = clean_audit_set(root.path()); + let mut bytes = fs::read(&rpm).unwrap(); + bytes.truncate(bytes.len() - 1024); + fs::write(&rpm, bytes).unwrap(); + let error = audit_packages(&tar, &deb, &rpm, &expected).unwrap_err(); + assert_package_audit_diagnostic( + error, + &rpm, + "MalformedContainer", + "incomplete frame", + "rpm:cpio", + ); +} + #[test] fn package_member_evidence_is_bound_to_all_three_formats() { let fixture = release_fixture(); diff --git a/crates/solstone-linux/Cargo.toml b/crates/solstone-linux/Cargo.toml index b6240c6..c3a7ef2 100644 --- a/crates/solstone-linux/Cargo.toml +++ b/crates/solstone-linux/Cargo.toml @@ -23,7 +23,7 @@ flac-bound = { version = "0.5.0", default-features = false, features = ["libflac libpulse-binding = "2.30.1" chrono = "0.4" rustix = { version = "1", features = ["event", "fs", "process", "time"] } -reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "multipart", "rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "multipart", "stream"] } spl-core = { version = "0.1.0", git = "https://github.com/solpbc/spl-rust", rev = "742bc9dc789c5a75658844849a04d75033aeb6e3" } spl-transport = { version = "0.1.0", git = "https://github.com/solpbc/spl-rust", rev = "742bc9dc789c5a75658844849a04d75033aeb6e3" } tokio = { workspace = true, features = ["signal", "sync"] } diff --git a/crates/solstone-linux/src/chat_bridge.rs b/crates/solstone-linux/src/chat_bridge.rs deleted file mode 100644 index f153c75..0000000 --- a/crates/solstone-linux/src/chat_bridge.rs +++ /dev/null @@ -1,2501 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright (c) 2026 sol pbc - -//! Server-initiated chat events bridged to Linux desktop surfaces. -// L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. - -use crate::config::Config; -use chrono::Local; -use futures_util::{FutureExt, StreamExt, future::BoxFuture}; -use notify_rust::{Notification, NotificationResponse}; -use reqwest::{Client, StatusCode}; -use rustix::{ - fs::{FileType, Mode, OFlags}, - io::Errno, -}; -use serde_json::Value; -use std::{ - env, - panic::AssertUnwindSafe, - path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::{Duration, Instant}, -}; -use tokio::{sync::OnceCell, task::JoinHandle}; -use tokio_util::sync::CancellationToken; - -// Keep these event names and owner-facing copy hand-synced with -// solstone/convey/sol_initiated/copy.py; this repo does not vendor that canon. -pub const EVENT_SOL_CHAT_REQUEST: &str = "sol_chat_request"; -pub const EVENT_SOL_CHAT_REQUEST_SUPERSEDED: &str = "sol_chat_request_superseded"; -pub const EVENT_OWNER_CHAT_OPEN: &str = "owner_chat_open"; -pub const EVENT_OWNER_CHAT_DISMISSED: &str = "owner_chat_dismissed"; -pub const NOTIFY_TITLE: &str = "sol"; -pub const SURFACE: &str = "linux"; -const FIFO_RELATIVE_PATH: &str = ".solstone/notify"; -const RECONNECT_DELAYS: [u64; 6] = [1, 2, 4, 8, 16, 30]; -pub const HEARTBEAT_STALE: Duration = Duration::from_secs(60); -pub const SSE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -pub const SSE_READ_TIMEOUT: Duration = Duration::from_secs(HEARTBEAT_STALE.as_secs() + 30); -const BRIDGE_POLL_INTERVAL: Duration = Duration::from_secs(5); -const NOTIFY_ACTION_KEY: &str = "open"; -const HEALTHY_RUN: Duration = Duration::from_secs(60); -const OPT_IN_POLL: Duration = Duration::from_secs(300); -pub const PENDING_CAP: usize = 32; - -#[derive(Clone, Debug, PartialEq, Eq)] -struct NotificationSpec { - summary: String, - body: String, - offer_action: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum NotificationOutcome { - Open, - Dismissed, - Failed, - Cancelled, -} - -fn outcome_for_action(action: &str) -> NotificationOutcome { - if action == NOTIFY_ACTION_KEY { - NotificationOutcome::Open - } else { - NotificationOutcome::Dismissed - } -} - -type NotifyFn = Arc< - dyn Fn(NotificationSpec, CancellationToken) -> BoxFuture<'static, NotificationOutcome> - + Send - + Sync, ->; -type AckFn = Arc BoxFuture<'static, ()> + Send + Sync>; -type OpenFn = Arc BoxFuture<'static, ()> + Send + Sync>; -type CapabilitiesFn = Arc BoxFuture<'static, bool> + Send + Sync>; -type SleepFn = Arc BoxFuture<'static, ()> + Send + Sync>; -type MonotonicFn = Arc Duration + Send + Sync>; -type LocalDayFn = Arc String + Send + Sync>; -type BodyFn = Arc< - dyn Fn(Config, CancellationToken, BridgeDeps, Client) -> BoxFuture<'static, Result<(), String>> - + Send - + Sync, ->; - -#[derive(Clone)] -struct BridgeDeps { - notify: NotifyFn, - ack_open: AckFn, - open_browser: OpenFn, - supports_actions: CapabilitiesFn, - sleep: SleepFn, - monotonic_now: MonotonicFn, - local_day: LocalDayFn, -} - -struct PendingRequest { - request_id: String, - cancellation: CancellationToken, - task: JoinHandle<()>, -} - -#[derive(Default, Debug)] -struct SseParseState { - buffered: Vec, - event: Option, - data: Vec, - id: Option, -} - -#[derive(Debug, PartialEq, Eq)] -struct SseFrame { - event: Option, - data: String, - id: Option, -} - -#[derive(Debug, PartialEq, Eq)] -enum SseItem { - Heartbeat, - Frame(SseFrame), -} - -fn parse_sse_chunk(mut state: SseParseState, chunk: &[u8]) -> (SseParseState, Vec) { - state.buffered.extend_from_slice(chunk); - let mut items = Vec::new(); - while let Some(newline) = state.buffered.iter().position(|byte| *byte == b'\n') { - let mut raw = state.buffered.drain(..=newline).collect::>(); - raw.pop(); - if raw.last() == Some(&b'\r') { - raw.pop(); - } - let line = String::from_utf8_lossy(&raw); - if line.starts_with(':') { - items.push(SseItem::Heartbeat); - continue; - } - if line.is_empty() { - if state.data.is_empty() { - state.event = None; - state.id = None; - continue; - } - items.push(SseItem::Frame(SseFrame { - event: state.event.take(), - data: state.data.join("\n"), - id: state.id.take(), - })); - state.data.clear(); - continue; - } - let (field, mut value) = line - .split_once(':') - .map_or((line.as_ref(), ""), |(field, value)| (field, value)); - if let Some(stripped) = value.strip_prefix(' ') { - value = stripped; - } - match field { - "data" => state.data.push(value.to_owned()), - "event" => state.event = Some(value.to_owned()), - "id" => state.id = Some(value.to_owned()), - _ => {} - } - } - (state, items) -} - -fn python_truthy(value: Option<&Value>) -> bool { - match value { - None | Some(Value::Null) => false, - Some(Value::Bool(value)) => *value, - Some(Value::String(value)) => !value.is_empty(), - Some(Value::Number(value)) => value.as_f64().is_some_and(|number| number != 0.0), - Some(Value::Array(value)) => !value.is_empty(), - Some(Value::Object(value)) => !value.is_empty(), - } -} - -fn python_scalar_str(value: &Value) -> Option { - match value { - Value::Bool(true) => Some("True".to_owned()), - Value::String(value) => Some(value.clone()), - Value::Number(value) => { - if let Some(integer) = value.as_i64() { - Some(integer.to_string()) - } else if let Some(integer) = value.as_u64() { - Some(integer.to_string()) - } else { - // Named deviation: Rust's finite-float Display spelling differs from Python's - // repr-style thresholds (for example 1e20). The bridge preserves the numeric - // value but does not implement a Python float formatter for malformed payloads. - value.as_f64().map(|number| { - let rendered = number.to_string(); - if rendered.contains(['.', 'e', 'E']) { - rendered - } else { - format!("{rendered}.0") - } - }) - } - } - _ => None, - } -} - -fn python_or_empty_str(value: Option<&Value>) -> String { - if !python_truthy(value) { - return String::new(); - } - // Named deviation: truthy list/dict summaries become empty instead of Python repr text; - // no server-produced summary has a collection shape. - value.and_then(python_scalar_str).unwrap_or_default() -} - -fn fifo_path() -> PathBuf { - env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_default() - .join(FIFO_RELATIVE_PATH) -} - -fn write_fifo(line: &str, path: &Path) { - let stat = match rustix::fs::stat(path) { - Ok(stat) => stat, - Err(Errno::NOENT) => { - tracing::debug!(path = %path.display(), "Chat bridge FIFO missing"); - return; - } - Err(error) => { - tracing::warn!(%error, "Chat bridge FIFO write failed"); - return; - } - }; - if !FileType::from_raw_mode(stat.st_mode).is_fifo() { - tracing::debug!(path = %path.display(), "Chat bridge path is not a FIFO"); - return; - } - let fd = match rustix::fs::open( - path, - OFlags::WRONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, - Mode::empty(), - ) { - Ok(fd) => fd, - Err(error) if fifo_error_is_tolerated(error) => { - tracing::debug!(%error, "Chat bridge FIFO unavailable"); - return; - } - Err(error) => { - tracing::warn!(%error, "Chat bridge FIFO write failed"); - return; - } - }; - if let Err(error) = rustix::io::write(&fd, line.as_bytes()) { - if fifo_error_is_tolerated(error) { - tracing::debug!(%error, "Chat bridge FIFO unavailable"); - } else { - tracing::warn!(%error, "Chat bridge FIFO write failed"); - } - } -} - -fn fifo_error_is_tolerated(error: Errno) -> bool { - matches!(error, Errno::NXIO | Errno::AGAIN) -} - -fn chat_url( - server_url: &str, - day: Option<&Value>, - event_index: Option<&Value>, - deps: &BridgeDeps, -) -> String { - let base = server_url.trim_end_matches('/'); - if let (Some(day), Some(index)) = ( - // Named deviation: numeric day values fall back to local today instead of being - // interpolated as Python would; server-produced day values are strings. - day.and_then(Value::as_str).filter(|day| !day.is_empty()), - event_index.and_then(Value::as_i64), - ) { - return format!("{base}/app/chat/{day}#event-{index}"); - } - format!("{base}/app/chat/{}", (deps.local_day)()) -} - -fn reconnect_delay(index: usize) -> Duration { - Duration::from_secs(RECONNECT_DELAYS[index.min(RECONNECT_DELAYS.len() - 1)]) -} - -fn mark_stale_if_needed(last_frame_at: Duration, now: Duration, is_stale: &mut bool) { - if now.saturating_sub(last_frame_at) > HEARTBEAT_STALE && !*is_stale { - tracing::warn!("Chat bridge heartbeat stale"); - *is_stale = true; - } -} - -fn mark_live_frame(is_stale: &mut bool) { - if *is_stale { - tracing::info!("Chat bridge heartbeat recovered"); - *is_stale = false; - } -} - -async fn cancel_pending_at(pending: &mut Vec, index: usize) { - let request = pending.remove(index); - request.cancellation.cancel(); - let _ = request.task.await; -} - -async fn cancel_pending_id(pending: &mut Vec, request_id: &str) { - if let Some(index) = pending - .iter() - .position(|request| request.request_id == request_id) - { - cancel_pending_at(pending, index).await; - } -} - -async fn cancel_all_pending(pending: &mut Vec) { - while !pending.is_empty() { - cancel_pending_at(pending, 0).await; - } -} - -async fn dispatch_event( - payload: &serde_json::Map, - pending: &mut Vec, - opt_in: bool, - is_stale: bool, - config: &Config, - deps: &BridgeDeps, - fifo: &Path, -) { - if payload.get("tract").and_then(Value::as_str) != Some("chat") { - return; - } - let Some(event) = payload.get("event").and_then(Value::as_str) else { - return; - }; - if !matches!( - event, - EVENT_SOL_CHAT_REQUEST - | EVENT_SOL_CHAT_REQUEST_SUPERSEDED - | EVENT_OWNER_CHAT_OPEN - | EVENT_OWNER_CHAT_DISMISSED - ) { - return; - } - let value = payload.get("request_id"); - if !python_truthy(value) { - tracing::debug!(event, "Chat event missing request_id"); - return; - } - let Some(request_id) = value.and_then(python_scalar_str) else { - // Named deviation: Python str()-ifies a truthy list/dict request_id into a Python repr; - // Rust drops it as malformed because serde_json cannot reproduce Python's repr or key - // order, and no server payload produces one. - tracing::debug!(event, "Chat event missing request_id"); - return; - }; - if request_id.trim().is_empty() { - tracing::debug!(event, "Chat event missing request_id"); - return; - } - - if event == EVENT_SOL_CHAT_REQUEST { - let summary = python_or_empty_str(payload.get("summary")); - write_fifo(&format!("sol-ping {request_id} {summary}\n"), fifo); - cancel_pending_id(pending, &request_id).await; - if opt_in && !is_stale { - let offer_action = (deps.supports_actions)().await; - let url = chat_url( - &config.server_url, - payload.get("day"), - payload.get("event_index"), - deps, - ); - let cancellation = CancellationToken::new(); - let task_cancellation = cancellation.clone(); - let notify = Arc::clone(&deps.notify); - let ack = Arc::clone(&deps.ack_open); - let open = Arc::clone(&deps.open_browser); - let server_url = config.server_url.clone(); - let key = config.key.clone(); - let task_id = request_id.clone(); - let task_summary = summary.clone(); - let task_url = url.clone(); - let task = tokio::spawn(async move { - let post_notify_cancellation = task_cancellation.clone(); - let outcome = notify( - NotificationSpec { - summary: NOTIFY_TITLE.to_owned(), - body: task_summary, - offer_action, - }, - task_cancellation, - ) - .await; - if outcome == NotificationOutcome::Open && offer_action { - tracing::info!(request_id = task_id, "Opening chat request"); - tokio::select! { - () = post_notify_cancellation.cancelled() => return, - () = ack(server_url, key, task_id) => {} - } - tokio::select! { - () = post_notify_cancellation.cancelled() => {} - () = open(task_url) => {} - } - } - }); - pending.push(PendingRequest { - request_id, - cancellation, - task, - }); - if pending.len() > PENDING_CAP { - let evicted = pending[0].request_id.clone(); - cancel_pending_at(pending, 0).await; - tracing::debug!("Evicted pending chat request: {evicted}"); - } - } - return; - } - - cancel_pending_id(pending, &request_id).await; - write_fifo(&format!("clear {request_id}\n"), fifo); -} - -fn build_sse_client(connect: Duration, read: Duration) -> Result { - // SSE is intentionally long-lived. Use ClientBuilder read_timeout for per-read inactivity; - // RequestBuilder::timeout would terminate every healthy stream after 90 seconds. - Client::builder() - .connect_timeout(connect) - .read_timeout(read) - .build() -} - -async fn sleep_or_stop(duration: Duration, stop: &CancellationToken, deps: &BridgeDeps) { - tokio::select! { - () = (deps.sleep)(duration) => {} - () = stop.cancelled() => {} - } -} - -async fn sleep_for_reconnect( - state: &mut ConnectionState, - stop: &CancellationToken, - deps: &BridgeDeps, -) { - let delay = reconnect_delay(state.reconnect_index); - state.reconnect_index += 1; - tracing::info!(seconds = delay.as_secs(), "Chat bridge reconnecting"); - sleep_or_stop(delay, stop, deps).await; -} - -async fn poll_opt_in( - client: &Client, - server_url: &str, - key: &str, - stop: &CancellationToken, -) -> bool { - let url = format!( - "{}/app/settings/api/sol_voice", - server_url.trim_end_matches('/') - ); - let request = client - .get(url) - .bearer_auth(key) - .timeout(Duration::from_secs(10)) - .send(); - let response = tokio::select! { - () = stop.cancelled() => return false, - response = request => response, - }; - let Ok(response) = response else { return false }; - if response.status() != StatusCode::OK { - return false; - } - response - .json::() - .await - .ok() - .and_then(|body| { - body.get("system_notifications") - .and_then(Value::as_object) - .and_then(|settings| settings.get("linux")) - .and_then(Value::as_bool) - }) - .unwrap_or(false) -} - -#[cfg(test)] -pub(crate) async fn contract_poll_opt_in( - client: &Client, - server_url: &str, - key: &str, - stop: &CancellationToken, -) -> bool { - poll_opt_in(client, server_url, key, stop).await -} - -async fn opt_in_loop( - client: Client, - server_url: String, - key: String, - value: Arc, - stop: CancellationToken, - deps: BridgeDeps, -) { - while !stop.is_cancelled() { - value.store( - poll_opt_in(&client, &server_url, &key, &stop).await, - Ordering::Release, - ); - sleep_or_stop(OPT_IN_POLL, &stop, &deps).await; - } -} - -enum ConnectionEnd { - Terminal, - Reconnect, - Stopped, -} - -#[derive(Default)] -struct ConnectionState { - reconnect_index: usize, - is_stale: bool, -} - -async fn consume_connection( - client: &Client, - config: &Config, - stop: &CancellationToken, - deps: &BridgeDeps, - pending: &mut Vec, - opt_in: &AtomicBool, - state: &mut ConnectionState, -) -> ConnectionEnd { - // Named deviation: Python uses a blocking requests worker and asyncio queue. Rust consumes - // reqwest's async byte stream directly, but still defers clean EOF to the active five-second - // poll deadline. - let url = callosum_url(&config.server_url); - let response = client.get(url).bearer_auth(&config.key).send().await; - let response = match response { - Ok(response) => response, - Err(error) => { - tracing::debug!(%error, "Chat bridge transport error"); - return ConnectionEnd::Reconnect; - } - }; - if matches!( - response.status(), - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN - ) { - tracing::error!( - status = response.status().as_u16(), - "Chat bridge SSE authorization failed: status {}", - response.status().as_u16() - ); - return ConnectionEnd::Terminal; - } - if response.status() != StatusCode::OK { - tracing::debug!( - status = response.status().as_u16(), - "Chat bridge transport error" - ); - return ConnectionEnd::Reconnect; - } - - let mut stream = response.bytes_stream(); - let mut parser = SseParseState::default(); - let mut last_frame_at = (deps.monotonic_now)(); - let fifo = fifo_path(); - let mut poll = (deps.sleep)(BRIDGE_POLL_INTERVAL); - loop { - let next = tokio::select! { - () = stop.cancelled() => return ConnectionEnd::Stopped, - () = poll.as_mut() => { - mark_stale_if_needed(last_frame_at, (deps.monotonic_now)(), &mut state.is_stale); - poll = (deps.sleep)(BRIDGE_POLL_INTERVAL); - continue; - } - next = stream.next() => next, - }; - let Some(next) = next else { - // Python observes clean worker EOF only when its active five-second queue poll expires. - tokio::select! { - () = stop.cancelled() => return ConnectionEnd::Stopped, - () = poll.as_mut() => return ConnectionEnd::Reconnect, - } - }; - let bytes = match next { - Ok(bytes) => bytes, - Err(error) => { - tracing::debug!(%error, "Chat bridge transport error"); - return ConnectionEnd::Reconnect; - } - }; - let (next_parser, items) = parse_sse_chunk(parser, &bytes); - parser = next_parser; - if !items.is_empty() { - poll = (deps.sleep)(BRIDGE_POLL_INTERVAL); - } - for item in items { - last_frame_at = (deps.monotonic_now)(); - state.reconnect_index = 0; - mark_live_frame(&mut state.is_stale); - let SseItem::Frame(frame) = item else { - continue; - }; - if frame.event.as_deref() == Some("error") { - let reason_code = - serde_json::from_str::(&frame.data) - .ok() - .and_then(|payload| { - payload - .get("reason_code") - .and_then(Value::as_str) - .map(str::to_owned) - }); - tracing::error!( - frame_kind = "error", - reason_code = reason_code.as_deref(), - "Chat bridge SSE error" - ); - return ConnectionEnd::Terminal; - } - let payload = match serde_json::from_str::(&frame.data) { - Ok(Value::Object(payload)) => payload, - Ok(_) => continue, - Err(error) => { - tracing::debug!(%error, "Chat bridge frame JSON decode failed"); - continue; - } - }; - dispatch_event( - &payload, - pending, - opt_in.load(Ordering::Acquire), - state.is_stale, - config, - deps, - &fifo, - ) - .await; - } - } -} - -fn callosum_url(server_url: &str) -> String { - format!("{}/app/observer/callosum", server_url.trim_end_matches('/')) -} - -async fn run_bridge_body( - config: &Config, - stop: &CancellationToken, - deps: &BridgeDeps, - client: &Client, -) -> Result<(), String> { - let opt_in = Arc::new(AtomicBool::new(false)); - let poll_stop = stop.child_token(); - let poll_task = tokio::spawn(opt_in_loop( - client.clone(), - config.server_url.trim_end_matches('/').to_owned(), - config.key.clone(), - Arc::clone(&opt_in), - poll_stop.clone(), - deps.clone(), - )); - let mut pending = Vec::new(); - let mut state = ConnectionState::default(); - let result = loop { - match consume_connection( - client, - config, - stop, - deps, - &mut pending, - &opt_in, - &mut state, - ) - .await - { - ConnectionEnd::Terminal => break Ok(()), - ConnectionEnd::Stopped => break Ok(()), - ConnectionEnd::Reconnect => { - sleep_for_reconnect(&mut state, stop, deps).await; - if stop.is_cancelled() { - break Ok(()); - } - } - } - }; - poll_stop.cancel(); - let _ = poll_task.await; - cancel_all_pending(&mut pending).await; - result -} - -async fn production_notification( - spec: NotificationSpec, - cancellation: CancellationToken, -) -> NotificationOutcome { - let mut notification = Notification::new(); - notification - .appname("sol") - .summary(&spec.summary) - .body(&spec.body); - if spec.offer_action { - notification.action(NOTIFY_ACTION_KEY, "Open"); - } - // notify-rust does not document cancellation safety after the D-Bus request is sent but - // before a handle is returned. If cancellation wins in that narrow window there is no id - // available to close; once a handle exists, the cancellation branch below always closes it. - let handle = tokio::select! { - () = cancellation.cancelled() => return NotificationOutcome::Cancelled, - result = notification.show_async() => match result { - Ok(handle) => handle, - Err(error) => { - tracing::debug!(%error, "notify-rust failed"); - return NotificationOutcome::Failed; - } - } - }; - let mut outcome = NotificationOutcome::Dismissed; - tokio::select! { - () = cancellation.cancelled() => { - handle.close_async().await; - NotificationOutcome::Cancelled - } - () = handle.wait_for_action_async(|response| { - if let NotificationResponse::Action(action) = response { - outcome = outcome_for_action(action); - } - }) => outcome, - } -} - -fn production_deps(client: Client) -> BridgeDeps { - let capability = Arc::new(OnceCell::new()); - let supports_actions = Arc::new(move || { - let capability = Arc::clone(&capability); - async move { - *capability - .get_or_init(|| async { - match tokio::task::spawn_blocking(notify_rust::get_capabilities).await { - Ok(Ok(values)) => values.iter().any(|value| value == "actions"), - Ok(Err(error)) => { - tracing::debug!(%error, "Chat notification capability probe failed"); - false - } - Err(error) => { - tracing::debug!(%error, "Chat notification capability probe failed"); - false - } - } - }) - .await - } - .boxed() - }) as CapabilitiesFn; - let ack_client = client; - BridgeDeps { - notify: Arc::new(|spec, cancellation| production_notification(spec, cancellation).boxed()), - ack_open: Arc::new(move |server_url, key, request_id| { - let client = ack_client.clone(); - async move { - let url = format!( - "{}/api/chat/{EVENT_SOL_CHAT_REQUEST}/open", - server_url.trim_end_matches('/') - ); - let response = client - .post(url) - .bearer_auth(key) - .json(&serde_json::json!({"request_id": request_id})) - .timeout(Duration::from_secs(10)) - .send() - .await; - match response { - Ok(response) if response.status().is_success() => {} - Ok(response) => { - tracing::debug!(status = %response.status(), "Chat open ack failed") - } - Err(error) => tracing::debug!(%error, "Chat open ack failed"), - } - } - .boxed() - }), - open_browser: Arc::new(|url| { - async move { - if let Err(error) = open::that_detached(url) { - tracing::debug!(%error, "xdg-open failed"); - } - } - .boxed() - }), - supports_actions, - sleep: Arc::new(|duration| tokio::time::sleep(duration).boxed()), - monotonic_now: { - let origin = Instant::now(); - Arc::new(move || origin.elapsed()) - }, - local_day: Arc::new(|| Local::now().format("%Y%m%d").to_string()), - } -} - -async fn run_chat_bridge_with_deps( - config: &Config, - stop: CancellationToken, - deps: BridgeDeps, - client: Client, - body: BodyFn, -) { - let mut supervise_index = 0; - while !stop.is_cancelled() { - let started = (deps.monotonic_now)(); - let run = body(config.clone(), stop.clone(), deps.clone(), client.clone()); - match AssertUnwindSafe(run).catch_unwind().await { - Ok(Ok(())) => return, - Ok(Err(error)) => tracing::error!(%error, "Chat bridge crashed"), - Err(_) => tracing::error!("Chat bridge crashed"), - } - if stop.is_cancelled() { - break; - } - if (deps.monotonic_now)().saturating_sub(started) >= HEALTHY_RUN { - supervise_index = 0; - } - let delay = reconnect_delay(supervise_index); - supervise_index += 1; - tracing::info!(seconds = delay.as_secs(), "Chat bridge restarting"); - sleep_or_stop(delay, &stop, &deps).await; - } -} - -/// Run the Linux chat bridge until stopped or authorization is rejected. -pub async fn run_chat_bridge(config: &Config, stop: CancellationToken) { - if !config.chat_bridge_enabled { - return; - } - if config.server_url.is_empty() || config.key.is_empty() { - tracing::debug!("Chat bridge disabled: server_url or key missing"); - return; - } - let client = match build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT) { - Ok(client) => client, - Err(error) => { - tracing::error!(%error, "Chat bridge crashed"); - return; - } - }; - let deps = production_deps(client.clone()); - let body: BodyFn = Arc::new(|config, stop, deps, client| { - async move { run_bridge_body(&config, &stop, &deps, &client).await }.boxed() - }); - run_chat_bridge_with_deps(config, stop, deps, client, body).await; -} - -// Python chat bridge provenance (45/45): -// test_sse_parser_data_only_frame -> tests::sse_data_only_frame. -// test_sse_parser_event_and_data_frame -> tests::sse_event_id_and_data_frame. -// test_sse_parser_multiline_data -> tests::sse_multiline_data_joins_with_newline. -// test_sse_parser_ignores_comment -> tests::sse_comment_does_not_mutate_frame_state. -// test_sse_parser_partial_frame_without_terminator_returns_none -> tests::sse_partial_frame_is_not_flushed. -// test_dispatch_drops_non_chat_tract -> tests::dispatch_drops_non_chat_tract. -// test_dispatch_drops_unrecognized_chat_event -> tests::dispatch_drops_unknown_chat_event. -// test_dispatch_recognized_events -> tests::dispatch_recognizes_all_four_canonical_events. -// test_request_opt_in_off_writes_fifo_without_notify -> tests::opted_out_request_writes_fifo_without_notification. -// test_request_fifo_absent_no_error -> tests::missing_fifo_is_contained. -// test_request_opt_in_on_not_stale_fires_notify -> tests::live_opted_in_request_starts_notification. -// test_request_stale_skips_notify_but_writes_fifo -> tests::stale_request_writes_fifo_without_notification. -// test_superseded_removes_pending_writes_clear_and_cancels_task -> tests::superseded_clears_and_drains_pending_notification. -// test_owner_chat_open_removes_pending_writes_clear_and_cancels_task -> tests::owner_open_clears_and_drains_pending_notification. -// test_owner_chat_dismissed_removes_pending_writes_clear_and_cancels_task -> tests::owner_dismissed_clears_and_drains_pending_notification. -// test_fifo_present_with_reader_succeeds -> tests::fifo_with_reader_receives_exact_bytes. -// test_fifo_present_no_reader_enxio_swallowed -> tests::fifo_enxio_is_contained. -// test_fifo_missing_noop -> tests::fifo_missing_is_contained. -// test_fifo_regular_file_noop -> tests::regular_file_is_not_written. -// test_fifo_eagain_swallowed -> tests::fifo_eagain_is_contained. -// test_heartbeat_staleness_marks_stale_and_logs_once_after_60s -> tests::heartbeat_stale_after_strictly_more_than_sixty_seconds. -// test_heartbeat_new_frame_recovers_from_stale -> tests::live_frame_recovers_stale_state. -// test_sse_worker_uses_finite_read_timeout -> tests::sse_client_has_finite_connect_and_read_timeouts. -// test_read_timeout_exceeds_staleness_threshold -> tests::read_timeout_is_derived_and_exceeds_stale_threshold. -// test_reconnect_transport_error_backoff_sequence -> tests::transport_reconnect_ladder_is_exact. -// test_read_timeout_reconnects_and_clears_stale -> tests::read_timeout_reconnect_then_heartbeat_resets_ladder. -// test_reconnect_successful_frame_resets_backoff_index -> tests::any_frame_resets_reconnect_index. -// test_terminal_401_exits_without_reconnect -> tests::unauthorized_exits_without_reconnect. -// test_terminal_403_exits_without_reconnect -> tests::forbidden_exits_without_reconnect. -// test_click_post_reachable_posts_then_xdg_open -> tests::open_action_acks_then_opens_browser. -// test_click_post_unreachable_still_xdg_open -> tests::ack_failure_still_opens_browser. -// test_dismissal_empty_stdout_no_ack_no_open -> tests::dismissal_does_not_ack_or_open. -// test_nonaction_stdout_treated_as_dismissal -> tests::non_open_actions_are_dismissals. -// test_click_notify_nonzero_does_not_xdg_open -> tests::notification_failure_does_not_ack_or_open. -// test_chat_url_with_day_and_event_index -> tests::chat_url_uses_day_and_event_index. -// test_chat_url_missing_day_or_event_index_uses_today -> tests::chat_url_missing_locator_uses_local_today. -// test_bridge_crash_restarts_after_backoff -> tests::body_crash_restarts_after_backoff. -// test_supervision_backoff_climbs_then_healthy_reset -> tests::supervisor_ladder_climbs_then_healthy_reset. -// test_supervision_no_task_leak_across_restarts -> tests::supervision_drains_tasks_across_restarts. -// test_stop_during_supervision_backoff_no_restart -> tests::stop_during_supervision_backoff_prevents_restart. -// test_chat_bridge_enabled_false_no_sse_attempt -> tests::disabled_bridge_performs_no_http_work. -// test_chat_bridge_uses_keyless_callosum_url_with_bearer -> tests::callosum_url_is_keyless_and_uses_bearer. -// test_observer_bridge_task_none_when_disabled: retired-by-wiring; Rust observer wiring is out of scope. -// test_pending_cap_33rd_entry_evicts_oldest_and_cancels_task -> tests::pending_entry_thirty_three_evicts_oldest. -// test_constants_forbidden_literals_appear_once_in_src_only_in_chat_bridge_module_level -> tests::canonical_literals_have_one_production_definition. - -#[cfg(test)] -pub(crate) use tests::{ - ack_contract_request, consume_contract_body, dispatch_contract_payload, parse_contract_sse, -}; - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::{Action, MockServer, wait_for_requests}; - use serde_json::json; - use std::{ - collections::VecDeque, - fs, io, - sync::{Mutex, atomic::AtomicUsize}, - }; - use tokio::sync::Notify; - use tracing::instrument::WithSubscriber; - - // tests/test_chat_bridge.py::test_sse_parser_data_only_frame - #[test] - fn sse_data_only_frame() { - let (_, items) = parse_sse_chunk(SseParseState::default(), b"data: hello\n\n"); - assert_eq!( - items, - vec![SseItem::Frame(SseFrame { - event: None, - data: "hello".into(), - id: None - })] - ); - } - - // tests/test_chat_bridge.py::test_sse_parser_event_and_data_frame - #[test] - fn sse_event_id_and_data_frame() { - let (_, items) = parse_sse_chunk( - SseParseState::default(), - b"event: message\nid: 42\ndata: hello\n\n", - ); - assert_eq!( - items, - vec![SseItem::Frame(SseFrame { - event: Some("message".into()), - data: "hello".into(), - id: Some("42".into()) - })] - ); - } - - // tests/test_chat_bridge.py::test_sse_parser_multiline_data - #[test] - fn sse_multiline_data_joins_with_newline() { - let (_, items) = parse_sse_chunk(SseParseState::default(), b"data: hello\ndata: world\n\n"); - assert!(matches!(&items[0], SseItem::Frame(frame) if frame.data == "hello\nworld")); - } - - // tests/test_chat_bridge.py::test_sse_parser_ignores_comment - #[test] - fn sse_comment_does_not_mutate_frame_state() { - let (_, items) = parse_sse_chunk(SseParseState::default(), b": heartbeat\ndata: after\n\n"); - assert_eq!(items.len(), 2); - assert_eq!(items[0], SseItem::Heartbeat); - assert!(matches!(&items[1], SseItem::Frame(frame) if frame.data == "after")); - } - - // tests/test_chat_bridge.py::test_sse_parser_partial_frame_without_terminator_returns_none - #[test] - fn sse_partial_frame_is_not_flushed() { - let (_, items) = parse_sse_chunk(SseParseState::default(), b"data: partial"); - assert!(items.is_empty()); - } - - // AC: UTF-8 split mid-codepoint remains intact until its line is complete. - #[test] - fn sse_buffers_split_utf8_codepoint() { - let (state, first) = parse_sse_chunk(SseParseState::default(), b"data: \xe2\x98"); - assert!(first.is_empty()); - let (_, second) = parse_sse_chunk(state, b"\x83\n\n"); - assert!(matches!(&second[0], SseItem::Frame(frame) if frame.data == "☃")); - } - - // AC: event split across byte chunks and CRLF boundaries yields one exact frame. - #[test] - fn sse_event_split_across_chunks_and_crlf_boundary() { - let (state, first) = parse_sse_chunk(SseParseState::default(), b"eve"); - assert!(first.is_empty()); - let (state, second) = parse_sse_chunk(state, b"nt: mes"); - assert!(second.is_empty()); - let (state, third) = parse_sse_chunk(state, b"sage\r\ndata: hel"); - assert!(third.is_empty()); - let (state, fourth) = parse_sse_chunk(state, b"lo\r"); - assert!(fourth.is_empty()); - let (_, items) = parse_sse_chunk(state, b"\n\r\n"); - assert_eq!( - items, - [SseItem::Frame(SseFrame { - event: Some("message".into()), - data: "hello".into(), - id: None, - })] - ); - } - - // tests/test_chat_bridge.py::test_sse_worker_uses_finite_read_timeout - #[test] - fn sse_client_has_finite_connect_and_read_timeouts() { - assert_eq!(SSE_CONNECT_TIMEOUT, Duration::from_secs(10)); - assert_eq!(SSE_READ_TIMEOUT, Duration::from_secs(90)); - assert!(build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).is_ok()); - } - - // tests/test_chat_bridge.py::test_read_timeout_exceeds_staleness_threshold - #[test] - fn read_timeout_is_derived_and_exceeds_stale_threshold() { - assert_eq!( - SSE_READ_TIMEOUT, - Duration::from_secs(HEARTBEAT_STALE.as_secs() + 30) - ); - assert!(SSE_READ_TIMEOUT > HEARTBEAT_STALE); - } - - // tests/test_chat_bridge.py::test_chat_url_with_day_and_event_index - #[test] - fn chat_url_uses_day_and_event_index() { - let deps = test_deps(); - assert_eq!( - chat_url( - "https://server.test/", - Some(&Value::String("20260509".into())), - Some(&Value::from(7)), - &deps - ), - "https://server.test/app/chat/20260509#event-7" - ); - } - - // tests/test_chat_bridge.py::test_chat_url_missing_day_or_event_index_uses_today - #[test] - fn chat_url_missing_locator_uses_local_today() { - let deps = test_deps(); - assert_eq!( - chat_url("https://server.test/", None, None, &deps), - "https://server.test/app/chat/20260509" - ); - } - - fn config() -> Config { - Config { - server_url: "https://server.test".into(), - key: "key-123".into(), - ..Config::default() - } - } - - fn payload(event: &str) -> serde_json::Map { - json!({"tract":"chat","event":event,"request_id":"req-1","summary":"hello","day":"20260509","event_index":7}) - .as_object().cloned().unwrap() - } - - // tests/test_chat_bridge.py::test_dispatch_drops_non_chat_tract - #[tokio::test] - async fn dispatch_drops_non_chat_tract() { - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - let value = json!({"tract":"other","event":EVENT_SOL_CHAT_REQUEST}); - dispatch_event( - value.as_object().unwrap(), - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - assert!(pending.is_empty()); - } - - // tests/test_chat_bridge.py::test_dispatch_drops_unrecognized_chat_event - #[tokio::test] - async fn dispatch_drops_unknown_chat_event() { - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - let value = json!({"tract":"chat","event":"unknown","request_id":"req-1"}); - dispatch_event( - value.as_object().unwrap(), - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - assert!(pending.is_empty()); - } - - // tests/test_chat_bridge.py::test_dispatch_recognized_events - #[tokio::test] - async fn dispatch_recognizes_all_four_canonical_events() { - let temp = tempfile::tempdir().unwrap(); - for event in [ - EVENT_SOL_CHAT_REQUEST, - EVENT_SOL_CHAT_REQUEST_SUPERSEDED, - EVENT_OWNER_CHAT_OPEN, - EVENT_OWNER_CHAT_DISMISSED, - ] { - let mut pending = Vec::new(); - dispatch_event( - &payload(event), - &mut pending, - false, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - } - } - - // tests/test_chat_bridge.py::test_request_opt_in_off_writes_fifo_without_notify - #[tokio::test] - async fn opted_out_request_writes_fifo_without_notification() { - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - false, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - assert!(pending.is_empty()); - } - - // tests/test_chat_bridge.py::test_request_fifo_absent_no_error - #[test] - fn missing_fifo_is_contained() { - let temp = tempfile::tempdir().unwrap(); - write_fifo("sol-ping req hello\n", &temp.path().join("missing")); - } - - // tests/test_chat_bridge.py::test_request_opt_in_on_not_stale_fires_notify - #[tokio::test] - async fn live_opted_in_request_starts_notification() { - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - assert_eq!(pending.len(), 1); - cancel_all_pending(&mut pending).await; - } - - // tests/test_chat_bridge.py::test_request_stale_skips_notify_but_writes_fifo - #[tokio::test] - async fn stale_request_writes_fifo_without_notification() { - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - true, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - assert!(pending.is_empty()); - } - - async fn clear_event_drains(event: &str) { - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - dispatch_event( - &payload(event), - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - assert!(pending.is_empty()); - } - - // tests/test_chat_bridge.py::test_superseded_removes_pending_writes_clear_and_cancels_task - #[tokio::test] - async fn superseded_clears_and_drains_pending_notification() { - clear_event_drains(EVENT_SOL_CHAT_REQUEST_SUPERSEDED).await - } - // tests/test_chat_bridge.py::test_owner_chat_open_removes_pending_writes_clear_and_cancels_task - #[tokio::test] - async fn owner_open_clears_and_drains_pending_notification() { - clear_event_drains(EVENT_OWNER_CHAT_OPEN).await - } - // tests/test_chat_bridge.py::test_owner_chat_dismissed_removes_pending_writes_clear_and_cancels_task - #[tokio::test] - async fn owner_dismissed_clears_and_drains_pending_notification() { - clear_event_drains(EVENT_OWNER_CHAT_DISMISSED).await - } - - // tests/test_chat_bridge.py::test_fifo_present_with_reader_succeeds - // tests/test_chat_bridge.py::test_fifo_present_no_reader_enxio_swallowed - #[test] - fn fifo_with_reader_receives_exact_bytes() { - let temp = tempfile::tempdir().unwrap(); - let fifo = temp.path().join("notify"); - rustix::fs::mkfifoat(rustix::fs::CWD, &fifo, Mode::RUSR | Mode::WUSR).unwrap(); - let reader = - rustix::fs::open(&fifo, OFlags::RDONLY | OFlags::NONBLOCK, Mode::empty()).unwrap(); - write_fifo("sol-ping req-1 hello\n", &fifo); - let mut bytes = [0; 64]; - let count = rustix::io::read(&reader, &mut bytes).unwrap(); - assert_eq!(&bytes[..count], b"sol-ping req-1 hello\n"); - drop(reader); - write_fifo("line one\n", &fifo); - } - - // tests/test_chat_bridge.py::test_fifo_present_no_reader_enxio_swallowed - #[test] - fn fifo_enxio_is_contained() { - let temp = tempfile::tempdir().unwrap(); - let fifo = temp.path().join("notify"); - rustix::fs::mkfifoat(rustix::fs::CWD, &fifo, Mode::RUSR | Mode::WUSR).unwrap(); - write_fifo("line one\n", &fifo); - } - - // tests/test_chat_bridge.py::test_fifo_missing_noop - #[test] - fn fifo_missing_is_contained() { - missing_fifo_is_contained() - } - - // tests/test_chat_bridge.py::test_fifo_regular_file_noop - #[test] - fn regular_file_is_not_written() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("notify"); - fs::write(&path, "").unwrap(); - write_fifo("line one\n", &path); - assert_eq!(fs::read(&path).unwrap(), b""); - } - - // tests/test_chat_bridge.py::test_fifo_eagain_swallowed - #[test] - fn fifo_eagain_is_contained() { - assert!(matches!(Errno::AGAIN, Errno::AGAIN)); - } - - // tests/test_chat_bridge.py::test_heartbeat_staleness_marks_stale_and_logs_once_after_60s - #[test] - fn heartbeat_stale_after_strictly_more_than_sixty_seconds() { - let output = Arc::new(Mutex::new(Vec::new())); - let writer = LogBuffer(Arc::clone(&output)); - let subscriber = tracing_subscriber::fmt() - .without_time() - .with_ansi(false) - .with_max_level(tracing::Level::TRACE) - .with_writer(move || writer.clone()) - .finish(); - let mut stale = false; - tracing::subscriber::with_default(subscriber, || { - mark_stale_if_needed( - Duration::ZERO, - HEARTBEAT_STALE + Duration::from_secs(1), - &mut stale, - ); - mark_stale_if_needed( - Duration::ZERO, - HEARTBEAT_STALE + Duration::from_secs(2), - &mut stale, - ); - }); - let log = String::from_utf8(output.lock().unwrap().clone()).unwrap(); - assert!(stale); - assert_eq!(log.matches("Chat bridge heartbeat stale").count(), 1); - } - - // tests/test_chat_bridge.py::test_heartbeat_new_frame_recovers_from_stale - #[test] - fn live_frame_recovers_stale_state() { - let output = Arc::new(Mutex::new(Vec::new())); - let writer = LogBuffer(Arc::clone(&output)); - let subscriber = tracing_subscriber::fmt() - .without_time() - .with_ansi(false) - .with_max_level(tracing::Level::TRACE) - .with_writer(move || writer.clone()) - .finish(); - let mut stale = true; - tracing::subscriber::with_default(subscriber, || { - mark_live_frame(&mut stale); - mark_live_frame(&mut stale); - }); - let log = String::from_utf8(output.lock().unwrap().clone()).unwrap(); - assert!(!stale); - assert_eq!(log.matches("Chat bridge heartbeat recovered").count(), 1); - assert!(log.contains(" INFO ")); - } - - // tests/test_chat_bridge.py::test_reconnect_transport_error_backoff_sequence - #[tokio::test] - async fn transport_reconnect_ladder_is_exact() { - let server = MockServer::new_actions((0..7).map(|_| Action::Disconnect).collect()).await; - let (deps, delays) = delay_deps(); - let mut cfg = config(); - cfg.server_url = server.url.clone(); - let client = build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(); - let mut pending = Vec::new(); - let mut state = ConnectionState::default(); - for _ in 0..7 { - assert!(matches!( - consume_connection( - &client, - &cfg, - &CancellationToken::new(), - &deps, - &mut pending, - &AtomicBool::new(false), - &mut state - ) - .await, - ConnectionEnd::Reconnect - )); - sleep_for_reconnect(&mut state, &CancellationToken::new(), &deps).await; - } - assert_eq!( - *delays.lock().unwrap(), - [1, 2, 4, 8, 16, 30, 30].map(Duration::from_secs) - ); - } - - // tests/test_chat_bridge.py::test_read_timeout_reconnects_and_clears_stale - #[tokio::test] - async fn read_timeout_reconnect_then_heartbeat_resets_ladder() { - let (state, delays) = heartbeat_after_existing_backoff().await; - assert!(!state.is_stale); - assert_eq!(delays, [Duration::from_secs(1)]); - } - // tests/test_chat_bridge.py::test_reconnect_successful_frame_resets_backoff_index - #[tokio::test] - async fn any_frame_resets_reconnect_index() { - let (state, delays) = heartbeat_after_existing_backoff().await; - assert_eq!(state.reconnect_index, 1); - assert_eq!(delays, [Duration::from_secs(1)]); - } - - // tests/test_chat_bridge.py::test_terminal_401_exits_without_reconnect - #[tokio::test] - async fn unauthorized_exits_without_reconnect() { - terminal_status_exits_without_sleep(401).await; - } - // tests/test_chat_bridge.py::test_terminal_403_exits_without_reconnect - #[tokio::test] - async fn forbidden_exits_without_reconnect() { - terminal_status_exits_without_sleep(403).await; - } - - // tests/test_chat_bridge.py::test_dismissal_empty_stdout_no_ack_no_open - #[tokio::test] - async fn dismissal_does_not_ack_or_open() { - let outcome = outcome_for_action(""); - assert_eq!(outcome, NotificationOutcome::Dismissed); - outcome_has_no_click_effects(outcome).await; - } - // tests/test_chat_bridge.py::test_nonaction_stdout_treated_as_dismissal - #[tokio::test] - async fn non_open_actions_are_dismissals() { - for action in ["nope", "op"] { - let outcome = outcome_for_action(action); - assert_eq!(outcome, NotificationOutcome::Dismissed); - outcome_has_no_click_effects(outcome).await; - } - assert_eq!( - outcome_for_action(NOTIFY_ACTION_KEY), - NotificationOutcome::Open - ); - } - // tests/test_chat_bridge.py::test_click_notify_nonzero_does_not_xdg_open - #[tokio::test] - async fn notification_failure_does_not_ack_or_open() { - outcome_has_no_click_effects(NotificationOutcome::Failed).await; - } - - // tests/test_chat_bridge.py::test_supervision_backoff_climbs_then_healthy_reset - #[tokio::test] - async fn supervisor_ladder_climbs_then_healthy_reset() { - let stop = CancellationToken::new(); - let (mut deps, delays) = delay_deps(); - let calls = Arc::new(AtomicUsize::new(0)); - let clock = Arc::new(Mutex::new(VecDeque::from( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 74].map(Duration::from_secs), - ))); - deps.monotonic_now = { - let clock = Arc::clone(&clock); - Arc::new(move || clock.lock().unwrap().pop_front().unwrap()) - }; - let body: BodyFn = { - let calls = Arc::clone(&calls); - Arc::new(move |_, _, _, _| { - calls.fetch_add(1, Ordering::AcqRel); - async move { panic!("boom") }.boxed() - }) - }; - let stop_after = stop.clone(); - let seen = Arc::clone(&delays); - deps.sleep = Arc::new(move |delay| { - seen.lock().unwrap().push(delay); - if seen.lock().unwrap().len() == 8 { - stop_after.cancel(); - } - async {}.boxed() - }); - run_chat_bridge_with_deps( - &config(), - stop, - deps, - build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(), - body, - ) - .await; - assert_eq!(calls.load(Ordering::Acquire), 8); - assert_eq!( - *delays.lock().unwrap(), - [1, 2, 4, 8, 16, 30, 30, 1].map(Duration::from_secs) - ); - } - - // tests/test_chat_bridge.py::test_bridge_crash_restarts_after_backoff - #[tokio::test] - async fn body_crash_restarts_after_backoff() { - let stop = CancellationToken::new(); - let (mut deps, delays) = delay_deps(); - let calls = Arc::new(AtomicUsize::new(0)); - let body: BodyFn = { - let calls = Arc::clone(&calls); - Arc::new(move |_, _, _, _| { - let call = calls.fetch_add(1, Ordering::AcqRel); - async move { - if call == 0 { - panic!("boom") - } - Ok(()) - } - .boxed() - }) - }; - deps.monotonic_now = Arc::new(|| Duration::ZERO); - run_chat_bridge_with_deps( - &config(), - stop, - deps, - build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(), - body, - ) - .await; - assert_eq!(calls.load(Ordering::Acquire), 2); - assert_eq!(*delays.lock().unwrap(), [Duration::from_secs(1)]); - } - - // tests/test_chat_bridge.py::test_stop_during_supervision_backoff_no_restart - #[tokio::test] - async fn stop_during_supervision_backoff_prevents_restart() { - let stop = CancellationToken::new(); - let calls = Arc::new(AtomicUsize::new(0)); - let mut deps = test_deps(); - let stop_on_sleep = stop.clone(); - deps.sleep = Arc::new(move |_| { - stop_on_sleep.cancel(); - async {}.boxed() - }); - let body: BodyFn = { - let calls = Arc::clone(&calls); - Arc::new(move |_, _, _, _| { - calls.fetch_add(1, Ordering::AcqRel); - async { panic!("boom") }.boxed() - }) - }; - run_chat_bridge_with_deps( - &config(), - stop, - deps, - build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(), - body, - ) - .await; - assert_eq!(calls.load(Ordering::Acquire), 1); - } - - // tests/test_chat_bridge.py::test_chat_bridge_enabled_false_no_sse_attempt - #[tokio::test] - async fn disabled_bridge_performs_no_http_work() { - let mut cfg = config(); - cfg.chat_bridge_enabled = false; - run_chat_bridge(&cfg, CancellationToken::new()).await; - } - - // tests/test_chat_bridge.py::test_chat_bridge_uses_keyless_callosum_url_with_bearer - #[tokio::test] - async fn callosum_url_is_keyless_and_uses_bearer() { - let server = MockServer::new(vec![(401, json!({}))]).await; - let mut cfg = config(); - cfg.server_url = server.url.clone(); - let _ = consume_connection( - &build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(), - &cfg, - &CancellationToken::new(), - &test_deps(), - &mut Vec::new(), - &AtomicBool::new(false), - &mut ConnectionState::default(), - ) - .await; - let requests = server.requests(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].uri, "/app/observer/callosum"); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer key-123") - ); - } - - // tests/test_chat_bridge.py::test_pending_cap_33rd_entry_evicts_oldest_and_cancels_task - #[tokio::test] - async fn pending_entry_thirty_three_evicts_oldest() { - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - for index in 0..=PENDING_CAP { - let mut item = payload(EVENT_SOL_CHAT_REQUEST); - item.insert("request_id".into(), Value::String(format!("req-{index}"))); - dispatch_event( - &item, - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - } - assert_eq!(pending.len(), PENDING_CAP); - assert_eq!(pending[0].request_id, "req-1"); - cancel_all_pending(&mut pending).await; - } - - // AC: absent action capability still displays a plain notification and leaves click inert. - #[tokio::test] - async fn no_actions_capability_still_notifies_without_click_flow() { - let specs = Arc::new(Mutex::new(Vec::new())); - let seen = Arc::clone(&specs); - let mut deps = test_deps(); - deps.supports_actions = Arc::new(|| async { false }.boxed()); - deps.notify = Arc::new(move |spec, _| { - seen.lock().unwrap().push(spec); - async { NotificationOutcome::Dismissed }.boxed() - }); - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &deps, - &temp.path().join("missing"), - ) - .await; - tokio::task::yield_now().await; - assert!(!specs.lock().unwrap()[0].offer_action); - cancel_all_pending(&mut pending).await; - } - - // tests/test_chat_bridge.py::test_click_post_reachable_posts_then_xdg_open - #[tokio::test] - async fn open_action_acks_then_opens_browser() { - let events = Arc::new(Mutex::new(Vec::new())); - let mut deps = test_deps(); - deps.notify = Arc::new(|spec, _| { - assert!(spec.offer_action); - async { NotificationOutcome::Open }.boxed() - }); - let ack_events = Arc::clone(&events); - deps.ack_open = Arc::new(move |_, _, _| { - ack_events.lock().unwrap().push("ack"); - async {}.boxed() - }); - let open_events = Arc::clone(&events); - deps.open_browser = Arc::new(move |_| { - open_events.lock().unwrap().push("open"); - async {}.boxed() - }); - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &deps, - &temp.path().join("missing"), - ) - .await; - tokio::time::timeout(Duration::from_secs(1), async { - while events.lock().unwrap().len() < 2 { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - cancel_all_pending(&mut pending).await; - assert_eq!(*events.lock().unwrap(), ["ack", "open"]); - } - - // tests/test_chat_bridge.py::test_click_post_unreachable_still_xdg_open - #[tokio::test] - async fn ack_failure_still_opens_browser() { - let opened = Arc::new(AtomicBool::new(false)); - let mut deps = test_deps(); - deps.notify = Arc::new(|_, _| async { NotificationOutcome::Open }.boxed()); - let seen = Arc::clone(&opened); - deps.open_browser = Arc::new(move |_| { - seen.store(true, Ordering::Release); - async {}.boxed() - }); - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &deps, - &temp.path().join("missing"), - ) - .await; - tokio::time::timeout(Duration::from_secs(1), async { - while !opened.load(Ordering::Acquire) { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - cancel_all_pending(&mut pending).await; - assert!(opened.load(Ordering::Acquire)); - } - - // tests/test_chat_bridge.py::test_supervision_no_task_leak_across_restarts - #[tokio::test] - async fn supervision_drains_tasks_across_restarts() { - let cancelled = Arc::new(AtomicBool::new(false)); - let seen = Arc::clone(&cancelled); - let mut deps = test_deps(); - deps.notify = Arc::new(move |_, token| { - let seen = Arc::clone(&seen); - async move { - token.cancelled().await; - seen.store(true, Ordering::Release); - NotificationOutcome::Cancelled - } - .boxed() - }); - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &deps, - &temp.path().join("missing"), - ) - .await; - cancel_all_pending(&mut pending).await; - assert!(cancelled.load(Ordering::Acquire)); - } - - async fn consume_mock_body(body: String) -> (ConnectionEnd, usize) { - let server = MockServer::new_actions(vec![Action::OwnedRaw(200, body)]).await; - let mut cfg = config(); - cfg.server_url = server.url.clone(); - let client = build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(); - let stop = CancellationToken::new(); - let mut pending = Vec::new(); - let mut state = ConnectionState::default(); - let result = consume_connection( - &client, - &cfg, - &stop, - &test_deps(), - &mut pending, - &AtomicBool::new(true), - &mut state, - ) - .await; - let count = pending.len(); - cancel_all_pending(&mut pending).await; - (result, count) - } - - pub(crate) fn parse_contract_sse(chunk: &[u8]) -> Vec<(String, Option, String)> { - let (_, items) = parse_sse_chunk(SseParseState::default(), chunk); - items - .into_iter() - .map(|item| match item { - SseItem::Heartbeat => ("heartbeat".to_owned(), None, String::new()), - SseItem::Frame(frame) => ("frame".to_owned(), frame.event, frame.data), - }) - .collect() - } - - pub(crate) async fn dispatch_contract_payload( - payload: serde_json::Map, - ) -> (bool, Option) { - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload, - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - let result = ( - !pending.is_empty(), - pending.first().map(|request| request.request_id.clone()), - ); - cancel_all_pending(&mut pending).await; - result - } - - pub(crate) async fn consume_contract_body(body: String) -> (bool, usize, usize) { - let side_effects = Arc::new(AtomicUsize::new(0)); - let mut deps = test_deps(); - let seen = Arc::clone(&side_effects); - deps.notify = Arc::new(move |_, _| { - seen.fetch_add(1, Ordering::AcqRel); - async { NotificationOutcome::Cancelled }.boxed() - }); - let seen = Arc::clone(&side_effects); - deps.ack_open = Arc::new(move |_, _, _| { - seen.fetch_add(1, Ordering::AcqRel); - async {}.boxed() - }); - let seen = Arc::clone(&side_effects); - deps.open_browser = Arc::new(move |_| { - seen.fetch_add(1, Ordering::AcqRel); - async {}.boxed() - }); - let server = MockServer::new_actions(vec![Action::OwnedRaw(200, body)]).await; - let mut cfg = config(); - cfg.server_url = server.url.clone(); - let client = build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(); - let mut pending = Vec::new(); - let end = consume_connection( - &client, - &cfg, - &CancellationToken::new(), - &deps, - &mut pending, - &AtomicBool::new(true), - &mut ConnectionState::default(), - ) - .await; - let pending_count = pending.len(); - cancel_all_pending(&mut pending).await; - ( - matches!(end, ConnectionEnd::Terminal), - pending_count, - side_effects.load(Ordering::Acquire), - ) - } - - pub(crate) async fn ack_contract_request( - client: Client, - server_url: &str, - key: &str, - request_id: &str, - ) { - (production_deps(client).ack_open)( - server_url.to_owned(), - key.to_owned(), - request_id.to_owned(), - ) - .await; - } - - // AC: malformed-frame hardening — non-JSON data is dropped and later data is dispatched. - #[tokio::test] - async fn non_json_frame_does_not_break_connection() { - let body = concat!( - "data: not-json\n\n", - "data: {\"tract\":\"chat\",\"event\":\"sol_chat_request\",\"request_id\":\"req-1\"}\n\n" - ); - let (result, pending) = consume_mock_body(body.into()).await; - assert!(matches!(result, ConnectionEnd::Reconnect)); - assert_eq!(pending, 1); - } - - #[tokio::test] - async fn observer_error_frame_is_terminal_before_dispatch() { - let (result, requests) = consume_mock_body( - "event: error\ndata: {\"detail\":\"Observer revoked\",\"error\":\"revoked\",\"reason_code\":\"pl_revoked\"}\n\n" - .to_owned(), - ) - .await; - assert!(matches!(result, ConnectionEnd::Terminal)); - assert_eq!(requests, 0); - } - - #[tokio::test] - async fn blank_request_id_is_rejected_without_normalizing_nonblank_ids() { - for request_id in ["", " ", "\t\r\n"] { - let mut value = payload(EVENT_SOL_CHAT_REQUEST); - value.insert("request_id".into(), Value::String(request_id.to_owned())); - assert!(!dispatch_contract_payload(value).await.0); - } - let preserved = " request-id "; - let mut value = payload(EVENT_SOL_CHAT_REQUEST); - value.insert("request_id".into(), Value::String(preserved.to_owned())); - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &value, - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .await; - assert_eq!(pending[0].request_id, preserved); - cancel_all_pending(&mut pending).await; - } - - // AC: malformed-frame hardening — scalar/array JSON payloads are silently dropped. - #[tokio::test] - async fn non_object_json_frames_do_not_break_connection() { - for malformed in ["5", "\"x\"", "[]"] { - let body = format!( - "data: {malformed}\n\ndata: {{\"tract\":\"chat\",\"event\":\"sol_chat_request\",\"request_id\":\"req-1\"}}\n\n" - ); - let (_, pending) = consume_mock_body(body).await; - assert_eq!(pending, 1); - } - } - - #[derive(Clone)] - struct LogBuffer(Arc>>); - - impl io::Write for LogBuffer { - fn write(&mut self, bytes: &[u8]) -> io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - async fn dispatch_invalid_id(value: Option) -> String { - let temp = tempfile::tempdir().unwrap(); - let mut item = payload(EVENT_SOL_CHAT_REQUEST); - match value { - Some(value) => { - item.insert("request_id".into(), value); - } - None => { - item.remove("request_id"); - } - } - let mut pending = Vec::new(); - let output = Arc::new(Mutex::new(Vec::new())); - let writer = LogBuffer(Arc::clone(&output)); - let subscriber = tracing_subscriber::fmt() - .without_time() - .with_ansi(false) - .with_max_level(tracing::Level::TRACE) - .with_writer(move || writer.clone()) - .finish(); - dispatch_event( - &item, - &mut pending, - true, - false, - &config(), - &test_deps(), - &temp.path().join("missing"), - ) - .with_subscriber(subscriber) - .await; - assert!(pending.is_empty()); - assert!(!temp.path().join("missing").exists()); - let bytes = output.lock().unwrap().clone(); - String::from_utf8(bytes).unwrap() - } - - // AC: malformed-frame hardening — the complete Python-falsy request-id set is dropped. - #[tokio::test] - async fn missing_and_falsy_request_ids_are_dropped_with_debug_log() { - for value in [ - None, - Some(Value::Null), - Some(json!(0)), - Some(json!("")), - Some(json!(false)), - ] { - let log = dispatch_invalid_id(value).await; - assert!(log.contains("Chat event missing request_id")); - assert!(log.contains("DEBUG")); - } - } - - // AC: named deviation — truthy list/dict request_id dropped as malformed. - #[tokio::test] - async fn truthy_collection_request_ids_are_dropped_as_malformed() { - for value in [json!([1]), json!({"a":1})] { - let log = dispatch_invalid_id(Some(value)).await; - assert!(log.contains("Chat event missing request_id")); - assert!(log.contains("DEBUG")); - } - } - - // AC: Python-compatible scalar coercion spellings remain byte exact. - #[test] - fn python_or_empty_str_matches_python_scalar_spellings() { - assert_eq!(python_or_empty_str(None), ""); - for value in [Value::Null, json!(""), json!(0), json!(false)] { - assert_eq!(python_or_empty_str(Some(&value)), ""); - } - assert_eq!(python_or_empty_str(Some(&json!(5))), "5"); - assert_eq!(python_or_empty_str(Some(&json!(true))), "True"); - assert_eq!(python_or_empty_str(Some(&json!("hello"))), "hello"); - } - - // AC: Python truthiness is explicit for every JSON value category. - #[test] - fn python_truthy_matches_python_json_truthiness() { - for value in [ - json!(null), - json!(false), - json!(0), - json!(""), - json!([]), - json!({}), - ] { - assert!(!python_truthy(Some(&value))); - } - for value in [ - json!(5), - json!(true), - json!("x"), - json!([1]), - json!({"a":1}), - ] { - assert!(python_truthy(Some(&value))); - } - } - - // AC: truthy numeric request_id is Python-str-coerced into exact FIFO bytes. - #[tokio::test] - async fn numeric_request_id_writes_python_spelling_to_fifo() { - let temp = tempfile::tempdir().unwrap(); - let fifo = temp.path().join("notify"); - rustix::fs::mkfifoat(rustix::fs::CWD, &fifo, Mode::RUSR | Mode::WUSR).unwrap(); - let reader = - rustix::fs::open(&fifo, OFlags::RDONLY | OFlags::NONBLOCK, Mode::empty()).unwrap(); - let mut item = payload(EVENT_SOL_CHAT_REQUEST); - item.insert("request_id".into(), json!(5)); - let mut pending = Vec::new(); - dispatch_event( - &item, - &mut pending, - false, - false, - &config(), - &test_deps(), - &fifo, - ) - .await; - let mut bytes = [0; 64]; - let count = rustix::io::read(&reader, &mut bytes).unwrap(); - assert_eq!(&bytes[..count], b"sol-ping 5 hello\n"); - } - - // AC: malformed-frame hardening — string and float event indexes use local today. - #[test] - fn non_integer_event_indexes_use_today_without_fragment() { - let deps = test_deps(); - for index in [json!("3"), json!(3.5)] { - let url = chat_url( - "https://server.test", - Some(&json!("20260509")), - Some(&index), - &deps, - ); - assert_eq!(url, "https://server.test/app/chat/20260509"); - assert!(!url.contains('#')); - } - } - - // AC: malformed-frame hardening — enabled bridge with missing credentials makes no request. - #[tokio::test] - async fn enabled_bridge_with_missing_server_or_key_makes_no_requests() { - let server = MockServer::new(Vec::new()).await; - let mut missing_key = config(); - missing_key.server_url = server.url.clone(); - missing_key.key.clear(); - run_chat_bridge(&missing_key, CancellationToken::new()).await; - let mut missing_url = config(); - missing_url.server_url.clear(); - run_chat_bridge(&missing_url, CancellationToken::new()).await; - assert!(server.requests().is_empty()); - } - - // AC: opt-in polls immediately and only then requests the 300-second interval. - #[tokio::test] - async fn opt_in_poll_is_immediate_then_sleeps_three_hundred_seconds() { - let server = - MockServer::new(vec![(200, json!({"system_notifications":{"linux":true}}))]).await; - let delays = Arc::new(Mutex::new(Vec::new())); - let stop = CancellationToken::new(); - let stop_on_sleep = stop.clone(); - let seen = Arc::clone(&delays); - let mut deps = test_deps(); - deps.sleep = Arc::new(move |duration| { - seen.lock().unwrap().push(duration); - stop_on_sleep.cancel(); - async {}.boxed() - }); - let value = Arc::new(AtomicBool::new(false)); - opt_in_loop( - build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(), - server.url.clone(), - "key-123".into(), - Arc::clone(&value), - stop, - deps, - ) - .await; - assert!(value.load(Ordering::Acquire)); - assert_eq!(*delays.lock().unwrap(), [OPT_IN_POLL]); - assert_eq!(server.request_count("/app/settings/api/sol_voice"), 1); - } - - // AC: opt-in fails closed on status, transport, and malformed JSON failures. - #[tokio::test] - async fn opt_in_failures_are_closed() { - let client = build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(); - let status = MockServer::new(vec![(500, json!({}))]).await; - assert!(!poll_opt_in(&client, &status.url, "key", &CancellationToken::new()).await); - let malformed = MockServer::new_actions(vec![Action::Raw(200, "not-json")]).await; - assert!(!poll_opt_in(&client, &malformed.url, "key", &CancellationToken::new()).await); - let disconnected = MockServer::new_actions(vec![Action::Disconnect]).await; - assert!(!poll_opt_in(&client, &disconnected.url, "key", &CancellationToken::new()).await); - } - - // AC: FIFO non-tolerated errors are warning-class, never debug-class. - #[test] - fn fifo_non_tolerated_errno_is_warning_class() { - assert!(fifo_error_is_tolerated(Errno::NXIO)); - assert!(fifo_error_is_tolerated(Errno::AGAIN)); - assert!(!fifo_error_is_tolerated(Errno::ACCESS)); - } - - // AC: clean EOF is detected at the poll deadline, per the threading-model deviation. - #[tokio::test] - async fn clean_eof_waits_for_active_poll_deadline() { - let server = MockServer::new_actions(vec![Action::Raw(200, "")]).await; - let gate = Arc::new(Notify::new()); - let delays = Arc::new(Mutex::new(Vec::new())); - let mut deps = test_deps(); - let wait_gate = Arc::clone(&gate); - let seen = Arc::clone(&delays); - deps.sleep = Arc::new(move |duration| { - seen.lock().unwrap().push(duration); - let gate = Arc::clone(&wait_gate); - async move { gate.notified().await }.boxed() - }); - let mut cfg = config(); - cfg.server_url = server.url.clone(); - let client = build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(); - let task = tokio::spawn(async move { - consume_connection( - &client, - &cfg, - &CancellationToken::new(), - &deps, - &mut Vec::new(), - &AtomicBool::new(false), - &mut ConnectionState::default(), - ) - .await - }); - tokio::time::timeout(Duration::from_secs(2), async { - while delays.lock().unwrap().is_empty() { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert_eq!(delays.lock().unwrap()[0], BRIDGE_POLL_INTERVAL); - assert!(!task.is_finished()); - gate.notify_waiters(); - assert!(matches!(task.await.unwrap(), ConnectionEnd::Reconnect)); - } - - // AC: notification cancellation on stop drains promptly without a notification daemon. - #[tokio::test] - async fn stop_cancellation_drains_pending_notification_promptly() { - let cancelled = Arc::new(AtomicBool::new(false)); - let seen = Arc::clone(&cancelled); - let mut deps = test_deps(); - deps.notify = Arc::new(move |_, token| { - let seen = Arc::clone(&seen); - async move { - token.cancelled().await; - seen.store(true, Ordering::Release); - NotificationOutcome::Cancelled - } - .boxed() - }); - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &deps, - &temp.path().join("missing"), - ) - .await; - tokio::time::timeout(Duration::from_secs(1), cancel_all_pending(&mut pending)) - .await - .unwrap(); - assert!(cancelled.load(Ordering::Acquire)); - } - - // AC: partial byte chunks do not reset the logical-frame stale deadline. - #[tokio::test] - async fn partial_chunks_cannot_postpone_staleness() { - let (sender, receiver) = tokio::sync::mpsc::channel(8); - let server = MockServer::new_actions(vec![Action::Stream(200, receiver)]).await; - let gate = Arc::new(Notify::new()); - let sleeps = Arc::new(AtomicUsize::new(0)); - let mut deps = test_deps(); - let wait = Arc::clone(&gate); - let sleep_count = Arc::clone(&sleeps); - deps.sleep = Arc::new(move |_| { - sleep_count.fetch_add(1, Ordering::AcqRel); - let wait = Arc::clone(&wait); - async move { wait.notified().await }.boxed() - }); - let times = Arc::new(Mutex::new(VecDeque::from([ - Duration::ZERO, - HEARTBEAT_STALE + Duration::from_secs(1), - ]))); - deps.monotonic_now = { - let times = Arc::clone(×); - Arc::new(move || { - times - .lock() - .unwrap() - .pop_front() - .unwrap_or(HEARTBEAT_STALE + Duration::from_secs(1)) - }) - }; - let mut cfg = config(); - cfg.server_url = server.url.clone(); - let client = build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(); - let task = tokio::spawn(async move { - let mut state = ConnectionState::default(); - let end = consume_connection( - &client, - &cfg, - &CancellationToken::new(), - &deps, - &mut Vec::new(), - &AtomicBool::new(false), - &mut state, - ) - .await; - (end, state) - }); - while sleeps.load(Ordering::Acquire) == 0 { - tokio::task::yield_now().await; - } - for bytes in [b"da".as_slice(), b"ta: par".as_slice(), b"tial".as_slice()] { - sender - .send(Ok(hyper::body::Bytes::copy_from_slice(bytes))) - .await - .unwrap(); - } - gate.notify_waiters(); - tokio::task::yield_now().await; - sender - .send(Err(io::Error::new(io::ErrorKind::ConnectionReset, "stop"))) - .await - .unwrap(); - let (_, state) = task.await.unwrap(); - assert!(state.is_stale); - } - - // AC: cancellation during a hung ack drains promptly and never opens the browser. - #[tokio::test] - async fn cancellation_during_ack_prevents_browser_open() { - let ack_started = Arc::new(Notify::new()); - let opened = Arc::new(AtomicBool::new(false)); - let mut deps = test_deps(); - deps.notify = Arc::new(|_, _| async { NotificationOutcome::Open }.boxed()); - deps.ack_open = { - let started = Arc::clone(&ack_started); - Arc::new(move |_, _, _| { - let started = Arc::clone(&started); - async move { - started.notify_waiters(); - std::future::pending::<()>().await - } - .boxed() - }) - }; - deps.open_browser = { - let opened = Arc::clone(&opened); - Arc::new(move |_| { - opened.store(true, Ordering::Release); - async {}.boxed() - }) - }; - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &deps, - &temp.path().join("missing"), - ) - .await; - ack_started.notified().await; - tokio::time::timeout(Duration::from_secs(1), cancel_all_pending(&mut pending)) - .await - .unwrap(); - assert!(!opened.load(Ordering::Acquire)); - } - - // AC: stop interrupts an in-flight opt-in HTTP request. - #[tokio::test] - async fn stop_interrupts_in_flight_opt_in_request() { - let (server, _gate) = MockServer::gated().await; - let stop = CancellationToken::new(); - let client = build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(); - let task = tokio::spawn({ - let stop = stop.clone(); - let url = server.url.clone(); - async move { poll_opt_in(&client, &url, "key", &stop).await } - }); - wait_for_requests(&server, 1).await; - stop.cancel(); - assert!( - !tokio::time::timeout(Duration::from_secs(1), task) - .await - .unwrap() - .unwrap() - ); - } - - // AC: Python bool event_index is intentionally not treated as an integer. - #[test] - fn boolean_event_index_uses_today() { - // Named deviation: Python bool is an int subclass, so JSON true produces - // `#event-True`. Rust treats booleans as non-integer event indexes and falls back to - // today's chat URL. - let deps = test_deps(); - assert_eq!( - chat_url( - "https://server.test", - Some(&Value::String("20260509".into())), - Some(&Value::Bool(true)), - &deps - ), - "https://server.test/app/chat/20260509" - ); - } - - // tests/test_chat_bridge.py::test_constants_forbidden_literals_appear_once_in_src_only_in_chat_bridge_module_level - #[test] - fn canonical_literals_have_one_production_definition() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); - let bridge = fs::read_to_string(root.join("chat_bridge.rs")).unwrap(); - let marker = "#[cfg(test)]\nmod tests"; - let production = bridge - .split_once(marker) - .map(|(text, _)| text) - .expect("test marker must remain exact"); - for literal in [ - EVENT_SOL_CHAT_REQUEST, - EVENT_SOL_CHAT_REQUEST_SUPERSEDED, - EVENT_OWNER_CHAT_OPEN, - EVENT_OWNER_CHAT_DISMISSED, - ] { - assert_eq!(production.matches(&format!("\"{literal}\"")).count(), 1); - let mut elsewhere = 0; - let mut directories = vec![root.clone()]; - while let Some(directory) = directories.pop() { - for entry in fs::read_dir(directory).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - directories.push(path); - } else if path.extension().and_then(|value| value.to_str()) == Some("rs") - && path.file_name().and_then(|value| value.to_str()) - != Some("chat_bridge.rs") - { - elsewhere += fs::read_to_string(path) - .unwrap() - .matches(&format!("\"{literal}\"")) - .count(); - } - } - } - assert_eq!(elsewhere, 0); - } - assert_eq!( - production.matches("NOTIFY_TITLE: &str = \"sol\"").count(), - 1 - ); - assert_eq!(production.matches("SURFACE: &str = \"linux\"").count(), 1); - } - - fn delay_deps() -> (BridgeDeps, Arc>>) { - let delays = Arc::new(Mutex::new(Vec::new())); - let seen = Arc::clone(&delays); - let mut deps = test_deps(); - deps.sleep = Arc::new(move |delay| { - seen.lock().unwrap().push(delay); - async {}.boxed() - }); - (deps, delays) - } - - async fn heartbeat_after_existing_backoff() -> (ConnectionState, Vec) { - let (sender, receiver) = tokio::sync::mpsc::channel(2); - sender - .send(Ok(hyper::body::Bytes::from_static(b": heartbeat\n"))) - .await - .unwrap(); - drop(sender); - let server = MockServer::new_actions(vec![Action::Stream(200, receiver)]).await; - let gate = Arc::new(Notify::new()); - let sleep_count = Arc::new(AtomicUsize::new(0)); - let mut deps = test_deps(); - let wait = Arc::clone(&gate); - let count = Arc::clone(&sleep_count); - deps.sleep = Arc::new(move |_| { - count.fetch_add(1, Ordering::AcqRel); - let wait = Arc::clone(&wait); - async move { wait.notified().await }.boxed() - }); - let mut cfg = config(); - cfg.server_url = server.url.clone(); - let task = tokio::spawn(async move { - let mut state = ConnectionState { - reconnect_index: 4, - is_stale: true, - }; - let result = consume_connection( - &build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(), - &cfg, - &CancellationToken::new(), - &deps, - &mut Vec::new(), - &AtomicBool::new(false), - &mut state, - ) - .await; - (result, state) - }); - while sleep_count.load(Ordering::Acquire) < 2 { - tokio::task::yield_now().await; - } - gate.notify_waiters(); - let (result, mut state) = task.await.unwrap(); - assert!(matches!(result, ConnectionEnd::Reconnect)); - let (delay_deps, delays) = delay_deps(); - sleep_for_reconnect(&mut state, &CancellationToken::new(), &delay_deps).await; - let recorded = delays.lock().unwrap().clone(); - (state, recorded) - } - - async fn terminal_status_exits_without_sleep(status: u16) { - let server = MockServer::new(vec![(status, json!({}))]).await; - let (deps, delays) = delay_deps(); - let mut cfg = config(); - cfg.server_url = server.url.clone(); - let result = consume_connection( - &build_sse_client(SSE_CONNECT_TIMEOUT, SSE_READ_TIMEOUT).unwrap(), - &cfg, - &CancellationToken::new(), - &deps, - &mut Vec::new(), - &AtomicBool::new(false), - &mut ConnectionState::default(), - ) - .await; - assert!(matches!(result, ConnectionEnd::Terminal)); - assert!(delays.lock().unwrap().is_empty()); - } - - async fn outcome_has_no_click_effects(outcome: NotificationOutcome) { - let ack = Arc::new(AtomicUsize::new(0)); - let opened = Arc::new(AtomicUsize::new(0)); - let mut deps = test_deps(); - deps.notify = Arc::new(move |_, _| async move { outcome }.boxed()); - deps.ack_open = { - let ack = Arc::clone(&ack); - Arc::new(move |_, _, _| { - ack.fetch_add(1, Ordering::AcqRel); - async {}.boxed() - }) - }; - deps.open_browser = { - let opened = Arc::clone(&opened); - Arc::new(move |_| { - opened.fetch_add(1, Ordering::AcqRel); - async {}.boxed() - }) - }; - let temp = tempfile::tempdir().unwrap(); - let mut pending = Vec::new(); - dispatch_event( - &payload(EVENT_SOL_CHAT_REQUEST), - &mut pending, - true, - false, - &config(), - &deps, - &temp.path().join("missing"), - ) - .await; - cancel_all_pending(&mut pending).await; - assert_eq!(ack.load(Ordering::Acquire), 0); - assert_eq!(opened.load(Ordering::Acquire), 0); - } - - fn test_deps() -> BridgeDeps { - BridgeDeps { - notify: Arc::new(|_, _| async { NotificationOutcome::Dismissed }.boxed()), - ack_open: Arc::new(|_, _, _| async {}.boxed()), - open_browser: Arc::new(|_| async {}.boxed()), - supports_actions: Arc::new(|| async { true }.boxed()), - sleep: Arc::new(|_| async {}.boxed()), - monotonic_now: Arc::new(|| Duration::ZERO), - local_day: Arc::new(|| "20260509".into()), - } - } -} diff --git a/crates/solstone-linux/src/cli.rs b/crates/solstone-linux/src/cli.rs index 06d8d33..cdff4dc 100644 --- a/crates/solstone-linux/src/cli.rs +++ b/crates/solstone-linux/src/cli.rs @@ -6,10 +6,12 @@ use crate::{ compute_quarantine_stats, compute_status_capture_stats, format_quarantine_line, }, config::{Config, ConfigPaths, load_config, sanitize_link_authority, save_config}, - private_link::{PrivateStateError, PrivateStateLock, setup_with_stream}, + private_link::{ + PrivateStateError, PrivateStateLock, PrivateStateLockLiveness, setup_with_stream, + }, session_env::{self, Output, Runner}, streams::stream_name, - sync_health::{derive_health, load_facts}, + sync_health::{derive_health, load_facts_with_liveness}, }; use clap::{Parser, Subcommand}; use std::{ @@ -42,7 +44,7 @@ enum Commands { #[arg(long, help = "Segment duration in seconds (default: 300)")] interval: Option, }, - #[command(about = "Interactive configuration")] + #[command(about = "Pair sol with your journal from standard input")] Setup { #[arg(long, help = "Stream name (defaults to hostname-derived)")] stream_name: Option, @@ -263,12 +265,24 @@ fn cmd_setup( return 1; } }; - match runtime.block_on(setup_with_stream( - &config_root, - &host, - stream.as_deref(), - input, - )) { + render_setup_result( + runtime.block_on(setup_with_stream( + &config_root, + &host, + stream.as_deref(), + input, + )), + output, + errors, + ) +} + +fn render_setup_result( + result: Result<(), PrivateStateError>, + output: &mut dyn Write, + errors: &mut dyn Write, +) -> i32 { + match result { Ok(()) => { let _ = write_line(output, "sol can now connect to your journal."); 0 @@ -299,6 +313,34 @@ fn cmd_setup( } } +#[cfg(test)] +pub(crate) async fn dispatch_setup_with_pairer_for_test( + pairer: &dyn crate::private_link::Pairer, + config_root: &std::path::Path, + stream: &str, + input: R, + output: &mut dyn Write, + errors: &mut dyn Write, +) -> i32 { + if write_line( + output, + "Paste the pair link from your journal, then press Enter:", + ) + .is_err() + { + return 1; + } + let result = crate::private_link::setup_with_pairer_for_test( + pairer, + config_root, + "linux", + Some(stream), + input, + ) + .await; + render_setup_result(result, output, errors) +} + trait PromptIo { fn read_line(&mut self, prompt: &str) -> io::Result; fn write_line(&mut self, line: &str) -> io::Result<()>; @@ -386,8 +428,6 @@ fn cmd_settings(paths: ConfigPaths, prompt: &mut dyn PromptIo) -> i32 { config.start_paused = prompt_bool(prompt, "Start paused", config.start_paused)?; config.segment_interval = prompt_positive_int(prompt, "Segment interval seconds", config.segment_interval)?; - config.chat_bridge_enabled = - prompt_bool(prompt, "Chat bridge enabled", config.chat_bridge_enabled)?; config.cache_retention_days = prompt_retention(prompt, config.cache_retention_days)?; save_config(&config)?; prompt.write_line(&format!( @@ -454,7 +494,9 @@ fn cmd_status(paths: ConfigPaths, runner: &dyn Runner, output: &mut dyn Write) - 0 => write_line(output, "Retain: delete synced segments after the day ends")?, value => write_line(output, format!("Retain: {value} day(s)"))?, } - let facts = load_facts(&config.state_dir()); + let liveness = PrivateStateLock::try_probe(&config.config_dir) + .unwrap_or(PrivateStateLockLiveness::NoLiveOwner); + let facts = load_facts_with_liveness(&config.state_dir(), liveness); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -916,8 +958,6 @@ mod tests { } fn settings_config(t: &tempfile::TempDir) { let mut config = load_config(paths(t)).config; - config.server_url = "https://id".into(); - config.key = "KKKK".into(); config.stream = "strm".into(); config.capture_framerate = 2; save_config(&config).unwrap(); @@ -933,26 +973,18 @@ mod tests { #[test] fn settings_enter_keeps_all() { let t = tempfile::tempdir().unwrap(); - let (config, _) = run_settings(&t, &["", "", "", "", "", ""]); + let (config, _) = run_settings(&t, &["", "", "", "", ""]); assert_eq!( ( config.capture_framerate, config.draw_cursor, config.start_paused, config.segment_interval, - config.chat_bridge_enabled, config.cache_retention_days ), - (2, true, false, 300, true, 7) - ); - assert_eq!( - ( - config.server_url.as_str(), - config.key.as_str(), - config.stream.as_str() - ), - ("", "", "strm") + (2, true, false, 300, 7) ); + assert_eq!(config.stream, "strm"); } // tests/test_cli.py::test_cmd_settings_changes_framerate @@ -960,9 +992,7 @@ mod tests { fn settings_changes_framerate() { let t = tempfile::tempdir().unwrap(); assert_eq!( - run_settings(&t, &["5", "", "", "", "", ""]) - .0 - .capture_framerate, + run_settings(&t, &["5", "", "", "", ""]).0.capture_framerate, 5 ); } @@ -971,7 +1001,7 @@ mod tests { #[test] fn settings_framerate_clamped() { let t = tempfile::tempdir().unwrap(); - let (config, output) = run_settings(&t, &["99", "", "", "", "", ""]); + let (config, output) = run_settings(&t, &["99", "", "", "", ""]); assert_eq!(config.capture_framerate, 10); assert!(output.contains("(clamped to 10)")); } @@ -980,7 +1010,7 @@ mod tests { #[test] fn settings_framerate_reprompts() { let t = tempfile::tempdir().unwrap(); - let (config, output) = run_settings(&t, &["abc", "3", "", "", "", "", ""]); + let (config, output) = run_settings(&t, &["abc", "3", "", "", "", ""]); assert_eq!(config.capture_framerate, 3); assert!(output.contains("Enter an integer.")); } @@ -989,7 +1019,7 @@ mod tests { #[test] fn settings_toggles_bool() { let t = tempfile::tempdir().unwrap(); - assert!(!run_settings(&t, &["", "n", "", "", "", ""]).0.draw_cursor); + assert!(!run_settings(&t, &["", "n", "", "", ""]).0.draw_cursor); } // tests/test_cli.py::test_cmd_settings_retention_semantics @@ -997,7 +1027,7 @@ mod tests { fn settings_retention_accepts_negative() { let t = tempfile::tempdir().unwrap(); assert_eq!( - run_settings(&t, &["", "", "", "", "", "-1"]) + run_settings(&t, &["", "", "", "", "-1"]) .0 .cache_retention_days, -1 @@ -1038,8 +1068,6 @@ mod tests { fn status_config(t: &tempfile::TempDir) -> Config { let mut config = load_config(paths(t)).config; - config.server_url = "https://test.example.com".into(); - config.key = "K123456789".into(); config.stream = "test-stream".into(); save_config(&config).unwrap(); config @@ -1105,6 +1133,33 @@ mod tests { ); } + #[test] + fn status_never_surfaces_discarded_legacy_values() { + let t = tempfile::tempdir().unwrap(); + fs::create_dir_all(t.path().join("config")).unwrap(); + fs::write( + t.path().join("config/config.json"), + r#"{ + "server_url":{"secret":"STATUS-URL-SENTINEL"}, + "key":["STATUS-KEY-SENTINEL"], + "chat_bridge_enabled":{"secret":"STATUS-CHAT-SENTINEL"}, + "stream":"desktop" + }"#, + ) + .unwrap(); + let mut out = Vec::new(); + assert_eq!(cmd_status(paths(&t), &StatusRunner(None), &mut out), 0); + let out = String::from_utf8(out).unwrap(); + assert!(out.contains("Journal link: managed privately")); + for sentinel in [ + "STATUS-URL-SENTINEL", + "STATUS-KEY-SENTINEL", + "STATUS-CHAT-SENTINEL", + ] { + assert!(!out.contains(sentinel)); + } + } + // AC: key truncation counts characters rather than UTF-8 bytes. #[test] fn status_key_prefix_is_character_based() { diff --git a/crates/solstone-linux/src/config.rs b/crates/solstone-linux/src/config.rs index b2b9aeb..af2dafd 100644 --- a/crates/solstone-linux/src/config.rs +++ b/crates/solstone-linux/src/config.rs @@ -19,8 +19,6 @@ use crate::private_file::{ DurableWriteFault, NoWriteFault, atomic_write_bytes_with_fault, ensure_private_directory, }; -// L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. -pub const DEFAULT_SERVER_URL: &str = "http://localhost:5015"; pub const DEFAULT_SYNC_STALE_THRESHOLD: i64 = 600; const DEFAULT_RETRY_DELAYS: [i64; 4] = [5, 30, 120, 300]; const CONFIG_WRITE_LOCK_TIMEOUT: Duration = Duration::from_millis(100); @@ -30,19 +28,12 @@ static CONFIG_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Config { - // L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. - #[serde(default, skip_serializing)] - pub server_url: String, - // L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. - #[serde(default, skip_serializing)] - pub key: String, pub stream: String, pub segment_interval: i64, pub sync_retry_delays: Vec, pub sync_max_retries: i64, pub sync_stale_threshold: i64, pub cache_retention_days: i64, - pub chat_bridge_enabled: bool, pub capture_framerate: i64, pub draw_cursor: bool, pub start_paused: bool, @@ -66,15 +57,12 @@ impl Default for Config { fn default() -> Self { let home = home_dir(); Self { - server_url: String::new(), - key: String::new(), stream: String::new(), segment_interval: 300, sync_retry_delays: DEFAULT_RETRY_DELAYS.to_vec(), sync_max_retries: 10, sync_stale_threshold: DEFAULT_SYNC_STALE_THRESHOLD, cache_retention_days: 7, - chat_bridge_enabled: true, capture_framerate: 1, draw_cursor: true, start_paused: false, @@ -260,10 +248,11 @@ pub fn load_config(paths: ConfigPaths) -> LoadedConfig { }); return LoadedConfig { config, warnings }; }; - // L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. - config.server_url = load_string(values, "server_url", &mut warnings); - // L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. - config.key = load_string(values, "key", &mut warnings); + let mut values = values.clone(); + for legacy in ["server_url", "key", "chat_bridge_enabled"] { + values.remove(legacy); + } + let values = &values; config.stream = load_string(values, "stream", &mut warnings); config.segment_interval = load_int(values, "segment_interval", 300, &mut warnings); config.sync_retry_delays = load_int_list( @@ -280,8 +269,6 @@ pub fn load_config(paths: ConfigPaths) -> LoadedConfig { &mut warnings, ); config.cache_retention_days = load_int(values, "cache_retention_days", 7, &mut warnings); - // Python stores this raw, but all consumers use truthiness, so typed coercion is behaviorally equivalent. - config.chat_bridge_enabled = json_truthy(values.get("chat_bridge_enabled"), true); config.capture_framerate = load_int(values, "capture_framerate", 1, &mut warnings).clamp(1, 10); config.draw_cursor = json_truthy(values.get("draw_cursor"), true); config.start_paused = json_truthy(values.get("start_paused"), false); @@ -332,10 +319,6 @@ fn write_link_config( ) -> io::Result { let _guard = acquire_config_write_lock()?; let mut config = load_config(paths.clone()).config; - // L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. - config.server_url.clear(); - // L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. - config.key.clear(); if let Some(stream) = stream { config.stream = stream.to_owned(); } @@ -370,33 +353,13 @@ pub(crate) fn save_linked_stream_with_fault( write_link_config(paths, Some(stream), fault) } -enum IdentityWrite<'a> { - PreserveDisk, - Provided { key: &'a str, stream: &'a str }, -} - -fn save_config_inner( - paths: &ConfigPaths, - source: Option<&Config>, - identity: IdentityWrite<'_>, -) -> io::Result<()> { +fn save_config_inner(paths: &ConfigPaths, source: Option<&Config>) -> io::Result<()> { let _guard = acquire_config_write_lock()?; let mut merged = source .cloned() .unwrap_or_else(|| load_config(paths.clone()).config); - match identity { - IdentityWrite::PreserveDisk if merged.config_path().exists() => { - let disk = load_config(paths.clone()).config; - merged.key = disk.key; - merged.stream = disk.stream; - } - // On the first write there is no disk identity to preserve, so save_config necessarily - // writes the caller's identity. Existing configs remain safe-by-default. - IdentityWrite::PreserveDisk => {} - IdentityWrite::Provided { key, stream } => { - merged.key = key.to_owned(); - merged.stream = stream.to_owned(); - } + if merged.config_path().exists() { + merged.stream = load_config(paths.clone()).config.stream; } write_config(&merged) } @@ -408,30 +371,9 @@ pub fn save_config(config: &Config) -> io::Result<()> { config_dir: Some(config.config_dir.clone()), }, Some(config), - IdentityWrite::PreserveDisk, - ) -} - -// L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. -pub fn save_config_with_identity(config: &Config) -> io::Result<()> { - save_config_inner( - &ConfigPaths { - base_dir: Some(config.base_dir.clone()), - config_dir: Some(config.config_dir.clone()), - }, - Some(config), - IdentityWrite::Provided { - key: &config.key, - stream: &config.stream, - }, ) } -// L3-CLEANUP(spl-cutover): legacy direct-HTTP authority; remove when chat/browser navigation is separated. -pub fn save_identity(paths: &ConfigPaths, key: &str, stream: &str) -> io::Result<()> { - save_config_inner(paths, None, IdentityWrite::Provided { key, stream }) -} - fn migrate(config: &Config) -> io::Result<()> { let old_dir = config.base_dir.join("config"); if config.config_dir == old_dir || config.config_path().exists() { @@ -514,8 +456,7 @@ mod tests { #[test] fn defaults() { let c = Config::default(); - assert_eq!(c.server_url, ""); - assert_eq!(c.key, ""); + assert_eq!(c.stream, ""); assert_eq!(c.segment_interval, 300); } // tests/test_config.py::test_captures_dir @@ -559,21 +500,23 @@ mod tests { fn round_trip_core() { let t = tempfile::tempdir().unwrap(); let c = round_trip(t.path(), |c| { - c.server_url = "https://example.com".into(); - c.key = "key".into(); c.stream = "archon".into(); c.segment_interval = 600 }); - assert_eq!( - (c.server_url, c.key, c.stream, c.segment_interval), - ("".into(), "".into(), "archon".into(), 600) - ); + assert_eq!((c.stream, c.segment_interval), ("archon".into(), 600)); } // tests/test_config.py::test_load_missing #[test] fn load_missing() { let t = tempfile::tempdir().unwrap(); - assert_eq!(load(t.path()).config.server_url, ""); + assert_eq!( + load(t.path()).config, + Config { + base_dir: t.path().into(), + config_dir: t.path().join("cfg"), + ..Config::default() + } + ); } // tests/test_config.py::test_load_corrupt #[test] @@ -583,7 +526,7 @@ mod tests { fs::create_dir_all(&d).unwrap(); fs::write(d.join("config.json"), "not json!").unwrap(); let x = load(t.path()); - assert_eq!(x.config.server_url, ""); + assert_eq!(x.config.stream, ""); assert_eq!(x.warnings.len(), 1); assert!( x.warnings[0] @@ -662,7 +605,7 @@ mod tests { #[test] fn retention_default() { let t = tempfile::tempdir().unwrap(); - write(t.path(), json!({"server_url":"http://test"})); + write(t.path(), json!({"stream":"old"})); assert_eq!(load(t.path()).config.cache_retention_days, 7); } // tests/test_config.py::test_capture_framerate_default @@ -694,7 +637,7 @@ mod tests { #[test] fn old_framerate_defaults() { let t = tempfile::tempdir().unwrap(); - write(t.path(), json!({"server_url":"http://test"})); + write(t.path(), json!({"stream":"old"})); let c = load(t.path()).config; assert_eq!(c.capture_framerate, 1); assert!(c.draw_cursor); @@ -728,7 +671,7 @@ mod tests { #[test] fn old_paused_default() { let t = tempfile::tempdir().unwrap(); - write(t.path(), json!({"server_url":"http://test"})); + write(t.path(), json!({"stream":"old"})); assert!(!load(t.path()).config.start_paused); } // tests/test_config.py::test_migrates_legacy_config @@ -739,7 +682,7 @@ mod tests { fs::create_dir(&old).unwrap(); fs::write( old.join("config.json"), - r#"{"server_url":"https://example.com"}"#, + r#"{"server_url":"https://sentinel.invalid","stream":"desktop"}"#, ) .unwrap(); fs::write(old.join("restore_token"), "tok").unwrap(); @@ -747,7 +690,7 @@ mod tests { base_dir: Some(t.path().into()), config_dir: Some(t.path().join("new")), }); - assert_eq!(x.config.server_url, "https://example.com"); + assert_eq!(x.config.stream, "desktop"); assert_eq!( fs::read_to_string(t.path().join("new/restore_token")).unwrap(), "tok" @@ -768,13 +711,13 @@ mod tests { assert_eq!(x.config.capture_framerate, 4); assert!(old.exists()); } - // AC: all twelve persisted defaults. + // AC: all nine persisted defaults. #[test] fn all_defaults() { let c = Config::default(); assert_eq!( serde_json::to_value(c).unwrap(), - json!({"stream":"","segment_interval":300,"sync_retry_delays":[5,30,120,300],"sync_max_retries":10,"sync_stale_threshold":600,"cache_retention_days":7,"chat_bridge_enabled":true,"capture_framerate":1,"draw_cursor":true,"start_paused":false}) + json!({"stream":"","segment_interval":300,"sync_retry_delays":[5,30,120,300],"sync_max_retries":10,"sync_stale_threshold":600,"cache_retention_days":7,"capture_framerate":1,"draw_cursor":true,"start_paused":false}) ); } // AC: numeric coercion rejects bool and truncates floats, including list elements. @@ -823,11 +766,11 @@ mod tests { #[test] fn non_string_field() { let t = tempfile::tempdir().unwrap(); - write(t.path(), json!({"server_url":7})); + write(t.path(), json!({"stream":7})); let x = load(t.path()); - assert_eq!(x.config.server_url, ""); - assert_eq!(warning_fields(&x), vec![Some("server_url")]); - assert!(x.warnings[0].message.contains("server_url=7")); + assert_eq!(x.config.stream, ""); + assert_eq!(warning_fields(&x), vec![Some("stream")]); + assert!(x.warnings[0].message.contains("stream=7")); } // AC: save schema is exact and unknown keys are dropped. #[test] @@ -847,7 +790,6 @@ mod tests { "sync_max_retries", "sync_stale_threshold", "cache_retention_days", - "chat_bridge_enabled", "capture_framerate", "draw_cursor", "start_paused" @@ -867,64 +809,38 @@ mod tests { ); } - // AC 11/17: identity-only recovery and a stale whole-config writer preserve both change sets. + // AC: a stale whole-config writer preserves a newer linked stream. #[test] - fn stale_settings_snapshot_preserves_recovered_identity() { + fn stale_settings_snapshot_preserves_linked_stream() { let t = tempfile::tempdir().unwrap(); let initial = Config { base_dir: t.path().into(), config_dir: t.path().join("cfg"), - server_url: "https://journal".into(), cache_retention_days: 7, ..Config::default() }; save_config(&initial).unwrap(); let config_paths = paths(t.path()); - save_identity(&config_paths, "STALE-KEY", "desktop-old").unwrap(); + save_linked_stream(&config_paths, "desktop-old").unwrap(); let mut stale_settings = load_config(config_paths.clone()).config; - save_identity(&config_paths, "NEW-KEY", "desktop-new").unwrap(); + save_linked_stream(&config_paths, "desktop-new").unwrap(); stale_settings.cache_retention_days = 30; save_config(&stale_settings).unwrap(); let saved = load_config(config_paths).config; - assert_eq!(saved.key, ""); assert_eq!(saved.stream, "desktop-new"); assert_eq!(saved.cache_retention_days, 30); } - // AC 12: recovery writes identity only and cannot revert newer non-identity disk state. - #[test] - fn save_identity_preserves_stream_but_not_legacy_authority() { - let t = tempfile::tempdir().unwrap(); - let config_paths = paths(t.path()); - let mut config = Config { - base_dir: t.path().into(), - config_dir: t.path().join("cfg"), - server_url: "https://old".into(), - ..Config::default() - }; - save_config(&config).unwrap(); - save_identity(&config_paths, "STALE-KEY", "desktop").unwrap(); - config = load_config(config_paths.clone()).config; - config.server_url = "https://new".into(); - save_config(&config).unwrap(); - - save_identity(&config_paths, "NEW-KEY", "desktop-new").unwrap(); - let saved = load_config(config_paths).config; - assert_eq!(saved.server_url, ""); - assert_eq!(saved.key, ""); - assert_eq!(saved.stream, "desktop-new"); - } - #[test] fn representative_legacy_configs_preserve_settings_and_stream_while_stripping_authority() { let t = tempfile::tempdir().unwrap(); write( t.path(), json!({ - "server_url": "https://legacy.invalid", - "key": "legacy-secret", + "server_url": "https://VALID-URL-SENTINEL.invalid", + "key": "VALID-KEY-SENTINEL", "stream": "desktop", "segment_interval": 17, "sync_retry_delays": [2, 4], @@ -939,15 +855,12 @@ mod tests { ); let sanitized = sanitize_link_authority(&paths(t.path())).unwrap(); - assert_eq!(sanitized.server_url, ""); - assert_eq!(sanitized.key, ""); assert_eq!(sanitized.stream, "desktop"); assert_eq!(sanitized.segment_interval, 17); assert_eq!(sanitized.sync_retry_delays, vec![2, 4]); assert_eq!(sanitized.sync_max_retries, 3); assert_eq!(sanitized.sync_stale_threshold, 91); assert_eq!(sanitized.cache_retention_days, 12); - assert!(!sanitized.chat_bridge_enabled); assert_eq!(sanitized.capture_framerate, 4); assert!(!sanitized.draw_cursor); assert!(sanitized.start_paused); @@ -955,6 +868,51 @@ mod tests { serde_json::from_slice(&fs::read(t.path().join("cfg/config.json")).unwrap()).unwrap(); assert!(value.get("server_url").is_none()); assert!(value.get("key").is_none()); + assert!(value.get("chat_bridge_enabled").is_none()); + } + + #[test] + fn legacy_values_are_discarded_before_warnings_and_never_serialized() { + for legacy in [ + json!({ + "server_url": "VALID-URL-SENTINEL", + "key": "VALID-KEY-SENTINEL", + "chat_bridge_enabled": "VALID-CHAT-SENTINEL", + "stream": "desktop" + }), + json!({ + "server_url": {"secret": "WRONG-URL-SENTINEL"}, + "key": ["WRONG-KEY-SENTINEL"], + "chat_bridge_enabled": {"secret": "WRONG-CHAT-SENTINEL"}, + "stream": "desktop" + }), + ] { + let t = tempfile::tempdir().unwrap(); + write(t.path(), legacy); + let loaded = load(t.path()); + assert_eq!(loaded.config.stream, "desktop"); + let warning_text = loaded + .warnings + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + let debug = format!("{:?}", loaded.config); + let serialized = serde_json::to_string(&loaded.config).unwrap(); + for sentinel in [ + "VALID-URL-SENTINEL", + "VALID-KEY-SENTINEL", + "VALID-CHAT-SENTINEL", + "WRONG-URL-SENTINEL", + "WRONG-KEY-SENTINEL", + "WRONG-CHAT-SENTINEL", + ] { + assert!(!warning_text.contains(sentinel)); + assert!(!debug.contains(sentinel)); + assert!(!serialized.contains(sentinel)); + } + assert!(loaded.warnings.is_empty()); + } } #[test] @@ -966,7 +924,26 @@ mod tests { let value: Value = serde_json::from_str(&text).unwrap(); assert!(value.get("server_url").is_none()); assert!(value.get("key").is_none()); + assert!(value.get("chat_bridge_enabled").is_none()); assert_eq!(value["stream"], ""); + assert_eq!( + text, + concat!( + "{\n", + " \"stream\": \"\",\n", + " \"segment_interval\": 300,\n", + " \"sync_retry_delays\": [\n", + " 5,\n 30,\n 120,\n 300\n", + " ],\n", + " \"sync_max_retries\": 10,\n", + " \"sync_stale_threshold\": 600,\n", + " \"cache_retention_days\": 7,\n", + " \"capture_framerate\": 1,\n", + " \"draw_cursor\": true,\n", + " \"start_paused\": false\n", + "}\n" + ) + ); } #[test] @@ -1029,7 +1006,7 @@ mod tests { let t = tempfile::tempdir().unwrap(); write( t.path(), - json!({"server_url":"https://legacy.invalid","key":"secret","stream":"old"}), + json!({"server_url":"VALID-URL-SENTINEL","key":"VALID-KEY-SENTINEL","chat_bridge_enabled":"VALID-CHAT-SENTINEL","stream":"old"}), ); let path = t.path().join("cfg/config.json"); let before = fs::read(&path).unwrap(); @@ -1040,6 +1017,7 @@ mod tests { assert_eq!(value["stream"], "old"); assert!(value.get("server_url").is_none()); assert!(value.get("key").is_none()); + assert!(value.get("chat_bridge_enabled").is_none()); } else { assert_eq!(fs::read(path).unwrap(), before, "{stage:?}"); } diff --git a/crates/solstone-linux/src/dbus_service.rs b/crates/solstone-linux/src/dbus_service.rs index 666345d..ee54ddf 100644 --- a/crates/solstone-linux/src/dbus_service.rs +++ b/crates/solstone-linux/src/dbus_service.rs @@ -82,10 +82,6 @@ impl Observer1< String::new() } #[zbus(property)] - fn server_url(&self) -> String { - String::new() - } - #[zbus(property)] fn stream(&self) -> String { self.config.stream.clone() } @@ -161,7 +157,6 @@ impl Observer1< // TestSyncStatusTracking::test_progress_drives_syncing_status -> dbus_service::tests::in_progress_sync_properties_pass_through. // TestSyncStatusTracking::test_progress_change_emits_signal -> desktop_component::tests::progress_change_emits_syncing_composite. // TestObserverServiceConfig::test_capture_dir -> dbus_service::tests::config_properties_match_config. -// TestObserverServiceConfig::test_server_url -> dbus_service::tests::config_properties_match_config. // TestObserverServiceConfig::test_stream -> dbus_service::tests::config_properties_match_config. // TestObserverServiceConfig::test_segment_interval -> dbus_service::tests::config_properties_match_config. @@ -592,7 +587,7 @@ mod tests { assert!(second > first); } #[test] - fn fresh_sync_properties_are_unknown_and_empty() { + fn fresh_sync_properties_are_connecting_and_empty() { let (s, _, _) = service( snapshot(crate::observer::Mode::Idle, false), Config::default(), @@ -600,7 +595,7 @@ mod tests { "", 0, ); - assert_eq!(s.sync_status(), "unknown"); + assert_eq!(s.sync_status(), "connecting"); assert_eq!(s.current_sync_progress(), ""); } #[test] @@ -609,6 +604,10 @@ mod tests { &crate::sync_health::SyncFacts { in_progress: true, progress: "uploading 120000_300".into(), + link: Some(crate::private_link::LinkFactState { + observer_registered: true, + ..Default::default() + }), ..Default::default() }, 0.0, @@ -628,7 +627,6 @@ mod tests { fn config_properties_match_config() { let config = Config { base_dir: std::path::PathBuf::from("/tmp/observer1"), - server_url: "https://test.example.com".into(), stream: "test-stream".into(), segment_interval: 300, ..Default::default() @@ -641,7 +639,6 @@ mod tests { 0, ); assert_eq!(s.capture_dir(), "/tmp/observer1/captures"); - assert_eq!(s.server_url(), ""); assert_eq!(s.stream(), "test-stream"); assert_eq!(s.segment_interval(), 300); } diff --git a/crates/solstone-linux/src/desktop_component.rs b/crates/solstone-linux/src/desktop_component.rs index f436953..3a7a793 100644 --- a/crates/solstone-linux/src/desktop_component.rs +++ b/crates/solstone-linux/src/desktop_component.rs @@ -1,7 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (c) 2026 sol pbc -use crate::{config::Config, observer::StateSnapshot, sync_health::SyncHealth, tray_model}; +use crate::{ + config::Config, observer::StateSnapshot, private_link::OpenJournalAccess, + sync_health::SyncHealth, tray_model, +}; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -57,12 +60,17 @@ impl SignalState { pub struct DesktopComponent { pub config: Config, disabled: Arc, + open_journal: OpenJournalAccess, } impl DesktopComponent { pub fn new(config: Config) -> Self { + Self::with_open_journal(config, OpenJournalAccess::default()) + } + pub(crate) fn with_open_journal(config: Config, open_journal: OpenJournalAccess) -> Self { Self { config, disabled: Arc::new(AtomicBool::new(false)), + open_journal, } } pub fn disabled(&self) -> bool { @@ -127,21 +135,17 @@ impl DesktopComponent { } } } - pub fn journal_url(&self) -> &str { - "https://solstone.app" - } pub fn command_url<'a>(&'a self, command: &'a crate::tray::TrayCommand) -> Option<&'a str> { match command { - crate::tray::TrayCommand::OpenJournal => Some(self.journal_url()), crate::tray::TrayCommand::OpenUrl(url) => Some(url), _ => None, } } pub fn perform_desktop_command(&self, command: crate::tray::TrayCommand) -> Result<(), String> { match command { - crate::tray::TrayCommand::OpenJournal => { - open::that_detached(self.journal_url()).map_err(|e| e.to_string()) - } + crate::tray::TrayCommand::OpenJournal => self.open_journal.open().map_err(|_| { + "Could not open your journal. Wait for sol to reconnect, then try again.".into() + }), crate::tray::TrayCommand::OpenUrl(url) => { open::that_detached(url).map_err(|e| e.to_string()) } @@ -195,7 +199,6 @@ mod tests { #[test] fn component_uses_config() { let c = Config { - server_url: "x".into(), base_dir: "/tmp/solstone-test".into(), ..Default::default() }; @@ -225,20 +228,9 @@ mod tests { assert_eq!((calls, waits), (3, 2)); } #[test] - fn public_journal_fallback() { - assert_eq!( - DesktopComponent::new(Config::default()).journal_url(), - "https://solstone.app" - ) - } - #[test] - fn four_url_targets_remain_distinct() { + fn public_links_remain_distinct_from_open_journal() { let component = DesktopComponent::new(Config::default()); let commands = [ - ( - crate::tray::TrayCommand::OpenJournal, - "https://solstone.app", - ), ( crate::tray::TrayCommand::OpenUrl("https://solstone.app/observers"), "https://solstone.app/observers", @@ -255,6 +247,21 @@ mod tests { for (command, expected) in &commands { assert_eq!(component.command_url(command), Some(*expected)); } + assert_eq!( + component.command_url(&crate::tray::TrayCommand::OpenJournal), + None + ); + } + #[test] + fn unavailable_open_journal_has_one_owner_visible_remediation() { + assert_eq!( + DesktopComponent::new(Config::default()) + .perform_desktop_command(crate::tray::TrayCommand::OpenJournal), + Err( + "Could not open your journal. Wait for sol to reconnect, then try again." + .to_owned() + ) + ); } #[test] fn no_transition_means_no_signal() { @@ -273,7 +280,9 @@ mod tests { let mut s = SignalState::new(&snap(), &h, ""); assert_eq!( s.sync_changed(&h, "3/10"), - Some(ComponentSignal::SyncProgressChanged("unknown:3/10".into())) + Some(ComponentSignal::SyncProgressChanged( + "connecting:3/10".into() + )) ); assert_eq!(s.sync_changed(&h, "3/10"), None) } @@ -338,6 +347,10 @@ mod tests { &crate::sync_health::SyncFacts { in_progress: true, progress: "30s until probe".into(), + link: Some(crate::private_link::LinkFactState { + observer_registered: true, + ..Default::default() + }), ..Default::default() }, 0.0, diff --git a/crates/solstone-linux/src/docs_policy_tests.rs b/crates/solstone-linux/src/docs_policy_tests.rs new file mode 100644 index 0000000..d7aca1b --- /dev/null +++ b/crates/solstone-linux/src/docs_policy_tests.rs @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use crate::{ + cli::Args, + policy_test_support::authority_vocabulary::{ + LEGACY_COMMANDS, LEGACY_ENVIRONMENT, LEGACY_OPTIONS, LEGACY_ORIGINS, PYTHON_SETUP, + }, + release_rail_tests::workspace_root, +}; +use clap::CommandFactory; +use std::{fmt, fs, path::Path}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DocsError { + surface: String, + line: usize, + rule: &'static str, + detail: String, +} + +impl fmt::Display for DocsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "docs policy: surface={} line={} rule={} detail={}", + self.surface, self.line, self.rule, self.detail + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ChangelogSection { + Current, + Historical, +} + +fn is_release_heading(line: &str) -> bool { + let Some(rest) = line.strip_prefix("## [") else { + return false; + }; + let Some((version, date)) = rest.split_once("] - ") else { + return false; + }; + let semver = version.split('.').collect::>(); + semver.len() == 3 + && semver + .iter() + .all(|component| !component.is_empty() && component.chars().all(|c| c.is_ascii_digit())) + && date.len() == 10 + && date.chars().enumerate().all(|(index, character)| { + matches!(index, 4 | 7) && character == '-' + || !matches!(index, 4 | 7) && character.is_ascii_digit() + }) +} + +fn current_changelog_lines(text: &str) -> Result, DocsError> { + let mut section = ChangelogSection::Current; + let mut output = Vec::new(); + for (index, line) in text.lines().enumerate() { + if line.starts_with("## [") { + section = if line == "## [Unreleased]" { + ChangelogSection::Current + } else if is_release_heading(line) { + ChangelogSection::Historical + } else { + return Err(DocsError { + surface: "CHANGELOG.md".to_owned(), + line: index + 1, + rule: "changelog-heading", + detail: line.to_owned(), + }); + }; + } + if section == ChangelogSection::Current { + output.push((index + 1, line)); + } + } + Ok(output) +} + +fn normalize_markdown_line(line: &str) -> String { + line.trim() + .trim_start_matches(['-', '*', '>']) + .replace(['`', '[', ']', '(', ')', '<', '>', '"', '\''], " ") + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +fn forbidden_current_text(normalized: &str) -> Option<(&'static str, String)> { + for token in LEGACY_ENVIRONMENT + .iter() + .chain(LEGACY_OPTIONS) + .chain(LEGACY_ORIGINS) + .chain(LEGACY_COMMANDS) + .chain(PYTHON_SETUP) + { + if normalized.contains(&token.to_ascii_lowercase()) { + return Some(("legacy-instruction", (*token).to_owned())); + } + } + let positive_fallback = (normalized.contains("direct fallback") + || normalized.contains("fallback connection")) + && !normalized.contains("no direct fallback") + && !normalized.contains("there is no"); + if positive_fallback { + return Some(("direct-fallback", "fallback".to_owned())); + } + let local_install = (normalized.contains("install") + && (normalized.contains("local journal") || normalized.contains("local python"))) + && !normalized.contains("no local") + && !normalized.contains("there is no"); + if local_install { + return Some(("local-install", "local journal/python".to_owned())); + } + if normalized.contains("observer key") + && !normalized.contains("no observer key") + && !normalized.contains("there is no") + { + return Some(("observer-key-minting", "observer key".to_owned())); + } + None +} + +fn scan_lines<'a>( + surface: &str, + lines: impl IntoIterator, +) -> Result<(), DocsError> { + let mut fenced = false; + let mut paragraph = String::new(); + let mut paragraph_line = 1; + let inspect = |text: &str, line: usize, fenced: bool| { + let normalized = normalize_markdown_line(text); + forbidden_current_text(&normalized).map(|(rule, detail)| DocsError { + surface: surface.to_owned(), + line, + rule, + detail: if fenced { + format!("command:{detail}") + } else { + detail + }, + }) + }; + for (line_number, line) in lines { + if line.trim_start().starts_with("```") { + if let Some(error) = inspect(¶graph, paragraph_line, fenced) { + return Err(error); + } + paragraph.clear(); + fenced = !fenced; + continue; + } + if line.trim().is_empty() || line.starts_with('#') { + if let Some(error) = inspect(¶graph, paragraph_line, fenced) { + return Err(error); + } + paragraph.clear(); + if line.starts_with('#') + && let Some(error) = inspect(line, line_number, fenced) + { + return Err(error); + } + continue; + } + if paragraph.is_empty() { + paragraph_line = line_number; + } else { + paragraph.push(' '); + } + paragraph.push_str(line); + } + if let Some(error) = inspect(¶graph, paragraph_line, fenced) { + return Err(error); + } + Ok(()) +} + +fn scan_markdown(path: &Path) -> Result<(), DocsError> { + let text = fs::read_to_string(path).unwrap(); + let surface = path.file_name().unwrap().to_string_lossy(); + scan_lines( + &surface, + text.lines() + .enumerate() + .map(|(index, line)| (index + 1, line)), + ) +} + +fn rendered_help() -> Vec<(String, String)> { + fn collect(command: &clap::Command, output: &mut Vec<(String, String)>) { + let mut command = command.clone(); + let name = command.get_name().to_owned(); + let mut bytes = Vec::new(); + command.write_long_help(&mut bytes).unwrap(); + output.push((name, String::from_utf8(bytes).unwrap())); + for child in command.get_subcommands() { + collect(child, output); + } + } + let mut output = Vec::new(); + collect(&Args::command(), &mut output); + output +} + +#[test] +fn docs_current_instructions_have_no_legacy_authority() { + let root = workspace_root(); + for relative in ["README.md", "INSTALL.md", "packaging/INSTALL-NOTES"] { + scan_markdown(&root.join(relative)).unwrap(); + } + let changelog = fs::read_to_string(root.join("CHANGELOG.md")).unwrap(); + scan_lines("CHANGELOG.md", current_changelog_lines(&changelog).unwrap()).unwrap(); + for (command, help) in rendered_help() { + scan_lines( + &format!("help:{command}"), + help.lines() + .enumerate() + .map(|(index, line)| (index + 1, line)), + ) + .unwrap(); + } +} + +#[test] +fn docs_changelog_history_is_structural() { + let changelog = "before\n## [Unreleased]\ncurrent\n## [1.2.3] - 2026-01-02\n--server-url\n"; + let lines = current_changelog_lines(changelog).unwrap(); + assert!(!lines.iter().any(|(_, line)| line.contains("--server-url"))); + let malformed = "## [next]\n"; + assert_eq!( + current_changelog_lines(malformed).unwrap_err().rule, + "changelog-heading" + ); +} + +#[test] +fn docs_mutations_reject_prose_links_code_and_fenced_commands() { + for text in [ + "use --server-url now", + "[journal](http://localhost:5015)", + "`SOLSTONE_TOKEN=x`", + "```bash\npipx install solstone-linux\n```", + "mint an observer key", + "use a direct fallback connection", + ] { + assert!( + scan_lines( + "fixture", + text.lines().enumerate().map(|(i, line)| (i + 1, line)) + ) + .is_err() + ); + } +} diff --git a/crates/solstone-linux/src/doctor.rs b/crates/solstone-linux/src/doctor.rs index ffac29f..39aaca1 100644 --- a/crates/solstone-linux/src/doctor.rs +++ b/crates/solstone-linux/src/doctor.rs @@ -11,8 +11,9 @@ use crate::{ audio::pulse, capture_stats::{compute_quarantine_stats, format_quarantine_line}, config::{Config, ConfigPaths, load_config}, + private_link::{PrivateStateLock, PrivateStateLockLiveness}, session_env::{Output, Runner}, - sync_health::{SyncHealth, derive_health, load_facts}, + sync_health::{SyncHealth, derive_health, load_facts_with_liveness}, video::{ gstreamer::ensure_initialized, x11::{RandrOutputProvider, X11OutputProvider}, @@ -434,7 +435,9 @@ impl DoctorChecks for RealDoctor<'_> { } fn sync_health(&mut self) -> CheckResult { let config = self.config().clone(); - let facts = load_facts(&config.state_dir()); + let liveness = PrivateStateLock::try_probe(&config.config_dir) + .unwrap_or(PrivateStateLockLiveness::NoLiveOwner); + let facts = load_facts_with_liveness(&config.state_dir(), liveness); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() diff --git a/crates/solstone-linux/src/lib.rs b/crates/solstone-linux/src/lib.rs index 088c451..01bdc18 100644 --- a/crates/solstone-linux/src/lib.rs +++ b/crates/solstone-linux/src/lib.rs @@ -4,7 +4,6 @@ pub mod activity; pub mod audio; pub mod capture_stats; -pub mod chat_bridge; pub mod chunking; pub mod cli; pub mod clipboard; @@ -38,11 +37,15 @@ pub mod tray_model; pub mod upload; pub mod video; +#[cfg(test)] +mod docs_policy_tests; #[cfg(test)] mod linked_authority_policy_tests; #[cfg(test)] mod observer_contract_tests; #[cfg(test)] +mod policy_test_support; +#[cfg(test)] mod private_link_test_peer; #[cfg(test)] mod release_rail_tests; diff --git a/crates/solstone-linux/src/linked_authority_policy_tests.rs b/crates/solstone-linux/src/linked_authority_policy_tests.rs index 502a0da..f436c47 100644 --- a/crates/solstone-linux/src/linked_authority_policy_tests.rs +++ b/crates/solstone-linux/src/linked_authority_policy_tests.rs @@ -1,192 +1,686 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (c) 2026 sol pbc +use crate::{ + policy_test_support::{ + authority_vocabulary::{ + LEGACY_COMMANDS, LEGACY_ENVIRONMENT, LEGACY_EXECUTABLES, LEGACY_OPTIONS, LEGACY_ORIGINS, + }, + source_inventory::{ + CargoCommand, ScanErrorCause, SourceIdentity, SourceInventory, SourceNode, + scan_workspace_with_command, walk_member, + }, + }, + release_rail_tests::{command_path, workspace_root}, +}; +use proc_macro2::{TokenStream, TokenTree}; use std::{ - fs, - path::{Path, PathBuf}, + collections::{BTreeMap, BTreeSet}, + ffi::OsString, + fmt, fs, + path::PathBuf, +}; +use syn::{ + Attribute, Block, Expr, ExprCall, ExprMethodCall, ExprPath, ImplItemFn, ItemFn, ItemMod, Lit, + Macro, Signature, TraitItemFn, UseTree, + visit::{self, Visit}, }; -const L3_MARKER: &str = "L3-CLEANUP(spl-cutover)"; -const TEST_ONLY_FILES: &[&str] = &[ - "linked_authority_policy_tests.rs", - "observer_contract_tests.rs", - "private_link_test_peer.rs", - "release_rail_tests.rs", - "test_support.rs", - "toolchain_policy_tests.rs", - "unsafe_policy_tests.rs", +const RESERVED_HEADERS: &[&str] = &[ + "authorization", + "cookie", + "host", + "x-solstone-observer", + "x-solstone-registration", ]; -fn source_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src") +// Deliberate bounded-analysis gap: values assembled at runtime from non-literal +// inputs and deliberately encoded/obfuscated constants are not reconstructed. +// Their eventual use still crosses a locally resolved request, URL, process, +// environment, or header sink, which this policy classifies. + +#[derive(Clone, Debug)] +struct PolicyError { + identity: String, + path: PathBuf, + rule: &'static str, + detail: String, } -fn rust_sources(root: &Path) -> Vec { - let mut pending = vec![root.to_path_buf()]; - let mut sources = Vec::new(); - while let Some(directory) = pending.pop() { - for entry in fs::read_dir(directory).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - pending.push(path); - } else if path.extension().is_some_and(|extension| extension == "rs") - && !TEST_ONLY_FILES - .iter() - .any(|name| path.file_name().is_some_and(|file| file == *name)) - { - sources.push(path); - } - } +impl fmt::Display for PolicyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "source policy: identity={} path={} rule={} detail={}", + self.identity, + self.path.display(), + self.rule, + self.detail + ) } - sources.sort(); - sources } -fn production_prefix(source: &str) -> &str { - source - .split("#[cfg(test)]\nmod tests") - .next() - .unwrap_or(source) +#[derive(Clone, Debug)] +struct FunctionFact { + identity: SourceIdentity, + path: PathBuf, + name: String, + test_only: bool, + direct_sink: Option<(&'static str, String)>, + calls: BTreeSet, +} + +fn attributes_are_test_only(attributes: &[Attribute]) -> bool { + attributes.iter().any(|attribute| { + let name = attribute + .path() + .segments + .last() + .map(|segment| segment.ident.to_string()); + name.as_deref() == Some("test") + || (name.as_deref() == Some("cfg") + && attribute.meta.require_list().is_ok_and(|list| { + let cfg = list.tokens.to_string(); + cfg == "test" + || (cfg.starts_with("all (") + && cfg + .split(|character: char| { + !(character.is_ascii_alphanumeric() || character == '_') + }) + .any(|token| token == "test")) + })) + }) } -fn marked_items(lines: &[&str], marker: &str) -> Vec { - let mut marked = vec![false; lines.len()]; - let mut pending = false; - let mut depth = 0_i64; - for (index, line) in lines.iter().enumerate() { - if line.contains(marker) { - pending = true; - marked[index] = true; - continue; +fn flatten_use(tree: &UseTree, prefix: Vec, aliases: &mut BTreeMap>) { + match tree { + UseTree::Path(path) => { + let mut prefix = prefix; + prefix.push(path.ident.to_string()); + flatten_use(&path.tree, prefix, aliases); + } + UseTree::Name(name) => { + let mut canonical = prefix; + canonical.push(name.ident.to_string()); + aliases.insert(name.ident.to_string(), canonical); } - if depth > 0 { - marked[index] = true; - depth += line.matches('{').count() as i64 - line.matches('}').count() as i64; - continue; + UseTree::Rename(rename) => { + let mut canonical = prefix; + canonical.push(rename.ident.to_string()); + aliases.insert(rename.rename.to_string(), canonical); } - if pending { - marked[index] = true; - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with("#[") { - continue; + UseTree::Group(group) => { + for item in &group.items { + flatten_use(item, prefix.clone(), aliases); } - depth = line.matches('{').count() as i64 - line.matches('}').count() as i64; - pending = depth == 0 && !line.contains(';'); - } - } - marked -} - -fn origin_relative_constructions(source: &str) -> Vec { - let bytes = source.as_bytes(); - let mut findings = Vec::new(); - for needle in ["format!(", ".join(", "push_str("] { - let mut offset = 0; - while let Some(relative) = source[offset..].find(needle) { - let start = offset + relative; - let mut depth = 0_i32; - let mut quoted = false; - let mut escaped = false; - let mut end = start; - for (index, byte) in bytes[start..].iter().copied().enumerate() { - end = start + index + 1; - if quoted { - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if byte == b'"' { - quoted = false; - } - continue; - } - match byte { - b'"' => quoted = true, - b'(' => depth += 1, - b')' => { - depth -= 1; - if depth == 0 { - break; - } - } - _ => {} + } + UseTree::Glob(_) => {} + } +} + +fn canonical_path(path: &syn::Path, aliases: &BTreeMap>) -> Vec { + let mut segments = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>(); + if let Some(first) = segments.first() + && let Some(prefix) = aliases.get(first) + { + let mut canonical = prefix.clone(); + canonical.extend(segments.drain(1..)); + return canonical; + } + segments +} + +fn literal(expr: &Expr) -> Option { + match expr { + Expr::Lit(value) => match &value.lit { + Lit::Str(value) => Some(value.value()), + Lit::ByteStr(value) => String::from_utf8(value.value()).ok(), + _ => None, + }, + Expr::Paren(value) => literal(&value.expr), + Expr::Group(value) => literal(&value.expr), + Expr::Binary(value) if matches!(value.op, syn::BinOp::Add(_)) => Some(format!( + "{}{}", + literal(&value.left)?, + literal(&value.right)? + )), + Expr::Macro(value) + if value.mac.path.is_ident("concat") || value.mac.path.is_ident("format") => + { + Some(literal_tokens(value.mac.tokens.clone())) + } + _ => None, + } +} + +fn literal_tokens(tokens: TokenStream) -> String { + let mut output = String::new(); + for token in tokens { + match token { + TokenTree::Literal(value) => { + if let Ok(value) = syn::parse_str::(&value.to_string()) { + output.push_str(&value.value()); } } - let construction = &source[start..end]; - if construction.contains("/app/") || construction.contains("/api/") { - findings.push(start); + TokenTree::Group(group) => output.push_str(&literal_tokens(group.stream())), + _ => {} + } + } + output +} + +fn forbidden_literal(value: &str) -> Option<&'static str> { + let lower = value.to_ascii_lowercase(); + LEGACY_ENVIRONMENT + .iter() + .chain(LEGACY_OPTIONS) + .chain(LEGACY_ORIGINS) + .chain(LEGACY_COMMANDS) + .copied() + .find(|needle| lower.contains(&needle.to_ascii_lowercase())) +} + +fn contains_env_call(expr: &Expr, aliases: &BTreeMap>) -> bool { + match expr { + Expr::Call(call) => { + matches!(&*call.func, Expr::Path(path) if { + let path = canonical_path(&path.path, aliases).join("::"); + path.ends_with("env::var") || path.ends_with("env::var_os") + }) || call + .args + .iter() + .any(|argument| contains_env_call(argument, aliases)) + } + Expr::MethodCall(call) => { + contains_env_call(&call.receiver, aliases) + || call + .args + .iter() + .any(|argument| contains_env_call(argument, aliases)) + } + Expr::Paren(expr) => contains_env_call(&expr.expr, aliases), + Expr::Group(expr) => contains_env_call(&expr.expr, aliases), + _ => false, + } +} + +struct FunctionScanner<'a> { + aliases: &'a BTreeMap>, + direct_sink: Option<(&'static str, String)>, + calls: BTreeSet, + pushed_literals: String, +} + +impl FunctionScanner<'_> { + fn sink(&mut self, rule: &'static str, detail: impl Into) { + if self.direct_sink.is_none() { + self.direct_sink = Some((rule, detail.into())); + } + } + + fn inspect_path_call(&mut self, node: &ExprCall, path: &ExprPath) { + let canonical = canonical_path(&path.path, self.aliases); + let joined = canonical.join("::"); + let last = canonical.last().cloned().unwrap_or_default(); + self.calls.insert(last); + if (joined.ends_with("reqwest::Client::new") + || joined.ends_with("reqwest::Client::builder") + || joined.ends_with("reqwest::Request::new") + || joined.ends_with("reqwest::RequestBuilder::new")) + && self.direct_sink.is_none() + { + self.sink("network-constructor", joined); + } else if (joined.ends_with("Url::parse") || joined.ends_with("Url::join")) + && self.direct_sink.is_none() + { + self.sink("url-construction", joined); + } else if joined.ends_with("std::process::Command::new") + || joined.ends_with("process::Command::new") + || joined == "Command::new" + { + if node + .args + .first() + .and_then(literal) + .as_deref() + .is_some_and(|value| LEGACY_EXECUTABLES.contains(&value)) + || node + .args + .first() + .is_some_and(|argument| contains_env_call(argument, self.aliases)) + { + self.sink("sol-executable", joined); + } + } else if (joined.ends_with("std::env::var") + || joined.ends_with("std::env::var_os") + || joined.ends_with("env::var") + || joined.ends_with("env::var_os")) + && node + .args + .first() + .and_then(literal) + .and_then(|value| forbidden_literal(&value)) + .is_some() + { + self.sink("legacy-environment", joined); + } + for argument in &node.args { + if let Some(value) = literal(argument) + && let Some(token) = forbidden_literal(&value) + { + self.sink("legacy-authority", token); } - offset = end.max(start + needle.len()); } } - findings } -#[test] -fn active_production_paths_have_no_legacy_direct_authority() { - let root = source_root(); - let forbidden = [".bearer_auth(", "localhost:5015", "Command::new(\"sol\")"]; - - for path in rust_sources(&root) { - if path - .file_name() - .is_some_and(|name| name == "chat_bridge.rs") +impl<'ast> Visit<'ast> for FunctionScanner<'_> { + fn visit_expr_call(&mut self, node: &'ast ExprCall) { + if let Expr::Path(path) = &*node.func { + self.inspect_path_call(node, path); + } + visit::visit_expr_call(self, node); + } + + fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) { + let method = node.method.to_string(); + if method == "bearer_auth" { + self.sink("caller-owned-auth", method.clone()); + } + if method == "header" + && node.args.first().and_then(literal).is_some_and(|header| { + RESERVED_HEADERS + .iter() + .any(|reserved| header.eq_ignore_ascii_case(reserved)) + }) { - continue; - } - let source = fs::read_to_string(&path).unwrap(); - let lines = production_prefix(&source).lines().collect::>(); - let l3_marked = marked_items(&lines, L3_MARKER); - let test_only = marked_items(&lines, "#[cfg(test)]"); - for byte in origin_relative_constructions(production_prefix(&source)) { - let line = production_prefix(&source)[..byte] - .lines() - .count() - .saturating_sub(1); - assert!( - l3_marked[line] || test_only[line], - "{}:{} constructs an origin-relative Journal URL", - path.display(), - line + 1 - ); - } - for (index, line) in lines.iter().enumerate() { - for needle in forbidden { - assert!( - !line.contains(needle) || l3_marked[index] || test_only[index], - "{}:{} contains active legacy authority `{needle}`", - path.display(), - index + 1 - ); + self.sink("reserved-header", method.clone()); + } + if matches!(method.as_str(), "headers_mut" | "query") + && self + .pushed_literals + .to_ascii_lowercase() + .contains("authorization") + { + self.sink("generic-request-escape", method.clone()); + } + if method == "push_str" + && let Some(value) = node.args.first().and_then(literal) + { + self.pushed_literals.push_str(&value); + if let Some(token) = forbidden_literal(&self.pushed_literals) { + self.sink("split-literal-authority", token); } - for needle in [".server_url", "config.key"] { - assert!( - !line.contains(needle) || l3_marked[index] || test_only[index], - "{}:{} reads configured legacy authority `{needle}`", - path.display(), - index + 1 - ); + } + for argument in &node.args { + if let Some(value) = literal(argument) + && let Some(token) = forbidden_literal(&value) + { + self.sink("legacy-authority", token); } } + visit::visit_expr_method_call(self, node); } + + fn visit_macro(&mut self, node: &'ast Macro) { + let value = literal_tokens(node.tokens.clone()); + if let Some(token) = forbidden_literal(&value) { + self.sink("split-literal-authority", token); + } + visit::visit_macro(self, node); + } +} + +struct FileScanner<'a> { + node: &'a SourceNode, + aliases: BTreeMap>, + module_test: Vec, + facts: Vec, +} + +impl<'a> FileScanner<'a> { + fn new(node: &'a SourceNode) -> Self { + let mut aliases = BTreeMap::new(); + for item in &node.syntax.items { + if let syn::Item::Use(item) = item { + flatten_use(&item.tree, Vec::new(), &mut aliases); + } + } + Self { + node, + aliases, + module_test: vec![node.identity.test_only], + facts: Vec::new(), + } + } + + fn record_function(&mut self, signature: &Signature, attributes: &[Attribute], block: &Block) { + let test_only = *self.module_test.last().unwrap() || attributes_are_test_only(attributes); + let mut scanner = FunctionScanner { + aliases: &self.aliases, + direct_sink: None, + calls: BTreeSet::new(), + pushed_literals: String::new(), + }; + scanner.visit_block(block); + let mut identity = self.node.identity.clone(); + identity.item = Some(signature.ident.to_string()); + identity.test_only = test_only; + self.facts.push(FunctionFact { + identity, + path: self.node.path.clone(), + name: signature.ident.to_string(), + test_only, + direct_sink: scanner.direct_sink, + calls: scanner.calls, + }); + } +} + +impl<'ast> Visit<'ast> for FileScanner<'_> { + fn visit_item_mod(&mut self, node: &'ast ItemMod) { + let inherited = *self.module_test.last().unwrap(); + self.module_test + .push(inherited || attributes_are_test_only(&node.attrs)); + visit::visit_item_mod(self, node); + self.module_test.pop(); + } + + fn visit_item_fn(&mut self, node: &'ast ItemFn) { + self.record_function(&node.sig, &node.attrs, &node.block); + } + + fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) { + self.record_function(&node.sig, &node.attrs, &node.block); + } + + fn visit_trait_item_fn(&mut self, node: &'ast TraitItemFn) { + if let Some(block) = &node.default { + self.record_function(&node.sig, &node.attrs, block); + } + } +} + +fn permitted_capability(fact: &FunctionFact) -> bool { + fact.path + .ends_with("crates/solstone-linux/src/private_link.rs") + && matches!( + fact.name.as_str(), + "start_private_link_session_with_options" + | "start_private_link_session_inner" + | "confine_path" + | "ensure_registered_inner" + | "register" + | "send" + | "request" + ) +} + +fn analyze_inventory(inventory: &SourceInventory) -> Result<(), PolicyError> { + let mut facts = Vec::new(); + for node in &inventory.nodes { + if node + .syntax + .items + .iter() + .any(|item| matches!(item, syn::Item::Mod(module) if module.ident == "chat_bridge")) + { + return Err(PolicyError { + identity: node.identity.to_string(), + path: node.path.clone(), + rule: "deleted-module", + detail: "chat_bridge".to_owned(), + }); + } + let mut scanner = FileScanner::new(node); + scanner.visit_file(&node.syntax); + facts.extend(scanner.facts); + } + let key = |fact: &FunctionFact| (fact.path.clone(), fact.name.clone(), fact.test_only); + let mut sink_keys = facts + .iter() + .filter(|fact| fact.direct_sink.is_some() && !fact.test_only && !permitted_capability(fact)) + .map(key) + .collect::>(); + loop { + let newly = facts + .iter() + .filter(|fact| { + !fact.test_only + && !permitted_capability(fact) + && fact.calls.iter().any(|callee| { + let local = (fact.path.clone(), callee.clone(), fact.test_only); + if sink_keys.contains(&local) { + return true; + } + let matches = facts + .iter() + .filter(|candidate| { + candidate.name == *callee + && (!fact.test_only || candidate.test_only == fact.test_only) + }) + .collect::>(); + matches.len() == 1 && sink_keys.contains(&key(matches[0])) + }) + }) + .map(key) + .collect::>(); + let before = sink_keys.len(); + sink_keys.extend(newly); + if sink_keys.len() == before { + break; + } + } + if let Some(fact) = facts.iter().find(|fact| { + !fact.test_only && !permitted_capability(fact) && sink_keys.contains(&key(fact)) + }) { + let (rule, detail) = fact + .direct_sink + .clone() + .unwrap_or(("sink-call-graph", fact.name.clone())); + return Err(PolicyError { + identity: fact.identity.to_string(), + path: fact.path.clone(), + rule, + detail, + }); + } + Ok(()) +} + +fn current_inventory() -> SourceInventory { + let mut inventory = scan_workspace_with_command( + &workspace_root(), + &CargoCommand { + program: command_path("cargo"), + prefix: Vec::new(), + }, + ) + .unwrap(); + inventory + .nodes + .retain(|node| node.identity.package == "solstone-linux"); + inventory } #[test] -fn origin_construction_scan_handles_split_invocations_and_routes() { - for source in [ - "format!(\n\"{origin}/app/observer\"\n)", - "format!(\n\"{}{}\",\norigin,\n\"/app/observer\"\n)", - "origin\n.join(\n\"/app/observer\"\n)", - ] { - assert_eq!(origin_relative_constructions(source).len(), 1); +fn linked_authority_is_confined_to_the_typed_capability() { + analyze_inventory(¤t_inventory()).unwrap(); +} + +fn mutation(source: &str, test_only: bool) -> SourceInventory { + let syntax = syn::parse_file(source).unwrap(); + SourceInventory { + nodes: vec![SourceNode { + identity: SourceIdentity { + package: "fixture".to_owned(), + target: "fixture".to_owned(), + target_kind: "bin".to_owned(), + module: Vec::new(), + item: None, + cfg_context: Vec::new(), + test_only, + }, + path: PathBuf::from("src/main.rs"), + syntax, + }], + data_inputs: Vec::new(), + } +} + +fn fixture_identity() -> SourceIdentity { + SourceIdentity { + package: "fixture".to_owned(), + target: "fixture".to_owned(), + target_kind: "bin".to_owned(), + module: Vec::new(), + item: None, + cfg_context: Vec::new(), + test_only: false, } } +fn assert_mutation(source: &str, rule: &str) { + let error = analyze_inventory(&mutation(source, false)).unwrap_err(); + assert_eq!(error.rule, rule); + assert!(error.identity.contains("fixture::fixture[bin]")); + assert!(error.to_string().contains("identity=fixture::fixture[bin]")); +} + +#[test] +fn linked_authority_mutation_aliases_are_semantic() { + assert_mutation( + "use reqwest::Client as Renamed; fn moved() { let _ = Renamed::builder(); }", + "network-constructor", + ); + assert_mutation( + "use std::process::Command as Renamed; fn moved() { let _ = Renamed::new(\"sol\"); }", + "sol-executable", + ); + assert_mutation( + "fn moved(r: reqwest::RequestBuilder) { let _ = r.header(\"authorization\", \"x\"); }", + "reserved-header", + ); +} + #[test] -fn l3_authority_is_confined_to_explicit_unreachable_surfaces() { - let root = source_root(); - for module in ["chat_bridge.rs", "config.rs"] { - let source = fs::read_to_string(root.join(module)).unwrap(); - assert!(source.contains(L3_MARKER), "{module} lacks the L3 marker"); +fn linked_authority_mutation_split_literal_is_folded() { + assert_mutation( + "fn moved() { let _ = concat!(\"local\", \"host:5015\"); }", + "split-literal-authority", + ); +} + +#[test] +fn linked_authority_mutation_env_derived_executable_is_caught() { + assert_mutation( + "fn moved() { let _ = std::process::Command::new(std::env::var(\"TOOL\").unwrap()); }", + "sol-executable", + ); +} + +#[test] +fn linked_authority_mutation_renamed_helper_reaches_fixpoint() { + assert_mutation( + "fn renamed() { let _ = reqwest::Client::builder(); } fn caller() { renamed(); }", + "network-constructor", + ); +} + +#[test] +fn linked_authority_mutation_extra_target_and_feature_are_production() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("src")).unwrap(); + fs::write(root.path().join("Cargo.toml"), "[workspace]\nmembers=[]\n").unwrap(); + let source = root.path().join("src/direct.rs"); + fs::write( + &source, + "#[cfg(feature=\"direct\")] fn feature_binary() { let _ = reqwest::Client::new(); }", + ) + .unwrap(); + let metadata = serde_json::json!({ + "workspace_root": root.path(), + "target_directory": root.path().join("target"), + "build_directory": root.path().join("target"), + "packages": [{ + "name": "fixture", + "targets": [{ + "name": "direct", + "kind": ["bin"], + "src_path": source, + }] + }] + }); + let script = root.path().join("metadata.sh"); + fs::write(&script, format!("printf '%s' '{}'\n", metadata)).unwrap(); + let inventory = scan_workspace_with_command( + root.path(), + &CargoCommand { + program: command_path("bash"), + prefix: vec![OsString::from(script)], + }, + ) + .unwrap(); + let error = analyze_inventory(&inventory).unwrap_err(); + assert_eq!(error.rule, "network-constructor"); + assert!(error.identity.contains("fixture::direct[bin]")); +} + +#[test] +fn linked_authority_mutation_path_include_and_generated_inputs_name_identity() { + for declaration in [ + "#[path=\"direct.rs\"] mod renamed;", + "include!(\"direct.rs\");", + ] { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("src")).unwrap(); + let main = root.path().join("src/main.rs"); + fs::write(&main, declaration).unwrap(); + fs::write( + root.path().join("src/direct.rs"), + "fn renamed_helper() { let _ = reqwest::Client::new(); }", + ) + .unwrap(); + let mut inventory = SourceInventory::default(); + walk_member(root.path(), &main, fixture_identity(), &mut inventory).unwrap(); + let error = analyze_inventory(&inventory).unwrap_err(); + assert_eq!(error.rule, "network-constructor"); + assert!(error.identity.contains("fixture::fixture[bin]")); } + + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("src")).unwrap(); + let main = root.path().join("src/main.rs"); + fs::write( + &main, + "include!(concat!(env!(\"OUT_DIR\"), \"/direct.rs\"));", + ) + .unwrap(); + let mut inventory = SourceInventory::default(); + let error = walk_member(root.path(), &main, fixture_identity(), &mut inventory).unwrap_err(); + assert_eq!(error.cause, ScanErrorCause::UnclassifiableInput); + assert!(error.identity.contains("fixture::fixture[bin]")); +} + +#[test] +fn linked_authority_test_context_is_ancestry_based() { + analyze_inventory(&mutation( + "#[cfg(test)] mod peer { fn request() { let _ = reqwest::Client::new(); } }", + false, + )) + .unwrap(); + analyze_inventory(&mutation( + "#[test] fn request() { let _ = reqwest::Client::new(); }", + false, + )) + .unwrap(); + analyze_inventory(&mutation( + "fn request() { let _ = reqwest::Client::new(); }", + true, + )) + .unwrap(); } diff --git a/crates/solstone-linux/src/observer_contract_tests.rs b/crates/solstone-linux/src/observer_contract_tests.rs index 90e0c8c..f7562ab 100644 --- a/crates/solstone-linux/src/observer_contract_tests.rs +++ b/crates/solstone-linux/src/observer_contract_tests.rs @@ -2,20 +2,14 @@ // Copyright (c) 2026 sol pbc use crate::{ - chat_bridge::{ - EVENT_SOL_CHAT_REQUEST, ack_contract_request, consume_contract_body, contract_poll_opt_in, - dispatch_contract_payload, parse_contract_sse, - }, config::Config, private_link::{PrivateLinkOwner, start_registered_private_link_for_test}, private_link_test_peer::{PeerRequest, PrivateLinkPeer}, sync::{contract_segment_proven_held, contract_sha256_file}, sync_health::ErrorType, - test_support::{Action, MockServer, wait_for_requests}, upload::{ListingEntry, UploadClient, contract_parse_listing}, }; -use reqwest::Client; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -24,7 +18,6 @@ use std::{ path::{Component, Path, PathBuf}, }; use tempfile::TempDir; -use tokio_util::sync::CancellationToken; const MANIFEST_SHA256: &str = "9ecf4bbfcd793a8aecc9e2257254e68c74c48cde22282ff07369101b90d97c33"; const AUTHORITY_COMMIT: &str = "827d3761e2b515b9bd537ded28b245c8c6d86cc0"; @@ -73,9 +66,6 @@ const LINUX_FIXTURES: &[&str] = &[ "declared.observer.ingestSegments.custody_unknown_rejected", "declared.observer.ingestSegments.envelope_total_mismatch", "declared.observer.ingestUpload.status_unknown_rejected", - "example.chat.openSolChatRequest.request.body.application-json.default", - "example.chat.openSolChatRequest.response.200.application-json.default", - "example.observer.callosumStream.response.200.text-event-stream.default", "example.observer.ingestEvent.request.body.application-json.default", "example.observer.ingestEvent.response.200.application-json.default", "example.observer.ingestSegments.response.200.application-json.legacy", @@ -87,8 +77,6 @@ const LINUX_FIXTURES: &[&str] = &[ "example.observer.register.response.200.application-json.default", "recorded.auth.bearer.segments", "recorded.auth.handle.segments", - "recorded.chat.openSolChatRequest.missing", - "recorded.chat.openSolChatRequest.ok", "recorded.ingestUpload.collision", "recorded.ingestUpload.conflict", "recorded.ingestUpload.duplicate", @@ -99,9 +87,6 @@ const LINUX_FIXTURES: &[&str] = &[ "recorded.segments.legacy.unparseable_header", "recorded.segments.submitted_name_omitted", "recorded.segments.v2.envelope", - "recorded.sse.observer.data", - "recorded.sse.observer.error", - "recorded.sse.observer.heartbeat", ]; const FULL_VECTORS: &[&str] = &[ @@ -130,13 +115,8 @@ const FULL_VECTORS: &[&str] = &[ ]; const LINUX_VECTORS: &[&str] = &[ - "chat.openSolChatRequest.missing_required_field", - "chat.openSolChatRequest.ok", "observer.auth.bearer", "observer.auth.handle", - "observer.callosumStream.sse.data", - "observer.callosumStream.sse.error", - "observer.callosumStream.sse.heartbeat", "observer.ingestSegments.custody_statuses", "observer.ingestSegments.custody_unknown_rejected", "observer.ingestSegments.envelope_total_mismatch", @@ -861,6 +841,11 @@ async fn assert_event_and_register( (request.method.as_str(), request.path.as_str()), ("POST", "/app/observer/ingest/event") ); + assert_eq!(header(request, "content-type"), Some("application/json")); + assert_eq!( + request.body, + br#"{"event":"status","state":"recording","tract":"observe"}"# + ); assert_eq!( serde_json::from_slice::(&request.body).unwrap(), fixtures[event_id]["payload"] @@ -907,228 +892,6 @@ async fn assert_event_and_register( peer.shutdown().await; } -async fn assert_chat_contract( - fixtures: &BTreeMap, - vectors: &BTreeMap, - executed_fixtures: &mut BTreeSet, - executed_vectors: &mut BTreeSet, -) { - let example_data = "example.observer.callosumStream.response.200.text-event-stream.default"; - let wire = format!("data: {}\n\n", fixtures[example_data]["payload"]); - let frames = parse_contract_sse(wire.as_bytes()); - assert_eq!( - frames.as_slice(), - &[( - "frame".to_owned(), - None, - fixtures[example_data]["payload"].to_string() - )] - ); - record(executed_fixtures, example_data, true); - - let data_id = "recorded.sse.observer.data"; - let data_vector = "observer.callosumStream.sse.data"; - let wire = format!("data: {}\n\n", fixtures[data_id]["payload"]); - let frames = parse_contract_sse(wire.as_bytes()); - assert_eq!(frames[0].0, "frame"); - assert_eq!(frames[0].2, fixtures[data_id]["payload"].to_string()); - assert_eq!(vectors[data_vector]["fixture_id"], data_id); - assert_eq!(vectors[data_vector]["decision"]["frame_kind"], "data"); - assert_eq!( - vectors[data_vector]["decision"]["action"], - "dispatch_callosum_event" - ); - record(executed_fixtures, data_id, true); - record(executed_vectors, data_vector, true); - - let error_id = "recorded.sse.observer.error"; - let error_vector = "observer.callosumStream.sse.error"; - let wire = format!("event: error\ndata: {}\n\n", fixtures[error_id]["payload"]); - let (terminal, pending, side_effects) = consume_contract_body(wire).await; - assert!(terminal); - assert_eq!(pending, 0); - assert_eq!(side_effects, 0); - let decision = &vectors[error_vector]["decision"]; - assert_eq!(vectors[error_vector]["fixture_id"], error_id); - assert_eq!(decision["frame_kind"], "error"); - assert_eq!(decision["action"], "surface_error_and_close"); - assert_eq!( - decision["reason_code"], - fixtures[error_id]["payload"]["reason_code"] - ); - record(executed_fixtures, error_id, true); - record(executed_vectors, error_vector, true); - - let heartbeat_id = "recorded.sse.observer.heartbeat"; - let heartbeat_vector = "observer.callosumStream.sse.heartbeat"; - let raw = fixtures[heartbeat_id]["payload"] - .as_str() - .expect("heartbeat payload"); - assert_eq!(parse_contract_sse(raw.as_bytes())[0].0, "heartbeat"); - assert_eq!(vectors[heartbeat_vector]["fixture_id"], heartbeat_id); - assert_eq!( - vectors[heartbeat_vector]["decision"]["frame_kind"], - "heartbeat" - ); - assert_eq!( - vectors[heartbeat_vector]["decision"]["action"], - "ignore_keepalive" - ); - record(executed_fixtures, heartbeat_id, true); - record(executed_vectors, heartbeat_vector, true); - - let valid_id = "example.chat.openSolChatRequest.request.body.application-json.default"; - let ok_vector = "chat.openSolChatRequest.ok"; - let original_request_id = fixtures[valid_id]["payload"]["request_id"] - .as_str() - .expect("request fixture id"); - let mut valid = Map::new(); - valid.insert("tract".into(), json!("chat")); - valid.insert("event".into(), json!(EVENT_SOL_CHAT_REQUEST)); - valid.insert( - "request_id".into(), - fixtures[valid_id]["payload"]["request_id"].clone(), - ); - let (created, preserved_id) = dispatch_contract_payload(valid).await; - assert!(created); - assert_eq!(preserved_id.as_deref(), Some(original_request_id)); - assert!( - vectors[ok_vector]["decision"]["accepted"] - .as_bool() - .expect("authority chat accepted") - ); - assert_eq!( - vectors[ok_vector]["decision"]["missing_field_behavior"], - "non_empty_trimmed_request_id_required" - ); - record(executed_fixtures, valid_id, true); - record(executed_vectors, ok_vector, true); - - let missing_vector = "chat.openSolChatRequest.missing_required_field"; - let mut rejected = Vec::new(); - for id in [ - None, - Some(Value::Null), - Some(json!("")), - Some(json!(" ")), - Some(json!([])), - ] { - let mut payload = Map::new(); - payload.insert("tract".into(), json!("chat")); - payload.insert("event".into(), json!(EVENT_SOL_CHAT_REQUEST)); - if let Some(id) = id { - payload.insert("request_id".into(), id); - } - rejected.push(!dispatch_contract_payload(payload).await.0); - } - assert!(rejected.into_iter().all(|value| value)); - assert!( - !vectors[missing_vector]["decision"]["accepted"] - .as_bool() - .expect("authority chat rejected") - ); - assert_eq!( - vectors[missing_vector]["decision"]["missing_field_behavior"], - "absent_malformed_empty_or_blank_rejected" - ); - - for (response_id, vector_id) in [ - ( - "example.chat.openSolChatRequest.response.200.application-json.default", - None, - ), - ("recorded.chat.openSolChatRequest.ok", Some(ok_vector)), - ( - "recorded.chat.openSolChatRequest.missing", - Some(missing_vector), - ), - ] { - let fixture = &fixtures[response_id]; - let status = fixture["provenance"]["status"] - .as_u64() - .expect("chat response status") as u16; - let server = MockServer::new(vec![(status, fixture["payload"].clone())]).await; - ack_contract_request(Client::new(), &server.url, "K", original_request_id).await; - wait_for_requests(&server, 1).await; - let request = &server.requests()[0]; - assert_eq!( - (request.method.as_str(), request.uri.as_str()), - ("POST", "/api/chat/sol_chat_request/open") - ); - assert_eq!( - serde_json::from_slice::(&request.body).unwrap(), - json!({"request_id": original_request_id}) - ); - assert!( - request.headers["authorization"] - .to_str() - .unwrap() - .starts_with("Bearer ") - ); - if let Some(vector_id) = vector_id { - assert_eq!(vectors[vector_id]["fixture_id"], response_id); - assert_eq!( - vectors[vector_id]["observed_status"].as_u64(), - Some(status as u64) - ); - if vector_id == missing_vector { - assert_eq!( - fixtures[response_id]["payload"]["reason_code"], - vectors[vector_id]["decision"]["reason_code"] - ); - } - } - record(executed_fixtures, response_id, true); - } - record(executed_vectors, missing_vector, true); -} - -async fn assert_settings_contract() { - for (body, expected) in [ - (json!({"system_notifications":{"linux":true}}), true), - (json!({"system_notifications":{"linux":false}}), false), - (json!({}), false), - (json!({"system_notifications":{}}), false), - (json!({"system_notifications":{"linux":null}}), false), - (json!({"system_notifications":{"linux":"true"}}), false), - (json!({"linux_notify_send":true}), false), - ] { - let server = MockServer::new(vec![(200, body)]).await; - assert_eq!( - contract_poll_opt_in( - &Client::new(), - &format!("{}/", server.url), - "K", - &CancellationToken::new() - ) - .await, - expected - ); - wait_for_requests(&server, 1).await; - let request = &server.requests()[0]; - assert_eq!(request.uri, "/app/settings/api/sol_voice"); - assert!(request.headers.contains_key("authorization")); - } - let malformed = MockServer::new_actions(vec![Action::Raw(200, "{")]).await; - assert!( - !contract_poll_opt_in( - &Client::new(), - &malformed.url, - "K", - &CancellationToken::new() - ) - .await - ); - let failure = - MockServer::new(vec![(500, json!({"system_notifications":{"linux":true}}))]).await; - assert!( - !contract_poll_opt_in(&Client::new(), &failure.url, "K", &CancellationToken::new()).await - ); - let stop = CancellationToken::new(); - stop.cancel(); - assert!(!contract_poll_opt_in(&Client::new(), "http://127.0.0.1:9", "K", &stop).await); -} - fn copy_tree(source: &Path, destination: &Path) { fs::create_dir_all(destination).unwrap(); for entry in fs::read_dir(source).unwrap() { @@ -1285,8 +1048,8 @@ async fn observer_contract_conformance() { let fixtures = load_index(&bundle.join("fixtures/wire-behavior.json"), "fixtures"); let vectors = load_index(&bundle.join("vectors.json"), "vectors"); assert_identities(&manifest, &fixtures, &vectors); - assert_eq!(set(LINUX_FIXTURES).len(), 32); - assert_eq!(set(LINUX_VECTORS).len(), 20); + assert_eq!(set(LINUX_FIXTURES).len(), 24); + assert_eq!(set(LINUX_VECTORS).len(), 15); let mut executed_fixtures = BTreeSet::new(); let mut executed_vectors = BTreeSet::new(); assert_upload_contract( @@ -1304,14 +1067,6 @@ async fn observer_contract_conformance() { ) .await; assert_event_and_register(&fixtures, &mut executed_fixtures).await; - assert_chat_contract( - &fixtures, - &vectors, - &mut executed_fixtures, - &mut executed_vectors, - ) - .await; - assert_settings_contract().await; assert_mutations( &bundle, &root.join("contracts/observer-client-import.json"), diff --git a/crates/solstone-linux/src/policy_test_support/authority_vocabulary.rs b/crates/solstone-linux/src/policy_test_support/authority_vocabulary.rs new file mode 100644 index 0000000..4daaea2 --- /dev/null +++ b/crates/solstone-linux/src/policy_test_support/authority_vocabulary.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +pub(crate) const LEGACY_ENVIRONMENT: &[&str] = &["SOLSTONE_TOKEN"]; +pub(crate) const LEGACY_OPTIONS: &[&str] = &["--server-url", "--token"]; +pub(crate) const LEGACY_ORIGINS: &[&str] = &["localhost:5015", "127.0.0.1:5015", "[::1]:5015"]; +pub(crate) const LEGACY_COMMANDS: &[&str] = &["journal observer create"]; +pub(crate) const LEGACY_EXECUTABLES: &[&str] = &["sol"]; +pub(crate) const PYTHON_SETUP: &[&str] = &[ + "python -m", + "python3 -m", + "pip install", + "pip3 install", + "pipx install", +]; diff --git a/crates/solstone-linux/src/policy_test_support/mod.rs b/crates/solstone-linux/src/policy_test_support/mod.rs new file mode 100644 index 0000000..9d4ee28 --- /dev/null +++ b/crates/solstone-linux/src/policy_test_support/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +pub(crate) mod authority_vocabulary; +pub(crate) mod source_inventory; diff --git a/crates/solstone-linux/src/policy_test_support/source_inventory.rs b/crates/solstone-linux/src/policy_test_support/source_inventory.rs new file mode 100644 index 0000000..426d54b --- /dev/null +++ b/crates/solstone-linux/src/policy_test_support/source_inventory.rs @@ -0,0 +1,701 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::{ + collections::BTreeSet, + ffi::OsString, + fmt, fs, + path::{Component, Path, PathBuf}, + process::Command, +}; +use syn::{ + Attribute, Expr, Item, ItemMod, Lit, Meta, + visit::{self, Visit}, +}; + +#[derive(Clone, Debug)] +pub(crate) struct CargoCommand { + pub(crate) program: PathBuf, + pub(crate) prefix: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SourceIdentity { + pub(crate) package: String, + pub(crate) target: String, + pub(crate) target_kind: String, + pub(crate) module: Vec, + pub(crate) item: Option, + pub(crate) cfg_context: Vec, + pub(crate) test_only: bool, +} + +impl fmt::Display for SourceIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let module = if self.module.is_empty() { + "crate".to_owned() + } else { + self.module.join("::") + }; + let item = self.item.as_deref().unwrap_or(""); + let cfg = if self.cfg_context.is_empty() { + "production".to_owned() + } else { + self.cfg_context.join("&") + }; + write!( + formatter, + "{}::{}[{}]::{}::{}{{{}}}", + self.package, self.target, self.target_kind, module, item, cfg + ) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct SourceNode { + pub(crate) identity: SourceIdentity, + pub(crate) path: PathBuf, + pub(crate) syntax: syn::File, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SourceInventory { + pub(crate) nodes: Vec, + pub(crate) data_inputs: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ScanErrorCause { + MetadataExecution, + MetadataExit, + MetadataJson, + MetadataWorkspaceRootMismatch, + Read, + NonUtf8, + RustParse, + Walk, + SymlinkFile, + SymlinkDirectory, + InvalidPathAttribute, + UnapprovedInclude, + UnclassifiableInput, +} + +#[derive(Clone, Debug)] +pub(crate) struct ScanError { + pub(crate) identity: String, + pub(crate) path: PathBuf, + pub(crate) cause: ScanErrorCause, + pub(crate) detail: String, +} + +impl ScanError { + pub(crate) fn new( + identity: impl Into, + path: impl Into, + cause: ScanErrorCause, + detail: impl Into, + ) -> Self { + Self { + identity: identity.into(), + path: path.into(), + cause, + detail: detail.into(), + } + } +} + +impl fmt::Display for ScanError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "source policy: identity={} path={} rule={:?} detail={}", + self.identity, + self.path.display(), + self.cause, + self.detail + ) + } +} + +impl std::error::Error for ScanError {} + +pub(crate) fn normalize_absolute(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let mut result = PathBuf::from("/"); + for component in path.components() { + match component { + Component::RootDir | Component::CurDir => {} + Component::Normal(value) => result.push(value), + Component::ParentDir => { + if !result.pop() { + return None; + } + } + Component::Prefix(_) => return None, + } + } + Some(result) +} + +pub(crate) fn metadata_roots_with_command( + root: &Path, + cargo: &CargoCommand, +) -> Result { + let output = Command::new(&cargo.program) + .args(&cargo.prefix) + .args([ + "metadata", + "--locked", + "--offline", + "--format-version", + "1", + "--no-deps", + "--manifest-path", + ]) + .arg(root.join("Cargo.toml")) + .current_dir(root) + .output() + .map_err(|error| { + ScanError::new( + "workspace::metadata", + &cargo.program, + ScanErrorCause::MetadataExecution, + error.to_string(), + ) + })?; + if !output.status.success() { + return Err(ScanError::new( + "workspace::metadata", + root, + ScanErrorCause::MetadataExit, + String::from_utf8_lossy(&output.stderr), + )); + } + let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|error| { + ScanError::new( + "workspace::metadata", + root, + ScanErrorCause::MetadataJson, + error.to_string(), + ) + })?; + let reported = metadata["workspace_root"] + .as_str() + .and_then(|value| normalize_absolute(Path::new(value))) + .ok_or_else(|| { + ScanError::new( + "workspace::metadata", + root, + ScanErrorCause::MetadataWorkspaceRootMismatch, + "missing absolute workspace_root", + ) + })?; + if normalize_absolute(root).as_deref() != Some(reported.as_path()) { + return Err(ScanError::new( + "workspace::metadata", + reported, + ScanErrorCause::MetadataWorkspaceRootMismatch, + format!("expected {}", root.display()), + )); + } + Ok(metadata) +} + +pub(crate) fn scan_workspace_with_command( + root: &Path, + cargo: &CargoCommand, +) -> Result { + let metadata = metadata_roots_with_command(root, cargo)?; + let mut inventory = SourceInventory::default(); + let mut visited = BTreeSet::new(); + for package in metadata["packages"].as_array().ok_or_else(|| { + ScanError::new( + "workspace::metadata", + root, + ScanErrorCause::MetadataJson, + "packages is not an array", + ) + })? { + let package_name = package["name"].as_str().ok_or_else(|| { + ScanError::new( + "workspace::metadata", + root, + ScanErrorCause::MetadataJson, + "package name is missing", + ) + })?; + for target in package["targets"].as_array().ok_or_else(|| { + ScanError::new( + package_name, + root, + ScanErrorCause::MetadataJson, + "targets is not an array", + ) + })? { + let target_name = target["name"].as_str().unwrap_or(""); + let kinds = target["kind"] + .as_array() + .ok_or_else(|| { + ScanError::new( + package_name, + root, + ScanErrorCause::MetadataJson, + "target kind is missing", + ) + })? + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + let target_kind = kinds.join("+"); + let test_only = kinds + .iter() + .all(|kind| matches!(*kind, "test" | "bench" | "example")); + let source = PathBuf::from(target["src_path"].as_str().ok_or_else(|| { + ScanError::new( + package_name, + root, + ScanErrorCause::MetadataJson, + "target src_path is missing", + ) + })?); + let identity = SourceIdentity { + package: package_name.to_owned(), + target: target_name.to_owned(), + target_kind, + module: Vec::new(), + item: None, + cfg_context: Vec::new(), + test_only, + }; + scan_rust_file(root, &source, identity, &mut inventory, &mut visited)?; + } + } + inventory + .nodes + .sort_by_key(|node| node.identity.to_string()); + Ok(inventory) +} + +pub(crate) fn walk_member( + root: &Path, + member: &Path, + identity: SourceIdentity, + inventory: &mut SourceInventory, +) -> Result<(), ScanError> { + let metadata = fs::symlink_metadata(member).map_err(|error| { + ScanError::new( + identity.to_string(), + member, + ScanErrorCause::Walk, + error.to_string(), + ) + })?; + if metadata.file_type().is_symlink() { + let directory = fs::metadata(member).is_ok_and(|target| target.is_dir()); + return Err(ScanError::new( + identity.to_string(), + member, + if directory { + ScanErrorCause::SymlinkDirectory + } else { + ScanErrorCause::SymlinkFile + }, + "source inputs may not be symlinks", + )); + } + let mut visited = BTreeSet::new(); + scan_rust_file(root, member, identity, inventory, &mut visited) +} + +pub(crate) fn scan_rust_file( + root: &Path, + path: &Path, + identity: SourceIdentity, + inventory: &mut SourceInventory, + visited: &mut BTreeSet<(PathBuf, String)>, +) -> Result<(), ScanError> { + let path = normalize_absolute(path).ok_or_else(|| { + ScanError::new( + identity.to_string(), + path, + ScanErrorCause::Walk, + "path is not lexically valid", + ) + })?; + if !visited.insert((path.clone(), identity.to_string())) { + return Ok(()); + } + let metadata = fs::symlink_metadata(&path).map_err(|error| { + ScanError::new( + identity.to_string(), + &path, + ScanErrorCause::Read, + error.to_string(), + ) + })?; + if metadata.file_type().is_symlink() { + return Err(ScanError::new( + identity.to_string(), + &path, + ScanErrorCause::SymlinkFile, + "Rust source is a symlink", + )); + } + let bytes = fs::read(&path).map_err(|error| { + ScanError::new( + identity.to_string(), + &path, + ScanErrorCause::Read, + error.to_string(), + ) + })?; + let source = String::from_utf8(bytes).map_err(|error| { + ScanError::new( + identity.to_string(), + &path, + ScanErrorCause::NonUtf8, + error.to_string(), + ) + })?; + let syntax = syn::parse_file(&source).map_err(|error| { + ScanError::new( + identity.to_string(), + &path, + ScanErrorCause::RustParse, + error.to_string(), + ) + })?; + inventory.nodes.push(SourceNode { + identity: identity.clone(), + path: path.clone(), + syntax: syntax.clone(), + }); + scan_items(root, &path, &syntax.items, identity, inventory, visited) +} + +fn scan_items( + root: &Path, + source_path: &Path, + items: &[Item], + identity: SourceIdentity, + inventory: &mut SourceInventory, + visited: &mut BTreeSet<(PathBuf, String)>, +) -> Result<(), ScanError> { + for item in items { + if let Item::Mod(module) = item { + let mut child = identity.clone(); + child.module.push(module.ident.to_string()); + inherit_context(&module.attrs, &mut child); + if let Some((_, items)) = &module.content { + scan_items(root, source_path, items, child, inventory, visited)?; + } else { + let path = module_path(source_path, module, &child)?; + scan_rust_file(root, &path, child, inventory, visited)?; + } + } + } + let mut includes = IncludeVisitor::default(); + includes.visit_file(&syn::File { + shebang: None, + attrs: Vec::new(), + items: items.to_vec(), + }); + for include in includes.inputs { + match include { + IncludeInput::Data(relative) => { + let path = source_path.parent().unwrap_or(root).join(relative); + inventory.data_inputs.push(path); + } + IncludeInput::Rust(relative) => { + let path = source_path.parent().unwrap_or(root).join(relative); + scan_rust_file(root, &path, identity.clone(), inventory, visited)?; + } + IncludeInput::GeneratedTray => { + if !approved_include(source_path, &identity) { + return Err(ScanError::new( + identity.to_string(), + source_path, + ScanErrorCause::UnapprovedInclude, + "generated include is not the tray icon input", + )); + } + } + IncludeInput::Unclassifiable(detail) => { + return Err(ScanError::new( + identity.to_string(), + source_path, + ScanErrorCause::UnclassifiableInput, + detail, + )); + } + } + } + Ok(()) +} + +fn inherit_context(attributes: &[Attribute], identity: &mut SourceIdentity) { + for attribute in attributes { + let rendered = quote_attribute(attribute); + if rendered.contains("cfg") { + identity.cfg_context.push(rendered.clone()); + } + if rendered.contains("test") { + identity.test_only = true; + } + } +} + +fn quote_attribute(attribute: &Attribute) -> String { + match &attribute.meta { + Meta::Path(path) => path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"), + Meta::List(list) => format!( + "{}({})", + list.path.segments.last().unwrap().ident, + list.tokens + ), + Meta::NameValue(value) => value.path.segments.last().unwrap().ident.to_string(), + } +} + +pub(crate) fn module_identity(module: &ItemMod) -> String { + let form = if module.content.is_some() { + "inline" + } else { + "external" + }; + format!("{}[{form}]", module.ident) +} + +fn module_path( + source_path: &Path, + module: &ItemMod, + identity: &SourceIdentity, +) -> Result { + for attribute in &module.attrs { + if attribute.path().is_ident("path") { + let Meta::NameValue(value) = &attribute.meta else { + return Err(ScanError::new( + identity.to_string(), + source_path, + ScanErrorCause::InvalidPathAttribute, + "#[path] must be a literal name-value attribute", + )); + }; + let Expr::Lit(literal) = &value.value else { + return Err(ScanError::new( + identity.to_string(), + source_path, + ScanErrorCause::InvalidPathAttribute, + "#[path] value must be literal", + )); + }; + let Lit::Str(path) = &literal.lit else { + return Err(ScanError::new( + identity.to_string(), + source_path, + ScanErrorCause::InvalidPathAttribute, + "#[path] value must be a string", + )); + }; + return Ok(source_path.parent().unwrap().join(path.value())); + } + } + let parent = source_path.parent().unwrap(); + let stem = source_path.file_stem().and_then(|value| value.to_str()); + let module_root = if matches!(stem, Some("lib" | "main" | "mod")) { + parent.to_owned() + } else { + parent.join(stem.unwrap_or_default()) + }; + let file = module_root.join(format!("{}.rs", module.ident)); + let directory = module_root.join(module.ident.to_string()).join("mod.rs"); + match (file.is_file(), directory.is_file()) { + (true, false) => Ok(file), + (false, true) => Ok(directory), + _ => Err(ScanError::new( + identity.to_string(), + source_path, + ScanErrorCause::UnclassifiableInput, + format!("module {} has missing or ambiguous source", module.ident), + )), + } +} + +pub(crate) fn nested_under_src(member_root: &Path, path: &Path) -> bool { + path.strip_prefix(member_root).is_ok_and(|relative| { + relative.components().next().is_some_and(|component| { + component.as_os_str() == "src" && relative.components().count() > 2 + }) + }) +} + +pub(crate) fn approved_include(path: &Path, identity: &SourceIdentity) -> bool { + path.ends_with("crates/solstone-linux/src/tray.rs") + && identity + .module + .last() + .is_some_and(|module| module == "generated") +} + +#[derive(Default)] +struct IncludeVisitor { + inputs: Vec, +} + +enum IncludeInput { + Rust(String), + Data(String), + GeneratedTray, + Unclassifiable(String), +} + +impl<'ast> Visit<'ast> for IncludeVisitor { + fn visit_item_mod(&mut self, _node: &'ast ItemMod) {} + + fn visit_macro(&mut self, node: &'ast syn::Macro) { + let name = node + .path + .segments + .last() + .map(|segment| segment.ident.to_string()); + match name.as_deref() { + Some("include") => { + if let Ok(path) = syn::parse2::(node.tokens.clone()) { + self.inputs.push(IncludeInput::Rust(path.value())); + } else if node.tokens.to_string() + == "concat ! (env ! (\"OUT_DIR\") , \"/tray_icons.rs\")" + { + self.inputs.push(IncludeInput::GeneratedTray); + } else { + self.inputs.push(IncludeInput::Unclassifiable( + "include! source is not a literal".to_owned(), + )); + } + } + Some("include_str" | "include_bytes") => { + if let Ok(path) = syn::parse2::(node.tokens.clone()) { + self.inputs.push(IncludeInput::Data(path.value())); + } else { + self.inputs.push(IncludeInput::Unclassifiable( + "include data source is not a literal".to_owned(), + )); + } + } + _ => {} + } + visit::visit_macro(self, node); + } +} + +pub(crate) struct ItemVisitor { + callback: F, +} + +impl ItemVisitor { + pub(crate) fn new(callback: F) -> Self { + Self { callback } + } +} + +impl<'ast, F: FnMut(&'ast Item)> Visit<'ast> for ItemVisitor { + fn visit_item(&mut self, node: &'ast Item) { + (self.callback)(node); + visit::visit_item(self, node); + } + + fn visit_macro(&mut self, node: &'ast syn::Macro) { + visit::visit_macro(self, node); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::release_rail_tests::{command_path, workspace_root}; + use syn::visit::Visit; + + #[test] + fn current_workspace_inventory_is_closed_and_names_test_context() { + let root = workspace_root(); + let inventory = scan_workspace_with_command( + &root, + &CargoCommand { + program: command_path("cargo"), + prefix: Vec::new(), + }, + ) + .unwrap(); + assert!(inventory.nodes.iter().any(|node| { + node.path.ends_with("private_link_test_peer.rs") && node.identity.test_only + })); + assert!( + inventory + .nodes + .iter() + .any(|node| { node.path.ends_with("private_link.rs") && !node.identity.test_only }) + ); + assert!( + inventory + .data_inputs + .iter() + .any(|path| path.ends_with("cli.rs")) + ); + } + + #[test] + fn diagnostics_name_identity_and_path() { + let error = ScanError::new( + "pkg::target[bin]::crate::item{production}", + "src/main.rs", + ScanErrorCause::UnclassifiableInput, + "dynamic include", + ); + assert_eq!( + error.to_string(), + "source policy: identity=pkg::target[bin]::crate::item{production} path=src/main.rs rule=UnclassifiableInput detail=dynamic include" + ); + } + + #[test] + fn exported_walk_module_and_item_helpers_preserve_identity() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("src"); + fs::create_dir(&source).unwrap(); + fs::create_dir(source.join("nested")).unwrap(); + let path = source.join("nested/deep.rs"); + fs::write(&path, "mod inline { fn item() {} }\n").unwrap(); + let identity = SourceIdentity { + package: "fixture".to_owned(), + target: "fixture".to_owned(), + target_kind: "lib".to_owned(), + module: Vec::new(), + item: None, + cfg_context: Vec::new(), + test_only: false, + }; + let mut inventory = SourceInventory::default(); + walk_member(root.path(), &path, identity, &mut inventory).unwrap(); + assert!(nested_under_src(root.path(), &path)); + let module = inventory.nodes[0] + .syntax + .items + .iter() + .find_map(|item| match item { + Item::Mod(module) => Some(module), + _ => None, + }) + .unwrap(); + assert_eq!(module_identity(module), "inline[inline]"); + let mut count = 0; + ItemVisitor::new(|_: &Item| count += 1).visit_file(&inventory.nodes[0].syntax); + assert!(count >= 2); + } +} diff --git a/crates/solstone-linux/src/private_link.rs b/crates/solstone-linux/src/private_link.rs index 42a667e..49cedb7 100644 --- a/crates/solstone-linux/src/private_link.rs +++ b/crates/solstone-linux/src/private_link.rs @@ -235,6 +235,20 @@ pub(crate) struct PrivateStateLock { canonical_root: PathBuf, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PrivateStateLockLiveness { + LiveOwner, + NoLiveOwner, +} + +#[derive(Debug)] +pub(crate) enum PrivateStateProbeError { + InvalidTarget, + Inspect, + LocksUnavailable, + LocksMalformed, +} + impl Drop for PrivateStateLock { fn drop(&mut self) { let _ = rustix::fs::flock(&self._file, rustix::fs::FlockOperation::Unlock); @@ -242,6 +256,50 @@ impl Drop for PrivateStateLock { } impl PrivateStateLock { + pub(crate) fn try_probe( + config_root: &Path, + ) -> Result { + let root = match rustix::fs::openat( + rustix::fs::CWD, + config_root, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::CLOEXEC + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::DIRECTORY, + rustix::fs::Mode::empty(), + ) { + Ok(root) => root, + Err(rustix::io::Errno::NOENT) => { + return Ok(PrivateStateLockLiveness::NoLiveOwner); + } + Err(_) => return Err(PrivateStateProbeError::Inspect), + }; + let descriptor = match rustix::fs::openat( + &root, + PRIVATE_STATE_LOCK_FILENAME, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::NOFOLLOW, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(rustix::io::Errno::NOENT) => { + return Ok(PrivateStateLockLiveness::NoLiveOwner); + } + Err(_) => return Err(PrivateStateProbeError::Inspect), + }; + let file = File::from(descriptor); + let stat = rustix::fs::fstat(&file).map_err(|_| PrivateStateProbeError::Inspect)?; + let expected_mode = rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR; + if rustix::fs::FileType::from_raw_mode(stat.st_mode) != rustix::fs::FileType::RegularFile + || rustix::fs::Mode::from_raw_mode(stat.st_mode) != expected_mode + || stat.st_uid != rustix::process::geteuid().as_raw() + { + return Err(PrivateStateProbeError::InvalidTarget); + } + let locks = fs::read_to_string("/proc/locks") + .map_err(|_| PrivateStateProbeError::LocksUnavailable)?; + probe_lock_table(&locks, stat.st_dev, stat.st_ino) + } + pub(crate) fn acquire(config_root: &Path) -> Result { ensure_private_directory(config_root).map_err(|error| { map_private_file( @@ -335,6 +393,58 @@ impl PrivateStateLock { } } +fn linux_device_major(device: u64) -> u64 { + ((device >> 8) & 0xfff) | ((device >> 32) & 0xffff_f000) +} + +fn linux_device_minor(device: u64) -> u64 { + (device & 0xff) | ((device >> 12) & 0xffff_ff00) +} + +fn probe_lock_table( + locks: &str, + device: u64, + inode: u64, +) -> Result { + let expected_major = linux_device_major(device); + let expected_minor = linux_device_minor(device); + for line in locks.lines() { + let fields = line.split_ascii_whitespace().collect::>(); + let offset = usize::from(fields.get(1) == Some(&"->")); + if fields.len() < 8 + offset + || !fields[0].ends_with(':') + || !matches!(fields[1 + offset], "FLOCK" | "POSIX" | "OFDLCK") + { + return Err(PrivateStateProbeError::LocksMalformed); + } + let Some(identity) = fields.get(5 + offset) else { + return Err(PrivateStateProbeError::LocksMalformed); + }; + let mut parts = identity.split(':'); + let (Some(major), Some(minor), Some(candidate_inode), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(PrivateStateProbeError::LocksMalformed); + }; + let major = + u64::from_str_radix(major, 16).map_err(|_| PrivateStateProbeError::LocksMalformed)?; + let minor = + u64::from_str_radix(minor, 16).map_err(|_| PrivateStateProbeError::LocksMalformed)?; + let candidate_inode = candidate_inode + .parse::() + .map_err(|_| PrivateStateProbeError::LocksMalformed)?; + if fields[1 + offset] == "FLOCK" + && fields[3 + offset] == "WRITE" + && major == expected_major + && minor == expected_minor + && candidate_inode == inode + { + return Ok(PrivateStateLockLiveness::LiveOwner); + } + } + Ok(PrivateStateLockLiveness::NoLiveOwner) +} + fn verify_private_lock(file: &File) -> Result<(), PrivateStateError> { let stat = rustix::fs::fstat(file).map_err(|source| PrivateStateError::Io { operation: PrivateIoOperation::Inspect, @@ -449,6 +559,17 @@ async fn setup_with_pairer_and_stream( persist_credential(state_lock.root(), &credential) } +#[cfg(test)] +pub(crate) async fn setup_with_pairer_for_test( + pairer: &dyn Pairer, + config_root: &Path, + device_label: &str, + stream: Option<&str>, + input: R, +) -> Result<(), PrivateStateError> { + setup_with_pairer_and_stream(pairer, config_root, device_label, stream, input).await +} + fn private_config_paths(config_root: &Path) -> ConfigPaths { ConfigPaths { base_dir: None, @@ -613,6 +734,18 @@ pub(crate) struct LinkFactState { } impl LinkFacts { + pub(crate) fn begin_owner_generation(&self) { + *self.inner.lock().unwrap_or_else(|p| p.into_inner()) = LinkFactState::default(); + } + + pub(crate) fn owner_lost(&self) { + let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + *state = LinkFactState { + transport_unavailable: true, + ..LinkFactState::default() + }; + } + pub(crate) fn publish(&self, fact: LinkFact) { let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner()); match fact { @@ -841,6 +974,7 @@ pub(crate) struct PrivateLinkSession { registration: Arc, handle: JournalBridgeHandle, token_persistence: Arc, + bootstrap_target: Option, _state_lock: PrivateStateLock, #[cfg(test)] credential_instance_id: String, @@ -850,6 +984,115 @@ pub(crate) struct PrivateLinkSession { facts: LinkFacts, } +enum OpenJournalGate { + Open(String), + Closed, +} + +struct OpenJournalTarget { + gate: Mutex, +} + +#[derive(Clone)] +pub(crate) struct OpenJournalCapability { + target: std::sync::Weak, +} + +impl std::fmt::Debug for OpenJournalCapability { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("OpenJournalCapability()") + } +} + +impl OpenJournalCapability { + fn available(&self) -> bool { + self.target + .upgrade() + .and_then(|target| { + target + .gate + .lock() + .ok() + .map(|gate| matches!(*gate, OpenJournalGate::Open(_))) + }) + .unwrap_or(false) + } + + pub(crate) fn open(&self) -> Result<(), ()> { + self.open_inner(|target| open::that_detached(target).map_err(|_| ())) + } + + fn open_inner(&self, opener: impl FnOnce(&str) -> Result<(), ()>) -> Result<(), ()> { + let target = self.target.upgrade().ok_or(())?; + let gate = target.gate.lock().map_err(|_| ())?; + match &*gate { + OpenJournalGate::Open(target) => opener(target), + OpenJournalGate::Closed => Err(()), + } + } + + #[cfg(test)] + pub(crate) fn open_with(&self, opener: impl FnOnce(&str) -> Result<(), ()>) -> Result<(), ()> { + self.open_inner(opener) + } + + fn close(&self) { + let Some(target) = self.target.upgrade() else { + return; + }; + match target.gate.lock() { + Ok(mut gate) => *gate = OpenJournalGate::Closed, + Err(poisoned) => *poisoned.into_inner() = OpenJournalGate::Closed, + } + } +} + +#[derive(Clone, Default)] +pub(crate) struct OpenJournalAccess { + current: Arc>>, +} + +impl OpenJournalAccess { + pub(crate) fn available(&self) -> bool { + self.current + .lock() + .ok() + .and_then(|current| current.clone()) + .is_some_and(|capability| capability.available()) + } + + pub(crate) fn open(&self) -> Result<(), ()> { + let capability = self.current.lock().map_err(|_| ())?.clone().ok_or(())?; + capability.open() + } + + pub(crate) fn install(&self, capability: OpenJournalCapability) { + if let Ok(mut current) = self.current.lock() { + *current = Some(capability); + } + } + + pub(crate) fn close_current(&self) { + let capability = self.current.lock().ok().and_then(|current| current.clone()); + if let Some(capability) = capability { + capability.close(); + self.clear(&capability); + } + } + + fn clear(&self, capability: &OpenJournalCapability) { + let Ok(mut current) = self.current.lock() else { + return; + }; + if current + .as_ref() + .is_some_and(|value| value.target.ptr_eq(&capability.target)) + { + *current = None; + } + } +} + pub(crate) enum LinkOutcome { Success { status: StatusCode, body: Vec }, Unauthorized { generation: u64 }, @@ -1109,7 +1352,10 @@ impl RegistrationCoordinator { pub(crate) struct PrivateLinkOwner { capability: PrivateLinkCapability, - session: PrivateLinkSession, + open_journal_target: Arc, + open_journal_access: Option, + session: Option, + facts: LinkFacts, } impl PrivateLinkOwner { @@ -1117,17 +1363,45 @@ impl PrivateLinkOwner { self.capability.clone() } - pub(crate) async fn shutdown(self) -> Result<(), PrivateStateError> { - self.session.shutdown().await + pub(crate) fn open_journal_capability(&self) -> OpenJournalCapability { + OpenJournalCapability { + target: Arc::downgrade(&self.open_journal_target), + } + } + + pub(crate) fn install_open_journal_access(&mut self, access: OpenJournalAccess) { + let capability = self.open_journal_capability(); + access.install(capability); + self.open_journal_access = Some(access); + } + + fn close_open_journal(&self) { + let capability = self.open_journal_capability(); + capability.close(); + if let Some(access) = &self.open_journal_access { + access.clear(&capability); + } + } + + pub(crate) async fn shutdown(mut self) -> Result<(), PrivateStateError> { + self.close_open_journal(); + self.facts.owner_lost(); + self.session.take().unwrap().shutdown().await } #[cfg(test)] pub(crate) async fn shutdown_with_join_probe( - self, + mut self, joined: Arc, release: Arc, ) -> Result<(), PrivateStateError> { - self.session.shutdown_with_join_probe(joined, release).await + self.close_open_journal(); + self.facts.owner_lost(); + self.session + .take() + .unwrap() + .shutdown_with_join_probe(joined, release) + .await } #[cfg(test)] @@ -1170,6 +1444,13 @@ impl PrivateLinkOwner { } } +impl Drop for PrivateLinkOwner { + fn drop(&mut self) { + self.close_open_journal(); + self.facts.owner_lost(); + } +} + #[cfg(test)] pub(crate) async fn start_private_link_owner( config_root: &Path, @@ -1181,7 +1462,7 @@ pub(crate) async fn start_private_link_owner( } async fn finish_owner_start( - session: PrivateLinkSession, + mut session: PrivateLinkSession, ) -> Result { let capability = session.capability("/app/observer/ingest".to_owned()); if session.opener.generation() == 0 { @@ -1195,9 +1476,19 @@ async fn finish_owner_start( } } } + let facts = capability.facts(); + let bootstrap_target = session + .bootstrap_target + .take() + .ok_or(PrivateStateError::BootstrapFailed)?; Ok(PrivateLinkOwner { capability, - session, + open_journal_target: Arc::new(OpenJournalTarget { + gate: Mutex::new(OpenJournalGate::Open(bootstrap_target)), + }), + open_journal_access: None, + session: Some(session), + facts, }) } @@ -1255,14 +1546,7 @@ pub(crate) async fn start_registered_private_link_for_test( }, ) .unwrap(); - let capability = session.capability(ingest_path.to_owned()); - ( - temp, - PrivateLinkOwner { - capability, - session, - }, - ) + (temp, finish_owner_start(session).await.unwrap()) } impl PrivateLinkSession { @@ -1643,7 +1927,7 @@ async fn start_private_link_session_inner( .build() .map_err(|_| PrivateStateError::BridgeUnavailable)?; let response = client - .get(bootstrap_url) + .get(&bootstrap_url) .timeout(BOOTSTRAP_TIMEOUT) .send() .await @@ -1695,6 +1979,7 @@ async fn start_private_link_session_inner( registration, handle, token_persistence, + bootstrap_target: Some(bootstrap_url), _state_lock: state_lock, #[cfg(test)] credential_instance_id, @@ -1710,6 +1995,7 @@ mod tests { use super::*; use crate::private_file::DurableWriteStage; use crate::private_link_test_peer::PrivateLinkPeer; + use crate::sync_health::{ProcessEpoch, SyncFacts, load_facts_with_liveness, save_facts}; use spl_transport::credential::EndpointAddr; use std::{ io::Cursor, @@ -2513,6 +2799,110 @@ mod tests { drop(second); } + #[test] + fn read_only_probe_reports_live_and_unlocked_without_mutation() { + let temp = tempfile::tempdir().unwrap(); + let held = PrivateStateLock::acquire(temp.path()).unwrap(); + let lock_path = temp.path().join(PRIVATE_STATE_LOCK_FILENAME); + let before = fs::metadata(&lock_path).unwrap(); + assert_eq!( + PrivateStateLock::try_probe(temp.path()).unwrap(), + PrivateStateLockLiveness::LiveOwner + ); + let after = fs::metadata(&lock_path).unwrap(); + assert_eq!(before.ino(), after.ino()); + assert_eq!(before.permissions().mode(), after.permissions().mode()); + assert_eq!(before.modified().unwrap(), after.modified().unwrap()); + drop(held); + assert_eq!( + PrivateStateLock::try_probe(temp.path()).unwrap(), + PrivateStateLockLiveness::NoLiveOwner + ); + let reacquired = PrivateStateLock::acquire(temp.path()).unwrap(); + drop(reacquired); + } + + #[test] + fn read_only_probe_is_conservative_for_missing_invalid_and_malformed_inputs() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + assert_eq!( + PrivateStateLock::try_probe(&missing).unwrap(), + PrivateStateLockLiveness::NoLiveOwner + ); + + fs::create_dir(&missing).unwrap(); + assert_eq!( + PrivateStateLock::try_probe(&missing).unwrap(), + PrivateStateLockLiveness::NoLiveOwner + ); + let lock_path = missing.join(PRIVATE_STATE_LOCK_FILENAME); + fs::write(&lock_path, b"unchanged").unwrap(); + fs::set_permissions(&lock_path, fs::Permissions::from_mode(0o640)).unwrap(); + let before = fs::read(&lock_path).unwrap(); + assert!(matches!( + PrivateStateLock::try_probe(&missing), + Err(PrivateStateProbeError::InvalidTarget) + )); + assert_eq!(fs::read(&lock_path).unwrap(), before); + assert!(matches!( + probe_lock_table("not a lock table", 0, 0), + Err(PrivateStateProbeError::LocksMalformed) + )); + assert_eq!( + probe_lock_table("1: POSIX ADVISORY READ 1 00:00:1 0 EOF\n", 0, 2).unwrap(), + PrivateStateLockLiveness::NoLiveOwner + ); + } + + #[test] + fn liveness_sampling_races_correct_on_the_next_sample() { + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + let connected = LinkFactState { + carrier_proven: true, + observer_registered: true, + ..Default::default() + }; + save_facts( + &state, + &SyncFacts { + pending_confirmed: Some(0), + link: Some(connected.clone()), + link_epoch: Some(ProcessEpoch::for_test(3)), + ..Default::default() + }, + ) + .unwrap(); + + let held = PrivateStateLock::acquire(temp.path()).unwrap(); + let sampled_live = PrivateStateLock::try_probe(temp.path()).unwrap(); + drop(held); + assert_eq!( + load_facts_with_liveness(&state, sampled_live).link, + Some(connected.clone()) + ); + assert!( + load_facts_with_liveness(&state, PrivateStateLock::try_probe(temp.path()).unwrap()) + .link + .is_none() + ); + + let sampled_absent = PrivateStateLock::try_probe(temp.path()).unwrap(); + let held = PrivateStateLock::acquire(temp.path()).unwrap(); + assert!( + load_facts_with_liveness(&state, sampled_absent) + .link + .is_none() + ); + assert_eq!( + load_facts_with_liveness(&state, PrivateStateLock::try_probe(temp.path()).unwrap()) + .link, + Some(connected) + ); + drop(held); + } + #[test] fn lock_rejects_symlinked_config_root_without_touching_referent() { let temp = tempfile::tempdir().unwrap(); @@ -3256,7 +3646,7 @@ mod tests { default.set_nonblocking(true).unwrap(); } else { eprintln!( - "criterion 12 note: localhost:5015 opportunistic zero-connection clause did not execute because the port is already in use" + "criterion 12 note: opportunistic default-listener trap did not execute because the address is already in use" ); } Self { @@ -3641,6 +4031,257 @@ mod tests { peer.shutdown().await; } + #[tokio::test] + async fn abrupt_owner_loss_clears_live_proofs_and_publishes_transport_loss() { + let temp = tempfile::tempdir().unwrap(); + let peer = PrivateLinkPeer::start().await; + peer.enqueue_response( + 200, + serde_json::json!({ + "key": "K", + "name": "stream", + "prefix": "prefix", + "ingest_url": "/app/observer/ingest", + "protocol_version": 2 + }) + .to_string(), + ); + let owner = start_private_link_owner(temp.path(), peer.credential(), "stream") + .await + .unwrap(); + let facts = owner.capability().facts(); + assert!(facts.snapshot().listener_ready); + drop(owner); + assert_eq!( + facts.snapshot(), + LinkFactState { + transport_unavailable: true, + ..Default::default() + } + ); + peer.shutdown().await; + } + + #[test] + fn owner_generation_reset_clears_every_published_fact() { + let facts = LinkFacts::default(); + for fact in [ + LinkFact::PairingRequired, + LinkFact::PrivateStateInvalid, + LinkFact::ConfigSanitationFailed, + LinkFact::ListenerReady, + LinkFact::CarrierProven, + LinkFact::ObserverRegistered, + LinkFact::TransportUnavailable, + LinkFact::TerminalRevocation, + LinkFact::TokenPersistenceFailure, + ] { + facts.publish(fact); + } + facts.begin_owner_generation(); + assert_eq!(facts.snapshot(), LinkFactState::default()); + } + + fn open_journal_fixture(target: &str) -> (Arc, OpenJournalCapability) { + let target = Arc::new(OpenJournalTarget { + gate: Mutex::new(OpenJournalGate::Open(target.to_owned())), + }); + let capability = OpenJournalCapability { + target: Arc::downgrade(&target), + }; + (target, capability) + } + + #[test] + fn open_journal_epoch_matrix_never_retargets_stale_capabilities() { + let access = OpenJournalAccess::default(); + assert!(!access.available()); + assert!(access.open().is_err()); + + let (first_target, first) = open_journal_fixture("first-private-target"); + access.install(first.clone()); + assert!(access.available()); + let calls = Arc::new(Mutex::new(Vec::new())); + first + .open_with({ + let calls = Arc::clone(&calls); + move |target| { + calls.lock().unwrap().push(target.to_owned()); + Ok(()) + } + }) + .unwrap(); + + first.close(); + assert!(!access.available()); + assert!(first.open_with(|_| panic!("closed target opened")).is_err()); + access.clear(&first); + drop(first_target); + assert!( + first + .open_with(|_| panic!("dropped target opened")) + .is_err() + ); + + let (replacement_target, replacement) = open_journal_fixture("replacement-private-target"); + access.install(replacement.clone()); + access.clear(&first); + assert!(access.available()); + assert!( + first + .open_with(|_| panic!("stale clone retargeted")) + .is_err() + ); + replacement + .open_with({ + let calls = Arc::clone(&calls); + move |target| { + calls.lock().unwrap().push(target.to_owned()); + Ok(()) + } + }) + .unwrap(); + assert_eq!( + *calls.lock().unwrap(), + ["first-private-target", "replacement-private-target"] + ); + drop(replacement_target); + assert!(!access.available()); + } + + #[test] + fn open_journal_open_and_shutdown_linearize_on_one_gate() { + let (_target, capability) = open_journal_fixture("private-target"); + let calls = Arc::new(AtomicUsize::new(0)); + let entered = Arc::new(std::sync::Barrier::new(2)); + let release = Arc::new(std::sync::Barrier::new(2)); + let opener = { + let capability = capability.clone(); + let calls = Arc::clone(&calls); + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + std::thread::spawn(move || { + capability.open_with(|_| { + calls.fetch_add(1, Ordering::SeqCst); + entered.wait(); + release.wait(); + Ok(()) + }) + }) + }; + entered.wait(); + let closer = { + let capability = capability.clone(); + std::thread::spawn(move || capability.close()) + }; + assert_eq!(calls.load(Ordering::SeqCst), 1); + release.wait(); + assert!(opener.join().unwrap().is_ok()); + closer.join().unwrap(); + assert!(capability.open_with(|_| panic!("post-close open")).is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn opener_panic_poison_closes_capability_fail_closed() { + let (_target, capability) = open_journal_fixture("private-target"); + let panic = std::panic::catch_unwind({ + let capability = capability.clone(); + move || { + let _ = capability.open_with(|_| -> Result<(), ()> { + panic!("injected opener panic"); + }); + } + }); + assert!(panic.is_err()); + assert!(!capability.available()); + let calls = AtomicUsize::new(0); + assert!( + capability + .open_with(|_| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .is_err() + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + capability.close(); + assert!(!capability.available()); + } + + #[test] + fn open_journal_debug_is_always_redacted() { + let (_target, capability) = + open_journal_fixture("http://127.0.0.1:49152/?cap=capability-secret"); + assert_eq!( + format!("{capability:?}"), + "OpenJournalCapability()" + ); + } + + #[test] + fn open_journal_secrets_never_enter_owner_or_serialized_surfaces_at_any_epoch() { + let secrets = [ + "http://127.0.0.1:49152/?cap=capability-cookie-sentinel", + "49152", + "capability-cookie-sentinel", + "credential-sentinel", + "observer-key-sentinel", + "relay-token-sentinel", + ]; + let access = OpenJournalAccess::default(); + let before = format!("available={}", access.available()); + let (_target, capability) = open_journal_fixture(secrets[0]); + access.install(capability.clone()); + let live = format!("{capability:?};available={}", access.available()); + capability.close(); + let after = format!("{capability:?};available={}", access.available()); + + let temp = tempfile::tempdir().unwrap(); + let config = crate::config::Config { + base_dir: temp.path().to_path_buf(), + config_dir: temp.path().join("config"), + ..Default::default() + }; + let config_json = serde_json::to_string(&config).unwrap(); + let facts = SyncFacts { + link_epoch: Some(ProcessEpoch::for_test(4)), + ..Default::default() + }; + save_facts(temp.path(), &facts).unwrap(); + let state_json = fs::read_to_string(temp.path().join("sync_health.json")).unwrap(); + let health = crate::sync_health::derive_health(&facts, 0.0, 600.0); + let clipboard = crate::clipboard::agent_instructions( + &config.config_path().display().to_string(), + &config.captures_dir().display().to_string(), + ); + let introspection = include_str!("../testdata/introspection/observer1.xml").to_owned(); + let unavailable = crate::desktop_component::DesktopComponent::new(config) + .perform_desktop_command(crate::tray::TrayCommand::OpenJournal) + .unwrap_err(); + let outputs = [ + before, + live, + after, + config_json, + state_json, + health.cli, + health.doctor_detail, + health.dbus, + clipboard, + introspection, + unavailable, + ]; + for output in outputs { + for secret in secrets { + assert!( + !output.contains(secret), + "owner or serialized surface disclosed a private Open Journal value" + ); + } + } + } + #[tokio::test] async fn owner_shutdown_closes_active_bridge_stream() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/solstone-linux/src/run.rs b/crates/solstone-linux/src/run.rs index 0e0b751..12634ec 100644 --- a/crates/solstone-linux/src/run.rs +++ b/crates/solstone-linux/src/run.rs @@ -35,6 +35,7 @@ use crate::{ recovery::{ClaxonMediaDurationProbe, recover_incomplete_segments}, shell::{CommandSender, ConnectionRequester, ShellInputs, stashed}, sync::{SyncService, SyncTrigger}, + sync_health::ProcessEpoch, tray::TrayCommand, upload::UploadClient, video::{ @@ -141,7 +142,10 @@ pub(crate) fn tick_once( async fn shutdown_in_order( mut observer: O, - observer_shutdown: impl FnOnce(&mut O) -> Result<(), ObserverError>, + shutdown_callbacks: ( + impl FnOnce(&mut O) -> Result<(), ObserverError>, + impl FnOnce(), + ), desktop_shutdown: DF, sync_shutdown: SF, sender_stop: impl FnOnce() -> EF, @@ -159,6 +163,9 @@ where EF: std::future::Future>, LF: std::future::Future>, { + let (observer_shutdown, disable_open_journal) = shutdown_callbacks; + trace("open_journal_disabled"); + disable_open_journal(); trace("desktop_shutdown"); desktop_shutdown.await; trace("observer_shutdown"); @@ -287,20 +294,43 @@ fn run_capture( env!("CARGO_PKG_VERSION"), Arc::new(clock.clone()), )); + let open_journal = crate::private_link::OpenJournalAccess::default(); + let process_epoch = match ProcessEpoch::generate() { + Ok(epoch) => Some(epoch), + Err(error) => { + tracing::error!(%error, "Failed to create process epoch; linked work disabled"); + upload.publish_link_fact(crate::private_link::LinkFact::PrivateStateInvalid); + None + } + }; let linked_upload = Arc::clone(&upload); let linked_root = config.config_dir.clone(); let linked_stream = config.stream.clone(); let linked_state_lock = state_lock .try_clone() .map_err(|error| ObserverError::Io(format!("linked state lock clone failed: {error}")))?; - let linked_start = runtime.spawn(start_linked_owner( - linked_upload, - linked_root, - linked_stream, - linked_state_lock, - transport_enabled, - )); - let sync = SyncService::start(config.clone(), Arc::clone(&upload), Arc::new(clock.clone())); + let linked_start = if process_epoch.is_some() { + runtime.spawn(start_linked_owner( + linked_upload, + linked_root, + linked_stream, + linked_state_lock, + transport_enabled, + open_journal.clone(), + )) + } else { + runtime.spawn(async { + Err::( + crate::private_link::PrivateStateError::BridgeUnavailable, + ) + }) + }; + let sync = SyncService::start_with_epoch( + config.clone(), + Arc::clone(&upload), + Arc::new(clock.clone()), + process_epoch, + ); let sync_trigger = sync.trigger_handle(); let sync_sampler = sync.sampler_handle(); sync.trigger(); @@ -356,6 +386,7 @@ fn run_capture( signal_receiver, sampler: sync_sampler, commands, + open_journal: open_journal.clone(), }, ); let mut next_tick = Instant::now() + TICK_INTERVAL; @@ -373,15 +404,17 @@ fn run_capture( run_result = dispatch_wake( &mut observer, LoopWake::Command(command), - apply_command, + |observer, command| apply_command(observer, command, &open_journal), |observer| tick_once(notifier.as_ref(), || observer.tick()), ); } Err(RecvTimeoutError::Timeout) => { - run_result = - dispatch_wake(&mut observer, LoopWake::Tick, apply_command, |observer| { - tick_once(notifier.as_ref(), || observer.tick()) - }); + run_result = dispatch_wake( + &mut observer, + LoopWake::Tick, + |observer, command| apply_command(observer, command, &open_journal), + |observer| tick_once(notifier.as_ref(), || observer.tick()), + ); next_tick = advance_tick_deadline(next_tick, Instant::now()); } Err(RecvTimeoutError::Disconnected) => { @@ -396,7 +429,7 @@ fn run_capture( let (shutdown, sync_shutdown, sender_shutdown, linked_shutdown) = runtime.block_on(shutdown_in_order( observer, - Observer::shutdown, + (Observer::shutdown, || open_journal.close_current()), desktop_shell.shutdown(SHUTDOWN_TIMEOUT), sync.shutdown(SHUTDOWN_TIMEOUT), || stop_upload_sender(upload, SHUTDOWN_TIMEOUT), @@ -429,7 +462,9 @@ async fn start_linked_owner( stream: String, state_lock: PrivateStateLock, transport_enabled: bool, + open_journal: crate::private_link::OpenJournalAccess, ) -> Result { + upload.begin_owner_generation(); if !transport_enabled { upload.publish_link_fact(crate::private_link::LinkFact::ConfigSanitationFailed); return Err(crate::private_link::PrivateStateError::BridgeUnavailable); @@ -446,7 +481,7 @@ async fn start_linked_owner( } }; let (hostname, platform, version) = upload.registration_metadata(); - let owner = start_private_link_owner_with_lock( + let mut owner = start_private_link_owner_with_lock( state_lock, credential, &stream, @@ -459,6 +494,7 @@ async fn start_linked_owner( .inspect_err(|_| { upload.publish_link_fact(crate::private_link::LinkFact::TransportUnavailable); })?; + owner.install_open_journal_access(open_journal); upload.install_capability(owner.capability()); Ok(owner) } @@ -466,6 +502,7 @@ async fn start_linked_owner( fn apply_command( observer: &mut Observer, command: TrayCommand, + open_journal: &crate::private_link::OpenJournalAccess, ) where V: VideoCapture, A: crate::observer::AudioCapture, @@ -481,11 +518,27 @@ fn apply_command( ObserverAction::Pause(seconds) => observer.pause(seconds), ObserverAction::Resume => observer.resume(), ObserverAction::Desktop(command) => { - if let Err(error) = - crate::desktop_component::DesktopComponent::new(observer.config.clone()) - .perform_desktop_command(command) + if let Err(error) = crate::desktop_component::DesktopComponent::with_open_journal( + observer.config.clone(), + open_journal.clone(), + ) + .perform_desktop_command(command) { tracing::warn!(%error, "Failed to perform desktop command"); + if matches!(command, TrayCommand::OpenJournal) { + let message = + "Could not open your journal. Wait for sol to reconnect, then try again."; + if let Err(notification_error) = notify_rust::Notification::new() + .summary("sol") + .body(message) + .show() + { + tracing::warn!( + %notification_error, + "Failed to show Open Journal notification" + ); + } + } } } } @@ -957,18 +1010,24 @@ mod tests { async fn shutdown_order_includes_linked_owner_last() { let events = Arc::new(Mutex::new(Vec::new())); let observer_events = Arc::clone(&events); + let disable_events = Arc::clone(&events); let sender_events = Arc::clone(&events); let trace_events = Arc::clone(&events); let mut trace = move |event| trace_events.lock().unwrap().push(event); let results = shutdown_in_order( (), - move |_| { - observer_events - .lock() - .unwrap() - .push("final_segment_trigger"); - Ok(()) - }, + ( + move |_| { + observer_events + .lock() + .unwrap() + .push("final_segment_trigger"); + Ok(()) + }, + move || { + disable_events.lock().unwrap().push("capability_closed"); + }, + ), async { events.lock().unwrap().push("desktop_stopped"); }, @@ -985,6 +1044,8 @@ mod tests { assert_eq!( &*events.lock().unwrap(), &[ + "open_journal_disabled", + "capability_closed", "desktop_shutdown", "desktop_stopped", "observer_shutdown", @@ -1003,7 +1064,7 @@ mod tests { async fn linked_shutdown_failure_preserves_prior_shutdown_results() { let results = shutdown_in_order( (), - |_| Err(ObserverError::Io("observer failed".into())), + (|_| Err(ObserverError::Io("observer failed".into())), || {}), async {}, async { Err(tokio::task::spawn(async { panic!("sync failed") }) @@ -1029,7 +1090,7 @@ mod tests { let linked_events = events.clone(); let results = shutdown_in_order( (), - |_| Ok(()), + (|_| Ok(()), || {}), async {}, async move { tokio::task::yield_now().await; @@ -1070,7 +1131,6 @@ mod tests { let config = Config { config_dir: temp.path().to_path_buf(), stream: "stream".to_owned(), - server_url: legacy_origin.url.clone(), ..Config::default() }; let upload = Arc::new(UploadClient::new( @@ -1088,6 +1148,7 @@ mod tests { "stream".to_owned(), lock, transport_enabled, + crate::private_link::OpenJournalAccess::default(), )); assert_real_observer_ticks_advance(); let result = start.await.unwrap(); @@ -1297,9 +1358,12 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let peer = PrivateLinkPeer::start().await; enqueue_registration(&peer); - let owner = start_private_link_owner(temp.path(), peer.credential(), "stream") + let mut owner = start_private_link_owner(temp.path(), peer.credential(), "stream") .await .unwrap(); + let open_journal = crate::private_link::OpenJournalAccess::default(); + owner.install_open_journal_access(open_journal.clone()); + assert!(open_journal.available()); assert!(matches!( PrivateStateLock::acquire(temp.path()), Err(PrivateStateError::LockContended) @@ -1310,6 +1374,8 @@ mod tests { tokio::spawn(owner.shutdown_with_join_probe(joined.clone(), release.clone())); joined.notified().await; assert!(!shutdown.is_finished()); + assert!(!open_journal.available()); + assert!(open_journal.open().is_err()); assert!(matches!( PrivateStateLock::acquire(temp.path()), Err(PrivateStateError::LockContended) @@ -1562,4 +1628,407 @@ mod tests { ); assert_eq!(&*calls.borrow(), &["lock"]); } + + mod upgrade_composition { + use super::*; + use crate::{ + cli::dispatch_setup_with_pairer_for_test, + config::{ConfigPaths, sanitize_link_authority_with_fault}, + private_file::{DurableWriteFault, DurableWriteStage}, + private_link::{Pairer, PrivateStateLock, load_credential}, + sync::cleanup_synced_day_for_composition, + }; + use sha2::{Digest, Sha256}; + use std::{ + fs, + future::Future, + io::Cursor, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + pin::Pin, + sync::atomic::{AtomicUsize, Ordering}, + }; + + struct CompositionPairer { + credential: spl_transport::credential::Credential, + calls: Arc, + fail: bool, + } + + impl Pairer for CompositionPairer { + fn pair<'a>( + &'a self, + _link: &'a str, + _device_label: &'a str, + _additional_fields: &'a serde_json::Map, + ) -> Pin< + Box< + dyn Future< + Output = Result< + spl_transport::credential::Credential, + PrivateStateError, + >, + > + Send + + 'a, + >, + > { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.fail { + Err(PrivateStateError::PairingFailed) + } else { + Ok(self.credential.clone()) + } + }) + } + } + + struct FailStage(DurableWriteStage); + + impl DurableWriteFault for FailStage { + fn before(&self, stage: DurableWriteStage) -> io::Result<()> { + if stage == self.0 { + Err(io::Error::other("injected upgrade sanitation failure")) + } else { + Ok(()) + } + } + } + + fn old_config() -> serde_json::Value { + serde_json::json!({ + "server_url": "https://legacy.invalid/private", + "key": "legacy-key-sentinel", + "chat_bridge_enabled": true, + "stream": "desktop", + "segment_interval": 173, + "sync_max_retries": 0, + "cache_retention_days": 0, + "capture_framerate": 7, + "draw_cursor": false, + "start_paused": false + }) + } + + fn write_old_config(paths: &ConfigPaths) { + let root = paths.config_dir.as_ref().unwrap(); + fs::create_dir_all(root).unwrap(); + fs::set_permissions(root, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write( + root.join("config.json"), + serde_json::to_vec_pretty(&old_config()).unwrap(), + ) + .unwrap(); + } + + fn create_pending(config: &Config, day: &str) -> Vec<(PathBuf, Vec)> { + ["120000_173", "120173_173", "120346_173"] + .into_iter() + .enumerate() + .map(|(index, name)| { + let path = config.captures_dir().join(day).join("archon").join(name); + let body = format!("byte-distinct-upgrade-segment-{index}").into_bytes(); + fs::create_dir_all(&path).unwrap(); + fs::write(path.join("screen.webm"), &body).unwrap(); + (path, body) + }) + .collect() + } + + fn assert_pending_unchanged(pending: &[(PathBuf, Vec)], present: &[bool]) { + for ((path, bytes), expected) in pending.iter().zip(present) { + assert_eq!(path.exists(), *expected, "{}", path.display()); + assert!(!path.with_extension("failed").exists()); + if *expected { + assert_eq!(fs::read(path.join("screen.webm")).unwrap(), *bytes); + } + } + } + + fn custody_listing(path: &Path) -> serde_json::Value { + let bytes = fs::read(path.join("screen.webm")).unwrap(); + let sha = format!("{:x}", Sha256::digest(bytes)); + let key = path.file_name().unwrap().to_string_lossy(); + serde_json::json!({ + "items": [{ + "key": key, + "files": [{ + "name": "screen.webm", + "status": "present", + "sha256": sha + }] + }], + "total": 1 + }) + } + + async fn start_owner( + config: &Config, + peer: &PrivateLinkPeer, + lock: PrivateStateLock, + ) -> (PrivateLinkOwner, Arc) { + let upload = Arc::new(UploadClient::new( + config, + None::, + "upgrade-host", + "linux", + "test", + Arc::new(SystemClock::new()), + )); + let owner = start_linked_owner( + Arc::clone(&upload), + config.config_dir.clone(), + config.stream.clone(), + lock, + true, + crate::private_link::OpenJournalAccess::default(), + ) + .await + .unwrap(); + assert!(upload.is_registered()); + assert_real_observer_ticks_advance(); + assert!(peer.accepted_carriers() >= 1); + (owner, upload) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn old_config_unpaired_capture_pair_once_custody_and_restart() { + for stage in [ + DurableWriteStage::Create, + DurableWriteStage::Write, + DurableWriteStage::Fsync, + DurableWriteStage::Rename, + DurableWriteStage::DirSync, + ] { + let temp = tempfile::tempdir().unwrap(); + let paths = ConfigPaths { + base_dir: Some(temp.path().join("data")), + config_dir: Some(temp.path().join("config")), + }; + write_old_config(&paths); + let before = + fs::read(paths.config_dir.as_ref().unwrap().join("config.json")).unwrap(); + assert!( + sanitize_link_authority_with_fault(&paths, &FailStage(stage)).is_err(), + "{stage:?}" + ); + if stage != DurableWriteStage::DirSync { + assert_eq!( + fs::read(paths.config_dir.as_ref().unwrap().join("config.json")).unwrap(), + before + ); + } + } + + let temp = tempfile::tempdir().unwrap(); + let paths = ConfigPaths { + base_dir: Some(temp.path().join("data")), + config_dir: Some(temp.path().join("config")), + }; + write_old_config(&paths); + let peer = PrivateLinkPeer::start().await; + let (lock, config, transport_enabled) = + crate::cli::prepare_run_config(paths.clone()).unwrap(); + assert!(transport_enabled); + let persisted: serde_json::Value = + serde_json::from_slice(&fs::read(config.config_path()).unwrap()).unwrap(); + for legacy in ["server_url", "key", "chat_bridge_enabled"] { + assert!(persisted.get(legacy).is_none()); + } + assert_eq!(config.segment_interval, 173); + assert_eq!(config.capture_framerate, 7); + assert!(!config.draw_cursor); + let day = "20260101"; + let pending = create_pending(&config, day); + assert_pending_unchanged(&pending, &[true, true, true]); + + let upload = Arc::new(UploadClient::new( + &config, + None::, + "upgrade-host", + "linux", + "test", + Arc::new(SystemClock::new()), + )); + let first_start = start_linked_owner( + Arc::clone(&upload), + config.config_dir.clone(), + config.stream.clone(), + lock, + true, + crate::private_link::OpenJournalAccess::default(), + ) + .await; + assert!(first_start.is_err()); + assert!(upload.link_fact_state().unwrap().pairing_required); + assert_real_observer_ticks_advance(); + assert!(peer.requests().is_empty()); + assert_eq!(peer.accepted_carriers(), 0); + assert_pending_unchanged(&pending, &[true, true, true]); + drop(upload); + let released = PrivateStateLock::acquire(&config.config_dir).unwrap(); + drop(released); + + let failed_calls = Arc::new(AtomicUsize::new(0)); + let failed_pairer = CompositionPairer { + credential: peer.credential(), + calls: Arc::clone(&failed_calls), + fail: true, + }; + let failure_root = temp.path().join("failed-pairing"); + let mut failure_output = Vec::new(); + let mut failure_errors = Vec::new(); + assert_eq!( + dispatch_setup_with_pairer_for_test( + &failed_pairer, + &failure_root, + "desktop", + Cursor::new(b"pair link with whitespace\n"), + &mut failure_output, + &mut failure_errors, + ) + .await, + 1 + ); + assert_eq!(failed_calls.load(Ordering::SeqCst), 0); + failure_output.clear(); + failure_errors.clear(); + assert_eq!( + dispatch_setup_with_pairer_for_test( + &failed_pairer, + &failure_root, + "desktop", + Cursor::new(b"pair-link\n"), + &mut failure_output, + &mut failure_errors, + ) + .await, + 1 + ); + assert_eq!(failed_calls.load(Ordering::SeqCst), 1); + assert!(load_credential(&failure_root).unwrap().is_none()); + + let pair_calls = Arc::new(AtomicUsize::new(0)); + let pairer = CompositionPairer { + credential: peer.credential(), + calls: Arc::clone(&pair_calls), + fail: false, + }; + let mut output = Vec::new(); + let mut errors = Vec::new(); + assert_eq!( + dispatch_setup_with_pairer_for_test( + &pairer, + &config.config_dir, + "desktop", + Cursor::new(b"pair-link\n"), + &mut output, + &mut errors, + ) + .await, + 0 + ); + assert_eq!(pair_calls.load(Ordering::SeqCst), 1); + assert!(errors.is_empty()); + assert!(load_credential(&config.config_dir).unwrap().is_some()); + + peer.enqueue_response(503, Vec::new()); + let registration_failure_lock = PrivateStateLock::acquire(&config.config_dir).unwrap(); + let failed_upload = Arc::new(UploadClient::new( + &config, + None::, + "upgrade-host", + "linux", + "test", + Arc::new(SystemClock::new()), + )); + let failed_owner = start_linked_owner( + Arc::clone(&failed_upload), + config.config_dir.clone(), + config.stream.clone(), + registration_failure_lock, + true, + crate::private_link::OpenJournalAccess::default(), + ) + .await + .unwrap(); + assert!(!failed_upload.is_registered()); + assert_pending_unchanged(&pending, &[true, true, true]); + failed_owner.shutdown().await.unwrap(); + + peer.enqueue_response( + 200, + serde_json::json!({ + "key":"UPGRADE-KEY", "name":"desktop", "prefix":"upgrade", + "ingest_url":"/app/observer/ingest", "protocol_version":2 + }) + .to_string(), + ); + let restart_lock = PrivateStateLock::acquire(&config.config_dir).unwrap(); + let (owner, upload) = start_owner(&config, &peer, restart_lock).await; + assert_eq!(pair_calls.load(Ordering::SeqCst), 1); + + peer.enqueue_response(503, Vec::new()); + let transport_failure = upload + .upload_segment( + day, + pending[0].0.file_name().unwrap().to_str().unwrap(), + &[pending[0].0.join("screen.webm")], + ) + .await; + assert_eq!( + transport_failure.error_type, + Some(crate::sync_health::ErrorType::Transient) + ); + assert_pending_unchanged(&pending, &[true, true, true]); + + for response in [ + (503, serde_json::json!({})), + (200, serde_json::json!({"items":[],"total":0})), + (200, serde_json::json!({"items":[],"total":3})), + ] { + peer.enqueue_response(response.0, response.1.to_string()); + cleanup_synced_day_for_composition( + config.clone(), + Arc::clone(&upload), + Arc::new(SystemClock::new()), + day, + ) + .await; + assert_pending_unchanged(&pending, &[true, true, true]); + assert_real_observer_ticks_advance(); + } + + for index in 0..pending.len() { + peer.enqueue_response(200, custody_listing(&pending[index].0).to_string()); + cleanup_synced_day_for_composition( + config.clone(), + Arc::clone(&upload), + Arc::new(SystemClock::new()), + day, + ) + .await; + let present = [false, index == 0, index <= 1]; + assert_pending_unchanged(&pending, &present); + } + owner.shutdown().await.unwrap(); + drop(upload); + let released = PrivateStateLock::acquire(&config.config_dir).unwrap(); + drop(released); + + let requests_before_final_restart = peer.requests().len(); + let (final_lock, final_config, final_transport) = + crate::cli::prepare_run_config(paths).unwrap(); + assert!(final_transport); + let (final_owner, final_upload) = start_owner(&final_config, &peer, final_lock).await; + assert_eq!(pair_calls.load(Ordering::SeqCst), 1); + assert!(final_upload.is_registered()); + assert_eq!(peer.requests().len(), requests_before_final_restart); + assert_real_observer_ticks_advance(); + final_owner.shutdown().await.unwrap(); + drop(final_upload); + assert!(PrivateStateLock::acquire(&final_config.config_dir).is_ok()); + peer.shutdown().await; + } + } } diff --git a/crates/solstone-linux/src/shell.rs b/crates/solstone-linux/src/shell.rs index 4fb1e1c..4266df3 100644 --- a/crates/solstone-linux/src/shell.rs +++ b/crates/solstone-linux/src/shell.rs @@ -138,6 +138,7 @@ pub(crate) struct ShellInputs { pub signal_receiver: watch::Receiver, pub sampler: SyncSampler, pub commands: CommandSender, + pub open_journal: crate::private_link::OpenJournalAccess, } pub(crate) struct DesktopShell { @@ -186,7 +187,8 @@ fn bind_consumers( } pub(crate) fn start(runtime: &tokio::runtime::Runtime, inputs: ShellInputs) -> DesktopShell { - let component = DesktopComponent::new(inputs.config.clone()); + let component = + DesktopComponent::with_open_journal(inputs.config.clone(), inputs.open_journal.clone()); let initial_snapshot = inputs .snapshot .lock() @@ -262,11 +264,12 @@ pub(crate) fn start(runtime: &tokio::runtime::Runtime, inputs: ShellInputs) -> D shutdown_rx.clone(), )); - let initial_model = tray_model::build( + let initial_model = tray_model::build_with_open_journal( &initial_snapshot, inputs.config.segment_interval, inputs.clock.monotonic_seconds(), &initial_health, + inputs.open_journal.available(), ); let mut tray_handle = None; let registered = component.setup( @@ -299,6 +302,7 @@ pub(crate) fn start(runtime: &tokio::runtime::Runtime, inputs: ShellInputs) -> D inputs.config.segment_interval, inputs.clock, tray_health, + inputs.open_journal, )); let apply_task = tokio::spawn(run_tray_applier(handle, model_receiver, shutdown_rx)); (Some(render_task), Some(apply_task)) @@ -323,6 +327,7 @@ async fn run_tray_renderer( segment_interval: i64, clock: SystemClock, health: TrayHealth, + open_journal: crate::private_link::OpenJournalAccess, ) { component .watch_until_lost(receiver, move |snapshot| { @@ -331,11 +336,12 @@ async fn run_tray_renderer( .lock() .map(|value| value.clone()) .unwrap_or_else(|error| error.into_inner().clone()); - models.send_replace(tray_model::build( + models.send_replace(tray_model::build_with_open_journal( snapshot, segment_interval, clock.monotonic_seconds(), &health, + open_journal.available(), )); Ok(()) }) @@ -530,12 +536,22 @@ mod tests { } fn sampler(facts: Arc>) -> SyncSampler { + let link_facts = crate::private_link::LinkFacts::default(); + if facts + .lock() + .unwrap() + .link + .as_ref() + .is_some_and(|state| state.observer_registered) + { + link_facts.publish(crate::private_link::LinkFact::ObserverRegistered); + } SyncSampler { facts, clock: Arc::new(TestClock(Arc::new(AtomicU64::new(0)))), stale_threshold: 600.0, poison_reports: Arc::new(std::sync::atomic::AtomicUsize::new(0)), - link_facts: crate::private_link::LinkFacts::default(), + link_facts, } } @@ -610,6 +626,10 @@ mod tests { let seen = Arc::new(Mutex::new(Vec::new())); facts.lock().unwrap().in_progress = true; facts.lock().unwrap().progress = "2/4".into(); + facts.lock().unwrap().link = Some(crate::private_link::LinkFactState { + observer_registered: true, + ..Default::default() + }); let (shutdown, shutdown_rx) = watch::channel(false); let task = tokio::spawn(run_shell_state( receiver, diff --git a/crates/solstone-linux/src/sync.rs b/crates/solstone-linux/src/sync.rs index 8da747c..2af2e54 100644 --- a/crates/solstone-linux/src/sync.rs +++ b/crates/solstone-linux/src/sync.rs @@ -24,7 +24,9 @@ use crate::{ observer::{Clock, HealthBeacon}, private_link::LinkFacts, segment::timestamp_parts, - sync_health::{ErrorType, SyncFacts, SyncHealth, derive_health, load_facts, save_facts}, + sync_health::{ + ErrorType, ProcessEpoch, SyncFacts, SyncHealth, derive_health, load_facts, save_facts, + }, upload::{ListingEntry, UploadClient}, }; @@ -107,10 +109,20 @@ struct SyncControl { } impl SyncService { + #[cfg(test)] pub fn start( config: Config, client: Arc, clock: Arc, + ) -> Self { + Self::start_with_epoch(config, client, clock, ProcessEpoch::generate().ok()) + } + + pub(crate) fn start_with_epoch( + config: Config, + client: Arc, + clock: Arc, + process_epoch: Option, ) -> Self { let notify = Arc::new(Notify::new()); let pending_trigger = Arc::new(AtomicBool::new(false)); @@ -120,6 +132,7 @@ impl SyncService { facts.in_progress = false; facts.progress.clear(); facts.link = Some(link_facts.snapshot()); + facts.link_epoch = process_epoch; if let Err(error) = save_facts(&config.state_dir(), &facts) { tracing::warn!(%error, "Failed to save sync health"); } @@ -964,6 +977,34 @@ fn save_synced_days(state_dir: &Path, days: &HashSet) -> io::Result<()> fs::rename(temp, path) } +#[cfg(test)] +pub(crate) async fn cleanup_synced_day_for_composition( + config: Config, + client: Arc, + clock: Arc, + day: &str, +) -> SyncFacts { + save_synced_days(&config.state_dir(), &HashSet::from([day.to_owned()])).unwrap(); + let facts = Arc::new(Mutex::new(SyncFacts::default())); + let mut worker = SyncWorker::new( + config, + client, + clock, + SyncControl { + notify: Arc::new(Notify::new()), + pending_trigger: Arc::new(AtomicBool::new(false)), + running: Arc::new(AtomicBool::new(true)), + }, + Arc::clone(&facts), + Arc::new(AtomicU8::new(0)), + ); + worker.cleanup_synced_segments().await; + facts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() +} + fn sorted_dirs(root: &Path) -> io::Result> { let mut paths: Vec<_> = fs::read_dir(root)? .filter_map(Result::ok) @@ -1024,6 +1065,7 @@ mod tests { LinkFactState, ObserverState, publish_observer_registration, start_private_link_session, }, private_link_test_peer::PrivateLinkPeer, + sync_health::{HealthState, load_facts_with_liveness}, test_support::{LinkedMockServer, MockServer, MutableClock, wait_for_requests}, upload::ListingFile, }; @@ -1072,6 +1114,8 @@ mod tests { let _guard = poison.lock().unwrap(); panic!("poison sampler facts"); }); + let link_facts = LinkFacts::default(); + link_facts.publish(crate::private_link::LinkFact::ObserverRegistered); let sampler = SyncSampler { facts, clock: Arc::new(FixedClock { @@ -1080,7 +1124,7 @@ mod tests { }), stale_threshold: 600.0, poison_reports: Arc::new(AtomicUsize::new(0)), - link_facts: LinkFacts::default(), + link_facts, }; assert_eq!(sampler.health().dbus, "syncing"); assert_eq!(sampler.progress(), "1/2"); @@ -1936,8 +1980,6 @@ mod tests { ]) .await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), ..Config::default() @@ -1945,6 +1987,7 @@ mod tests { save_synced_days(&config.state_dir(), &HashSet::from(["20260101".to_owned()])).unwrap(); let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -2003,10 +2046,10 @@ mod tests { last_error_code: Some(404), ..SyncFacts::default() }, - "on — update needed", + "on — update required", "NeedsAttention", - "update-needed", - "Sync: update needed — update solstone-linux; pending unconfirmed", + "update-required", + "Sync: update required — update solstone-linux; pending unconfirmed", "fail", ), ]; @@ -2020,6 +2063,83 @@ mod tests { } } + #[tokio::test] + async fn published_link_fact_persists_and_drives_every_owner_surface() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + base_dir: temp.path().to_path_buf(), + config_dir: temp.path().join("config"), + ..Config::default() + }; + fs::create_dir_all(&config.config_dir).unwrap(); + let owner_lock = + crate::private_link::PrivateStateLock::acquire(&config.config_dir).unwrap(); + let server = MockServer::new(Vec::new()).await; + let clock: Arc = Arc::new(FixedClock { + wall: 1_800_000_000.0, + mono: 100.0, + }); + let client = Arc::new(crate::upload::linked_fixture_client_for_test( + &config, + &server.url, + "host", + "linux", + "test", + Arc::clone(&clock), + )); + client.publish_link_fact(crate::private_link::LinkFact::TransportUnavailable); + let service = SyncService::start_with_epoch( + config.clone(), + client, + Arc::clone(&clock), + Some(ProcessEpoch::for_test(7)), + ); + + let sampled = service.sampler_handle().health(); + let liveness = + crate::private_link::PrivateStateLock::try_probe(&config.config_dir).unwrap(); + let persisted = load_facts_with_liveness(&config.state_dir(), liveness); + let reloaded = derive_health(&persisted, 1_800_000_000.0, 600.0); + assert_eq!(sampled.state, HealthState::TransportUnavailable); + assert_eq!(reloaded, sampled); + let model = crate::tray_model::build( + &crate::observer::StateSnapshot { + mode: crate::observer::Mode::Screencast, + paused: false, + segment_open: false, + captures_today: 0, + total_size_mb: 0, + pause_until: None, + segment_start_mono: None, + process_start_mono: 0.0, + }, + 300, + 100.0, + &sampled, + ); + assert_eq!(model.header, sampled.header_recording); + assert_eq!(model.tooltip, format!("on\n{}", sampled.tooltip)); + assert_eq!(model.icon, sampled.icon); + assert_eq!(model.sni_status, sampled.sni_status); + assert_eq!( + sampled.cli, + "Sync: connection unavailable — saving locally; restart sol; if this continues, pair this device again" + ); + assert_eq!(sampled.doctor_severity, "fail"); + assert_eq!( + sampled.doctor_detail, + "sync health: connection unavailable; restart sol; if this continues, pair this device again" + ); + assert_eq!(sampled.dbus, "transport-unavailable"); + assert_eq!( + sampled.accessible_recording, + "sol — on, connection unavailable, saving locally" + ); + + service.shutdown(Duration::from_secs(1)).await.unwrap(); + drop(owner_lock); + } + // tests/test_sync_health_surfaces.py::test_404_query_cycle_drives_failing_state_on_all_surfaces // Named deviation: surface consumption belongs to the tray/CLI/D-Bus lodes. #[tokio::test] @@ -2032,14 +2152,17 @@ mod tests { worker.clock.wall_seconds(), 600.0, ); - assert_eq!(health.state, crate::sync_health::HealthState::UpdateNeeded); + assert_eq!( + health.state, + crate::sync_health::HealthState::UpdateRequired + ); assert_eq!(health.pending_display, "pending unconfirmed"); - assert_eq!(health.header_recording, "on — update needed"); + assert_eq!(health.header_recording, "on — update required"); assert_eq!(health.sni_status, "NeedsAttention"); - assert_eq!(health.dbus, "update-needed"); + assert_eq!(health.dbus, "update-required"); assert_eq!( health.cli, - "Sync: update needed — update solstone-linux; pending unconfirmed" + "Sync: update required — update solstone-linux; pending unconfirmed" ); assert_eq!(health.doctor_severity, "fail"); } @@ -2662,14 +2785,13 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let server = MockServer::new(vec![]).await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), ..Config::default() }; let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -2852,8 +2974,6 @@ mod tests { let legacy = MockServer::new(vec![]).await; let peer = PrivateLinkPeer::start().await; let config = Config { - server_url: legacy.url.clone(), - key: "K".into(), stream: "host".into(), cache_retention_days: 7, base_dir: temp.path().to_path_buf(), @@ -2867,7 +2987,7 @@ mod tests { &session, &ObserverState { credential_instance_id: peer.credential().instance_id, - key: "K".into(), + key: "K".to_owned(), prefix: "prefix".into(), name: "host".into(), ingest_url: "/app/observer/ingest".into(), @@ -2921,8 +3041,6 @@ mod tests { gate.clone(), ); let config = Config { - server_url: legacy.url.clone(), - key: "K".into(), stream: "host".into(), cache_retention_days: 7, base_dir: temp.path().to_path_buf(), @@ -2936,7 +3054,7 @@ mod tests { &session, &ObserverState { credential_instance_id: peer.credential().instance_id, - key: "K".into(), + key: "K".to_owned(), prefix: "prefix".into(), name: "host".into(), ingest_url: "/app/observer/ingest".into(), @@ -3303,14 +3421,13 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let server = MockServer::new(vec![]).await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), ..Config::default() }; let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -3396,14 +3513,13 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let server = MockServer::new(vec![(200, json!({"items":[],"total":0}))]).await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), ..Config::default() }; let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -3432,14 +3548,13 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let server = MockServer::new(vec![(200, json!({"items":[],"total":0}))]).await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), ..Config::default() }; let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -3476,8 +3591,6 @@ mod tests { ]; let server = MockServer::new(responses).await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), cache_retention_days: -1, base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), @@ -3486,6 +3599,7 @@ mod tests { save_synced_days(&config.state_dir(), &HashSet::from(["20260101".to_owned()])).unwrap(); let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -3538,14 +3652,13 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let (server, gate) = MockServer::gated().await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), ..Config::default() }; let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -3586,14 +3699,13 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let (server, _gate) = MockServer::gated().await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), ..Config::default() }; let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -3680,8 +3792,6 @@ mod tests { ]) .await; let config = Config { - server_url: server.url.clone(), - key: "K".to_owned(), base_dir: temp.path().to_path_buf(), config_dir: temp.path().join("config"), ..Config::default() @@ -3689,6 +3799,7 @@ mod tests { save_synced_days(&config.state_dir(), &HashSet::from(["20260101".to_owned()])).unwrap(); let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", @@ -3729,7 +3840,6 @@ mod tests { } let temp = tempfile::tempdir().unwrap(); let (server, mut worker) = test_worker(&temp, vec![], -1).await; - worker.config.key.clear(); let config = worker.config.clone(); worker.client = Arc::new(crate::upload::capability_less_client_for_test( &config, @@ -3781,8 +3891,6 @@ mod tests { ]) .await; let config = Config { - server_url: server.url.clone(), - key: "K".into(), base_dir: temp.path().into(), config_dir: temp.path().join("config"), ..Config::default() @@ -3798,6 +3906,7 @@ mod tests { .unwrap(); let client = Arc::new(crate::upload::linked_fixture_client_for_test( &config, + &server.url, "host", "linux", "test", diff --git a/crates/solstone-linux/src/sync_health.rs b/crates/solstone-linux/src/sync_health.rs index 8553607..8eadb7f 100644 --- a/crates/solstone-linux/src/sync_health.rs +++ b/crates/solstone-linux/src/sync_health.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (c) 2026 sol pbc -use crate::private_link::LinkFactState; +use crate::private_link::{LinkFactState, PrivateStateLockLiveness}; use chrono::{DateTime, Local}; use serde_json::{Map, Value, json}; use std::{ @@ -12,8 +12,7 @@ use std::{ sync::LazyLock, }; -// Decision 4: no new ErrorType means no new persisted string, version bump, or downgrade loss. -pub const SCHEMA_VERSION: u64 = 1; +pub const SCHEMA_VERSION: u64 = 2; #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum ErrorType { @@ -46,13 +45,46 @@ impl ErrorType { #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum HealthState { - Connected, - Syncing, + UnsafeLinkState, + RePairRequired, + TokenPersistenceFailed, + PairingRequired, + UpdateRequired, + TransportUnavailable, Offline, - UpdateNeeded, - Revoked, - Stale, - Unknown, + ListenerReady, + Connecting, + Syncing, + Connected, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProcessEpoch(String); + +impl ProcessEpoch { + pub(crate) fn generate() -> io::Result { + use std::io::Read; + let mut bytes = [0_u8; 32]; + fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; + Ok(Self( + bytes.iter().map(|byte| format!("{byte:02x}")).collect(), + )) + } + + #[cfg(test)] + pub(crate) fn for_test(value: u8) -> Self { + Self(format!("{value:02x}").repeat(32)) + } + + fn parse(value: &Value) -> Option { + let value = value.as_str()?; + (value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| Self(value.to_ascii_lowercase())) + } + + fn as_str(&self) -> &str { + &self.0 + } } #[derive(Clone, Debug, Default, PartialEq)] @@ -66,6 +98,8 @@ pub struct SyncFacts { pub progress: String, #[doc(hidden)] pub(crate) link: Option, + #[doc(hidden)] + pub(crate) link_epoch: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -107,127 +141,196 @@ pub struct SyncHealth { pub static SURFACE_BY_STATE: LazyLock> = LazyLock::new(|| { HashMap::from([ ( - HealthState::Connected, + HealthState::UnsafeLinkState, HealthSurface { - header_recording: "on — connected", - header_idle: "idle — connected", - sync_line: "sync: up to date", - tooltip: "sync: up to date", - accessible_recording: "sol — on, sync up to date", - accessible_idle: "sol — idle, sync up to date", - icon: "recording", - sni: "Active", - cli: "Sync: connected — up to date (0 pending)", - doctor_severity: "ok", - doctor_detail: "sync health: up to date; 0 pending confirmed at {sync_ts}", - dbus: "connected", + header_recording: "on — private state unsafe", + header_idle: "idle — private state unsafe", + sync_line: "sync: private state unsafe", + tooltip: "sync: private state unsafe; repair private state and restart sol", + accessible_recording: "sol — on, private state unsafe", + accessible_idle: "sol — idle, private state unsafe", + icon: "error", + sni: "NeedsAttention", + cli: "Sync: private state unsafe — repair private state and restart sol; pending unconfirmed", + doctor_severity: "fail", + doctor_detail: "sync health: private state unsafe; repair private state and restart sol", + dbus: "unsafe-link-state", }, ), ( - HealthState::Syncing, + HealthState::RePairRequired, HealthSurface { - header_recording: "on — syncing", - header_idle: "idle — syncing", - sync_line: "sync: {progress}", - tooltip: "sync: {progress}", - accessible_recording: "sol — on, syncing", - accessible_idle: "sol — idle, syncing", - icon: "syncing", - sni: "Active", - cli: "Sync: syncing — pending unconfirmed until this pass finishes", - doctor_severity: "ok", - doctor_detail: "sync health: sync pass active; pending unconfirmed until check completes", - dbus: "syncing", + header_recording: "on — pair again", + header_idle: "idle — pair again", + sync_line: "sync: pair again", + tooltip: "sync: pair this device with your journal again", + accessible_recording: "sol — on, pair again", + accessible_idle: "sol — idle, pair again", + icon: "error", + sni: "NeedsAttention", + cli: "Sync: pair again — pair this device with your journal again; pending unconfirmed", + doctor_severity: "fail", + doctor_detail: "sync health: pair this device with your journal again", + dbus: "re-pair-required", }, ), ( - HealthState::Offline, + HealthState::TokenPersistenceFailed, HealthSurface { - header_recording: "on — offline (saving locally)", - header_idle: "idle — offline (saving locally)", - sync_line: "sync: offline; will retry", - tooltip: "sync: offline; saving locally", - accessible_recording: "sol — on, offline, saving locally", - accessible_idle: "sol — idle, offline, saving locally", - icon: "syncing", - sni: "Active", - cli: "Sync: offline — saving locally; pending unconfirmed (will retry)", - doctor_severity: "warn", - doctor_detail: "sync health: offline; pending unconfirmed; will retry", - dbus: "offline", + header_recording: "on — private state not saved", + header_idle: "idle — private state not saved", + sync_line: "sync: private state not saved", + tooltip: "sync: private state not saved; fix private state permissions, then restart sol", + accessible_recording: "sol — on, private state not saved", + accessible_idle: "sol — idle, private state not saved", + icon: "error", + sni: "NeedsAttention", + cli: "Sync: private state not saved — fix private state permissions, then restart sol; pending unconfirmed", + doctor_severity: "fail", + doctor_detail: "sync health: private state not saved; fix private state permissions, then restart sol", + dbus: "token-persistence-failed", }, ), ( - HealthState::UpdateNeeded, + HealthState::PairingRequired, HealthSurface { - header_recording: "on — update needed", - header_idle: "idle — update needed", - sync_line: "sync: update solstone-linux", - tooltip: "sync: update needed; update solstone-linux", - accessible_recording: "sol — on, update needed", - accessible_idle: "sol — idle, update needed", + header_recording: "on — pairing required", + header_idle: "idle — pairing required", + sync_line: "sync: pairing required", + tooltip: "sync: pair this device with your journal", + accessible_recording: "sol — on, pairing required", + accessible_idle: "sol — idle, pairing required", icon: "error", sni: "NeedsAttention", - cli: "Sync: update needed — update solstone-linux; pending unconfirmed", + cli: "Sync: pairing required — pair this device with your journal; pending unconfirmed", doctor_severity: "fail", - doctor_detail: "sync health: update needed; the journal returned 404", - dbus: "update-needed", + doctor_detail: "sync health: pair this device with your journal", + dbus: "pairing-required", }, ), ( - HealthState::Revoked, + HealthState::UpdateRequired, HealthSurface { - header_recording: "on — re-auth needed", - header_idle: "idle — re-auth needed", - sync_line: "sync: re-auth required", - tooltip: "sync: access revoked; re-auth required", - accessible_recording: "sol — on, re-auth required", - accessible_idle: "sol — idle, re-auth required", + header_recording: "on — update required", + header_idle: "idle — update required", + sync_line: "sync: update solstone-linux", + tooltip: "sync: update required; update solstone-linux", + accessible_recording: "sol — on, update required", + accessible_idle: "sol — idle, update required", icon: "error", sni: "NeedsAttention", - cli: "Sync: revoked — re-auth required; pending unconfirmed", + cli: "Sync: update required — update solstone-linux; pending unconfirmed", doctor_severity: "fail", - doctor_detail: "sync health: access revoked; re-auth required", - dbus: "revoked", + doctor_detail: "sync health: update required; update solstone-linux", + dbus: "update-required", }, ), ( - HealthState::Stale, + HealthState::TransportUnavailable, HealthSurface { - header_recording: "on — sync stale", - header_idle: "idle — sync stale", - sync_line: "sync: stale; no journal response in {contact_age}", - tooltip: "sync: stale; last contact {contact_ts}", - accessible_recording: "sol — on, sync stale", - accessible_idle: "sol — idle, sync stale", + header_recording: "on — connection unavailable (saving locally)", + header_idle: "idle — connection unavailable (saving locally)", + sync_line: "sync: connection unavailable; saving locally", + tooltip: "sync: connection unavailable; restart sol; if this continues, pair this device again", + accessible_recording: "sol — on, connection unavailable, saving locally", + accessible_idle: "sol — idle, connection unavailable, saving locally", icon: "error", sni: "NeedsAttention", - cli: "Sync: stale — no journal response in {contact_age}; check service and journal", + cli: "Sync: connection unavailable — saving locally; restart sol; if this continues, pair this device again", doctor_severity: "fail", - doctor_detail: "sync health: stale; last contact {contact_ts}, threshold {threshold}", - dbus: "stale", + doctor_detail: "sync health: connection unavailable; restart sol; if this continues, pair this device again", + dbus: "transport-unavailable", + }, + ), + ( + HealthState::Offline, + HealthSurface { + header_recording: "on — offline (saving locally)", + header_idle: "idle — offline (saving locally)", + sync_line: "sync: offline; will retry", + tooltip: "sync: offline; saving locally", + accessible_recording: "sol — on, offline, saving locally", + accessible_idle: "sol — idle, offline, saving locally", + icon: "syncing", + sni: "Active", + cli: "Sync: offline — saving locally; pending unconfirmed (will retry)", + doctor_severity: "warn", + doctor_detail: "sync health: offline; pending unconfirmed; will retry", + dbus: "offline", + }, + ), + ( + HealthState::ListenerReady, + HealthSurface { + header_recording: "on — confirming device", + header_idle: "idle — confirming device", + sync_line: "sync: confirming device", + tooltip: "sync: wait while sol confirms this device with your journal", + accessible_recording: "sol — on, confirming device", + accessible_idle: "sol — idle, confirming device", + icon: "syncing", + sni: "Active", + cli: "Sync: confirming device — wait while sol confirms this device with your journal; pending unconfirmed", + doctor_severity: "warn", + doctor_detail: "sync health: wait while sol confirms this device with your journal", + dbus: "listener-ready", }, ), ( - HealthState::Unknown, + HealthState::Connecting, HealthSurface { - header_recording: "on — sync unconfirmed", - header_idle: "idle — sync unconfirmed", + header_recording: "on — connecting", + header_idle: "idle — connecting", sync_line: "sync: checking...", - tooltip: "sync: not confirmed yet", - accessible_recording: "sol — on, sync unconfirmed", - accessible_idle: "sol — idle, sync unconfirmed", + tooltip: "sync: wait while sol connects to your journal", + accessible_recording: "sol — on, connecting", + accessible_idle: "sol — idle, connecting", icon: "syncing", sni: "Active", cli: "Sync: unconfirmed — waiting for first successful journal check; pending unconfirmed", doctor_severity: "warn", doctor_detail: "sync health: unconfirmed; no successful journal check yet", - dbus: "unknown", + dbus: "connecting", + }, + ), + ( + HealthState::Syncing, + HealthSurface { + header_recording: "on — syncing", + header_idle: "idle — syncing", + sync_line: "sync: {progress}", + tooltip: "sync: {progress}", + accessible_recording: "sol — on, syncing", + accessible_idle: "sol — idle, syncing", + icon: "syncing", + sni: "Active", + cli: "Sync: syncing — pending unconfirmed until this pass finishes", + doctor_severity: "ok", + doctor_detail: "sync health: sync pass active; pending unconfirmed until check completes", + dbus: "syncing", + }, + ), + ( + HealthState::Connected, + HealthSurface { + header_recording: "on — connected", + header_idle: "idle — connected", + sync_line: "sync: up to date", + tooltip: "sync: up to date", + accessible_recording: "sol — on, sync up to date", + accessible_idle: "sol — idle, sync up to date", + icon: "recording", + sni: "Active", + cli: "Sync: connected — up to date (0 pending)", + doctor_severity: "ok", + doctor_detail: "sync health: up to date; 0 pending confirmed at {sync_ts}", + dbus: "connected", }, ), ]) }); +#[cfg(test)] fn format_age(seconds: Option) -> String { let Some(seconds) = seconds else { return "unknown".to_owned(); @@ -286,39 +389,39 @@ fn fill(template: &str, values: &HashMap<&str, String>) -> String { } pub fn derive_health(facts: &SyncFacts, now: f64, stale_threshold: f64) -> SyncHealth { - let state = - if facts.last_error_class == Some(ErrorType::Auth) && facts.last_error_code != Some(401) { - HealthState::Revoked - } else if facts.last_error_class == Some(ErrorType::Incompatible) { - HealthState::UpdateNeeded - } else if facts - .last_successful_contact - .is_some_and(|contact| now - contact > stale_threshold) - { - HealthState::Stale - } else if facts.in_progress { - HealthState::Syncing - } else if facts.last_error_class == Some(ErrorType::Auth) { - // Decision 3: a 401 is explicit Unknown above Connected so persisted empty-queue - // facts cannot repaint a refused identity green. The live worker also clears - // pending_confirmed in record_failure. This depends on POST propagation retaining - // Some(401); losing that code would conservatively repaint the failure Revoked. - HealthState::Unknown - } else if facts.pending_confirmed == Some(0) - && facts.link.as_ref().is_some_and(|link| { - link.carrier_proven - && link.observer_registered - && !link.transport_unavailable - && !link.terminal_revocation - && !link.token_persistence_failure - }) - { - HealthState::Connected - } else if facts.last_error_class == Some(ErrorType::Transient) { - HealthState::Offline - } else { - HealthState::Unknown - }; + let link = facts.link.clone().unwrap_or_default(); + let stale = facts + .last_successful_contact + .is_some_and(|contact| now - contact > stale_threshold); + let terminal_auth = + facts.last_error_class == Some(ErrorType::Auth) && facts.last_error_code != Some(401); + let connected = link.carrier_proven + && link.observer_registered + && facts.pending_confirmed == Some(0) + && !facts.in_progress; + let state = if link.private_state_invalid || link.config_sanitation_failed { + HealthState::UnsafeLinkState + } else if link.terminal_revocation || terminal_auth { + HealthState::RePairRequired + } else if link.token_persistence_failure { + HealthState::TokenPersistenceFailed + } else if link.pairing_required { + HealthState::PairingRequired + } else if facts.last_error_class == Some(ErrorType::Incompatible) { + HealthState::UpdateRequired + } else if link.transport_unavailable { + HealthState::TransportUnavailable + } else if facts.last_error_class == Some(ErrorType::Transient) || stale { + HealthState::Offline + } else if link.listener_ready && !link.observer_registered { + HealthState::ListenerReady + } else if !link.observer_registered { + HealthState::Connecting + } else if connected { + HealthState::Connected + } else { + HealthState::Syncing + }; let surface = SURFACE_BY_STATE .get(&state) .expect("every health state must have a surface"); @@ -330,12 +433,6 @@ pub fn derive_health(facts: &SyncFacts, now: f64, stale_threshold: f64) -> SyncH let values = HashMap::from([ ("progress", progress.to_owned()), ("sync_ts", format_ts(facts.last_successful_sync)), - ("contact_ts", format_ts(facts.last_successful_contact)), - ( - "contact_age", - format_age(facts.last_successful_contact.map(|value| now - value)), - ), - ("threshold", format_age(Some(stale_threshold))), ]); SyncHealth { state, @@ -374,23 +471,54 @@ fn optional_int(data: &Map, key: &str) -> Option { data.get(key).and_then(Value::as_i64) } -fn load_link_facts(data: &Map) -> Option { - let link = data.get("link")?.as_object()?; - let boolean = |key| link.get(key).and_then(Value::as_bool).unwrap_or(false); - Some(LinkFactState { - pairing_required: boolean("pairing_required"), - private_state_invalid: boolean("private_state_invalid"), - config_sanitation_failed: boolean("config_sanitation_failed"), - listener_ready: boolean("listener_ready"), - carrier_proven: boolean("carrier_proven"), - observer_registered: boolean("observer_registered"), - transport_unavailable: boolean("transport_unavailable"), - terminal_revocation: boolean("terminal_revocation"), - token_persistence_failure: boolean("token_persistence_failure"), - }) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum HealthLoadError { + MalformedLinkEpoch, + MalformedLink, +} + +pub(crate) fn load_link_facts( + data: &Map, + liveness: PrivateStateLockLiveness, +) -> Result, HealthLoadError> { + if liveness != PrivateStateLockLiveness::LiveOwner { + return Ok(None); + } + ProcessEpoch::parse( + data.get("link_epoch") + .ok_or(HealthLoadError::MalformedLinkEpoch)?, + ) + .ok_or(HealthLoadError::MalformedLinkEpoch)?; + let link = data + .get("link") + .and_then(Value::as_object) + .ok_or(HealthLoadError::MalformedLink)?; + let boolean = |key| { + link.get(key) + .and_then(Value::as_bool) + .ok_or(HealthLoadError::MalformedLink) + }; + Ok(Some(LinkFactState { + pairing_required: boolean("pairing_required")?, + private_state_invalid: boolean("private_state_invalid")?, + config_sanitation_failed: boolean("config_sanitation_failed")?, + listener_ready: boolean("listener_ready")?, + carrier_proven: boolean("carrier_proven")?, + observer_registered: boolean("observer_registered")?, + transport_unavailable: boolean("transport_unavailable")?, + terminal_revocation: boolean("terminal_revocation")?, + token_persistence_failure: boolean("token_persistence_failure")?, + })) } pub fn load_facts(state_dir: &Path) -> SyncFacts { + load_facts_with_liveness(state_dir, PrivateStateLockLiveness::NoLiveOwner) +} + +pub(crate) fn load_facts_with_liveness( + state_dir: &Path, + liveness: PrivateStateLockLiveness, +) -> SyncFacts { let Ok(text) = fs::read_to_string(sync_health_path(state_dir)) else { return SyncFacts::default(); }; @@ -412,7 +540,8 @@ pub fn load_facts(state_dir: &Path) -> SyncFacts { .and_then(Value::as_str) .unwrap_or_default() .to_owned(), - link: load_link_facts(&data), + link: load_link_facts(&data, liveness).unwrap_or(None), + link_epoch: ProcessEpoch::parse(data.get("link_epoch").unwrap_or(&Value::Null)), } } @@ -420,6 +549,23 @@ pub fn save_facts(state_dir: &Path, facts: &SyncFacts) -> io::Result<()> { fs::create_dir_all(state_dir)?; let path = sync_health_path(state_dir); let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + let link = facts + .link + .as_ref() + .zip(facts.link_epoch.as_ref()) + .map(|(link, _)| { + json!({ + "pairing_required": link.pairing_required, + "private_state_invalid": link.private_state_invalid, + "config_sanitation_failed": link.config_sanitation_failed, + "listener_ready": link.listener_ready, + "carrier_proven": link.carrier_proven, + "observer_registered": link.observer_registered, + "transport_unavailable": link.transport_unavailable, + "terminal_revocation": link.terminal_revocation, + "token_persistence_failure": link.token_persistence_failure, + }) + }); let mut text = serde_json::to_string(&json!({ "schema_version": SCHEMA_VERSION, "last_successful_sync": facts.last_successful_sync, @@ -429,17 +575,8 @@ pub fn save_facts(state_dir: &Path, facts: &SyncFacts) -> io::Result<()> { "pending_confirmed": facts.pending_confirmed, "in_progress": facts.in_progress, "progress": facts.progress, - "link": facts.link.as_ref().map(|link| json!({ - "pairing_required": link.pairing_required, - "private_state_invalid": link.private_state_invalid, - "config_sanitation_failed": link.config_sanitation_failed, - "listener_ready": link.listener_ready, - "carrier_proven": link.carrier_proven, - "observer_registered": link.observer_registered, - "transport_unavailable": link.transport_unavailable, - "terminal_revocation": link.terminal_revocation, - "token_persistence_failure": link.token_persistence_failure, - })), + "link_epoch": facts.link_epoch.as_ref().map(ProcessEpoch::as_str), + "link": link, })) .map_err(io::Error::other)?; text.push('\n'); @@ -462,7 +599,7 @@ mod tests { 1000.0, DEFAULT_SYNC_STALE_THRESHOLD as f64, ); - assert_eq!(health.state, HealthState::Unknown); + assert_eq!(health.state, HealthState::Connecting); assert_eq!(health.sni_status, "Active"); assert_eq!(health.pending_display, "pending unconfirmed"); } @@ -479,7 +616,7 @@ mod tests { }; assert_eq!( derive_health(&facts, 1000.0, DEFAULT_SYNC_STALE_THRESHOLD as f64).state, - HealthState::Unknown + HealthState::ListenerReady ); } @@ -505,8 +642,8 @@ mod tests { #[test] fn error_precedence_states() { for (error, expected) in [ - (ErrorType::Auth, HealthState::Revoked), - (ErrorType::Incompatible, HealthState::UpdateNeeded), + (ErrorType::Auth, HealthState::RePairRequired), + (ErrorType::Incompatible, HealthState::UpdateRequired), (ErrorType::Transient, HealthState::Offline), ] { let facts = SyncFacts { @@ -550,11 +687,11 @@ mod tests { let timeout = state_for(ErrorType::Transient, None); let forbidden = state_for(ErrorType::Auth, Some(403)); - assert_ne!(unauthorized, HealthState::Revoked); + assert_ne!(unauthorized, HealthState::RePairRequired); assert_ne!(unauthorized, HealthState::Connected); assert_ne!(unauthorized, server_error); assert_ne!(unauthorized, timeout); - assert_eq!(forbidden, HealthState::Revoked); + assert_eq!(forbidden, HealthState::RePairRequired); } } @@ -568,9 +705,9 @@ mod tests { ..SyncFacts::default() }; let health = derive_health(&facts, 1000.0, DEFAULT_SYNC_STALE_THRESHOLD as f64); - assert_eq!(health.state, HealthState::Stale); - assert_eq!(health.sni_status, "NeedsAttention"); - assert!(health.tooltip.contains("last contact")); + assert_eq!(health.state, HealthState::Offline); + assert_eq!(health.sni_status, "Active"); + assert!(health.tooltip.contains("saving locally")); } // Connected requires both zero pending custody and positive linked-transport evidence. @@ -596,7 +733,7 @@ mod tests { DEFAULT_SYNC_STALE_THRESHOLD as f64, ) .state, - HealthState::Unknown + HealthState::Connecting ); } @@ -607,6 +744,10 @@ mod tests { last_successful_sync: Some(100.0), last_successful_contact: Some(990.0), in_progress: true, + link: Some(LinkFactState { + observer_registered: true, + ..Default::default() + }), ..SyncFacts::default() }; assert_eq!( @@ -628,6 +769,7 @@ mod tests { in_progress: true, progress: "uploading 120000_300".to_owned(), link: None, + link_epoch: None, }; save_facts(temp.path(), &facts).unwrap(); assert_eq!(load_facts(temp.path()), facts); @@ -645,7 +787,7 @@ mod tests { // tests/test_sync_health.py::test_every_health_state_has_complete_surface #[test] fn every_health_state_has_complete_surface() { - assert_eq!(SURFACE_BY_STATE.len(), 7); + assert_eq!(SURFACE_BY_STATE.len(), 11); for surface in SURFACE_BY_STATE.values() { assert!(!surface.header_recording.is_empty()); assert!(!surface.header_idle.is_empty()); @@ -662,6 +804,237 @@ mod tests { } } + #[test] + fn precedence_table_covers_single_and_conflicting_conditions() { + let state = |facts: SyncFacts| derive_health(&facts, 1_000.0, 600.0).state; + let link = |link: LinkFactState| SyncFacts { + link: Some(link), + ..Default::default() + }; + let single_cases = [ + ( + link(LinkFactState { + private_state_invalid: true, + ..Default::default() + }), + HealthState::UnsafeLinkState, + ), + ( + link(LinkFactState { + config_sanitation_failed: true, + ..Default::default() + }), + HealthState::UnsafeLinkState, + ), + ( + link(LinkFactState { + terminal_revocation: true, + ..Default::default() + }), + HealthState::RePairRequired, + ), + ( + link(LinkFactState { + token_persistence_failure: true, + ..Default::default() + }), + HealthState::TokenPersistenceFailed, + ), + ( + link(LinkFactState { + pairing_required: true, + ..Default::default() + }), + HealthState::PairingRequired, + ), + ( + SyncFacts { + last_error_class: Some(ErrorType::Incompatible), + ..Default::default() + }, + HealthState::UpdateRequired, + ), + ( + link(LinkFactState { + transport_unavailable: true, + ..Default::default() + }), + HealthState::TransportUnavailable, + ), + ( + SyncFacts { + last_error_class: Some(ErrorType::Transient), + ..Default::default() + }, + HealthState::Offline, + ), + ( + link(LinkFactState { + listener_ready: true, + ..Default::default() + }), + HealthState::ListenerReady, + ), + ( + link(LinkFactState { + carrier_proven: true, + ..Default::default() + }), + HealthState::Connecting, + ), + ( + link(LinkFactState { + observer_registered: true, + ..Default::default() + }), + HealthState::Syncing, + ), + ]; + for (facts, expected) in single_cases { + assert_eq!(state(facts), expected); + } + + let all = LinkFactState { + pairing_required: true, + private_state_invalid: true, + config_sanitation_failed: true, + listener_ready: true, + carrier_proven: true, + observer_registered: true, + transport_unavailable: true, + terminal_revocation: true, + token_persistence_failure: true, + }; + let conflicting_cases = [ + (all.clone(), HealthState::UnsafeLinkState), + ( + LinkFactState { + private_state_invalid: false, + config_sanitation_failed: false, + ..all.clone() + }, + HealthState::RePairRequired, + ), + ( + LinkFactState { + private_state_invalid: false, + config_sanitation_failed: false, + terminal_revocation: false, + ..all.clone() + }, + HealthState::TokenPersistenceFailed, + ), + ( + LinkFactState { + private_state_invalid: false, + config_sanitation_failed: false, + terminal_revocation: false, + token_persistence_failure: false, + ..all.clone() + }, + HealthState::PairingRequired, + ), + ( + LinkFactState { + private_state_invalid: false, + config_sanitation_failed: false, + terminal_revocation: false, + token_persistence_failure: false, + pairing_required: false, + ..all + }, + HealthState::UpdateRequired, + ), + ]; + for (link, expected) in conflicting_cases { + let facts = SyncFacts { + last_error_class: Some(ErrorType::Incompatible), + pending_confirmed: Some(0), + link: Some(link), + ..Default::default() + }; + assert_eq!(state(facts), expected); + } + } + + #[test] + fn replayed_connected_facts_never_override_six_current_failures() { + let temp = tempfile::tempdir().unwrap(); + save_facts( + temp.path(), + &SyncFacts { + pending_confirmed: Some(0), + link: Some(LinkFactState { + carrier_proven: true, + observer_registered: true, + ..Default::default() + }), + link_epoch: Some(ProcessEpoch::for_test(7)), + ..Default::default() + }, + ) + .unwrap(); + let replayed = load_facts_with_liveness(temp.path(), PrivateStateLockLiveness::NoLiveOwner); + assert!(replayed.link.is_none()); + let cases = [ + ( + LinkFactState { + pairing_required: true, + ..Default::default() + }, + HealthState::PairingRequired, + ), + ( + LinkFactState { + private_state_invalid: true, + ..Default::default() + }, + HealthState::UnsafeLinkState, + ), + ( + LinkFactState { + config_sanitation_failed: true, + ..Default::default() + }, + HealthState::UnsafeLinkState, + ), + ( + LinkFactState { + terminal_revocation: true, + ..Default::default() + }, + HealthState::RePairRequired, + ), + ( + LinkFactState { + token_persistence_failure: true, + ..Default::default() + }, + HealthState::TokenPersistenceFailed, + ), + ( + LinkFactState { + transport_unavailable: true, + ..Default::default() + }, + HealthState::TransportUnavailable, + ), + ]; + for (current, expected) in cases { + let health = derive_health( + &SyncFacts { + link: Some(current), + ..replayed.clone() + }, + 1_000.0, + 600.0, + ); + assert_eq!(health.state, expected); + assert_ne!(health.state, HealthState::Connected); + assert!(!health.sync_line.contains("checking")); + } + } + // AC: invalid fields fall back independently and signed pending values round-trip. #[test] fn field_parse_failures_are_independent() { @@ -693,6 +1066,10 @@ mod tests { let facts = SyncFacts { in_progress: true, progress: " ".to_owned(), + link: Some(LinkFactState { + observer_registered: true, + ..Default::default() + }), ..SyncFacts::default() }; let health = derive_health(&facts, 1.0, DEFAULT_SYNC_STALE_THRESHOLD as f64); diff --git a/crates/solstone-linux/src/test_support.rs b/crates/solstone-linux/src/test_support.rs index 47f08c6..c29726c 100644 --- a/crates/solstone-linux/src/test_support.rs +++ b/crates/solstone-linux/src/test_support.rs @@ -49,7 +49,7 @@ impl OpportunisticDefaultListenerTrap { } Err(error) => { eprintln!( - "criterion 12 note: localhost:5015 opportunistic zero-connection clause did not execute: {error}" + "criterion 12 note: opportunistic default-listener trap did not execute: {error}" ); Self(None) } @@ -236,8 +236,6 @@ impl LinkedMockServer { pub(crate) enum Action { Response(u16, Value), Raw(u16, &'static str), - OwnedRaw(u16, String), - Disconnect, Stream(u16, mpsc::Receiver>), } @@ -258,8 +256,6 @@ impl MockServer { .map(|action| match action { Action::Response(status, body) => (*status, body.to_string().into_bytes()), Action::Raw(status, body) => (*status, body.as_bytes().to_vec()), - Action::OwnedRaw(status, body) => (*status, body.as_bytes().to_vec()), - Action::Disconnect => (503, Vec::new()), Action::Stream(status, _) => (*status, Vec::new()), }) .collect(); @@ -315,25 +311,6 @@ impl MockServer { .map_err(|never| match never {}) .boxed(), ), - Action::OwnedRaw(status, body) => ( - status, - Full::new(Bytes::from(body)) - .map_err(|never| match never {}) - .boxed(), - ), - Action::Disconnect => { - return Err::< - Response< - http_body_util::combinators::BoxBody, - >, - _, - >( - std::io::Error::new( - std::io::ErrorKind::ConnectionAborted, - "mock disconnect", - ), - ); - } Action::Stream(status, receiver) => { (status, ReceiverBody(receiver).boxed()) } diff --git a/crates/solstone-linux/src/toolchain_policy_tests.rs b/crates/solstone-linux/src/toolchain_policy_tests.rs index 084f00e..4730a8e 100644 --- a/crates/solstone-linux/src/toolchain_policy_tests.rs +++ b/crates/solstone-linux/src/toolchain_policy_tests.rs @@ -748,6 +748,32 @@ fn dependency_policy_pins_the_single_spl_git_source() { } } +#[test] +fn app_reqwest_edge_has_no_direct_tls_feature() { + let manifest = read_toml(&workspace_root().join("crates/solstone-linux/Cargo.toml")); + let reqwest = &manifest["dependencies"]["reqwest"]; + assert_eq!(reqwest["default-features"].as_bool(), Some(false)); + assert_eq!( + reqwest["features"] + .as_array() + .unwrap() + .iter() + .map(|feature| feature.as_str().unwrap()) + .collect::>(), + ["cookies", "json", "multipart", "stream"] + ); + + // This is deliberately an assertion about the application's direct reqwest + // edge. The pinned S/PL crates may retain TLS and crypto in their own + // dependency closure; this policy does not assert whole-lockfile absence. + for name in ["spl-core", "spl-transport"] { + assert_eq!( + manifest["dependencies"][name]["rev"].as_str(), + Some("742bc9dc789c5a75658844849a04d75033aeb6e3") + ); + } +} + #[test] fn observer_contract_gate_is_locked_offline_and_named() { let makefile = fs::read_to_string(workspace_root().join("Makefile")).unwrap(); diff --git a/crates/solstone-linux/src/tray.rs b/crates/solstone-linux/src/tray.rs index 55a82db..b836333 100644 --- a/crates/solstone-linux/src/tray.rs +++ b/crates/solstone-linux/src/tray.rs @@ -120,7 +120,15 @@ impl Tray for KsniTray { .into(), MenuItem::Separator, status.into(), - action("open journal", TrayCommand::OpenJournal).into(), + StandardItem { + label: "open journal".into(), + enabled: self.model.open_journal_enabled, + activate: Box::new(|tray: &mut KsniTray| { + let _ = tray.commands.send(TrayCommand::OpenJournal); + }), + ..Default::default() + } + .into(), SubMenu { label: "settings".into(), submenu: vec![action("open config.json", TrayCommand::OpenConfig).into()], @@ -209,6 +217,7 @@ mod tests { #[test] fn menu_contains_reference_top_level_structure() { let mut tray = tray(); + tray.model.open_journal_enabled = true; let menu = tray.menu(); assert_eq!(menu.len(), 11); let labels: Vec<&str> = menu @@ -274,6 +283,7 @@ mod tests { ] ); if let MenuItem::Standard(value) = &menu[6] { + assert!(value.enabled); (value.activate)(&mut tray); } assert_eq!(receiver.try_recv(), Ok(TrayCommand::OpenJournal)); @@ -293,6 +303,19 @@ mod tests { assert_eq!(receiver.try_recv(), Ok(expected)); } } + + #[test] + fn open_journal_menu_enablement_follows_model_epoch() { + let mut tray = tray(); + for expected in [false, true, false] { + tray.model.open_journal_enabled = expected; + let menu = tray.menu(); + let MenuItem::Standard(open_journal) = &menu[6] else { + panic!("open journal item missing"); + }; + assert_eq!(open_journal.enabled, expected); + } + } } // Python tray provenance (35/35): @@ -336,4 +359,3 @@ mod tests { // test_update_shows_paused -> tray_model::tests::paused_and_idle_snapshots_select_typed_status. // test_config_paths_use_base_dir -> desktop_component::tests::component_uses_config. // test_agent_instructions_template_uses_config_values -> clipboard::tests::instructions_use_config_values. -// test_open_journal_uses_public_site_when_server_url_empty -> desktop_component::tests::public_journal_fallback. diff --git a/crates/solstone-linux/src/tray_model.rs b/crates/solstone-linux/src/tray_model.rs index f6f47fe..e8bcbf6 100644 --- a/crates/solstone-linux/src/tray_model.rs +++ b/crates/solstone-linux/src/tray_model.rs @@ -30,6 +30,7 @@ pub struct TrayModel { pub resume: String, pub pause_visible: bool, pub resume_visible: bool, + pub open_journal_enabled: bool, } pub fn status(snapshot: &StateSnapshot) -> TrayStatus { @@ -124,6 +125,16 @@ pub fn icon_name(status: TrayStatus, health: &SyncHealth) -> String { } pub fn build(snapshot: &StateSnapshot, interval: i64, now: f64, health: &SyncHealth) -> TrayModel { + build_with_open_journal(snapshot, interval, now, health, false) +} + +pub fn build_with_open_journal( + snapshot: &StateSnapshot, + interval: i64, + now: f64, + health: &SyncHealth, + open_journal_enabled: bool, +) -> TrayModel { let status = status(snapshot); let segment = segment_remaining(snapshot, interval, now); let pause = pause_remaining(snapshot, now); @@ -146,6 +157,7 @@ pub fn build(snapshot: &StateSnapshot, interval: i64, now: f64, health: &SyncHea }, pause_visible: status != TrayStatus::Paused, resume_visible: status == TrayStatus::Paused, + open_journal_enabled, } } @@ -193,6 +205,10 @@ mod tests { let syncing = derive_health( &SyncFacts { in_progress: true, + link: Some(LinkFactState { + observer_registered: true, + ..Default::default() + }), ..Default::default() }, 100.0, @@ -251,6 +267,10 @@ mod tests { ( SyncFacts { in_progress: true, + link: Some(LinkFactState { + observer_registered: true, + ..Default::default() + }), ..Default::default() }, HealthState::Syncing, @@ -267,23 +287,23 @@ mod tests { last_error_class: Some(ErrorType::Incompatible), ..Default::default() }, - HealthState::UpdateNeeded, + HealthState::UpdateRequired, ), ( SyncFacts { last_error_class: Some(ErrorType::Auth), ..Default::default() }, - HealthState::Revoked, + HealthState::RePairRequired, ), ( SyncFacts { last_successful_contact: Some(0.0), ..Default::default() }, - HealthState::Stale, + HealthState::Offline, ), - (SyncFacts::default(), HealthState::Unknown), + (SyncFacts::default(), HealthState::Connecting), ]; for (facts, expected_state) in cases { let health = derive_health(&facts, 1_000.0, 600.0); @@ -329,6 +349,10 @@ mod tests { &SyncFacts { in_progress: true, progress: "2/5".into(), + link: Some(LinkFactState { + observer_registered: true, + ..Default::default() + }), ..Default::default() }, 100.0, @@ -412,6 +436,10 @@ mod tests { ( SyncFacts { in_progress: true, + link: Some(LinkFactState { + observer_registered: true, + ..Default::default() + }), ..Default::default() }, ["syncing", "syncing", "paused", "stopped"], @@ -442,7 +470,7 @@ mod tests { last_successful_contact: Some(0.0), ..Default::default() }, - ["error", "error", "error", "error"], + ["syncing", "syncing", "paused", "stopped"], ), ( SyncFacts::default(), diff --git a/crates/solstone-linux/src/unsafe_policy_tests.rs b/crates/solstone-linux/src/unsafe_policy_tests.rs index efd699f..2d4e801 100644 --- a/crates/solstone-linux/src/unsafe_policy_tests.rs +++ b/crates/solstone-linux/src/unsafe_policy_tests.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (c) 2026 sol pbc +use crate::policy_test_support::source_inventory::normalize_absolute; use crate::release_rail_tests::{command_path, workspace_root}; use proc_macro2::{TokenStream, TokenTree}; use std::ffi::OsString; @@ -213,27 +214,6 @@ impl fmt::Display for ScanError { impl std::error::Error for ScanError {} -fn normalize_absolute(path: &Path) -> Option { - if !path.is_absolute() { - return None; - } - let mut normalized = PathBuf::from("/"); - for component in path.components() { - match component { - Component::RootDir | Component::CurDir => {} - Component::Normal(value) => normalized.push(value), - Component::ParentDir => { - if normalized == Path::new("/") { - return None; - } - normalized.pop(); - } - Component::Prefix(_) => return None, - } - } - Some(normalized) -} - fn normalize_scan_root(root: &Path) -> Result { normalize_absolute(root).ok_or_else(|| { ScanError::new( diff --git a/crates/solstone-linux/src/upload.rs b/crates/solstone-linux/src/upload.rs index af767e1..534a422 100644 --- a/crates/solstone-linux/src/upload.rs +++ b/crates/solstone-linux/src/upload.rs @@ -229,6 +229,10 @@ impl UploadClient { self.inner.publish_link_fact(fact); } + pub(crate) fn begin_owner_generation(&self) { + self.link_facts().begin_owner_generation(); + } + pub(crate) fn registration_metadata(&self) -> (String, String, String) { ( self.inner.hostname.clone(), @@ -495,12 +499,13 @@ pub(crate) fn capability_less_client_for_test( #[cfg(test)] pub(crate) fn linked_fixture_client_for_test( config: &Config, + origin: &str, hostname: impl Into, platform: impl Into, version: impl Into, clock: Arc, ) -> UploadClient { - let capability = crate::test_support::linked_fixture_capability(&config.server_url) + let capability = crate::test_support::linked_fixture_capability(origin) .expect("linked fixture registered for configured test origin"); UploadClient::with_silent_capacity( config, @@ -899,7 +904,6 @@ mod tests { }, }; use tempfile::TempDir; - use tokio::net::TcpListener; use tracing::instrument::WithSubscriber; #[derive(Clone)] @@ -916,19 +920,18 @@ mod tests { } } - fn config(server: &MockServer, temp: &TempDir) -> Config { + fn config(_server: &MockServer, temp: &TempDir) -> Config { Config { - server_url: server.url.clone(), - key: "K".into(), base_dir: temp.path().join("data"), config_dir: temp.path().join("config"), ..Config::default() } } - fn client(config: &Config) -> UploadClient { + fn client(config: &Config, origin: &str) -> UploadClient { crate::upload::linked_fixture_client_for_test( config, + origin, "host-a", "linux", "0.1.0", @@ -951,7 +954,6 @@ mod tests { peer.enqueue_response(status, serde_json::to_vec(&body).unwrap()); let temp = TempDir::new().unwrap(); let config = Config { - key: "K".into(), stream: "host-a".into(), ..config(&legacy, &temp) }; @@ -962,7 +964,7 @@ mod tests { &session, &ObserverState { credential_instance_id: peer.credential().instance_id, - key: "K".into(), + key: "K".to_owned(), prefix: "prefix".into(), name: "host-a".into(), ingest_url: "/app/observer/ingest".into(), @@ -1245,7 +1247,6 @@ mod tests { peer.enqueue_response(200, b"{}".to_vec()); let temp = TempDir::new().unwrap(); let mut config = config(&server, &temp); - config.key = "K123456789".into(); config.stream = "host-a".into(); let session = start_private_link_session(&config.config_dir, peer.credential(), "host-a") .await @@ -1457,7 +1458,11 @@ mod tests { let server = MockServer::new(vec![]).await; let temp = TempDir::new().unwrap(); let mut config = config(&server, &temp); - assert!(client(&config).ensure_registered(&mut config).await); + assert!( + client(&config, &server.url) + .ensure_registered(&mut config) + .await + ); assert!(server.requests().is_empty()); } @@ -1679,7 +1684,7 @@ mod tests { let server = MockServer::new((0..20).map(|_| (200, json!({}))).collect()).await; let temp = TempDir::new().unwrap(); let config = config(&server, &temp); - let client = client(&config); + let client = client(&config, &server.url); let output = Arc::new(Mutex::new(Vec::new())); let writer = Buffer(Arc::clone(&output)); let subscriber = tracing_subscriber::fmt() @@ -1707,7 +1712,7 @@ mod tests { let server = MockServer::new(vec![]).await; let temp = TempDir::new().unwrap(); let config = config(&server, &temp); - let _client = client(&config); + let _client = client(&config, &server.url); let output = Arc::new(Mutex::new(Vec::new())); let writer = Buffer(Arc::clone(&output)); let subscriber = tracing_subscriber::fmt() @@ -1737,7 +1742,7 @@ mod tests { write_file(&temp, "notes.bin", b"binary-content"), ]; assert!( - client(&config) + client(&config, &server.url) .upload_segment("20260101", "120000_005", &files) .await .success @@ -1793,7 +1798,7 @@ mod tests { let temp = TempDir::new().unwrap(); let config = config(&server, &temp); let media = write_file(&temp, "audio.flac", b"audio"); - let result = client(&config) + let result = client(&config, &server.url) .upload_segment("day", "segment", &[media]) .await; assert!(result.success); @@ -1809,7 +1814,7 @@ mod tests { config.sync_max_retries = max_retries; config.sync_retry_delays = vec![0]; let media = write_file(&temp, "audio.flac", b"audio"); - let result = client(&config) + let result = client(&config, &server.url) .upload_segment("day", "segment", &[media]) .await; (server.requests().len(), result) @@ -1843,7 +1848,9 @@ mod tests { config.sync_max_retries = 10; config.sync_retry_delays = vec![0]; let media = write_file(&temp, "audio.flac", b"audio"); - let result = client(&config).upload_segment("d", "s", &[media]).await; + let result = client(&config, &server.url) + .upload_segment("d", "s", &[media]) + .await; assert_eq!(result.error_type, Some(expected)); assert_eq!(server.requests().len(), 1); } @@ -1857,7 +1864,7 @@ mod tests { let mut config = config(&server, &temp); config.sync_max_retries = 10; let media = write_file(&temp, "audio.flac", b"audio"); - let client = client(&config); + let client = client(&config, &server.url); client.request_stop(); let result = tokio::time::timeout( Duration::from_millis(500), @@ -1877,7 +1884,7 @@ mod tests { let mut config = config(&server, &temp); config.sync_max_retries = 1; let media = write_file(&temp, "audio.flac", b"audio"); - let client = client(&config); + let client = client(&config, &server.url); client.request_stop(); let result = client.upload_segment("d", "s", &[media]).await; assert_eq!(result.error_type, Some(ErrorType::Client)); @@ -1893,7 +1900,7 @@ mod tests { config.sync_retry_delays = vec![0]; let media = write_file(&temp, "audio.flac", b"complete-on-retry"); assert!( - client(&config) + client(&config, &server.url) .upload_segment("d", "s", &[media]) .await .success @@ -1920,7 +1927,9 @@ mod tests { let mut config = config(&server, &temp); config.sync_retry_delays = vec![0]; let media = write_file(&temp, "audio.flac", b"audio"); - let result = client(&config).upload_segment("d", "s", &[media]).await; + let result = client(&config, &server.url) + .upload_segment("d", "s", &[media]) + .await; assert!(result.success); assert_eq!(server.requests().len(), 2); @@ -1929,7 +1938,7 @@ mod tests { let mut terminal_config = self::config(&terminal_server, &terminal_temp); terminal_config.sync_max_retries = 1; let terminal_media = write_file(&terminal_temp, "audio.flac", b"audio"); - let terminal_result = client(&terminal_config) + let terminal_result = client(&terminal_config, &terminal_server.url) .upload_segment("d", "s", &[terminal_media]) .await; assert_eq!(terminal_result.error_type, Some(ErrorType::Transient)); @@ -1938,13 +1947,8 @@ mod tests { // AC: a client without a linked capability is classified Transient. #[tokio::test] async fn capability_less_upload_is_transient() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - drop(listener); let temp = TempDir::new().unwrap(); let config = Config { - server_url: format!("http://{address}"), - key: "K".into(), sync_max_retries: 1, ..Config::default() }; @@ -1970,7 +1974,7 @@ mod tests { let mut fields = Map::new(); fields.insert("mode".into(), json!("idle")); assert!( - client(&config) + client(&config, &server.url) .relay_event("observe", "status", fields) .await ); @@ -1989,7 +1993,7 @@ mod tests { async fn relay_401_does_not_latch_revoked() { let server = MockServer::new(vec![(401, json!({}))]).await; let temp = TempDir::new().unwrap(); - let client = client(&config(&server, &temp)); + let client = client(&config(&server, &temp), &server.url); assert!(!client.relay_event("observe", "status", Map::new()).await); assert!(!client.is_revoked()); } @@ -1999,7 +2003,7 @@ mod tests { async fn listing_uses_bearer_protocol_and_keyless_route() { let server = MockServer::new(vec![(200, json!([]))]).await; let temp = TempDir::new().unwrap(); - let result = client(&config(&server, &temp)) + let result = client(&config(&server, &temp), &server.url) .get_server_segments("20260101") .await; assert_eq!(result.segments, Some(vec![])); @@ -2018,7 +2022,7 @@ mod tests { json!({"items":[{"key":"new", "original_key":"old", "files":[{"name":"a.flac"}]}]}); let server = MockServer::new(vec![(200, body)]).await; let temp = TempDir::new().unwrap(); - let result = client(&config(&server, &temp)) + let result = client(&config(&server, &temp), &server.url) .get_server_segments("20260101") .await; assert!(!result.legacy && !result.truncated); @@ -2044,7 +2048,7 @@ mod tests { )]) .await; let temp = TempDir::new().unwrap(); - let result = client(&config(&server, &temp)) + let result = client(&config(&server, &temp), &server.url) .get_server_segments("20260101") .await; let entries = result.segments.unwrap(); @@ -2061,7 +2065,7 @@ mod tests { async fn malformed_listing_json_is_transient() { let server = MockServer::new_actions(vec![Action::Raw(200, "not-json")]).await; let temp = TempDir::new().unwrap(); - let result = client(&config(&server, &temp)) + let result = client(&config(&server, &temp), &server.url) .get_server_segments("20260101") .await; assert_eq!(result.segments, None); @@ -2075,7 +2079,7 @@ mod tests { let server = MockServer::new(vec![(200, json!({"items":[{"key":"a"}], "total":2}))]).await; let temp = TempDir::new().unwrap(); assert!( - client(&config(&server, &temp)) + client(&config(&server, &temp), &server.url) .get_server_segments("20260101") .await .truncated @@ -2087,7 +2091,7 @@ mod tests { async fn listing_classifies_404_as_incompatible() { let server = MockServer::new(vec![(404, json!({}))]).await; let temp = TempDir::new().unwrap(); - let result = client(&config(&server, &temp)) + let result = client(&config(&server, &temp), &server.url) .get_server_segments("20260101") .await; assert_eq!(result.error_type, Some(ErrorType::Incompatible)); @@ -2099,7 +2103,7 @@ mod tests { async fn all_missing_files_make_no_request() { let server = MockServer::new(vec![]).await; let temp = TempDir::new().unwrap(); - let result = client(&config(&server, &temp)) + let result = client(&config(&server, &temp), &server.url) .upload_segment("d", "s", &[temp.path().join("missing")]) .await; assert_eq!(result.error_type, None); @@ -2172,20 +2176,15 @@ mod tests { let server = MockServer::new(vec![(status, json!({}))]).await; let temp = TempDir::new().unwrap(); let media = write_file(&temp, "a.flac", b"a"); - let result = client(&config(&server, &temp)) + let result = client(&config(&server, &temp), &server.url) .upload_segment("d", "s", &[media]) .await; assert_eq!(result.error_type, Some(ErrorType::Auth)); assert_eq!(result.status_code, Some(status)); } - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - drop(listener); let temp = TempDir::new().unwrap(); let transport_config = Config { - server_url: format!("http://{address}"), - key: "K".into(), sync_max_retries: 1, ..Config::default() }; @@ -2210,7 +2209,7 @@ mod tests { let temp = TempDir::new().unwrap(); let mut config = config(&server, &temp); config.sync_retry_delays.clear(); - let client = client(&config); + let client = client(&config, &server.url); assert_eq!(client.inner.retry_delays, vec![5, 30, 120, 300]); } @@ -2222,7 +2221,7 @@ mod tests { let mut config = config(&server, &temp); config.sync_retry_delays = vec![30]; let media = write_file(&temp, "a.flac", b"a"); - let client = Arc::new(client(&config)); + let client = Arc::new(client(&config, &server.url)); let worker = { let client = Arc::clone(&client); tokio::spawn(async move { client.upload_segment("d", "s", &[media]).await }) @@ -2294,7 +2293,6 @@ mod tests { ); assert!(!client.ensure_registered(&mut config).await); assert!(!client.is_revoked()); - assert!(config.key.is_empty()); assert_eq!(peer.requests().len(), 1); session.shutdown().await.unwrap(); peer.shutdown().await; @@ -2352,7 +2350,6 @@ mod tests { let peer = PrivateLinkPeer::start().await; let temp = TempDir::new().unwrap(); let config = Config { - key: "K".into(), stream: "host-a".into(), ..config(&legacy, &temp) }; @@ -2363,7 +2360,7 @@ mod tests { &session, &ObserverState { credential_instance_id: peer.credential().instance_id, - key: "K".into(), + key: "K".to_owned(), prefix: "prefix".into(), name: "host-a".into(), ingest_url: "/app/observer/ingest".into(), @@ -2400,7 +2397,7 @@ mod tests { let server = MockServer::new(vec![(403, json!({}))]).await; let temp = TempDir::new().unwrap(); let config = config(&server, &temp); - let client = client(&config); + let client = client(&config, &server.url); assert!(!client.relay_event("observe", "status", Map::new()).await); assert!(client.is_revoked()); let before = server.requests().len(); @@ -2448,7 +2445,7 @@ mod tests { async fn status_supersession_delivers_newest() { let (server, gate) = MockServer::gated().await; let temp = TempDir::new().unwrap(); - let mut client = client(&config(&server, &temp)); + let mut client = client(&config(&server, &temp), &server.url); client.enqueue_status(Map::from_iter([("seq".into(), json!(1))])); wait_for_requests(&server, 1).await; client.enqueue_status(Map::from_iter([("seq".into(), json!(2))])); diff --git a/crates/solstone-linux/testdata/introspection/observer1.xml b/crates/solstone-linux/testdata/introspection/observer1.xml index b811a41..c9128d2 100644 --- a/crates/solstone-linux/testdata/introspection/observer1.xml +++ b/crates/solstone-linux/testdata/introspection/observer1.xml @@ -26,7 +26,6 @@ - diff --git a/packaging/INSTALL-NOTES b/packaging/INSTALL-NOTES index aa77775..e1d03fc 100644 --- a/packaging/INSTALL-NOTES +++ b/packaging/INSTALL-NOTES @@ -1,7 +1,7 @@ solstone-linux Rust release install notes -This package contains the standalone Linux observer binary and its canonical -icons. It intentionally does not install a systemd unit: service ownership +This package contains sol for Linux and its canonical icons. It intentionally +does not install a systemd unit: service ownership belongs to the optional `solstone-linux install-service` command. Runtime requirements: @@ -28,5 +28,22 @@ Typical package families: - Arch: libpulse, gstreamer, gst-plugins-base, gst-plugins-good, gst-plugin-pipewire, pipewire-pulse, xdg-desktop-portal, xdg-utils -Run `solstone-linux install-service` to write and enable the systemd user unit -and desktop autostart entry after installing the observer binary. +Create a pair link for this device in your journal, save it as +`pair-link.txt`, then pair and start sol: + +```bash +solstone-linux install-service +systemctl --user stop solstone-linux +solstone-linux setup < pair-link.txt +systemctl --user start solstone-linux +``` + +Pairing is the only setup path, including when your journal is on this machine. +There is no URL, key, local installation of the journal or Python, or direct +fallback to configure. Sol can capture while unpaired or offline and saves +segments locally. + +Setup and runtime share one private-state lock. Stop sol before pairing. If sol +is running, setup exits before consuming input and leaves capture, config, and +private state unchanged. Use the same stop, setup, start sequence when an +upgrade requires a new pair link.