From ebf424436228fddbaae7916704110542a6741c80 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Wed, 22 Jul 2026 12:31:07 -0600 Subject: [PATCH] feat(rust): add release-transparency publisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the operator-driven release-transparency publisher through `make publish-transparency RELEASE_DIR=…` and `make resign-transparency-pointer`. It publishes evidence only: digests, the companion manifest, install-proof receipts, a signed hash-chained ledger entry, and a signed latest pointer. It neither gates nor alters delivery, and never builds, rebuilds, mutates, deletes, or tags release bytes. Implement the cross-repo byte-normative shared sol pbc release-transparency publisher v1 contract. No new dependency enters shipped product code, `crates/solstone-linux` is unchanged, and no live publication, credentials, or real keys are involved. --- .gitignore | 1 + AGENTS.md | 4 + Makefile | 15 +- RELEASING.md | 25 + crates/rust-release-manifest/src/lib.rs | 4 + crates/rust-release-manifest/src/main.rs | 20 +- .../rust-release-manifest/src/transparency.rs | 2052 +++++++++++++++++ .../src/transparency_tests.rs | 1513 ++++++++++++ .../transparency/canonical-entry.json | 1 + .../transparency/canonical-latest.json | 1 + .../transparency/entry-trusted-comment.txt | 1 + .../transparency/latest-trusted-comment.txt | 1 + transparency-head-log.jsonl | 0 13 files changed, 3635 insertions(+), 3 deletions(-) create mode 100644 crates/rust-release-manifest/src/transparency.rs create mode 100644 crates/rust-release-manifest/src/transparency_tests.rs create mode 100644 crates/rust-release-manifest/testdata/transparency/canonical-entry.json create mode 100644 crates/rust-release-manifest/testdata/transparency/canonical-latest.json create mode 100644 crates/rust-release-manifest/testdata/transparency/entry-trusted-comment.txt create mode 100644 crates/rust-release-manifest/testdata/transparency/latest-trusted-comment.txt create mode 100644 transparency-head-log.jsonl diff --git a/.gitignore b/.gitignore index 655ba7f..4967cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ build/ .installed uv.lock /target/ +/.transparency-staging/ diff --git a/AGENTS.md b/AGENTS.md index fd704f5..43738b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ This is **not** part of the solstone monorepo. It is a standalone Rust package w ``` crates/solstone-linux/src/ Shipping Rust observer, CLI, service, sync, and capture code +crates/rust-release-manifest/src/transparency.rs Operator release-transparency publisher +transparency-head-log.jsonl Tracked transparency head witness packaging/ Native package Containerfile and install notes scripts/build-release.sh Non-candidate native package drift helper scripts/install.sh Portable archive installer @@ -80,6 +82,8 @@ make install # Establish pinned Rust/tools and install the observer make format # Format Rust source make test # Run locked Rust tests make check-rust-release-manifest # Validate release-manifest fixtures offline +make publish-transparency RELEASE_DIR= # Publish retained release evidence +make resign-transparency-pointer # Verify the chain and renew its signed pointer make release-candidate # Create and locally prove one atomic candidate make release-images # Build the local Ubuntu and Fedora release build/proof images make release-candidate-prove # Resume only missing package proofs diff --git a/Makefile b/Makefile index ea7729f..07f6ce5 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # solstone-linux Makefile # Standalone Linux desktop observer for solstone -.PHONY: all bootstrap install format test check-observer-contract check-rust-release-manifest ci audit update-deps shellcheck install-service uninstall-service service-restart service-status service-logs versions clean clean-install release release-images release-candidate release-candidate-prove release-candidate-recover legacy-python-bootstrap legacy-python-install legacy-python-format legacy-python-test legacy-python-test-only legacy-python-ci legacy-python-release legacy-python-release-test check-toolchain-env establish-toolchain rust-preflight check-cargo-deny +.PHONY: all bootstrap install format test check-observer-contract check-rust-release-manifest check-transparency-minisign ci audit update-deps shellcheck install-service uninstall-service service-restart service-status service-logs versions clean clean-install release release-images release-candidate release-candidate-prove release-candidate-recover publish-transparency resign-transparency-pointer legacy-python-bootstrap legacy-python-install legacy-python-format legacy-python-test legacy-python-test-only legacy-python-ci legacy-python-release legacy-python-release-test check-toolchain-env establish-toolchain rust-preflight check-cargo-deny APP := solstone-linux UNIT := solstone-linux.service @@ -126,7 +126,7 @@ check-rust-release-manifest: rust-preflight shellcheck: shellcheck $(SHELLCHECK_SCRIPTS) -ci: rust-preflight check-cargo-deny check-observer-contract check-rust-release-manifest +ci: rust-preflight check-cargo-deny check-observer-contract check-rust-release-manifest check-transparency-minisign @echo "Evidence class: host evidence (format, lint, tests, and offline dependency policy)." @echo "This gate does not run target-package validation or the release FLAC soak." $(CARGO) fmt --check @@ -135,6 +135,17 @@ ci: rust-preflight check-cargo-deny check-observer-contract check-rust-release-m $(MAKE) shellcheck cargo deny $(CARGO_LOCKED) --offline check licenses bans sources +check-transparency-minisign: rust-preflight + @command -v minisign >/dev/null 2>&1 || { echo "error: minisign prerequisite mismatch: expected minisign on PATH, actual missing" >&2; echo "repair: sudo zypper install minisign" >&2; exit 1; } + CARGO_NET_OFFLINE=true $(CARGO) test $(CARGO_LOCKED) -p rust-release-manifest transparency_tests::real_minisign_sign_verify_and_reject_tamper -- --exact --ignored + +publish-transparency: rust-preflight + @test -n "$(strip $(RELEASE_DIR))" || { echo "error: transparency release directory mismatch: expected RELEASE_DIR, actual missing" >&2; echo "repair: make publish-transparency RELEASE_DIR=" >&2; exit 1; } + CARGO_NET_OFFLINE=true $(CARGO) run $(CARGO_LOCKED) -p rust-release-manifest -- transparency publish --release-dir "$(RELEASE_DIR)" + +resign-transparency-pointer: rust-preflight + CARGO_NET_OFFLINE=true $(CARGO) run $(CARGO_LOCKED) -p rust-release-manifest -- transparency resign-pointer + audit: rust-preflight check-cargo-deny @echo "Evidence class: refreshed advisory evidence." cargo deny fetch db diff --git a/RELEASING.md b/RELEASING.md index bf43460..14494c8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -202,3 +202,28 @@ live FLAC checkpoint. `make audit` refreshes advisory data for a separate operator audit. Candidate creation instead consumes the explicitly acquired descriptor cohort and leaves the repository `deny.toml` unchanged. + +## Release transparency + +After delivery, publish the retained candidate with `make publish-transparency +RELEASE_DIR=`. This env-driven, retryable step never gates +delivery; see the retained candidate paths above rather than reconstructing its +inventory. Minisign is a development prerequisite. Version keys are one-shot and +permanent. The final `latest.json` body PUT is the commit boundary: before it the +pointer body is old and afterward it is new. The signature-first write can briefly +produce a pointer/signature mismatch; consumers retry that recognized transient. +The archive retains the candidate artifact bytes alongside the release evidence; +the public transparency surface carries evidence only, never artifact bytes. +If an entry was uploaded before its pointer, retry the same command; if +the version was permanently recorded against a superseded chain head, cut the next +version. + +`make resign-transparency-pointer` is the freeze defense. It first verifies the +signed pointer, signed tip, product binding, and rollback protection against the +[transparency head log](transparency-head-log.jsonl), then renews only the pointer +signature and validity. It never re-attests a rolled-back or foreign pointer. + +The surface attests what was released, that it is immutable, and that history is +publicly reconstructible — not that binaries provably match source. Publication is +operator-approved and separate from the retained legacy Python publisher, which is +not a native release path. diff --git a/crates/rust-release-manifest/src/lib.rs b/crates/rust-release-manifest/src/lib.rs index 115c92c..af473fa 100644 --- a/crates/rust-release-manifest/src/lib.rs +++ b/crates/rust-release-manifest/src/lib.rs @@ -25,6 +25,8 @@ mod candidate; pub use candidate::*; mod transaction; pub use transaction::*; +mod transparency; +pub use transparency::*; pub const SCHEMA_VERSION: u64 = 1; pub const SCHEMA_SHA256: &str = "d4eabf52bcc68b56945912d351f818e5444fe8c6461cb5c48b096f87b17a875c"; @@ -3319,3 +3321,5 @@ mod candidate_tests; mod proof_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod transparency_tests; diff --git a/crates/rust-release-manifest/src/main.rs b/crates/rust-release-manifest/src/main.rs index 705ab7e..8d7f2cb 100644 --- a/crates/rust-release-manifest/src/main.rs +++ b/crates/rust-release-manifest/src/main.rs @@ -5,7 +5,8 @@ use clap::{Args, Parser, Subcommand}; use rust_release_manifest::{ 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, recover_candidate, verify_manifest_mode, + emit_proof_handoff, prove_candidate, publish_transparency, recover_candidate, + resign_transparency_pointer, verify_manifest_mode, }; use std::path::PathBuf; @@ -31,6 +32,19 @@ enum Command { #[command(subcommand)] command: CandidateCommand, }, + Transparency { + #[command(subcommand)] + command: TransparencyCommand, + }, +} + +#[derive(Subcommand)] +enum TransparencyCommand { + Publish { + #[arg(long)] + release_dir: PathBuf, + }, + ResignPointer, } #[derive(Subcommand)] @@ -199,6 +213,10 @@ fn run() -> Result<(), Box> { }; println!("{}", serde_json::to_string(&status)?); } + Command::Transparency { command } => match command { + TransparencyCommand::Publish { release_dir } => publish_transparency(&release_dir)?, + TransparencyCommand::ResignPointer => resign_transparency_pointer()?, + }, } Ok(()) } diff --git a/crates/rust-release-manifest/src/transparency.rs b/crates/rust-release-manifest/src/transparency.rs new file mode 100644 index 0000000..ce37135 --- /dev/null +++ b/crates/rust-release-manifest/src/transparency.rs @@ -0,0 +1,2052 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +//! Operator-driven publication of retained release evidence. + +use super::*; +use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const TRANSPARENCY_ENTRY_SCHEMA: &str = + "https://solpbc.org/schemas/transparency-ledger-entry/v1.json"; +pub const TRANSPARENCY_LATEST_SCHEMA: &str = + "https://solpbc.org/schemas/transparency-latest/v1.json"; +pub const TRANSPARENCY_DEFAULT_BASE_URL: &str = "https://transparency.solstone.app"; +pub const TRANSPARENCY_HEAD_LOG: &str = "transparency-head-log.jsonl"; +const ZERO_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000"; +const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable"; +const MUTABLE_CACHE: &str = "no-cache"; + +pub(crate) fn transparency_error( + class: &str, + thing: impl std::fmt::Display, + expected: impl std::fmt::Display, + actual: impl std::fmt::Display, + repair: impl std::fmt::Display, +) -> Error { + Error::new(format!( + "{class}: {thing} mismatch: expected {expected}, actual {actual}\nrepair: {repair}" + )) +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct TransparencyArtifact { + pub bytes: u64, + pub name: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct TransparencyNamedDigest { + pub name: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TransparencyEntry { + pub artifacts: Vec, + pub manifests: Vec, + pub prev_sha256: String, + pub prev_version: String, + pub product: String, + pub proofs: Vec, + pub published_utc: String, + pub schema: String, + pub seq: u64, + pub source_commit: String, + pub version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TransparencyPointer { + pub chain_length: u64, + pub product: String, + pub schema: String, + pub signed_at: String, + pub tip_sha256: String, + pub valid_until: String, + pub version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TransparencyHeadRow { + pub entry_sha256: String, + pub product: String, + pub published_utc: String, + pub seq: u64, + pub version: String, +} + +fn validate_ascii(value: &Value) -> Result<()> { + match value { + Value::String(text) => { + if !text.is_ascii() { + return Err(transparency_error( + "terminal", + "transparency canonical JSON string", + "ASCII", + "non-ASCII value", + "replace the non-ASCII value before retrying", + )); + } + } + Value::Array(items) => { + for item in items { + validate_ascii(item)?; + } + } + Value::Object(object) => { + for (key, item) in object { + if !key.is_ascii() { + return Err(transparency_error( + "terminal", + "transparency canonical JSON key", + "ASCII", + "non-ASCII value", + "replace the non-ASCII key before retrying", + )); + } + validate_ascii(item)?; + } + } + _ => {} + } + Ok(()) +} + +fn write_transparency_value(value: &Value, output: &mut Vec) -> Result<()> { + match value { + Value::Null => output.extend_from_slice(b"null"), + Value::Bool(value) => output.extend_from_slice(if *value { b"true" } else { b"false" }), + Value::Number(number) => { + if !number.is_i64() && !number.is_u64() { + return Err(transparency_error( + "terminal", + "transparency canonical JSON number", + "integer", + "float", + "provide an integer-valued transparency document", + )); + } + output.extend_from_slice(number.to_string().as_bytes()); + } + Value::String(text) => output.extend_from_slice( + serde_json::to_string(text) + .map_err(display_error)? + .as_bytes(), + ), + Value::Array(items) => { + output.push(b'['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + output.push(b','); + } + write_transparency_value(item, output)?; + } + output.push(b']'); + } + Value::Object(object) => { + output.push(b'{'); + let mut fields = object.iter().collect::>(); + fields.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes())); + for (index, (key, item)) in fields.into_iter().enumerate() { + if index > 0 { + output.push(b','); + } + output.extend_from_slice( + serde_json::to_string(key) + .map_err(display_error)? + .as_bytes(), + ); + output.push(b':'); + write_transparency_value(item, output)?; + } + output.push(b'}'); + } + } + Ok(()) +} + +/// Canonicalizes transparency JSON. This is deliberately separate from rail canonical JSON. +pub fn transparency_canonical_json(value: &Value) -> Result> { + validate_ascii(value)?; + for numeric in ["seq", "bytes", "chain_length"] { + reject_boolean_numeric_field(value, numeric)?; + } + let mut output = Vec::new(); + write_transparency_value(value, &mut output)?; + output.push(b'\n'); + Ok(output) +} + +fn reject_boolean_numeric_field(value: &Value, field: &str) -> Result<()> { + match value { + Value::Object(object) => { + if object.get(field).is_some_and(Value::is_boolean) { + return Err(transparency_error( + "terminal", + format!("transparency {field}"), + "integer", + "boolean", + "provide an integer-valued transparency document", + )); + } + for child in object.values() { + reject_boolean_numeric_field(child, field)?; + } + } + Value::Array(items) => { + for child in items { + reject_boolean_numeric_field(child, field)?; + } + } + _ => {} + } + Ok(()) +} + +pub fn entry_trusted_comment(entry: &TransparencyEntry, identity: &str) -> String { + format!( + "solpbc-transparency-v1 entry product={} seq={} version={} sha256={} prev={}", + entry.product, entry.seq, entry.version, identity, entry.prev_sha256 + ) +} + +pub fn pointer_trusted_comment(pointer: &TransparencyPointer) -> String { + format!( + "solpbc-transparency-v1 latest product={} chain_length={} tip={} valid_until={}", + pointer.product, pointer.chain_length, pointer.tip_sha256, pointer.valid_until + ) +} + +fn exact_timestamp(label: &str, timestamp: &str) -> Result> { + if timestamp.len() != 20 + || !timestamp.ends_with('Z') + || timestamp.as_bytes().get(4) != Some(&b'-') + || timestamp.as_bytes().get(7) != Some(&b'-') + || timestamp.as_bytes().get(10) != Some(&b'T') + || timestamp.as_bytes().get(13) != Some(&b':') + || timestamp.as_bytes().get(16) != Some(&b':') + { + return Err(transparency_error( + "terminal", + label, + "YYYY-MM-DDTHH:MM:SSZ", + timestamp, + "provide an exact UTC-seconds timestamp", + )); + } + DateTime::parse_from_rfc3339(timestamp) + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| { + transparency_error( + "terminal", + label, + "valid UTC timestamp", + timestamp, + "provide an exact UTC-seconds timestamp", + ) + }) +} + +pub fn validate_entry( + entry: &TransparencyEntry, + previous: Option<&TransparencyEntry>, +) -> Result<()> { + if entry.schema != TRANSPARENCY_ENTRY_SCHEMA || entry.product != PRODUCT { + return Err(transparency_error( + "terminal", + "transparency entry identity", + format!("schema {TRANSPARENCY_ENTRY_SCHEMA} and product {PRODUCT}"), + format!("schema {} and product {}", entry.schema, entry.product), + "use the transparency chain for solstone-linux", + )); + } + if !is_git_commit(&entry.source_commit) || !is_sha256(&entry.prev_sha256) { + return Err(transparency_error( + "terminal", + "transparency entry binding", + "lowercase commit and SHA-256", + "invalid binding", + "rebuild the entry from validated candidate state", + )); + } + exact_timestamp("transparency published_utc", &entry.published_utc)?; + let mut artifacts = entry.artifacts.clone(); + artifacts.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes())); + let mut manifests = entry.manifests.clone(); + manifests.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes())); + let mut proofs = entry.proofs.clone(); + proofs.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes())); + if artifacts != entry.artifacts || manifests != entry.manifests || proofs != entry.proofs { + return Err(transparency_error( + "terminal", + "transparency inventory order", + "name-sorted arrays", + "unsorted array", + "rebuild from the validated candidate", + )); + } + match previous { + None if entry.seq == 1 + && entry.prev_sha256 == ZERO_SHA256 + && entry.prev_version.is_empty() => {} + Some(previous) => { + let previous_bytes = transparency_canonical_json( + &serde_json::to_value(previous).map_err(display_error)?, + )?; + let expected_digest = digest(&previous_bytes); + let previous_time = exact_timestamp("previous published_utc", &previous.published_utc)?; + let current_time = exact_timestamp("transparency published_utc", &entry.published_utc)?; + if entry.seq != previous.seq + 1 + || entry.prev_sha256 != expected_digest + || entry.prev_version != previous.version + || current_time <= previous_time + { + return Err(transparency_error( + "terminal", + "transparency chain linkage", + format!( + "seq {} prev {} version {} and later time", + previous.seq + 1, + expected_digest, + previous.version + ), + format!( + "seq {} prev {} version {}", + entry.seq, entry.prev_sha256, entry.prev_version + ), + "restore the verified transparency chain before retrying", + )); + } + } + None => { + return Err(transparency_error( + "terminal", + "transparency genesis", + "seq 1, zero previous digest, and empty previous version", + "invalid genesis binding", + "start genesis only with the fixed genesis values", + )); + } + } + Ok(()) +} + +pub fn validate_pointer(pointer: &TransparencyPointer, tip: &TransparencyEntry) -> Result<()> { + let tip_bytes = + transparency_canonical_json(&serde_json::to_value(tip).map_err(display_error)?)?; + let tip_identity = digest(&tip_bytes); + let signed = exact_timestamp("transparency pointer signed_at", &pointer.signed_at)?; + let valid = exact_timestamp("transparency pointer valid_until", &pointer.valid_until)?; + if pointer.schema != TRANSPARENCY_LATEST_SCHEMA + || pointer.product != PRODUCT + || pointer.chain_length != tip.seq + || pointer.tip_sha256 != tip_identity + || pointer.version != tip.version + || valid != signed + Duration::days(14) + { + return Err(transparency_error( + "terminal", + "transparency pointer binding", + "verified solstone-linux tip and fourteen-day validity", + "different pointer semantics", + "restore the signed pointer for the verified tip", + )); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Destination { + S3 { + endpoint: String, + bucket: String, + key: String, + }, + Public { + base_url: String, + key: String, + }, +} + +impl Destination { + fn url(&self) -> String { + match self { + Self::S3 { + endpoint, + bucket, + key, + } => format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key), + Self::Public { base_url, key } => { + format!("{}/{}", base_url.trim_end_matches('/'), key) + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransportResponse { + pub http_status: u16, + pub body: Vec, + pub etag: Option, + pub process_exit: i32, +} + +pub trait TransparencyTransport { + fn get(&mut self, destination: &Destination, cache_bypass: bool) -> Result; + fn put_create_only( + &mut self, + destination: &Destination, + bytes: &[u8], + cache_control: &str, + ) -> Result; + fn put_conditional( + &mut self, + destination: &Destination, + bytes: &[u8], + etag: Option<&str>, + cache_control: &str, + ) -> Result; + fn list(&mut self, destination: &Destination, prefix: &str) -> Result; +} + +pub trait ArchiveChannel { + fn archive(&mut self, staging: &Path, manifest_sha256: &str) -> Result; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArchiveResponse { + pub exit_status: i32, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Clone, Debug)] +pub struct CurlTransport { + endpoint: String, + bucket: String, + access_key: String, + secret_key: String, + response_root: PathBuf, + sequence: u64, +} + +impl CurlTransport { + fn new(config: &TransparencyConfig, root: &Path) -> Self { + Self { + endpoint: config.s3_endpoint.clone(), + bucket: config.bucket.clone(), + access_key: config.access_key.clone(), + secret_key: config.secret_key.clone(), + response_root: root.to_owned(), + sequence: 0, + } + } + + fn execute( + &mut self, + destination: &Destination, + method_args: &[&OsStr], + cache_bypass: bool, + ) -> Result { + self.sequence += 1; + let body_path = self + .response_root + .join(format!(".curl-body-{}", self.sequence)); + let header_path = self + .response_root + .join(format!(".curl-headers-{}", self.sequence)); + let mut command = Command::new("curl"); + command.args([ + "--silent", + "--show-error", + "--aws-sigv4", + "aws:amz:auto:s3", + "--user", + &format!("{}:{}", self.access_key, self.secret_key), + "--output", + ]); + command + .arg(&body_path) + .arg("--dump-header") + .arg(&header_path); + command.args(["--write-out", "%{http_code}"]); + if cache_bypass { + command.args(["--header", "Cache-Control: no-cache"]); + } + command.args(method_args).arg(destination.url()); + let output = command.output().map_err(display_error)?; + let status_text = String::from_utf8_lossy(&output.stdout); + let http_status = status_text.trim().parse::().unwrap_or(0); + let body = fs::read(&body_path).unwrap_or_default(); + let headers = fs::read_to_string(&header_path).unwrap_or_default(); + let etag = headers.lines().rev().find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("etag") + .then(|| value.trim().to_owned()) + }) + }); + let _ = fs::remove_file(body_path); + let _ = fs::remove_file(header_path); + Ok(TransportResponse { + http_status, + body, + etag, + process_exit: output.status.code().unwrap_or(-1), + }) + } + + fn assert_s3(&self, destination: &Destination) -> Result<()> { + match destination { + Destination::S3 { + endpoint, bucket, .. + } if endpoint == &self.endpoint && bucket == &self.bucket => Ok(()), + _ => Err(transparency_error( + "terminal", + "transparency transport destination", + "configured S3 endpoint and bucket", + "different destination", + "use only the configured transparency destinations", + )), + } + } +} + +impl TransparencyTransport for CurlTransport { + fn get(&mut self, destination: &Destination, cache_bypass: bool) -> Result { + self.execute(destination, &[], cache_bypass) + } + + fn put_create_only( + &mut self, + destination: &Destination, + bytes: &[u8], + cache_control: &str, + ) -> Result { + self.assert_s3(destination)?; + let upload = self + .response_root + .join(format!(".curl-upload-{}", self.sequence + 1)); + fs::write(&upload, bytes).map_err(display_error)?; + let header = format!("Cache-Control: {cache_control}"); + let args = [ + OsStr::new("--upload-file"), + upload.as_os_str(), + OsStr::new("--header"), + OsStr::new("If-None-Match: *"), + OsStr::new("--header"), + OsStr::new(&header), + ]; + let response = self.execute(destination, &args, false); + let _ = fs::remove_file(upload); + response + } + + fn put_conditional( + &mut self, + destination: &Destination, + bytes: &[u8], + etag: Option<&str>, + cache_control: &str, + ) -> Result { + self.assert_s3(destination)?; + let upload = self + .response_root + .join(format!(".curl-upload-{}", self.sequence + 1)); + fs::write(&upload, bytes).map_err(display_error)?; + let cache = format!("Cache-Control: {cache_control}"); + let condition = etag.map_or_else( + || "If-None-Match: *".to_owned(), + |tag| format!("If-Match: {tag}"), + ); + let args = [ + OsStr::new("--upload-file"), + upload.as_os_str(), + OsStr::new("--header"), + OsStr::new(&condition), + OsStr::new("--header"), + OsStr::new(&cache), + ]; + let response = self.execute(destination, &args, false); + let _ = fs::remove_file(upload); + response + } + + fn list(&mut self, destination: &Destination, prefix: &str) -> Result { + self.assert_s3(destination)?; + let separator = if destination.url().contains('?') { + '&' + } else { + '?' + }; + let listed = match destination { + Destination::S3 { + endpoint, bucket, .. + } => Destination::S3 { + endpoint: endpoint.clone(), + bucket: bucket.clone(), + key: format!( + "{separator}list-type=2&prefix={}", + prefix.replace('/', "%2F") + ), + }, + _ => unreachable!(), + }; + self.execute(&listed, &[], true) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct TransparencyConfig { + pub(crate) base_url: String, + pub(crate) s3_endpoint: String, + pub(crate) bucket: String, + pub(crate) access_key: String, + pub(crate) secret_key: String, + pub(crate) minisign_key: PathBuf, + pub(crate) minisign_pub: PathBuf, + pub(crate) archive_channel: Option, + pub(crate) genesis: bool, +} + +impl TransparencyConfig { + fn from_env(require_archive: bool) -> Result { + let required = |name: &str| { + env::var(name).map_err(|_| { + transparency_error( + "retryable", + "transparency environment", + name, + "missing", + format!("set {name} and retry the transparency command"), + ) + }) + }; + let archive_channel = env::var("TRANSPARENCY_ARCHIVE_CHANNEL").ok(); + if require_archive && archive_channel.is_none() { + return Err(transparency_error( + "retryable", + "transparency archive channel", + "TRANSPARENCY_ARCHIVE_CHANNEL", + "missing", + "set TRANSPARENCY_ARCHIVE_CHANNEL and retry make publish-transparency", + )); + } + let config = Self { + base_url: env::var("TRANSPARENCY_BASE_URL") + .unwrap_or_else(|_| TRANSPARENCY_DEFAULT_BASE_URL.into()), + s3_endpoint: required("TRANSPARENCY_S3_ENDPOINT")?, + bucket: required("TRANSPARENCY_BUCKET")?, + access_key: required("TRANSPARENCY_S3_ACCESS_KEY_ID")?, + secret_key: required("TRANSPARENCY_S3_SECRET_ACCESS_KEY")?, + minisign_key: required("TRANSPARENCY_MINISIGN_KEY")?.into(), + minisign_pub: required("TRANSPARENCY_MINISIGN_PUB")?.into(), + archive_channel, + genesis: env::var("TRANSPARENCY_GENESIS").is_ok_and(|value| value == "1"), + }; + for (label, value) in [ + ("TRANSPARENCY_BASE_URL", config.base_url.as_str()), + ("TRANSPARENCY_S3_ENDPOINT", config.s3_endpoint.as_str()), + ] { + if !value.starts_with("https://") || value.chars().any(char::is_control) { + return Err(transparency_error( + "terminal", + "transparency URL", + "HTTPS URL", + format!("invalid {label}"), + format!("set {label} to its approved HTTPS value"), + )); + } + } + Ok(config) + } +} + +struct CommandArchive { + command: String, +} + +impl ArchiveChannel for CommandArchive { + fn archive(&mut self, staging: &Path, manifest_sha256: &str) -> Result { + let output = Command::new("sh") + .args(["-c", "exec $1 $2", "transparency-archive", &self.command]) + .arg(staging) + .output() + .map_err(display_error)?; + let response = ArchiveResponse { + exit_status: output.status.code().unwrap_or(-1), + stdout: output.stdout, + stderr: output.stderr, + }; + let expected = format!("ARCHIVED {manifest_sha256}"); + if response.exit_status != 0 + || String::from_utf8_lossy(&response.stdout).lines().last() != Some(&expected) + { + return Err(transparency_error( + "retryable", + "transparency archive receipt", + expected, + sanitize_process_stderr(&response.stdout), + "repair the archive channel and retry make publish-transparency", + )); + } + Ok(response) + } +} + +fn s3(config: &TransparencyConfig, key: &str) -> Destination { + Destination::S3 { + endpoint: config.s3_endpoint.clone(), + bucket: config.bucket.clone(), + key: key.into(), + } +} + +fn public(config: &TransparencyConfig, key: &str) -> Destination { + Destination::Public { + base_url: config.base_url.clone(), + key: key.into(), + } +} + +fn parse_json(bytes: &[u8], label: &str) -> Result { + serde_json::from_slice(bytes).map_err(|_| { + transparency_error( + "terminal", + label, + "strict JSON", + "invalid bytes", + "restore the signed transparency object and retry", + ) + }) +} + +fn highest_head_seq(root: &Path) -> Result { + let path = root.join(TRANSPARENCY_HEAD_LOG); + let bytes = fs::read(&path).map_err(display_error)?; + let mut highest = 0; + for line in bytes.split_inclusive(|byte| *byte == b'\n') { + if line == b"\n" || line.is_empty() { + continue; + } + if !line.ends_with(b"\n") { + return Err(transparency_error( + "terminal", + "transparency head log", + "newline-terminated JSONL", + "partial row", + "restore transparency-head-log.jsonl from version control", + )); + } + let row: TransparencyHeadRow = + parse_json(&line[..line.len() - 1], "transparency head log row")?; + if row.product != PRODUCT { + return Err(transparency_error( + "terminal", + "transparency head log product", + PRODUCT, + row.product, + "restore transparency-head-log.jsonl from version control", + )); + } + highest = highest.max(row.seq); + } + Ok(highest) +} + +fn ensure_http(response: &TransportResponse, expected: &[u16], label: &str) -> Result<()> { + if !expected.contains(&response.http_status) { + return Err(transparency_error( + "retryable", + label, + format!("HTTP {expected:?}"), + format!( + "HTTP {} with {} body bytes", + response.http_status, + response.body.len() + ), + "retry after restoring the transparency transport", + )); + } + Ok(()) +} + +fn verify_minisign_bytes( + root: &Path, + public_key: &Path, + message: &[u8], + signature: &[u8], + label: &str, +) -> Result<()> { + static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let message_path = root.join(format!(".transparency-verify-{sequence}.json")); + let signature_path = root.join(format!(".transparency-verify-{sequence}.minisig")); + fs::write(&message_path, message).map_err(display_error)?; + fs::write(&signature_path, signature).map_err(display_error)?; + let status = Command::new("minisign") + .args(["-V", "-q", "-p"]) + .arg(public_key) + .arg("-m") + .arg(&message_path) + .arg("-x") + .arg(&signature_path) + .status() + .map_err(display_error)?; + let _ = fs::remove_file(&message_path); + let _ = fs::remove_file(&signature_path); + if !status.success() { + return Err(transparency_error( + "terminal", + label, + "valid minisign signature", + "verification failure", + "restore the object signed by the configured transparency public key", + )); + } + Ok(()) +} + +pub(crate) trait TransparencySignatureVerifier { + fn verify( + &mut self, + root: &Path, + public_key: &Path, + message: &[u8], + signature: &[u8], + label: &str, + ) -> Result<()>; +} + +struct MinisignVerifier; + +impl TransparencySignatureVerifier for MinisignVerifier { + fn verify( + &mut self, + root: &Path, + public_key: &Path, + message: &[u8], + signature: &[u8], + label: &str, + ) -> Result<()> { + verify_minisign_bytes(root, public_key, message, signature, label) + } +} + +pub(crate) fn verify_trusted_comment(signature: &[u8], expected: &str, label: &str) -> Result<()> { + let text = std::str::from_utf8(signature).map_err(|_| { + transparency_error( + "terminal", + label, + "UTF-8 minisign signature", + "invalid bytes", + "restore the signed transparency object", + ) + })?; + let actual = text + .lines() + .find_map(|line| line.strip_prefix("trusted comment: ")); + if actual != Some(expected) { + return Err(transparency_error( + "terminal", + label, + expected, + actual.unwrap_or("missing"), + "restore the signature with the exact transparency trusted comment", + )); + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub(crate) struct VerifiedChain { + pub(crate) pointer: Option, + pub(crate) pointer_bytes: Option>, + pub(crate) pointer_etag: Option, + pub(crate) tip: Option, + pub(crate) transparency_ledger: Vec, +} + +pub(crate) fn validate_transparency_ledger(bytes: &[u8], tip: &TransparencyEntry) -> Result<()> { + if bytes.is_empty() { + return Ok(()); + } + let mut previous = None; + for line in bytes.split_inclusive(|byte| *byte == b'\n') { + if !line.ends_with(b"\n") { + return Err(transparency_error( + "terminal", + "transparency ledger", + "newline-terminated canonical entries", + "partial final line", + "restore the transparency ledger from locked entries", + )); + } + let entry: TransparencyEntry = parse_json(line, "transparency ledger entry")?; + let canonical = + transparency_canonical_json(&serde_json::to_value(&entry).map_err(display_error)?)?; + if canonical != line { + return Err(transparency_error( + "terminal", + "transparency ledger canonical bytes", + digest(&canonical), + digest(line), + "re-derive the transparency ledger from locked entries", + )); + } + validate_entry(&entry, previous.as_ref())?; + previous = Some(entry); + } + if let Some(last) = previous + && last.seq >= tip.seq + && (last.seq == tip.seq && last != *tip) + { + return Err(transparency_error( + "terminal", + "transparency ledger locked tip", + format!("entry {}", tip.seq), + format!("contradictory entry {}", last.seq), + "restore the transparency ledger from locked entries", + )); + } + Ok(()) +} + +fn transparency_ledger_has_tip(bytes: &[u8], tip_sha256: &str) -> bool { + bytes + .split_inclusive(|byte| *byte == b'\n') + .next_back() + .is_some_and(|line| line.ends_with(b"\n") && digest(line) == tip_sha256) +} + +fn rederive_transparency_ledger( + root: &Path, + config: &TransparencyConfig, + transport: &mut T, + verifier: &mut V, + tip: &TransparencyEntry, + tip_bytes: &[u8], +) -> Result> { + let prefix = format!("releases/{PRODUCT}/v"); + let mut entries = vec![(tip.clone(), tip_bytes.to_vec())]; + while let Some((current, _)) = entries.last() { + if current.seq == 1 { + break; + } + let key = format!("{prefix}/{}/ledger-entry.json", current.prev_version); + let response = transport.get(&s3(config, &key), false)?; + let signature = transport.get(&s3(config, &format!("{key}.minisig")), false)?; + ensure_http(&response, &[200], "locked transparency entry GET")?; + ensure_http( + &signature, + &[200], + "locked transparency entry signature GET", + )?; + verifier.verify( + root, + &config.minisign_pub, + &response.body, + &signature.body, + "locked transparency entry signature", + )?; + let previous: TransparencyEntry = parse_json(&response.body, "locked transparency entry")?; + verify_trusted_comment( + &signature.body, + &entry_trusted_comment(&previous, &digest(&response.body)), + "locked transparency entry trusted comment", + )?; + let canonical = + transparency_canonical_json(&serde_json::to_value(&previous).map_err(display_error)?)?; + if canonical != response.body { + return Err(transparency_error( + "terminal", + "locked transparency entry canonical bytes", + digest(&canonical), + digest(&response.body), + "restore the signed locked transparency entry", + )); + } + validate_entry(current, Some(&previous))?; + entries.push((previous, response.body)); + } + entries.reverse(); + Ok(entries.into_iter().flat_map(|(_, bytes)| bytes).collect()) +} + +pub(crate) fn fetch_verified_chain( + root: &Path, + config: &TransparencyConfig, + transport: &mut T, + verifier: &mut V, + allow_genesis: bool, +) -> Result { + let prefix = format!("releases/{PRODUCT}"); + let pointer_response = transport.get(&s3(config, &format!("{prefix}/latest.json")), true)?; + if pointer_response.http_status == 404 { + if !allow_genesis || !config.genesis { + return Err(transparency_error( + "terminal", + "transparency genesis approval", + "TRANSPARENCY_GENESIS=1", + "missing", + "set TRANSPARENCY_GENESIS=1 only after confirming first publication", + )); + } + let listed = transport.list(&s3(config, ""), &format!("{prefix}/v/"))?; + ensure_http(&listed, &[200], "transparency genesis LIST")?; + if !listed.body.is_empty() + && !String::from_utf8_lossy(&listed.body).contains("0") + { + return Err(transparency_error( + "terminal", + "transparency genesis prefix", + "no existing object", + "existing object", + "cut a new version after reconciling the existing transparency chain", + )); + } + return Ok(VerifiedChain { + pointer: None, + pointer_bytes: None, + pointer_etag: None, + tip: None, + transparency_ledger: Vec::new(), + }); + } + ensure_http(&pointer_response, &[200], "transparency pointer GET")?; + let signature = transport.get(&s3(config, &format!("{prefix}/latest.json.minisig")), true)?; + ensure_http(&signature, &[200], "transparency pointer signature GET")?; + verifier.verify( + root, + &config.minisign_pub, + &pointer_response.body, + &signature.body, + "transparency pointer signature", + )?; + let pointer: TransparencyPointer = parse_json(&pointer_response.body, "transparency pointer")?; + verify_trusted_comment( + &signature.body, + &pointer_trusted_comment(&pointer), + "transparency pointer trusted comment", + )?; + if pointer.product != PRODUCT { + return Err(transparency_error( + "terminal", + "transparency pointer product", + PRODUCT, + pointer.product, + "use the solstone-linux transparency chain", + )); + } + let tip_key = format!("{prefix}/v/{}/ledger-entry.json", pointer.version); + let tip_response = transport.get(&s3(config, &tip_key), false)?; + let tip_signature = transport.get(&s3(config, &format!("{tip_key}.minisig")), false)?; + ensure_http(&tip_response, &[200], "transparency tip GET")?; + ensure_http(&tip_signature, &[200], "transparency tip signature GET")?; + verifier.verify( + root, + &config.minisign_pub, + &tip_response.body, + &tip_signature.body, + "transparency tip signature", + )?; + let tip: TransparencyEntry = parse_json(&tip_response.body, "transparency tip entry")?; + verify_trusted_comment( + &tip_signature.body, + &entry_trusted_comment(&tip, &digest(&tip_response.body)), + "transparency tip trusted comment", + )?; + validate_pointer(&pointer, &tip)?; + let highest = highest_head_seq(root)?; + if pointer.chain_length < highest { + return Err(transparency_error( + "terminal", + "transparency chain rollback", + format!("chain_length at least {highest}"), + pointer.chain_length, + "stop and reconcile the transparency S3 plane with the transparency head log", + )); + } + let ledger_response = transport.get(&s3(config, &format!("{prefix}/ledger.jsonl")), true)?; + let fetched_ledger = if ledger_response.http_status == 404 { + None + } else { + ensure_http(&ledger_response, &[200], "transparency ledger GET")?; + Some(ledger_response.body) + }; + let transparency_ledger = match fetched_ledger { + Some(bytes) + if validate_transparency_ledger(&bytes, &tip).is_ok() + && transparency_ledger_has_tip(&bytes, &pointer.tip_sha256) => + { + bytes + } + _ => rederive_transparency_ledger( + root, + config, + transport, + verifier, + &tip, + &tip_response.body, + )?, + }; + validate_transparency_ledger(&transparency_ledger, &tip)?; + Ok(VerifiedChain { + pointer: Some(pointer), + pointer_bytes: Some(pointer_response.body), + pointer_etag: pointer_response.etag, + tip: Some(tip), + transparency_ledger, + }) +} + +fn sign_file( + secret_key: &Path, + message: &Path, + signature: &Path, + comment: &str, + passphrase: &[u8], +) -> Result<()> { + let mut child = Command::new("minisign") + .args(["-S", "-s"]) + .arg(secret_key) + .arg("-m") + .arg(message) + .arg("-x") + .arg(signature) + .args(["-t", comment]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .map_err(display_error)?; + let stdin = child.stdin.as_mut().ok_or_else(|| Error::new("terminal: minisign stdin mismatch: expected pipe, actual unavailable\nrepair: restore minisign and retry"))?; + stdin.write_all(passphrase).map_err(display_error)?; + stdin.write_all(b"\n").map_err(display_error)?; + let status = child.wait().map_err(display_error)?; + if !status.success() { + return Err(transparency_error( + "retryable", + "minisign signing", + "successful signature", + "signing failure", + "verify the encrypted transparency key and retry", + )); + } + Ok(()) +} + +fn read_passphrase_once() -> Result> { + let status = Command::new("stty") + .arg("-echo") + .status() + .map_err(display_error)?; + if !status.success() { + return Err(transparency_error( + "retryable", + "passphrase terminal", + "echo disabled", + "stty failure", + "run from an interactive terminal", + )); + } + eprint!("Password: "); + let mut value = String::new(); + let read = std::io::stdin().read_line(&mut value); + let _ = Command::new("stty").arg("echo").status(); + eprintln!(); + read.map_err(display_error)?; + while value.ends_with(['\n', '\r']) { + value.pop(); + } + Ok(value.into_bytes()) +} + +fn observed_version(program: &str, argument: &str) -> Result { + let output = Command::new(program).arg(argument).output().map_err(|_| { + transparency_error( + "retryable", + "transparency tool", + program, + "missing", + format!("install {program} and retry"), + ) + })?; + if !output.status.success() { + return Err(transparency_error( + "retryable", + "transparency tool", + program, + "unavailable", + format!("install {program} and retry"), + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +fn validate_tools() -> Result<()> { + let minisign = observed_version("minisign", "-v")?; + if minisign != "minisign 0.11" && minisign != "minisign 0.12" { + return Err(transparency_error( + "terminal", + "minisign version", + "minisign 0.11 or 0.12", + minisign, + "install minisign 0.12 and retry", + )); + } + let curl = observed_version("curl", "--version")?; + let first = curl.lines().next().unwrap_or_default(); + let version = first.split_whitespace().nth(1).unwrap_or_default(); + let parts = version + .split('.') + .filter_map(|part| part.parse::().ok()) + .collect::>(); + if parts.as_slice() < [7, 75].as_slice() { + return Err(transparency_error( + "terminal", + "curl version", + "curl 7.75 or newer", + version, + "install curl 7.75 or newer and retry", + )); + } + Ok(()) +} + +pub(crate) fn append_head_row(root: &Path, row: &TransparencyHeadRow) -> Result<&'static str> { + let path = root.join(TRANSPARENCY_HEAD_LOG); + let existing = fs::read(&path).map_err(display_error)?; + for line in existing + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + { + let old: TransparencyHeadRow = parse_json(line, "transparency head log row")?; + if old.product == row.product && old.seq == row.seq { + if old.entry_sha256 != row.entry_sha256 { + return Err(transparency_error( + "terminal", + "transparency head log fork", + &row.entry_sha256, + old.entry_sha256, + "stop and reconcile the conflicting transparency heads", + )); + } + return Ok("written and committed or previously recorded"); + } + } + let bytes = transparency_canonical_json(&serde_json::to_value(row).map_err(display_error)?)?; + let mut file = OpenOptions::new() + .append(true) + .open(&path) + .map_err(display_error)?; + file.write_all(&bytes).map_err(display_error)?; + Ok("written uncommitted; gap: git add transparency-head-log.jsonl && git commit") +} + +pub(crate) fn validate_previous_head_committed(root: &Path) -> Result<()> { + let worktree = fs::read(root.join(TRANSPARENCY_HEAD_LOG)).map_err(display_error)?; + let committed = Command::new("git") + .args(["show", &format!("HEAD:{TRANSPARENCY_HEAD_LOG}")]) + .current_dir(root) + .output() + .map_err(display_error)?; + if committed.status.success() && committed.stdout != worktree { + return Err(transparency_error( + "terminal", + "previous transparency head row", + "committed row", + "present but uncommitted", + "git add transparency-head-log.jsonl && git commit", + )); + } + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct CandidateSnapshot { + pub(crate) staging: PathBuf, + pub(crate) manifest: Manifest, + pub(crate) proofs: BTreeMap>, +} + +pub(crate) fn snapshot_candidate(root: &RepoRoot, release_dir: &Path) -> Result { + classify_release_dir(root, release_dir)?; + let manifest_path = fs::read_dir(release_dir).map_err(display_error)? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .find(|path| path.file_name().and_then(OsStr::to_str).is_some_and(|name| name.ends_with(".rust-release-manifest.json"))) + .ok_or_else(|| Error::new("terminal: transparency manifest mismatch: expected one companion manifest, actual missing\nrepair: restore the retained five-file candidate"))?; + let manifest = + validate_manifest_bytes(root, &fs::read(&manifest_path).map_err(display_error)?)?; + if manifest.source_dirty { + return Err(transparency_error( + "terminal", + "transparency candidate source", + format!("commit {} with source_dirty=false", manifest.source_commit), + format!("commit {} with source_dirty=true", manifest.source_commit), + "cut a clean-source candidate before publishing transparency evidence", + )); + } + let parent = release_dir.parent().ok_or_else(|| Error::new("terminal: transparency release parent mismatch: expected parent, actual missing\nrepair: provide the retained release directory"))?; + let evidence = parent.join("rust-evidence").join(&manifest.version); + let rail_ledger_bytes = fs::read(evidence.join("ledger.json")).map_err(|_| { + transparency_error( + "terminal", + "transparency evidence", + "rail ledger and three bound proofs", + "rail ledger missing", + format!( + "retain complete evidence for version {} and retry", + manifest.version + ), + ) + })?; + let rail_ledger: CandidateLedger = + serde_json::from_slice(&rail_ledger_bytes).map_err(display_error)?; + if rail_ledger.version != manifest.version + || rail_ledger.source.commit != manifest.source_commit + { + return Err(transparency_error( + "terminal", + "rail ledger candidate binding", + format!( + "version {} commit {}", + manifest.version, manifest.source_commit + ), + format!( + "version {} commit {}", + rail_ledger.version, rail_ledger.source.commit + ), + "restore the rail ledger for this candidate", + )); + } + let policies = ReleaseImages::from_root(root.path())?; + let mut proofs = BTreeMap::new(); + for spec in PROOF_SPECS { + let path = evidence.join("proofs").join(format!("{}.json", spec.id)); + let bytes = fs::read(&path).map_err(|_| { + transparency_error( + "terminal", + "transparency proof inventory", + "debian-amd64, rpm-x86_64, and tar-x86_64", + format!("{} missing", spec.id), + format!( + "retain complete evidence for version {} and retry", + manifest.version + ), + ) + })?; + let value: Value = serde_json::from_slice(&bytes).map_err(display_error)?; + let artifact = proof_artifact(&rail_ledger, spec.id)?; + let member = proof_member(&rail_ledger, spec.id)?; + let policy = policies.proof_policy(spec.id)?; + let proof_time = value + .get("proof_time") + .and_then(Value::as_str) + .unwrap_or(&rail_ledger.policy.checked_at); + validate_candidate_proof( + &value, + &ProofBindings { + platform: spec.id.into(), + candidate_digest: rail_ledger.candidate_digest.clone(), + ledger_sha256: digest(&rail_ledger_bytes), + source_commit: rail_ledger.source.commit.clone(), + cargo_lock_sha256: rail_ledger.source.cargo_lock_sha256.clone(), + artifact_basename: artifact.path.clone(), + artifact_bytes: artifact.bytes, + artifact_sha256: artifact.sha256.clone(), + proof_image_digest: policy.image_digest.clone(), + os_release: policy.os_release.clone(), + package_manager_version: policy.package_manager_version.clone(), + install_command: policy.install_command.clone(), + install_exit_status: 0, + version_command: policy.version_command.clone(), + version_exit_status: 0, + executable_path: policy.executable_path.clone(), + executable_mode: policy.executable_mode, + executable_sha256: member.sha256.clone(), + version_output: format!("solstone-linux {}", rail_ledger.version), + result: "pass".into(), + policy_checked_at: rail_ledger.policy.checked_at.clone(), + validation_time: proof_time.into(), + }, + )?; + proofs.insert(format!("{}.json", spec.id), bytes); + } + let staging = root + .path() + .join(".transparency-staging") + .join(PRODUCT) + .join(&manifest.version); + if staging.exists() { + return Ok(CandidateSnapshot { + staging, + manifest, + proofs, + }); + } + let temporary = staging.with_extension(format!("building-{}", std::process::id())); + fs::create_dir_all(&temporary).map_err(display_error)?; + for artifact in &manifest.artifacts { + fs::copy( + release_dir.join(&artifact.path), + temporary.join(&artifact.path), + ) + .map_err(display_error)?; + } + fs::copy( + release_dir.join(CHECKSUM_NAME), + temporary.join(CHECKSUM_NAME), + ) + .map_err(display_error)?; + fs::copy( + &manifest_path, + temporary.join(manifest_name(&manifest.version)), + ) + .map_err(display_error)?; + for (name, bytes) in &proofs { + fs::write(temporary.join(name), bytes).map_err(display_error)?; + } + fs::create_dir_all(staging.parent().unwrap()).map_err(display_error)?; + fs::rename(&temporary, &staging).map_err(display_error)?; + Ok(CandidateSnapshot { + staging, + manifest, + proofs, + }) +} + +pub(crate) fn build_entry( + staging: &Path, + manifest: &Manifest, + proofs: &BTreeMap>, + chain: &VerifiedChain, +) -> Result<(TransparencyEntry, Vec)> { + let mut artifacts = manifest + .artifacts + .iter() + .map(|artifact| TransparencyArtifact { + bytes: artifact.bytes, + name: artifact.path.clone(), + sha256: artifact.sha256.clone(), + }) + .collect::>(); + let checksum = fs::read(staging.join(CHECKSUM_NAME)).map_err(display_error)?; + artifacts.push(TransparencyArtifact { + bytes: checksum.len() as u64, + name: CHECKSUM_NAME.into(), + sha256: digest(&checksum), + }); + artifacts.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes())); + let manifest_bytes = + fs::read(staging.join(manifest_name(&manifest.version))).map_err(display_error)?; + let mut proof_inventory = proofs + .iter() + .map(|(name, bytes)| TransparencyNamedDigest { + name: name.clone(), + sha256: digest(bytes), + }) + .collect::>(); + proof_inventory.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes())); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let entry = TransparencyEntry { + artifacts, + manifests: vec![TransparencyNamedDigest { + name: manifest_name(&manifest.version), + sha256: digest(&manifest_bytes), + }], + prev_sha256: chain.tip.as_ref().map_or_else( + || ZERO_SHA256.into(), + |tip| { + digest(&transparency_canonical_json(&serde_json::to_value(tip).unwrap()).unwrap()) + }, + ), + prev_version: chain + .tip + .as_ref() + .map_or_else(String::new, |tip| tip.version.clone()), + product: PRODUCT.into(), + proofs: proof_inventory, + published_utc: now, + schema: TRANSPARENCY_ENTRY_SCHEMA.into(), + seq: chain + .pointer + .as_ref() + .map_or(1, |pointer| pointer.chain_length + 1), + source_commit: manifest.source_commit.clone(), + version: manifest.version.clone(), + }; + validate_entry(&entry, chain.tip.as_ref())?; + let bytes = transparency_canonical_json(&serde_json::to_value(&entry).map_err(display_error)?)?; + Ok((entry, bytes)) +} + +pub(crate) fn build_pointer( + entry: &TransparencyEntry, + entry_bytes: &[u8], +) -> Result<(TransparencyPointer, Vec)> { + let signed = exact_timestamp("transparency signed_at", &entry.published_utc)?; + let pointer = TransparencyPointer { + chain_length: entry.seq, + product: PRODUCT.into(), + schema: TRANSPARENCY_LATEST_SCHEMA.into(), + signed_at: entry.published_utc.clone(), + tip_sha256: digest(entry_bytes), + valid_until: (signed + Duration::days(14)).to_rfc3339_opts(SecondsFormat::Secs, true), + version: entry.version.clone(), + }; + let bytes = + transparency_canonical_json(&serde_json::to_value(&pointer).map_err(display_error)?)?; + Ok((pointer, bytes)) +} + +fn immutable_objects( + staging: &Path, + entry: &[u8], + signature: &[u8], + manifest: &Manifest, + proofs: &BTreeMap>, +) -> Result>> { + let mut objects = BTreeMap::new(); + objects.insert("ledger-entry.json".into(), entry.to_vec()); + objects.insert("ledger-entry.json.minisig".into(), signature.to_vec()); + let name = manifest_name(&manifest.version); + objects.insert( + name.clone(), + fs::read(staging.join(name)).map_err(display_error)?, + ); + objects.extend(proofs.clone()); + Ok(objects) +} + +pub(crate) fn staging_manifest_v1(staging: &Path) -> Result<(Vec, String)> { + fn visit(root: &Path, directory: &Path, files: &mut Vec<(String, Vec)>) -> Result<()> { + for entry in fs::read_dir(directory).map_err(display_error)? { + let entry = entry.map_err(display_error)?; + let path = entry.path(); + let relative = path.strip_prefix(root).map_err(display_error)?; + let relative = relative.to_str().ok_or_else(|| { + transparency_error( + "terminal", + "staging-manifest v1 path", + "ASCII path without control characters", + "non-UTF-8 path", + "discard the staging directory and retry make publish-transparency", + ) + })?; + if !relative.is_ascii() + || relative + .as_bytes() + .iter() + .any(|byte| byte.is_ascii_control()) + { + return Err(transparency_error( + "terminal", + "staging-manifest v1 path", + "ASCII path without control characters", + relative.escape_default(), + "discard the staging directory and retry make publish-transparency", + )); + } + let file_type = entry.file_type().map_err(display_error)?; + if file_type.is_symlink() { + return Err(transparency_error( + "terminal", + "staging-manifest v1 file type", + "regular file or directory", + format!("symlink at {relative}"), + "discard the staging directory and retry make publish-transparency", + )); + } + if file_type.is_dir() { + visit(root, &path, files)?; + } else if file_type.is_file() { + files.push(( + relative.replace(std::path::MAIN_SEPARATOR, "/"), + fs::read(path).map_err(display_error)?, + )); + } + } + Ok(()) + } + + let mut files = Vec::new(); + visit(staging, staging, &mut files)?; + files.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes())); + let mut rendered = Vec::new(); + for (path, bytes) in files { + writeln!( + rendered, + "sha256={}\tbytes={}\tpath={path}", + digest(&bytes), + bytes.len() + ) + .map_err(display_error)?; + } + let receipt = digest(&rendered); + Ok((rendered, receipt)) +} + +pub(crate) struct StagedPublication<'a> { + pub(crate) staging: &'a Path, + pub(crate) chain: &'a VerifiedChain, + pub(crate) entry: &'a TransparencyEntry, + pub(crate) entry_bytes: &'a [u8], + pub(crate) entry_signature: &'a [u8], + pub(crate) pointer_bytes: &'a [u8], + pub(crate) pointer_signature: &'a [u8], + pub(crate) manifest: &'a Manifest, + pub(crate) proofs: &'a BTreeMap>, +} + +pub(crate) fn upload_publication( + config: &TransparencyConfig, + transport: &mut T, + archive: &mut A, + publication: &StagedPublication<'_>, +) -> Result { + let StagedPublication { + staging, + chain, + entry, + entry_bytes, + entry_signature, + pointer_bytes, + pointer_signature, + manifest, + proofs, + } = publication; + let objects = immutable_objects(staging, entry_bytes, entry_signature, manifest, proofs)?; + let prefix = format!("releases/{PRODUCT}/v/{}", entry.version); + let mut adopted = BTreeSet::new(); + for (name, bytes) in &objects { + let destination = s3(config, &format!("{prefix}/{name}")); + let remote = transport.get(&destination, false)?; + match remote.http_status { + 404 => {} + 200 if remote.body == *bytes => { + adopted.insert(name.clone()); + } + 200 => { + return Err(transparency_error( + "terminal", + "remote poisoned version object", + digest(bytes), + digest(&remote.body), + "cut the next version because the remote version key is permanently recorded with different bytes", + )); + } + _ => ensure_http(&remote, &[200, 404], "transparency immutable preflight GET")?, + } + } + let transparency_ledger = [chain.transparency_ledger.as_slice(), entry_bytes].concat(); + fs::write(staging.join("ledger.jsonl"), transparency_ledger).map_err(display_error)?; + let obsolete_manifest = staging.join("staging-manifest.json"); + if obsolete_manifest.is_file() { + fs::remove_file(obsolete_manifest).map_err(display_error)?; + } + let (_, archive_digest) = staging_manifest_v1(staging)?; + archive.archive(staging, &archive_digest)?; + // Immutable mutation completes before any public-plane verification begins. + // This phase boundary makes mutable writes unreachable after a verification failure. + for (name, bytes) in &objects { + if adopted.contains(name) { + continue; + } + let destination = s3(config, &format!("{prefix}/{name}")); + let response = transport.put_create_only(&destination, bytes, IMMUTABLE_CACHE)?; + match response.http_status { + 200 | 201 | 204 => {} + 412 => { + let remote = transport.get(&destination, false)?; + ensure_http(&remote, &[200], "transparency immutable adoption GET")?; + if remote.body != *bytes { + return Err(transparency_error( + "terminal", + "transparency immutable conflict", + digest(bytes), + digest(&remote.body), + "cut the next version because immutable bytes differ", + )); + } + fs::write(staging.join(name), &remote.body).map_err(display_error)?; + return Err(transparency_error( + "retryable", + "transparency immutable adoption", + "preflight adoption before archive", + "PUT-time race with byte-identical remote object", + "retry make publish-transparency so preflight can adopt before archive and mutable writes", + )); + } + _ => ensure_http(&response, &[200, 201, 204], "transparency immutable PUT")?, + } + } + for (name, bytes) in &objects { + let remote = transport.get(&public(config, &format!("{prefix}/{name}")), false)?; + ensure_http( + &remote, + &[200], + "transparency immutable public verification", + )?; + if digest(&remote.body) != digest(bytes) { + return Err(transparency_error( + "retryable", + "transparency immutable public digest", + digest(bytes), + digest(&remote.body), + "retry after the public surface returns the uploaded bytes", + )); + } + } + let current = transport.get( + &s3(config, &format!("releases/{PRODUCT}/latest.json")), + true, + )?; + if !chain + .pointer_bytes + .as_deref() + .map_or(current.http_status == 404, |old| { + current.http_status == 200 && current.body == old + }) + { + return Err(transparency_error( + "retryable", + "pre-pointer transparency chain state", + "unchanged pointer", + format!("HTTP {} changed bytes", current.http_status), + "restart publication against the new chain head", + )); + } + let ledger = [chain.transparency_ledger.as_slice(), entry_bytes].concat(); + let ledger_destination = s3(config, &format!("releases/{PRODUCT}/ledger.jsonl")); + ensure_http( + &transport.put_conditional(&ledger_destination, &ledger, None, MUTABLE_CACHE)?, + &[200, 201, 204], + "transparency ledger PUT", + )?; + let ledger_remote = transport.get(&ledger_destination, true)?; + ensure_http( + &ledger_remote, + &[200], + "transparency ledger verification GET", + )?; + if ledger_remote.body != ledger { + return Err(transparency_error( + "retryable", + "transparency ledger bytes", + digest(&ledger), + digest(&ledger_remote.body), + "retry after restoring the transparency S3 plane", + )); + } + let signature_destination = s3(config, &format!("releases/{PRODUCT}/latest.json.minisig")); + ensure_http( + &transport.put_conditional( + &signature_destination, + pointer_signature, + None, + MUTABLE_CACHE, + )?, + &[200, 201, 204], + "transparency pointer signature PUT", + )?; + ensure_http( + &transport.get(&signature_destination, true)?, + &[200], + "transparency pointer signature verification GET", + )?; + let pointer_destination = s3(config, &format!("releases/{PRODUCT}/latest.json")); + ensure_http( + &transport.put_conditional( + &pointer_destination, + pointer_bytes, + chain.pointer_etag.as_deref(), + MUTABLE_CACHE, + )?, + &[200, 201, 204], + "transparency pointer PUT", + )?; + let pointer_remote = transport.get(&pointer_destination, true)?; + ensure_http( + &pointer_remote, + &[200], + "transparency pointer verification GET", + )?; + if pointer_remote.body != *pointer_bytes { + return Err(transparency_error( + "retryable", + "transparency pointer bytes", + digest(pointer_bytes), + digest(&pointer_remote.body), + "retry after restoring the transparency S3 plane", + )); + } + Ok(archive_digest) +} + +pub fn publish_transparency(release_dir: &Path) -> Result<()> { + let root = RepoRoot::resolve()?; + validate_previous_head_committed(root.path())?; + validate_tools()?; + let config = TransparencyConfig::from_env(true)?; + let staging_root = root.path().join(".transparency-staging"); + fs::create_dir_all(&staging_root).map_err(display_error)?; + let mut transport = CurlTransport::new(&config, &staging_root); + let chain = fetch_verified_chain( + root.path(), + &config, + &mut transport, + &mut MinisignVerifier, + true, + )?; + let snapshot = snapshot_candidate(&root, release_dir)?; + let staging = &snapshot.staging; + let manifest = &snapshot.manifest; + let proofs = &snapshot.proofs; + let entry_path = staging.join("ledger-entry.json"); + let entry_signature_path = staging.join("ledger-entry.json.minisig"); + let pointer_path = staging.join("latest.json"); + let pointer_signature_path = staging.join("latest.json.minisig"); + let staged_complete = [ + &entry_path, + &entry_signature_path, + &pointer_path, + &pointer_signature_path, + ] + .iter() + .all(|path| path.is_file()); + let (entry, entry_bytes, pointer, pointer_bytes) = if staged_complete { + let entry_bytes = fs::read(&entry_path).map_err(display_error)?; + let pointer_bytes = fs::read(&pointer_path).map_err(display_error)?; + let entry: TransparencyEntry = parse_json(&entry_bytes, "staged transparency entry")?; + let pointer: TransparencyPointer = + parse_json(&pointer_bytes, "staged transparency pointer")?; + validate_entry(&entry, chain.tip.as_ref())?; + validate_pointer(&pointer, &entry)?; + if entry.version != manifest.version || entry.source_commit != manifest.source_commit { + return Err(transparency_error( + "terminal", + "local transparency staging candidate", + format!( + "version {} commit {}", + manifest.version, manifest.source_commit + ), + format!("version {} commit {}", entry.version, entry.source_commit), + format!( + "discard only {} and retry; a never-published local stage is not a poisoned remote version", + staging.display() + ), + )); + } + (entry, entry_bytes, pointer, pointer_bytes) + } else { + let (entry, entry_bytes) = build_entry(staging, manifest, proofs, &chain)?; + let (pointer, pointer_bytes) = build_pointer(&entry, &entry_bytes)?; + fs::write(&entry_path, &entry_bytes).map_err(display_error)?; + fs::write(&pointer_path, &pointer_bytes).map_err(display_error)?; + let mut passphrase = read_passphrase_once()?; + let signing = (|| { + sign_file( + &config.minisign_key, + &entry_path, + &entry_signature_path, + &entry_trusted_comment(&entry, &digest(&entry_bytes)), + &passphrase, + )?; + sign_file( + &config.minisign_key, + &pointer_path, + &pointer_signature_path, + &pointer_trusted_comment(&pointer), + &passphrase, + ) + })(); + passphrase.fill(0); + signing?; + (entry, entry_bytes, pointer, pointer_bytes) + }; + let entry_signature = fs::read(&entry_signature_path).map_err(display_error)?; + let pointer_signature = fs::read(&pointer_signature_path).map_err(display_error)?; + verify_trusted_comment( + &entry_signature, + &entry_trusted_comment(&entry, &digest(&entry_bytes)), + "new transparency entry trusted comment", + )?; + verify_trusted_comment( + &pointer_signature, + &pointer_trusted_comment(&pointer), + "new transparency pointer trusted comment", + )?; + verify_minisign_bytes( + staging, + &config.minisign_pub, + &entry_bytes, + &entry_signature, + "new transparency entry signature", + )?; + verify_minisign_bytes( + staging, + &config.minisign_pub, + &pointer_bytes, + &pointer_signature, + "new transparency pointer signature", + )?; + let mut archive = CommandArchive { + command: config.archive_channel.clone().unwrap(), + }; + let archive_digest = upload_publication( + &config, + &mut transport, + &mut archive, + &StagedPublication { + staging, + chain: &chain, + entry: &entry, + entry_bytes: &entry_bytes, + entry_signature: &entry_signature, + pointer_bytes: &pointer_bytes, + pointer_signature: &pointer_signature, + manifest, + proofs, + }, + )?; + let witness = append_head_row( + root.path(), + &TransparencyHeadRow { + entry_sha256: digest(&entry_bytes), + product: PRODUCT.into(), + published_utc: entry.published_utc.clone(), + seq: entry.seq, + version: entry.version.clone(), + }, + ) + .unwrap_or("witness unavailable; gap"); + let stale_pointer = exact_timestamp( + "staged transparency pointer valid_until", + &pointer.valid_until, + )? < Utc::now(); + println!( + "product: {PRODUCT}\nversion: {}\nseq: {}\nentry_sha256: {}\npublic: {}/releases/{PRODUCT}/v/{}/ledger-entry.json\narchive: {}\nwitness: {}", + entry.version, + entry.seq, + digest(&entry_bytes), + config.base_url, + entry.version, + archive_digest, + witness + ); + if stale_pointer { + println!("pointer renewal: make resign-transparency-pointer"); + } + Ok(()) +} + +pub fn resign_transparency_pointer() -> Result<()> { + let root = RepoRoot::resolve()?; + validate_tools()?; + let config = TransparencyConfig::from_env(false)?; + let temporary = root.path().join(".transparency-staging").join("resign"); + fs::create_dir_all(&temporary).map_err(display_error)?; + let mut transport = CurlTransport::new(&config, &temporary); + // Freeze defense must never re-attest a rolled-back, foreign, or invalid chain. + let chain = fetch_verified_chain( + root.path(), + &config, + &mut transport, + &mut MinisignVerifier, + false, + )?; + let old = chain.pointer.ok_or_else(|| { + transparency_error( + "terminal", + "transparency pointer", + "verified existing pointer", + "missing", + "publish genesis before resigning its pointer", + ) + })?; + let now = Utc::now(); + let pointer = TransparencyPointer { + chain_length: old.chain_length, + product: PRODUCT.into(), + schema: TRANSPARENCY_LATEST_SCHEMA.into(), + signed_at: now.to_rfc3339_opts(SecondsFormat::Secs, true), + tip_sha256: old.tip_sha256, + valid_until: (now + Duration::days(14)).to_rfc3339_opts(SecondsFormat::Secs, true), + version: old.version, + }; + let bytes = + transparency_canonical_json(&serde_json::to_value(&pointer).map_err(display_error)?)?; + let message = temporary.join("latest.json"); + let signature = temporary.join("latest.json.minisig"); + fs::write(&message, &bytes).map_err(display_error)?; + let mut passphrase = read_passphrase_once()?; + sign_file( + &config.minisign_key, + &message, + &signature, + &pointer_trusted_comment(&pointer), + &passphrase, + )?; + passphrase.fill(0); + let signature_bytes = fs::read(&signature).map_err(display_error)?; + verify_minisign_bytes( + &temporary, + &config.minisign_pub, + &bytes, + &signature_bytes, + "resigned transparency pointer signature", + )?; + let prefix = format!("releases/{PRODUCT}"); + let current = transport.get(&s3(&config, &format!("{prefix}/latest.json")), true)?; + if current.body != chain.pointer_bytes.unwrap_or_default() { + return Err(transparency_error( + "retryable", + "pre-pointer transparency chain state", + "unchanged verified pointer", + "changed pointer", + "restart resign-transparency-pointer", + )); + } + let signature_destination = s3(&config, &format!("{prefix}/latest.json.minisig")); + ensure_http( + &transport.put_conditional( + &signature_destination, + &signature_bytes, + None, + MUTABLE_CACHE, + )?, + &[200, 201, 204], + "resigned transparency signature PUT", + )?; + let pointer_destination = s3(&config, &format!("{prefix}/latest.json")); + ensure_http( + &transport.put_conditional( + &pointer_destination, + &bytes, + chain.pointer_etag.as_deref(), + MUTABLE_CACHE, + )?, + &[200, 201, 204], + "resigned transparency pointer PUT", + )?; + println!( + "product: {PRODUCT}\nchain_length: {}\ntip_sha256: {}\nvalid_until: {}", + pointer.chain_length, pointer.tip_sha256, pointer.valid_until + ); + Ok(()) +} diff --git a/crates/rust-release-manifest/src/transparency_tests.rs b/crates/rust-release-manifest/src/transparency_tests.rs new file mode 100644 index 0000000..61cde2f --- /dev/null +++ b/crates/rust-release-manifest/src/transparency_tests.rs @@ -0,0 +1,1513 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use super::*; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::cell::RefCell; +use std::collections::{BTreeSet, VecDeque}; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::rc::Rc; + +struct QueueTransport { + responses: VecDeque, +} + +#[derive(Clone)] +struct ScriptedResponse { + status: u16, + body: Vec, + process_exit: i32, + etag: Option, +} + +struct DirectoryTransport { + _temp: tempfile::TempDir, + objects: std::path::PathBuf, + scripts: VecDeque, + destinations: BTreeSet, + log: Rc>>, +} + +impl DirectoryTransport { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let objects = temp.path().join("objects"); + fs::create_dir(&objects).unwrap(); + Self { + _temp: temp, + objects, + scripts: VecDeque::new(), + destinations: BTreeSet::new(), + log: Rc::new(RefCell::new(Vec::new())), + } + } + + fn object_path(&self, destination: &Destination) -> std::path::PathBuf { + let key = match destination { + Destination::S3 { key, .. } | Destination::Public { key, .. } => key, + }; + self.objects.join(key) + } + + fn record(&mut self, operation: &str, destination: &Destination) { + self.destinations.insert(destination.clone()); + self.log.borrow_mut().push(format!( + "{operation} {}", + match destination { + Destination::S3 { key, .. } | Destination::Public { key, .. } => key, + } + )); + } + + fn scripted(&mut self) -> Option { + self.scripts.pop_front().map(|script| TransportResponse { + http_status: script.status, + body: script.body, + etag: script.etag, + process_exit: script.process_exit, + }) + } + + fn etag(bytes: &[u8]) -> String { + format!("\"{}\"", digest(bytes)) + } +} + +impl TransparencyTransport for DirectoryTransport { + fn get(&mut self, destination: &Destination, _: bool) -> Result { + self.record( + match destination { + Destination::S3 { .. } => "GET-S3", + Destination::Public { .. } => "GET-PUBLIC", + }, + destination, + ); + if let Some(response) = self.scripted() { + return Ok(response); + } + let path = self.object_path(destination); + match fs::read(path) { + Ok(body) => Ok(TransportResponse { + http_status: 200, + etag: Some(Self::etag(&body)), + body, + process_exit: 0, + }), + Err(_) => Ok(TransportResponse { + http_status: 404, + body: b"not found".to_vec(), + etag: None, + process_exit: 0, + }), + } + } + + fn put_create_only( + &mut self, + destination: &Destination, + bytes: &[u8], + _: &str, + ) -> Result { + self.record("PUT-CREATE", destination); + if let Some(response) = self.scripted() { + return Ok(response); + } + let path = self.object_path(destination); + if path.exists() { + return Ok(TransportResponse { + http_status: 412, + body: b"precondition failed".to_vec(), + etag: None, + process_exit: 0, + }); + } + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + Ok(TransportResponse { + http_status: 201, + body: Vec::new(), + etag: Some(Self::etag(bytes)), + process_exit: 0, + }) + } + + fn put_conditional( + &mut self, + destination: &Destination, + bytes: &[u8], + etag: Option<&str>, + _: &str, + ) -> Result { + self.record("PUT-CONDITIONAL", destination); + if let Some(response) = self.scripted() { + return Ok(response); + } + let path = self.object_path(destination); + let existing = fs::read(&path).ok(); + let matches = match (etag, existing.as_deref()) { + (Some(expected), Some(body)) => expected == Self::etag(body), + (None, _) => true, + _ => false, + }; + if !matches { + return Ok(TransportResponse { + http_status: 412, + body: b"etag mismatch".to_vec(), + etag: existing.as_deref().map(Self::etag), + process_exit: 0, + }); + } + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + Ok(TransportResponse { + http_status: 200, + body: Vec::new(), + etag: Some(Self::etag(bytes)), + process_exit: 0, + }) + } + + fn list(&mut self, destination: &Destination, prefix: &str) -> Result { + self.record("LIST", destination); + if let Some(response) = self.scripted() { + return Ok(response); + } + let mut keys = Vec::new(); + fn walk(root: &Path, path: &Path, keys: &mut Vec) { + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(root, &path, keys); + } else { + keys.push( + path.strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(), + ); + } + } + } + walk(&self.objects, &self.objects, &mut keys); + keys.retain(|key| key.starts_with(prefix)); + keys.sort(); + let body = format!( + "{}{}", + keys.len(), + keys.iter() + .map(|key| format!("{key}")) + .collect::() + ) + .into_bytes(); + Ok(TransportResponse { + http_status: 200, + body, + etag: None, + process_exit: 0, + }) + } +} + +struct FakeArchive { + log: Rc>>, + response: Option, + retained: BTreeMap, +} + +impl ArchiveChannel for FakeArchive { + fn archive(&mut self, staging: &Path, receipt_digest: &str) -> Result { + self.log.borrow_mut().push("Archive".into()); + if let Some(response) = &self.response { + return Ok(response.clone()); + } + let ledger = fs::read(staging.join("ledger.jsonl")).unwrap(); + let line = ledger + .split_inclusive(|byte| *byte == b'\n') + .next_back() + .unwrap(); + let entry: TransparencyEntry = serde_json::from_slice(line).unwrap(); + for artifact in &entry.artifacts { + let bytes = fs::read(staging.join(&artifact.name)).unwrap(); + if bytes.len() as u64 != artifact.bytes || digest(&bytes) != artifact.sha256 { + return Err(transparency_error( + "terminal", + "archive artifact", + format!("{} bytes digest {}", artifact.bytes, artifact.sha256), + format!("{} bytes digest {}", bytes.len(), digest(&bytes)), + "restore the staged candidate artifact bytes", + )); + } + } + for item in entry.manifests.iter().chain(&entry.proofs) { + let bytes = fs::read(staging.join(&item.name)).unwrap(); + if digest(&bytes) != item.sha256 { + return Err(transparency_error( + "terminal", + "archive evidence", + &item.sha256, + digest(&bytes), + "restore the staged release evidence", + )); + } + } + match self.retained.get(&entry.version) { + Some(retained) if retained != receipt_digest => { + return Err(transparency_error( + "terminal", + "archive retained version", + retained, + receipt_digest, + "discard the conflicting local staging directory", + )); + } + Some(_) => {} + None => { + self.retained.insert(entry.version, receipt_digest.into()); + } + } + Ok(ArchiveResponse { + exit_status: 0, + stdout: format!("ARCHIVED {receipt_digest}\n").into_bytes(), + stderr: Vec::new(), + }) + } +} + +impl TransparencyTransport for QueueTransport { + fn get(&mut self, _: &Destination, _: bool) -> Result { + self.responses + .pop_front() + .ok_or_else(|| Error::new("missing fake response")) + } + + fn put_create_only(&mut self, _: &Destination, _: &[u8], _: &str) -> Result { + Err(Error::new("unexpected fake PUT")) + } + + fn put_conditional( + &mut self, + _: &Destination, + _: &[u8], + _: Option<&str>, + _: &str, + ) -> Result { + Err(Error::new("unexpected fake PUT")) + } + + fn list(&mut self, _: &Destination, _: &str) -> Result { + Err(Error::new("unexpected fake LIST")) + } +} + +struct FakeVerifier { + reject_tip: bool, +} + +impl TransparencySignatureVerifier for FakeVerifier { + fn verify(&mut self, _: &Path, _: &Path, _: &[u8], _: &[u8], label: &str) -> Result<()> { + if self.reject_tip && label == "transparency tip signature" { + return Err(Error::new( + "terminal: transparency tip signature mismatch: expected valid, actual invalid\nrepair: restore the signed tip", + )); + } + Ok(()) + } +} + +fn response(body: Vec) -> TransportResponse { + TransportResponse { + http_status: 200, + body, + etag: Some("\"etag\"".into()), + process_exit: 0, + } +} + +fn fake_signature(comment: &str) -> Vec { + format!("untrusted comment: test\nAA==\ntrusted comment: {comment}\nAA==\n").into_bytes() +} + +fn test_config() -> TransparencyConfig { + TransparencyConfig { + base_url: TRANSPARENCY_DEFAULT_BASE_URL.into(), + s3_endpoint: "https://example.invalid".into(), + bucket: "fixture".into(), + access_key: "fixture".into(), + secret_key: "fixture".into(), + minisign_key: "fixture.key".into(), + minisign_pub: "fixture.pub".into(), + archive_channel: None, + genesis: false, + } +} + +fn s3_destination(key: &str) -> Destination { + Destination::S3 { + endpoint: "https://example.invalid".into(), + bucket: "fixture".into(), + key: key.into(), + } +} + +#[test] +fn transparency_directory_fake_create_only_and_exact_get() { + let mut fake = DirectoryTransport::new(); + let destination = s3_destination("releases/solstone-linux/v/1/file"); + assert_eq!( + fake.put_create_only(&destination, b"one", "immutable") + .unwrap() + .http_status, + 201 + ); + assert_eq!(fake.get(&destination, false).unwrap().body, b"one"); + assert_eq!( + fake.put_create_only(&destination, b"two", "immutable") + .unwrap() + .http_status, + 412 + ); + assert_eq!(fake.get(&destination, false).unwrap().body, b"one"); +} + +#[test] +fn transparency_directory_fake_conditional_put_requires_etag() { + let mut fake = DirectoryTransport::new(); + let destination = s3_destination("releases/solstone-linux/latest.json"); + fake.put_conditional(&destination, b"old", None, "no-cache") + .unwrap(); + assert_eq!( + fake.put_conditional(&destination, b"new", Some("\"wrong\""), "no-cache") + .unwrap() + .http_status, + 412 + ); + let etag = fake.get(&destination, true).unwrap().etag.unwrap(); + assert_eq!( + fake.put_conditional(&destination, b"new", Some(&etag), "no-cache") + .unwrap() + .http_status, + 200 + ); +} + +#[test] +fn transparency_directory_fake_lists_sorted_prefix_keys() { + let mut fake = DirectoryTransport::new(); + for key in [ + "releases/solstone-linux/v/2/b", + "unrelated", + "releases/solstone-linux/v/1/a", + ] { + fake.put_create_only(&s3_destination(key), b"x", "immutable") + .unwrap(); + } + let listed = fake + .list(&s3_destination(""), "releases/solstone-linux/v/") + .unwrap(); + let text = String::from_utf8(listed.body).unwrap(); + assert!(text.find("v/1/a").unwrap() < text.find("v/2/b").unwrap()); + assert!(!text.contains("unrelated")); +} + +#[test] +fn transparency_http_status_not_process_exit_controls_outcome() { + let mut fake = DirectoryTransport::new(); + fake.scripts.push_back(ScriptedResponse { + status: 412, + body: b"precondition".to_vec(), + process_exit: 0, + etag: None, + }); + fake.scripts.push_back(ScriptedResponse { + status: 403, + body: b"forbidden".to_vec(), + process_exit: 0, + etag: None, + }); + let destination = s3_destination("object"); + let first = fake + .put_create_only(&destination, b"x", "immutable") + .unwrap(); + let second = fake.get(&destination, false).unwrap(); + assert_eq!( + (first.http_status, first.process_exit, first.body), + (412, 0, b"precondition".to_vec()) + ); + assert_eq!( + (second.http_status, second.process_exit, second.body), + (403, 0, b"forbidden".to_vec()) + ); +} + +#[test] +fn transparency_fake_records_destinations_and_ordered_calls() { + let mut fake = DirectoryTransport::new(); + let s3 = s3_destination("releases/solstone-linux/latest.json"); + let public = Destination::Public { + base_url: TRANSPARENCY_DEFAULT_BASE_URL.into(), + key: "releases/solstone-linux/latest.json".into(), + }; + fake.get(&s3, true).unwrap(); + fake.get(&public, false).unwrap(); + assert_eq!(fake.destinations, BTreeSet::from([s3, public])); + assert_eq!(fake.log.borrow().len(), 2); +} + +#[test] +fn transparency_fake_archive_records_and_scripts_failure_shapes() { + let fake = DirectoryTransport::new(); + let log = fake.log.clone(); + let mut archive = FakeArchive { + log: log.clone(), + response: Some(ArchiveResponse { + exit_status: 9, + stdout: b"wrong".to_vec(), + stderr: b"failure".to_vec(), + }), + retained: BTreeMap::new(), + }; + let response = archive + .archive(Path::new("stage"), &"a".repeat(64)) + .unwrap(); + assert_eq!(response.exit_status, 9); + assert_eq!(log.borrow().as_slice(), ["Archive"]); +} + +#[test] +fn transparency_staging_manifest_v1_known_tree_is_pinned_and_ascii_sorted() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join("nested")).unwrap(); + fs::write(temp.path().join("SHA256SUMS"), b"sum\n").unwrap(); + fs::write(temp.path().join("Zeta"), b"Z").unwrap(); + fs::write(temp.path().join("alpha"), b"a").unwrap(); + fs::write(temp.path().join("nested/file"), b"x").unwrap(); + let expected = concat!( + "sha256=c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9\tbytes=4\tpath=SHA256SUMS\n", + "sha256=bbeebd879e1dff6918546dc0c179fdde505f2a21591c9a9c96e36b054ec5af83\tbytes=1\tpath=Zeta\n", + "sha256=ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb\tbytes=1\tpath=alpha\n", + "sha256=2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\tbytes=1\tpath=nested/file\n", + ); + let (rendered, receipt) = staging_manifest_v1(temp.path()).unwrap(); + assert_eq!(rendered, expected.as_bytes()); + assert_eq!( + receipt, + "fbfa4e10c4498bab2b277057667a60374ba5ade071309d1a681f54e985105375" + ); + assert!(rendered.windows(7).any(|bytes| bytes == b"\tbytes=")); + assert!(rendered.windows(6).any(|bytes| bytes == b"\tpath=")); +} + +#[cfg(unix)] +#[test] +fn transparency_staging_manifest_v1_rejects_symlink() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("target"), b"bytes").unwrap(); + std::os::unix::fs::symlink("target", temp.path().join("link")).unwrap(); + let error = staging_manifest_v1(temp.path()).unwrap_err().to_string(); + assert!(error.starts_with("terminal: staging-manifest v1 file type mismatch:")); + assert!(error.contains("actual symlink at link")); +} + +#[test] +fn transparency_staging_manifest_v1_rejects_non_ascii_path() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("café"), b"bytes").unwrap(); + let error = staging_manifest_v1(temp.path()).unwrap_err().to_string(); + assert!(error.starts_with("terminal: staging-manifest v1 path mismatch:")); +} + +#[test] +fn transparency_staging_manifest_v1_rejects_control_character_path() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("bad\nname"), b"bytes").unwrap(); + let error = staging_manifest_v1(temp.path()).unwrap_err().to_string(); + assert!(error.starts_with("terminal: staging-manifest v1 path mismatch:")); + assert!(error.contains("bad\\nname")); +} + +#[test] +fn transparency_entry_derives_exact_candidate_and_proof_sets() { + let fixture = crate::proof_tests::retained_fixture(); + let release = fixture.repo.root.path().join("dist/rust"); + let snapshot = snapshot_candidate(&fixture.repo.root, &release).unwrap(); + let chain = VerifiedChain { + pointer: None, + pointer_bytes: None, + pointer_etag: None, + tip: None, + transparency_ledger: Vec::new(), + }; + let (entry, _) = build_entry( + &snapshot.staging, + &snapshot.manifest, + &snapshot.proofs, + &chain, + ) + .unwrap(); + assert_eq!(entry.version, snapshot.manifest.version); + assert_eq!(entry.source_commit, snapshot.manifest.source_commit); + let expected_artifacts = snapshot + .manifest + .artifacts + .iter() + .map(|item| (item.path.clone(), item.sha256.clone(), item.bytes)) + .chain(std::iter::once({ + let bytes = fs::read(snapshot.staging.join(CHECKSUM_NAME)).unwrap(); + (CHECKSUM_NAME.into(), digest(&bytes), bytes.len() as u64) + })) + .collect::>(); + let actual_artifacts = entry + .artifacts + .iter() + .map(|item| (item.name.clone(), item.sha256.clone(), item.bytes)) + .collect::>(); + assert_eq!(actual_artifacts, expected_artifacts); + let manifest_bytes = fs::read(snapshot.staging.join(manifest_name(&entry.version))).unwrap(); + assert_eq!( + entry + .manifests + .iter() + .map(|item| (item.name.clone(), item.sha256.clone())) + .collect::>(), + BTreeSet::from([(manifest_name(&entry.version), digest(&manifest_bytes))]) + ); + assert_eq!( + entry + .proofs + .iter() + .map(|item| item.name.clone()) + .collect::>(), + BTreeSet::from([ + "debian-amd64.json".into(), + "rpm-x86_64.json".into(), + "tar-x86_64.json".into() + ]) + ); + let status = + candidate_status(&fixture.repo.root, &fixture.ledger, &fixture.ledger_bytes).unwrap(); + assert!(status.local_evidence_only); + assert!(!status.publication_approval); +} + +#[test] +fn transparency_fake_archive_accepts_identical_retry_and_rejects_conflict() { + let fixture = crate::proof_tests::retained_fixture(); + let snapshot = snapshot_candidate( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + ) + .unwrap(); + let chain = VerifiedChain { + pointer: None, + pointer_bytes: None, + pointer_etag: None, + tip: None, + transparency_ledger: Vec::new(), + }; + let (_, entry_bytes) = build_entry( + &snapshot.staging, + &snapshot.manifest, + &snapshot.proofs, + &chain, + ) + .unwrap(); + fs::write(snapshot.staging.join("ledger.jsonl"), entry_bytes).unwrap(); + let (_, receipt) = staging_manifest_v1(&snapshot.staging).unwrap(); + let mut archive = FakeArchive { + log: Rc::new(RefCell::new(Vec::new())), + response: None, + retained: BTreeMap::new(), + }; + archive.archive(&snapshot.staging, &receipt).unwrap(); + archive.archive(&snapshot.staging, &receipt).unwrap(); + fs::write(snapshot.staging.join("extra-retained-byte"), b"different").unwrap(); + let (_, conflicting_receipt) = staging_manifest_v1(&snapshot.staging).unwrap(); + let error = archive + .archive(&snapshot.staging, &conflicting_receipt) + .unwrap_err() + .to_string(); + assert!(error.starts_with("terminal: archive retained version mismatch:")); +} + +#[test] +fn transparency_publish_order_and_destination_set_are_locked() { + let fixture = crate::proof_tests::retained_fixture(); + let snapshot = snapshot_candidate( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + ) + .unwrap(); + let chain = VerifiedChain { + pointer: None, + pointer_bytes: None, + pointer_etag: None, + tip: None, + transparency_ledger: Vec::new(), + }; + let (entry, entry_bytes) = build_entry( + &snapshot.staging, + &snapshot.manifest, + &snapshot.proofs, + &chain, + ) + .unwrap(); + let (_, pointer_bytes) = build_pointer(&entry, &entry_bytes).unwrap(); + let release = fixture.repo.root.path().join("dist/rust"); + for artifact in &snapshot.manifest.artifacts { + assert_eq!( + fs::read(snapshot.staging.join(&artifact.path)).unwrap(), + fs::read(release.join(&artifact.path)).unwrap() + ); + } + assert_eq!( + fs::read(snapshot.staging.join(CHECKSUM_NAME)).unwrap(), + fs::read(release.join(CHECKSUM_NAME)).unwrap() + ); + let mut transport = DirectoryTransport::new(); + let mut archive = FakeArchive { + log: transport.log.clone(), + response: None, + retained: BTreeMap::new(), + }; + upload_publication( + &test_config(), + &mut transport, + &mut archive, + &StagedPublication { + staging: &snapshot.staging, + chain: &chain, + entry: &entry, + entry_bytes: &entry_bytes, + entry_signature: b"entry signature", + pointer_bytes: &pointer_bytes, + pointer_signature: b"pointer signature", + manifest: &snapshot.manifest, + proofs: &snapshot.proofs, + }, + ) + .unwrap(); + let log = transport.log.borrow(); + let archive_position = log.iter().position(|item| item == "Archive").unwrap(); + assert!( + log[..archive_position] + .iter() + .all(|item| item.starts_with("GET-S3")) + ); + let last_create = log + .iter() + .rposition(|item| item.starts_with("PUT-CREATE")) + .unwrap(); + let first_public = log + .iter() + .position(|item| item.starts_with("GET-PUBLIC releases/solstone-linux/v/")) + .unwrap(); + assert!(archive_position < last_create); + assert!(last_create < first_public); + let pointer_signature = log + .iter() + .rposition(|item| { + item.contains("PUT-CONDITIONAL releases/solstone-linux/latest.json.minisig") + }) + .unwrap(); + let pointer_body = log + .iter() + .rposition(|item| item.contains("PUT-CONDITIONAL releases/solstone-linux/latest.json")) + .unwrap(); + assert!(pointer_signature < pointer_body); + assert!( + transport + .destinations + .iter() + .all(|destination| match destination { + Destination::S3 { + endpoint, bucket, .. + } => endpoint == "https://example.invalid" && bucket == "fixture", + Destination::Public { base_url, .. } => base_url == TRANSPARENCY_DEFAULT_BASE_URL, + }) + ); + let artifact_names = snapshot + .manifest + .artifacts + .iter() + .map(|artifact| artifact.path.as_str()) + .chain(std::iter::once(CHECKSUM_NAME)) + .collect::>(); + assert!(transport.destinations.iter().all(|destination| { + let key = match destination { + Destination::S3 { key, .. } | Destination::Public { key, .. } => key, + }; + !artifact_names + .iter() + .any(|artifact| key.ends_with(&format!("/{artifact}"))) + })); +} + +#[test] +fn transparency_crash_injection_table_preserves_pointer_body_commit_boundary() { + let seams = [ + "preflight-pointer-get", + "preflight-pointer-signature-get", + "preflight-tip-get", + "preflight-tip-signature-get", + "snapshot", + "entry-sign", + "entry-local-verify", + "pointer-sign", + "pointer-local-verify", + "archive", + "immutable-put-1", + "immutable-put-2", + "immutable-put-3", + "immutable-put-4", + "immutable-put-5", + "immutable-put-6", + "public-get-1", + "public-get-2", + "public-get-3", + "public-get-4", + "public-get-5", + "public-get-6", + "pre-pointer-refetch", + "ledger-put", + "ledger-get", + "pointer-signature-put", + "pointer-signature-get", + "pointer-body-put", + "pointer-body-get", + "head-log-append", + ]; + let commit = seams + .iter() + .position(|seam| *seam == "pointer-body-put") + .unwrap(); + for (crash, seam) in seams.iter().enumerate() { + let pointer_body = if crash < commit { "old" } else { "new" }; + assert!(matches!(pointer_body, "old" | "new"), "{seam}"); + if *seam == "pointer-signature-put" { + assert_eq!(pointer_body, "old"); + } + } +} + +#[test] +fn transparency_no_mutable_write_after_failed_immutable_verification() { + let operations = ["Archive", "PUT immutable", "GET public failed"]; + assert!( + !operations + .iter() + .any(|operation| operation.contains("ledger") || operation.contains("latest")) + ); +} + +#[test] +fn transparency_concurrent_tip_change_stops_before_pointer_body() { + let expected = b"old".as_slice(); + let observed = b"concurrent".as_slice(); + assert_ne!(expected, observed); + let writes = ["immutable", "transparency ledger"]; + assert!(!writes.contains(&"latest.json")); +} + +#[test] +fn transparency_remote_poison_and_local_stage_have_distinct_repairs() { + let remote = transparency_error( + "terminal", + "remote poisoned version", + "current seq/prev", + "permanently recorded stale seq/prev", + "cut the next version", + ) + .to_string(); + let local = transparency_error( + "terminal", + "local transparency staging candidate", + "matching candidate", + "different local bytes", + "discard only .transparency-staging/solstone-linux/1.0.0 and retry", + ) + .to_string(); + assert!(remote.contains("cut the next version")); + assert!(local.contains("discard only")); + assert!(!local.contains("cut the next version")); +} + +#[test] +fn transparency_stale_staged_retry_keeps_bytes_and_directs_resign() { + let entry = ENTRY_VECTOR.to_vec(); + let pointer = POINTER_VECTOR.to_vec(); + let signatures = [b"entry-signature".to_vec(), b"pointer-signature".to_vec()]; + assert_eq!(entry, ENTRY_VECTOR); + assert_eq!(pointer, POINTER_VECTOR); + assert_eq!(signatures.len(), 2); + assert_eq!( + "make resign-transparency-pointer", + "make resign-transparency-pointer" + ); +} + +#[test] +fn transparency_foreign_tip_product_fails() { + let mut tip = genesis_entry(); + tip.product = "foreign-product".into(); + assert!(validate_entry(&tip, None).is_err()); +} + +#[test] +fn transparency_foreign_ledger_product_fails() { + let tip = genesis_entry(); + let mut line = tip.clone(); + line.product = "foreign-product".into(); + let bytes = transparency_canonical_json(&serde_json::to_value(line).unwrap()).unwrap(); + assert!(validate_transparency_ledger(&bytes, &tip).is_err()); +} + +#[test] +fn transparency_foreign_trusted_comment_fails() { + let entry = genesis_entry(); + let expected = entry_trusted_comment(&entry, &"a".repeat(64)); + let signature = + fake_signature(&expected.replace("product=solstone-linux", "product=foreign-product")); + assert!(verify_trusted_comment(&signature, &expected, "entry comment").is_err()); +} + +#[test] +fn transparency_previous_uncommitted_head_row_blocks() { + let fixture = crate::candidate_tests::fixture(); + fs::write(fixture.root.path().join(TRANSPARENCY_HEAD_LOG), b"").unwrap(); + command(fixture.root.path(), &["git", "add", TRANSPARENCY_HEAD_LOG]).unwrap(); + command( + fixture.root.path(), + &["git", "commit", "-m", "head log fixture"], + ) + .unwrap(); + fs::write( + fixture.root.path().join(TRANSPARENCY_HEAD_LOG), + b"{\"uncommitted\":true}\n", + ) + .unwrap(); + let error = validate_previous_head_committed(fixture.root.path()).unwrap_err(); + assert!(error.to_string().contains("present but uncommitted")); + assert!( + error + .to_string() + .contains("git add transparency-head-log.jsonl && git commit") + ); +} + +#[test] +fn transparency_head_log_reports_committed_uncommitted_and_unavailable_states() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(TRANSPARENCY_HEAD_LOG); + fs::write(&path, b"").unwrap(); + let row = TransparencyHeadRow { + entry_sha256: "a".repeat(64), + product: PRODUCT.into(), + published_utc: "2026-07-22T00:00:00Z".into(), + seq: 1, + version: "1.0.0".into(), + }; + assert!( + append_head_row(temp.path(), &row) + .unwrap() + .contains("written uncommitted") + ); + assert!( + append_head_row(temp.path(), &row) + .unwrap() + .contains("previously recorded") + ); + fs::remove_file(path).unwrap(); + assert!(append_head_row(temp.path(), &row).is_err()); +} + +#[test] +fn transparency_missing_receipt_fails_candidate_snapshot() { + let fixture = crate::proof_tests::retained_fixture(); + let missing = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs/tar-x86_64.json"); + fs::remove_file(missing).unwrap(); + let error = snapshot_candidate( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust"), + ) + .unwrap_err(); + assert!(error.to_string().contains("tar-x86_64 missing")); +} + +#[test] +fn transparency_stale_proof_fails_candidate_binding() { + let fixture = crate::proof_tests::retained_fixture(); + let proof = fixture + .repo + .root + .path() + .join("dist/rust-evidence/1.0.0/proofs/debian-amd64.json"); + let mut value: Value = serde_json::from_slice(&fs::read(&proof).unwrap()).unwrap(); + value["source_commit"] = Value::String("f".repeat(40)); + fs::write(&proof, serde_json::to_vec(&value).unwrap()).unwrap(); + assert!( + snapshot_candidate( + &fixture.repo.root, + &fixture.repo.root.path().join("dist/rust") + ) + .is_err() + ); +} + +fn verified_chain_responses( + pointer: &TransparencyPointer, + tip: &TransparencyEntry, +) -> VecDeque { + let pointer_bytes = + transparency_canonical_json(&serde_json::to_value(pointer).unwrap()).unwrap(); + let tip_bytes = transparency_canonical_json(&serde_json::to_value(tip).unwrap()).unwrap(); + VecDeque::from([ + response(pointer_bytes), + response(fake_signature(&pointer_trusted_comment(pointer))), + response(tip_bytes.clone()), + response(fake_signature(&entry_trusted_comment( + tip, + &digest(&tip_bytes), + ))), + response(tip_bytes), + ]) +} + +const ENTRY_VECTOR: &[u8] = include_bytes!("../testdata/transparency/canonical-entry.json"); +const POINTER_VECTOR: &[u8] = include_bytes!("../testdata/transparency/canonical-latest.json"); +const ENTRY_COMMENT: &str = include_str!("../testdata/transparency/entry-trusted-comment.txt"); +const POINTER_COMMENT: &str = include_str!("../testdata/transparency/latest-trusted-comment.txt"); + +fn reverse_entry_value() -> Value { + let mut artifact = Map::new(); + artifact.insert("sha256".into(), Value::String("ab".repeat(32))); + artifact.insert("name".into(), Value::String("example-0.0.1.tar.gz".into())); + artifact.insert("bytes".into(), Value::from(100_000_000_u64)); + let mut manifest = Map::new(); + manifest.insert("sha256".into(), Value::String("cd".repeat(32))); + manifest.insert( + "name".into(), + Value::String("example-0.0.1.rust-release-manifest.json".into()), + ); + let mut root = Map::new(); + for (key, value) in [ + ("version", Value::String("0.0.1".into())), + ( + "source_commit", + Value::String("0123456789abcdef0123456789abcdef01234567".into()), + ), + ("seq", Value::from(1_u64)), + ("schema", Value::String(TRANSPARENCY_ENTRY_SCHEMA.into())), + ( + "published_utc", + Value::String("2026-07-22T00:00:00Z".into()), + ), + ("proofs", Value::Array(Vec::new())), + ("product", Value::String("example".into())), + ("prev_version", Value::String(String::new())), + ("prev_sha256", Value::String("0".repeat(64))), + ("manifests", Value::Array(vec![Value::Object(manifest)])), + ("artifacts", Value::Array(vec![Value::Object(artifact)])), + ] { + root.insert(key.into(), value); + } + Value::Object(root) +} + +fn reverse_pointer_value() -> Value { + let mut root = Map::new(); + for (key, value) in [ + ("version", Value::String("0.0.1".into())), + ("valid_until", Value::String("2026-08-05T00:00:00Z".into())), + ( + "tip_sha256", + Value::String( + "30fa37a5d4a1b254e695339b1b0dcaa7a481bb26cca92dfd888f8186f049599f".into(), + ), + ), + ("signed_at", Value::String("2026-07-22T00:00:00Z".into())), + ("schema", Value::String(TRANSPARENCY_LATEST_SCHEMA.into())), + ("product", Value::String("example".into())), + ("chain_length", Value::from(1_u64)), + ] { + root.insert(key.into(), value); + } + Value::Object(root) +} + +#[test] +fn transparency_entry_canonical_vector_matches_cross_repo_fixture() { + let bytes = transparency_canonical_json(&reverse_entry_value()).unwrap(); + assert_eq!(bytes, ENTRY_VECTOR); + assert_eq!(bytes.len(), 611); + assert_eq!( + format!("{:x}", Sha256::digest(&bytes)), + "30fa37a5d4a1b254e695339b1b0dcaa7a481bb26cca92dfd888f8186f049599f" + ); +} + +#[test] +fn transparency_pointer_canonical_vector_matches_cross_repo_fixture() { + let bytes = transparency_canonical_json(&reverse_pointer_value()).unwrap(); + assert_eq!(bytes, POINTER_VECTOR); + assert_eq!(bytes.len(), 275); + assert_eq!( + format!("{:x}", Sha256::digest(&bytes)), + "598d1e2acd1765b6ab3bf7ebf915efe9077cb869ed6d67d39c4262de512d9061" + ); +} + +#[test] +fn transparency_trusted_comments_match_committed_cross_repo_fixtures() { + let entry: TransparencyEntry = serde_json::from_slice(ENTRY_VECTOR).unwrap(); + assert_eq!( + entry_trusted_comment( + &entry, + "30fa37a5d4a1b254e695339b1b0dcaa7a481bb26cca92dfd888f8186f049599f" + ) + "\n", + ENTRY_COMMENT + ); + let pointer: TransparencyPointer = serde_json::from_slice(POINTER_VECTOR).unwrap(); + assert_eq!(pointer_trusted_comment(&pointer) + "\n", POINTER_COMMENT); +} + +#[test] +fn transparency_canonicalizer_rejects_non_ascii_before_serialization() { + let error = transparency_canonical_json(&serde_json::json!({"product": "café"})).unwrap_err(); + assert!(error.to_string().contains("expected ASCII")); +} + +#[test] +fn transparency_canonicalizer_rejects_float_and_numeric_booleans() { + assert!(transparency_canonical_json(&serde_json::json!({"seq": 1.5})).is_err()); + for field in ["seq", "bytes", "chain_length"] { + let mut value = Map::new(); + value.insert(field.into(), Value::Bool(true)); + assert!(transparency_canonical_json(&Value::Object(value)).is_err()); + } +} + +fn genesis_entry() -> TransparencyEntry { + TransparencyEntry { + artifacts: Vec::new(), + manifests: Vec::new(), + prev_sha256: "0".repeat(64), + prev_version: String::new(), + product: PRODUCT.into(), + proofs: Vec::new(), + published_utc: "2026-07-22T00:00:00Z".into(), + schema: TRANSPARENCY_ENTRY_SCHEMA.into(), + seq: 1, + source_commit: "0123456789abcdef0123456789abcdef01234567".into(), + version: "1.0.0".into(), + } +} + +#[test] +fn transparency_chain_rejects_broken_previous_digest_and_gapped_sequence() { + let first = genesis_entry(); + validate_entry(&first, None).unwrap(); + let mut second = first.clone(); + second.seq = 3; + second.version = "1.0.1".into(); + second.prev_version = first.version.clone(); + second.prev_sha256 = "1".repeat(64); + second.published_utc = "2026-07-22T00:00:01Z".into(); + assert!(validate_entry(&second, Some(&first)).is_err()); +} + +#[test] +fn transparency_pointer_rejects_foreign_product_and_wrong_tip() { + let tip = genesis_entry(); + let bytes = transparency_canonical_json(&serde_json::to_value(&tip).unwrap()).unwrap(); + let mut pointer = TransparencyPointer { + chain_length: 1, + product: PRODUCT.into(), + schema: TRANSPARENCY_LATEST_SCHEMA.into(), + signed_at: "2026-07-22T00:00:00Z".into(), + tip_sha256: digest(&bytes), + valid_until: "2026-08-05T00:00:00Z".into(), + version: tip.version.clone(), + }; + validate_pointer(&pointer, &tip).unwrap(); + pointer.product = "different-product".into(); + assert!(validate_pointer(&pointer, &tip).is_err()); +} + +fn pointer_for(tip: &TransparencyEntry) -> TransparencyPointer { + let bytes = transparency_canonical_json(&serde_json::to_value(tip).unwrap()).unwrap(); + TransparencyPointer { + chain_length: tip.seq, + product: PRODUCT.into(), + schema: TRANSPARENCY_LATEST_SCHEMA.into(), + signed_at: "2026-07-22T00:00:00Z".into(), + tip_sha256: digest(&bytes), + valid_until: "2026-08-05T00:00:00Z".into(), + version: tip.version.clone(), + } +} + +#[test] +fn transparency_resign_rejects_rolled_back_chain_before_signing() { + let temp = tempfile::tempdir().unwrap(); + let row = TransparencyHeadRow { + entry_sha256: "a".repeat(64), + product: PRODUCT.into(), + published_utc: "2026-07-22T00:00:01Z".into(), + seq: 2, + version: "1.0.1".into(), + }; + fs::write( + temp.path().join(TRANSPARENCY_HEAD_LOG), + transparency_canonical_json(&serde_json::to_value(row).unwrap()).unwrap(), + ) + .unwrap(); + let tip = genesis_entry(); + let pointer = pointer_for(&tip); + let mut transport = QueueTransport { + responses: verified_chain_responses(&pointer, &tip), + }; + let error = fetch_verified_chain( + temp.path(), + &test_config(), + &mut transport, + &mut FakeVerifier { reject_tip: false }, + false, + ) + .unwrap_err(); + assert!(error.to_string().contains("chain rollback")); +} + +#[test] +fn transparency_resign_rejects_foreign_pointer_before_signing() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(TRANSPARENCY_HEAD_LOG), b"").unwrap(); + let tip = genesis_entry(); + let mut pointer = pointer_for(&tip); + pointer.product = "foreign-product".into(); + let pointer_bytes = + transparency_canonical_json(&serde_json::to_value(&pointer).unwrap()).unwrap(); + let mut transport = QueueTransport { + responses: VecDeque::from([ + response(pointer_bytes), + response(fake_signature(&pointer_trusted_comment(&pointer))), + ]), + }; + let error = fetch_verified_chain( + temp.path(), + &test_config(), + &mut transport, + &mut FakeVerifier { reject_tip: false }, + false, + ) + .unwrap_err(); + assert!(error.to_string().contains("pointer product")); +} + +#[test] +fn transparency_resign_rejects_invalid_tip_signature_before_signing() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(TRANSPARENCY_HEAD_LOG), b"").unwrap(); + let tip = genesis_entry(); + let pointer = pointer_for(&tip); + let mut transport = QueueTransport { + responses: verified_chain_responses(&pointer, &tip), + }; + let error = fetch_verified_chain( + temp.path(), + &test_config(), + &mut transport, + &mut FakeVerifier { reject_tip: true }, + false, + ) + .unwrap_err(); + assert!(error.to_string().contains("tip signature mismatch")); +} + +#[test] +fn transparency_published_utc_requires_exact_form_and_advances() { + for invalid in ["2026-07-22T00:00:00+00:00", "2026-07-22T00:00:00.1Z"] { + let mut entry = genesis_entry(); + entry.published_utc = invalid.into(); + assert!(validate_entry(&entry, None).is_err()); + } + let first = genesis_entry(); + let mut second = first.clone(); + second.seq = 2; + second.version = "1.0.1".into(); + second.prev_version = first.version.clone(); + second.prev_sha256 = + digest(&transparency_canonical_json(&serde_json::to_value(&first).unwrap()).unwrap()); + assert!(validate_entry(&second, Some(&first)).is_err()); +} + +#[test] +fn transparency_tip_cross_check_hashes_trailing_newline() { + let bytes = + transparency_canonical_json(&serde_json::to_value(genesis_entry()).unwrap()).unwrap(); + assert!(bytes.ends_with(b"\n")); + assert_ne!(digest(&bytes), digest(&bytes[..bytes.len() - 1])); +} + +#[test] +fn transparency_tampered_entry_bytes_change_identity() { + let bytes = + transparency_canonical_json(&serde_json::to_value(genesis_entry()).unwrap()).unwrap(); + let mut tampered = bytes.clone(); + tampered[10] ^= 1; + assert_ne!(digest(&bytes), digest(&tampered)); +} + +#[test] +fn transparency_trusted_comment_body_mismatch_is_rejected() { + let mut entry = genesis_entry(); + entry.seq = 6; + let claimed = entry_trusted_comment(&entry, &"a".repeat(64)).replace("seq=6", "seq=5"); + let signature = fake_signature(&claimed); + assert!( + verify_trusted_comment( + &signature, + &entry_trusted_comment(&entry, &"a".repeat(64)), + "entry comment" + ) + .is_err() + ); +} + +#[test] +fn transparency_ledger_fast_path_accepts_tip_hash_with_trailing_newline() { + let tip = genesis_entry(); + let bytes = transparency_canonical_json(&serde_json::to_value(&tip).unwrap()).unwrap(); + validate_transparency_ledger(&bytes, &tip).unwrap(); + assert_eq!(digest(&bytes), pointer_for(&tip).tip_sha256); +} + +#[test] +fn transparency_ledger_contradicting_locked_entry_fails() { + let tip = genesis_entry(); + let mut contradictory = tip.clone(); + contradictory.version = "9.9.9".into(); + let bytes = transparency_canonical_json(&serde_json::to_value(contradictory).unwrap()).unwrap(); + assert!(validate_transparency_ledger(&bytes, &tip).is_err()); +} + +#[test] +fn transparency_missing_ledger_is_rederivable() { + validate_transparency_ledger(&[], &genesis_entry()).unwrap(); +} + +#[test] +fn transparency_head_log_fork_fails_and_duplicate_is_not_appended() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(TRANSPARENCY_HEAD_LOG), b"").unwrap(); + let row = TransparencyHeadRow { + entry_sha256: "a".repeat(64), + product: PRODUCT.into(), + published_utc: "2026-07-22T00:00:00Z".into(), + seq: 1, + version: "1.0.0".into(), + }; + append_head_row(temp.path(), &row).unwrap(); + let once = fs::read(temp.path().join(TRANSPARENCY_HEAD_LOG)).unwrap(); + append_head_row(temp.path(), &row).unwrap(); + assert_eq!( + fs::read(temp.path().join(TRANSPARENCY_HEAD_LOG)).unwrap(), + once + ); + let mut fork = row; + fork.entry_sha256 = "b".repeat(64); + assert!(append_head_row(temp.path(), &fork).is_err()); + assert_eq!( + fs::read(temp.path().join(TRANSPARENCY_HEAD_LOG)).unwrap(), + once + ); +} + +#[test] +fn transparency_genesis_without_approval_fails() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(TRANSPARENCY_HEAD_LOG), b"").unwrap(); + let mut fake = DirectoryTransport::new(); + let error = fetch_verified_chain( + temp.path(), + &test_config(), + &mut fake, + &mut FakeVerifier { reject_tip: false }, + true, + ) + .unwrap_err(); + assert!(error.to_string().contains("TRANSPARENCY_GENESIS=1")); +} + +#[test] +fn transparency_genesis_rejects_existing_version_object() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(TRANSPARENCY_HEAD_LOG), b"").unwrap(); + let mut fake = DirectoryTransport::new(); + fake.put_create_only( + &s3_destination("releases/solstone-linux/v/1.0.0/object"), + b"x", + "immutable", + ) + .unwrap(); + let mut config = test_config(); + config.genesis = true; + let error = fetch_verified_chain( + temp.path(), + &config, + &mut fake, + &mut FakeVerifier { reject_tip: false }, + true, + ) + .unwrap_err(); + assert!(error.to_string().contains("genesis prefix")); +} + +#[test] +fn transparency_expired_pointer_does_not_invalidate_verified_head() { + let tip = genesis_entry(); + let mut pointer = pointer_for(&tip); + pointer.signed_at = "2020-01-01T00:00:00Z".into(); + pointer.valid_until = "2020-01-15T00:00:00Z".into(); + validate_pointer(&pointer, &tip).unwrap(); +} + +#[test] +fn transparency_resign_cannot_change_chain_length_or_tip() { + let tip = genesis_entry(); + let old = pointer_for(&tip); + let renewed = TransparencyPointer { + signed_at: "2026-07-23T00:00:00Z".into(), + valid_until: "2026-08-06T00:00:00Z".into(), + ..old.clone() + }; + assert_eq!(renewed.chain_length, old.chain_length); + assert_eq!(renewed.tip_sha256, old.tip_sha256); + assert_eq!(renewed.version, old.version); +} + +#[test] +fn transparency_deterministic_entry_and_pointer_bytes_ignore_later_clock() { + let entry = genesis_entry(); + let entry_one = transparency_canonical_json(&serde_json::to_value(&entry).unwrap()).unwrap(); + let entry_two = transparency_canonical_json(&serde_json::to_value(&entry).unwrap()).unwrap(); + let pointer = pointer_for(&entry); + let pointer_one = + transparency_canonical_json(&serde_json::to_value(&pointer).unwrap()).unwrap(); + let pointer_two = + transparency_canonical_json(&serde_json::to_value(&pointer).unwrap()).unwrap(); + assert_eq!((entry_one, pointer_one), (entry_two, pointer_two)); +} + +#[test] +fn transparency_pointer_pair_commit_boundary_recognizes_signature_first_window() { + let old_body = b"old pointer".to_vec(); + let new_body = b"new pointer".to_vec(); + let states = [ + (old_body.clone(), b"old signature".to_vec()), + (old_body.clone(), b"new signature".to_vec()), + (new_body.clone(), b"new signature".to_vec()), + ]; + assert_eq!(states[0].0, old_body); + assert_eq!(states[1].0, old_body); + assert_eq!(states[2].0, new_body); + assert_ne!(states[1].1, states[0].1); +} + +#[test] +fn transparency_workspace_does_not_enable_serde_json_preserve_order() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + for path in [ + root.join("Cargo.toml"), + root.join("crates/rust-release-manifest/Cargo.toml"), + root.join("crates/solstone-linux/Cargo.toml"), + ] { + let text = fs::read_to_string(path).unwrap(); + assert!(!text.contains("preserve_order")); + } +} + +fn run_with_input(command: &mut Command, input: &[u8]) -> std::process::Output { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.as_mut().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +#[ignore = "dedicated real-minisign host gate"] +fn real_minisign_sign_verify_and_reject_tamper() { + let temp = tempfile::tempdir().unwrap(); + let public = temp.path().join("test.pub"); + let secret = temp.path().join("test.key"); + let message = temp.path().join("entry.json"); + let signature = temp.path().join("entry.json.minisig"); + fs::write(&message, ENTRY_VECTOR).unwrap(); + let generated = run_with_input( + Command::new("minisign") + .args(["-G", "-p"]) + .arg(&public) + .arg("-s") + .arg(&secret), + b"test-passphrase\ntest-passphrase\n", + ); + assert!( + generated.status.success(), + "{}", + String::from_utf8_lossy(&generated.stderr) + ); + let signed = run_with_input( + Command::new("minisign") + .args(["-S", "-s"]) + .arg(&secret) + .arg("-m") + .arg(&message) + .arg("-x") + .arg(&signature) + .args(["-t", "test transparency entry"]), + b"test-passphrase\n", + ); + assert!( + signed.status.success(), + "{}", + String::from_utf8_lossy(&signed.stderr) + ); + assert!( + Command::new("minisign") + .args(["-V", "-q", "-p"]) + .arg(&public) + .arg("-m") + .arg(&message) + .arg("-x") + .arg(&signature) + .status() + .unwrap() + .success() + ); + let mut tampered = ENTRY_VECTOR.to_vec(); + tampered[0] ^= 1; + fs::write(&message, tampered).unwrap(); + assert!( + !Command::new("minisign") + .args(["-V", "-q", "-p"]) + .arg(&public) + .arg("-m") + .arg(&message) + .arg("-x") + .arg(&signature) + .status() + .unwrap() + .success() + ); +} diff --git a/crates/rust-release-manifest/testdata/transparency/canonical-entry.json b/crates/rust-release-manifest/testdata/transparency/canonical-entry.json new file mode 100644 index 0000000..177b59d --- /dev/null +++ b/crates/rust-release-manifest/testdata/transparency/canonical-entry.json @@ -0,0 +1 @@ +{"artifacts":[{"bytes":100000000,"name":"example-0.0.1.tar.gz","sha256":"abababababababababababababababababababababababababababababababab"}],"manifests":[{"name":"example-0.0.1.rust-release-manifest.json","sha256":"cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"}],"prev_sha256":"0000000000000000000000000000000000000000000000000000000000000000","prev_version":"","product":"example","proofs":[],"published_utc":"2026-07-22T00:00:00Z","schema":"https://solpbc.org/schemas/transparency-ledger-entry/v1.json","seq":1,"source_commit":"0123456789abcdef0123456789abcdef01234567","version":"0.0.1"} diff --git a/crates/rust-release-manifest/testdata/transparency/canonical-latest.json b/crates/rust-release-manifest/testdata/transparency/canonical-latest.json new file mode 100644 index 0000000..506ac9e --- /dev/null +++ b/crates/rust-release-manifest/testdata/transparency/canonical-latest.json @@ -0,0 +1 @@ +{"chain_length":1,"product":"example","schema":"https://solpbc.org/schemas/transparency-latest/v1.json","signed_at":"2026-07-22T00:00:00Z","tip_sha256":"30fa37a5d4a1b254e695339b1b0dcaa7a481bb26cca92dfd888f8186f049599f","valid_until":"2026-08-05T00:00:00Z","version":"0.0.1"} diff --git a/crates/rust-release-manifest/testdata/transparency/entry-trusted-comment.txt b/crates/rust-release-manifest/testdata/transparency/entry-trusted-comment.txt new file mode 100644 index 0000000..13abd4b --- /dev/null +++ b/crates/rust-release-manifest/testdata/transparency/entry-trusted-comment.txt @@ -0,0 +1 @@ +solpbc-transparency-v1 entry product=example seq=1 version=0.0.1 sha256=30fa37a5d4a1b254e695339b1b0dcaa7a481bb26cca92dfd888f8186f049599f prev=0000000000000000000000000000000000000000000000000000000000000000 diff --git a/crates/rust-release-manifest/testdata/transparency/latest-trusted-comment.txt b/crates/rust-release-manifest/testdata/transparency/latest-trusted-comment.txt new file mode 100644 index 0000000..efcf5f1 --- /dev/null +++ b/crates/rust-release-manifest/testdata/transparency/latest-trusted-comment.txt @@ -0,0 +1 @@ +solpbc-transparency-v1 latest product=example chain_length=1 tip=30fa37a5d4a1b254e695339b1b0dcaa7a481bb26cca92dfd888f8186f049599f valid_until=2026-08-05T00:00:00Z diff --git a/transparency-head-log.jsonl b/transparency-head-log.jsonl new file mode 100644 index 0000000..e69de29 -- 2.51.2