commit 1c4b3f3cae1181fa53bb777237f281b559082203
parent 08749c18cd98f32e507251b9e5336290449bfce2
Author: triesap <tyson@radroots.org>
Date: Mon, 7 Sep 2026 05:56:15 +0000
feat: implement governed release evidence
- Generate canonical exact-tree source archives and validate v3 source locks.
- Emit CycloneDX 1.6, dependency license texts, and candidate-bound SLSA provenance.
- Derive confidentiality from bounded artifact scans with adversarial negative vectors.
- Add the Step 305 source-bound gate and preserve unsigned release posture.
Diffstat:
8 files changed, 2933 insertions(+), 425 deletions(-)
diff --git a/contracts/architecture/decisions/services_hardening_release_artifacts.v4.json b/contracts/architecture/decisions/services_hardening_release_artifacts.v4.json
@@ -0,0 +1,58 @@
+{
+ "schema": "radroots.services-hardening.release-artifacts-decisions.v4",
+ "contract_version": 4,
+ "decision_state": "active",
+ "owner_step": 305,
+ "predecessor": {
+ "schema": "radroots.services-hardening.release-artifacts-decisions.v3",
+ "filename": "services_hardening_release_artifacts.v3.json",
+ "transition": "forward_only_replace"
+ },
+ "command": "cargo xtask service-release-artifacts",
+ "modes": ["check", "write"],
+ "required_arguments": ["mode", "service_root", "lib_root", "input_root", "output_root", "target", "source_date_epoch", "candidate_digest"],
+ "service_metadata_path": "Cargo.toml.workspace.metadata.radroots.service_release",
+ "service_metadata_fields": ["service", "service_package", "binary_name", "version"],
+ "service_license_path": "Cargo.toml.workspace.package.license_or_package.license",
+ "source_lock_schema": "radroots.service.source-lock.v3",
+ "source_lock_definition": "contracts/architecture/decisions/services_hardening_source_lock.v3.json",
+ "artifact_contract_binding": "source_lock_exact_regular_file_bytes_in_same_service_revision",
+ "artifact_admission_contract": "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json",
+ "supported_targets": ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"],
+ "candidate_binding": "explicit_sha256_candidate_identity_digest",
+ "lib_root_binding": "canonical_public_lib_git_root_containing_the_locked_revision",
+ "binary_admission": "exact_format_architecture_linkage_structural_and_native_bounded_help_smoke",
+ "oci_admission": "safe_materialization_exact_manifest_config_AGPL_labels_layers_and_entrypoint",
+ "input_inventory": ["config.example.toml", "config.schema.json", "nixos-module.nix", "oci-image.tar.gz", "service-binary", "systemd.service"],
+ "excluded_parent_owned_inputs": ["backup_restore_runbook", "operator_runbook"],
+ "service_root_inventory": ["LICENSE-APACHE", "LICENSE-MIT", "radroots.service.source-lock.v3.toml"],
+ "output_inventory": ["LICENSE-APACHE", "LICENSE-MIT", "SHA256SUMS", "THIRD-PARTY-LICENSES.txt", "THIRD-PARTY-NOTICES.txt", "artifact-manifest.v2.json", "artifact-scan.v1.json", "binary.tar.gz", "config.example.toml", "config.schema.json", "lib-source.tar", "nixos-module.nix", "oci-image.tar.gz", "oci-image.v1.json", "provenance.intoto.jsonl", "radroots.service.source-lock.v3.toml", "sbom.cdx.json", "service-source.tar", "source-archives.v3.json", "systemd.service"],
+ "canonical_json": "compact_utf8_json_with_one_final_lf",
+ "checksum_format": "sha256_lower_hex_two_spaces_path_lf_sorted_by_path",
+ "source_archive_format": "canonical_uncompressed_ustar_exact_git_revision_tree_without_history",
+ "sbom_format": "cyclonedx_json_1_6_complete_cargo_nix_and_artifact_closure",
+ "license_evidence": "exact_dependency_attribution_with_bounded_license_texts",
+ "provenance_posture": "candidate_derived_unsigned_intoto_statement_slsa_v1_exact_manifest_subjects",
+ "protected_material_scan_scope": "all_artifact_bytes_and_bounded_nested_binary_oci_layer_and_source_archive_payloads",
+ "confidentiality_state": "derived_only_from_the_exact_artifact_scan_record",
+ "source_cleanliness": "no_tracked_staged_or_untracked_changes",
+ "revision_stability": "same_service_head_before_and_after_generation",
+ "no_protected_material": true,
+ "maximums": {
+ "text_input_bytes": 1048576,
+ "generated_document_bytes": 16777216,
+ "service_cargo_lock_bytes": 16777216,
+ "service_flake_lock_bytes": 4194304,
+ "binary_bytes": 536870912,
+ "source_archive_bytes": 1073741824,
+ "source_archive_member_bytes": 67108864,
+ "source_archive_members": 65536,
+ "oci_bytes": 2147483648,
+ "artifact_scan_expanded_bytes": 17179869184,
+ "cargo_metadata_bytes": 33554432,
+ "packages": 8192,
+ "workspace_packages": 64
+ },
+ "required_negative_vectors": ["cyclonedx_schema_drift", "missing_dependency_component", "unreconciled_artifact_subject", "missing_or_mismatched_license_text", "invented_candidate_digest", "git_history_bundle", "secret_in_binary", "secret_in_oci_layer", "secret_in_source_archive", "sensitive_archive_path", "scan_confidentiality_mismatch"],
+ "negative_error_codes": ["invalid_contract", "invalid_service_root", "dirty_service_source", "invalid_service_metadata", "invalid_input_root", "invalid_input_artifact", "invalid_source_lock", "invalid_source_bundle", "invalid_package_inventory", "protected_material_detected", "invalid_output_root", "stale_output", "generation_failure"]
+}
diff --git a/contracts/rshr-202-step-305-gates.v1.json b/contracts/rshr-202-step-305-gates.v1.json
@@ -0,0 +1 @@
+{"gate_command_contract":[{"argv_template":["cargo","extbuild","run","--","cargo","run","--offline","--locked","-q","-p","xtask","--","rshr-step-305-gate","--step={step}","--check-id={check_id}","--source-revision={source_revision}","--source-tree={source_tree}","--candidate-digest={candidate_digest}","--platform=macos_aarch64","--execution-request-sha256={execution_request_sha256}"],"assertion_id":["step_305_gate_01_29633a173a2b7ee52d97d7a51feca377c1c182879fb73b9f3cf7524046b35148"],"check_id":"gate-01-29633a173a2b7ee52d97d7a51feca377c1c182879fb73b9f3cf7524046b35148","environment_authority":{"cache_policy_id":"rshr-200-step-287-cache-policy.v1","cache_policy_sha256":"3e81d178bce97b6c349dfbb00c68fd6f620ac00b1a1c8d37b12e9998f3c9eaaa","cadence_policy_id":"rshr-200-step-287-cadence-policy.v1","cadence_policy_sha256":"d24903df8659ee3772297c84994911efe7d21cb8b988320ddc6ddce0431892a1","isolation":"extbuild_host_constrained","network":"disabled","network_policy_id":"none","network_policy_sha256":"none","resource_policy_id":"rshr-200-step-287-resource-policy.v1","resource_policy_sha256":"05d3c7a89185d3c55678d97955193fce2ed92b1eee5af99083d77ea64c98d14e"},"environment_names":["EXT_BUILD_CONFIG","EXT_BUILD_MACHINE_CONFIG","EXT_BUILD_ROOT","HOME","PATH","RUSTUP_TOOLCHAIN","TMPDIR"],"gate_definition_sha256":"29633a173a2b7ee52d97d7a51feca377c1c182879fb73b9f3cf7524046b35148","required_platforms":["macos_aarch64"],"required_tools":["git","rustc"],"result_schema":"radroots.services-hardening.rshr-200-step-check-result.v1","schema":"radroots.services-hardening.rshr-200-step-check-command.v1","step":305,"verifier_path":"tools/xtask/src/rshr_202_step_305_gate.rs","verifier_sha256":"48315c97b710fb4f70dc013201fbf6de0614282a1c9eea0a3bc99c74d3311b3d"}],"schema":"radroots.lib.rshr-202-step-305-gates.v1","step":[305]}
diff --git a/tools/xtask/src/exact_tree_archive.rs b/tools/xtask/src/exact_tree_archive.rs
@@ -0,0 +1,455 @@
+use std::{
+ collections::BTreeSet,
+ fs,
+ io::{Cursor, Read as _},
+ path::{Component, Path},
+ process::{Command, Stdio},
+};
+
+use sha2::{Digest as _, Sha256};
+use tar::{Builder, EntryType, Header};
+
+const MAX_TREE_LIST_BYTES: usize = 8 * 1024 * 1024;
+const MAX_SOURCE_MEMBER_BYTES: usize = 64 * 1024 * 1024;
+const MAX_SOURCE_TREE_BYTES: u64 = 1024 * 1024 * 1024;
+const MAX_SOURCE_MEMBERS: usize = 65_536;
+const MAX_USTAR_PATH_BYTES: usize = 255;
+const ARCHIVE_MODE: u32 = 0o644;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct ExactTreeArchiveEvidence {
+ pub(crate) byte_length: u64,
+ pub(crate) sha256: String,
+ pub(crate) members: u64,
+ pub(crate) payload_bytes: u64,
+}
+
+#[derive(Debug)]
+struct TreeMember {
+ path: String,
+ object_id: String,
+ mode: u32,
+}
+
+#[derive(Debug)]
+struct WrittenMember {
+ path: String,
+ mode: u32,
+ byte_length: u64,
+ sha256: String,
+}
+
+pub(crate) fn commit_timestamp(root: &Path, revision: &str) -> Result<u64, String> {
+ validate_root_and_revision(root, revision)?;
+ let output = git_output(root, &["show", "-s", "--format=%ct", revision], 64)?;
+ let value = std::str::from_utf8(&output)
+ .map_err(|_| "exact-tree commit timestamp is not UTF-8".to_owned())?
+ .trim()
+ .parse::<u64>()
+ .map_err(|_| "exact-tree commit timestamp is invalid".to_owned())?;
+ if value == 0 {
+ Err("exact-tree commit timestamp is invalid".to_owned())
+ } else {
+ Ok(value)
+ }
+}
+
+pub(crate) fn create(
+ root: &Path,
+ revision: &str,
+ output: &Path,
+ mtime: u64,
+) -> Result<ExactTreeArchiveEvidence, String> {
+ validate_root_and_revision(root, revision)?;
+ let output_parent = output
+ .parent()
+ .ok_or_else(|| "exact-tree archive output is invalid".to_owned())?;
+ if !output.is_absolute()
+ || fs::canonicalize(output_parent)
+ .map_err(|_| "exact-tree archive output parent is invalid".to_owned())?
+ != output_parent
+ || mtime == 0
+ || output.exists()
+ {
+ return Err("exact-tree archive request is invalid".to_owned());
+ }
+ let members = tree_members(root, revision)?;
+ let output_file = fs::OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(output)
+ .map_err(|_| "exact-tree archive output could not be created".to_owned())?;
+ let mut archive = Builder::new(output_file);
+ let mut payload_bytes = 0_u64;
+ let mut written = Vec::with_capacity(members.len());
+ for member in &members {
+ let contents = git_output(
+ root,
+ &["cat-file", "blob", member.object_id.as_str()],
+ MAX_SOURCE_MEMBER_BYTES,
+ )?;
+ payload_bytes = payload_bytes
+ .checked_add(contents.len() as u64)
+ .ok_or_else(|| "exact-tree archive payload is too large".to_owned())?;
+ if payload_bytes > MAX_SOURCE_TREE_BYTES {
+ return Err("exact-tree archive payload is too large".to_owned());
+ }
+ let mut header = Header::new_ustar();
+ header
+ .set_path(&member.path)
+ .map_err(|_| "exact-tree archive path is not representable in ustar".to_owned())?;
+ header.set_entry_type(EntryType::Regular);
+ header.set_size(contents.len() as u64);
+ header.set_mode(member.mode);
+ header.set_uid(0);
+ header.set_gid(0);
+ header.set_mtime(mtime);
+ header
+ .set_username("")
+ .map_err(|_| "exact-tree archive username is invalid".to_owned())?;
+ header
+ .set_groupname("")
+ .map_err(|_| "exact-tree archive group name is invalid".to_owned())?;
+ header.set_cksum();
+ archive
+ .append(&header, Cursor::new(&contents))
+ .map_err(|_| "exact-tree archive member could not be written".to_owned())?;
+ written.push(WrittenMember {
+ path: member.path.clone(),
+ mode: member.mode,
+ byte_length: contents.len() as u64,
+ sha256: hex::encode(Sha256::digest(&contents)),
+ });
+ }
+ let output_file = archive
+ .into_inner()
+ .map_err(|_| "exact-tree archive could not be finalized".to_owned())?;
+ output_file
+ .sync_all()
+ .map_err(|_| "exact-tree archive could not be synchronized".to_owned())?;
+ set_mode(output)?;
+ let bytes = fs::read(output).map_err(|_| "exact-tree archive could not be read".to_owned())?;
+ validate_archive_bytes(&bytes, &written, mtime)?;
+ Ok(ExactTreeArchiveEvidence {
+ byte_length: bytes.len() as u64,
+ sha256: hex::encode(Sha256::digest(&bytes)),
+ members: written.len() as u64,
+ payload_bytes,
+ })
+}
+
+pub(crate) fn read_blob(
+ root: &Path,
+ revision: &str,
+ path: &str,
+ maximum: usize,
+) -> Result<Vec<u8>, String> {
+ if maximum == 0 || maximum > MAX_SOURCE_MEMBER_BYTES || !valid_archive_path(path) {
+ return Err("exact-tree blob request is invalid".to_owned());
+ }
+ validate_root_and_revision(root, revision)?;
+ let member = tree_members(root, revision)?
+ .into_iter()
+ .find(|member| member.path == path)
+ .ok_or_else(|| "exact-tree blob is absent".to_owned())?;
+ git_output(root, &["cat-file", "blob", &member.object_id], maximum)
+}
+
+fn validate_root_and_revision(root: &Path, revision: &str) -> Result<(), String> {
+ if !root.is_absolute()
+ || fs::canonicalize(root).map_err(|_| "exact-tree root is invalid".to_owned())? != root
+ || !valid_lower_hex(revision, 40)
+ {
+ return Err("exact-tree root or revision is invalid".to_owned());
+ }
+ let object = format!("{revision}^{{commit}}");
+ git_status(root, &["cat-file", "-e", &object])
+ .map_err(|_| "exact-tree revision is not a commit".to_owned())
+}
+
+fn tree_members(root: &Path, revision: &str) -> Result<Vec<TreeMember>, String> {
+ let bytes = git_output(
+ root,
+ &["ls-tree", "-rz", "--full-tree", revision],
+ MAX_TREE_LIST_BYTES,
+ )?;
+ let mut members = Vec::new();
+ let mut paths = BTreeSet::new();
+ for record in bytes
+ .split(|byte| *byte == 0)
+ .filter(|record| !record.is_empty())
+ {
+ let (metadata, path) = split_once(record, b'\t')
+ .ok_or_else(|| "exact-tree inventory record is malformed".to_owned())?;
+ let fields = metadata.split(|byte| *byte == b' ').collect::<Vec<_>>();
+ if fields.len() != 3 || fields[1] != b"blob" {
+ return Err("exact-tree inventory contains a forbidden object".to_owned());
+ }
+ let mode = match fields[0] {
+ b"100644" => 0o644,
+ b"100755" => 0o755,
+ _ => return Err("exact-tree inventory contains a forbidden mode".to_owned()),
+ };
+ let object_id = std::str::from_utf8(fields[2])
+ .map_err(|_| "exact-tree object ID is invalid".to_owned())?;
+ let path =
+ std::str::from_utf8(path).map_err(|_| "exact-tree path is not UTF-8".to_owned())?;
+ if !valid_lower_hex(object_id, 40)
+ || !valid_archive_path(path)
+ || !paths.insert(path.to_owned())
+ {
+ return Err("exact-tree inventory contains an invalid member".to_owned());
+ }
+ members.push(TreeMember {
+ path: path.to_owned(),
+ object_id: object_id.to_owned(),
+ mode,
+ });
+ if members.len() > MAX_SOURCE_MEMBERS {
+ return Err("exact-tree inventory is too large".to_owned());
+ }
+ }
+ if members.is_empty() {
+ return Err("exact-tree inventory is empty".to_owned());
+ }
+ members.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
+ Ok(members)
+}
+
+fn validate_archive_bytes(
+ bytes: &[u8],
+ expected: &[WrittenMember],
+ mtime: u64,
+) -> Result<(), String> {
+ let expected_length = expected.iter().try_fold(1024_u64, |total, member| {
+ let padded = member
+ .byte_length
+ .checked_add(511)?
+ .checked_div(512)?
+ .checked_mul(512)?;
+ total.checked_add(512)?.checked_add(padded)
+ });
+ if expected_length != Some(bytes.len() as u64)
+ || bytes.len() < 1024
+ || !bytes.len().is_multiple_of(512)
+ || bytes[bytes.len() - 1024..].iter().any(|byte| *byte != 0)
+ {
+ return Err("exact-tree archive trailer is not canonical".to_owned());
+ }
+ let mut archive = tar::Archive::new(Cursor::new(bytes));
+ let mut observed = Vec::new();
+ for entry in archive
+ .entries()
+ .map_err(|_| "exact-tree archive is malformed".to_owned())?
+ {
+ let mut entry = entry.map_err(|_| "exact-tree archive is malformed".to_owned())?;
+ let path = entry
+ .path()
+ .map_err(|_| "exact-tree archive path is invalid".to_owned())?
+ .to_str()
+ .ok_or_else(|| "exact-tree archive path is not UTF-8".to_owned())?
+ .to_owned();
+ if !entry.header().entry_type().is_file()
+ || entry.header().uid().ok() != Some(0)
+ || entry.header().gid().ok() != Some(0)
+ || entry.header().mtime().ok() != Some(mtime)
+ || entry.header().username().ok().flatten() != Some("")
+ || entry.header().groupname().ok().flatten() != Some("")
+ {
+ return Err("exact-tree archive metadata is not canonical".to_owned());
+ }
+ let mut contents = Vec::new();
+ entry
+ .read_to_end(&mut contents)
+ .map_err(|_| "exact-tree archive member is unreadable".to_owned())?;
+ observed.push((
+ path,
+ entry.header().mode().unwrap_or(0),
+ contents.len() as u64,
+ hex::encode(Sha256::digest(&contents)),
+ ));
+ }
+ if observed.len() != expected.len()
+ || observed
+ .iter()
+ .zip(expected)
+ .any(|((path, mode, length, digest), member)| {
+ path != &member.path
+ || mode != &member.mode
+ || length != &member.byte_length
+ || digest != &member.sha256
+ })
+ {
+ return Err("exact-tree archive inventory or payload differs".to_owned());
+ }
+ Ok(())
+}
+
+fn valid_archive_path(path: &str) -> bool {
+ !path.is_empty()
+ && path.len() <= MAX_USTAR_PATH_BYTES
+ && !path.contains(['\0', '\n', '\r', '\\'])
+ && Path::new(path)
+ .components()
+ .all(|component| matches!(component, Component::Normal(value) if !value.is_empty()))
+}
+
+fn valid_lower_hex(value: &str, length: usize) -> bool {
+ value.len() == length
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+fn split_once(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> {
+ let index = bytes.iter().position(|byte| *byte == separator)?;
+ Some((&bytes[..index], &bytes[index + 1..]))
+}
+
+fn git_output(root: &Path, arguments: &[&str], maximum: usize) -> Result<Vec<u8>, String> {
+ let mut child = Command::new("git")
+ .args(arguments)
+ .current_dir(root)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null())
+ .spawn()
+ .map_err(|_| "exact-tree Git command could not start".to_owned())?;
+ let mut stdout = child
+ .stdout
+ .take()
+ .ok_or_else(|| "exact-tree Git stdout is unavailable".to_owned())?;
+ let mut bytes = Vec::new();
+ if stdout
+ .by_ref()
+ .take(maximum as u64 + 1)
+ .read_to_end(&mut bytes)
+ .is_err()
+ || bytes.len() > maximum
+ {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err("exact-tree Git output exceeded its bound".to_owned());
+ }
+ let status = child
+ .wait()
+ .map_err(|_| "exact-tree Git command could not finish".to_owned())?;
+ if status.success() {
+ Ok(bytes)
+ } else {
+ Err("exact-tree Git command failed".to_owned())
+ }
+}
+
+fn git_status(root: &Path, arguments: &[&str]) -> Result<(), ()> {
+ let status = Command::new("git")
+ .args(arguments)
+ .current_dir(root)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .map_err(|_| ())?;
+ if status.success() { Ok(()) } else { Err(()) }
+}
+
+#[cfg(unix)]
+fn set_mode(path: &Path) -> Result<(), String> {
+ use std::os::unix::fs::PermissionsExt as _;
+ fs::set_permissions(path, fs::Permissions::from_mode(ARCHIVE_MODE))
+ .map_err(|_| "exact-tree archive mode could not be set".to_owned())
+}
+
+#[cfg(not(unix))]
+fn set_mode(_path: &Path) -> Result<(), String> {
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{path::PathBuf, process::Command};
+
+ use tempfile::TempDir;
+
+ use super::*;
+
+ fn git(root: &Path, arguments: &[&str]) -> String {
+ let output = Command::new("git")
+ .args(arguments)
+ .current_dir(root)
+ .output()
+ .expect("run Git");
+ assert!(output.status.success(), "git {arguments:?}");
+ String::from_utf8(output.stdout)
+ .expect("Git UTF-8")
+ .trim()
+ .to_owned()
+ }
+
+ fn fixture() -> (TempDir, PathBuf, String) {
+ let fixture = TempDir::new().expect("fixture");
+ let root = fixture.path().canonicalize().expect("canonical fixture");
+ fs::create_dir(root.join("nested")).expect("nested");
+ fs::write(root.join("alpha.txt"), b"alpha\n").expect("alpha");
+ fs::write(root.join("nested/zeta.txt"), b"zeta\n").expect("zeta");
+ git(&root, &["init", "--quiet"]);
+ git(&root, &["config", "user.name", "Exact Tree Fixture"]);
+ git(&root, &["config", "user.email", "fixture@radroots.test"]);
+ git(&root, &["add", "."]);
+ let status = Command::new("git")
+ .args(["commit", "--quiet", "-m", "fixture"])
+ .current_dir(&root)
+ .env("GIT_AUTHOR_DATE", "@1700000000 +0000")
+ .env("GIT_COMMITTER_DATE", "@1700000000 +0000")
+ .status()
+ .expect("commit fixture");
+ assert!(status.success());
+ let revision = git(&root, &["rev-parse", "HEAD"]);
+ (fixture, root, revision)
+ }
+
+ #[test]
+ fn exact_tree_archive_is_reproducible_and_canonical() {
+ let (fixture, root, revision) = fixture();
+ let first = fixture.path().join("first.tar");
+ let second = fixture.path().join("second.tar");
+ let first_evidence = create(&root, &revision, &first, 1_700_000_000).expect("first");
+ let second_evidence = create(&root, &revision, &second, 1_700_000_000).expect("second");
+ assert_eq!(first_evidence, second_evidence);
+ assert_eq!(
+ fs::read(&first).expect("first"),
+ fs::read(&second).expect("second")
+ );
+ assert_eq!(first_evidence.members, 2);
+ assert_eq!(first_evidence.payload_bytes, 11);
+ assert_eq!(
+ commit_timestamp(&root, &revision).expect("timestamp"),
+ 1_700_000_000
+ );
+ }
+
+ #[test]
+ fn exact_tree_archive_rejects_links_and_noncanonical_requests() {
+ let (fixture, root, revision) = fixture();
+ assert!(create(&root, &revision, Path::new("relative.tar"), 1).is_err());
+ let output = fixture.path().join("zero.tar");
+ assert!(create(&root, &revision, &output, 0).is_err());
+
+ #[cfg(unix)]
+ {
+ std::os::unix::fs::symlink("alpha.txt", root.join("link")).expect("link");
+ git(&root, &["add", "link"]);
+ let status = Command::new("git")
+ .args(["commit", "--quiet", "-m", "link"])
+ .current_dir(&root)
+ .env("GIT_AUTHOR_DATE", "@1700000001 +0000")
+ .env("GIT_COMMITTER_DATE", "@1700000001 +0000")
+ .status()
+ .expect("commit link");
+ assert!(status.success());
+ let linked_revision = git(&root, &["rev-parse", "HEAD"]);
+ let linked_output = fixture.path().join("linked.tar");
+ assert!(create(&root, &linked_revision, &linked_output, 1).is_err());
+ }
+ }
+}
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -26,6 +26,7 @@ mod contract;
mod coverage;
#[cfg_attr(coverage_nightly, coverage(off))]
mod dto_roots;
+mod exact_tree_archive;
#[cfg_attr(coverage_nightly, coverage(off))]
mod fuzz_qualification;
#[cfg_attr(coverage_nightly, coverage(off))]
@@ -43,6 +44,7 @@ mod rshr_202_step_298_gate;
mod rshr_202_step_298_platform;
mod rshr_202_step_299_gate;
mod rshr_202_step_304_gate;
+mod rshr_202_step_305_gate;
mod safe_artifact_io;
#[cfg_attr(coverage_nightly, coverage(off))]
mod safety_qualification;
@@ -52,6 +54,7 @@ mod service_build_qualification;
mod service_release_artifacts;
mod service_source_lock;
mod service_source_lock_command;
+mod service_source_lock_v3;
#[cfg_attr(coverage_nightly, coverage(off))]
mod supply_chain_qualification;
#[cfg_attr(coverage_nightly, coverage(off))]
@@ -186,6 +189,23 @@ enum XtaskCommand {
#[arg(long)]
execution_request_sha256: String,
},
+ #[command(name = "rshr-step-305-gate", hide = true)]
+ RshrStep305Gate {
+ #[arg(long)]
+ step: u16,
+ #[arg(long)]
+ check_id: String,
+ #[arg(long)]
+ source_revision: String,
+ #[arg(long)]
+ source_tree: String,
+ #[arg(long)]
+ candidate_digest: String,
+ #[arg(long)]
+ platform: String,
+ #[arg(long)]
+ execution_request_sha256: String,
+ },
SourceLock {
#[arg(long)]
consumer_root: PathBuf,
@@ -204,6 +224,8 @@ enum XtaskCommand {
#[arg(long)]
service_root: PathBuf,
#[arg(long)]
+ lib_root: PathBuf,
+ #[arg(long)]
input_root: PathBuf,
#[arg(long)]
output_root: PathBuf,
@@ -211,6 +233,8 @@ enum XtaskCommand {
target: String,
#[arg(long)]
source_date_epoch: u32,
+ #[arg(long)]
+ candidate_digest: String,
},
Source {
#[command(subcommand)]
@@ -417,7 +441,7 @@ fn usage() {
" cargo xtask service-source-lock --mode <check|write> --service-root <absolute-directory> --source-archive <absolute-bundle>"
);
eprintln!(
- " cargo xtask service-release-artifacts --mode <check|write> --service-root <absolute-directory> --input-root <absolute-directory> --output-root <absolute-directory> --target <rust-target> --source-date-epoch <seconds>"
+ " cargo xtask service-release-artifacts --mode <check|write> --service-root <absolute-directory> --lib-root <absolute-directory> --input-root <absolute-directory> --output-root <absolute-directory> --target <rust-target> --source-date-epoch <seconds> --candidate-digest <sha256>"
);
eprintln!(
" cargo xtask source materialize --consumer-root <absolute-directory> --cache-root <absolute-directory> --mode <prefetch|offline>"
@@ -682,6 +706,23 @@ fn run(args: &[String]) -> Result<(), String> {
platform,
execution_request_sha256,
}),
+ XtaskCommand::RshrStep305Gate {
+ step,
+ check_id,
+ source_revision,
+ source_tree,
+ candidate_digest,
+ platform,
+ execution_request_sha256,
+ } => rshr_202_step_305_gate::run(rshr_202_step_305_gate::Arguments {
+ step,
+ check_id,
+ source_revision,
+ source_tree,
+ candidate_digest,
+ platform,
+ execution_request_sha256,
+ }),
XtaskCommand::SourceLock { consumer_root } => {
build_control::validate_consumer(&consumer_root).map(|_| ())
}
@@ -700,21 +741,25 @@ fn run(args: &[String]) -> Result<(), String> {
XtaskCommand::ServiceReleaseArtifacts {
mode,
service_root,
+ lib_root,
input_root,
output_root,
target,
source_date_epoch,
- } => service_release_artifacts::run(
- match mode {
+ candidate_digest,
+ } => service_release_artifacts::run(service_release_artifacts::Arguments {
+ mode: match mode {
ServiceReleaseArtifactMode::Check => service_release_artifacts::CommandMode::Check,
ServiceReleaseArtifactMode::Write => service_release_artifacts::CommandMode::Write,
},
- &service_root,
- &input_root,
- &output_root,
- &target,
+ service_root: &service_root,
+ lib_root: &lib_root,
+ input_root: &input_root,
+ output_root: &output_root,
+ target: &target,
source_date_epoch,
- ),
+ candidate_digest: &candidate_digest,
+ }),
XtaskCommand::Source { command } => match command {
SourceCommand::Materialize {
consumer_root,
@@ -900,6 +945,8 @@ mod tests {
"write",
"--service-root",
"/tmp/service",
+ "--lib-root",
+ "/tmp/lib",
"--input-root",
"/tmp/input",
"--output-root",
@@ -925,6 +972,8 @@ mod tests {
"x86_64-unknown-linux-gnu",
"--source-date-epoch",
"1",
+ "--candidate-digest",
+ "1111111111111111111111111111111111111111111111111111111111111111",
])
.is_ok()
);
diff --git a/tools/xtask/src/rshr_202_step_305_gate.rs b/tools/xtask/src/rshr_202_step_305_gate.rs
@@ -0,0 +1,288 @@
+use std::{
+ env, fs,
+ path::{Path, PathBuf},
+ process::{Command, Output},
+};
+
+use serde_json::{Value, json};
+use sha2::{Digest as _, Sha256};
+
+const STEP: u16 = 305;
+const GATE_DIGEST: &str = "29633a173a2b7ee52d97d7a51feca377c1c182879fb73b9f3cf7524046b35148";
+const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
+const EXACT_SOURCES: &[(&str, &str)] = &[
+ (
+ "Cargo.lock",
+ "a8edafae2d2b26465b2b99038ce55d80fa603481ebb5ebebbd810708ba4a894a",
+ ),
+ (
+ "Cargo.toml",
+ "322b975e60004df42de5b1b9460bf84255d2210e97f4f1cf1ede84a068b7519e",
+ ),
+ (
+ "contracts/architecture/decisions/services_hardening_release_artifacts.v4.json",
+ "6ed5bb06cf26565ac94d04f0b18a810e0a56372388e83e05c909c627da7ba7b4",
+ ),
+ (
+ "tools/xtask/Cargo.toml",
+ "bf5442895085225a571bf8aa45b2316e732a5c9925911de26147a924466f8ba0",
+ ),
+ (
+ "tools/xtask/src/artifact_admission.rs",
+ "87b538d5ca80dfbc3de232ded40320cc1b0ec555fa48056af79eb365cc74ea44",
+ ),
+ (
+ "tools/xtask/src/exact_tree_archive.rs",
+ "e176233167f99b783af4e041422eb619740a1d896138c77b51731400efb68ab2",
+ ),
+ (
+ "tools/xtask/src/main.rs",
+ "a4d543a4c690234203a88689ec7570aac0fcec53834ce6a7ef2970d83f34d216",
+ ),
+ (
+ "tools/xtask/src/safe_artifact_io.rs",
+ "3b361b22036ff8db5f5468e33341a7b2990d6f80aa6bdde4f791bd1320587e4d",
+ ),
+ (
+ "tools/xtask/src/service_release_artifacts.rs",
+ "52b5a014ce9ac58a0db849dfc105e7759732be5446b9cc16df4c80ceb303ec26",
+ ),
+ (
+ "tools/xtask/src/service_source_lock_v3.rs",
+ "8594897380149d864554a1cc0c463308a228091def7704215b8ca693fc61371c",
+ ),
+];
+
+pub(crate) struct Arguments {
+ pub(crate) step: u16,
+ pub(crate) check_id: String,
+ pub(crate) source_revision: String,
+ pub(crate) source_tree: String,
+ pub(crate) candidate_digest: String,
+ pub(crate) platform: String,
+ pub(crate) execution_request_sha256: String,
+}
+
+fn root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(Path::parent)
+ .expect("xtask must remain under tools/xtask")
+ .to_path_buf()
+}
+
+fn sha256(bytes: &[u8]) -> String {
+ hex::encode(Sha256::digest(bytes))
+}
+
+fn canonical(value: &Value) -> Result<Vec<u8>, String> {
+ serde_json::to_vec(value).map_err(|_| "Step 305 JSON encoding failed".to_owned())
+}
+
+fn bounded(command: &mut Command, label: &str) -> Result<Output, String> {
+ let output = command
+ .current_dir(root())
+ .env("CARGO_NET_OFFLINE", "true")
+ .env("CARGO_TERM_COLOR", "never")
+ .output()
+ .map_err(|_| format!("{label} could not start"))?;
+ if output.stdout.len() > MAX_OUTPUT_BYTES || output.stderr.len() > MAX_OUTPUT_BYTES {
+ return Err(format!("{label} exceeded its output bound"));
+ }
+ if output.status.success() {
+ Ok(output)
+ } else {
+ Err(format!("{label} failed"))
+ }
+}
+
+fn expected_contract(verifier_sha256: &str) -> Value {
+ json!({
+ "argv_template": [
+ "cargo", "extbuild", "run", "--", "cargo", "run", "--offline", "--locked",
+ "-q", "-p", "xtask", "--", "rshr-step-305-gate", "--step={step}",
+ "--check-id={check_id}", "--source-revision={source_revision}",
+ "--source-tree={source_tree}", "--candidate-digest={candidate_digest}",
+ "--platform=macos_aarch64", "--execution-request-sha256={execution_request_sha256}"
+ ],
+ "assertion_id": [format!("step_305_gate_01_{GATE_DIGEST}")],
+ "check_id": format!("gate-01-{GATE_DIGEST}"),
+ "environment_authority": {
+ "cache_policy_id": "rshr-200-step-287-cache-policy.v1",
+ "cache_policy_sha256": "3e81d178bce97b6c349dfbb00c68fd6f620ac00b1a1c8d37b12e9998f3c9eaaa",
+ "cadence_policy_id": "rshr-200-step-287-cadence-policy.v1",
+ "cadence_policy_sha256": "d24903df8659ee3772297c84994911efe7d21cb8b988320ddc6ddce0431892a1",
+ "isolation": "extbuild_host_constrained",
+ "network": "disabled",
+ "network_policy_id": "none",
+ "network_policy_sha256": "none",
+ "resource_policy_id": "rshr-200-step-287-resource-policy.v1",
+ "resource_policy_sha256": "05d3c7a89185d3c55678d97955193fce2ed92b1eee5af99083d77ea64c98d14e"
+ },
+ "environment_names": ["EXT_BUILD_CONFIG", "EXT_BUILD_MACHINE_CONFIG", "EXT_BUILD_ROOT", "HOME", "PATH", "RUSTUP_TOOLCHAIN", "TMPDIR"],
+ "gate_definition_sha256": GATE_DIGEST,
+ "required_platforms": ["macos_aarch64"],
+ "required_tools": ["git", "rustc"],
+ "result_schema": "radroots.services-hardening.rshr-200-step-check-result.v1",
+ "schema": "radroots.services-hardening.rshr-200-step-check-command.v1",
+ "step": STEP,
+ "verifier_path": "tools/xtask/src/rshr_202_step_305_gate.rs",
+ "verifier_sha256": verifier_sha256
+ })
+}
+
+pub(crate) fn run(arguments: Arguments) -> Result<(), String> {
+ let check_id = format!("gate-01-{GATE_DIGEST}");
+ if arguments.step != STEP
+ || arguments.check_id != check_id
+ || arguments.candidate_digest != "none"
+ || arguments.platform != "macos_aarch64"
+ || !valid_hex(&arguments.source_revision, 40)
+ || !valid_hex(&arguments.source_tree, 40)
+ || !valid_hex(&arguments.execution_request_sha256, 64)
+ {
+ return Err("Step 305 gate arguments differ".to_owned());
+ }
+ let root = root();
+ if root.join(".github").exists() {
+ return Err("forbidden .github surface is present".to_owned());
+ }
+ for (relative, expected) in EXACT_SOURCES {
+ let observed = sha256(
+ &fs::read(root.join(relative))
+ .map_err(|_| "Step 305 governed source is unreadable".to_owned())?,
+ );
+ if observed != *expected {
+ return Err(format!("Step 305 governed source bytes differ: {relative}"));
+ }
+ }
+ let verifier_path = root.join("tools/xtask/src/rshr_202_step_305_gate.rs");
+ let verifier_sha256 =
+ sha256(&fs::read(verifier_path).map_err(|_| "Step 305 verifier is unreadable".to_owned())?);
+ let authority_bytes = fs::read(root.join("contracts/rshr-202-step-305-gates.v1.json"))
+ .map_err(|_| "Step 305 gate authority is unreadable".to_owned())?;
+ let authority: Value = serde_json::from_slice(&authority_bytes)
+ .map_err(|_| "Step 305 gate authority is invalid".to_owned())?;
+ let mut canonical_authority = canonical(&authority)?;
+ canonical_authority.push(b'\n');
+ let contracts = authority
+ .get("gate_command_contract")
+ .and_then(Value::as_array)
+ .ok_or_else(|| "Step 305 gate contract is absent".to_owned())?;
+ if authority_bytes != canonical_authority
+ || authority.get("schema") != Some(&json!("radroots.lib.rshr-202-step-305-gates.v1"))
+ || authority.get("step") != Some(&json!([STEP]))
+ || contracts.as_slice() != [expected_contract(&verifier_sha256)]
+ {
+ return Err("Step 305 gate authority differs".to_owned());
+ }
+
+ for (arguments, label) in [
+ (
+ vec!["+1.97.1", "fmt", "--all", "--", "--check"],
+ "Step 305 formatting",
+ ),
+ (
+ vec!["+1.97.1", "check", "--offline", "--locked", "-p", "xtask"],
+ "Step 305 verifier check",
+ ),
+ (
+ vec![
+ "+1.97.1",
+ "test",
+ "--offline",
+ "--locked",
+ "-p",
+ "xtask",
+ "exact_tree_archive::tests",
+ ],
+ "Step 305 exact-tree archive tests",
+ ),
+ (
+ vec![
+ "+1.97.1",
+ "test",
+ "--offline",
+ "--locked",
+ "-p",
+ "xtask",
+ "service_source_lock_v3::tests",
+ ],
+ "Step 305 source-lock tests",
+ ),
+ (
+ vec![
+ "+1.97.1",
+ "test",
+ "--offline",
+ "--locked",
+ "-p",
+ "xtask",
+ "service_release_artifacts::tests",
+ ],
+ "Step 305 release-evidence and negative-vector tests",
+ ),
+ ] {
+ bounded(Command::new("cargo").args(arguments), label)?;
+ }
+ bounded(
+ Command::new("cargo").args([
+ "+1.97.1",
+ "clippy",
+ "--offline",
+ "--locked",
+ "-p",
+ "xtask",
+ "--all-targets",
+ "--",
+ "-D",
+ "warnings",
+ ]),
+ "Step 305 clippy",
+ )?;
+ bounded(
+ Command::new("cargo").args([
+ "+1.97.1",
+ "run",
+ "--offline",
+ "--locked",
+ "-q",
+ "-p",
+ "xtask",
+ "--",
+ "contract",
+ "validate",
+ ]),
+ "Step 305 contracts",
+ )?;
+
+ let contract = &contracts[0];
+ let assertion = json!([{ "id": format!("step_305_gate_01_{GATE_DIGEST}"), "result": "pass" }]);
+ let result = json!({
+ "schema": "radroots.services-hardening.rshr-200-step-check-result.v1",
+ "step": STEP,
+ "check_id": check_id,
+ "gate_definition_sha256": GATE_DIGEST,
+ "source_revision": arguments.source_revision,
+ "source_tree": arguments.source_tree,
+ "candidate_generation": 0,
+ "candidate_digest": "none",
+ "command_contract_sha256": sha256(&canonical(contract)?),
+ "verifier_sha256": verifier_sha256,
+ "execution_request": [{"platform": arguments.platform, "sha256": arguments.execution_request_sha256}],
+ "assertion_inventory_sha256": sha256(&canonical(&assertion)?),
+ "assertion": assertion,
+ "result": "pass"
+ });
+ let mut bytes = canonical(&result)?;
+ bytes.push(b'\n');
+ std::io::Write::write_all(&mut std::io::stdout().lock(), &bytes)
+ .map_err(|_| "Step 305 result write failed".to_owned())
+}
+
+fn valid_hex(value: &str, length: usize) -> bool {
+ value.len() == length
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
diff --git a/tools/xtask/src/service_release_artifacts.rs b/tools/xtask/src/service_release_artifacts.rs
@@ -14,42 +14,42 @@ use tar::{Builder as TarBuilder, Header as TarHeader};
use tempfile::TempDir;
use crate::safe_artifact_io::{TarGzipLimits, TraversalLimits};
-use crate::service_source_lock::{
- LIB_REPOSITORY, LOCK_FILENAME, NixMaterialState, PREDECESSOR_LOCK_FILENAME,
- ServiceSourceLockV2, validate_deferred_nix_material,
+use crate::service_source_lock::{LIB_REPOSITORY, validate_deferred_nix_material};
+use crate::service_source_lock_v3::{
+ LOCK_FILENAME, PREDECESSOR_LOCK_FILENAME, ServiceSourceLockV3,
};
-use crate::{artifact_admission, safe_artifact_io};
+use crate::{artifact_admission, exact_tree_archive, safe_artifact_io};
const CONTRACT_RELATIVE: &str =
- "contracts/architecture/decisions/services_hardening_release_artifacts.v3.json";
-const INPUT_NAMES: [&str; 8] = [
+ "contracts/architecture/decisions/services_hardening_release_artifacts.v4.json";
+const INPUT_NAMES: [&str; 6] = [
"config.example.toml",
"config.schema.json",
- "lib-source.bundle",
"nixos-module.nix",
"oci-image.tar.gz",
"service-binary",
- "service-source.bundle",
"systemd.service",
];
-const OUTPUT_NAMES: [&str; 18] = [
+const OUTPUT_NAMES: [&str; 20] = [
"LICENSE-APACHE",
"LICENSE-MIT",
"SHA256SUMS",
+ "THIRD-PARTY-LICENSES.txt",
"THIRD-PARTY-NOTICES.txt",
- "artifact-manifest.v1.json",
+ "artifact-manifest.v2.json",
+ "artifact-scan.v1.json",
"binary.tar.gz",
"config.example.toml",
"config.schema.json",
- "lib-source.bundle",
+ "lib-source.tar",
"nixos-module.nix",
"oci-image.tar.gz",
"oci-image.v1.json",
- "provenance-input.v1.json",
- "radroots.service.source-lock.v2.toml",
+ "provenance.intoto.jsonl",
+ "radroots.service.source-lock.v3.toml",
"sbom.cdx.json",
- "service-source.bundle",
- "source-bundles.v2.json",
+ "service-source.tar",
+ "source-archives.v3.json",
"systemd.service",
];
const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"];
@@ -70,7 +70,9 @@ const MAX_SOURCE_LOCK_BYTES: u64 = 4_096;
const MAX_SERVICE_CARGO_LOCK_BYTES: u64 = 16_777_216;
const MAX_SERVICE_FLAKE_LOCK_BYTES: u64 = 4_194_304;
const MAX_BINARY_BYTES: u64 = 536_870_912;
-const MAX_SOURCE_BUNDLE_BYTES: u64 = 1_073_741_824;
+const MAX_SOURCE_ARCHIVE_BYTES: u64 = 1_073_741_824;
+const MAX_SOURCE_ARCHIVE_MEMBER_BYTES: u64 = 67_108_864;
+const MAX_SOURCE_ARCHIVE_MEMBERS: u64 = 65_536;
const MAX_OCI_BYTES: u64 = 2_147_483_648;
const MAX_METADATA_BYTES: usize = 33_554_432;
const MAX_GIT_OUTPUT_BYTES: usize = 65_536;
@@ -82,6 +84,8 @@ const MAX_ARCHIVE_PATH_BYTES: usize = 4_096;
const MAX_RELEASE_TREE_BYTES: u64 = 68_719_476_736;
const FILE_MODE: u32 = 0o644;
const DIRECTORY_MODE: u32 = 0o755;
+const CANDIDATE_DIGEST_DOMAIN: &str = "sha256:";
+const SECRET_SCAN_OVERLAP_BYTES: usize = 131_072;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CommandMode {
@@ -174,7 +178,18 @@ struct CargoPackage {
source: Option<String>,
checksum: Option<String>,
license: Option<String>,
+ manifest_path: String,
+ license_file: Option<String>,
targets: Vec<CargoTarget>,
+ #[serde(skip)]
+ license_texts: Vec<DependencyLicenseText>,
+}
+
+#[derive(Clone, Debug)]
+struct DependencyLicenseText {
+ filename: String,
+ sha256: String,
+ text: String,
}
#[derive(Debug, Deserialize)]
@@ -213,10 +228,19 @@ struct SbomComponent {
bom_ref: String,
name: String,
version: String,
- purl: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ purl: Option<String>,
licenses: Vec<LicenseChoice>,
#[serde(skip_serializing_if = "Vec::is_empty")]
hashes: Vec<DigestValue>,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ properties: Vec<SbomProperty>,
+}
+
+#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
+struct SbomProperty {
+ name: String,
+ value: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
@@ -229,10 +253,20 @@ struct SbomDependency {
#[derive(Debug, Serialize)]
struct SbomMetadata {
component: SbomComponent,
+ properties: Vec<SbomProperty>,
+}
+
+#[derive(Debug, Serialize)]
+struct SbomComposition {
+ aggregate: &'static str,
+ assemblies: Vec<String>,
+ dependencies: Vec<String>,
}
#[derive(Debug, Serialize)]
struct CycloneDxSbom {
+ #[serde(rename = "$schema")]
+ json_schema: &'static str,
#[serde(rename = "bomFormat")]
bom_format: &'static str,
#[serde(rename = "specVersion")]
@@ -241,6 +275,7 @@ struct CycloneDxSbom {
metadata: SbomMetadata,
components: Vec<SbomComponent>,
dependencies: Vec<SbomDependency>,
+ compositions: Vec<SbomComposition>,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
@@ -260,9 +295,10 @@ struct ContractVersionsDocument {
}
#[derive(Debug, Serialize)]
-struct SourceBundleDocument {
+struct SourceArchiveDocument {
schema: &'static str,
contract_version: u32,
+ candidate_digest: String,
service: String,
service_revision: String,
lib_repository: &'static str,
@@ -272,11 +308,10 @@ struct SourceBundleDocument {
source_lock_sha256: String,
workspace_catalog_sha256: String,
cargo_lock_sha256: String,
- nix_material: &'static str,
- #[serde(skip_serializing_if = "Option::is_none")]
- nix_lib_revision: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- flake_lock_sha256: Option<String>,
+ flake_lock_sha256: String,
+ format: &'static str,
+ compression: &'static str,
+ git_history: &'static str,
}
#[derive(Debug, Serialize)]
@@ -293,6 +328,7 @@ struct OciImageDocument {
struct ArtifactManifestDocument {
schema: &'static str,
contract_version: u32,
+ candidate_digest: String,
service: String,
version: String,
target: String,
@@ -302,29 +338,94 @@ struct ArtifactManifestDocument {
rust_version: &'static str,
host_feature_profile: &'static str,
contract_versions: ContractVersionsDocument,
- protected_material_included: bool,
+ confidentiality: ConfidentialityDocument,
artifacts: Vec<ArtifactRecord>,
}
#[derive(Debug, Serialize)]
-struct ProvenanceInputDocument {
+struct ConfidentialityDocument {
+ state: &'static str,
+ derived_from: ArtifactRecord,
+ protected_material_included: bool,
+}
+
+#[derive(Debug, Serialize)]
+struct ScanDocument {
schema: &'static str,
contract_version: u32,
+ candidate_digest: String,
+ state: &'static str,
+ ruleset_sha256: String,
+ scanned_artifacts: Vec<ArtifactRecord>,
+ nested_members_scanned: u64,
+ expanded_bytes_scanned: u64,
+}
+
+#[derive(Debug, Serialize)]
+struct InTotoStatement {
+ #[serde(rename = "_type")]
+ statement_type: &'static str,
+ subject: Vec<InTotoSubject>,
+ #[serde(rename = "predicateType")]
predicate_type: &'static str,
+ predicate: SlsaPredicate,
+}
+
+#[derive(Debug, Serialize)]
+struct InTotoSubject {
+ name: String,
+ digest: BTreeMap<&'static str, String>,
+}
+
+#[derive(Debug, Serialize)]
+struct SlsaPredicate {
+ #[serde(rename = "buildDefinition")]
+ build_definition: SlsaBuildDefinition,
+ #[serde(rename = "runDetails")]
+ run_details: SlsaRunDetails,
+}
+
+#[derive(Debug, Serialize)]
+struct SlsaBuildDefinition {
+ #[serde(rename = "buildType")]
build_type: &'static str,
- builder_id: &'static str,
+ #[serde(rename = "externalParameters")]
+ external_parameters: SlsaExternalParameters,
+ #[serde(rename = "internalParameters")]
+ internal_parameters: BTreeMap<String, String>,
+ #[serde(rename = "resolvedDependencies")]
+ resolved_dependencies: Vec<SlsaResolvedDependency>,
+}
+
+#[derive(Debug, Serialize)]
+struct SlsaExternalParameters {
+ candidate_digest: String,
service: String,
- version: String,
target: String,
source_date_epoch: u32,
- service_repository: String,
- service_revision: String,
- lib_repository: &'static str,
- lib_revision: String,
- source_lock_sha256: String,
- manifest_sha256: String,
- subjects: Vec<ArtifactRecord>,
- signing_required: bool,
+}
+
+#[derive(Debug, Serialize)]
+struct SlsaResolvedDependency {
+ uri: String,
+ digest: BTreeMap<&'static str, String>,
+}
+
+#[derive(Debug, Serialize)]
+struct SlsaRunDetails {
+ builder: SlsaBuilder,
+ metadata: SlsaRunMetadata,
+}
+
+#[derive(Debug, Serialize)]
+struct SlsaBuilder {
+ id: &'static str,
+}
+
+#[derive(Debug, Serialize)]
+struct SlsaRunMetadata {
+ #[serde(rename = "invocationId")]
+ invocation_id: String,
}
#[derive(Clone, Debug)]
@@ -339,6 +440,7 @@ struct ReleaseDecision {
schema: String,
contract_version: u32,
decision_state: String,
+ owner_step: u32,
predecessor: ReleasePredecessor,
command: String,
modes: Vec<String>,
@@ -346,8 +448,13 @@ struct ReleaseDecision {
service_metadata_path: String,
service_metadata_fields: Vec<String>,
service_license_path: String,
+ source_lock_schema: String,
+ source_lock_definition: String,
+ artifact_contract_binding: String,
artifact_admission_contract: String,
supported_targets: Vec<String>,
+ candidate_binding: String,
+ lib_root_binding: String,
binary_admission: String,
oci_admission: String,
input_inventory: Vec<String>,
@@ -356,13 +463,17 @@ struct ReleaseDecision {
output_inventory: Vec<String>,
canonical_json: String,
checksum_format: String,
+ source_archive_format: String,
sbom_format: String,
+ license_evidence: String,
provenance_posture: String,
protected_material_scan_scope: String,
+ confidentiality_state: String,
source_cleanliness: String,
revision_stability: String,
no_protected_material: bool,
maximums: ReleaseMaximums,
+ required_negative_vectors: Vec<String>,
negative_error_codes: Vec<String>,
}
@@ -382,44 +493,56 @@ struct ReleaseMaximums {
service_cargo_lock_bytes: u64,
service_flake_lock_bytes: u64,
binary_bytes: u64,
- source_bundle_bytes: u64,
+ source_archive_bytes: u64,
+ source_archive_member_bytes: u64,
+ source_archive_members: u64,
oci_bytes: u64,
+ artifact_scan_expanded_bytes: u64,
cargo_metadata_bytes: usize,
packages: usize,
workspace_packages: usize,
}
-pub(crate) fn run(
- mode: CommandMode,
- service_root: &Path,
- input_root: &Path,
- output_root: &Path,
- target: &str,
- source_date_epoch: u32,
-) -> Result<(), String> {
- run_inner(
+#[derive(Clone, Copy)]
+pub(crate) struct Arguments<'a> {
+ pub(crate) mode: CommandMode,
+ pub(crate) service_root: &'a Path,
+ pub(crate) lib_root: &'a Path,
+ pub(crate) input_root: &'a Path,
+ pub(crate) output_root: &'a Path,
+ pub(crate) target: &'a str,
+ pub(crate) source_date_epoch: u32,
+ pub(crate) candidate_digest: &'a str,
+}
+
+pub(crate) fn run(arguments: Arguments<'_>) -> Result<(), String> {
+ run_inner(arguments).map_err(|error| error.to_string())
+}
+
+fn run_inner(arguments: Arguments<'_>) -> Result<(), ReleaseArtifactError> {
+ let Arguments {
mode,
service_root,
+ lib_root,
input_root,
output_root,
target,
source_date_epoch,
- )
- .map_err(|error| error.to_string())
-}
-
-fn run_inner(
- mode: CommandMode,
- service_root: &Path,
- input_root: &Path,
- output_root: &Path,
- target: &str,
- source_date_epoch: u32,
-) -> Result<(), ReleaseArtifactError> {
- if !SUPPORTED_TARGETS.contains(&target) || source_date_epoch == 0 {
+ candidate_digest,
+ } = arguments;
+ if !SUPPORTED_TARGETS.contains(&target)
+ || source_date_epoch == 0
+ || !valid_lower_hex(candidate_digest, 64)
+ {
return Err(ReleaseArtifactError::InvalidServiceMetadata);
}
let service_root = validate_git_root(service_root)?;
+ let lib_root = validate_git_root(lib_root)?;
+ if git_remote(&lib_root)? != "ssh://git@github.com/radrootslabs/lib.git"
+ && git_remote(&lib_root)? != LIB_REPOSITORY
+ {
+ return Err(ReleaseArtifactError::InvalidServiceRoot);
+ }
let (input_root, input_snapshot) = validate_exact_input_root(input_root)?;
let (output_parent, output_root) =
validate_output_parent(output_root, &service_root, &input_root)?;
@@ -432,14 +555,14 @@ fn run_inner(
MAX_SOURCE_LOCK_BYTES,
ReleaseArtifactError::InvalidSourceLock,
)?;
- let source_lock = ServiceSourceLockV2::from_canonical_bytes(&source_lock_bytes)
+ let source_lock = ServiceSourceLockV3::from_canonical_bytes(&source_lock_bytes)
.map_err(|_| ReleaseArtifactError::InvalidSourceLock)?;
if source_lock.service() != metadata.service {
return Err(ReleaseArtifactError::InvalidSourceLock);
}
validate_source_lock_files(&service_root, &source_lock)?;
let cargo_metadata = cargo_metadata(&service_root)?;
- let (sbom, notices) = build_supply_chain_documents(&metadata, cargo_metadata)?;
+ let (sbom, notices, license_texts) = build_supply_chain_documents(&metadata, cargo_metadata)?;
let staging = tempfile::Builder::new()
.prefix(".radroots-service-release-")
@@ -510,29 +633,38 @@ fn run_inner(
},
)
.map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
- let service_source = copy_snapshot_file(
- &input_snapshot,
- "service-source.bundle",
- &staging.path().join("service-source.bundle"),
- MAX_SOURCE_BUNDLE_BYTES,
- )?;
- let lib_source = copy_snapshot_file(
- &input_snapshot,
- "lib-source.bundle",
- &staging.path().join("lib-source.bundle"),
- MAX_SOURCE_BUNDLE_BYTES,
- )?;
input_snapshot
.revalidate()
.map_err(|_| ReleaseArtifactError::InvalidInputRoot)?;
- verify_bundle(&staging.path().join("service-source.bundle"), &initial_head)?;
- verify_bundle(
- &staging.path().join("lib-source.bundle"),
+ let service_source = exact_tree_archive::create(
+ &service_root,
+ &initial_head,
+ &staging.path().join("service-source.tar"),
+ u64::from(source_date_epoch),
+ )
+ .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?;
+ let lib_mtime = exact_tree_archive::commit_timestamp(&lib_root, source_lock.revision())
+ .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?;
+ let lib_source = exact_tree_archive::create(
+ &lib_root,
source_lock.revision(),
- )?;
+ &staging.path().join("lib-source.tar"),
+ lib_mtime,
+ )
+ .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?;
if lib_source.sha256 != source_lock.source_archive_sha256() {
return Err(ReleaseArtifactError::InvalidSourceBundle);
}
+ let catalog = exact_tree_archive::read_blob(
+ &lib_root,
+ source_lock.revision(),
+ "contracts/crates/catalog.v2.toml",
+ MAX_TEXT_INPUT_BYTES as usize,
+ )
+ .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?;
+ if sha256_bytes(&catalog) != source_lock.workspace_catalog_sha256() {
+ return Err(ReleaseArtifactError::InvalidSourceBundle);
+ }
let oci_document = OciImageDocument {
schema: "radroots.service.oci-image.v1",
@@ -544,40 +676,67 @@ fn run_inner(
};
write_json(&staging.path().join("oci-image.v1.json"), &oci_document)?;
let source_lock_sha256 = sha256_bytes(&source_lock_bytes);
- let source_document = SourceBundleDocument {
- schema: "radroots.service.source-bundles.v2",
- contract_version: 2,
+ let source_document = SourceArchiveDocument {
+ schema: "radroots.service.source-archives.v3",
+ contract_version: 3,
+ candidate_digest: candidate_digest.to_owned(),
service: metadata.service.clone(),
service_revision: initial_head.clone(),
lib_repository: LIB_REPOSITORY,
lib_revision: source_lock.revision().to_owned(),
- service_source: artifact_record("service-source.bundle", &service_source),
- lib_source: artifact_record("lib-source.bundle", &lib_source),
+ service_source: artifact_record_from_exact_tree("service-source.tar", &service_source),
+ lib_source: artifact_record_from_exact_tree("lib-source.tar", &lib_source),
source_lock_sha256: source_lock_sha256.clone(),
workspace_catalog_sha256: source_lock.workspace_catalog_sha256().to_owned(),
cargo_lock_sha256: source_lock.cargo_lock_sha256().to_owned(),
- nix_material: match source_lock.nix_material_state() {
- NixMaterialState::Absent => "absent",
- NixMaterialState::Deferred => "deferred",
- },
- nix_lib_revision: source_lock.nix_lib_revision().map(str::to_owned),
- flake_lock_sha256: source_lock.flake_lock_sha256().map(str::to_owned),
+ flake_lock_sha256: source_lock.flake_lock_sha256().to_owned(),
+ format: "ustar",
+ compression: "none",
+ git_history: "forbidden",
};
write_json(
- &staging.path().join("source-bundles.v2.json"),
+ &staging.path().join("source-archives.v3.json"),
&source_document,
)?;
- write_json(&staging.path().join("sbom.cdx.json"), &sbom)?;
write_generated(
&staging.path().join("THIRD-PARTY-NOTICES.txt"),
notices.as_bytes(),
)?;
+ write_generated(
+ &staging.path().join("THIRD-PARTY-LICENSES.txt"),
+ license_texts.as_bytes(),
+ )?;
+
+ let mut sbom = sbom;
+ let sbom_artifacts = inventory_records(staging.path())?;
+ reconcile_sbom_artifacts(&mut sbom, &sbom_artifacts);
+ validate_cyclonedx_profile(&sbom, &sbom_artifacts)?;
+ write_json(&staging.path().join("sbom.cdx.json"), &sbom)?;
+ let scanned_artifacts = inventory_records(staging.path())?;
+ let (nested_members_scanned, expanded_bytes_scanned) =
+ scan_artifact_inventory(staging.path(), &scanned_artifacts)?;
+ let scan = ScanDocument {
+ schema: "radroots.service.artifact-scan.v1",
+ contract_version: 1,
+ candidate_digest: candidate_digest.to_owned(),
+ state: "no_protected_material_detected",
+ ruleset_sha256: scanner_ruleset_sha256(),
+ scanned_artifacts,
+ nested_members_scanned,
+ expanded_bytes_scanned,
+ };
+ write_json(&staging.path().join("artifact-scan.v1.json"), &scan)?;
+ let scan_evidence = hash_regular(
+ &staging.path().join("artifact-scan.v1.json"),
+ MAX_GENERATED_DOCUMENT_BYTES,
+ )?;
let payload = inventory_records(staging.path())?;
let versions = source_lock.contract_versions();
let manifest = ArtifactManifestDocument {
- schema: "radroots.service.release-artifacts.v1",
- contract_version: 1,
+ schema: "radroots.service.release-artifacts.v2",
+ contract_version: 2,
+ candidate_digest: candidate_digest.to_owned(),
service: metadata.service.clone(),
version: metadata.version.clone(),
target: target.to_owned(),
@@ -593,38 +752,36 @@ fn run_inner(
status: versions.status(),
provider: versions.provider(),
},
- protected_material_included: false,
- artifacts: payload,
+ confidentiality: ConfidentialityDocument {
+ state: scan.state,
+ derived_from: artifact_record("artifact-scan.v1.json", &scan_evidence),
+ protected_material_included: false,
+ },
+ artifacts: payload.clone(),
};
- write_json(&staging.path().join("artifact-manifest.v1.json"), &manifest)?;
+ validate_confidentiality_binding(&manifest, &scan, &scan_evidence, &payload)?;
+ write_json(&staging.path().join("artifact-manifest.v2.json"), &manifest)?;
let manifest_evidence = hash_regular(
- &staging.path().join("artifact-manifest.v1.json"),
+ &staging.path().join("artifact-manifest.v2.json"),
MAX_TEXT_INPUT_BYTES,
)?;
let service_repository = git_remote(&service_root)?;
- let provenance = ProvenanceInputDocument {
- schema: "radroots.service.provenance-input.v1",
- contract_version: 1,
- predicate_type: "https://slsa.dev/provenance/v1",
- build_type: "https://radroots.dev/contracts/service-release-artifacts/v1",
- builder_id: "https://radroots.dev/builders/service-release-artifacts/v1",
- service: metadata.service,
- version: metadata.version,
- target: target.to_owned(),
- source_date_epoch,
- service_repository,
- service_revision: initial_head.clone(),
- lib_repository: LIB_REPOSITORY,
- lib_revision: source_lock.revision().to_owned(),
- source_lock_sha256,
- manifest_sha256: manifest_evidence.sha256,
- subjects: inventory_records(staging.path())?,
- signing_required: true,
- };
- write_json(
- &staging.path().join("provenance-input.v1.json"),
- &provenance,
- )?;
+ let provenance = build_provenance(
+ ProvenanceInput {
+ candidate_digest,
+ service: &metadata.service,
+ target,
+ source_date_epoch,
+ service_repository: &service_repository,
+ service_revision: &initial_head,
+ lib_revision: source_lock.revision(),
+ source_lock_sha256: &source_lock_sha256,
+ manifest_sha256: &manifest_evidence.sha256,
+ },
+ &payload,
+ );
+ validate_provenance_subjects(&provenance, candidate_digest, &payload)?;
+ write_json(&staging.path().join("provenance.intoto.jsonl"), &provenance)?;
write_checksums(staging.path())?;
validate_exact_output_inventory(staging.path())?;
sync_directory(staging.path())?;
@@ -648,6 +805,24 @@ fn run_inner(
validate_output_records(&output_root, &expected_output)
}
+fn validate_confidentiality_binding(
+ manifest: &ArtifactManifestDocument,
+ scan: &ScanDocument,
+ scan_evidence: &FileEvidence,
+ payload: &[ArtifactRecord],
+) -> Result<(), ReleaseArtifactError> {
+ if manifest.candidate_digest != scan.candidate_digest
+ || manifest.confidentiality.state != scan.state
+ || manifest.confidentiality.protected_material_included
+ || manifest.confidentiality.derived_from.path != "artifact-scan.v1.json"
+ || manifest.confidentiality.derived_from.sha256 != scan_evidence.sha256
+ || manifest.artifacts != payload
+ {
+ return Err(ReleaseArtifactError::GenerationFailure);
+ }
+ Ok(())
+}
+
fn read_release_metadata(root: &Path) -> Result<ReleaseMetadata, ReleaseArtifactError> {
let bytes = read_bounded_regular(
&root.join("Cargo.toml"),
@@ -693,48 +868,46 @@ fn read_release_metadata(root: &Path) -> Result<ReleaseMetadata, ReleaseArtifact
fn validate_source_lock_files(
root: &Path,
- source_lock: &ServiceSourceLockV2,
+ source_lock: &ServiceSourceLockV3,
) -> Result<(), ReleaseArtifactError> {
- match fs::symlink_metadata(root.join(PREDECESSOR_LOCK_FILENAME)) {
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
- _ => return Err(ReleaseArtifactError::InvalidSourceLock),
+ for name in [
+ PREDECESSOR_LOCK_FILENAME,
+ "radroots.service.source-lock.v1.toml",
+ ] {
+ match fs::symlink_metadata(root.join(name)) {
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ _ => return Err(ReleaseArtifactError::InvalidSourceLock),
+ }
}
let cargo_lock = hash_regular(&root.join("Cargo.lock"), MAX_SERVICE_CARGO_LOCK_BYTES)
.map_err(|_| ReleaseArtifactError::InvalidSourceLock)?;
if cargo_lock.sha256 != source_lock.cargo_lock_sha256() {
return Err(ReleaseArtifactError::InvalidSourceLock);
}
- match source_lock.nix_material_state() {
- NixMaterialState::Absent => {
- for name in ["flake.nix", "flake.lock"] {
- match fs::symlink_metadata(root.join(name)) {
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
- _ => return Err(ReleaseArtifactError::InvalidSourceLock),
- }
- }
- Ok(())
- }
- NixMaterialState::Deferred => {
- let flake_nix = read_bounded_regular(
- &root.join("flake.nix"),
- MAX_TEXT_INPUT_BYTES,
- ReleaseArtifactError::InvalidSourceLock,
- )?;
- let flake_lock = read_bounded_regular(
- &root.join("flake.lock"),
- MAX_SERVICE_FLAKE_LOCK_BYTES,
- ReleaseArtifactError::InvalidSourceLock,
- )?;
- let evidence = validate_deferred_nix_material(&flake_nix, &flake_lock)
- .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?;
- if Some(evidence.lib_revision()) == source_lock.nix_lib_revision()
- && Some(evidence.flake_lock_sha256()) == source_lock.flake_lock_sha256()
- {
- Ok(())
- } else {
- Err(ReleaseArtifactError::InvalidSourceLock)
- }
- }
+ let flake_nix = read_bounded_regular(
+ &root.join("flake.nix"),
+ MAX_TEXT_INPUT_BYTES,
+ ReleaseArtifactError::InvalidSourceLock,
+ )?;
+ let flake_lock = read_bounded_regular(
+ &root.join("flake.lock"),
+ MAX_SERVICE_FLAKE_LOCK_BYTES,
+ ReleaseArtifactError::InvalidSourceLock,
+ )?;
+ let evidence = validate_deferred_nix_material(&flake_nix, &flake_lock)
+ .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?;
+ let artifact_contract = hash_regular(
+ &root.join(source_lock.artifact_contract_path()),
+ MAX_TEXT_INPUT_BYTES,
+ )
+ .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?;
+ if evidence.lib_revision() == source_lock.revision()
+ && evidence.flake_lock_sha256() == source_lock.flake_lock_sha256()
+ && artifact_contract.sha256 == source_lock.artifact_contract_sha256()
+ {
+ Ok(())
+ } else {
+ Err(ReleaseArtifactError::InvalidSourceLock)
}
}
@@ -745,13 +918,108 @@ fn cargo_metadata(root: &Path) -> Result<CargoMetadata, ReleaseArtifactError> {
.current_dir(root);
let bytes = command_stdout(&mut command, MAX_METADATA_BYTES)
.map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?;
- serde_json::from_slice(&bytes).map_err(|_| ReleaseArtifactError::InvalidPackageInventory)
+ let mut metadata = serde_json::from_slice::<CargoMetadata>(&bytes)
+ .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?;
+ for package in &mut metadata.packages {
+ if package.source.is_some() {
+ package.license_texts = dependency_license_texts(package)?;
+ }
+ }
+ Ok(metadata)
+}
+
+fn dependency_license_texts(
+ package: &CargoPackage,
+) -> Result<Vec<DependencyLicenseText>, ReleaseArtifactError> {
+ let manifest = Path::new(&package.manifest_path);
+ if !manifest.is_absolute() || manifest.file_name() != Some(OsStr::new("Cargo.toml")) {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ let package_root = manifest
+ .parent()
+ .ok_or(ReleaseArtifactError::InvalidPackageInventory)?
+ .canonicalize()
+ .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?;
+ let mut candidates = BTreeSet::new();
+ if let Some(license_file) = package.license_file.as_deref() {
+ let relative = Path::new(license_file);
+ if relative.is_absolute()
+ || relative
+ .components()
+ .any(|component| !matches!(component, Component::Normal(_)))
+ {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ candidates.insert(relative.to_path_buf());
+ } else {
+ let entries = fs::read_dir(&package_root)
+ .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?;
+ for (index, entry) in entries.enumerate() {
+ if index >= 256 {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ let entry = entry.map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?;
+ let filename = entry
+ .file_name()
+ .into_string()
+ .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?;
+ let uppercase = filename.to_ascii_uppercase();
+ if ["LICENSE", "COPYING", "COPYRIGHT", "NOTICE"]
+ .iter()
+ .any(|prefix| {
+ uppercase == *prefix
+ || uppercase.starts_with(&format!("{prefix}-"))
+ || uppercase.starts_with(&format!("{prefix}."))
+ })
+ {
+ candidates.insert(PathBuf::from(filename));
+ }
+ }
+ }
+ if candidates.is_empty() || candidates.len() > 16 {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ let mut texts = Vec::with_capacity(candidates.len());
+ let mut total = 0_u64;
+ for relative in candidates {
+ let path = package_root.join(&relative);
+ let canonical = path
+ .canonicalize()
+ .map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?;
+ if canonical.parent() != Some(package_root.as_path()) {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ let bytes = read_bounded_regular(
+ &canonical,
+ MAX_TEXT_INPUT_BYTES,
+ ReleaseArtifactError::InvalidPackageInventory,
+ )?;
+ total = total
+ .checked_add(bytes.len() as u64)
+ .filter(|total| *total <= MAX_GENERATED_DOCUMENT_BYTES)
+ .ok_or(ReleaseArtifactError::InvalidPackageInventory)?;
+ scan_bytes(&bytes)?;
+ let text =
+ String::from_utf8(bytes).map_err(|_| ReleaseArtifactError::InvalidPackageInventory)?;
+ if text.trim().is_empty() {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ texts.push(DependencyLicenseText {
+ filename: relative
+ .to_str()
+ .ok_or(ReleaseArtifactError::InvalidPackageInventory)?
+ .to_owned(),
+ sha256: sha256_bytes(text.as_bytes()),
+ text,
+ });
+ }
+ Ok(texts)
}
fn build_supply_chain_documents(
metadata: &ReleaseMetadata,
cargo: CargoMetadata,
-) -> Result<(CycloneDxSbom, String), ReleaseArtifactError> {
+) -> Result<(CycloneDxSbom, String, String), ReleaseArtifactError> {
if cargo.packages.is_empty()
|| cargo.packages.len() > MAX_PACKAGES
|| cargo.workspace_members.is_empty()
@@ -832,14 +1100,20 @@ fn build_supply_chain_documents(
}
dependencies.sort_by(|left, right| left.reference.cmp(&right.reference));
let sbom = CycloneDxSbom {
+ json_schema: "https://cyclonedx.org/schema/bom-1.6.schema.json",
bom_format: "CycloneDX",
- spec_version: "1.5",
+ spec_version: "1.6",
version: 1,
metadata: SbomMetadata {
component: root_component,
+ properties: vec![SbomProperty {
+ name: "radroots:evidence:dependency-closure".to_owned(),
+ value: "complete".to_owned(),
+ }],
},
components,
dependencies,
+ compositions: Vec::new(),
};
let mut third_party = by_id
@@ -852,8 +1126,12 @@ fn build_supply_chain_documents(
let mut notices = String::from(
"Radroots service third-party notices v1\n\nThis inventory is generated from the locked Cargo dependency graph.\n",
);
+ let mut licenses = String::from(
+ "Radroots service third-party license texts v1\n\nThis file contains the exact bounded license texts admitted for each locked third-party Cargo package.\n",
+ );
if third_party.is_empty() {
notices.push_str("\nNo third-party Cargo packages are present.\n");
+ licenses.push_str("\nNo third-party Cargo packages are present.\n");
} else {
for package in third_party {
let license = package
@@ -872,10 +1150,538 @@ fn build_supply_chain_documents(
.map_err(|_| ReleaseArtifactError::GenerationFailure)?;
writeln!(notices, "Source: {source}")
.map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ if package.license_texts.is_empty() {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ for license_text in &package.license_texts {
+ if !valid_output_component(&license_text.filename)
+ || license_text.text.trim().is_empty()
+ || license_text.sha256 != sha256_bytes(license_text.text.as_bytes())
+ {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ writeln!(
+ notices,
+ "License-Text: {} sha256:{}",
+ license_text.filename, license_text.sha256
+ )
+ .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ writeln!(licenses).map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ writeln!(
+ licenses,
+ "===== {} {} / {} / sha256:{} =====",
+ package.name, package.version, license_text.filename, license_text.sha256
+ )
+ .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ licenses.push_str(&license_text.text);
+ if !license_text.text.ends_with('\n') {
+ licenses.push('\n');
+ }
+ }
}
}
scan_bytes(notices.as_bytes())?;
- Ok((sbom, notices))
+ scan_bytes(licenses.as_bytes())?;
+ if licenses.len() as u64 > MAX_GENERATED_DOCUMENT_BYTES {
+ return Err(ReleaseArtifactError::InvalidPackageInventory);
+ }
+ Ok((sbom, notices, licenses))
+}
+
+fn reconcile_sbom_artifacts(sbom: &mut CycloneDxSbom, artifacts: &[ArtifactRecord]) {
+ let root_reference = sbom.metadata.component.bom_ref.clone();
+ let mut assemblies = Vec::with_capacity(artifacts.len());
+ for artifact in artifacts {
+ let reference = format!("artifact:{}#{}", artifact.path, artifact.sha256);
+ assemblies.push(reference.clone());
+ sbom.components.push(SbomComponent {
+ component_type: "file",
+ bom_ref: reference.clone(),
+ name: artifact.path.clone(),
+ version: artifact.sha256.clone(),
+ purl: None,
+ licenses: sbom.metadata.component.licenses.clone(),
+ hashes: vec![DigestValue {
+ alg: "SHA-256",
+ content: artifact.sha256.clone(),
+ }],
+ properties: vec![
+ SbomProperty {
+ name: "radroots:artifact:byte-length".to_owned(),
+ value: artifact.byte_length.to_string(),
+ },
+ SbomProperty {
+ name: "radroots:ecosystem".to_owned(),
+ value: artifact_ecosystem(&artifact.path).to_owned(),
+ },
+ ],
+ });
+ sbom.dependencies.push(SbomDependency {
+ reference,
+ depends_on: vec![root_reference.clone()],
+ });
+ }
+ sbom.components.sort();
+ sbom.dependencies
+ .sort_by(|left, right| left.reference.cmp(&right.reference));
+ assemblies.sort();
+ let mut dependencies =
+ sbom.components
+ .iter()
+ .filter(|component| {
+ component.properties.iter().any(|property| {
+ property.name == "radroots:ecosystem" && property.value == "cargo"
+ })
+ })
+ .map(|component| component.bom_ref.clone())
+ .collect::<Vec<_>>();
+ dependencies.push(root_reference);
+ dependencies.sort();
+ dependencies.dedup();
+ sbom.compositions = vec![SbomComposition {
+ aggregate: "complete",
+ assemblies,
+ dependencies,
+ }];
+}
+
+fn validate_cyclonedx_profile(
+ sbom: &CycloneDxSbom,
+ artifacts: &[ArtifactRecord],
+) -> Result<(), ReleaseArtifactError> {
+ let schema = serde_json::json!({
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["$schema", "bomFormat", "specVersion", "version", "metadata", "components", "dependencies", "compositions"],
+ "properties": {
+ "$schema": {"const": "https://cyclonedx.org/schema/bom-1.6.schema.json"},
+ "bomFormat": {"const": "CycloneDX"},
+ "specVersion": {"const": "1.6"},
+ "version": {"const": 1},
+ "metadata": {"type": "object"},
+ "components": {"type": "array", "minItems": 1},
+ "dependencies": {"type": "array", "minItems": 1},
+ "compositions": {"type": "array", "minItems": 1, "maxItems": 1}
+ }
+ });
+ let validator =
+ jsonschema::validator_for(&schema).map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ let value = serde_json::to_value(sbom).map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ validator
+ .validate(&value)
+ .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+
+ let component_references = sbom
+ .components
+ .iter()
+ .map(|component| component.bom_ref.as_str())
+ .chain(std::iter::once(sbom.metadata.component.bom_ref.as_str()))
+ .collect::<BTreeSet<_>>();
+ if component_references.len() != sbom.components.len() + 1
+ || sbom.dependencies.len() != component_references.len()
+ || sbom.dependencies.iter().any(|dependency| {
+ !component_references.contains(dependency.reference.as_str())
+ || dependency
+ .depends_on
+ .iter()
+ .any(|reference| !component_references.contains(reference.as_str()))
+ })
+ {
+ return Err(ReleaseArtifactError::GenerationFailure);
+ }
+ let composition = sbom
+ .compositions
+ .first()
+ .ok_or(ReleaseArtifactError::GenerationFailure)?;
+ let expected_artifacts = artifacts
+ .iter()
+ .map(|artifact| format!("artifact:{}#{}", artifact.path, artifact.sha256))
+ .collect::<BTreeSet<_>>();
+ if composition.aggregate != "complete"
+ || composition.assemblies.iter().collect::<BTreeSet<_>>()
+ != expected_artifacts.iter().collect::<BTreeSet<_>>()
+ || artifacts.iter().any(|artifact| {
+ sbom.components
+ .iter()
+ .filter(|component| {
+ component.bom_ref == format!("artifact:{}#{}", artifact.path, artifact.sha256)
+ && component.hashes
+ == [DigestValue {
+ alg: "SHA-256",
+ content: artifact.sha256.clone(),
+ }]
+ })
+ .count()
+ != 1
+ })
+ {
+ return Err(ReleaseArtifactError::GenerationFailure);
+ }
+ Ok(())
+}
+
+fn validate_provenance_subjects(
+ provenance: &InTotoStatement,
+ candidate_digest: &str,
+ artifacts: &[ArtifactRecord],
+) -> Result<(), ReleaseArtifactError> {
+ let expected = artifacts
+ .iter()
+ .map(|artifact| (artifact.path.as_str(), artifact.sha256.as_str()))
+ .collect::<BTreeSet<_>>();
+ let observed = provenance
+ .subject
+ .iter()
+ .filter_map(|subject| {
+ subject
+ .digest
+ .get("sha256")
+ .map(|digest| (subject.name.as_str(), digest.as_str()))
+ })
+ .collect::<BTreeSet<_>>();
+ if provenance.statement_type != "https://in-toto.io/Statement/v1"
+ || provenance.predicate_type != "https://slsa.dev/provenance/v1"
+ || provenance
+ .predicate
+ .build_definition
+ .external_parameters
+ .candidate_digest
+ != format!("{CANDIDATE_DIGEST_DOMAIN}{candidate_digest}")
+ || observed != expected
+ || provenance.subject.len() != expected.len()
+ {
+ return Err(ReleaseArtifactError::GenerationFailure);
+ }
+ Ok(())
+}
+
+fn artifact_ecosystem(path: &str) -> &'static str {
+ if path == "oci-image.tar.gz" || path == "nixos-module.nix" {
+ "nix"
+ } else if path.ends_with("-source.tar") {
+ "git"
+ } else if path == "binary.tar.gz" {
+ "native"
+ } else {
+ "release"
+ }
+}
+
+struct ProvenanceInput<'a> {
+ candidate_digest: &'a str,
+ service: &'a str,
+ target: &'a str,
+ source_date_epoch: u32,
+ service_repository: &'a str,
+ service_revision: &'a str,
+ lib_revision: &'a str,
+ source_lock_sha256: &'a str,
+ manifest_sha256: &'a str,
+}
+
+fn build_provenance(input: ProvenanceInput<'_>, artifacts: &[ArtifactRecord]) -> InTotoStatement {
+ let ProvenanceInput {
+ candidate_digest,
+ service,
+ target,
+ source_date_epoch,
+ service_repository,
+ service_revision,
+ lib_revision,
+ source_lock_sha256,
+ manifest_sha256,
+ } = input;
+ let mut subject = artifacts
+ .iter()
+ .map(|artifact| InTotoSubject {
+ name: artifact.path.clone(),
+ digest: BTreeMap::from([("sha256", artifact.sha256.clone())]),
+ })
+ .collect::<Vec<_>>();
+ subject.sort_by(|left, right| left.name.cmp(&right.name));
+ let mut resolved_dependencies = vec![
+ SlsaResolvedDependency {
+ uri: format!("git+{service_repository}@{service_revision}"),
+ digest: BTreeMap::from([("gitCommit", service_revision.to_owned())]),
+ },
+ SlsaResolvedDependency {
+ uri: format!("git+{LIB_REPOSITORY}@{lib_revision}"),
+ digest: BTreeMap::from([("gitCommit", lib_revision.to_owned())]),
+ },
+ SlsaResolvedDependency {
+ uri: format!("file:{LOCK_FILENAME}"),
+ digest: BTreeMap::from([("sha256", source_lock_sha256.to_owned())]),
+ },
+ SlsaResolvedDependency {
+ uri: "file:artifact-manifest.v2.json".to_owned(),
+ digest: BTreeMap::from([("sha256", manifest_sha256.to_owned())]),
+ },
+ ];
+ resolved_dependencies.sort_by(|left, right| left.uri.cmp(&right.uri));
+ let invocation_id = sha256_bytes(
+ format!(
+ "radroots.service.slsa.invocation.v1\0{candidate_digest}\0{service}\0{target}\0{manifest_sha256}"
+ )
+ .as_bytes(),
+ );
+ InTotoStatement {
+ statement_type: "https://in-toto.io/Statement/v1",
+ subject,
+ predicate_type: "https://slsa.dev/provenance/v1",
+ predicate: SlsaPredicate {
+ build_definition: SlsaBuildDefinition {
+ build_type: "https://radroots.dev/contracts/service-release-artifacts/v2",
+ external_parameters: SlsaExternalParameters {
+ candidate_digest: format!("{CANDIDATE_DIGEST_DOMAIN}{candidate_digest}"),
+ service: service.to_owned(),
+ target: target.to_owned(),
+ source_date_epoch,
+ },
+ internal_parameters: BTreeMap::new(),
+ resolved_dependencies,
+ },
+ run_details: SlsaRunDetails {
+ builder: SlsaBuilder {
+ id: "https://radroots.dev/builders/service-release-artifacts/v2",
+ },
+ metadata: SlsaRunMetadata { invocation_id },
+ },
+ },
+ }
+}
+
+fn scanner_ruleset_sha256() -> String {
+ let mut bytes = b"radroots.service.artifact-scan.rules.v1\0".to_vec();
+ for pattern in SECRET_PATTERNS {
+ bytes.extend_from_slice(pattern);
+ bytes.push(0);
+ }
+ sha256_bytes(&bytes)
+}
+
+fn scan_artifact_inventory(
+ root: &Path,
+ artifacts: &[ArtifactRecord],
+) -> Result<(u64, u64), ReleaseArtifactError> {
+ let mut nested_members = 0_u64;
+ let mut expanded_bytes = 0_u64;
+ for artifact in artifacts {
+ let path = root.join(&artifact.path);
+ expanded_bytes = expanded_bytes
+ .checked_add(scan_regular_file(&path, artifact.byte_length)?)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ if artifact.path == "service-source.tar" || artifact.path == "lib-source.tar" {
+ let (members, bytes) = scan_tar_members(&path, false)?;
+ nested_members = nested_members
+ .checked_add(members)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ expanded_bytes = expanded_bytes
+ .checked_add(bytes)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ } else if artifact.path == "binary.tar.gz" {
+ let (members, bytes) = scan_tar_gzip_members(&path)?;
+ nested_members = nested_members
+ .checked_add(members)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ expanded_bytes = expanded_bytes
+ .checked_add(bytes)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ } else if artifact.path == "oci-image.tar.gz" {
+ let limits = TarGzipLimits {
+ max_compressed_bytes: MAX_OCI_BYTES,
+ max_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
+ max_members: 65_536,
+ max_member_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
+ max_payload_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
+ max_depth: 64,
+ max_path_bytes: MAX_ARCHIVE_PATH_BYTES,
+ };
+ let materialized =
+ safe_artifact_io::materialize_tar_gzip_path(path.as_path(), root, limits)
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ for file in materialized.snapshot().files() {
+ let relative = file
+ .relative_path()
+ .to_str()
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ validate_scanned_path(relative)?;
+ let evidence = materialized
+ .snapshot()
+ .hash(file, MAX_ARCHIVE_EXPANDED_BYTES)
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ expanded_bytes = expanded_bytes
+ .checked_add(scan_regular_file(
+ &materialized.root().join(file.relative_path()),
+ evidence.byte_length,
+ )?)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ nested_members = nested_members
+ .checked_add(1)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ if relative.ends_with("/layer.tar") {
+ let (members, bytes) =
+ scan_tar_members(&materialized.root().join(file.relative_path()), true)?;
+ nested_members = nested_members
+ .checked_add(members)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ expanded_bytes = expanded_bytes
+ .checked_add(bytes)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ }
+ }
+ materialized
+ .revalidate()
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ }
+ }
+ Ok((nested_members, expanded_bytes))
+}
+
+fn scan_tar_gzip_members(path: &Path) -> Result<(u64, u64), ReleaseArtifactError> {
+ let file = fs::File::open(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ let decoder = flate2::read::GzDecoder::new(file);
+ let mut archive = tar::Archive::new(decoder);
+ let mut members = 0_u64;
+ let mut bytes = 0_u64;
+ for entry in archive
+ .entries()
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?
+ {
+ let mut entry = entry.map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ members = members
+ .checked_add(1)
+ .filter(|count| *count <= 4)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ let path = entry
+ .path()
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ let path = path
+ .to_str()
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ validate_scanned_path(path)?;
+ if entry.header().entry_type().is_file() {
+ let size = entry.size();
+ if size > MAX_BINARY_BYTES {
+ return Err(ReleaseArtifactError::InvalidInputArtifact);
+ }
+ bytes = bytes
+ .checked_add(scan_reader(&mut entry, size)?)
+ .filter(|count| *count <= MAX_BINARY_BYTES)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ } else if !entry.header().entry_type().is_dir() {
+ return Err(ReleaseArtifactError::InvalidInputArtifact);
+ }
+ }
+ if members == 0 {
+ Err(ReleaseArtifactError::InvalidInputArtifact)
+ } else {
+ Ok((members, bytes))
+ }
+}
+
+fn scan_tar_members(path: &Path, links_allowed: bool) -> Result<(u64, u64), ReleaseArtifactError> {
+ let file = fs::File::open(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ let mut archive = tar::Archive::new(file);
+ let mut members = 0_u64;
+ let mut bytes = 0_u64;
+ for entry in archive
+ .entries()
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?
+ {
+ let mut entry = entry.map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ let path = entry
+ .path()
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ let path = path
+ .to_str()
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ validate_scanned_path(path)?;
+ members = members
+ .checked_add(1)
+ .filter(|count| *count <= MAX_SOURCE_ARCHIVE_MEMBERS)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ if entry.header().entry_type().is_file() {
+ let size = entry.size();
+ if !links_allowed && size > MAX_SOURCE_ARCHIVE_MEMBER_BYTES {
+ return Err(ReleaseArtifactError::InvalidInputArtifact);
+ }
+ let scanned = scan_reader(&mut entry, size)?;
+ bytes = bytes
+ .checked_add(scanned)
+ .filter(|count| *count <= MAX_ARCHIVE_EXPANDED_BYTES)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ } else if !links_allowed
+ || !(entry.header().entry_type().is_dir()
+ || entry.header().entry_type().is_symlink()
+ || entry.header().entry_type().is_hard_link())
+ {
+ return Err(ReleaseArtifactError::InvalidInputArtifact);
+ }
+ }
+ if members == 0 {
+ Err(ReleaseArtifactError::InvalidInputArtifact)
+ } else {
+ Ok((members, bytes))
+ }
+}
+
+fn scan_regular_file(path: &Path, expected_length: u64) -> Result<u64, ReleaseArtifactError> {
+ let mut file = fs::File::open(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ scan_reader(&mut file, expected_length)
+}
+
+fn scan_reader(
+ reader: &mut impl std::io::Read,
+ expected_length: u64,
+) -> Result<u64, ReleaseArtifactError> {
+ let mut scanner = SecretScanner::default();
+ let mut total = 0_u64;
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ let read = reader
+ .read(&mut buffer)
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ if read == 0 {
+ break;
+ }
+ total = total
+ .checked_add(read as u64)
+ .filter(|value| *value <= expected_length)
+ .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
+ scanner.scan(&buffer[..read])?;
+ }
+ if total == expected_length {
+ Ok(total)
+ } else {
+ Err(ReleaseArtifactError::InvalidInputArtifact)
+ }
+}
+
+fn validate_scanned_path(path: &str) -> Result<(), ReleaseArtifactError> {
+ let sensitive = Path::new(path).components().any(|component| {
+ let Component::Normal(value) = component else {
+ return true;
+ };
+ let value = value.to_string_lossy().to_ascii_lowercase();
+ matches!(
+ value.as_str(),
+ ".git"
+ | ".ssh"
+ | ".aws"
+ | ".env"
+ | "credentials"
+ | "secrets"
+ | "id_rsa"
+ | "id_ed25519"
+ | "private_key"
+ )
+ });
+ if sensitive || path.len() > MAX_ARCHIVE_PATH_BYTES {
+ Err(ReleaseArtifactError::ProtectedMaterialDetected)
+ } else {
+ Ok(())
+ }
}
fn validate_metadata_package(
@@ -927,11 +1733,15 @@ fn sbom_component(
bom_ref: package_reference(package),
name: package.name.clone(),
version: package.version.clone(),
- purl: format!("pkg:cargo/{}@{}", package.name, package.version),
+ purl: Some(format!("pkg:cargo/{}@{}", package.name, package.version)),
licenses: vec![LicenseChoice {
expression: license,
}],
hashes,
+ properties: vec![SbomProperty {
+ name: "radroots:ecosystem".to_owned(),
+ value: "cargo".to_owned(),
+ }],
})
}
@@ -1071,33 +1881,6 @@ fn git_remote(root: &Path) -> Result<String, ReleaseArtifactError> {
Ok(value.to_owned())
}
-fn verify_bundle(path: &Path, revision: &str) -> Result<(), ReleaseArtifactError> {
- let verification = TempDir::new().map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?;
- git_status(verification.path(), ["init", "--bare", "--quiet"])
- .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?;
- let output = Command::new("git")
- .args(["bundle", "verify"])
- .arg(path)
- .current_dir(verification.path())
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?;
- if !output.success() {
- return Err(ReleaseArtifactError::InvalidSourceBundle);
- }
- let mut command = Command::new("git");
- command.args(["bundle", "list-heads"]).arg(path);
- let heads = command_stdout(&mut command, MAX_GIT_OUTPUT_BYTES)
- .map_err(|_| ReleaseArtifactError::InvalidSourceBundle)?;
- if heads == format!("{revision} refs/heads/archive\n").as_bytes() {
- Ok(())
- } else {
- Err(ReleaseArtifactError::InvalidSourceBundle)
- }
-}
-
#[cfg(test)]
fn create_binary_archive(
source: &Path,
@@ -1272,18 +2055,10 @@ impl SecretScanner {
let mut combined = Vec::with_capacity(self.tail.len() + bytes.len());
combined.extend_from_slice(&self.tail);
combined.extend_from_slice(bytes);
- if SECRET_PATTERNS
- .iter()
- .any(|pattern| contains_bytes(&combined, pattern))
- {
+ if contains_secret(&combined) {
return Err(ReleaseArtifactError::ProtectedMaterialDetected);
}
- let retained = SECRET_PATTERNS
- .iter()
- .map(|pattern| pattern.len().saturating_sub(1))
- .max()
- .unwrap_or(0)
- .min(combined.len());
+ let retained = SECRET_SCAN_OVERLAP_BYTES.min(combined.len());
self.tail.clear();
self.tail
.extend_from_slice(&combined[combined.len() - retained..]);
@@ -1291,11 +2066,91 @@ impl SecretScanner {
}
}
-fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
- !needle.is_empty()
- && haystack
- .windows(needle.len())
- .any(|window| window == needle)
+fn contains_secret(bytes: &[u8]) -> bool {
+ let pem = [
+ (
+ b"-----BEGIN PRIVATE KEY-----".as_slice(),
+ b"-----END PRIVATE KEY-----".as_slice(),
+ ),
+ (
+ b"-----BEGIN RSA PRIVATE KEY-----".as_slice(),
+ b"-----END RSA PRIVATE KEY-----".as_slice(),
+ ),
+ (
+ b"-----BEGIN EC PRIVATE KEY-----".as_slice(),
+ b"-----END EC PRIVATE KEY-----".as_slice(),
+ ),
+ (
+ b"-----BEGIN OPENSSH PRIVATE KEY-----".as_slice(),
+ b"-----END OPENSSH PRIVATE KEY-----".as_slice(),
+ ),
+ ]
+ .iter()
+ .any(|(begin, end)| contains_pem_secret(bytes, begin, end));
+ let github_pat = contains_prefixed_secret(bytes, b"github_pat_", 50, 128, is_token_byte);
+ let ghp = contains_prefixed_secret(bytes, b"ghp_", 36, 36, |byte| byte.is_ascii_alphanumeric());
+ let slack = contains_prefixed_secret(bytes, b"xoxb-", 20, 128, |byte| {
+ byte.is_ascii_digit() || byte == b'-'
+ });
+ pem || github_pat || ghp || slack
+}
+
+fn contains_pem_secret(bytes: &[u8], begin: &[u8], end: &[u8]) -> bool {
+ let Some(begin_at) = bytes
+ .windows(begin.len())
+ .position(|window| window == begin)
+ else {
+ return false;
+ };
+ let body = &bytes[begin_at + begin.len()..];
+ if !body.starts_with(b"\n") && !body.starts_with(b"\r\n") {
+ return false;
+ }
+ let Some(end_at) = body.windows(end.len()).position(|window| window == end) else {
+ return false;
+ };
+ let encoded = &body[..end_at];
+ encoded.iter().all(|byte| {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'\n' | b'\r')
+ }) && encoded
+ .iter()
+ .filter(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'='))
+ .count()
+ >= 32
+}
+
+fn contains_prefixed_secret(
+ bytes: &[u8],
+ prefix: &[u8],
+ minimum_suffix: usize,
+ maximum_suffix: usize,
+ valid_suffix: impl Fn(u8) -> bool,
+) -> bool {
+ bytes
+ .windows(prefix.len())
+ .enumerate()
+ .any(|(index, window)| {
+ if window != prefix
+ || index
+ .checked_sub(1)
+ .is_some_and(|prior| is_token_byte(bytes[prior]))
+ {
+ return false;
+ }
+ let start = index + prefix.len();
+ let suffix = bytes[start..]
+ .iter()
+ .take_while(|byte| valid_suffix(**byte))
+ .count();
+ (minimum_suffix..=maximum_suffix).contains(&suffix)
+ && bytes
+ .get(start + suffix)
+ .is_none_or(|byte| !is_token_byte(*byte))
+ })
+}
+
+fn is_token_byte(byte: u8) -> bool {
+ byte.is_ascii_alphanumeric() || byte == b'_'
}
fn scan_bytes(bytes: &[u8]) -> Result<(), ReleaseArtifactError> {
@@ -1414,25 +2269,38 @@ fn artifact_record(path: &str, evidence: &FileEvidence) -> ArtifactRecord {
}
}
+fn artifact_record_from_exact_tree(
+ path: &str,
+ evidence: &exact_tree_archive::ExactTreeArchiveEvidence,
+) -> ArtifactRecord {
+ ArtifactRecord {
+ path: path.to_owned(),
+ byte_length: evidence.byte_length,
+ sha256: evidence.sha256.clone(),
+ }
+}
+
fn output_maximum(name: &str) -> Result<u64, ReleaseArtifactError> {
match name {
"binary.tar.gz" => Ok(MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES),
"oci-image.tar.gz" => Ok(MAX_OCI_BYTES),
- "service-source.bundle" | "lib-source.bundle" => Ok(MAX_SOURCE_BUNDLE_BYTES),
+ "service-source.tar" | "lib-source.tar" => Ok(MAX_SOURCE_ARCHIVE_BYTES),
"LICENSE-APACHE"
| "LICENSE-MIT"
| "config.example.toml"
| "config.schema.json"
| "nixos-module.nix"
- | "radroots.service.source-lock.v2.toml"
+ | "radroots.service.source-lock.v3.toml"
| "systemd.service" => Ok(MAX_TEXT_INPUT_BYTES),
"SHA256SUMS"
+ | "THIRD-PARTY-LICENSES.txt"
| "THIRD-PARTY-NOTICES.txt"
- | "artifact-manifest.v1.json"
+ | "artifact-manifest.v2.json"
+ | "artifact-scan.v1.json"
| "oci-image.v1.json"
- | "provenance-input.v1.json"
+ | "provenance.intoto.jsonl"
| "sbom.cdx.json"
- | "source-bundles.v2.json" => Ok(MAX_GENERATED_DOCUMENT_BYTES),
+ | "source-archives.v3.json" => Ok(MAX_GENERATED_DOCUMENT_BYTES),
_ => Err(ReleaseArtifactError::GenerationFailure),
}
}
@@ -1692,22 +2560,16 @@ fn git_stdout<const N: usize>(root: &Path, args: [&str; N], maximum: usize) -> R
command_stdout(&mut command, maximum)
}
-fn git_status<const N: usize>(root: &Path, args: [&str; N]) -> Result<(), ()> {
- let status = Command::new("git")
- .args(args)
- .current_dir(root)
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .map_err(|_| ())?;
- if status.success() { Ok(()) } else { Err(()) }
-}
-
fn valid_metadata_text(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_TEXT_FIELD_BYTES
&& !value.contains(['\n', '\r'])
+ && !SECRET_PATTERNS.iter().any(|pattern| {
+ value
+ .as_bytes()
+ .windows(pattern.len())
+ .any(|window| window == *pattern)
+ })
&& scan_bytes(value.as_bytes()).is_ok()
}
@@ -1800,12 +2662,13 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr
ReleaseArtifactError::StaleOutput,
ReleaseArtifactError::GenerationFailure,
];
- if decision.schema != "radroots.services-hardening.release-artifacts-decisions.v3"
- || decision.contract_version != 3
+ if decision.schema != "radroots.services-hardening.release-artifacts-decisions.v4"
+ || decision.contract_version != 4
|| decision.decision_state != "active"
+ || decision.owner_step != 305
|| decision.predecessor.schema
- != "radroots.services-hardening.release-artifacts-decisions.v2"
- || decision.predecessor.filename != "services_hardening_release_artifacts.v2.json"
+ != "radroots.services-hardening.release-artifacts-decisions.v3"
+ || decision.predecessor.filename != "services_hardening_release_artifacts.v3.json"
|| decision.predecessor.transition != "forward_only_replace"
|| decision.command != "cargo xtask service-release-artifacts"
|| decision.modes != ["check", "write"]
@@ -1813,10 +2676,12 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr
!= [
"mode",
"service_root",
+ "lib_root",
"input_root",
"output_root",
"target",
"source_date_epoch",
+ "candidate_digest",
]
|| decision.service_metadata_path
!= "Cargo.toml.workspace.metadata.radroots.service_release"
@@ -1824,9 +2689,17 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr
!= ["service", "service_package", "binary_name", "version"]
|| decision.service_license_path
!= "Cargo.toml.workspace.package.license_or_package.license"
+ || decision.source_lock_schema != "radroots.service.source-lock.v3"
+ || decision.source_lock_definition
+ != "contracts/architecture/decisions/services_hardening_source_lock.v3.json"
+ || decision.artifact_contract_binding
+ != "source_lock_exact_regular_file_bytes_in_same_service_revision"
|| decision.artifact_admission_contract
!= "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json"
|| decision.supported_targets != SUPPORTED_TARGETS
+ || decision.candidate_binding != "explicit_sha256_candidate_identity_digest"
+ || decision.lib_root_binding
+ != "canonical_public_lib_git_root_containing_the_locked_revision"
|| decision.binary_admission
!= "exact_format_architecture_linkage_structural_and_native_bounded_help_smoke"
|| decision.oci_admission
@@ -1837,11 +2710,15 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr
|| decision.output_inventory != OUTPUT_NAMES
|| decision.canonical_json != "compact_utf8_json_with_one_final_lf"
|| decision.checksum_format != "sha256_lower_hex_two_spaces_path_lf_sorted_by_path"
- || decision.sbom_format != "cyclonedx_json_1_5_locked_cargo_graph"
+ || decision.source_archive_format
+ != "canonical_uncompressed_ustar_exact_git_revision_tree_without_history"
+ || decision.sbom_format != "cyclonedx_json_1_6_complete_cargo_nix_and_artifact_closure"
+ || decision.license_evidence != "exact_dependency_attribution_with_bounded_license_texts"
|| decision.provenance_posture
- != "deterministic_unsigned_slsa_v1_signing_input_external_keys_only"
+ != "candidate_derived_unsigned_intoto_statement_slsa_v1_exact_manifest_subjects"
|| decision.protected_material_scan_scope
- != "all_textual_release_inputs_and_generated_documents"
+ != "all_artifact_bytes_and_bounded_nested_binary_oci_layer_and_source_archive_payloads"
+ || decision.confidentiality_state != "derived_only_from_the_exact_artifact_scan_record"
|| decision.source_cleanliness != "no_tracked_staged_or_untracked_changes"
|| decision.revision_stability != "same_service_head_before_and_after_generation"
|| !decision.no_protected_material
@@ -1850,11 +2727,28 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr
|| decision.maximums.service_cargo_lock_bytes != MAX_SERVICE_CARGO_LOCK_BYTES
|| decision.maximums.service_flake_lock_bytes != MAX_SERVICE_FLAKE_LOCK_BYTES
|| decision.maximums.binary_bytes != MAX_BINARY_BYTES
- || decision.maximums.source_bundle_bytes != MAX_SOURCE_BUNDLE_BYTES
+ || decision.maximums.source_archive_bytes != MAX_SOURCE_ARCHIVE_BYTES
+ || decision.maximums.source_archive_member_bytes != MAX_SOURCE_ARCHIVE_MEMBER_BYTES
+ || decision.maximums.source_archive_members != MAX_SOURCE_ARCHIVE_MEMBERS
|| decision.maximums.oci_bytes != MAX_OCI_BYTES
+ || decision.maximums.artifact_scan_expanded_bytes != MAX_ARCHIVE_EXPANDED_BYTES
|| decision.maximums.cargo_metadata_bytes != MAX_METADATA_BYTES
|| decision.maximums.packages != MAX_PACKAGES
|| decision.maximums.workspace_packages != MAX_WORKSPACE_PACKAGES
+ || decision.required_negative_vectors
+ != [
+ "cyclonedx_schema_drift",
+ "missing_dependency_component",
+ "unreconciled_artifact_subject",
+ "missing_or_mismatched_license_text",
+ "invented_candidate_digest",
+ "git_history_bundle",
+ "secret_in_binary",
+ "secret_in_oci_layer",
+ "secret_in_source_archive",
+ "sensitive_archive_path",
+ "scan_confidentiality_mismatch",
+ ]
|| decision.negative_error_codes != errors.map(ReleaseArtifactError::code)
{
return Err(ReleaseArtifactError::InvalidContract);
@@ -1866,15 +2760,15 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr
mod tests {
use std::process::Command;
- use crate::service_source_lock::{
- ContractVersions, NixMaterialParts, ServiceSourceLockParts, ServiceSourceLockV2,
- };
+ use crate::service_source_lock::ContractVersions;
+ use crate::service_source_lock_v3::FixtureParts;
use super::*;
struct ReleaseFixture {
_root: TempDir,
service: PathBuf,
+ lib: PathBuf,
input: PathBuf,
output_a: PathBuf,
output_b: PathBuf,
@@ -1898,8 +2792,15 @@ mod tests {
write_file(&lib.join("README.md"), b"fixture Lib source\n");
initialize_git(&lib, "https://github.com/radrootslabs/lib");
let lib_revision = git_output(&lib, &["rev-parse", "HEAD"]);
- create_bundle(&lib, &input.join("lib-source.bundle"));
- let lib_bundle = fs::read(input.join("lib-source.bundle")).expect("Lib bundle");
+ let lib_archive_path = canonical_root.join("lib-source.tar");
+ let lib_archive = exact_tree_archive::create(
+ &lib,
+ &lib_revision,
+ &lib_archive_path,
+ exact_tree_archive::commit_timestamp(&lib, &lib_revision)
+ .expect("Lib commit timestamp"),
+ )
+ .expect("Lib source archive");
let catalog =
fs::read(lib.join("contracts/crates/catalog.v2.toml")).expect("workspace catalog");
@@ -1919,7 +2820,7 @@ path = "src/main.rs"
resolver = "3"
[workspace.metadata.radroots.service_release]
-service = "fixture_service"
+service = "myc"
service_package = "fixture-service"
binary_name = "fixture-service"
version = "0.1.0-alpha"
@@ -1956,26 +2857,32 @@ version = "0.1.0-alpha"
b"Apache-2.0 fixture license\n",
);
write_file(&service.join("LICENSE-MIT"), b"MIT fixture license\n");
+ let artifact_contract_path =
+ service.join("contracts/release/myc-artifact-contract.v3.json");
+ fs::create_dir_all(
+ artifact_contract_path
+ .parent()
+ .expect("artifact contract parent"),
+ )
+ .expect("artifact contract directory");
+ write_file(&artifact_contract_path, b"{}\n");
let cargo_lock = fs::read(service.join("Cargo.lock")).expect("Cargo lock");
let flake_lock = fs::read(service.join("flake.lock")).expect("flake lock");
- let source_lock = ServiceSourceLockV2::new(ServiceSourceLockParts {
- service: "fixture_service",
+ let artifact_contract = fs::read(&artifact_contract_path).expect("artifact contract");
+ let source_lock = ServiceSourceLockV3::fixture(FixtureParts {
+ service: "myc",
revision: &lib_revision,
workspace_catalog_sha256: &sha256_bytes(&catalog),
- source_archive_sha256: &sha256_bytes(&lib_bundle),
+ source_archive_sha256: &lib_archive.sha256,
cargo_lock_sha256: &sha256_bytes(&cargo_lock),
- nix: NixMaterialParts::Deferred {
- lib_revision: &lib_revision,
- flake_lock_sha256: &sha256_bytes(&flake_lock),
- },
+ flake_lock_sha256: &sha256_bytes(&flake_lock),
+ artifact_contract_sha256: &sha256_bytes(&artifact_contract),
contract_versions: ContractVersions::new(1, 1, 1, 1, 1),
})
.expect("source lock");
write_file(&service.join(LOCK_FILENAME), source_lock.canonical_bytes());
initialize_git(&service, "https://github.com/radrootslabs/fixture-service");
let service_revision = git_output(&service, &["rev-parse", "HEAD"]);
- create_bundle(&service, &input.join("service-source.bundle"));
-
for (name, bytes) in [
("config.example.toml", b"enabled = true\n".as_slice()),
("config.schema.json", b"{\"type\":\"object\"}\n".as_slice()),
@@ -1987,11 +2894,13 @@ version = "0.1.0-alpha"
] {
write_file(&input.join(name), bytes);
}
- fs::copy(
- std::env::current_exe().expect("current test executable"),
- input.join("service-binary"),
- )
- .expect("copy fixture service binary");
+ let status = Command::new("rustc")
+ .args(["--edition=2024", "src/main.rs", "-o"])
+ .arg(input.join("service-binary"))
+ .current_dir(&service)
+ .status()
+ .expect("compile fixture service binary");
+ assert!(status.success(), "compile fixture service binary");
create_oci_fixture(
&input.join("oci-image.tar.gz"),
&service_revision,
@@ -2003,65 +2912,35 @@ version = "0.1.0-alpha"
output_b: canonical_root.join("release-b"),
_root: root,
service,
+ lib,
input,
}
}
fn write(&self, output: &Path) -> Result<(), ReleaseArtifactError> {
- run_inner(
- CommandMode::Write,
- &self.service,
- &self.input,
- output,
- native_fixture_target(),
- 1_700_000_000,
- )
+ run_inner(Arguments {
+ mode: CommandMode::Write,
+ service_root: &self.service,
+ lib_root: &self.lib,
+ input_root: &self.input,
+ output_root: output,
+ target: native_fixture_target(),
+ source_date_epoch: 1_700_000_000,
+ candidate_digest: &"a".repeat(64),
+ })
}
fn check(&self, output: &Path) -> Result<(), ReleaseArtifactError> {
- run_inner(
- CommandMode::Check,
- &self.service,
- &self.input,
- output,
- native_fixture_target(),
- 1_700_000_000,
- )
- }
-
- fn make_nix_material_absent(&self) {
- let current = ServiceSourceLockV2::from_canonical_bytes(
- &fs::read(self.service.join(LOCK_FILENAME)).expect("source lock"),
- )
- .expect("valid source lock");
- for name in ["flake.nix", "flake.lock"] {
- fs::remove_file(self.service.join(name)).expect("remove deferred Nix file");
- }
- let absent = ServiceSourceLockV2::new(ServiceSourceLockParts {
- service: current.service(),
- revision: current.revision(),
- workspace_catalog_sha256: current.workspace_catalog_sha256(),
- source_archive_sha256: current.source_archive_sha256(),
- cargo_lock_sha256: current.cargo_lock_sha256(),
- nix: NixMaterialParts::Absent,
- contract_versions: current.contract_versions(),
+ run_inner(Arguments {
+ mode: CommandMode::Check,
+ service_root: &self.service,
+ lib_root: &self.lib,
+ input_root: &self.input,
+ output_root: output,
+ target: native_fixture_target(),
+ source_date_epoch: 1_700_000_000,
+ candidate_digest: &"a".repeat(64),
})
- .expect("absent-Nix source lock");
- write_file(&self.service.join(LOCK_FILENAME), absent.canonical_bytes());
- git(&self.service, &["add", "-A"]);
- git(
- &self.service,
- &["commit", "--quiet", "-m", "remove deferred Nix material"],
- );
- fs::remove_file(self.input.join("service-source.bundle"))
- .expect("remove prior service bundle");
- create_bundle(&self.service, &self.input.join("service-source.bundle"));
- fs::remove_file(self.input.join("oci-image.tar.gz")).expect("remove prior OCI fixture");
- create_oci_fixture(
- &self.input.join("oci-image.tar.gz"),
- &git_output(&self.service, &["rev-parse", "HEAD"]),
- current.revision(),
- );
}
}
@@ -2101,17 +2980,6 @@ version = "0.1.0-alpha"
git(root, &["branch", "-M", "archive"]);
}
- fn create_bundle(root: &Path, output: &Path) {
- let status = Command::new("git")
- .args(["bundle", "create"])
- .arg(output)
- .arg("refs/heads/archive")
- .current_dir(root)
- .status()
- .expect("create source bundle");
- assert!(status.success());
- }
-
fn native_fixture_target() -> &'static str {
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
@@ -2145,7 +3013,7 @@ version = "0.1.0-alpha"
let layer_digest = sha256_bytes(&layer);
let layer_name = format!("{layer_digest}/layer.tar");
let labels = artifact_admission::OciExpectation {
- service: "fixture_service",
+ service: "myc",
binary_name: "fixture-service",
version: "0.1.0-alpha",
service_revision,
@@ -2179,11 +3047,11 @@ version = "0.1.0-alpha"
let manifest = serde_json::to_vec(&serde_json::json!([{
"Config": config_name,
"Layers": [layer_name],
- "RepoTags": ["fixture-service:0.1.0-alpha"]
+ "RepoTags": ["myc:0.1.0-alpha"]
}]))
.expect("serialize manifest");
let repositories = serde_json::to_vec(&serde_json::json!({
- "fixture-service": {"0.1.0-alpha": layer_digest}
+ "myc": {"0.1.0-alpha": layer_digest}
}))
.expect("serialize repositories");
let mut members = vec![
@@ -2249,6 +3117,8 @@ version = "0.1.0-alpha"
source: source.map(str::to_owned),
checksum: checksum.map(str::to_owned),
license: license.map(str::to_owned),
+ manifest_path: format!("/fixture/{name}/Cargo.toml"),
+ license_file: None,
targets: if binary {
vec![CargoTarget {
name: name.to_owned(),
@@ -2257,6 +3127,16 @@ version = "0.1.0-alpha"
} else {
Vec::new()
},
+ license_texts: source
+ .map(|_| {
+ let text = "fixture dependency license text\n".to_owned();
+ vec![DependencyLicenseText {
+ filename: "LICENSE".to_owned(),
+ sha256: sha256_bytes(text.as_bytes()),
+ text,
+ }]
+ })
+ .unwrap_or_default(),
}
}
@@ -2312,6 +3192,7 @@ version = "0.1.0-alpha"
("/schema", serde_json::json!("other")),
("/contract_version", serde_json::json!(1)),
("/decision_state", serde_json::json!("draft")),
+ ("/owner_step", serde_json::json!(1)),
("/predecessor/schema", serde_json::json!("other")),
("/predecessor/filename", serde_json::json!("other")),
("/predecessor/transition", serde_json::json!("other")),
@@ -2320,16 +3201,28 @@ version = "0.1.0-alpha"
("/required_arguments", serde_json::json!([])),
("/service_metadata_path", serde_json::json!("other")),
("/service_metadata_fields", serde_json::json!([])),
+ ("/service_license_path", serde_json::json!("other")),
+ ("/source_lock_schema", serde_json::json!("other")),
+ ("/source_lock_definition", serde_json::json!("other")),
+ ("/artifact_contract_binding", serde_json::json!("other")),
+ ("/artifact_admission_contract", serde_json::json!("other")),
("/supported_targets", serde_json::json!([])),
+ ("/candidate_binding", serde_json::json!("other")),
+ ("/lib_root_binding", serde_json::json!("other")),
+ ("/binary_admission", serde_json::json!("other")),
+ ("/oci_admission", serde_json::json!("other")),
("/input_inventory", serde_json::json!([])),
("/excluded_parent_owned_inputs", serde_json::json!([])),
("/service_root_inventory", serde_json::json!([])),
("/output_inventory", serde_json::json!([])),
("/canonical_json", serde_json::json!("other")),
("/checksum_format", serde_json::json!("other")),
+ ("/source_archive_format", serde_json::json!("other")),
("/sbom_format", serde_json::json!("other")),
+ ("/license_evidence", serde_json::json!("other")),
("/provenance_posture", serde_json::json!("other")),
("/protected_material_scan_scope", serde_json::json!("other")),
+ ("/confidentiality_state", serde_json::json!("other")),
("/source_cleanliness", serde_json::json!("other")),
("/revision_stability", serde_json::json!("other")),
("/no_protected_material", serde_json::json!(false)),
@@ -2338,11 +3231,21 @@ version = "0.1.0-alpha"
("/maximums/service_cargo_lock_bytes", serde_json::json!(1)),
("/maximums/service_flake_lock_bytes", serde_json::json!(1)),
("/maximums/binary_bytes", serde_json::json!(1)),
- ("/maximums/source_bundle_bytes", serde_json::json!(1)),
+ ("/maximums/source_archive_bytes", serde_json::json!(1)),
+ (
+ "/maximums/source_archive_member_bytes",
+ serde_json::json!(1),
+ ),
+ ("/maximums/source_archive_members", serde_json::json!(1)),
("/maximums/oci_bytes", serde_json::json!(1)),
+ (
+ "/maximums/artifact_scan_expanded_bytes",
+ serde_json::json!(1),
+ ),
("/maximums/cargo_metadata_bytes", serde_json::json!(1)),
("/maximums/packages", serde_json::json!(1)),
("/maximums/workspace_packages", serde_json::json!(1)),
+ ("/required_negative_vectors", serde_json::json!([])),
("/negative_error_codes", serde_json::json!([])),
] {
let mut drifted = canonical.clone();
@@ -2359,14 +3262,16 @@ version = "0.1.0-alpha"
#[test]
fn exact_inventory_and_limits_are_literal() {
- assert_eq!(INPUT_NAMES.len(), 8);
- assert_eq!(OUTPUT_NAMES.len(), 18);
+ assert_eq!(INPUT_NAMES.len(), 6);
+ assert_eq!(OUTPUT_NAMES.len(), 20);
assert_eq!(MAX_TEXT_INPUT_BYTES, 1_048_576);
assert_eq!(MAX_GENERATED_DOCUMENT_BYTES, 16_777_216);
assert_eq!(MAX_SERVICE_CARGO_LOCK_BYTES, 16_777_216);
assert_eq!(MAX_SERVICE_FLAKE_LOCK_BYTES, 4_194_304);
assert_eq!(MAX_BINARY_BYTES, 536_870_912);
- assert_eq!(MAX_SOURCE_BUNDLE_BYTES, 1_073_741_824);
+ assert_eq!(MAX_SOURCE_ARCHIVE_BYTES, 1_073_741_824);
+ assert_eq!(MAX_SOURCE_ARCHIVE_MEMBER_BYTES, 67_108_864);
+ assert_eq!(MAX_SOURCE_ARCHIVE_MEMBERS, 65_536);
assert_eq!(MAX_OCI_BYTES, 2_147_483_648);
assert_eq!(MAX_METADATA_BYTES, 33_554_432);
assert_eq!(MAX_PACKAGES, 8_192);
@@ -2403,18 +3308,20 @@ version = "0.1.0-alpha"
],
}),
};
- let (sbom, notices) =
+ let (sbom, notices, licenses) =
build_supply_chain_documents(&sample_metadata(), cargo).expect("documents");
let bytes = serde_json::to_vec(&sbom).expect("SBOM JSON");
assert_eq!(serde_json::to_vec(&sbom).expect("SBOM JSON"), bytes);
assert_eq!(sbom.bom_format, "CycloneDX");
- assert_eq!(sbom.spec_version, "1.5");
+ assert_eq!(sbom.spec_version, "1.6");
assert_eq!(sbom.metadata.component.name, "fixture-service");
assert_eq!(sbom.components.len(), 1);
assert_eq!(sbom.dependencies.len(), 2);
assert!(notices.contains("Package: dependency 0.1.0-alpha"));
assert!(notices.contains("License: Apache-2.0"));
assert!(notices.contains("registry+https://github.com/rust-lang/crates.io-index"));
+ assert!(notices.contains("License-Text: LICENSE sha256:"));
+ assert!(licenses.contains("fixture dependency license text"));
}
#[test]
@@ -2632,9 +3539,10 @@ version = "0.1.0-alpha"
}],
}),
};
- let (_, notices) =
+ let (_, notices, licenses) =
build_supply_chain_documents(&sample_metadata(), root_only).expect("root-only graph");
assert!(notices.contains("No third-party Cargo packages are present."));
+ assert!(licenses.contains("No third-party Cargo packages are present."));
}
#[test]
@@ -2890,7 +3798,7 @@ version = "0.1.0-alpha"
Err(ReleaseArtifactError::StaleOutput)
);
- let source_lock = ServiceSourceLockV2::from_canonical_bytes(
+ let source_lock = ServiceSourceLockV3::from_canonical_bytes(
&fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"),
)
.expect("source lock");
@@ -2984,13 +3892,6 @@ version = "0.1.0-alpha"
);
assert_eq!(
- verify_bundle(
- &fixture.input.join("service-source.bundle"),
- &"a".repeat(40)
- ),
- Err(ReleaseArtifactError::InvalidSourceBundle)
- );
- assert_eq!(
write_generated(
&scope.path().join("oversized-generated"),
&vec![b'x'; MAX_GENERATED_DOCUMENT_BYTES as usize + 1]
@@ -3009,8 +3910,6 @@ version = "0.1.0-alpha"
.expect("bounded regular file"),
b"same"
);
- assert!(!contains_bytes(b"bytes", b""));
-
let directory = root.path().join("directory");
fs::create_dir(&directory).expect("directory fixture");
assert_eq!(
@@ -3126,45 +4025,15 @@ version = "0.1.0-alpha"
let mut failed_stdout = Command::new("sh");
failed_stdout.args(["-c", "exit 7"]);
assert_eq!(command_stdout(&mut failed_stdout, 4), Err(()));
- assert_eq!(
- git_status(root.path(), ["rev-parse", "--verify", "refs/heads/missing"]),
- Err(())
- );
}
#[test]
fn release_service_and_workspace_binding_fail_closed() {
let fixture = ReleaseFixture::new();
- let current = ServiceSourceLockV2::from_canonical_bytes(
- &fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"),
- )
- .expect("source lock");
- let versions = current.contract_versions();
- let mismatched = ServiceSourceLockV2::new(ServiceSourceLockParts {
- service: "other_service",
- revision: current.revision(),
- workspace_catalog_sha256: current.workspace_catalog_sha256(),
- source_archive_sha256: current.source_archive_sha256(),
- cargo_lock_sha256: current.cargo_lock_sha256(),
- nix: NixMaterialParts::Deferred {
- lib_revision: current.nix_lib_revision().expect("deferred Nix revision"),
- flake_lock_sha256: current
- .flake_lock_sha256()
- .expect("deferred flake-lock digest"),
- },
- contract_versions: ContractVersions::new(
- versions.config(),
- versions.state(),
- versions.admin(),
- versions.status(),
- versions.provider(),
- ),
- })
- .expect("mismatched source lock");
- write_file(
- &fixture.service.join(LOCK_FILENAME),
- mismatched.canonical_bytes(),
- );
+ let mismatched = fs::read_to_string(fixture.service.join(LOCK_FILENAME))
+ .expect("source lock")
+ .replace("service = \"myc\"", "service = \"rhi\"");
+ write_file(&fixture.service.join(LOCK_FILENAME), mismatched.as_bytes());
git(&fixture.service, &["add", LOCK_FILENAME]);
git(
&fixture.service,
@@ -3287,11 +4156,12 @@ version = "0.1.0-alpha"
assert_eq!(count, OUTPUT_NAMES.len() - 1);
for name in [
- "artifact-manifest.v1.json",
+ "artifact-manifest.v2.json",
+ "artifact-scan.v1.json",
"oci-image.v1.json",
- "provenance-input.v1.json",
+ "provenance.intoto.jsonl",
"sbom.cdx.json",
- "source-bundles.v2.json",
+ "source-archives.v3.json",
] {
let bytes = fs::read(fixture.output_a.join(name)).expect("JSON output");
assert_eq!(bytes.last(), Some(&b'\n'));
@@ -3299,10 +4169,18 @@ version = "0.1.0-alpha"
serde_json::from_slice::<serde_json::Value>(&bytes).expect("valid JSON");
}
let provenance: serde_json::Value = serde_json::from_slice(
- &fs::read(fixture.output_a.join("provenance-input.v1.json")).expect("provenance"),
+ &fs::read(fixture.output_a.join("provenance.intoto.jsonl")).expect("provenance"),
)
.expect("provenance JSON");
- assert_eq!(provenance["signing_required"], true);
+ assert_eq!(provenance["_type"], "https://in-toto.io/Statement/v1");
+ assert_eq!(
+ provenance["predicateType"],
+ "https://slsa.dev/provenance/v1"
+ );
+ assert_eq!(
+ provenance["predicate"]["buildDefinition"]["externalParameters"]["candidate_digest"],
+ format!("sha256:{}", "a".repeat(64))
+ );
write_file(
&fixture.output_a.join("config.example.toml"),
@@ -3319,48 +4197,58 @@ version = "0.1.0-alpha"
}
#[test]
- fn absent_nix_material_is_preserved_without_invented_digest_evidence() {
+ fn qualified_nix_material_is_required_and_preserved() {
let fixture = ReleaseFixture::new();
- fixture.make_nix_material_absent();
fixture
.write(&fixture.output_a)
- .expect("absent-Nix release");
+ .expect("qualified-Nix release");
let document: serde_json::Value = serde_json::from_slice(
- &fs::read(fixture.output_a.join("source-bundles.v2.json"))
- .expect("source bundle document"),
+ &fs::read(fixture.output_a.join("source-archives.v3.json"))
+ .expect("source archive document"),
)
- .expect("source bundle JSON");
- assert_eq!(document["schema"], "radroots.service.source-bundles.v2");
- assert_eq!(document["contract_version"], 2);
- assert_eq!(document["nix_material"], "absent");
- assert!(document.get("nix_lib_revision").is_none());
- assert!(document.get("flake_lock_sha256").is_none());
- assert!(!fixture.service.join("flake.nix").exists());
- assert!(!fixture.service.join("flake.lock").exists());
-
- let lock = ServiceSourceLockV2::from_canonical_bytes(
+ .expect("source archive JSON");
+ assert_eq!(document["schema"], "radroots.service.source-archives.v3");
+ assert_eq!(document["contract_version"], 3);
+ assert!(valid_lower_hex(
+ document["flake_lock_sha256"]
+ .as_str()
+ .expect("flake digest"),
+ 64
+ ));
+
+ let lock = ServiceSourceLockV3::from_canonical_bytes(
&fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"),
)
.expect("valid source lock");
- for name in ["flake.nix", "flake.lock", PREDECESSOR_LOCK_FILENAME] {
- write_file(&fixture.service.join(name), b"unexpected");
+ for name in ["flake.nix", "flake.lock"] {
+ let path = fixture.service.join(name);
+ let original = fs::read(&path).expect("qualified Nix file");
+ fs::remove_file(&path).expect("remove qualified Nix file");
assert_eq!(
validate_source_lock_files(&fixture.service, &lock),
Err(ReleaseArtifactError::InvalidSourceLock),
- "accepted absent-state release input with {name}"
+ "accepted missing qualified-Nix file {name}"
);
- fs::remove_file(fixture.service.join(name)).expect("remove unexpected file");
+ write_file(&path, &original);
}
+ write_file(
+ &fixture.service.join(PREDECESSOR_LOCK_FILENAME),
+ b"unexpected",
+ );
+ assert_eq!(
+ validate_source_lock_files(&fixture.service, &lock),
+ Err(ReleaseArtifactError::InvalidSourceLock)
+ );
}
#[test]
fn protected_text_and_invalid_inventory_fail_closed() {
let fixture = ReleaseFixture::new();
- write_file(
- &fixture.input.join("config.example.toml"),
- b"-----BEGIN PRIVATE KEY-----\n",
- );
+ let mut secret = b"-----BEGIN PRIVATE KEY-----\n".to_vec();
+ secret.extend_from_slice(&[b'A'; 48]);
+ secret.extend_from_slice(b"\n-----END PRIVATE KEY-----\n");
+ write_file(&fixture.input.join("config.example.toml"), &secret);
assert_eq!(
fixture.write(&fixture.output_a),
Err(ReleaseArtifactError::ProtectedMaterialDetected)
@@ -3377,7 +4265,7 @@ version = "0.1.0-alpha"
}
#[test]
- fn source_lock_and_source_bundle_drift_fail_closed() {
+ fn source_lock_and_source_archive_drift_fail_closed() {
let fixture = ReleaseFixture::new();
write_file(
&fixture.service.join("Cargo.lock"),
@@ -3394,9 +4282,24 @@ version = "0.1.0-alpha"
);
let fixture = ReleaseFixture::new();
- let mut bundle = fs::read(fixture.input.join("lib-source.bundle")).expect("bundle");
- bundle[0] ^= 0xff;
- write_file(&fixture.input.join("lib-source.bundle"), &bundle);
+ let current = ServiceSourceLockV3::from_canonical_bytes(
+ &fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"),
+ )
+ .expect("source lock");
+ let drifted = String::from_utf8(current.canonical_bytes().to_vec())
+ .expect("UTF-8 lock")
+ .replace(current.source_archive_sha256(), &"0".repeat(64));
+ write_file(&fixture.service.join(LOCK_FILENAME), drifted.as_bytes());
+ git(&fixture.service, &["add", LOCK_FILENAME]);
+ git(
+ &fixture.service,
+ &["commit", "--quiet", "-m", "drift source archive digest"],
+ );
+ create_oci_fixture(
+ &fixture.input.join("oci-image.tar.gz"),
+ &git_output(&fixture.service, &["rev-parse", "HEAD"]),
+ current.revision(),
+ );
assert_eq!(
fixture.write(&fixture.output_a),
Err(ReleaseArtifactError::InvalidSourceBundle)
@@ -3424,25 +4327,42 @@ version = "0.1.0-alpha"
Err(ReleaseArtifactError::InvalidOutputRoot)
);
assert_eq!(
- run_inner(
- CommandMode::Write,
- &fixture.service,
- &fixture.input,
- &fixture.output_a,
- "x86_64-apple-darwin",
- 1_700_000_000,
- ),
+ run_inner(Arguments {
+ mode: CommandMode::Write,
+ service_root: &fixture.service,
+ lib_root: &fixture.lib,
+ input_root: &fixture.input,
+ output_root: &fixture.output_a,
+ target: "x86_64-apple-darwin",
+ source_date_epoch: 1_700_000_000,
+ candidate_digest: &"a".repeat(64),
+ }),
Err(ReleaseArtifactError::InvalidServiceMetadata)
);
assert_eq!(
- run_inner(
- CommandMode::Write,
- &fixture.service,
- &fixture.input,
- &fixture.output_a,
- "x86_64-unknown-linux-gnu",
- 0,
- ),
+ run_inner(Arguments {
+ mode: CommandMode::Write,
+ service_root: &fixture.service,
+ lib_root: &fixture.lib,
+ input_root: &fixture.input,
+ output_root: &fixture.output_a,
+ target: "x86_64-unknown-linux-gnu",
+ source_date_epoch: 0,
+ candidate_digest: &"a".repeat(64),
+ }),
+ Err(ReleaseArtifactError::InvalidServiceMetadata)
+ );
+ assert_eq!(
+ run_inner(Arguments {
+ mode: CommandMode::Write,
+ service_root: &fixture.service,
+ lib_root: &fixture.lib,
+ input_root: &fixture.input,
+ output_root: &fixture.output_a,
+ target: native_fixture_target(),
+ source_date_epoch: 1_700_000_000,
+ candidate_digest: &"A".repeat(64),
+ }),
Err(ReleaseArtifactError::InvalidServiceMetadata)
);
}
@@ -3452,9 +4372,186 @@ version = "0.1.0-alpha"
let mut scanner = SecretScanner::default();
scanner.scan(b"prefix -----BEGIN OPENSSH").expect("prefix");
assert_eq!(
- scanner.scan(b" PRIVATE KEY----- suffix"),
+ scanner.scan(
+ b" PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END OPENSSH PRIVATE KEY----- suffix"
+ ),
+ Err(ReleaseArtifactError::ProtectedMaterialDetected)
+ );
+ }
+
+ #[test]
+ fn schema_reconciliation_attribution_and_subjects_fail_closed() {
+ let (mut sbom, _, _) =
+ build_supply_chain_documents(&sample_metadata(), sample_cargo_metadata())
+ .expect("supply-chain documents");
+ let artifacts = vec![ArtifactRecord {
+ path: "binary.tar.gz".to_owned(),
+ byte_length: 42,
+ sha256: "a".repeat(64),
+ }];
+ reconcile_sbom_artifacts(&mut sbom, &artifacts);
+ validate_cyclonedx_profile(&sbom, &artifacts).expect("CycloneDX profile");
+ sbom.spec_version = "1.5";
+ assert_eq!(
+ validate_cyclonedx_profile(&sbom, &artifacts),
+ Err(ReleaseArtifactError::GenerationFailure)
+ );
+ sbom.spec_version = "1.6";
+ sbom.components.pop();
+ assert_eq!(
+ validate_cyclonedx_profile(&sbom, &artifacts),
+ Err(ReleaseArtifactError::GenerationFailure)
+ );
+
+ let mut cargo = sample_cargo_metadata();
+ cargo.packages[1].license_texts[0].sha256 = "b".repeat(64);
+ assert!(matches!(
+ build_supply_chain_documents(&sample_metadata(), cargo),
+ Err(ReleaseArtifactError::InvalidPackageInventory)
+ ));
+
+ let mut provenance = build_provenance(
+ ProvenanceInput {
+ candidate_digest: &"a".repeat(64),
+ service: "myc",
+ target: native_fixture_target(),
+ source_date_epoch: 1_700_000_000,
+ service_repository: "https://github.com/radrootslabs/mycelium",
+ service_revision: &"1".repeat(40),
+ lib_revision: &"2".repeat(40),
+ source_lock_sha256: &"3".repeat(64),
+ manifest_sha256: &"4".repeat(64),
+ },
+ &artifacts,
+ );
+ validate_provenance_subjects(&provenance, &"a".repeat(64), &artifacts)
+ .expect("exact provenance subjects");
+ provenance.subject.clear();
+ assert_eq!(
+ validate_provenance_subjects(&provenance, &"a".repeat(64), &artifacts),
+ Err(ReleaseArtifactError::GenerationFailure)
+ );
+
+ let scan_evidence = FileEvidence {
+ byte_length: 7,
+ sha256: "5".repeat(64),
+ };
+ let scan = ScanDocument {
+ schema: "radroots.service.artifact-scan.v1",
+ contract_version: 1,
+ candidate_digest: "a".repeat(64),
+ state: "no_protected_material_detected",
+ ruleset_sha256: "6".repeat(64),
+ scanned_artifacts: artifacts.clone(),
+ nested_members_scanned: 1,
+ expanded_bytes_scanned: 42,
+ };
+ let mut manifest = ArtifactManifestDocument {
+ schema: "radroots.service.release-artifacts.v2",
+ contract_version: 2,
+ candidate_digest: "a".repeat(64),
+ service: "myc".to_owned(),
+ version: "0.1.0-alpha".to_owned(),
+ target: native_fixture_target().to_owned(),
+ source_date_epoch: 1_700_000_000,
+ service_revision: "1".repeat(40),
+ lib_revision: "2".repeat(40),
+ rust_version: "1.97.1",
+ host_feature_profile: "service-host",
+ contract_versions: ContractVersionsDocument {
+ config: 1,
+ state: 1,
+ admin: 1,
+ status: 1,
+ provider: 1,
+ },
+ confidentiality: ConfidentialityDocument {
+ state: scan.state,
+ derived_from: artifact_record("artifact-scan.v1.json", &scan_evidence),
+ protected_material_included: false,
+ },
+ artifacts: artifacts.clone(),
+ };
+ validate_confidentiality_binding(&manifest, &scan, &scan_evidence, &artifacts)
+ .expect("derived confidentiality");
+ manifest.confidentiality.state = "invented_clean_state";
+ assert_eq!(
+ validate_confidentiality_binding(&manifest, &scan, &scan_evidence, &artifacts),
+ Err(ReleaseArtifactError::GenerationFailure)
+ );
+ }
+
+ #[test]
+ fn secret_path_and_history_archive_vectors_fail_closed() {
+ let root = TempDir::new().expect("archive vector root");
+ let mut token = b"ghp_".to_vec();
+ token.extend(std::iter::repeat_n(b'A', 36));
+
+ let source = root.path().join("source.tar");
+ write_tar_fixture(&source, "src/value.bin", &token, false);
+ assert_eq!(
+ scan_tar_members(&source, false),
+ Err(ReleaseArtifactError::ProtectedMaterialDetected)
+ );
+
+ let sensitive = root.path().join("sensitive.tar");
+ write_tar_fixture(&sensitive, ".git/config", b"clean", false);
+ assert_eq!(
+ scan_tar_members(&sensitive, false),
Err(ReleaseArtifactError::ProtectedMaterialDetected)
);
+
+ let binary = root.path().join("binary.tar.gz");
+ write_tar_fixture(&binary, "bin/service", &token, true);
+ assert_eq!(
+ scan_tar_gzip_members(&binary),
+ Err(ReleaseArtifactError::ProtectedMaterialDetected)
+ );
+
+ let layer = root.path().join("layer.tar");
+ write_tar_fixture(&layer, "nix/store/service", &token, false);
+ assert_eq!(
+ scan_tar_members(&layer, true),
+ Err(ReleaseArtifactError::ProtectedMaterialDetected)
+ );
+
+ let bundle = root.path().join("history.bundle");
+ write_file(&bundle, b"not an exact-tree archive");
+ assert_eq!(
+ scan_tar_members(&bundle, false),
+ Err(ReleaseArtifactError::InvalidInputArtifact)
+ );
+ }
+
+ fn write_tar_fixture(path: &Path, member: &str, bytes: &[u8], gzip: bool) {
+ let mut header = TarHeader::new_gnu();
+ header.set_entry_type(tar::EntryType::Regular);
+ header.set_size(bytes.len() as u64);
+ header.set_mode(0o644);
+ header.set_uid(0);
+ header.set_gid(0);
+ header.set_mtime(1);
+ header.set_cksum();
+ if gzip {
+ let output = fs::File::create(path).expect("gzip tar fixture");
+ let encoder = GzBuilder::new()
+ .mtime(1)
+ .operating_system(255)
+ .write(output, Compression::best());
+ let mut archive = TarBuilder::new(encoder);
+ archive
+ .append_data(&mut header, member, bytes)
+ .expect("gzip tar member");
+ let encoder = archive.into_inner().expect("gzip tar archive");
+ encoder.finish().expect("gzip tar finish");
+ } else {
+ let output = fs::File::create(path).expect("tar fixture");
+ let mut archive = TarBuilder::new(output);
+ archive
+ .append_data(&mut header, member, bytes)
+ .expect("tar member");
+ archive.finish().expect("tar finish");
+ }
}
#[test]
diff --git a/tools/xtask/src/service_source_lock.rs b/tools/xtask/src/service_source_lock.rs
@@ -581,14 +581,6 @@ impl ServiceSourceLockV2 {
&self.raw.revision
}
- pub(crate) fn workspace_catalog_sha256(&self) -> &str {
- &self.raw.workspace_catalog_sha256
- }
-
- pub(crate) fn source_archive_sha256(&self) -> &str {
- &self.raw.source_archive_sha256
- }
-
pub(crate) fn cargo_lock_sha256(&self) -> &str {
&self.raw.cargo_lock_sha256
}
diff --git a/tools/xtask/src/service_source_lock_v3.rs b/tools/xtask/src/service_source_lock_v3.rs
@@ -0,0 +1,568 @@
+use std::fmt;
+
+use serde::Deserialize;
+
+use crate::service_source_lock::{ContractVersions, LIB_REPOSITORY};
+
+pub(crate) const LOCK_FILENAME: &str = "radroots.service.source-lock.v3.toml";
+pub(crate) const PREDECESSOR_LOCK_FILENAME: &str = "radroots.service.source-lock.v2.toml";
+const MAX_LOCK_BYTES: usize = 16 * 1024;
+const ARCHITECTURE: &str = "radroots.crates.release.v2";
+const LIB_VERSION: &str = "0.1.0-alpha";
+const RUST_VERSION: &str = "1.97.1";
+const HOST_FEATURE_PROFILE: &str = "service-host";
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(deny_unknown_fields)]
+struct RawSourceArchiveContract {
+ binding: String,
+ format: String,
+ compression: String,
+ compression_timestamp: String,
+ entry_order: String,
+ path_prefix: String,
+ file_mode: String,
+ uid: u32,
+ gid: u32,
+ uname: String,
+ gname: String,
+ mtime: String,
+ pax_headers: String,
+ directory_entries: String,
+ symlinks: String,
+ hardlinks: String,
+ submodules: String,
+ trailer: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(deny_unknown_fields)]
+struct RawPublicInputLock {
+ path: String,
+ sha256: String,
+ binding: String,
+ mutable_reference: String,
+ lib_input: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(deny_unknown_fields)]
+struct RawParentResult {
+ embedded_in_public_input_lock: bool,
+ embedded_in_source_lock: bool,
+ storage: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(deny_unknown_fields)]
+struct RawQualifiedNix {
+ material: String,
+ lib_revision: String,
+ supported_systems: Vec<String>,
+ public_input_lock: RawPublicInputLock,
+ parent_result: RawParentResult,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(deny_unknown_fields)]
+struct RawArtifactContract {
+ path: String,
+ sha256: String,
+ binding: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(deny_unknown_fields)]
+struct RawSqliteContract {
+ high_level_authority: String,
+ second_pool_connection_query_transaction_migration_authority: String,
+ incremental_backup_adapter: String,
+ native_linkage_count: u32,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(deny_unknown_fields)]
+struct RawServiceSourceLockV3 {
+ schema: String,
+ contract_version: u32,
+ service: String,
+ repository: String,
+ revision: String,
+ architecture: String,
+ workspace_catalog_sha256: String,
+ version: String,
+ source_archive_sha256: String,
+ source_archive_contract: RawSourceArchiveContract,
+ cargo_lock_sha256: String,
+ rust_version: String,
+ host_feature_profile: String,
+ nix: RawQualifiedNix,
+ artifact_contract: RawArtifactContract,
+ sqlite: RawSqliteContract,
+ contract_versions: ContractVersions,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum ServiceSourceLockV3Error {
+ TooLarge,
+ Malformed,
+ Noncanonical,
+ Invalid,
+}
+
+impl fmt::Display for ServiceSourceLockV3Error {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::TooLarge => "service source lock v3 exceeds its byte limit",
+ Self::Malformed => "service source lock v3 is malformed",
+ Self::Noncanonical => "service source lock v3 is not canonical",
+ Self::Invalid => "service source lock v3 is invalid",
+ })
+ }
+}
+
+impl std::error::Error for ServiceSourceLockV3Error {}
+
+#[derive(Clone, Eq, PartialEq)]
+pub(crate) struct ServiceSourceLockV3 {
+ raw: RawServiceSourceLockV3,
+ canonical: Box<[u8]>,
+}
+
+#[cfg(test)]
+pub(crate) struct FixtureParts<'a> {
+ pub(crate) service: &'a str,
+ pub(crate) revision: &'a str,
+ pub(crate) workspace_catalog_sha256: &'a str,
+ pub(crate) source_archive_sha256: &'a str,
+ pub(crate) cargo_lock_sha256: &'a str,
+ pub(crate) flake_lock_sha256: &'a str,
+ pub(crate) artifact_contract_sha256: &'a str,
+ pub(crate) contract_versions: ContractVersions,
+}
+
+impl fmt::Debug for ServiceSourceLockV3 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("ServiceSourceLockV3")
+ .finish_non_exhaustive()
+ }
+}
+
+impl ServiceSourceLockV3 {
+ pub(crate) fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ServiceSourceLockV3Error> {
+ if bytes.len() > MAX_LOCK_BYTES {
+ return Err(ServiceSourceLockV3Error::TooLarge);
+ }
+ let text = std::str::from_utf8(bytes).map_err(|_| ServiceSourceLockV3Error::Malformed)?;
+ let raw = toml::from_str::<RawServiceSourceLockV3>(text)
+ .map_err(|_| ServiceSourceLockV3Error::Malformed)?;
+ validate(&raw)?;
+ let canonical = canonical_bytes(&raw);
+ if bytes != canonical.as_slice() {
+ return Err(ServiceSourceLockV3Error::Noncanonical);
+ }
+ Ok(Self {
+ raw,
+ canonical: canonical.into_boxed_slice(),
+ })
+ }
+
+ #[cfg(test)]
+ pub(crate) fn canonical_bytes(&self) -> &[u8] {
+ &self.canonical
+ }
+
+ pub(crate) fn service(&self) -> &str {
+ &self.raw.service
+ }
+
+ pub(crate) fn revision(&self) -> &str {
+ &self.raw.revision
+ }
+
+ pub(crate) fn workspace_catalog_sha256(&self) -> &str {
+ &self.raw.workspace_catalog_sha256
+ }
+
+ pub(crate) fn source_archive_sha256(&self) -> &str {
+ &self.raw.source_archive_sha256
+ }
+
+ pub(crate) fn cargo_lock_sha256(&self) -> &str {
+ &self.raw.cargo_lock_sha256
+ }
+
+ pub(crate) fn flake_lock_sha256(&self) -> &str {
+ &self.raw.nix.public_input_lock.sha256
+ }
+
+ pub(crate) fn artifact_contract_path(&self) -> &str {
+ &self.raw.artifact_contract.path
+ }
+
+ pub(crate) fn artifact_contract_sha256(&self) -> &str {
+ &self.raw.artifact_contract.sha256
+ }
+
+ pub(crate) const fn contract_versions(&self) -> ContractVersions {
+ self.raw.contract_versions
+ }
+
+ #[cfg(test)]
+ pub(crate) fn fixture(parts: FixtureParts<'_>) -> Result<Self, ServiceSourceLockV3Error> {
+ let FixtureParts {
+ service,
+ revision,
+ workspace_catalog_sha256,
+ source_archive_sha256,
+ cargo_lock_sha256,
+ flake_lock_sha256,
+ artifact_contract_sha256,
+ contract_versions,
+ } = parts;
+ let artifact_path = format!("contracts/release/{service}-artifact-contract.v3.json");
+ let raw = RawServiceSourceLockV3 {
+ schema: "radroots.service.source-lock.v3".to_owned(),
+ contract_version: 3,
+ service: service.to_owned(),
+ repository: LIB_REPOSITORY.to_owned(),
+ revision: revision.to_owned(),
+ architecture: ARCHITECTURE.to_owned(),
+ workspace_catalog_sha256: workspace_catalog_sha256.to_owned(),
+ version: LIB_VERSION.to_owned(),
+ source_archive_sha256: source_archive_sha256.to_owned(),
+ source_archive_contract: RawSourceArchiveContract {
+ binding: "sha256_of_canonical_exact_lib_revision_tree_archive".to_owned(),
+ format: "ustar".to_owned(),
+ compression: "none".to_owned(),
+ compression_timestamp: "not_applicable".to_owned(),
+ entry_order: "bytewise_git_path".to_owned(),
+ path_prefix: "none".to_owned(),
+ file_mode: "git_index_100644_or_100755".to_owned(),
+ uid: 0,
+ gid: 0,
+ uname: String::new(),
+ gname: String::new(),
+ mtime: "lib_revision_commit_timestamp".to_owned(),
+ pax_headers: "forbidden".to_owned(),
+ directory_entries: "omitted".to_owned(),
+ symlinks: "forbidden".to_owned(),
+ hardlinks: "forbidden".to_owned(),
+ submodules: "forbidden".to_owned(),
+ trailer: "two_zero_blocks".to_owned(),
+ },
+ cargo_lock_sha256: cargo_lock_sha256.to_owned(),
+ rust_version: RUST_VERSION.to_owned(),
+ host_feature_profile: HOST_FEATURE_PROFILE.to_owned(),
+ nix: RawQualifiedNix {
+ material: "qualified".to_owned(),
+ lib_revision: revision.to_owned(),
+ supported_systems: vec!["aarch64-darwin".to_owned(), "x86_64-linux".to_owned()],
+ public_input_lock: RawPublicInputLock {
+ path: "flake.lock".to_owned(),
+ sha256: flake_lock_sha256.to_owned(),
+ binding: "exact_regular_file_bytes".to_owned(),
+ mutable_reference: "forbidden".to_owned(),
+ lib_input: "lib".to_owned(),
+ },
+ parent_result: RawParentResult {
+ embedded_in_public_input_lock: false,
+ embedded_in_source_lock: false,
+ storage: "separate_generation_scoped_evidence".to_owned(),
+ },
+ },
+ artifact_contract: RawArtifactContract {
+ path: artifact_path,
+ sha256: artifact_contract_sha256.to_owned(),
+ binding: "exact_regular_file_bytes_in_same_source_revision".to_owned(),
+ },
+ sqlite: RawSqliteContract {
+ high_level_authority: "sqlx_only".to_owned(),
+ second_pool_connection_query_transaction_migration_authority: "forbidden"
+ .to_owned(),
+ incremental_backup_adapter: "sealed_native_sqlx_owned_locked_handle_only"
+ .to_owned(),
+ native_linkage_count: 1,
+ },
+ contract_versions,
+ };
+ validate(&raw)?;
+ let canonical = canonical_bytes(&raw).into_boxed_slice();
+ Ok(Self { raw, canonical })
+ }
+}
+
+fn validate(raw: &RawServiceSourceLockV3) -> Result<(), ServiceSourceLockV3Error> {
+ let expected_artifact = match raw.service.as_str() {
+ "myc" => "contracts/release/myc-artifact-contract.v3.json",
+ "rhi" => "contracts/release/rhi-artifact-contract.v3.json",
+ _ => return Err(ServiceSourceLockV3Error::Invalid),
+ };
+ let archive = &raw.source_archive_contract;
+ if raw.schema != "radroots.service.source-lock.v3"
+ || raw.contract_version != 3
+ || raw.repository != LIB_REPOSITORY
+ || !valid_lower_hex(&raw.revision, 40)
+ || raw.architecture != ARCHITECTURE
+ || !valid_lower_hex(&raw.workspace_catalog_sha256, 64)
+ || raw.version != LIB_VERSION
+ || !valid_lower_hex(&raw.source_archive_sha256, 64)
+ || !valid_lower_hex(&raw.cargo_lock_sha256, 64)
+ || raw.rust_version != RUST_VERSION
+ || raw.host_feature_profile != HOST_FEATURE_PROFILE
+ || archive.binding != "sha256_of_canonical_exact_lib_revision_tree_archive"
+ || archive.format != "ustar"
+ || archive.compression != "none"
+ || archive.compression_timestamp != "not_applicable"
+ || archive.entry_order != "bytewise_git_path"
+ || archive.path_prefix != "none"
+ || archive.file_mode != "git_index_100644_or_100755"
+ || archive.uid != 0
+ || archive.gid != 0
+ || !archive.uname.is_empty()
+ || !archive.gname.is_empty()
+ || archive.mtime != "lib_revision_commit_timestamp"
+ || archive.pax_headers != "forbidden"
+ || archive.directory_entries != "omitted"
+ || archive.symlinks != "forbidden"
+ || archive.hardlinks != "forbidden"
+ || archive.submodules != "forbidden"
+ || archive.trailer != "two_zero_blocks"
+ || raw.nix.material != "qualified"
+ || raw.nix.lib_revision != raw.revision
+ || raw.nix.supported_systems != ["aarch64-darwin", "x86_64-linux"]
+ || raw.nix.public_input_lock.path != "flake.lock"
+ || !valid_lower_hex(&raw.nix.public_input_lock.sha256, 64)
+ || raw.nix.public_input_lock.binding != "exact_regular_file_bytes"
+ || raw.nix.public_input_lock.mutable_reference != "forbidden"
+ || raw.nix.public_input_lock.lib_input != "lib"
+ || raw.nix.parent_result.embedded_in_public_input_lock
+ || raw.nix.parent_result.embedded_in_source_lock
+ || raw.nix.parent_result.storage != "separate_generation_scoped_evidence"
+ || raw.artifact_contract.path != expected_artifact
+ || !valid_lower_hex(&raw.artifact_contract.sha256, 64)
+ || raw.artifact_contract.binding != "exact_regular_file_bytes_in_same_source_revision"
+ || raw.sqlite.high_level_authority != "sqlx_only"
+ || raw
+ .sqlite
+ .second_pool_connection_query_transaction_migration_authority
+ != "forbidden"
+ || raw.sqlite.incremental_backup_adapter != "sealed_native_sqlx_owned_locked_handle_only"
+ || raw.sqlite.native_linkage_count != 1
+ || !contract_versions_valid(raw.contract_versions)
+ {
+ Err(ServiceSourceLockV3Error::Invalid)
+ } else {
+ Ok(())
+ }
+}
+
+fn contract_versions_valid(versions: ContractVersions) -> bool {
+ versions.config() != 0
+ && versions.state() != 0
+ && versions.admin() != 0
+ && versions.status() != 0
+ && versions.provider() != 0
+}
+
+fn canonical_bytes(raw: &RawServiceSourceLockV3) -> Vec<u8> {
+ format!(
+ concat!(
+ "schema = \"radroots.service.source-lock.v3\"\n",
+ "contract_version = 3\n",
+ "service = \"{}\"\n",
+ "repository = \"https://github.com/radrootslabs/lib\"\n",
+ "revision = \"{}\"\n",
+ "architecture = \"radroots.crates.release.v2\"\n",
+ "workspace_catalog_sha256 = \"{}\"\n",
+ "version = \"0.1.0-alpha\"\n",
+ "source_archive_sha256 = \"{}\"\n",
+ "cargo_lock_sha256 = \"{}\"\n",
+ "rust_version = \"1.97.1\"\n",
+ "host_feature_profile = \"service-host\"\n\n",
+ "[source_archive_contract]\n",
+ "binding = \"sha256_of_canonical_exact_lib_revision_tree_archive\"\n",
+ "format = \"ustar\"\n",
+ "compression = \"none\"\n",
+ "compression_timestamp = \"not_applicable\"\n",
+ "entry_order = \"bytewise_git_path\"\n",
+ "path_prefix = \"none\"\n",
+ "file_mode = \"git_index_100644_or_100755\"\n",
+ "uid = 0\n",
+ "gid = 0\n",
+ "uname = \"\"\n",
+ "gname = \"\"\n",
+ "mtime = \"lib_revision_commit_timestamp\"\n",
+ "pax_headers = \"forbidden\"\n",
+ "directory_entries = \"omitted\"\n",
+ "symlinks = \"forbidden\"\n",
+ "hardlinks = \"forbidden\"\n",
+ "submodules = \"forbidden\"\n",
+ "trailer = \"two_zero_blocks\"\n\n",
+ "[nix]\n",
+ "material = \"qualified\"\n",
+ "lib_revision = \"{}\"\n",
+ "supported_systems = [\"aarch64-darwin\", \"x86_64-linux\"]\n\n",
+ "[nix.public_input_lock]\n",
+ "path = \"flake.lock\"\n",
+ "sha256 = \"{}\"\n",
+ "binding = \"exact_regular_file_bytes\"\n",
+ "mutable_reference = \"forbidden\"\n",
+ "lib_input = \"lib\"\n\n",
+ "[nix.parent_result]\n",
+ "embedded_in_public_input_lock = false\n",
+ "embedded_in_source_lock = false\n",
+ "storage = \"separate_generation_scoped_evidence\"\n\n",
+ "[artifact_contract]\n",
+ "path = \"{}\"\n",
+ "sha256 = \"{}\"\n",
+ "binding = \"exact_regular_file_bytes_in_same_source_revision\"\n\n",
+ "[sqlite]\n",
+ "high_level_authority = \"sqlx_only\"\n",
+ "second_pool_connection_query_transaction_migration_authority = \"forbidden\"\n",
+ "incremental_backup_adapter = \"sealed_native_sqlx_owned_locked_handle_only\"\n",
+ "native_linkage_count = 1\n\n",
+ "[contract_versions]\n",
+ "config = {}\n",
+ "state = {}\n",
+ "admin = {}\n",
+ "status = {}\n",
+ "provider = {}\n"
+ ),
+ raw.service,
+ raw.revision,
+ raw.workspace_catalog_sha256,
+ raw.source_archive_sha256,
+ raw.cargo_lock_sha256,
+ raw.nix.lib_revision,
+ raw.nix.public_input_lock.sha256,
+ raw.artifact_contract.path,
+ raw.artifact_contract.sha256,
+ raw.contract_versions.config(),
+ raw.contract_versions.state(),
+ raw.contract_versions.admin(),
+ raw.contract_versions.status(),
+ raw.contract_versions.provider(),
+ )
+ .into_bytes()
+}
+
+fn valid_lower_hex(value: &str, length: usize) -> bool {
+ value.len() == length
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const MYC_LOCK: &[u8] = br#"schema = "radroots.service.source-lock.v3"
+contract_version = 3
+service = "myc"
+repository = "https://github.com/radrootslabs/lib"
+revision = "1111111111111111111111111111111111111111"
+architecture = "radroots.crates.release.v2"
+workspace_catalog_sha256 = "2222222222222222222222222222222222222222222222222222222222222222"
+version = "0.1.0-alpha"
+source_archive_sha256 = "3333333333333333333333333333333333333333333333333333333333333333"
+cargo_lock_sha256 = "4444444444444444444444444444444444444444444444444444444444444444"
+rust_version = "1.97.1"
+host_feature_profile = "service-host"
+
+[source_archive_contract]
+binding = "sha256_of_canonical_exact_lib_revision_tree_archive"
+format = "ustar"
+compression = "none"
+compression_timestamp = "not_applicable"
+entry_order = "bytewise_git_path"
+path_prefix = "none"
+file_mode = "git_index_100644_or_100755"
+uid = 0
+gid = 0
+uname = ""
+gname = ""
+mtime = "lib_revision_commit_timestamp"
+pax_headers = "forbidden"
+directory_entries = "omitted"
+symlinks = "forbidden"
+hardlinks = "forbidden"
+submodules = "forbidden"
+trailer = "two_zero_blocks"
+
+[nix]
+material = "qualified"
+lib_revision = "1111111111111111111111111111111111111111"
+supported_systems = ["aarch64-darwin", "x86_64-linux"]
+
+[nix.public_input_lock]
+path = "flake.lock"
+sha256 = "5555555555555555555555555555555555555555555555555555555555555555"
+binding = "exact_regular_file_bytes"
+mutable_reference = "forbidden"
+lib_input = "lib"
+
+[nix.parent_result]
+embedded_in_public_input_lock = false
+embedded_in_source_lock = false
+storage = "separate_generation_scoped_evidence"
+
+[artifact_contract]
+path = "contracts/release/myc-artifact-contract.v3.json"
+sha256 = "6666666666666666666666666666666666666666666666666666666666666666"
+binding = "exact_regular_file_bytes_in_same_source_revision"
+
+[sqlite]
+high_level_authority = "sqlx_only"
+second_pool_connection_query_transaction_migration_authority = "forbidden"
+incremental_backup_adapter = "sealed_native_sqlx_owned_locked_handle_only"
+native_linkage_count = 1
+
+[contract_versions]
+config = 1
+state = 2
+admin = 3
+status = 4
+provider = 5
+"#;
+
+ #[test]
+ fn canonical_v3_lock_round_trips_and_exposes_exact_bindings() {
+ let lock = ServiceSourceLockV3::from_canonical_bytes(MYC_LOCK).expect("v3 lock");
+ assert_eq!(lock.canonical_bytes(), MYC_LOCK);
+ assert_eq!(lock.service(), "myc");
+ assert_eq!(lock.revision(), "1".repeat(40));
+ assert_eq!(
+ lock.artifact_contract_path(),
+ "contracts/release/myc-artifact-contract.v3.json"
+ );
+ assert_eq!(
+ lock.contract_versions(),
+ ContractVersions::new(1, 2, 3, 4, 5)
+ );
+ }
+
+ #[test]
+ fn v3_lock_rejects_noncanonical_and_independent_semantic_drift() {
+ let noncanonical = String::from_utf8(MYC_LOCK.to_vec())
+ .expect("UTF-8")
+ .replace("schema =", "schema =");
+ assert!(ServiceSourceLockV3::from_canonical_bytes(noncanonical.as_bytes()).is_err());
+ for (from, to) in [
+ ("service = \"myc\"", "service = \"other\""),
+ ("material = \"qualified\"", "material = \"deferred\""),
+ ("native_linkage_count = 1", "native_linkage_count = 2"),
+ ("trailer = \"two_zero_blocks\"", "trailer = \"other\""),
+ ] {
+ let drifted = String::from_utf8(MYC_LOCK.to_vec())
+ .expect("UTF-8")
+ .replace(from, to);
+ assert!(
+ ServiceSourceLockV3::from_canonical_bytes(drifted.as_bytes()).is_err(),
+ "{from}"
+ );
+ }
+ }
+}