diff --git a/Cargo.toml b/Cargo.toml index 9eb5541..afd533c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ unimplemented = "deny" [workspace.dependencies] clap = { version = "4.5", features = ["derive"] } +spl-core = { version = "0.1.0", git = "https://github.com/solpbc/spl-rust", rev = "e86c6d0fa0518fcde1fdc1d0e6b9c1ba090a9dbe" } +spl-transport = { version = "0.1.0", git = "https://github.com/solpbc/spl-rust", rev = "e86c6d0fa0518fcde1fdc1d0e6b9c1ba090a9dbe" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] } tracing = "0.1" tracing-subscriber = "0.3" diff --git a/crates/rust-release-manifest/src/lib.rs b/crates/rust-release-manifest/src/lib.rs index cbad58e..959165f 100644 --- a/crates/rust-release-manifest/src/lib.rs +++ b/crates/rust-release-manifest/src/lib.rs @@ -29,6 +29,8 @@ mod elf64; mod package_audit; pub use audit::*; pub use package_audit::audit_packages; +mod spl_pin; +pub use spl_pin::validate_spl_pin; mod transaction; pub use transaction::*; mod transparency; @@ -44,6 +46,7 @@ pub(crate) const CONTEXT_BINDING_NAME: &str = ".release-context.json"; pub(crate) const CONTEXT_ARCHIVE_NAME: &str = ".release-context.tar"; pub const MANIFEST_OK_MESSAGE: &str = "Named manifest and artifacts verified; this is NOT candidate-readiness classification."; +pub const SPL_PIN_OK_MESSAGE: &str = "SPL dependency pin verified."; pub const RELEASE_DIR_OK_MESSAGE: &str = "Release directory verified as a complete five-file candidate."; pub const LEDGER_SCHEMA_SHA256: &str = @@ -284,7 +287,11 @@ impl RepoRoot { } let workspace: toml::Value = toml::from_str(&fs::read_to_string(root.join("Cargo.toml")).map_err(display_error)?) - .map_err(display_error)?; + .map_err(|cause| { + Error::new(format!( + "workspace manifest parse mismatch: expected valid TOML, actual Cargo.toml: {cause}\nrepair: restore valid TOML in Cargo.toml" + )) + })?; let members = workspace["workspace"]["members"] .as_array() .ok_or_else(|| { diff --git a/crates/rust-release-manifest/src/main.rs b/crates/rust-release-manifest/src/main.rs index 35bb889..2e3b9a5 100644 --- a/crates/rust-release-manifest/src/main.rs +++ b/crates/rust-release-manifest/src/main.rs @@ -4,9 +4,10 @@ use clap::{Args, Parser, Subcommand}; use rust_release_manifest::{ AuditRequest, Lane, LaneEmitRequest, MANIFEST_OK_MESSAGE, ProcessEnvironment, - 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, + ProofHandoffInput, RELEASE_DIR_OK_MESSAGE, RepoRoot, SPL_PIN_OK_MESSAGE, audit_packages, + classify_release_dir, create_candidate, emit_lane_handoff, emit_proof_handoff, prove_candidate, + publish_transparency, recover_candidate, resign_transparency_pointer, run_audit, + validate_spl_pin, verify_manifest_mode, }; use std::path::PathBuf; @@ -44,6 +45,7 @@ enum Command { #[arg(long, conflicts_with = "manifest")] release_dir: Option, }, + ValidateSplPin, #[command(hide = true)] LaneHandoff(Box), #[command(hide = true)] @@ -179,6 +181,11 @@ fn run() -> Result<(), Box> { println!("{RELEASE_DIR_OK_MESSAGE}"); } Command::Validate { .. } => return Err("exactly one validation path is required".into()), + Command::ValidateSplPin => { + let root = RepoRoot::resolve()?; + validate_spl_pin(&root)?; + println!("{SPL_PIN_OK_MESSAGE}"); + } Command::LaneHandoff(args) => { let LaneHandoffArgs { lane, diff --git a/crates/rust-release-manifest/src/spl_pin.rs b/crates/rust-release-manifest/src/spl_pin.rs new file mode 100644 index 0000000..bce8c58 --- /dev/null +++ b/crates/rust-release-manifest/src/spl_pin.rs @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use super::{Error, RepoRoot, Result}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; + +const SPL_SOURCE: &str = "https://github.com/solpbc/spl-rust"; +const PACKAGES: [&str; 2] = ["spl-core", "spl-transport"]; +const DEPENDENCY_TABLES: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"]; + +fn error(message: String) -> Error { + Error::new(message) +} + +fn read_toml(root: &Path, relative: &Path, subject: &str) -> Result { + let text = fs::read_to_string(root.join(relative)).map_err(|cause| { + error(format!( + "{subject} parse mismatch: expected valid TOML, actual {}: {cause}\nrepair: restore valid TOML in {}", + relative.display(), + relative.display() + )) + })?; + toml::from_str(&text).map_err(|cause| { + error(format!( + "{subject} parse mismatch: expected valid TOML, actual {}: {cause}\nrepair: restore valid TOML in {}", + relative.display(), + relative.display() + )) + }) +} + +fn package_identity(key: &str, value: &toml::Value) -> String { + value + .as_table() + .and_then(|table| table.get("package")) + .and_then(toml::Value::as_str) + .unwrap_or(key) + .to_owned() +} + +fn dependency_tables(manifest: &toml::Value) -> Vec<&toml::value::Table> { + let mut tables = DEPENDENCY_TABLES + .iter() + .filter_map(|name| manifest.get(*name).and_then(toml::Value::as_table)) + .collect::>(); + if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { + for target in targets.values().filter_map(toml::Value::as_table) { + tables.extend( + DEPENDENCY_TABLES + .iter() + .filter_map(|name| target.get(*name).and_then(toml::Value::as_table)), + ); + } + } + tables +} + +fn member_path(root: &Path, member: &str) -> Result { + let relative = Path::new(member); + if relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(error(format!( + "workspace manifest parse mismatch: expected valid TOML, actual Cargo.toml: invalid workspace member {member}\nrepair: restore valid TOML in Cargo.toml" + ))); + } + let manifest = relative.join("Cargo.toml"); + if !root.join(&manifest).starts_with(root) { + return Err(error(format!( + "workspace manifest parse mismatch: expected valid TOML, actual Cargo.toml: invalid workspace member {member}\nrepair: restore valid TOML in Cargo.toml" + ))); + } + Ok(manifest) +} + +fn selector_actual(table: &toml::value::Table) -> String { + let selectors = ["rev", "branch", "tag"] + .into_iter() + .filter(|key| table.contains_key(*key)) + .collect::>(); + if selectors.is_empty() { + "version-only".to_owned() + } else { + selectors.join(",") + } +} + +fn validate_workspace(root: &toml::Value) -> Result> { + let dependencies = root + .get("workspace") + .and_then(|value| value.get("dependencies")) + .and_then(toml::Value::as_table); + let mut revisions = BTreeMap::new(); + for package in PACKAGES { + let matches = dependencies + .into_iter() + .flat_map(|table| table.iter()) + .filter(|(key, value)| package_identity(key, value) == package) + .collect::>(); + if matches.len() != 1 { + let actual = if matches.is_empty() { + "missing".to_owned() + } else { + matches.len().to_string() + }; + return Err(error(format!( + "SPL package {package} workspace declaration mismatch: expected exactly one declaration, actual {actual}\nrepair: declare {package} once in root [workspace.dependencies]" + ))); + } + let table = matches[0].1.as_table().ok_or_else(|| { + error(format!( + "SPL package {package} source mismatch: expected {SPL_SOURCE}, actual non-table\nrepair: declare {package} from the approved SPL Git source in root Cargo.toml" + )) + })?; + let source = table + .get("git") + .and_then(toml::Value::as_str) + .unwrap_or("missing"); + if source != SPL_SOURCE { + return Err(error(format!( + "SPL package {package} source mismatch: expected {SPL_SOURCE}, actual {source}\nrepair: declare {package} from the approved SPL Git source in root Cargo.toml" + ))); + } + if selector_actual(table) != "rev" { + return Err(error(format!( + "SPL package {package} selector mismatch: expected rev, actual {}\nrepair: select {package} with only rev in root Cargo.toml", + selector_actual(table) + ))); + } + let revision = table + .get("rev") + .and_then(toml::Value::as_str) + .unwrap_or("missing"); + if revision.len() != 40 + || !revision + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(error(format!( + "SPL package {package} revision mismatch: expected 40 lowercase hexadecimal characters, actual {revision}\nrepair: declare the full approved {package} commit in root Cargo.toml" + ))); + } + revisions.insert(package.to_owned(), revision.to_owned()); + } + if revisions["spl-core"] != revisions["spl-transport"] { + return Err(error(format!( + "SPL package spl-transport revision alignment mismatch: expected {}, actual {}\nrepair: pin spl-core and spl-transport to the same revision in root Cargo.toml", + revisions["spl-core"], revisions["spl-transport"] + ))); + } + if let Some(dependencies) = dependencies { + for (key, dependency) in dependencies { + let package = package_identity(key, dependency); + if !PACKAGES.contains(&package.as_str()) + && let Some(source) = dependency.get("git").and_then(toml::Value::as_str) + { + return Err(error(format!( + "Cargo package {package} Git source mismatch: expected no unapproved member Git dependency, actual Cargo.toml:{key}={source}\nrepair: remove the unapproved Git dependency from Cargo.toml" + ))); + } + } + } + Ok(revisions) +} + +fn validate_member_dependencies(manifests: &[(PathBuf, toml::Value)]) -> Result<()> { + for (path, manifest) in manifests { + let mut inherited = BTreeMap::from([("spl-core", 0_usize), ("spl-transport", 0_usize)]); + for table in dependency_tables(manifest) { + for (key, dependency) in table { + let package = package_identity(key, dependency); + let Some(details) = dependency.as_table() else { + if PACKAGES.contains(&package.as_str()) { + return Err(error(format!( + "SPL package {package} leaf inheritance mismatch: expected workspace = true, actual not inherited\nrepair: inherit {package} from root [workspace.dependencies] in {}", + path.display() + ))); + } + continue; + }; + if PACKAGES.contains(&package.as_str()) { + *inherited.get_mut(package.as_str()).unwrap() += 1; + if details.contains_key("path") { + return Err(error(format!( + "SPL package {package} path dependency mismatch: expected absent, actual {}:{key}\nrepair: remove the local path route for {package} from {}", + path.display(), + path.display() + ))); + } + let forbidden = ["git", "rev", "path", "tag", "branch", "version"] + .into_iter() + .filter(|name| details.contains_key(*name)) + .collect::>(); + if details.get("workspace").and_then(toml::Value::as_bool) != Some(true) { + return Err(error(format!( + "SPL package {package} leaf inheritance mismatch: expected workspace = true, actual not inherited\nrepair: inherit {package} from root [workspace.dependencies] in {}", + path.display() + ))); + } + if !forbidden.is_empty() { + return Err(error(format!( + "SPL package {package} leaf inheritance mismatch: expected only workspace = true, actual keys {}\nrepair: remove local source and version keys from {package} in {}", + forbidden.join(","), + path.display() + ))); + } + } else if let Some(source) = details.get("git").and_then(toml::Value::as_str) { + return Err(error(format!( + "Cargo package {package} Git source mismatch: expected no unapproved member Git dependency, actual {}:{key}={source}\nrepair: remove the unapproved Git dependency from {}", + path.display(), + path.display() + ))); + } + } + } + if path == Path::new("crates/solstone-linux/Cargo.toml") { + for package in PACKAGES { + if inherited[package] != 1 { + return Err(error(format!( + "SPL package {package} leaf inheritance mismatch: expected workspace = true, actual missing\nrepair: inherit {package} from root [workspace.dependencies] in {}", + path.display() + ))); + } + } + } + } + Ok(()) +} + +fn validate_overrides(root: &toml::Value) -> Result<()> { + if let Some(patches) = root.get("patch").and_then(toml::Value::as_table) { + for (source, entries) in patches { + if let Some(entries) = entries.as_table() { + for (key, value) in entries { + let package = package_identity(key, value); + if PACKAGES.contains(&package.as_str()) { + return Err(error(format!( + "SPL package {package} patch override mismatch: expected absent, actual [patch.{source}]\nrepair: remove the {package} patch override from root Cargo.toml" + ))); + } + } + } + } + } + if let Some(replacements) = root.get("replace").and_then(toml::Value::as_table) { + for package_id in replacements.keys() { + let package = package_id.split(':').next().unwrap_or(package_id); + if PACKAGES.contains(&package) { + return Err(error(format!( + "SPL package {package} replacement mismatch: expected absent, actual {package_id}\nrepair: remove the {package} replacement from root Cargo.toml" + ))); + } + } + } + Ok(()) +} + +fn validate_config(repo: &Path) -> Result<()> { + for relative in [Path::new(".cargo/config.toml"), Path::new(".cargo/config")] { + if !repo.join(relative).exists() { + continue; + } + let config = read_toml(repo, relative, "workspace manifest")?; + if let Some(sources) = config.get("source").and_then(toml::Value::as_table) { + for (source, value) in sources { + if let Some(replacement) = value.get("replace-with").and_then(toml::Value::as_str) { + return Err(error(format!( + "SPL package spl-core Cargo source replacement mismatch: expected absent, actual {}:{source}->{replacement}\nrepair: remove the source replace-with route affecting spl-core", + relative.display() + ))); + } + } + } + } + Ok(()) +} + +fn validate_in_tree(repo: &Path) -> Result<()> { + let output = Command::new("git") + .args(["ls-files", "-z", "--", "*Cargo.toml"]) + .current_dir(repo) + .output() + .map_err(|cause| error(format!("tracked manifest inventory mismatch: expected git ls-files, actual {cause}\nrepair: restore the Git checkout before validating SPL pins")))?; + if !output.status.success() { + return Err(error(format!( + "tracked manifest inventory mismatch: expected git ls-files, actual {}\nrepair: restore the Git checkout before validating SPL pins", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + for bytes in output + .stdout + .split(|byte| *byte == 0) + .filter(|value| !value.is_empty()) + { + let relative = PathBuf::from(String::from_utf8(bytes.to_vec()).map_err(|cause| error(format!("tracked manifest inventory mismatch: expected UTF-8 paths, actual {cause}\nrepair: restore the Git checkout before validating SPL pins")))?); + let manifest = read_toml(repo, &relative, "workspace manifest")?; + if let Some(package) = manifest + .get("package") + .and_then(|value| value.get("name")) + .and_then(toml::Value::as_str) + && PACKAGES.contains(&package) + { + return Err(error(format!( + "SPL package {package} in-tree implementation mismatch: expected absent, actual {}\nrepair: remove or rename the tracked in-tree crate implementing {package}", + relative.display() + ))); + } + } + Ok(()) +} + +fn validate_lock(repo: &Path, revisions: &BTreeMap) -> Result<()> { + let text = fs::read_to_string(repo.join("Cargo.lock")).map_err(|cause| error(format!("lockfile parse mismatch: expected valid TOML, actual {cause}\nrepair: restore a valid Cargo.lock before validating the SPL pin")))?; + let lock: toml::Value = toml::from_str(&text).map_err(|cause| error(format!("lockfile parse mismatch: expected valid TOML, actual {cause}\nrepair: restore a valid Cargo.lock before validating the SPL pin")))?; + let records = lock + .get("package") + .and_then(toml::Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]); + for package in PACKAGES { + let matches = records + .iter() + .filter(|record| record.get("name").and_then(toml::Value::as_str) == Some(package)) + .collect::>(); + if matches.len() != 1 { + if matches.is_empty() { + return Err(error(format!( + "SPL package {package} lockfile record mismatch: expected exactly one package record, actual missing\nrepair: regenerate Cargo.lock with the approved {package} workspace pin" + ))); + } + return Err(error(format!( + "SPL package {package} lockfile record mismatch: expected exactly one package record, actual {}\nrepair: regenerate Cargo.lock with one resolved {package} package", + matches.len() + ))); + } + let source = matches[0] + .get("source") + .and_then(toml::Value::as_str) + .unwrap_or("missing"); + let Some(rest) = source.strip_prefix("git+") else { + return Err(error(format!( + "SPL package {package} lockfile source mismatch: expected {SPL_SOURCE}, actual {source}\nrepair: regenerate Cargo.lock from the approved {package} workspace source" + ))); + }; + let (before_fragment, fragment) = rest.rsplit_once('#').unwrap_or((rest, "missing")); + let (url, query) = before_fragment + .split_once('?') + .unwrap_or((before_fragment, "")); + if url != SPL_SOURCE { + return Err(error(format!( + "SPL package {package} lockfile source mismatch: expected {SPL_SOURCE}, actual {url}\nrepair: regenerate Cargo.lock from the approved {package} workspace source" + ))); + } + let pairs = query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .collect::>(); + let selectors = pairs.iter().map(|(key, _)| *key).collect::>(); + if selectors != ["rev"] { + let actual = if selectors.is_empty() { + "missing".to_owned() + } else { + selectors.join(",") + }; + return Err(error(format!( + "SPL package {package} lockfile selector mismatch: expected rev, actual {actual}\nrepair: regenerate Cargo.lock from the rev-selected {package} workspace declaration" + ))); + } + let query_revision = pairs[0].1; + if query_revision != revisions[package] { + return Err(error(format!( + "SPL package {package} lockfile revision query mismatch: expected {}, actual {query_revision}\nrepair: regenerate Cargo.lock from the approved {package} workspace revision", + revisions[package] + ))); + } + if fragment != revisions[package] { + return Err(error(format!( + "SPL package {package} lockfile resolved revision mismatch: expected {}, actual {fragment}\nrepair: regenerate Cargo.lock so {package} resolves to the approved workspace revision", + revisions[package] + ))); + } + } + Ok(()) +} + +pub fn validate_spl_pin(repo: &RepoRoot) -> Result<()> { + let root = read_toml(repo.path(), Path::new("Cargo.toml"), "workspace manifest")?; + let revisions = validate_workspace(&root)?; + validate_overrides(&root)?; + let members = root.get("workspace").and_then(|value| value.get("members")).and_then(toml::Value::as_array).ok_or_else(|| error("workspace manifest parse mismatch: expected valid TOML, actual Cargo.toml: missing workspace.members\nrepair: restore valid TOML in Cargo.toml".to_owned()))?; + let mut manifests = Vec::new(); + for member in members { + let member = member.as_str().ok_or_else(|| error("workspace manifest parse mismatch: expected valid TOML, actual Cargo.toml: non-string workspace member\nrepair: restore valid TOML in Cargo.toml".to_owned()))?; + let path = member_path(repo.path(), member)?; + manifests.push(( + path.clone(), + read_toml(repo.path(), &path, "workspace manifest")?, + )); + } + validate_member_dependencies(&manifests)?; + validate_config(repo.path())?; + validate_in_tree(repo.path())?; + validate_lock(repo.path(), &revisions) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn real_checkout_spl_pin_is_valid() { + let repo = RepoRoot::resolve().unwrap(); + validate_spl_pin(&repo).unwrap(); + } +} diff --git a/crates/rust-release-manifest/tests/spl_pin.rs b/crates/rust-release-manifest/tests/spl_pin.rs new file mode 100644 index 0000000..0a6253b --- /dev/null +++ b/crates/rust-release-manifest/tests/spl_pin.rs @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +struct Fixture { + _temp: tempfile::TempDir, + root: PathBuf, +} + +fn live_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap() +} + +fn fixture(mutate: impl FnOnce(&Path)) -> Fixture { + let live = live_root(); + let archive = Command::new("git") + .args(["archive", "--format=tar", "HEAD"]) + .current_dir(&live) + .output() + .unwrap(); + assert!(archive.status.success()); + let temp = tempfile::tempdir().unwrap(); + tar::Archive::new(Cursor::new(archive.stdout)) + .unpack(temp.path()) + .unwrap(); + for relative in [ + "Cargo.toml", + "crates/solstone-linux/Cargo.toml", + "Cargo.lock", + ] { + fs::copy(live.join(relative), temp.path().join(relative)).unwrap(); + } + mutate(temp.path()); + for args in [ + &["init", "-q"][..], + &["config", "user.email", "spl-pin-fixture@invalid.example"][..], + &["config", "user.name", "SPL Pin Fixture"][..], + &["add", "--all"][..], + &["commit", "-q", "-m", "fixture"][..], + ] { + let status = Command::new("git") + .args(args) + .current_dir(temp.path()) + .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z") + .status() + .unwrap(); + assert!(status.success(), "git {args:?}"); + } + let root = temp.path().to_owned(); + Fixture { _temp: temp, root } +} + +fn run(fixture: &Fixture) -> Output { + Command::new(env!("CARGO_BIN_EXE_rust-release-manifest")) + .arg("validate-spl-pin") + .current_dir(&fixture.root) + .output() + .unwrap() +} + +fn rejected(mutate: impl FnOnce(&Path), repair: &str) { + let fixture = fixture(mutate); + let output = run(&fixture); + assert!( + !output.status.success(), + "stdout={}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains(repair), + "expected {repair:?}, actual {stderr:?}" + ); +} + +fn replace(path: &Path, from: &str, to: &str) { + let text = fs::read_to_string(path).unwrap(); + assert!(text.contains(from), "missing replacement source {from:?}"); + fs::write(path, text.replacen(from, to, 1)).unwrap(); +} + +fn root_revision(root: &Path, package: &str) -> String { + let value: toml::Value = + toml::from_str(&fs::read_to_string(root.join("Cargo.toml")).unwrap()).unwrap(); + value["workspace"]["dependencies"][package]["rev"] + .as_str() + .unwrap() + .to_owned() +} + +fn package_block(lock: &str, package: &str) -> String { + let marker = format!("[[package]]\nname = \"{package}\""); + let start = lock.find(&marker).unwrap(); + let end = lock[start + marker.len()..] + .find("\n[[package]]") + .map_or(lock.len(), |offset| start + marker.len() + offset); + lock[start..end].to_owned() +} + +#[test] +fn spl_pin_accepts_consistent_different_revision() { + let fixture = fixture(|root| { + let old = root_revision(root, "spl-core"); + let new = if old == "a".repeat(40) { + "b".repeat(40) + } else { + "a".repeat(40) + }; + for relative in ["Cargo.toml", "Cargo.lock"] { + let path = root.join(relative); + let text = fs::read_to_string(&path).unwrap(); + fs::write(path, text.replace(&old, &new)).unwrap(); + } + }); + let output = run(&fixture); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "SPL dependency pin verified.\n" + ); +} + +#[test] +fn spl_pin_rejects_unapproved_workspace_source() { + rejected( + |root| { + replace( + &root.join("Cargo.toml"), + "https://github.com/solpbc/spl-rust", + "https://example.invalid/spl-rust", + ) + }, + "repair: declare spl-core from the approved SPL Git source in root Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_non_rev_workspace_selector() { + rejected( + |root| replace(&root.join("Cargo.toml"), "rev =", "branch ="), + "repair: select spl-core with only rev in root Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_short_workspace_revision() { + rejected( + |root| { + let revision = root_revision(root, "spl-core"); + replace(&root.join("Cargo.toml"), &revision, &revision[..39]); + }, + "repair: declare the full approved spl-core commit in root Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_missing_workspace_declaration() { + rejected( + |root| { + let path = root.join("Cargo.toml"); + let mut lines = fs::read_to_string(&path) + .unwrap() + .lines() + .map(str::to_owned) + .collect::>(); + lines.retain(|line| !line.starts_with("spl-core =")); + fs::write(path, lines.join("\n") + "\n").unwrap(); + }, + "repair: declare spl-core once in root [workspace.dependencies]", + ); +} + +#[test] +fn spl_pin_rejects_aliased_duplicate_workspace_declaration() { + rejected( + |root| { + let revision = root_revision(root, "spl-core"); + let path = root.join("Cargo.toml"); + let mut text = fs::read_to_string(&path).unwrap(); + text.push_str(&format!("\n[workspace.dependencies.spl_alias]\npackage = \"spl-core\"\nversion = \"0.1.0\"\ngit = \"https://github.com/solpbc/spl-rust\"\nrev = \"{revision}\"\n")); + fs::write(path, text).unwrap(); + }, + "repair: declare spl-core once in root [workspace.dependencies]", + ); +} + +#[test] +fn spl_pin_rejects_different_workspace_revisions() { + rejected( + |root| { + let old = root_revision(root, "spl-transport"); + let new = if old == "a".repeat(40) { + "b".repeat(40) + } else { + "a".repeat(40) + }; + let path = root.join("Cargo.toml"); + let text = fs::read_to_string(&path).unwrap(); + let line = text + .lines() + .find(|line| line.starts_with("spl-transport =")) + .unwrap(); + fs::write(&path, text.replacen(line, &line.replace(&old, &new), 1)).unwrap(); + }, + "repair: pin spl-core and spl-transport to the same revision in root Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_leaf_keys_alongside_inheritance() { + rejected( + |root| { + replace( + &root.join("crates/solstone-linux/Cargo.toml"), + "spl-core.workspace = true", + "spl-core = { workspace = true, rev = \"bad\" }", + ) + }, + "repair: remove local source and version keys from spl-core in crates/solstone-linux/Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_leaf_without_inheritance() { + rejected( + |root| { + replace( + &root.join("crates/solstone-linux/Cargo.toml"), + "spl-core.workspace = true", + "spl-core = \"0.1.0\"", + ) + }, + "repair: inherit spl-core from root [workspace.dependencies] in crates/solstone-linux/Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_missing_shipping_leaf() { + rejected( + |root| { + let path = root.join("crates/solstone-linux/Cargo.toml"); + let text = fs::read_to_string(&path).unwrap(); + fs::write(path, text.replace("spl-core.workspace = true\n", "")).unwrap(); + }, + "repair: inherit spl-core from root [workspace.dependencies] in crates/solstone-linux/Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_missing_lock_record() { + rejected( + |root| { + let path = root.join("Cargo.lock"); + let text = fs::read_to_string(&path).unwrap(); + let block = package_block(&text, "spl-core"); + fs::write(path, text.replacen(&block, "", 1)).unwrap(); + }, + "repair: regenerate Cargo.lock with the approved spl-core workspace pin", + ); +} + +#[test] +fn spl_pin_rejects_duplicate_lock_record() { + rejected( + |root| { + let path = root.join("Cargo.lock"); + let text = fs::read_to_string(&path).unwrap(); + let block = package_block(&text, "spl-core"); + fs::write(path, format!("{text}\n{block}\n")).unwrap(); + }, + "repair: regenerate Cargo.lock with one resolved spl-core package", + ); +} + +#[test] +fn spl_pin_rejects_wrong_lock_source() { + rejected( + |root| { + let path = root.join("Cargo.lock"); + let text = fs::read_to_string(&path).unwrap(); + let block = package_block(&text, "spl-core"); + fs::write( + path, + text.replacen( + &block, + &block.replacen( + "https://github.com/solpbc/spl-rust", + "https://example.invalid/spl-rust", + 1, + ), + 1, + ), + ) + .unwrap(); + }, + "repair: regenerate Cargo.lock from the approved spl-core workspace source", + ); +} + +#[test] +fn spl_pin_rejects_non_rev_lock_selector() { + rejected( + |root| { + let path = root.join("Cargo.lock"); + let text = fs::read_to_string(&path).unwrap(); + let block = package_block(&text, "spl-core"); + fs::write( + path, + text.replacen(&block, &block.replacen("?rev=", "?branch=", 1), 1), + ) + .unwrap(); + }, + "repair: regenerate Cargo.lock from the rev-selected spl-core workspace declaration", + ); +} + +#[test] +fn spl_pin_rejects_wrong_lock_query_revision() { + rejected( + |root| { + let revision = root_revision(root, "spl-core"); + let path = root.join("Cargo.lock"); + let text = fs::read_to_string(&path).unwrap(); + let block = package_block(&text, "spl-core"); + let changed = block.replacen( + &format!("rev={revision}"), + &format!("rev={}", "a".repeat(40)), + 1, + ); + fs::write(path, text.replacen(&block, &changed, 1)).unwrap(); + }, + "repair: regenerate Cargo.lock from the approved spl-core workspace revision", + ); +} + +#[test] +fn spl_pin_rejects_wrong_lock_resolved_revision() { + rejected( + |root| { + let revision = root_revision(root, "spl-core"); + let path = root.join("Cargo.lock"); + let text = fs::read_to_string(&path).unwrap(); + let block = package_block(&text, "spl-core"); + let changed = + block.replacen(&format!("#{revision}"), &format!("#{}", "a".repeat(40)), 1); + fs::write(path, text.replacen(&block, &changed, 1)).unwrap(); + }, + "repair: regenerate Cargo.lock so spl-core resolves to the approved workspace revision", + ); +} + +#[test] +fn spl_pin_rejects_git_source_patch() { + rejected( + |root| { + let path = root.join("Cargo.toml"); + let mut text = fs::read_to_string(&path).unwrap(); + text.push_str("\n[patch.\"https://github.com/solpbc/spl-rust\"]\nspl-core = { path = \"local\" }\n"); + fs::write(path, text).unwrap(); + }, + "repair: remove the spl-core patch override from root Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_crates_io_patch() { + rejected( + |root| { + let path = root.join("Cargo.toml"); + let mut text = fs::read_to_string(&path).unwrap(); + text.push_str("\n[patch.crates-io]\nspl-core = { path = \"local\" }\n"); + fs::write(path, text).unwrap(); + }, + "repair: remove the spl-core patch override from root Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_replace() { + rejected( + |root| { + let path = root.join("Cargo.toml"); + let mut text = fs::read_to_string(&path).unwrap(); + text.push_str("\n[replace]\n\"spl-core:0.1.0\" = { path = \"local\" }\n"); + fs::write(path, text).unwrap(); + }, + "repair: remove the spl-core replacement from root Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_aliased_path_dependency() { + rejected( + |root| { + let path = root.join("crates/solstone-linux/Cargo.toml"); + let mut text = fs::read_to_string(&path).unwrap(); + text.push_str("\n[target.'cfg(any())'.dependencies]\nspl_alias = { package = \"spl-core\", path = \"../local\" }\n"); + fs::write(path, text).unwrap(); + }, + "repair: remove the local path route for spl-core from crates/solstone-linux/Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_root_cargo_source_replacement() { + rejected( + |root| { + fs::create_dir(root.join(".cargo")).unwrap(); + fs::write(root.join(".cargo/config.toml"), "[source.crates-io]\nreplace-with = \"local\"\n[source.local]\ndirectory = \"vendor\"\n").unwrap(); + }, + "repair: remove the source replace-with route affecting spl-core", + ); +} + +#[test] +fn spl_pin_rejects_tracked_in_tree_package() { + rejected( + |root| { + fs::create_dir(root.join("local-spl")).unwrap(); + fs::write( + root.join("local-spl/Cargo.toml"), + "[package]\nname = \"spl-core\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + }, + "repair: remove or rename the tracked in-tree crate implementing spl-core", + ); +} + +#[test] +fn spl_pin_rejects_unapproved_member_git_dependency() { + rejected( + |root| { + let path = root.join("crates/solstone-linux/Cargo.toml"); + let mut text = fs::read_to_string(&path).unwrap(); + text.push_str("\n[target.'cfg(any())'.dependencies]\nother = { git = \"https://example.invalid/other\" }\n"); + fs::write(path, text).unwrap(); + }, + "repair: remove the unapproved Git dependency from crates/solstone-linux/Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_invalid_workspace_manifest_toml() { + rejected( + |root| fs::write(root.join("Cargo.toml"), "not = [valid").unwrap(), + "repair: restore valid TOML in Cargo.toml", + ); +} + +#[test] +fn spl_pin_rejects_invalid_lockfile_toml() { + rejected( + |root| fs::write(root.join("Cargo.lock"), "not = [valid").unwrap(), + "repair: restore a valid Cargo.lock before validating the SPL pin", + ); +} diff --git a/crates/solstone-linux/Cargo.toml b/crates/solstone-linux/Cargo.toml index e8548dd..a80e0cb 100644 --- a/crates/solstone-linux/Cargo.toml +++ b/crates/solstone-linux/Cargo.toml @@ -24,8 +24,8 @@ 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", "stream"] } -spl-core = { version = "0.1.0", git = "https://github.com/solpbc/spl-rust", rev = "e86c6d0fa0518fcde1fdc1d0e6b9c1ba090a9dbe" } -spl-transport = { version = "0.1.0", git = "https://github.com/solpbc/spl-rust", rev = "e86c6d0fa0518fcde1fdc1d0e6b9c1ba090a9dbe" } +spl-core.workspace = true +spl-transport.workspace = true tokio = { workspace = true, features = ["signal", "sync"] } tokio-util = { version = "0.7", features = ["io", "rt"] } ashpd = { version = "=0.13.12", features = ["screencast"] } diff --git a/crates/solstone-linux/src/toolchain_policy_tests.rs b/crates/solstone-linux/src/toolchain_policy_tests.rs index ea33faf..dc65eb2 100644 --- a/crates/solstone-linux/src/toolchain_policy_tests.rs +++ b/crates/solstone-linux/src/toolchain_policy_tests.rs @@ -704,8 +704,10 @@ fn dependency_policy_denies_wildcards_and_unknown_sources() { } } +// Manifest-level Git-route and revision enforcement lives in validate_spl_pin in +// the rust-release-manifest crate. #[test] -fn dependency_policy_pins_the_single_spl_git_source() { +fn dependency_policy_allows_only_the_approved_sources() { let root = workspace_root(); let deny = read_toml(&root.join("deny.toml")); let strings = |value: &toml::Value| { @@ -724,28 +726,6 @@ fn dependency_policy_pins_the_single_spl_git_source() { strings(&deny["sources"]["allow-git"]), ["https://github.com/solpbc/spl-rust"] ); - assert_eq!(deny["sources"]["unknown-registry"].as_str(), Some("deny")); - assert_eq!(deny["sources"]["unknown-git"].as_str(), Some("deny")); - assert_eq!(deny["bans"]["wildcards"].as_str(), Some("deny")); - - let manifest = read_toml(&root.join("crates/solstone-linux/Cargo.toml")); - for name in ["spl-core", "spl-transport"] { - let dependency = &manifest["dependencies"][name]; - assert_eq!(dependency["version"].as_str(), Some("0.1.0")); - assert_eq!( - dependency["git"].as_str(), - Some("https://github.com/solpbc/spl-rust") - ); - assert_eq!( - dependency["rev"].as_str(), - Some("e86c6d0fa0518fcde1fdc1d0e6b9c1ba090a9dbe") - ); - } - for (name, dependency) in manifest["dependencies"].as_table().unwrap() { - if dependency.get("git").is_some() { - assert!(matches!(name.as_str(), "spl-core" | "spl-transport")); - } - } } #[test] @@ -764,14 +744,8 @@ fn app_reqwest_edge_has_no_direct_tls_feature() { ); // 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 + // edge. Transitive dependencies 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("e86c6d0fa0518fcde1fdc1d0e6b9c1ba090a9dbe") - ); - } } #[test]