lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 7d7b454b4c9ed86569671993bd03ca868b676665
parent 04b532678eb526d8f7eefdf22ebe01ba442e53dd
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 07:58:41 +0000

release: separate deferred Nix source identity

- add canonical source-lock v2 with closed absent and deferred Nix material
- preserve active Cargo revision authority while binding deferred Nix inputs
- version build and release qualification contracts and source-bundle evidence
- reject predecessor ambiguity and strictly validate bounded Nix lock material

Diffstat:
MAGENTS.md | 31++++++++++++++++++-------------
Acontracts/architecture/decisions/services_hardening_build_qualification.v2.json | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/architecture/decisions/services_hardening_release_artifacts.v2.json | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/architecture/decisions/services_hardening_source_lock.v2.json | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/fixtures/service-build-qualification/Cargo.toml | 1+
Dtools/xtask/fixtures/service-build-qualification/flake.lock | 1-
Dtools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v1.toml | 20--------------------
Atools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v2.toml | 22++++++++++++++++++++++
Mtools/xtask/src/service_build_qualification.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mtools/xtask/src/service_release_artifacts.rs | 207++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mtools/xtask/src/service_source_lock.rs | 661++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtools/xtask/src/service_source_lock_command.rs | 376+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
12 files changed, 1446 insertions(+), 245 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -37,21 +37,26 @@ This file exists for compatibility with tools that look for AGENTS.md. Only the first two are generated-artifact product identities; accepting a service consumer marker must not expose an artifact route. - The canonical service source lock is the bounded, canonical - `radroots.service.source-lock.v1.toml` model. It binds the exact public Lib - repository and full revision, Lib source-archive and workspace-catalog - digests, the service `Cargo.lock` and `flake.lock` digests, Rust `1.97.1`, - the `service-host` feature profile, and positive config, state, admin, - status, and provider contract versions. Keep its model and diagnostics - private to repo tooling, reject noncanonical or extra fields, and never put - credentials, local paths, floating refs, or private repository identity in - it. + `radroots.service.source-lock.v2.toml` model. It binds the exact active public + Lib repository and full revision, Lib source-archive and workspace-catalog + digests, the service `Cargo.lock` digest, Rust `1.97.1`, the `service-host` + feature profile, positive config, state, admin, status, and provider contract + versions, and an exact closed Nix-material state. `absent` requires both Nix + files to be absent. `deferred` independently binds an exact mutually + consistent `flake.nix` and `flake.lock` revision and digest without claiming + Nix qualification or active-revision alignment. Keep the model and + diagnostics private to repo tooling, reject noncanonical or extra fields, + and never put credentials, local paths, floating refs, or private repository + identity in it. - Generate or verify that lock with `cargo xtask service-source-lock --mode write|check --service-root <absolute-directory> --source-archive <absolute-bundle>`. The service root supplies the exact - `workspace.metadata.radroots.service_source_lock` Cargo metadata, and every - Lib dependency, the Cargo lock, the direct revision-pinned Nix input, the - source archive, and the canonical public remote must agree. The command - rejects every source-tree change except the exact generated lock path. + `workspace.metadata.radroots.service_source_lock` Cargo metadata. Every Lib + dependency, the Cargo lock, source archive, and canonical public remote must + agree on the active revision. Deferred Nix inputs must agree with each other + and remain remotely reachable, but need not equal the active revision before + terminal Nix alignment. The command rejects every source-tree change except + the exact generated lock path. - Generate or verify one immutable service release artifact set with `cargo xtask service-release-artifacts --mode write|check --service-root <absolute-directory> --input-root <absolute-directory> --output-root @@ -62,7 +67,7 @@ This file exists for compatibility with tools that look for AGENTS.md. provenance signing input, and checksums. Signing credentials and signatures remain external; generated artifacts must contain no protected material. - The native shared-build qualification contract is - `contracts/architecture/decisions/services_hardening_build_qualification.v1.json`. + `contracts/architecture/decisions/services_hardening_build_qualification.v2.json`. It freezes the supported Rust targets, standalone Cargo and xtask commands, native release evidence, and the fixture agreement among Cargo metadata, the source lock, and release metadata. Nix package/app/check, development diff --git a/contracts/architecture/decisions/services_hardening_build_qualification.v2.json b/contracts/architecture/decisions/services_hardening_build_qualification.v2.json @@ -0,0 +1,59 @@ +{ + "schema": "radroots.services-hardening.build-qualification-decisions.v2", + "contract_version": 2, + "decision_state": "active", + "predecessor": { + "schema": "radroots.services-hardening.build-qualification-decisions.v1", + "filename": "services_hardening_build_qualification.v1.json", + "transition": "forward_only_replace" + }, + "qualification_scope": "native_release_foundation", + "fixture_root": "tools/xtask/fixtures/service-build-qualification", + "supported_rust_targets": [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu" + ], + "required_native_commands": [ + "cargo build --locked --release", + "cargo fmt --all --check", + "cargo check --workspace --all-targets --locked", + "cargo test --workspace --all-targets --locked", + "cargo clippy --workspace --all-targets --locked -- -D warnings", + "RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --locked" + ], + "required_xtask_commands": [ + "cargo test --locked -p xtask service_source_lock::tests", + "cargo test --locked -p xtask service_release_artifacts::tests", + "cargo test --locked -p xtask service_build_qualification::tests", + "cargo run --locked -q -p xtask -- contract validate", + "cargo run --locked -q -p xtask -- release preflight" + ], + "required_evidence": [ + "cargo_lock", + "source_lock", + "package_metadata", + "release_metadata", + "binary_archive", + "oci_source_artifact", + "cyclonedx_sbom", + "notices", + "artifact_manifest", + "unsigned_provenance_input", + "checksums" + ], + "fixture_source_lock": "tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v2.toml", + "fixture_contract": "source_lock_package_and_release_metadata_exact_agreement", + "release_artifact_command": "cargo xtask service-release-artifacts", + "source_lock_command": "cargo xtask service-source-lock", + "signing_authority": "external_only", + "deferred_outputs": [ + "nix_packages", + "nix_apps", + "nix_checks", + "nix_development_shells", + "nixos_modules", + "nix_produced_oci" + ] +} diff --git a/contracts/architecture/decisions/services_hardening_release_artifacts.v2.json b/contracts/architecture/decisions/services_hardening_release_artifacts.v2.json @@ -0,0 +1,108 @@ +{ + "schema": "radroots.services-hardening.release-artifacts-decisions.v2", + "contract_version": 2, + "decision_state": "active", + "predecessor": { + "schema": "radroots.services-hardening.release-artifacts-decisions.v1", + "filename": "services_hardening_release_artifacts.v1.json", + "transition": "forward_only_replace" + }, + "command": "cargo xtask service-release-artifacts", + "modes": [ + "check", + "write" + ], + "required_arguments": [ + "mode", + "service_root", + "input_root", + "output_root", + "target", + "source_date_epoch" + ], + "service_metadata_path": "Cargo.toml.workspace.metadata.radroots.service_release", + "service_metadata_fields": [ + "service", + "service_package", + "binary_name", + "version" + ], + "supported_targets": [ + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu" + ], + "input_inventory": [ + "config.example.toml", + "config.schema.json", + "lib-source.bundle", + "nixos-module.nix", + "oci-image.tar.gz", + "service-binary", + "service-source.bundle", + "systemd.service" + ], + "excluded_parent_owned_inputs": [ + "backup_restore_runbook", + "operator_runbook" + ], + "service_root_inventory": [ + "LICENSE-APACHE", + "LICENSE-MIT", + "radroots.service.source-lock.v2.toml" + ], + "output_inventory": [ + "LICENSE-APACHE", + "LICENSE-MIT", + "SHA256SUMS", + "THIRD-PARTY-NOTICES.txt", + "artifact-manifest.v1.json", + "binary.tar.gz", + "config.example.toml", + "config.schema.json", + "lib-source.bundle", + "nixos-module.nix", + "oci-image.tar.gz", + "oci-image.v1.json", + "provenance-input.v1.json", + "radroots.service.source-lock.v2.toml", + "sbom.cdx.json", + "service-source.bundle", + "source-bundles.v2.json", + "systemd.service" + ], + "canonical_json": "compact_utf8_json_with_one_final_lf", + "checksum_format": "sha256_lower_hex_two_spaces_path_lf_sorted_by_path", + "sbom_format": "cyclonedx_json_1_5_locked_cargo_graph", + "provenance_posture": "deterministic_unsigned_slsa_v1_signing_input_external_keys_only", + "protected_material_scan_scope": "all_textual_release_inputs_and_generated_documents", + "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_bundle_bytes": 1073741824, + "oci_bytes": 2147483648, + "cargo_metadata_bytes": 33554432, + "packages": 8192, + "workspace_packages": 64 + }, + "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/architecture/decisions/services_hardening_source_lock.v2.json b/contracts/architecture/decisions/services_hardening_source_lock.v2.json @@ -0,0 +1,136 @@ +{ + "schema": "radroots.services-hardening.source-lock-decisions.v2", + "contract_version": 2, + "decision_state": "active", + "predecessor": { + "schema": "radroots.service.source-lock.v1", + "filename": "radroots.service.source-lock.v1.toml", + "transition": "forward_only_replace" + }, + "lock_filename": "radroots.service.source-lock.v2.toml", + "lock_schema": "radroots.service.source-lock.v2", + "canonical_encoding": "compact_canonical_toml_with_final_newline", + "maximum_lock_utf8_bytes": 4096, + "maximum_service_utf8_bytes": 128, + "canonical_field_order_deferred": [ + "schema", + "contract_version", + "service", + "repository", + "revision", + "architecture", + "workspace_catalog_sha256", + "version", + "source_archive_sha256", + "cargo_lock_sha256", + "rust_version", + "host_feature_profile", + "nix.material", + "nix.lib_revision", + "nix.flake_lock_sha256", + "contract_versions.config", + "contract_versions.state", + "contract_versions.admin", + "contract_versions.status", + "contract_versions.provider" + ], + "canonical_field_order_absent": [ + "schema", + "contract_version", + "service", + "repository", + "revision", + "architecture", + "workspace_catalog_sha256", + "version", + "source_archive_sha256", + "cargo_lock_sha256", + "rust_version", + "host_feature_profile", + "nix.material", + "contract_versions.config", + "contract_versions.state", + "contract_versions.admin", + "contract_versions.status", + "contract_versions.provider" + ], + "fixed": { + "repository": "https://github.com/radrootslabs/lib", + "architecture": "radroots.crates.release.v2", + "version": "0.1.0-alpha", + "rust_version": "1.97.1", + "host_feature_profile": "service-host" + }, + "revision_encoding": "git_oid_lowercase_hex_40", + "digest_encoding": "sha256_lowercase_hex_64", + "digest_subjects": { + "workspace_catalog_sha256": "lib/contracts/crates/catalog.v2.toml", + "source_archive_sha256": "canonical_lib_revision_source_archive", + "cargo_lock_sha256": "service/Cargo.lock", + "flake_lock_sha256": "service/flake.lock" + }, + "service_identifier": "ascii_lower_snake_case_starting_with_letter_ending_with_letter_or_digit_no_empty_segments_1_to_128_bytes", + "contract_version_rule": "u32_nonzero", + "negative_error_codes": [ + "invalid_contract_version", + "invalid_digest", + "invalid_feature_profile", + "invalid_fixed_identity", + "invalid_nix_material", + "invalid_revision", + "invalid_service", + "invalid_toolchain", + "malformed", + "noncanonical", + "too_large" + ], + "canonical_vectors": { + "deferred": { + "toml": "schema = \"radroots.service.source-lock.v2\"\ncontract_version = 2\nservice = \"fixture_service\"\nrepository = \"https://github.com/radrootslabs/lib\"\nrevision = \"2222222222222222222222222222222222222222\"\narchitecture = \"radroots.crates.release.v2\"\nworkspace_catalog_sha256 = \"2222222222222222222222222222222222222222222222222222222222222222\"\nversion = \"0.1.0-alpha\"\nsource_archive_sha256 = \"3333333333333333333333333333333333333333333333333333333333333333\"\ncargo_lock_sha256 = \"3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0\"\nrust_version = \"1.97.1\"\nhost_feature_profile = \"service-host\"\n\n[nix]\nmaterial = \"deferred\"\nlib_revision = \"1111111111111111111111111111111111111111\"\nflake_lock_sha256 = \"13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353\"\n\n[contract_versions]\nconfig = 1\nstate = 2\nadmin = 3\nstatus = 4\nprovider = 5\n", + "sha256": "da7b8894a6480e7022d9937369a5c9bafbf90128a901652923e501c52aced1a2" + }, + "absent": { + "toml": "schema = \"radroots.service.source-lock.v2\"\ncontract_version = 2\nservice = \"fixture_service\"\nrepository = \"https://github.com/radrootslabs/lib\"\nrevision = \"2222222222222222222222222222222222222222\"\narchitecture = \"radroots.crates.release.v2\"\nworkspace_catalog_sha256 = \"2222222222222222222222222222222222222222222222222222222222222222\"\nversion = \"0.1.0-alpha\"\nsource_archive_sha256 = \"3333333333333333333333333333333333333333333333333333333333333333\"\ncargo_lock_sha256 = \"3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0\"\nrust_version = \"1.97.1\"\nhost_feature_profile = \"service-host\"\n\n[nix]\nmaterial = \"absent\"\n\n[contract_versions]\nconfig = 1\nstate = 2\nadmin = 3\nstatus = 4\nprovider = 5\n", + "sha256": "2af058ba042509c77efd6dc264c60a7abf4371378223e52e34eb441cab7e263c" + } + }, + "operations": { + "command": "cargo xtask service-source-lock", + "modes": ["check", "write"], + "required_arguments": ["mode", "service_root", "source_archive"], + "service_metadata_path": "Cargo.toml.workspace.metadata.radroots.service_source_lock", + "service_metadata_fields": [ + "service", + "host_feature_profile", + "nix_material", + "config_contract_version", + "state_contract_version", + "admin_contract_version", + "status_contract_version", + "provider_contract_version" + ], + "lib_dependency_inventory": "verified_source_archive_workspace_catalog", + "source_cleanliness": "all_changes_forbidden_except_exact_generated_lock_path", + "predecessor_lock_presence": "forbidden", + "service_revision_stability": "same_head_before_and_after_evidence_and_output", + "active_revision_agreement": [ + "cargo_manifests", + "cargo_lock", + "source_archive", + "canonical_public_remote" + ], + "nix_material_states": ["absent", "deferred"], + "deferred_nix_agreement": [ + "flake_expression", + "flake_lock", + "source_lock_nix_revision", + "canonical_public_remote" + ], + "maximum_source_archive_bytes": 1073741824 + }, + "deferred_operations": [ + "embedded_build_information_agreement", + "nix_qualification", + "nix_active_revision_alignment" + ] +} diff --git a/tools/xtask/fixtures/service-build-qualification/Cargo.toml b/tools/xtask/fixtures/service-build-qualification/Cargo.toml @@ -14,6 +14,7 @@ resolver = "3" [workspace.metadata.radroots.service_source_lock] service = "fixture_service" host_feature_profile = "service-host" +nix_material = "absent" config_contract_version = 1 state_contract_version = 2 admin_contract_version = 3 diff --git a/tools/xtask/fixtures/service-build-qualification/flake.lock b/tools/xtask/fixtures/service-build-qualification/flake.lock @@ -1 +0,0 @@ -{"nodes":{},"root":"root","version":7} diff --git a/tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v1.toml b/tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v1.toml @@ -1,20 +0,0 @@ -schema = "radroots.service.source-lock.v1" -contract_version = 1 -service = "fixture_service" -repository = "https://github.com/radrootslabs/lib" -revision = "2222222222222222222222222222222222222222" -architecture = "radroots.crates.release.v2" -workspace_catalog_sha256 = "2222222222222222222222222222222222222222222222222222222222222222" -version = "0.1.0-alpha" -source_archive_sha256 = "3333333333333333333333333333333333333333333333333333333333333333" -cargo_lock_sha256 = "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0" -flake_lock_sha256 = "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353" -rust_version = "1.97.1" -host_feature_profile = "service-host" - -[contract_versions] -config = 1 -state = 2 -admin = 3 -status = 4 -provider = 5 diff --git a/tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v2.toml b/tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v2.toml @@ -0,0 +1,22 @@ +schema = "radroots.service.source-lock.v2" +contract_version = 2 +service = "fixture_service" +repository = "https://github.com/radrootslabs/lib" +revision = "2222222222222222222222222222222222222222" +architecture = "radroots.crates.release.v2" +workspace_catalog_sha256 = "2222222222222222222222222222222222222222222222222222222222222222" +version = "0.1.0-alpha" +source_archive_sha256 = "3333333333333333333333333333333333333333333333333333333333333333" +cargo_lock_sha256 = "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0" +rust_version = "1.97.1" +host_feature_profile = "service-host" + +[nix] +material = "absent" + +[contract_versions] +config = 1 +state = 2 +admin = 3 +status = 4 +provider = 5 diff --git a/tools/xtask/src/service_build_qualification.rs b/tools/xtask/src/service_build_qualification.rs @@ -3,10 +3,12 @@ use std::{fmt, fs, io::Read as _, path::Path}; use serde::Deserialize; use sha2::{Digest as _, Sha256}; -use crate::service_source_lock::{LOCK_FILENAME, ServiceSourceLockV1}; +use crate::service_source_lock::{ + LOCK_FILENAME, NixMaterialState, PREDECESSOR_LOCK_FILENAME, ServiceSourceLockV2, +}; const CONTRACT_RELATIVE: &str = - "contracts/architecture/decisions/services_hardening_build_qualification.v1.json"; + "contracts/architecture/decisions/services_hardening_build_qualification.v2.json"; const FIXTURE_RELATIVE: &str = "tools/xtask/fixtures/service-build-qualification"; const MAX_CONTRACT_BYTES: usize = 32_768; const MAX_FIXTURE_FILE_BYTES: usize = 1_048_576; @@ -77,6 +79,7 @@ struct BuildQualificationDecision { schema: String, contract_version: u32, decision_state: String, + predecessor: PredecessorDecision, qualification_scope: String, fixture_root: String, supported_rust_targets: Vec<String>, @@ -91,6 +94,14 @@ struct BuildQualificationDecision { deferred_outputs: Vec<String>, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PredecessorDecision { + schema: String, + filename: String, + transition: String, +} + pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> { validate_contract_inner(workspace_root).map_err(|error| error.to_string()) } @@ -108,9 +119,13 @@ fn validate_contract_inner(workspace_root: &Path) -> Result<(), BuildQualificati } fn validate_decision(decision: &BuildQualificationDecision) -> Result<(), BuildQualificationError> { - let exact = decision.schema == "radroots.services-hardening.build-qualification-decisions.v1" - && decision.contract_version == 1 + let exact = decision.schema == "radroots.services-hardening.build-qualification-decisions.v2" + && decision.contract_version == 2 && decision.decision_state == "active" + && decision.predecessor.schema + == "radroots.services-hardening.build-qualification-decisions.v1" + && decision.predecessor.filename == "services_hardening_build_qualification.v1.json" + && decision.predecessor.transition == "forward_only_replace" && decision.qualification_scope == "native_release_foundation" && decision.fixture_root == FIXTURE_RELATIVE && decision.supported_rust_targets == SUPPORTED_RUST_TARGETS @@ -118,7 +133,7 @@ fn validate_decision(decision: &BuildQualificationDecision) -> Result<(), BuildQ && decision.required_xtask_commands == REQUIRED_XTASK_COMMANDS && decision.required_evidence == REQUIRED_EVIDENCE && decision.fixture_source_lock - == "tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v1.toml" + == "tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v2.toml" && decision.fixture_contract == "source_lock_package_and_release_metadata_exact_agreement" && decision.release_artifact_command == "cargo xtask service-release-artifacts" && decision.source_lock_command == "cargo xtask service-source-lock" @@ -138,18 +153,13 @@ fn validate_fixture(workspace_root: &Path) -> Result<(), BuildQualificationError 4_096, BuildQualificationError::InvalidFixture, )?; - let lock = ServiceSourceLockV1::from_canonical_bytes(&lock_bytes) + let lock = ServiceSourceLockV2::from_canonical_bytes(&lock_bytes) .map_err(|_| BuildQualificationError::InvalidFixture)?; let cargo_lock = read_bounded( &fixture.join("Cargo.lock"), MAX_FIXTURE_FILE_BYTES, BuildQualificationError::InvalidFixture, )?; - let flake_lock = read_bounded( - &fixture.join("flake.lock"), - MAX_FIXTURE_FILE_BYTES, - BuildQualificationError::InvalidFixture, - )?; let manifest = read_bounded( &fixture.join("Cargo.toml"), MAX_FIXTURE_FILE_BYTES, @@ -177,7 +187,15 @@ fn validate_fixture(workspace_root: &Path) -> Result<(), BuildQualificationError let exact = lock.service() == "fixture_service" && lock.revision() == "2222222222222222222222222222222222222222" && lock.cargo_lock_sha256() == digest(&cargo_lock) - && lock.flake_lock_sha256() == digest(&flake_lock) + && lock.nix_material_state() == NixMaterialState::Absent + && lock.nix_lib_revision().is_none() + && lock.flake_lock_sha256().is_none() + && ["flake.nix", "flake.lock", PREDECESSOR_LOCK_FILENAME] + .into_iter() + .all(|name| { + fs::symlink_metadata(fixture.join(name)) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound) + }) && versions.config() == 1 && versions.state() == 2 && versions.admin() == 3 @@ -188,13 +206,17 @@ fn validate_fixture(workspace_root: &Path) -> Result<(), BuildQualificationError .and_then(|value| value.get("version")) .and_then(toml::Value::as_str) == Some("0.1.0-alpha") - && source_metadata.len() == 7 + && source_metadata.len() == 8 && source_metadata.get("service").and_then(toml::Value::as_str) == Some("fixture_service") && source_metadata .get("host_feature_profile") .and_then(toml::Value::as_str) == Some("service-host") && source_metadata + .get("nix_material") + .and_then(toml::Value::as_str) + == Some("absent") + && source_metadata .get("config_contract_version") .and_then(toml::Value::as_integer) == Some(i64::from(versions.config())) @@ -284,8 +306,11 @@ mod tests { let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("decision json"); for (pointer, replacement) in [ ("/schema", serde_json::json!("other")), - ("/contract_version", serde_json::json!(2)), + ("/contract_version", serde_json::json!(1)), ("/decision_state", serde_json::json!("draft")), + ("/predecessor/schema", serde_json::json!("other")), + ("/predecessor/filename", serde_json::json!("other")), + ("/predecessor/transition", serde_json::json!("other")), ("/qualification_scope", serde_json::json!("other")), ("/fixture_root", serde_json::json!("other")), ("/supported_rust_targets", serde_json::json!([])), @@ -330,12 +355,11 @@ mod tests { "service_package = \"other-service\"", ), ( - "radroots.service.source-lock.v1.toml", + "radroots.service.source-lock.v2.toml", "revision = \"2222222222222222222222222222222222222222\"", "revision = \"3333333333333333333333333333333333333333\"", ), ("Cargo.lock", "version = 4", "version = 3"), - ("flake.lock", "\"version\":7", "\"version\":8"), ] { let root = copied_fixture(); let path = root.path().join(FIXTURE_RELATIVE).join(name); @@ -357,6 +381,17 @@ mod tests { validate_fixture(root.path()), Err(BuildQualificationError::InvalidFixture) ); + + for name in ["flake.nix", "flake.lock", PREDECESSOR_LOCK_FILENAME] { + let root = copied_fixture(); + fs::write(root.path().join(FIXTURE_RELATIVE).join(name), b"unexpected") + .expect("unexpected Nix material"); + assert_eq!( + validate_fixture(root.path()), + Err(BuildQualificationError::InvalidFixture), + "accepted absent-state fixture with {name}" + ); + } } #[test] @@ -495,7 +530,7 @@ mod tests { let destination = root.path().join(FIXTURE_RELATIVE); fs::create_dir_all(&destination).expect("fixture directory"); let source = workspace_root().join(FIXTURE_RELATIVE); - for name in ["Cargo.toml", "Cargo.lock", "flake.lock", LOCK_FILENAME] { + for name in ["Cargo.toml", "Cargo.lock", LOCK_FILENAME] { fs::copy(source.join(name), destination.join(name)).expect("fixture file"); } root diff --git a/tools/xtask/src/service_release_artifacts.rs b/tools/xtask/src/service_release_artifacts.rs @@ -13,10 +13,13 @@ use sha2::{Digest as _, Sha256}; use tar::{Builder as TarBuilder, Header as TarHeader}; use tempfile::TempDir; -use crate::service_source_lock::{LIB_REPOSITORY, LOCK_FILENAME, ServiceSourceLockV1}; +use crate::service_source_lock::{ + LIB_REPOSITORY, LOCK_FILENAME, NixMaterialState, PREDECESSOR_LOCK_FILENAME, + ServiceSourceLockV2, validate_deferred_nix_material, +}; const CONTRACT_RELATIVE: &str = - "contracts/architecture/decisions/services_hardening_release_artifacts.v1.json"; + "contracts/architecture/decisions/services_hardening_release_artifacts.v2.json"; const INPUT_NAMES: [&str; 8] = [ "config.example.toml", "config.schema.json", @@ -41,10 +44,10 @@ const OUTPUT_NAMES: [&str; 18] = [ "oci-image.tar.gz", "oci-image.v1.json", "provenance-input.v1.json", - "radroots.service.source-lock.v1.toml", + "radroots.service.source-lock.v2.toml", "sbom.cdx.json", "service-source.bundle", - "source-bundles.v1.json", + "source-bundles.v2.json", "systemd.service", ]; const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]; @@ -262,7 +265,11 @@ struct SourceBundleDocument { source_lock_sha256: String, workspace_catalog_sha256: String, cargo_lock_sha256: String, - flake_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>, } #[derive(Debug, Serialize)] @@ -325,6 +332,7 @@ struct ReleaseDecision { schema: String, contract_version: u32, decision_state: String, + predecessor: ReleasePredecessor, command: String, modes: Vec<String>, required_arguments: Vec<String>, @@ -349,6 +357,14 @@ struct ReleaseDecision { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] +struct ReleasePredecessor { + schema: String, + filename: String, + transition: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct ReleaseMaximums { text_input_bytes: u64, generated_document_bytes: u64, @@ -405,7 +421,7 @@ fn run_inner( MAX_SOURCE_LOCK_BYTES, ReleaseArtifactError::InvalidSourceLock, )?; - let source_lock = ServiceSourceLockV1::from_canonical_bytes(&source_lock_bytes) + let source_lock = ServiceSourceLockV2::from_canonical_bytes(&source_lock_bytes) .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; if source_lock.service() != metadata.service { return Err(ReleaseArtifactError::InvalidSourceLock); @@ -488,8 +504,8 @@ 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.v1", - contract_version: 1, + schema: "radroots.service.source-bundles.v2", + contract_version: 2, service: metadata.service.clone(), service_revision: initial_head.clone(), lib_repository: LIB_REPOSITORY, @@ -499,10 +515,15 @@ fn run_inner( 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(), - flake_lock_sha256: source_lock.flake_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), }; write_json( - &staging.path().join("source-bundles.v1.json"), + &staging.path().join("source-bundles.v2.json"), &source_document, )?; write_json(&staging.path().join("sbom.cdx.json"), &sbom)?; @@ -622,18 +643,48 @@ fn read_release_metadata(root: &Path) -> Result<ReleaseMetadata, ReleaseArtifact fn validate_source_lock_files( root: &Path, - source_lock: &ServiceSourceLockV1, + source_lock: &ServiceSourceLockV2, ) -> Result<(), ReleaseArtifactError> { + match fs::symlink_metadata(root.join(PREDECESSOR_LOCK_FILENAME)) { + 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)?; - let flake_lock = hash_regular(&root.join("flake.lock"), MAX_SERVICE_FLAKE_LOCK_BYTES) - .map_err(|_| ReleaseArtifactError::InvalidSourceLock)?; - if cargo_lock.sha256 == source_lock.cargo_lock_sha256() - && flake_lock.sha256 == source_lock.flake_lock_sha256() - { - Ok(()) - } else { - 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) + } + } } } @@ -1218,7 +1269,7 @@ fn output_maximum(name: &str) -> Result<u64, ReleaseArtifactError> { | "config.example.toml" | "config.schema.json" | "nixos-module.nix" - | "radroots.service.source-lock.v1.toml" + | "radroots.service.source-lock.v2.toml" | "systemd.service" => Ok(MAX_TEXT_INPUT_BYTES), "SHA256SUMS" | "THIRD-PARTY-NOTICES.txt" @@ -1226,7 +1277,7 @@ fn output_maximum(name: &str) -> Result<u64, ReleaseArtifactError> { | "oci-image.v1.json" | "provenance-input.v1.json" | "sbom.cdx.json" - | "source-bundles.v1.json" => Ok(MAX_GENERATED_DOCUMENT_BYTES), + | "source-bundles.v2.json" => Ok(MAX_GENERATED_DOCUMENT_BYTES), _ => Err(ReleaseArtifactError::GenerationFailure), } } @@ -1612,9 +1663,13 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr ReleaseArtifactError::StaleOutput, ReleaseArtifactError::GenerationFailure, ]; - if decision.schema != "radroots.services-hardening.release-artifacts-decisions.v1" - || decision.contract_version != 1 + if decision.schema != "radroots.services-hardening.release-artifacts-decisions.v2" + || decision.contract_version != 2 || decision.decision_state != "active" + || decision.predecessor.schema + != "radroots.services-hardening.release-artifacts-decisions.v1" + || decision.predecessor.filename != "services_hardening_release_artifacts.v1.json" + || decision.predecessor.transition != "forward_only_replace" || decision.command != "cargo xtask service-release-artifacts" || decision.modes != ["check", "write"] || decision.required_arguments @@ -1667,7 +1722,7 @@ mod tests { use std::process::Command; use crate::service_source_lock::{ - ContractVersions, ServiceSourceLockParts, ServiceSourceLockV1, + ContractVersions, NixMaterialParts, ServiceSourceLockParts, ServiceSourceLockV2, }; use super::*; @@ -1736,8 +1791,18 @@ version = "0.1.0-alpha" "#, ); write_file( + &service.join("flake.nix"), + format!( + "{{\n inputs.lib = {{\n url = \"github:radrootslabs/lib/{lib_revision}\";\n flake = false;\n }};\n outputs = {{ ... }}: {{ }};\n}}\n" + ) + .as_bytes(), + ); + write_file( &service.join("flake.lock"), - b"{\"nodes\":{},\"root\":\"root\",\"version\":7}\n", + format!( + "{{\"nodes\":{{\"root\":{{\"inputs\":{{\"lib\":\"lib\"}}}},\"lib\":{{\"locked\":{{\"lastModified\":1,\"narHash\":\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\",\"owner\":\"radrootslabs\",\"repo\":\"lib\",\"rev\":\"{lib_revision}\",\"type\":\"github\"}},\"original\":{{\"owner\":\"radrootslabs\",\"repo\":\"lib\",\"rev\":\"{lib_revision}\",\"type\":\"github\"}}}}}},\"root\":\"root\",\"version\":7}}\n" + ) + .as_bytes(), ); write_file( &service.join("LICENSE-APACHE"), @@ -1746,13 +1811,16 @@ version = "0.1.0-alpha" write_file(&service.join("LICENSE-MIT"), b"MIT fixture license\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 = ServiceSourceLockV1::new(ServiceSourceLockParts { + let source_lock = ServiceSourceLockV2::new(ServiceSourceLockParts { service: "fixture_service", revision: &lib_revision, workspace_catalog_sha256: &sha256_bytes(&catalog), source_archive_sha256: &sha256_bytes(&lib_bundle), cargo_lock_sha256: &sha256_bytes(&cargo_lock), - flake_lock_sha256: &sha256_bytes(&flake_lock), + nix: NixMaterialParts::Deferred { + lib_revision: &lib_revision, + flake_lock_sha256: &sha256_bytes(&flake_lock), + }, contract_versions: ContractVersions::new(1, 1, 1, 1, 1), }) .expect("source lock"); @@ -1810,6 +1878,35 @@ version = "0.1.0-alpha" 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(), + }) + .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")); + } } fn write_file(path: &Path, bytes: &[u8]) { @@ -1944,8 +2041,11 @@ version = "0.1.0-alpha" let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("decision json"); for (pointer, replacement) in [ ("/schema", serde_json::json!("other")), - ("/contract_version", serde_json::json!(2)), + ("/contract_version", serde_json::json!(1)), ("/decision_state", serde_json::json!("draft")), + ("/predecessor/schema", serde_json::json!("other")), + ("/predecessor/filename", serde_json::json!("other")), + ("/predecessor/transition", serde_json::json!("other")), ("/command", serde_json::json!("other")), ("/modes", serde_json::json!([])), ("/required_arguments", serde_json::json!([])), @@ -2475,7 +2575,7 @@ version = "0.1.0-alpha" Err(ReleaseArtifactError::StaleOutput) ); - let source_lock = ServiceSourceLockV1::from_canonical_bytes( + let source_lock = ServiceSourceLockV2::from_canonical_bytes( &fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"), ) .expect("source lock"); @@ -2720,18 +2820,23 @@ version = "0.1.0-alpha" #[test] fn release_service_and_workspace_binding_fail_closed() { let fixture = ReleaseFixture::new(); - let current = ServiceSourceLockV1::from_canonical_bytes( + 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 = ServiceSourceLockV1::new(ServiceSourceLockParts { + 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(), - flake_lock_sha256: current.flake_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(), @@ -2867,7 +2972,7 @@ version = "0.1.0-alpha" "oci-image.v1.json", "provenance-input.v1.json", "sbom.cdx.json", - "source-bundles.v1.json", + "source-bundles.v2.json", ] { let bytes = fs::read(fixture.output_a.join(name)).expect("JSON output"); assert_eq!(bytes.last(), Some(&b'\n')); @@ -2895,6 +3000,42 @@ version = "0.1.0-alpha" } #[test] + fn absent_nix_material_is_preserved_without_invented_digest_evidence() { + let fixture = ReleaseFixture::new(); + fixture.make_nix_material_absent(); + fixture + .write(&fixture.output_a) + .expect("absent-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"), + ) + .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( + &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"); + assert_eq!( + validate_source_lock_files(&fixture.service, &lock), + Err(ReleaseArtifactError::InvalidSourceLock), + "accepted absent-state release input with {name}" + ); + fs::remove_file(fixture.service.join(name)).expect("remove unexpected file"); + } + } + + #[test] fn protected_text_and_invalid_inventory_fail_closed() { let fixture = ReleaseFixture::new(); write_file( diff --git a/tools/xtask/src/service_source_lock.rs b/tools/xtask/src/service_source_lock.rs @@ -1,12 +1,16 @@ -use std::{fmt, fmt::Write as _, fs, io::Read as _, path::Path}; +use std::{collections::BTreeSet, fmt, fmt::Write as _, fs, io::Read as _, path::Path}; -use serde::Deserialize; +use serde::{ + Deserialize, Deserializer, + de::{self, MapAccess, SeqAccess, Visitor}, +}; use sha2::{Digest, Sha256}; const CONTRACT_RELATIVE: &str = - "contracts/architecture/decisions/services_hardening_source_lock.v1.json"; -const LOCK_SCHEMA: &str = "radroots.service.source-lock.v1"; -pub(crate) const LOCK_FILENAME: &str = "radroots.service.source-lock.v1.toml"; + "contracts/architecture/decisions/services_hardening_source_lock.v2.json"; +const LOCK_SCHEMA: &str = "radroots.service.source-lock.v2"; +pub(crate) const LOCK_FILENAME: &str = "radroots.service.source-lock.v2.toml"; +pub(crate) const PREDECESSOR_LOCK_FILENAME: &str = "radroots.service.source-lock.v1.toml"; pub(crate) const LIB_REPOSITORY: &str = "https://github.com/radrootslabs/lib"; const ARCHITECTURE: &str = "radroots.crates.release.v2"; const LIB_VERSION: &str = "0.1.0-alpha"; @@ -15,8 +19,10 @@ const HOST_FEATURE_PROFILE: &str = "service-host"; const MAX_LOCK_BYTES: usize = 4096; const MAX_SERVICE_BYTES: usize = 128; const MAX_CONTRACT_BYTES: usize = 32_768; +const MAX_FLAKE_NIX_BYTES: usize = 1_048_576; +const MAX_FLAKE_LOCK_BYTES: usize = 4_194_304; -const FIELD_ORDER: [&str; 18] = [ +const DEFERRED_FIELD_ORDER: [&str; 20] = [ "schema", "contract_version", "service", @@ -27,9 +33,11 @@ const FIELD_ORDER: [&str; 18] = [ "version", "source_archive_sha256", "cargo_lock_sha256", - "flake_lock_sha256", "rust_version", "host_feature_profile", + "nix.material", + "nix.lib_revision", + "nix.flake_lock_sha256", "contract_versions.config", "contract_versions.state", "contract_versions.admin", @@ -37,11 +45,33 @@ const FIELD_ORDER: [&str; 18] = [ "contract_versions.provider", ]; -const ERROR_CODES: [&str; 10] = [ +const ABSENT_FIELD_ORDER: [&str; 18] = [ + "schema", + "contract_version", + "service", + "repository", + "revision", + "architecture", + "workspace_catalog_sha256", + "version", + "source_archive_sha256", + "cargo_lock_sha256", + "rust_version", + "host_feature_profile", + "nix.material", + "contract_versions.config", + "contract_versions.state", + "contract_versions.admin", + "contract_versions.status", + "contract_versions.provider", +]; + +const ERROR_CODES: [&str; 11] = [ "invalid_contract_version", "invalid_digest", "invalid_feature_profile", "invalid_fixed_identity", + "invalid_nix_material", "invalid_revision", "invalid_service", "invalid_toolchain", @@ -56,6 +86,7 @@ pub(crate) enum ServiceSourceLockError { Malformed, Noncanonical, InvalidFixedIdentity, + InvalidNixMaterial, InvalidService, InvalidRevision, InvalidDigest, @@ -71,6 +102,7 @@ impl ServiceSourceLockError { Self::Malformed => "malformed", Self::Noncanonical => "noncanonical", Self::InvalidFixedIdentity => "invalid_fixed_identity", + Self::InvalidNixMaterial => "invalid_nix_material", Self::InvalidService => "invalid_service", Self::InvalidRevision => "invalid_revision", Self::InvalidDigest => "invalid_digest", @@ -88,6 +120,7 @@ impl fmt::Display for ServiceSourceLockError { Self::Malformed => "service source lock is malformed", Self::Noncanonical => "service source lock is not canonical", Self::InvalidFixedIdentity => "service source lock identity is invalid", + Self::InvalidNixMaterial => "service source lock Nix material is invalid", Self::InvalidService => "service source lock service is invalid", Self::InvalidRevision => "service source lock revision is invalid", Self::InvalidDigest => "service source lock digest is invalid", @@ -169,42 +202,326 @@ struct RawServiceSourceLock { version: String, source_archive_sha256: String, cargo_lock_sha256: String, - flake_lock_sha256: String, + nix: RawNixMaterial, rust_version: String, host_feature_profile: String, contract_versions: ContractVersions, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(tag = "material", rename_all = "snake_case", deny_unknown_fields)] +enum RawNixMaterial { + Absent, + Deferred { + lib_revision: String, + flake_lock_sha256: String, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum NixMaterialParts<'a> { + Absent, + Deferred { + lib_revision: &'a str, + flake_lock_sha256: &'a str, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum NixMaterialState { + Absent, + Deferred, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DeferredNixMaterialEvidence { + lib_revision: String, + flake_lock_sha256: String, +} + +impl DeferredNixMaterialEvidence { + pub(crate) fn lib_revision(&self) -> &str { + &self.lib_revision + } + + pub(crate) fn flake_lock_sha256(&self) -> &str { + &self.flake_lock_sha256 + } +} + +pub(crate) fn validate_deferred_nix_material( + expression: &[u8], + lock: &[u8], +) -> Result<DeferredNixMaterialEvidence, ServiceSourceLockError> { + if expression.len() > MAX_FLAKE_NIX_BYTES || lock.len() > MAX_FLAKE_LOCK_BYTES { + return Err(ServiceSourceLockError::InvalidNixMaterial); + } + let revision = validate_deferred_nix_lock(lock)?; + let text = + std::str::from_utf8(expression).map_err(|_| ServiceSourceLockError::InvalidNixMaterial)?; + let expected = format!("url = \"github:radrootslabs/lib/{revision}\";"); + let lines = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::<Vec<_>>(); + let direct_blocks = lines + .windows(4) + .filter(|window| { + window[0] == "inputs.lib = {" + && window[1] == expected + && window[2] == "flake = false;" + && window[3] == "};" + }) + .count(); + let nested_blocks = lines + .windows(5) + .filter(|window| { + window[0] == "inputs = {" + && window[1] == "lib = {" + && window[2] == expected + && window[3] == "flake = false;" + && window[4] == "};" + }) + .count(); + let selecting_lines = lines + .iter() + .filter(|line| line.contains("github:radrootslabs/lib")) + .count(); + if direct_blocks + nested_blocks != 1 || selecting_lines != 1 { + return Err(ServiceSourceLockError::InvalidNixMaterial); + } + Ok(DeferredNixMaterialEvidence { + lib_revision: revision, + flake_lock_sha256: hex::encode(Sha256::digest(lock)), + }) +} + +pub(crate) fn validate_deferred_nix_lock(bytes: &[u8]) -> Result<String, ServiceSourceLockError> { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = NoDuplicateJsonValue::deserialize(&mut deserializer) + .map_err(|_| ServiceSourceLockError::InvalidNixMaterial)? + .0; + deserializer + .end() + .map_err(|_| ServiceSourceLockError::InvalidNixMaterial)?; + if value + .as_object() + .map(|object| object.keys().map(String::as_str).collect::<BTreeSet<_>>()) + != Some(BTreeSet::from(["nodes", "root", "version"])) + { + return Err(ServiceSourceLockError::InvalidNixMaterial); + } + let version = value.get("version").and_then(serde_json::Value::as_u64); + let root_name = value.get("root").and_then(serde_json::Value::as_str); + let nodes = value.get("nodes").and_then(serde_json::Value::as_object); + if version != Some(7) || root_name.is_none_or(str::is_empty) || nodes.is_none() { + return Err(ServiceSourceLockError::InvalidNixMaterial); + } + let nodes = nodes.ok_or(ServiceSourceLockError::InvalidNixMaterial)?; + let root = nodes + .get(root_name.ok_or(ServiceSourceLockError::InvalidNixMaterial)?) + .and_then(|node| node.get("inputs")) + .and_then(serde_json::Value::as_object) + .ok_or(ServiceSourceLockError::InvalidNixMaterial)?; + let direct_lib_node = root.get("lib").and_then(serde_json::Value::as_str); + let mut lib_nodes = 0_usize; + let mut exact_direct = 0_usize; + let mut selected_revision = None; + for (name, node) in nodes { + let locked = node.get("locked").and_then(serde_json::Value::as_object); + let original = node.get("original").and_then(serde_json::Value::as_object); + let is_lib = locked.is_some_and(|locked| { + locked.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") + && locked.get("repo").and_then(serde_json::Value::as_str) == Some("lib") + }) || original.is_some_and(|original| { + original.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") + && original.get("repo").and_then(serde_json::Value::as_str) == Some("lib") + }); + if !is_lib { + continue; + } + lib_nodes += 1; + let locked = locked.ok_or(ServiceSourceLockError::InvalidNixMaterial)?; + let original = original.ok_or(ServiceSourceLockError::InvalidNixMaterial)?; + let locked_keys = locked.keys().map(String::as_str).collect::<BTreeSet<_>>(); + let original_keys = original.keys().map(String::as_str).collect::<BTreeSet<_>>(); + let locked_revision = locked.get("rev").and_then(serde_json::Value::as_str); + let original_revision = original.get("rev").and_then(serde_json::Value::as_str); + let exact = locked_keys + == BTreeSet::from(["lastModified", "narHash", "owner", "repo", "rev", "type"]) + && original_keys == BTreeSet::from(["owner", "repo", "rev", "type"]) + && node.as_object().is_some_and(|node| { + node.keys().map(String::as_str).collect::<BTreeSet<_>>() + == BTreeSet::from(["locked", "original"]) + }) + && locked + .get("lastModified") + .and_then(serde_json::Value::as_u64) + .is_some() + && locked.get("type").and_then(serde_json::Value::as_str) == Some("github") + && locked.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") + && locked.get("repo").and_then(serde_json::Value::as_str) == Some("lib") + && locked_revision.is_some_and(|revision| valid_lower_hex(revision, 40)) + && locked + .get("narHash") + .and_then(serde_json::Value::as_str) + .is_some_and(valid_nix_sha256) + && original.get("type").and_then(serde_json::Value::as_str) == Some("github") + && original.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") + && original.get("repo").and_then(serde_json::Value::as_str) == Some("lib") + && original_revision == locked_revision + && original.get("ref").is_none(); + if direct_lib_node == Some(name) && root_name != Some(name.as_str()) && exact { + exact_direct += 1; + selected_revision = locked_revision.map(str::to_owned); + } + } + if lib_nodes == 1 && exact_direct == 1 { + selected_revision.ok_or(ServiceSourceLockError::InvalidNixMaterial) + } else { + Err(ServiceSourceLockError::InvalidNixMaterial) + } +} + +struct NoDuplicateJsonValue(serde_json::Value); + +impl<'de> Deserialize<'de> for NoDuplicateJsonValue { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(NoDuplicateJsonValueVisitor) + } +} + +struct NoDuplicateJsonValueVisitor; + +impl<'de> Visitor<'de> for NoDuplicateJsonValueVisitor { + type Value = NoDuplicateJsonValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON without duplicate object keys") + } + + fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(serde_json::Value::Bool(value))) + } + + fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(serde_json::Value::Number( + value.into(), + ))) + } + + fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(serde_json::Value::Number( + value.into(), + ))) + } + + fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> + where + E: de::Error, + { + Err(E::custom("unsupported JSON number")) + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(serde_json::Value::String( + value.to_owned(), + ))) + } + + fn visit_string<E>(self, value: String) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(serde_json::Value::String(value))) + } + + fn visit_none<E>(self) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(serde_json::Value::Null)) + } + + fn visit_unit<E>(self) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(serde_json::Value::Null)) + } + + fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: Deserializer<'de>, + { + Deserialize::deserialize(deserializer) + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element::<NoDuplicateJsonValue>()? { + values.push(value.0); + } + Ok(NoDuplicateJsonValue(serde_json::Value::Array(values))) + } + + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'de>, + { + let mut values = serde_json::Map::new(); + while let Some(key) = map.next_key::<String>()? { + if values.contains_key(&key) { + return Err(<A::Error as de::Error>::custom("duplicate JSON object key")); + } + let value = map.next_value::<NoDuplicateJsonValue>()?; + values.insert(key, value.0); + } + Ok(NoDuplicateJsonValue(serde_json::Value::Object(values))) + } +} + +fn valid_nix_sha256(value: &str) -> bool { + let Some(encoded) = value.strip_prefix("sha256-") else { + return false; + }; + let bytes = encoded.as_bytes(); + bytes.len() == 44 + && bytes[43] == b'=' + && bytes[..43] + .iter() + .copied() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/')) +} + pub(crate) struct ServiceSourceLockParts<'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) nix: NixMaterialParts<'a>, pub(crate) contract_versions: ContractVersions, } #[derive(Clone, Eq, PartialEq)] -pub(crate) struct ServiceSourceLockV1 { +pub(crate) struct ServiceSourceLockV2 { raw: RawServiceSourceLock, canonical: Box<[u8]>, } -impl fmt::Debug for ServiceSourceLockV1 { +impl fmt::Debug for ServiceSourceLockV2 { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter - .debug_struct("ServiceSourceLockV1") + .debug_struct("ServiceSourceLockV2") .finish_non_exhaustive() } } -impl ServiceSourceLockV1 { +impl ServiceSourceLockV2 { pub(crate) fn new(parts: ServiceSourceLockParts<'_>) -> Result<Self, ServiceSourceLockError> { validate_parts(&parts)?; let raw = RawServiceSourceLock { schema: LOCK_SCHEMA.to_owned(), - contract_version: 1, + contract_version: 2, service: parts.service.to_owned(), repository: LIB_REPOSITORY.to_owned(), revision: parts.revision.to_owned(), @@ -213,7 +530,16 @@ impl ServiceSourceLockV1 { version: LIB_VERSION.to_owned(), source_archive_sha256: parts.source_archive_sha256.to_owned(), cargo_lock_sha256: parts.cargo_lock_sha256.to_owned(), - flake_lock_sha256: parts.flake_lock_sha256.to_owned(), + nix: match parts.nix { + NixMaterialParts::Absent => RawNixMaterial::Absent, + NixMaterialParts::Deferred { + lib_revision, + flake_lock_sha256, + } => RawNixMaterial::Deferred { + lib_revision: lib_revision.to_owned(), + flake_lock_sha256: flake_lock_sha256.to_owned(), + }, + }, rust_version: RUST_VERSION.to_owned(), host_feature_profile: HOST_FEATURE_PROFILE.to_owned(), contract_versions: parts.contract_versions, @@ -267,8 +593,27 @@ impl ServiceSourceLockV1 { &self.raw.cargo_lock_sha256 } - pub(crate) fn flake_lock_sha256(&self) -> &str { - &self.raw.flake_lock_sha256 + pub(crate) const fn nix_material_state(&self) -> NixMaterialState { + match &self.raw.nix { + RawNixMaterial::Absent => NixMaterialState::Absent, + RawNixMaterial::Deferred { .. } => NixMaterialState::Deferred, + } + } + + pub(crate) fn nix_lib_revision(&self) -> Option<&str> { + match &self.raw.nix { + RawNixMaterial::Absent => None, + RawNixMaterial::Deferred { lib_revision, .. } => Some(lib_revision), + } + } + + pub(crate) fn flake_lock_sha256(&self) -> Option<&str> { + match &self.raw.nix { + RawNixMaterial::Absent => None, + RawNixMaterial::Deferred { + flake_lock_sha256, .. + } => Some(flake_lock_sha256), + } } pub(crate) const fn contract_versions(&self) -> ContractVersions { @@ -282,12 +627,14 @@ struct SourceLockDecision { schema: String, contract_version: u32, decision_state: String, + predecessor: PredecessorDecision, lock_filename: String, lock_schema: String, canonical_encoding: String, maximum_lock_utf8_bytes: usize, maximum_service_utf8_bytes: usize, - canonical_field_order: Vec<String>, + canonical_field_order_deferred: Vec<String>, + canonical_field_order_absent: Vec<String>, fixed: FixedDecision, revision_encoding: String, digest_encoding: String, @@ -295,13 +642,21 @@ struct SourceLockDecision { service_identifier: String, contract_version_rule: String, negative_error_codes: Vec<String>, - canonical_vector: CanonicalVector, + canonical_vectors: CanonicalVectors, operations: OperationsDecision, deferred_operations: Vec<String>, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] +struct PredecessorDecision { + schema: String, + filename: String, + transition: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct OperationsDecision { command: String, modes: Vec<String>, @@ -310,8 +665,11 @@ struct OperationsDecision { service_metadata_fields: Vec<String>, lib_dependency_inventory: String, source_cleanliness: String, + predecessor_lock_presence: String, service_revision_stability: String, - revision_agreement: Vec<String>, + active_revision_agreement: Vec<String>, + nix_material_states: Vec<String>, + deferred_nix_agreement: Vec<String>, maximum_source_archive_bytes: u64, } @@ -341,6 +699,13 @@ struct CanonicalVector { sha256: String, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CanonicalVectors { + deferred: CanonicalVector, + absent: CanonicalVector, +} + pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> { validate_contract_inner(workspace_root).map_err(|error| error.to_string()) } @@ -363,13 +728,22 @@ fn validate_contract_inner(workspace_root: &Path) -> Result<(), ServiceSourceLoc .map_err(|_| ServiceSourceLockError::Malformed)?; validate_decision(&decision)?; - let parsed = - ServiceSourceLockV1::from_canonical_bytes(decision.canonical_vector.toml.as_bytes())?; - let expected = canonical_vector(); - if parsed != expected - || hex::encode(Sha256::digest(parsed.canonical_bytes())) != decision.canonical_vector.sha256 - { - return Err(ServiceSourceLockError::Noncanonical); + for (vector, expected) in [ + ( + &decision.canonical_vectors.deferred, + canonical_deferred_vector(), + ), + ( + &decision.canonical_vectors.absent, + canonical_absent_vector(), + ), + ] { + let parsed = ServiceSourceLockV2::from_canonical_bytes(vector.toml.as_bytes())?; + if parsed != expected + || hex::encode(Sha256::digest(parsed.canonical_bytes())) != vector.sha256 + { + return Err(ServiceSourceLockError::Noncanonical); + } } Ok(()) } @@ -380,6 +754,7 @@ fn validate_decision(decision: &SourceLockDecision) -> Result<(), ServiceSourceL ServiceSourceLockError::InvalidDigest, ServiceSourceLockError::InvalidFeatureProfile, ServiceSourceLockError::InvalidFixedIdentity, + ServiceSourceLockError::InvalidNixMaterial, ServiceSourceLockError::InvalidRevision, ServiceSourceLockError::InvalidService, ServiceSourceLockError::InvalidToolchain, @@ -388,15 +763,19 @@ fn validate_decision(decision: &SourceLockDecision) -> Result<(), ServiceSourceL ServiceSourceLockError::TooLarge, ] .map(ServiceSourceLockError::code); - let exact = decision.schema == "radroots.services-hardening.source-lock-decisions.v1" - && decision.contract_version == 1 + let exact = decision.schema == "radroots.services-hardening.source-lock-decisions.v2" + && decision.contract_version == 2 && decision.decision_state == "active" + && decision.predecessor.schema == "radroots.service.source-lock.v1" + && decision.predecessor.filename == PREDECESSOR_LOCK_FILENAME + && decision.predecessor.transition == "forward_only_replace" && decision.lock_filename == LOCK_FILENAME && decision.lock_schema == LOCK_SCHEMA && decision.canonical_encoding == "compact_canonical_toml_with_final_newline" && decision.maximum_lock_utf8_bytes == MAX_LOCK_BYTES && decision.maximum_service_utf8_bytes == MAX_SERVICE_BYTES - && decision.canonical_field_order == FIELD_ORDER + && decision.canonical_field_order_deferred == DEFERRED_FIELD_ORDER + && decision.canonical_field_order_absent == ABSENT_FIELD_ORDER && decision.fixed.repository == LIB_REPOSITORY && decision.fixed.architecture == ARCHITECTURE && decision.fixed.version == LIB_VERSION @@ -424,6 +803,7 @@ fn validate_decision(decision: &SourceLockDecision) -> Result<(), ServiceSourceL == [ "service", "host_feature_profile", + "nix_material", "config_contract_version", "state_contract_version", "admin_contract_version", @@ -434,18 +814,31 @@ fn validate_decision(decision: &SourceLockDecision) -> Result<(), ServiceSourceL == "verified_source_archive_workspace_catalog" && decision.operations.source_cleanliness == "all_changes_forbidden_except_exact_generated_lock_path" + && decision.operations.predecessor_lock_presence == "forbidden" && decision.operations.service_revision_stability == "same_head_before_and_after_evidence_and_output" - && decision.operations.revision_agreement + && decision.operations.active_revision_agreement == [ "cargo_manifests", "cargo_lock", - "direct_exact_nix_input", "source_archive", "canonical_public_remote", ] + && decision.operations.nix_material_states == ["absent", "deferred"] + && decision.operations.deferred_nix_agreement + == [ + "flake_expression", + "flake_lock", + "source_lock_nix_revision", + "canonical_public_remote", + ] && decision.operations.maximum_source_archive_bytes == 1_073_741_824 - && decision.deferred_operations == ["embedded_build_information_agreement"]; + && decision.deferred_operations + == [ + "embedded_build_information_agreement", + "nix_qualification", + "nix_active_revision_alignment", + ]; if exact { Ok(()) } else { @@ -453,22 +846,38 @@ fn validate_decision(decision: &SourceLockDecision) -> Result<(), ServiceSourceL } } -fn canonical_vector() -> ServiceSourceLockV1 { - ServiceSourceLockV1::new(ServiceSourceLockParts { +fn canonical_deferred_vector() -> ServiceSourceLockV2 { + ServiceSourceLockV2::new(ServiceSourceLockParts { service: "fixture_service", revision: "2222222222222222222222222222222222222222", workspace_catalog_sha256: "2222222222222222222222222222222222222222222222222222222222222222", source_archive_sha256: "3333333333333333333333333333333333333333333333333333333333333333", cargo_lock_sha256: "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0", - flake_lock_sha256: "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353", + nix: NixMaterialParts::Deferred { + lib_revision: "1111111111111111111111111111111111111111", + flake_lock_sha256: "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353", + }, contract_versions: ContractVersions::new(1, 2, 3, 4, 5), }) .expect("the governed source-lock vector is valid") } +fn canonical_absent_vector() -> ServiceSourceLockV2 { + ServiceSourceLockV2::new(ServiceSourceLockParts { + service: "fixture_service", + revision: "2222222222222222222222222222222222222222", + workspace_catalog_sha256: "2222222222222222222222222222222222222222222222222222222222222222", + source_archive_sha256: "3333333333333333333333333333333333333333333333333333333333333333", + cargo_lock_sha256: "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0", + nix: NixMaterialParts::Absent, + contract_versions: ContractVersions::new(1, 2, 3, 4, 5), + }) + .expect("the governed absent-Nix source-lock vector is valid") +} + fn validate_raw(raw: &RawServiceSourceLock) -> Result<(), ServiceSourceLockError> { if raw.schema != LOCK_SCHEMA - || raw.contract_version != 1 + || raw.contract_version != 2 || raw.repository != LIB_REPOSITORY || raw.architecture != ARCHITECTURE || raw.version != LIB_VERSION @@ -487,7 +896,16 @@ fn validate_raw(raw: &RawServiceSourceLock) -> Result<(), ServiceSourceLockError workspace_catalog_sha256: &raw.workspace_catalog_sha256, source_archive_sha256: &raw.source_archive_sha256, cargo_lock_sha256: &raw.cargo_lock_sha256, - flake_lock_sha256: &raw.flake_lock_sha256, + nix: match &raw.nix { + RawNixMaterial::Absent => NixMaterialParts::Absent, + RawNixMaterial::Deferred { + lib_revision, + flake_lock_sha256, + } => NixMaterialParts::Deferred { + lib_revision, + flake_lock_sha256, + }, + }, contract_versions: raw.contract_versions, }) } @@ -503,13 +921,20 @@ fn validate_parts(parts: &ServiceSourceLockParts<'_>) -> Result<(), ServiceSourc parts.workspace_catalog_sha256, parts.source_archive_sha256, parts.cargo_lock_sha256, - parts.flake_lock_sha256, ] .into_iter() .all(|value| valid_lower_hex(value, 64)) { return Err(ServiceSourceLockError::InvalidDigest); } + if let NixMaterialParts::Deferred { + lib_revision, + flake_lock_sha256, + } = parts.nix + && (!valid_lower_hex(lib_revision, 40) || !valid_lower_hex(flake_lock_sha256, 64)) + { + return Err(ServiceSourceLockError::InvalidNixMaterial); + } if !parts.contract_versions.is_valid() { return Err(ServiceSourceLockError::InvalidContractVersion); } @@ -558,8 +983,6 @@ fn render(raw: &RawServiceSourceLock) -> String { .expect("render to String"); writeln!(output, "cargo_lock_sha256 = \"{}\"", raw.cargo_lock_sha256) .expect("render to String"); - writeln!(output, "flake_lock_sha256 = \"{}\"", raw.flake_lock_sha256) - .expect("render to String"); writeln!(output, "rust_version = \"{}\"", raw.rust_version).expect("render to String"); writeln!( output, @@ -567,6 +990,21 @@ fn render(raw: &RawServiceSourceLock) -> String { raw.host_feature_profile ) .expect("render to String"); + output.push_str("\n[nix]\n"); + match &raw.nix { + RawNixMaterial::Absent => { + output.push_str("material = \"absent\"\n"); + } + RawNixMaterial::Deferred { + lib_revision, + flake_lock_sha256, + } => { + output.push_str("material = \"deferred\"\n"); + writeln!(output, "lib_revision = \"{lib_revision}\"").expect("render to String"); + writeln!(output, "flake_lock_sha256 = \"{flake_lock_sha256}\"") + .expect("render to String"); + } + } output.push_str("\n[contract_versions]\n"); writeln!(output, "config = {}", raw.contract_versions.config).expect("render to String"); writeln!(output, "state = {}", raw.contract_versions.state).expect("render to String"); @@ -585,14 +1023,23 @@ mod tests { use super::*; #[test] - fn canonical_vector_round_trips_with_exact_digest() { - let lock = canonical_vector(); + fn canonical_vectors_round_trip_with_exact_digests() { + let lock = canonical_deferred_vector(); assert_eq!( hex::encode(Sha256::digest(lock.canonical_bytes())), - "7251222df95da414d8cb073b8907f4a53c9ac4c89354bb2d895ac78fab79d81a" + "da7b8894a6480e7022d9937369a5c9bafbf90128a901652923e501c52aced1a2" ); assert_eq!( - ServiceSourceLockV1::from_canonical_bytes(lock.canonical_bytes()), + ServiceSourceLockV2::from_canonical_bytes(lock.canonical_bytes()), + Ok(lock) + ); + let lock = canonical_absent_vector(); + assert_eq!( + hex::encode(Sha256::digest(lock.canonical_bytes())), + "2af058ba042509c77efd6dc264c60a7abf4371378223e52e34eb441cab7e263c" + ); + assert_eq!( + ServiceSourceLockV2::from_canonical_bytes(lock.canonical_bytes()), Ok(lock) ); } @@ -603,14 +1050,18 @@ mod tests { let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("decision json"); for (pointer, replacement) in [ ("/schema", serde_json::json!("other")), - ("/contract_version", serde_json::json!(2)), + ("/contract_version", serde_json::json!(1)), ("/decision_state", serde_json::json!("draft")), + ("/predecessor/schema", serde_json::json!("other")), + ("/predecessor/filename", serde_json::json!("other")), + ("/predecessor/transition", serde_json::json!("other")), ("/lock_filename", serde_json::json!("other")), ("/lock_schema", serde_json::json!("other")), ("/canonical_encoding", serde_json::json!("other")), ("/maximum_lock_utf8_bytes", serde_json::json!(1)), ("/maximum_service_utf8_bytes", serde_json::json!(1)), - ("/canonical_field_order", serde_json::json!([])), + ("/canonical_field_order_deferred", serde_json::json!([])), + ("/canonical_field_order_absent", serde_json::json!([])), ("/fixed/repository", serde_json::json!("other")), ("/fixed/architecture", serde_json::json!("other")), ("/fixed/version", serde_json::json!("other")), @@ -651,10 +1102,19 @@ mod tests { ), ("/operations/source_cleanliness", serde_json::json!("other")), ( + "/operations/predecessor_lock_presence", + serde_json::json!("other"), + ), + ( "/operations/service_revision_stability", serde_json::json!("other"), ), - ("/operations/revision_agreement", serde_json::json!([])), + ( + "/operations/active_revision_agreement", + serde_json::json!([]), + ), + ("/operations/nix_material_states", serde_json::json!([])), + ("/operations/deferred_nix_agreement", serde_json::json!([])), ( "/operations/maximum_source_archive_bytes", serde_json::json!(1), @@ -675,21 +1135,21 @@ mod tests { #[test] fn parser_rejects_noncanonical_and_ambiguous_toml() { - let canonical = String::from_utf8(canonical_vector().canonical_bytes().to_vec()) + let canonical = String::from_utf8(canonical_deferred_vector().canonical_bytes().to_vec()) .expect("canonical UTF-8"); for malformed in [ canonical.replacen("schema =", "unknown = 1\nschema =", 1), canonical.replacen( - "contract_version = 1\nservice = \"fixture_service\"", - "service = \"fixture_service\"\ncontract_version = 1", + "contract_version = 2\nservice = \"fixture_service\"", + "service = \"fixture_service\"\ncontract_version = 2", 1, ), format!(" {canonical}"), canonical.replacen("schema =", "schema=", 1), - canonical.replacen("contract_version = 1", "contract_version = 01", 1), + canonical.replacen("contract_version = 2", "contract_version = 02", 1), ] { assert!(matches!( - ServiceSourceLockV1::from_canonical_bytes(malformed.as_bytes()), + ServiceSourceLockV2::from_canonical_bytes(malformed.as_bytes()), Err(ServiceSourceLockError::Malformed | ServiceSourceLockError::Noncanonical) )); } @@ -699,24 +1159,78 @@ mod tests { 1, ); assert_eq!( - ServiceSourceLockV1::from_canonical_bytes(duplicate.as_bytes()), + ServiceSourceLockV2::from_canonical_bytes(duplicate.as_bytes()), Err(ServiceSourceLockError::Malformed) ); } #[test] + fn nix_material_variants_are_closed_and_independently_bounded() { + let absent = canonical_absent_vector(); + assert_eq!(absent.nix_material_state(), NixMaterialState::Absent); + assert_eq!(absent.nix_lib_revision(), None); + assert_eq!(absent.flake_lock_sha256(), None); + + let deferred = canonical_deferred_vector(); + assert_eq!(deferred.nix_material_state(), NixMaterialState::Deferred); + assert_eq!( + deferred.nix_lib_revision(), + Some("1111111111111111111111111111111111111111") + ); + assert_eq!( + deferred.flake_lock_sha256(), + Some("13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353") + ); + + let absent_text = String::from_utf8(absent.canonical_bytes().to_vec()).expect("UTF-8"); + let deferred_text = String::from_utf8(deferred.canonical_bytes().to_vec()).expect("UTF-8"); + for malformed in [ + absent_text.replacen( + "material = \"absent\"", + "material = \"absent\"\nlib_revision = \"1111111111111111111111111111111111111111\"", + 1, + ), + absent_text.replacen("material = \"absent\"", "material = \"other\"", 1), + deferred_text.replacen( + "lib_revision = \"1111111111111111111111111111111111111111\"\n", + "", + 1, + ), + deferred_text.replacen( + "flake_lock_sha256 = \"13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353\"\n", + "", + 1, + ), + ] { + assert!(matches!( + ServiceSourceLockV2::from_canonical_bytes(malformed.as_bytes()), + Err(ServiceSourceLockError::Malformed | ServiceSourceLockError::Noncanonical) + )); + } + + assert_eq!( + validate_deferred_nix_material(&vec![b'x'; MAX_FLAKE_NIX_BYTES + 1], b"{}"), + Err(ServiceSourceLockError::InvalidNixMaterial) + ); + assert_eq!( + validate_deferred_nix_material(b"", &vec![b'x'; MAX_FLAKE_LOCK_BYTES + 1]), + Err(ServiceSourceLockError::InvalidNixMaterial) + ); + } + + #[test] fn parser_rejects_every_identity_and_bound_drift() { - let canonical = String::from_utf8(canonical_vector().canonical_bytes().to_vec()) + let canonical = String::from_utf8(canonical_deferred_vector().canonical_bytes().to_vec()) .expect("canonical UTF-8"); let cases = [ ( - "radroots.service.source-lock.v1", + "radroots.service.source-lock.v2", "wrong", ServiceSourceLockError::InvalidFixedIdentity, ), ( - "contract_version = 1", "contract_version = 2", + "contract_version = 1", ServiceSourceLockError::InvalidFixedIdentity, ), ( @@ -772,7 +1286,12 @@ mod tests { ( "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", - ServiceSourceLockError::InvalidDigest, + ServiceSourceLockError::InvalidNixMaterial, + ), + ( + "1111111111111111111111111111111111111111", + "invalid", + ServiceSourceLockError::InvalidNixMaterial, ), ( RUST_VERSION, @@ -813,14 +1332,14 @@ mod tests { for (from, to, expected) in cases { let mutated = canonical.replacen(from, to, 1); assert_eq!( - ServiceSourceLockV1::from_canonical_bytes(mutated.as_bytes()), + ServiceSourceLockV2::from_canonical_bytes(mutated.as_bytes()), Err(expected) ); } let too_large = vec![b' '; MAX_LOCK_BYTES + 1]; assert_eq!( - ServiceSourceLockV1::from_canonical_bytes(&too_large), + ServiceSourceLockV2::from_canonical_bytes(&too_large), Err(ServiceSourceLockError::TooLarge) ); } @@ -828,13 +1347,16 @@ mod tests { #[test] fn exact_service_and_contract_version_maxima_are_admitted() { let service = "a".repeat(MAX_SERVICE_BYTES); - let maximum = ServiceSourceLockV1::new(ServiceSourceLockParts { + let maximum = ServiceSourceLockV2::new(ServiceSourceLockParts { service: &service, revision: &"a".repeat(40), workspace_catalog_sha256: &"b".repeat(64), source_archive_sha256: &"c".repeat(64), cargo_lock_sha256: &"d".repeat(64), - flake_lock_sha256: &"e".repeat(64), + nix: NixMaterialParts::Deferred { + lib_revision: &"f".repeat(40), + flake_lock_sha256: &"e".repeat(64), + }, contract_versions: ContractVersions::new( u32::MAX, u32::MAX, @@ -848,13 +1370,13 @@ mod tests { let overlong_service = "a".repeat(MAX_SERVICE_BYTES + 1); assert_eq!( - ServiceSourceLockV1::new(ServiceSourceLockParts { + ServiceSourceLockV2::new(ServiceSourceLockParts { service: &overlong_service, revision: &"a".repeat(40), workspace_catalog_sha256: &"b".repeat(64), source_archive_sha256: &"c".repeat(64), cargo_lock_sha256: &"d".repeat(64), - flake_lock_sha256: &"e".repeat(64), + nix: NixMaterialParts::Absent, contract_versions: ContractVersions::new(1, 1, 1, 1, 1), }), Err(ServiceSourceLockError::InvalidService) @@ -868,13 +1390,13 @@ mod tests { "service-name", ] { assert_eq!( - ServiceSourceLockV1::new(ServiceSourceLockParts { + ServiceSourceLockV2::new(ServiceSourceLockParts { service: invalid, revision: &"a".repeat(40), workspace_catalog_sha256: &"b".repeat(64), source_archive_sha256: &"c".repeat(64), cargo_lock_sha256: &"d".repeat(64), - flake_lock_sha256: &"e".repeat(64), + nix: NixMaterialParts::Absent, contract_versions: ContractVersions::new(1, 1, 1, 1, 1), }), Err(ServiceSourceLockError::InvalidService) @@ -884,9 +1406,9 @@ mod tests { #[test] fn diagnostics_are_fixed_and_source_free() { - let lock = canonical_vector(); + let lock = canonical_deferred_vector(); let debug = format!("{lock:?}"); - assert_eq!(debug, "ServiceSourceLockV1 { .. }"); + assert_eq!(debug, "ServiceSourceLockV2 { .. }"); for sensitive in [ "fixture_service", "radrootslabs", @@ -900,6 +1422,7 @@ mod tests { ServiceSourceLockError::Malformed, ServiceSourceLockError::Noncanonical, ServiceSourceLockError::InvalidFixedIdentity, + ServiceSourceLockError::InvalidNixMaterial, ServiceSourceLockError::InvalidService, ServiceSourceLockError::InvalidRevision, ServiceSourceLockError::InvalidDigest, diff --git a/tools/xtask/src/service_source_lock_command.rs b/tools/xtask/src/service_source_lock_command.rs @@ -13,12 +13,15 @@ use tempfile::{NamedTempFile, TempDir}; use walkdir::WalkDir; use crate::service_source_lock::{ - ContractVersions, LIB_REPOSITORY, LOCK_FILENAME, ServiceSourceLockParts, ServiceSourceLockV1, + ContractVersions, LIB_REPOSITORY, LOCK_FILENAME, NixMaterialParts, NixMaterialState, + PREDECESSOR_LOCK_FILENAME, ServiceSourceLockParts, ServiceSourceLockV2, + validate_deferred_nix_material, }; const CATALOG_RELATIVE: &str = "contracts/crates/catalog.v2.toml"; const CARGO_MANIFEST: &str = "Cargo.toml"; const CARGO_LOCK: &str = "Cargo.lock"; +const FLAKE_NIX: &str = "flake.nix"; const FLAKE_LOCK: &str = "flake.lock"; const RUST_TOOLCHAIN: &str = "rust-toolchain.toml"; const LIB_VERSION_REQUIREMENT: &str = "=0.1.0-alpha"; @@ -28,6 +31,7 @@ const RUST_VERSION: &str = "1.97.1"; const MAX_MANIFEST_BYTES: usize = 1_048_576; const MAX_CARGO_LOCK_BYTES: usize = 16_777_216; const MAX_FLAKE_LOCK_BYTES: usize = 4_194_304; +const MAX_FLAKE_NIX_BYTES: usize = 1_048_576; const MAX_TOOLCHAIN_BYTES: usize = 65_536; const MAX_CATALOG_BYTES: usize = 4_194_304; const MAX_GIT_OUTPUT_BYTES: usize = 65_536; @@ -48,6 +52,7 @@ enum CommandError { InvalidServiceMetadata, InvalidCargoManifest, InvalidCargoLock, + InvalidNixMaterial, InvalidFlakeLock, InvalidToolchain, InvalidSourceArchive, @@ -65,6 +70,7 @@ impl fmt::Display for CommandError { Self::InvalidServiceMetadata => "service source-lock metadata is invalid", Self::InvalidCargoManifest => "service Cargo manifest dependency is invalid", Self::InvalidCargoLock => "service Cargo lock is invalid", + Self::InvalidNixMaterial => "service deferred Nix material is invalid", Self::InvalidFlakeLock => "service flake lock is invalid", Self::InvalidToolchain => "service Rust toolchain is invalid", Self::InvalidSourceArchive => "Lib source archive is invalid", @@ -81,9 +87,41 @@ impl std::error::Error for CommandError {} #[derive(Clone, Debug, Eq, PartialEq)] struct ServiceMetadata { service: String, + nix_material: NixMaterialState, contract_versions: ContractVersions, } +#[derive(Clone, Debug, Eq, PartialEq)] +enum NixMaterialEvidence { + Absent, + Deferred { + lib_revision: String, + flake_lock_sha256: String, + }, +} + +impl NixMaterialEvidence { + fn as_parts(&self) -> NixMaterialParts<'_> { + match self { + Self::Absent => NixMaterialParts::Absent, + Self::Deferred { + lib_revision, + flake_lock_sha256, + } => NixMaterialParts::Deferred { + lib_revision, + flake_lock_sha256, + }, + } + } + + fn revision(&self) -> Option<&str> { + match self { + Self::Absent => None, + Self::Deferred { lib_revision, .. } => Some(lib_revision), + } + } +} + #[derive(Debug, Deserialize)] struct CargoLockDocument { #[serde(default)] @@ -148,6 +186,10 @@ fn run_with( reachability: &dyn RevisionReachability, ) -> Result<(), CommandError> { let service_root = validate_service_root(service_root)?; + match fs::symlink_metadata(service_root.join(PREDECESSOR_LOCK_FILENAME)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return Err(CommandError::InvalidSourceLock), + } let initial_head = service_head(&service_root)?; validate_service_cleanliness(&service_root)?; @@ -160,13 +202,7 @@ fn run_with( let metadata = parse_service_metadata(&root_manifest)?; let revision = validate_cargo_manifests(&service_root, None)?; - let flake_lock = read_bounded_regular( - &service_root.join(FLAKE_LOCK), - MAX_FLAKE_LOCK_BYTES, - CommandError::InvalidFlakeLock, - )?; - validate_flake_lock(&flake_lock, &revision)?; - let flake_lock_sha256 = sha256(&flake_lock); + let nix = read_nix_material(&service_root, metadata.nix_material)?; validate_toolchain(&service_root)?; let archive = validate_archive(source_archive, &revision)?; @@ -182,18 +218,23 @@ fn run_with( validate_cargo_lock(&cargo_lock, &revision, &archive.package_names)?; let cargo_lock_sha256 = sha256(&cargo_lock); reachability.verify(&archive.revision)?; + if let Some(nix_revision) = nix.revision() + && nix_revision != archive.revision + { + reachability.verify(nix_revision)?; + } validate_service_cleanliness(&service_root)?; if service_head(&service_root)? != initial_head { return Err(CommandError::DirtyServiceSource); } - let desired = ServiceSourceLockV1::new(ServiceSourceLockParts { + let desired = ServiceSourceLockV2::new(ServiceSourceLockParts { service: &metadata.service, revision: &archive.revision, workspace_catalog_sha256: &archive.catalog_sha256, source_archive_sha256: &archive.archive_sha256, cargo_lock_sha256: &cargo_lock_sha256, - flake_lock_sha256: &flake_lock_sha256, + nix: nix.as_parts(), contract_versions: metadata.contract_versions, }) .map_err(|_| CommandError::InvalidServiceMetadata)?; @@ -202,7 +243,7 @@ fn run_with( let result = match mode { CommandMode::Check => { let current = read_bounded_regular(&lock_path, 4096, CommandError::InvalidSourceLock)?; - let current = ServiceSourceLockV1::from_canonical_bytes(&current) + let current = ServiceSourceLockV2::from_canonical_bytes(&current) .map_err(|_| CommandError::InvalidSourceLock)?; if current == desired { Ok(()) @@ -213,7 +254,7 @@ fn run_with( CommandMode::Write => { atomic_write_lock(&service_root, &lock_path, desired.canonical_bytes())?; let current = read_bounded_regular(&lock_path, 4096, CommandError::WriteFailure)?; - let current = ServiceSourceLockV1::from_canonical_bytes(&current) + let current = ServiceSourceLockV2::from_canonical_bytes(&current) .map_err(|_| CommandError::WriteFailure)?; if current == desired { Ok(()) @@ -276,7 +317,7 @@ fn validate_service_cleanliness(root: &Path) -> Result<(), CommandError> { "--quiet", "--", ".", - ":(exclude)radroots.service.source-lock.v1.toml", + ":(exclude)radroots.service.source-lock.v2.toml", ], ) .map_err(|_| CommandError::DirtyServiceSource)?; @@ -288,7 +329,7 @@ fn validate_service_cleanliness(root: &Path) -> Result<(), CommandError> { "--quiet", "--", ".", - ":(exclude)radroots.service.source-lock.v1.toml", + ":(exclude)radroots.service.source-lock.v2.toml", ], ) .map_err(|_| CommandError::DirtyServiceSource)?; @@ -320,6 +361,7 @@ fn parse_service_metadata(root: &toml::Value) -> Result<ServiceMetadata, Command "admin_contract_version", "config_contract_version", "host_feature_profile", + "nix_material", "provider_contract_version", "service", "state_contract_version", @@ -345,8 +387,14 @@ fn parse_service_metadata(root: &toml::Value) -> Result<ServiceMetadata, Command if text("host_feature_profile")? != HOST_FEATURE_PROFILE { return Err(CommandError::InvalidServiceMetadata); } + let nix_material = match text("nix_material")? { + "absent" => NixMaterialState::Absent, + "deferred" => NixMaterialState::Deferred, + _ => return Err(CommandError::InvalidServiceMetadata), + }; Ok(ServiceMetadata { service: text("service")?.to_owned(), + nix_material, contract_versions: ContractVersions::new( version("config_contract_version")?, version("state_contract_version")?, @@ -530,70 +578,47 @@ fn validate_cargo_lock( } } -fn validate_flake_lock(bytes: &[u8], revision: &str) -> Result<(), CommandError> { - let value = serde_json::from_slice::<serde_json::Value>(bytes) - .map_err(|_| CommandError::InvalidFlakeLock)?; - let version = value.get("version").and_then(serde_json::Value::as_u64); - let root_name = value.get("root").and_then(serde_json::Value::as_str); - let nodes = value.get("nodes").and_then(serde_json::Value::as_object); - if version != Some(7) || root_name.is_none() || nodes.is_none() { - return Err(CommandError::InvalidFlakeLock); - } - let nodes = nodes.ok_or(CommandError::InvalidFlakeLock)?; - let root = nodes - .get(root_name.ok_or(CommandError::InvalidFlakeLock)?) - .and_then(|node| node.get("inputs")) - .and_then(serde_json::Value::as_object) - .ok_or(CommandError::InvalidFlakeLock)?; - let direct = root - .values() - .filter_map(serde_json::Value::as_str) - .collect::<Vec<_>>(); - let mut lib_nodes = 0_usize; - let mut exact_direct = 0_usize; - for (name, node) in nodes { - let locked = node.get("locked").and_then(serde_json::Value::as_object); - let original = node.get("original").and_then(serde_json::Value::as_object); - let is_lib = locked.is_some_and(|locked| { - locked.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") - && locked.get("repo").and_then(serde_json::Value::as_str) == Some("lib") - }) || original.is_some_and(|original| { - original.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") - && original.get("repo").and_then(serde_json::Value::as_str) == Some("lib") - }); - if !is_lib { - continue; +fn read_nix_material( + root: &Path, + state: NixMaterialState, +) -> Result<NixMaterialEvidence, CommandError> { + match 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(CommandError::InvalidNixMaterial), + } + } + Ok(NixMaterialEvidence::Absent) } - lib_nodes += 1; - let locked = locked.ok_or(CommandError::InvalidFlakeLock)?; - let original = original.ok_or(CommandError::InvalidFlakeLock)?; - let locked_keys = locked.keys().map(String::as_str).collect::<BTreeSet<_>>(); - let original_keys = original.keys().map(String::as_str).collect::<BTreeSet<_>>(); - let exact = locked_keys - == BTreeSet::from(["lastModified", "narHash", "owner", "repo", "rev", "type"]) - && original_keys == BTreeSet::from(["owner", "repo", "rev", "type"]) - && locked.get("type").and_then(serde_json::Value::as_str) == Some("github") - && locked.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") - && locked.get("repo").and_then(serde_json::Value::as_str) == Some("lib") - && locked.get("rev").and_then(serde_json::Value::as_str) == Some(revision) - && locked - .get("narHash") - .and_then(serde_json::Value::as_str) - .is_some_and(valid_nix_sha256) - && original.get("type").and_then(serde_json::Value::as_str) == Some("github") - && original.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") - && original.get("repo").and_then(serde_json::Value::as_str) == Some("lib") - && original.get("rev").and_then(serde_json::Value::as_str) == Some(revision) - && original.get("ref").is_none(); - if direct.iter().filter(|direct| **direct == name).count() == 1 && exact { - exact_direct += 1; + NixMaterialState::Deferred => { + let expression = read_bounded_regular( + &root.join(FLAKE_NIX), + MAX_FLAKE_NIX_BYTES, + CommandError::InvalidNixMaterial, + )?; + let lock = read_bounded_regular( + &root.join(FLAKE_LOCK), + MAX_FLAKE_LOCK_BYTES, + CommandError::InvalidFlakeLock, + )?; + crate::service_source_lock::validate_deferred_nix_lock(&lock) + .map_err(|_| CommandError::InvalidFlakeLock)?; + let evidence = validate_deferred_nix_material(&expression, &lock) + .map_err(|_| CommandError::InvalidNixMaterial)?; + Ok(NixMaterialEvidence::Deferred { + lib_revision: evidence.lib_revision().to_owned(), + flake_lock_sha256: evidence.flake_lock_sha256().to_owned(), + }) } } - if lib_nodes == 1 && exact_direct == 1 { - Ok(()) - } else { - Err(CommandError::InvalidFlakeLock) - } +} + +#[cfg(test)] +fn validate_flake_lock(bytes: &[u8]) -> Result<String, CommandError> { + crate::service_source_lock::validate_deferred_nix_lock(bytes) + .map_err(|_| CommandError::InvalidFlakeLock) } fn validate_toolchain(root: &Path) -> Result<(), CommandError> { @@ -818,6 +843,7 @@ fn valid_lower_hex(value: &str, length: usize) -> bool { .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } +#[cfg(test)] fn valid_nix_sha256(value: &str) -> bool { let Some(encoded) = value.strip_prefix("sha256-") else { return false; @@ -978,6 +1004,7 @@ resolver = "3" [workspace.metadata.radroots.service_source_lock] service = "fixture_service" host_feature_profile = "service-host" +nix_material = "deferred" config_contract_version = 1 state_contract_version = 2 admin_contract_version = 3 @@ -998,6 +1025,13 @@ radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version ) .expect("Cargo.lock"); fs::write( + service.join(FLAKE_NIX), + format!( + "{{\n inputs.lib = {{\n url = \"github:radrootslabs/lib/{revision}\";\n flake = false;\n }};\n outputs = {{ ... }}: {{ }};\n}}\n" + ), + ) + .expect("flake.nix"); + fs::write( service.join(FLAKE_LOCK), format!( r#"{{"nodes":{{"root":{{"inputs":{{"lib":"lib"}}}},"lib":{{"locked":{{"lastModified":1,"narHash":"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","owner":"radrootslabs","repo":"lib","rev":"{revision}","type":"github"}},"original":{{"owner":"radrootslabs","repo":"lib","rev":"{revision}","type":"github"}}}}}},"root":"root","version":7}} @@ -1065,6 +1099,8 @@ radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version let text = std::str::from_utf8(&bytes).expect("UTF-8"); assert!(text.contains("service = \"fixture_service\"")); assert!(text.contains(&format!("revision = \"{}\"", fixture.revision))); + assert!(text.contains("material = \"deferred\"")); + assert!(text.contains(&format!("lib_revision = \"{}\"", fixture.revision))); assert!(text.contains("config = 1\nstate = 2\nadmin = 3\nstatus = 4\nprovider = 5")); assert!(fixture.root.path().exists()); } @@ -1164,6 +1200,18 @@ radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version CommandError::InvalidCargoLock, ), ( + FLAKE_NIX, + "radrootslabs/lib", + "example.invalid/lib", + CommandError::InvalidNixMaterial, + ), + ( + FLAKE_NIX, + "inputs.lib = {", + "other = {", + CommandError::InvalidNixMaterial, + ), + ( FLAKE_LOCK, "radrootslabs", "other", @@ -1344,6 +1392,136 @@ radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version } #[test] + fn absent_and_independently_revisioned_deferred_nix_material_are_exact() { + let fixture = Fixture::new(); + let manifest = fixture.service.join(CARGO_MANIFEST); + let source = fs::read_to_string(&manifest).expect("manifest"); + fs::write( + &manifest, + source.replacen( + "nix_material = \"deferred\"", + "nix_material = \"absent\"", + 1, + ), + ) + .expect("absent metadata"); + fs::remove_file(fixture.service.join(FLAKE_NIX)).expect("remove flake expression"); + fs::remove_file(fixture.service.join(FLAKE_LOCK)).expect("remove flake lock"); + git(&fixture.service, &["add", "-A"]); + git(&fixture.service, &["commit", "--quiet", "-m", "absent Nix"]); + run_with( + CommandMode::Write, + &fixture.service, + &fixture.archive, + &fixture.reachable(), + ) + .expect("write absent-Nix lock"); + let lock = fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"); + let lock = ServiceSourceLockV2::from_canonical_bytes(&lock).expect("canonical lock"); + assert_eq!(lock.nix_material_state(), NixMaterialState::Absent); + assert_eq!(lock.nix_lib_revision(), None); + assert_eq!(lock.flake_lock_sha256(), None); + + let fixture = Fixture::new(); + let deferred_revision = "b".repeat(40); + for name in [FLAKE_NIX, FLAKE_LOCK] { + let path = fixture.service.join(name); + let current = fs::read_to_string(&path).expect("Nix source"); + fs::write( + &path, + current.replace(&fixture.revision, &deferred_revision), + ) + .expect("deferred revision"); + } + git(&fixture.service, &["add", "."]); + git( + &fixture.service, + &["commit", "--quiet", "-m", "defer Nix revision"], + ); + run_with( + CommandMode::Write, + &fixture.service, + &fixture.archive, + &fixture.reachable(), + ) + .expect("write independently revisioned lock"); + let lock = fs::read(fixture.service.join(LOCK_FILENAME)).expect("source lock"); + let lock = ServiceSourceLockV2::from_canonical_bytes(&lock).expect("canonical lock"); + assert_eq!(lock.revision(), fixture.revision); + assert_eq!(lock.nix_material_state(), NixMaterialState::Deferred); + assert_eq!(lock.nix_lib_revision(), Some(deferred_revision.as_str())); + } + + #[test] + fn predecessor_source_lock_is_rejected_instead_of_forming_dual_authority() { + let fixture = Fixture::new(); + fs::write( + fixture.service.join(PREDECESSOR_LOCK_FILENAME), + b"historical lock", + ) + .expect("predecessor lock"); + git(&fixture.service, &["add", "."]); + git( + &fixture.service, + &["commit", "--quiet", "-m", "retain predecessor lock"], + ); + assert_eq!( + run_with( + CommandMode::Write, + &fixture.service, + &fixture.archive, + &fixture.reachable(), + ), + Err(CommandError::InvalidSourceLock) + ); + } + + #[test] + fn partial_or_mismatched_nix_material_fails_closed() { + for removed in [FLAKE_NIX, FLAKE_LOCK] { + let fixture = Fixture::new(); + fs::remove_file(fixture.service.join(removed)).expect("remove one Nix input"); + git(&fixture.service, &["add", "-A"]); + git( + &fixture.service, + &["commit", "--quiet", "-m", "partial Nix"], + ); + assert!(matches!( + run_with( + CommandMode::Write, + &fixture.service, + &fixture.archive, + &fixture.reachable(), + ), + Err(CommandError::InvalidNixMaterial | CommandError::InvalidFlakeLock) + )); + } + + let fixture = Fixture::new(); + let expression = fixture.service.join(FLAKE_NIX); + let source = fs::read_to_string(&expression).expect("flake expression"); + fs::write( + &expression, + source.replace(&fixture.revision, &"b".repeat(40)), + ) + .expect("mismatch expression"); + git(&fixture.service, &["add", "."]); + git( + &fixture.service, + &["commit", "--quiet", "-m", "mismatch Nix"], + ); + assert_eq!( + run_with( + CommandMode::Write, + &fixture.service, + &fixture.archive, + &fixture.reachable(), + ), + Err(CommandError::InvalidNixMaterial) + ); + } + + #[test] fn flake_lock_rejects_each_independent_fixed_field_drift() { let fixture = Fixture::new(); let bytes = fs::read(fixture.service.join(FLAKE_LOCK)).expect("flake lock"); @@ -1355,6 +1533,7 @@ radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version ("/nodes/lib/locked/owner", serde_json::json!("other")), ("/nodes/lib/locked/repo", serde_json::json!("other")), ("/nodes/lib/locked/rev", serde_json::json!("other")), + ("/nodes/lib/locked/lastModified", serde_json::json!("1")), ( "/nodes/lib/locked/narHash", serde_json::json!("sha256-invalid"), @@ -1367,10 +1546,7 @@ radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version let mut drifted = canonical.clone(); *drifted.pointer_mut(pointer).expect("governed field") = replacement; assert_eq!( - validate_flake_lock( - &serde_json::to_vec(&drifted).expect("flake json"), - &fixture.revision - ), + validate_flake_lock(&serde_json::to_vec(&drifted).expect("flake json")), Err(CommandError::InvalidFlakeLock), "accepted drift at {pointer}" ); @@ -1387,22 +1563,40 @@ radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version .expect("flake section") .insert(field.into(), serde_json::json!("unexpected")); assert_eq!( - validate_flake_lock( - &serde_json::to_vec(&drifted).expect("flake json"), - &fixture.revision - ), + validate_flake_lock(&serde_json::to_vec(&drifted).expect("flake json")), Err(CommandError::InvalidFlakeLock), "accepted {section}.{field}" ); } + let mut extra_node_field = canonical.clone(); + extra_node_field["nodes"]["lib"]["extra"] = serde_json::json!(true); + assert_eq!( + validate_flake_lock(&serde_json::to_vec(&extra_node_field).expect("flake json")), + Err(CommandError::InvalidFlakeLock) + ); + let mut duplicate = canonical.clone(); duplicate["nodes"]["lib2"] = duplicate["nodes"]["lib"].clone(); assert_eq!( - validate_flake_lock( - &serde_json::to_vec(&duplicate).expect("flake json"), - &fixture.revision - ), + validate_flake_lock(&serde_json::to_vec(&duplicate).expect("flake json")), + Err(CommandError::InvalidFlakeLock) + ); + + let aliased = String::from_utf8(bytes.clone()) + .expect("flake UTF-8") + .replacen("\"lib\":\"lib\"", "\"other\":\"lib\"", 1); + assert_eq!( + validate_flake_lock(aliased.as_bytes()), + Err(CommandError::InvalidFlakeLock) + ); + let duplicated = String::from_utf8(bytes).expect("flake UTF-8").replacen( + "\"version\":7", + "\"version\":7,\"version\":7", + 1, + ); + assert_eq!( + validate_flake_lock(duplicated.as_bytes()), Err(CommandError::InvalidFlakeLock) ); } @@ -1415,6 +1609,7 @@ radroots_service_host = {{ git = "{LIB_REPOSITORY}", rev = "{revision}", version for (field, replacement) in [ ("service", toml::Value::Integer(1)), ("host_feature_profile", toml::Value::String("other".into())), + ("nix_material", toml::Value::String("other".into())), ("config_contract_version", toml::Value::Integer(0)), ("state_contract_version", toml::Value::Integer(0)), ("admin_contract_version", toml::Value::Integer(0)), @@ -1728,16 +1923,12 @@ package = [] ); assert_eq!( - validate_flake_lock( - br#"{"nodes":{"root":{"inputs":{}}},"root":"root","version":7}"#, - &"a".repeat(40), - ), + validate_flake_lock(br#"{"nodes":{"root":{"inputs":{}}},"root":"root","version":7}"#,), Err(CommandError::InvalidFlakeLock) ); assert_eq!( validate_flake_lock( br#"{"nodes":{"root":{"inputs":{"lib":"lib"}},"lib":{"original":{"owner":"radrootslabs","repo":"lib"}}},"root":"root","version":7}"#, - &"a".repeat(40), ), Err(CommandError::InvalidFlakeLock) ); @@ -1770,6 +1961,7 @@ package = [] CommandError::InvalidServiceMetadata, CommandError::InvalidCargoManifest, CommandError::InvalidCargoLock, + CommandError::InvalidNixMaterial, CommandError::InvalidFlakeLock, CommandError::InvalidToolchain, CommandError::InvalidSourceArchive,