diff --git a/Cargo.lock b/Cargo.lock index d7a2ef2..c6cd625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2695,6 +2695,7 @@ dependencies = [ "reqwest", "resvg", "roxmltree", + "rust-release-manifest", "rustix", "rustls", "sd-notify", diff --git a/INSTALL.md b/INSTALL.md index 4d7a7a8..fa1a1c0 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -87,10 +87,10 @@ 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 +systemctl --user stop solstone-linux solstone-linux setup < pair-link.txt systemctl --user start solstone-linux ``` diff --git a/crates/rust-release-manifest/src/authority_vocabulary.rs b/crates/rust-release-manifest/src/authority_vocabulary.rs new file mode 100644 index 0000000..357eadc --- /dev/null +++ b/crates/rust-release-manifest/src/authority_vocabulary.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +pub const LEGACY_ENVIRONMENT: &[&str] = &["SOLSTONE_TOKEN"]; +pub const LEGACY_OPTIONS: &[&str] = &["--server-url", "--token"]; +pub const LEGACY_ORIGINS: &[&str] = &["localhost:5015", "127.0.0.1:5015", "[::1]:5015"]; +pub const LEGACY_COMMANDS: &[&str] = &["journal observer create"]; +pub const LEGACY_EXECUTABLES: &[&str] = &["sol"]; +pub const PYTHON_SETUP: &[&str] = &[ + "python -m", + "python3 -m", + "pip install", + "pip3 install", + "pipx install", +]; diff --git a/crates/rust-release-manifest/src/lib.rs b/crates/rust-release-manifest/src/lib.rs index b096d27..cbad58e 100644 --- a/crates/rust-release-manifest/src/lib.rs +++ b/crates/rust-release-manifest/src/lib.rs @@ -24,6 +24,7 @@ use xz2::read::XzDecoder; mod candidate; pub use candidate::*; mod audit; +pub mod authority_vocabulary; mod elf64; mod package_audit; pub use audit::*; diff --git a/crates/rust-release-manifest/src/package_audit.rs b/crates/rust-release-manifest/src/package_audit.rs index eb0b000..39312d1 100644 --- a/crates/rust-release-manifest/src/package_audit.rs +++ b/crates/rust-release-manifest/src/package_audit.rs @@ -2,7 +2,11 @@ // Copyright (c) 2026 sol pbc use crate::{ - Error, Result, digest, + Error, Result, + authority_vocabulary::{ + LEGACY_COMMANDS, LEGACY_ENVIRONMENT, LEGACY_OPTIONS, LEGACY_ORIGINS, PYTHON_SETUP, + }, + digest, elf64::{Elf64Linkage, parse_elf64}, }; use flate2::read::GzDecoder; @@ -19,12 +23,13 @@ use xz2::read::XzDecoder; const MAX_MEMBER_BYTES: u64 = 256 * 1024 * 1024; const INSTALL_NOTES: &[u8] = include_bytes!("../../../packaging/INSTALL-NOTES"); +const PACKAGE_NOTE_PHRASES: &[&str] = &["observer key", "pipx"]; // 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. +// Source commit: eacc273a1e97b8ad365feec745c082dae7f86607. const EXPECTED_ELF_TYPE: u16 = 3; const EXPECTED_MACHINE: u16 = 62; const EXPECTED_INTERPRETER: &str = "/lib64/ld-linux-x86-64.so.2"; @@ -323,6 +328,126 @@ fn valid_deb_dependency_field(value: &str) -> bool { }) } +pub(crate) fn md5_digest(bytes: &[u8]) -> String { + const SHIFTS: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + const TABLE: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, + 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, + 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, + 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, + 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, + 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, + 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, + 0xeb86d391, + ]; + let bit_len = (bytes.len() as u64).wrapping_mul(8); + let mut padded = bytes.to_vec(); + padded.push(0x80); + while padded.len() % 64 != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_len.to_le_bytes()); + let mut state = [0x67452301_u32, 0xefcdab89, 0x98badcfe, 0x10325476]; + for chunk in padded.chunks_exact(64) { + let mut words = [0_u32; 16]; + for (word, bytes) in words.iter_mut().zip(chunk.chunks_exact(4)) { + *word = u32::from_le_bytes(bytes.try_into().expect("four-byte chunk")); + } + let [mut a, mut b, mut c, mut d] = state; + for index in 0..64 { + let (mixed, word) = match index { + 0..=15 => ((b & c) | (!b & d), index), + 16..=31 => ((d & b) | (!d & c), (5 * index + 1) % 16), + 32..=47 => (b ^ c ^ d, (3 * index + 5) % 16), + _ => (c ^ (b | !d), (7 * index) % 16), + }; + let next = a + .wrapping_add(mixed) + .wrapping_add(TABLE[index]) + .wrapping_add(words[word]) + .rotate_left(SHIFTS[index]) + .wrapping_add(b); + a = d; + d = c; + c = b; + b = next; + } + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + } + state + .into_iter() + .flat_map(u32::to_le_bytes) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn verify_deb_md5sums(path: &Path, member: &Member, data: &[Member]) -> Result<()> { + let text = std::str::from_utf8(&member.bytes) + .map_err(|_| audit_error(path, "MalformedMetadata", "non-utf8-md5sums", "deb:md5sums"))?; + let mut declared = BTreeMap::new(); + for line in text.lines() { + let Some((sum, member_path)) = line.split_once(" ") else { + return Err(audit_error( + path, + "MalformedMetadata", + "md5sums-grammar", + "deb:md5sums", + )); + }; + if sum.len() != 32 + || !sum.bytes().all(|byte| byte.is_ascii_hexdigit()) + || member_path.is_empty() + || normalized_path(Path::new(member_path)).as_deref() != Some(member_path) + { + return Err(audit_error( + path, + "MalformedMetadata", + "md5sums-grammar", + "deb:md5sums", + )); + } + if declared + .insert(member_path.to_owned(), sum.to_ascii_lowercase()) + .is_some() + { + return Err(audit_error( + path, + "MalformedMetadata", + "md5sums-duplicate", + member_path, + )); + } + } + let actual = data + .iter() + .map(|member| (member.path.clone(), md5_digest(&member.bytes))) + .collect::>(); + if declared != actual { + let offending = declared + .keys() + .chain(actual.keys()) + .find(|member| declared.get(*member) != actual.get(*member)) + .map(String::as_str) + .unwrap_or("deb:md5sums"); + return Err(audit_error( + path, + "MalformedMetadata", + "md5sums-mismatch", + offending, + )); + } + Ok(()) +} + fn deb_members(path: &Path) -> Result> { let mut archive = ar::Archive::new( File::open(path) @@ -385,17 +510,10 @@ fn deb_members(path: &Path) -> Result> { .iter() .find(|member| member.path.trim_start_matches("./") == "control") .ok_or_else(|| audit_error(path, "MalformedMetadata", "missing-control", "deb"))?; - if !control + let md5sums = control .iter() - .any(|member| member.path.trim_start_matches("./") == "md5sums") - { - return Err(audit_error( - path, - "MalformedMetadata", - "missing-md5sums", - "deb", - )); - } + .find(|member| member.path.trim_start_matches("./") == "md5sums") + .ok_or_else(|| 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(); @@ -422,7 +540,17 @@ fn deb_members(path: &Path) -> Result> { )); } } - for dependency_field in ["Depends", "Pre-Depends", "Provides"] { + for dependency_field in [ + "Depends", + "Pre-Depends", + "Recommends", + "Suggests", + "Enhances", + "Breaks", + "Conflicts", + "Replaces", + "Provides", + ] { if let Some(value) = fields.get(dependency_field) { if !valid_deb_dependency_field(value) { return Err(audit_error( @@ -444,40 +572,40 @@ fn deb_members(path: &Path) -> Result> { } let (data_name, data_bytes) = data.ok_or_else(|| audit_error(path, "MalformedContainer", "missing-data", "deb"))?; - compressed_tar(path, &data_name, data_bytes) + let data = compressed_tar(path, &data_name, data_bytes)?; + verify_deb_md5sums(path, md5sums, &data)?; + Ok(data) } 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| { + for (field, dependencies) in [ + ("Requires", package.metadata.get_requires()), + ("Provides", package.metadata.get_provides()), + ("Recommends", package.metadata.get_recommends()), + ("Suggests", package.metadata.get_suggests()), + ("Supplements", package.metadata.get_supplements()), + ("Enhances", package.metadata.get_enhances()), + ("Conflicts", package.metadata.get_conflicts()), + ("Obsoletes", package.metadata.get_obsoletes()), + ] { + for dependency in dependencies.map_err(|error| { audit_error( path, "MalformedMetadata", &error.to_string(), - "rpm:Provides", + &format!("rpm:{field}"), ) - })?) - { - if let Some(token) = forbidden_dependency(&dependency.name) { - return Err(audit_error( - path, - "ForbiddenDependency", - token, - "rpm:Requires/Provides", - )); + })? { + if let Some(token) = forbidden_dependency(&dependency.name) { + return Err(audit_error( + path, + "ForbiddenDependency", + token, + &format!("rpm:{field}"), + )); + } } } for (name, present) in [ @@ -642,17 +770,17 @@ fn inspect_payload( 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)); + for token in LEGACY_ENVIRONMENT + .iter() + .chain(LEGACY_OPTIONS) + .chain(LEGACY_ORIGINS) + .chain(LEGACY_COMMANDS) + .chain(PYTHON_SETUP) + .chain(PACKAGE_NOTE_PHRASES) + { + let token = token.to_ascii_lowercase(); + if normalized.contains(&token) { + return Err(audit_error(path, "StaleInstallNotes", &token, &expected)); } } if member.bytes != INSTALL_NOTES { @@ -872,6 +1000,75 @@ mod tests { } } + #[test] + fn deb_md5sums_are_closed_and_digest_bound() { + assert_eq!(md5_digest(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5_digest(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + + let data = vec![ + Member { + path: "usr/bin/solstone-linux".to_owned(), + mode: 0o755, + bytes: b"binary".to_vec(), + }, + Member { + path: "usr/share/doc/solstone-linux/LICENSE".to_owned(), + mode: 0o644, + bytes: b"license".to_vec(), + }, + ]; + let valid = Member { + path: "md5sums".to_owned(), + mode: 0o644, + bytes: format!( + "{} usr/bin/solstone-linux\n{} usr/share/doc/solstone-linux/LICENSE\n", + md5_digest(b"binary"), + md5_digest(b"license") + ) + .into_bytes(), + }; + verify_deb_md5sums(artifact(Format::Deb), &valid, &data).unwrap(); + for (bytes, token, member) in [ + ( + b"not-an-md5-line\n".to_vec(), + "md5sums-grammar", + "deb:md5sums", + ), + ( + format!( + "{} usr/bin/solstone-linux\n{} usr/bin/solstone-linux\n", + md5_digest(b"binary"), + md5_digest(b"binary") + ) + .into_bytes(), + "md5sums-duplicate", + "usr/bin/solstone-linux", + ), + ( + format!( + "{} usr/bin/solstone-linux\n{} usr/share/doc/solstone-linux/LICENSE\n", + md5_digest(b"different"), + md5_digest(b"license") + ) + .into_bytes(), + "md5sums-mismatch", + "usr/bin/solstone-linux", + ), + ] { + let mutated = Member { + bytes, + ..valid.clone() + }; + exact( + Format::Deb, + "MalformedMetadata", + token, + member, + verify_deb_md5sums(artifact(Format::Deb), &mutated, &data).unwrap_err(), + ); + } + } + #[test] fn extra_payload_is_rejected_in_all_formats() { for format in [Format::Tar, Format::Deb, Format::Rpm] { diff --git a/crates/rust-release-manifest/src/tests.rs b/crates/rust-release-manifest/src/tests.rs index 3912677..8776248 100644 --- a/crates/rust-release-manifest/src/tests.rs +++ b/crates/rust-release-manifest/src/tests.rs @@ -486,6 +486,7 @@ fn audit_deb_with( payload: &AuditPayloadOptions, ) -> PathBuf { let mut data = tar::Builder::new(Vec::new()); + let mut md5sums = String::new(); for authority in crate::package_audit::PAYLOAD_AUTHORITY { if payload.omit == Some(authority.role) { continue; @@ -503,6 +504,11 @@ fn audit_deb_with( header.set_cksum(); data.append_data(&mut header, member, bytes.as_slice()) .unwrap(); + md5sums.push_str(&format!( + "{} {}\n", + crate::package_audit::md5_digest(&bytes), + authority.installed.trim_start_matches('/') + )); } for (installed, bytes, mode) in &payload.extras { let mut header = tar::Header::new_gnu(); @@ -515,13 +521,18 @@ fn audit_deb_with( bytes.as_slice(), ) .unwrap(); + md5sums.push_str(&format!( + "{} {}\n", + crate::package_audit::md5_digest(bytes), + installed.trim_start_matches('/') + )); } 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()), + ("./md5sums", md5sums.as_bytes()), ]; control_members.extend_from_slice(scripts); for (name, body) in control_members { @@ -966,7 +977,7 @@ fn package_audit_rejects_each_rpm_dependency_and_accepts_product_name() { &rpm, "ForbiddenDependency", forbidden, - "rpm:Requires/Provides", + "rpm:Requires", ); } @@ -985,6 +996,95 @@ fn package_audit_rejects_each_rpm_dependency_and_accepts_product_name() { audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap(); } +#[test] +fn package_audit_rejects_every_deb_relationship_field() { + for field in [ + "Depends", + "Pre-Depends", + "Recommends", + "Suggests", + "Enhances", + "Breaks", + "Conflicts", + "Replaces", + "Provides", + ] { + 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\n{field}: journal\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", + "journal", + &format!("deb:control/{field}"), + ); + } +} + +#[test] +fn package_audit_rejects_every_rpm_relationship_field() { + type Configure = fn(&mut rpm::PackageBuilder, rpm::Dependency); + let fields: [(&str, Configure); 8] = [ + ("Requires", |builder, dependency| { + builder.requires(dependency); + }), + ("Provides", |builder, dependency| { + builder.provides(dependency); + }), + ("Recommends", |builder, dependency| { + builder.recommends(dependency); + }), + ("Suggests", |builder, dependency| { + builder.suggests(dependency); + }), + ("Supplements", |builder, dependency| { + builder.supplements(dependency); + }), + ("Enhances", |builder, dependency| { + builder.enhances(dependency); + }), + ("Conflicts", |builder, dependency| { + builder.conflicts(dependency); + }), + ("Obsoletes", |builder, dependency| { + builder.obsoletes(dependency); + }), + ]; + for (field, configure) in fields { + 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| configure(builder, rpm::Dependency::any("journal")), + ); + let error = audit_packages(&tar, &deb, &rpm, &digest(&executable)).unwrap_err(); + assert_package_audit_diagnostic( + error, + &rpm, + "ForbiddenDependency", + "journal", + &format!("rpm:{field}"), + ); + } +} + #[test] fn package_audit_rejects_every_deb_control_script_even_when_empty() { for script in [ diff --git a/crates/solstone-linux/Cargo.toml b/crates/solstone-linux/Cargo.toml index c3a7ef2..236d9e1 100644 --- a/crates/solstone-linux/Cargo.toml +++ b/crates/solstone-linux/Cargo.toml @@ -44,6 +44,7 @@ sd-notify = "0.5.0" resvg = { version = "0.47", default-features = false } [dev-dependencies] +rust-release-manifest = { version = "1.0.0", path = "../rust-release-manifest" } proc-macro2 = "1" syn = { version = "2", features = ["full", "visit"] } tempfile = "3" diff --git a/crates/solstone-linux/src/cli.rs b/crates/solstone-linux/src/cli.rs index cdff4dc..0524b02 100644 --- a/crates/solstone-linux/src/cli.rs +++ b/crates/solstone-linux/src/cli.rs @@ -11,7 +11,7 @@ use crate::{ }, session_env::{self, Output, Runner}, streams::stream_name, - sync_health::{derive_health, load_facts_with_liveness}, + sync_health::{ProcessEpoch, SyncFacts, derive_health, load_facts_with_liveness, save_facts}, }; use clap::{Parser, Subcommand}; use std::{ @@ -298,6 +298,13 @@ fn render_setup_result( ); 1 } + Err(PrivateStateError::LockContended) => { + let _ = write_line( + errors, + "Setup could not start because sol is running. Stop sol first and try again. No input was consumed; capture, config, and private state are unchanged.", + ); + 1 + } Err(error @ (PrivateStateError::Io { .. } | PrivateStateError::InvalidTarget { .. })) => { let _ = write_line( errors, @@ -522,7 +529,8 @@ fn cmd_status(paths: ConfigPaths, runner: &dyn Runner, output: &mut dyn Write) - fn cmd_run(interval: Option) -> i32 { let paths = ConfigPaths::default(); - let (state_lock, mut config, transport_enabled) = match prepare_run_config(paths) { + let (state_lock, mut config, transport_enabled, process_epoch) = match prepare_run_config(paths) + { Ok(prepared) => prepared, Err(error) => { tracing::error!(%error, "Linked private state is already in use"); @@ -565,12 +573,16 @@ fn cmd_run(interval: Option) -> i32 { hostname().unwrap_or_else(|_| "linux".into()), state_lock, transport_enabled, + process_epoch, ) } pub(crate) fn prepare_run_config( paths: ConfigPaths, -) -> Result<(PrivateStateLock, Config, bool), crate::private_link::PrivateStateError> { +) -> Result< + (PrivateStateLock, Config, bool, Option), + crate::private_link::PrivateStateError, +> { let config_root = paths .config_dir .clone() @@ -591,7 +603,27 @@ pub(crate) fn prepare_run_config( false } }; - Ok((state_lock, config, transport_enabled)) + let process_epoch = match ProcessEpoch::generate() { + Ok(epoch) => Some(epoch), + Err(error) => { + tracing::error!(%error, "Failed to create process epoch; linked work disabled"); + None + } + }; + let reset = SyncFacts { + link: Some(Default::default()), + link_epoch: process_epoch.clone(), + ..Default::default() + }; + if let Err(error) = save_facts(&config.state_dir(), &reset) { + tracing::warn!(%error, "Failed to reset sync health for the new owner"); + } + Ok(( + state_lock, + config, + transport_enabled && process_epoch.is_some(), + process_epoch, + )) } #[cfg(test)] @@ -838,6 +870,7 @@ mod tests { .unwrap() .map(|entry| entry.unwrap().file_name()) .collect::>(); + let mut errors = Vec::new(); assert_eq!( cmd_setup( SetupOptions { @@ -849,11 +882,15 @@ mod tests { }, &mut input, &mut Vec::new(), - &mut Vec::new(), + &mut errors, ), 1 ); assert_eq!(reads.load(Ordering::SeqCst), 0); + assert_eq!( + String::from_utf8(errors).unwrap(), + "Setup could not start because sol is running. Stop sol first and try again. No input was consumed; capture, config, and private state are unchanged.\n" + ); let after = std::fs::read_dir(&config_root) .unwrap() .map(|entry| entry.unwrap().file_name()) @@ -862,6 +899,33 @@ mod tests { drop(lock); } + #[test] + fn prepare_run_config_resets_prior_connected_facts_before_returning() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(&temp); + let config = load_config(paths.clone()).config; + let prior = SyncFacts { + pending_confirmed: Some(0), + link: Some(crate::private_link::LinkFactState { + listener_ready: true, + carrier_proven: true, + observer_registered: true, + ..Default::default() + }), + link_epoch: Some(ProcessEpoch::for_test(9)), + ..Default::default() + }; + save_facts(&config.state_dir(), &prior).unwrap(); + + let (_lock, config, _, _) = prepare_run_config(paths).unwrap(); + let current = + load_facts_with_liveness(&config.state_dir(), PrivateStateLockLiveness::LiveOwner); + assert_ne!( + derive_health(¤t, 1_000.0, 600.0).state, + crate::sync_health::HealthState::Connected + ); + } + #[test] fn setup_surfaces_never_disclose_pair_material() { let temp = tempfile::tempdir().unwrap(); @@ -1093,7 +1157,7 @@ mod tests { 0 ); let expected = format!( - "Config: {}\nJournal link: managed privately\nStream: test-stream\n\nCache: {}\n 0 segments across 0 day(s), 0.0 MB\nRetain: 7 day(s)\nSync: offline — saving locally; pending unconfirmed (will retry)\n\nService: active\n", + "Config: {}\nJournal link: managed privately\nStream: test-stream\n\nCache: {}\n 0 segments across 0 day(s), 0.0 MB\nRetain: 7 day(s)\nSync: offline — saving locally; will retry; pending unconfirmed\n\nService: active\n", config.config_path().display(), config.captures_dir().display() ); diff --git a/crates/solstone-linux/src/desktop_component.rs b/crates/solstone-linux/src/desktop_component.rs index 3a7a793..508a9c8 100644 --- a/crates/solstone-linux/src/desktop_component.rs +++ b/crates/solstone-linux/src/desktop_component.rs @@ -12,6 +12,8 @@ use std::sync::{ pub const OBSERVER_BUS_NAME: &str = "org.solpbc.solstone.Observer1"; pub const ALREADY_RUNNING_MESSAGE: &str = "Another solstone-linux observer is already running (owns org.solpbc.solstone.Observer1). Check: systemctl --user status solstone-linux"; +pub(crate) const OPEN_JOURNAL_REMEDIATION: &str = + "Could not open your journal. Wait for sol to reconnect, then try again."; pub trait BusNameRequester { fn request_name( @@ -143,9 +145,10 @@ impl DesktopComponent { } pub fn perform_desktop_command(&self, command: crate::tray::TrayCommand) -> Result<(), String> { match command { - 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::OpenJournal => self + .open_journal + .open() + .map_err(|_| OPEN_JOURNAL_REMEDIATION.into()), crate::tray::TrayCommand::OpenUrl(url) => { open::that_detached(url).map_err(|e| e.to_string()) } diff --git a/crates/solstone-linux/src/docs_policy_tests.rs b/crates/solstone-linux/src/docs_policy_tests.rs index d7aca1b..f48de7f 100644 --- a/crates/solstone-linux/src/docs_policy_tests.rs +++ b/crates/solstone-linux/src/docs_policy_tests.rs @@ -58,18 +58,20 @@ 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("## [") { + if line.starts_with("## ") { section = if line == "## [Unreleased]" { ChangelogSection::Current } else if is_release_heading(line) { ChangelogSection::Historical - } else { + } else if line.starts_with("## [") { return Err(DocsError { surface: "CHANGELOG.md".to_owned(), line: index + 1, rule: "changelog-heading", detail: line.to_owned(), }); + } else { + ChangelogSection::Current }; } if section == ChangelogSection::Current { @@ -234,6 +236,12 @@ fn docs_changelog_history_is_structural() { current_changelog_lines(malformed).unwrap_err().rule, "changelog-heading" ); + let resumed = + "## [1.2.3] - 2026-01-02\nhistorical\n## Current guidance\nuse --server-url now\n"; + assert!( + scan_lines("CHANGELOG.md", current_changelog_lines(resumed).unwrap()).is_err(), + "a non-bracketed level-two heading must end historical classification" + ); } #[test] diff --git a/crates/solstone-linux/src/linked_authority_policy_tests.rs b/crates/solstone-linux/src/linked_authority_policy_tests.rs index f436c47..483d7a2 100644 --- a/crates/solstone-linux/src/linked_authority_policy_tests.rs +++ b/crates/solstone-linux/src/linked_authority_policy_tests.rs @@ -8,7 +8,7 @@ use crate::{ }, source_inventory::{ CargoCommand, ScanErrorCause, SourceIdentity, SourceInventory, SourceNode, - scan_workspace_with_command, walk_member, + cfg_attribute_requires_test, scan_workspace_with_command, walk_member, }, }, release_rail_tests::{command_path, workspace_root}, @@ -21,8 +21,8 @@ use std::{ path::PathBuf, }; use syn::{ - Attribute, Block, Expr, ExprCall, ExprMethodCall, ExprPath, ImplItemFn, ItemFn, ItemMod, Lit, - Macro, Signature, TraitItemFn, UseTree, + Attribute, Block, Expr, ExprCall, ExprMethodCall, ExprPath, ImplItemFn, ItemFn, ItemImpl, + ItemMod, Lit, Macro, Signature, TraitItemFn, Type, UseTree, visit::{self, Visit}, }; @@ -65,6 +65,7 @@ struct FunctionFact { identity: SourceIdentity, path: PathBuf, name: String, + item_path: String, test_only: bool, direct_sink: Option<(&'static str, String)>, calls: BTreeSet, @@ -72,23 +73,7 @@ struct FunctionFact { 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")) - })) + attribute.path().is_ident("test") || cfg_attribute_requires_test(attribute) }) } @@ -208,6 +193,13 @@ fn contains_env_call(expr: &Expr, aliases: &BTreeMap>) -> bo } } +fn absolute_url(expr: &Expr) -> bool { + literal(expr).is_some_and(|value| { + let lower = value.to_ascii_lowercase(); + lower.starts_with("http://") || lower.starts_with("https://") + }) +} + struct FunctionScanner<'a> { aliases: &'a BTreeMap>, direct_sink: Option<(&'static str, String)>, @@ -225,8 +217,7 @@ impl FunctionScanner<'_> { 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); + self.calls.insert(joined.clone()); if (joined.ends_with("reqwest::Client::new") || joined.ends_with("reqwest::Client::builder") || joined.ends_with("reqwest::Request::new") @@ -234,6 +225,17 @@ impl FunctionScanner<'_> { && self.direct_sink.is_none() { self.sink("network-constructor", joined); + } else if matches!( + joined.as_str(), + "reqwest::get" + | "reqwest::post" + | "reqwest::put" + | "reqwest::patch" + | "reqwest::delete" + | "reqwest::head" + | "reqwest::request" + ) { + self.sink("free-network-request", joined); } else if (joined.ends_with("Url::parse") || joined.ends_with("Url::join")) && self.direct_sink.is_none() { @@ -288,6 +290,14 @@ impl<'ast> Visit<'ast> for FunctionScanner<'_> { fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) { let method = node.method.to_string(); + if matches!( + method.as_str(), + "get" | "post" | "put" | "patch" | "delete" | "head" + ) && node.args.first().is_some_and(absolute_url) + || method == "request" && node.args.iter().skip(1).any(absolute_url) + { + self.sink("direct-network-request", method.clone()); + } if method == "bearer_auth" { self.sink("caller-owned-auth", method.clone()); } @@ -337,31 +347,46 @@ impl<'ast> Visit<'ast> for FunctionScanner<'_> { struct FileScanner<'a> { node: &'a SourceNode, - aliases: BTreeMap>, + aliases: Vec>>, module_test: Vec, + modules: Vec, + impl_types: Vec>, facts: Vec, } impl<'a> FileScanner<'a> { - fn new(node: &'a SourceNode) -> Self { + fn aliases_for_items(items: &[syn::Item]) -> BTreeMap> { let mut aliases = BTreeMap::new(); - for item in &node.syntax.items { + for item in items { if let syn::Item::Use(item) = item { flatten_use(&item.tree, Vec::new(), &mut aliases); } } + aliases + } + + fn new(node: &'a SourceNode) -> Self { Self { node, - aliases, + aliases: vec![Self::aliases_for_items(&node.syntax.items)], module_test: vec![node.identity.test_only], + modules: node.identity.module.clone(), + impl_types: vec![None], 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 aliases = BTreeMap::new(); + for scope in &self.aliases { + aliases.extend(scope.clone()); + } + let mut local_aliases = LocalUseCollector::default(); + local_aliases.visit_block(block); + aliases.extend(local_aliases.aliases); let mut scanner = FunctionScanner { - aliases: &self.aliases, + aliases: &aliases, direct_sink: None, calls: BTreeSet::new(), pushed_literals: String::new(), @@ -370,10 +395,22 @@ impl<'a> FileScanner<'a> { let mut identity = self.node.identity.clone(); identity.item = Some(signature.ident.to_string()); identity.test_only = test_only; + let owner = self.impl_types.last().and_then(Clone::clone); + let mut item_path = self.modules.join("::"); + if !item_path.is_empty() { + item_path.push_str("::"); + } + if let Some(owner) = owner { + item_path.push_str("impl "); + item_path.push_str(&owner); + item_path.push_str("::"); + } + item_path.push_str(&signature.ident.to_string()); self.facts.push(FunctionFact { identity, path: self.node.path.clone(), name: signature.ident.to_string(), + item_path, test_only, direct_sink: scanner.direct_sink, calls: scanner.calls, @@ -386,10 +423,25 @@ impl<'ast> Visit<'ast> for FileScanner<'_> { let inherited = *self.module_test.last().unwrap(); self.module_test .push(inherited || attributes_are_test_only(&node.attrs)); + self.modules.push(node.ident.to_string()); + let aliases = node + .content + .as_ref() + .map(|(_, items)| Self::aliases_for_items(items)) + .unwrap_or_default(); + self.aliases.push(aliases); visit::visit_item_mod(self, node); + self.aliases.pop(); + self.modules.pop(); self.module_test.pop(); } + fn visit_item_impl(&mut self, node: &'ast ItemImpl) { + self.impl_types.push(type_identity(&node.self_ty)); + visit::visit_item_impl(self, node); + self.impl_types.pop(); + } + fn visit_item_fn(&mut self, node: &'ast ItemFn) { self.record_function(&node.sig, &node.attrs, &node.block); } @@ -405,30 +457,49 @@ impl<'ast> Visit<'ast> for FileScanner<'_> { } } +#[derive(Default)] +struct LocalUseCollector { + aliases: BTreeMap>, +} + +impl<'ast> Visit<'ast> for LocalUseCollector { + fn visit_item_use(&mut self, node: &'ast syn::ItemUse) { + flatten_use(&node.tree, Vec::new(), &mut self.aliases); + } +} + +fn type_identity(value: &Type) -> Option { + let Type::Path(path) = value else { + return None; + }; + Some( + path.path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"), + ) +} + 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" - ) + && (fact + .item_path + .starts_with("private_link::impl PrivateLinkCapability::") + || matches!( + fact.item_path.as_str(), + "private_link::confine_path" | "private_link::start_private_link_session_inner" + )) } 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")) - { + let mut deleted = DeletedModuleVisitor::default(); + deleted.visit_file(&node.syntax); + if deleted.found { return Err(PolicyError { identity: node.identity.to_string(), path: node.path.clone(), @@ -440,7 +511,7 @@ fn analyze_inventory(inventory: &SourceInventory) -> Result<(), PolicyError> { scanner.visit_file(&node.syntax); facts.extend(scanner.facts); } - let key = |fact: &FunctionFact| (fact.path.clone(), fact.name.clone(), fact.test_only); + let key = |fact: &FunctionFact| (fact.path.clone(), fact.item_path.clone(), fact.test_only); let mut sink_keys = facts .iter() .filter(|fact| fact.direct_sink.is_some() && !fact.test_only && !permitted_capability(fact)) @@ -453,15 +524,21 @@ fn analyze_inventory(inventory: &SourceInventory) -> Result<(), PolicyError> { !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 callee = callee + .strip_prefix("crate::") + .or_else(|| callee.strip_prefix("self::")) + .unwrap_or(callee); + let unqualified = !callee.contains("::"); let matches = facts .iter() .filter(|candidate| { - candidate.name == *callee - && (!fact.test_only || candidate.test_only == fact.test_only) + (!fact.test_only || candidate.test_only == fact.test_only) + && if unqualified { + candidate.path == fact.path && candidate.name == callee + } else { + candidate.item_path == callee + || candidate.item_path.ends_with(&format!("::{callee}")) + } }) .collect::>(); matches.len() == 1 && sink_keys.contains(&key(matches[0])) @@ -492,6 +569,20 @@ fn analyze_inventory(inventory: &SourceInventory) -> Result<(), PolicyError> { Ok(()) } +#[derive(Default)] +struct DeletedModuleVisitor { + found: bool, +} + +impl<'ast> Visit<'ast> for DeletedModuleVisitor { + fn visit_item_mod(&mut self, node: &'ast ItemMod) { + if node.ident == "chat_bridge" { + self.found = true; + } + visit::visit_item_mod(self, node); + } +} + fn current_inventory() -> SourceInventory { let mut inventory = scan_workspace_with_command( &workspace_root(), @@ -565,6 +656,49 @@ fn linked_authority_mutation_aliases_are_semantic() { "fn moved(r: reqwest::RequestBuilder) { let _ = r.header(\"authorization\", \"x\"); }", "reserved-header", ); + assert_mutation( + "mod nested { use reqwest::Client as Renamed; fn moved() { let _ = Renamed::builder(); } }", + "network-constructor", + ); + assert_mutation( + "fn moved() { use reqwest::Client as Renamed; let _ = Renamed::builder(); }", + "network-constructor", + ); +} + +#[test] +fn linked_authority_mutation_production_cfg_is_not_test_context() { + for source in [ + "#[cfg(not(test))] mod direct { fn moved() { let _ = reqwest::Client::new(); } }", + "#[cfg(feature=\"latest\")] mod direct { fn moved() { let _ = reqwest::Client::new(); } }", + ] { + assert_mutation(source, "network-constructor"); + } +} + +#[test] +fn linked_authority_mutation_free_and_method_requests_are_sinks() { + assert_mutation( + "async fn moved() { let _ = reqwest::get(\"https://journal.invalid\").await; }", + "free-network-request", + ); + assert_mutation( + "fn moved(client: reqwest::Client) { let _ = client.post(\"https://journal.invalid\"); }", + "direct-network-request", + ); +} + +#[test] +fn linked_authority_mutation_nested_deleted_module_is_rejected() { + assert_mutation("mod nested { mod chat_bridge {} }", "deleted-module"); +} + +#[test] +fn linked_authority_capability_permission_uses_exact_item_identity() { + let mut inventory = mutation("fn send() { let _ = reqwest::Client::new(); }", false); + inventory.nodes[0].path = workspace_root().join("crates/solstone-linux/src/private_link.rs"); + let error = analyze_inventory(&inventory).unwrap_err(); + assert_eq!(error.rule, "network-constructor"); } #[test] diff --git a/crates/solstone-linux/src/policy_test_support/authority_vocabulary.rs b/crates/solstone-linux/src/policy_test_support/authority_vocabulary.rs index 4daaea2..8a1ccf5 100644 --- a/crates/solstone-linux/src/policy_test_support/authority_vocabulary.rs +++ b/crates/solstone-linux/src/policy_test_support/authority_vocabulary.rs @@ -1,15 +1,7 @@ // 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", -]; +pub(crate) use rust_release_manifest::authority_vocabulary::{ + LEGACY_COMMANDS, LEGACY_ENVIRONMENT, LEGACY_EXECUTABLES, LEGACY_OPTIONS, LEGACY_ORIGINS, + PYTHON_SETUP, +}; diff --git a/crates/solstone-linux/src/policy_test_support/source_inventory.rs b/crates/solstone-linux/src/policy_test_support/source_inventory.rs index 426d54b..4119c5e 100644 --- a/crates/solstone-linux/src/policy_test_support/source_inventory.rs +++ b/crates/solstone-linux/src/policy_test_support/source_inventory.rs @@ -9,7 +9,8 @@ use std::{ process::Command, }; use syn::{ - Attribute, Expr, Item, ItemMod, Lit, Meta, + Attribute, Expr, Item, ItemMod, Lit, Meta, Token, + punctuated::Punctuated, visit::{self, Visit}, }; @@ -422,6 +423,8 @@ fn scan_items( "generated include is not the tray icon input", )); } + let generated = PathBuf::from(env!("OUT_DIR")).join("tray_icons.rs"); + scan_rust_file(root, &generated, identity.clone(), inventory, visited)?; } IncludeInput::Unclassifiable(detail) => { return Err(ScanError::new( @@ -439,15 +442,45 @@ fn scan_items( fn inherit_context(attributes: &[Attribute], identity: &mut SourceIdentity) { for attribute in attributes { let rendered = quote_attribute(attribute); - if rendered.contains("cfg") { + if attribute.path().is_ident("cfg") { identity.cfg_context.push(rendered.clone()); } - if rendered.contains("test") { + if attribute.path().is_ident("test") || cfg_attribute_requires_test(attribute) { identity.test_only = true; } } } +pub(crate) fn cfg_attribute_requires_test(attribute: &Attribute) -> bool { + if !attribute.path().is_ident("cfg") { + return false; + } + let Meta::List(list) = &attribute.meta else { + return false; + }; + syn::parse2::(list.tokens.clone()) + .is_ok_and(|predicate| cfg_predicate_requires_test(&predicate)) +} + +fn cfg_predicate_requires_test(predicate: &Meta) -> bool { + match predicate { + Meta::Path(path) => path.is_ident("test"), + Meta::List(list) if list.path.is_ident("all") || list.path.is_ident("any") => { + let Ok(children) = + list.parse_args_with(Punctuated::::parse_terminated) + else { + return false; + }; + if list.path.is_ident("all") { + children.iter().any(cfg_predicate_requires_test) + } else { + !children.is_empty() && children.iter().all(cfg_predicate_requires_test) + } + } + Meta::List(_) | Meta::NameValue(_) => false, + } +} + fn quote_attribute(attribute: &Attribute) -> String { match &attribute.meta { Meta::Path(path) => path @@ -664,6 +697,31 @@ mod tests { ); } + #[test] + fn cfg_test_context_is_structural() { + let attribute = |source: &str| { + let file = syn::parse_file(source).unwrap(); + let Item::Mod(module) = &file.items[0] else { + panic!("fixture must be a module"); + }; + module.attrs[0].clone() + }; + for source in [ + "#[cfg(test)] mod fixture {}", + "#[cfg(all(unix, test))] mod fixture {}", + "#[cfg(any(test, all(test, unix)))] mod fixture {}", + ] { + assert!(cfg_attribute_requires_test(&attribute(source)), "{source}"); + } + for source in [ + "#[cfg(not(test))] mod fixture {}", + "#[cfg(feature = \"latest\")] mod fixture {}", + "#[cfg(any(test, unix))] mod fixture {}", + ] { + assert!(!cfg_attribute_requires_test(&attribute(source)), "{source}"); + } + } + #[test] fn exported_walk_module_and_item_helpers_preserve_identity() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/solstone-linux/src/private_link.rs b/crates/solstone-linux/src/private_link.rs index 49cedb7..495e584 100644 --- a/crates/solstone-linux/src/private_link.rs +++ b/crates/solstone-linux/src/private_link.rs @@ -274,6 +274,15 @@ impl PrivateStateLock { } Err(_) => return Err(PrivateStateProbeError::Inspect), }; + let root_stat = rustix::fs::fstat(&root).map_err(|_| PrivateStateProbeError::Inspect)?; + let expected_root_mode = + rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR | rustix::fs::Mode::XUSR; + if rustix::fs::FileType::from_raw_mode(root_stat.st_mode) != rustix::fs::FileType::Directory + || rustix::fs::Mode::from_raw_mode(root_stat.st_mode) != expected_root_mode + || root_stat.st_uid != rustix::process::geteuid().as_raw() + { + return Err(PrivateStateProbeError::InvalidTarget); + } let descriptor = match rustix::fs::openat( &root, PRIVATE_STATE_LOCK_FILENAME, @@ -1019,6 +1028,9 @@ impl OpenJournalCapability { } pub(crate) fn open(&self) -> Result<(), ()> { + // Opening necessarily hands the approved target to the desktop browser. + // The target is accepted in child argv here; it remains excluded from the + // secrecy contract's logs, errors, status, clipboard, D-Bus, state, and Debug surfaces. self.open_inner(|target| open::that_detached(target).map_err(|_| ())) } @@ -2832,6 +2844,11 @@ mod tests { ); fs::create_dir(&missing).unwrap(); + assert!(matches!( + PrivateStateLock::try_probe(&missing), + Err(PrivateStateProbeError::InvalidTarget) + )); + fs::set_permissions(&missing, fs::Permissions::from_mode(0o700)).unwrap(); assert_eq!( PrivateStateLock::try_probe(&missing).unwrap(), PrivateStateLockLiveness::NoLiveOwner diff --git a/crates/solstone-linux/src/run.rs b/crates/solstone-linux/src/run.rs index 12634ec..59eff34 100644 --- a/crates/solstone-linux/src/run.rs +++ b/crates/solstone-linux/src/run.rs @@ -217,6 +217,7 @@ pub(crate) fn run_observer( host: String, state_lock: PrivateStateLock, transport_enabled: bool, + process_epoch: Option, ) -> i32 { let runtime = match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -264,6 +265,7 @@ pub(crate) fn run_observer( connection, state_lock, transport_enabled, + process_epoch, ) }, || Ok(()), @@ -278,6 +280,7 @@ fn run_capture( connection: Option, state_lock: PrivateStateLock, transport_enabled: bool, + process_epoch: Option, ) -> Result<(), ObserverError> { let notifier: Arc = Arc::new(SdNotifier); let stopped = Arc::new(AtomicBool::new(false)); @@ -295,14 +298,9 @@ fn run_capture( 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 - } - }; + if process_epoch.is_none() { + upload.publish_link_fact(crate::private_link::LinkFact::PrivateStateInvalid); + } let linked_upload = Arc::clone(&upload); let linked_root = config.config_dir.clone(); let linked_stream = config.stream.clone(); @@ -526,8 +524,7 @@ fn apply_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."; + let message = crate::desktop_component::OPEN_JOURNAL_REMEDIATION; if let Err(notification_error) = notify_rust::Notification::new() .summary("sol") .body(message) @@ -1827,7 +1824,7 @@ mod tests { }; write_old_config(&paths); let peer = PrivateLinkPeer::start().await; - let (lock, config, transport_enabled) = + let (lock, config, transport_enabled, _process_epoch) = crate::cli::prepare_run_config(paths.clone()).unwrap(); assert!(transport_enabled); let persisted: serde_json::Value = @@ -2017,7 +2014,7 @@ mod tests { drop(released); let requests_before_final_restart = peer.requests().len(); - let (final_lock, final_config, final_transport) = + let (final_lock, final_config, final_transport, _process_epoch) = crate::cli::prepare_run_config(paths).unwrap(); assert!(final_transport); let (final_owner, final_upload) = start_owner(&final_config, &peer, final_lock).await; diff --git a/crates/solstone-linux/src/sync_health.rs b/crates/solstone-linux/src/sync_health.rs index 8eadb7f..c251725 100644 --- a/crates/solstone-linux/src/sync_health.rs +++ b/crates/solstone-linux/src/sync_health.rs @@ -248,14 +248,14 @@ pub static SURFACE_BY_STATE: LazyLock> = Laz header_recording: "on — offline (saving locally)", header_idle: "idle — offline (saving locally)", sync_line: "sync: offline; will retry", - tooltip: "sync: offline; saving locally", + tooltip: "sync: offline; saving locally; will retry", 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)", + cli: "Sync: offline — saving locally; will retry; pending unconfirmed", doctor_severity: "warn", - doctor_detail: "sync health: offline; pending unconfirmed; will retry", + doctor_detail: "sync health: offline; saving locally; will retry", dbus: "offline", }, ), @@ -287,9 +287,9 @@ pub static SURFACE_BY_STATE: LazyLock> = Laz accessible_idle: "sol — idle, connecting", icon: "syncing", sni: "Active", - cli: "Sync: unconfirmed — waiting for first successful journal check; pending unconfirmed", + cli: "Sync: connecting — wait while sol connects to your journal; pending unconfirmed", doctor_severity: "warn", - doctor_detail: "sync health: unconfirmed; no successful journal check yet", + doctor_detail: "sync health: connecting; wait while sol connects to your journal", dbus: "connecting", }, ), @@ -473,6 +473,7 @@ fn optional_int(data: &Map, key: &str) -> Option { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum HealthLoadError { + UnsupportedSchema, MalformedLinkEpoch, MalformedLink, } @@ -484,6 +485,9 @@ pub(crate) fn load_link_facts( if liveness != PrivateStateLockLiveness::LiveOwner { return Ok(None); } + if data.get("schema_version").and_then(Value::as_u64) != Some(SCHEMA_VERSION) { + return Err(HealthLoadError::UnsupportedSchema); + } ProcessEpoch::parse( data.get("link_epoch") .ok_or(HealthLoadError::MalformedLinkEpoch)?, @@ -784,6 +788,34 @@ mod tests { assert_eq!(load_facts(temp.path()), SyncFacts::default()); } + #[test] + fn live_link_facts_require_current_schema() { + let mut data = serde_json::Map::new(); + data.insert("schema_version".to_owned(), json!(SCHEMA_VERSION - 1)); + data.insert( + "link_epoch".to_owned(), + json!(ProcessEpoch::for_test(1).as_str()), + ); + data.insert( + "link".to_owned(), + json!({ + "pairing_required": false, + "private_state_invalid": false, + "config_sanitation_failed": false, + "listener_ready": false, + "carrier_proven": false, + "observer_registered": false, + "transport_unavailable": false, + "terminal_revocation": false, + "token_persistence_failure": false, + }), + ); + assert_eq!( + load_link_facts(&data, PrivateStateLockLiveness::LiveOwner), + Err(HealthLoadError::UnsupportedSchema) + ); + } + // tests/test_sync_health.py::test_every_health_state_has_complete_surface #[test] fn every_health_state_has_complete_surface() {