lib

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

commit 2fc8a2ce7502817fc8ff69ad5db0610e95932768
parent 055096853fca95e15d0f813d33a14aca13be3881
Author: triesap <tyson@radroots.org>
Date:   Mon,  7 Sep 2026 04:24:38 +0000

feat: admit governed binary and OCI artifacts

- Parse exact Mach-O and ELF architectures with bounded linkage checks.
- Reject external SQLite linkage and run native help smoke tests safely.
- Validate OCI manifests, configs, AGPL labels, and layer digests.
- Derive service image licensing from the checked-in Cargo manifest.

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mbuild/nix/service/fixture-service/Cargo.toml | 1+
Mbuild/nix/service/fixture.nix | 3+--
Mbuild/nix/service/oci.nix | 8+++++++-
Mbuild/nix/service/package.nix | 15+++++++++++++++
Acontracts/architecture/decisions/services_hardening_artifact_admission.v1.json | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/architecture/decisions/services_hardening_release_artifacts.v3.json | 45+++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/Cargo.toml | 1+
Atools/xtask/src/artifact_admission.rs | 893+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 2++
Mtools/xtask/src/safe_artifact_io.rs | 26++++++++++++++++++++------
Mtools/xtask/src/service_release_artifacts.rs | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
13 files changed, 1238 insertions(+), 53 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -6094,6 +6094,7 @@ dependencies = [ "dto_bindgen_core", "flate2", "fs2", + "goblin", "hex", "jsonschema", "prettyplease", diff --git a/Cargo.toml b/Cargo.toml @@ -189,6 +189,7 @@ futures = { version = "0.3" } futures-executor = { version = "0.3" } flate2 = { version = "1" } fs2 = { version = "0.4" } +goblin = { version = "0.8.2" } getrandom = { version = "0.2", default-features = false } hkdf = { version = "0.12", default-features = false } hmac = { version = "0.12", default-features = false } diff --git a/build/nix/service/fixture-service/Cargo.toml b/build/nix/service/fixture-service/Cargo.toml @@ -2,6 +2,7 @@ name = "fixture-service" version = "0.1.0" edition = "2024" +license = "AGPL-3.0-or-later" publish = false [[bin]] diff --git a/build/nix/service/fixture.nix b/build/nix/service/fixture.nix @@ -113,7 +113,7 @@ let "dev.radroots.mount.state.mode": "read-write", "dev.radroots.rootfs": "read-only-compatible", "org.opencontainers.image.description": "Hardened fixture_service service image", - "org.opencontainers.image.licenses": "MIT OR Apache-2.0", + "org.opencontainers.image.licenses": "AGPL-3.0-or-later", "org.opencontainers.image.revision": "1111111111111111111111111111111111111111", "org.opencontainers.image.title": "fixture_service", "org.opencontainers.image.version": "0.1.0-alpha" @@ -645,7 +645,6 @@ let ociArgs // { serviceName = lib.concatStrings (lib.replicate 128 "a"); - binaryName = lib.concatStrings (lib.replicate 128 "b"); buildInfo = fixtureBuildInfo // { serviceVersion = lib.concatStrings (lib.replicate 128 "1"); contractVersions = lib.mapAttrs (_: _: 4294967295) fixtureBuildInfo.contractVersions; diff --git a/build/nix/service/oci.nix b/build/nix/service/oci.nix @@ -19,6 +19,12 @@ assert lib.assertMsg ( ) "serviceName must be a lowercase snake-case identifier"; assert lib.assertMsg (lib.isDerivation package) "package must be a derivation"; assert lib.assertMsg ( + (package.passthru.radrootsServicePackage.schema or null) == "radroots.service-package.v1" + && (package.passthru.radrootsServicePackage.servicePackage or null) == binaryName + && (package.passthru.radrootsServicePackage.binaryName or null) == binaryName + && (package.passthru.radrootsServicePackage.serviceLicense or null) == "AGPL-3.0-or-later" +) "package must carry the governed Cargo-derived service identity and license"; +assert lib.assertMsg ( builtins.isString binaryName && builtins.stringLength binaryName <= 128 && builtins.match "^[a-z][a-z0-9_-]*$" binaryName != null @@ -102,7 +108,7 @@ let "dev.radroots.mount.state.mode" = "read-write"; "dev.radroots.rootfs" = "read-only-compatible"; "org.opencontainers.image.description" = "Hardened ${serviceName} service image"; - "org.opencontainers.image.licenses" = "MIT OR Apache-2.0"; + "org.opencontainers.image.licenses" = package.passthru.radrootsServicePackage.serviceLicense; "org.opencontainers.image.revision" = buildInfo.serviceCommit; "org.opencontainers.image.title" = serviceName; "org.opencontainers.image.version" = buildInfo.serviceVersion; diff --git a/build/nix/service/package.nix b/build/nix/service/package.nix @@ -36,6 +36,15 @@ assert lib.assertMsg ( !(builtins.hasAttr "CARGO_PROFILE" nativeInputs.environment) ) "nativeInputs.environment must not replace CARGO_PROFILE"; let + manifest = builtins.fromTOML (builtins.readFile (source + "/Cargo.toml")); + serviceLicense = + if manifest ? workspace && manifest.workspace ? package then + manifest.workspace.package.license or null + else + manifest.package.license or null; + _licenseAssertion = assert lib.assertMsg ( + serviceLicense == "AGPL-3.0-or-later" + ) "service source must derive the governed AGPL-3.0-or-later license from Cargo.toml"; true; craneLib = (crane.mkLib pkgs).overrideToolchain toolchain; cargoExtraArgs = "--locked --package ${servicePackage} --bin ${binaryName}"; commonArgs = { @@ -50,9 +59,15 @@ let }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; in +assert _licenseAssertion; craneLib.buildPackage ( commonArgs // { inherit cargoArtifacts; + passthru.radrootsServicePackage = { + inherit binaryName serviceLicense servicePackage; + schema = "radroots.service-package.v1"; + }; + meta.license = lib.licenses.agpl3Plus; } ) diff --git a/contracts/architecture/decisions/services_hardening_artifact_admission.v1.json b/contracts/architecture/decisions/services_hardening_artifact_admission.v1.json @@ -0,0 +1,72 @@ +{ + "schema": "radroots.services-hardening.artifact-admission-decisions.v1", + "contract_version": 1, + "decision_state": "active", + "owner_step": 304, + "command_owner": "tools/xtask", + "supported_binary_targets": [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu" + ], + "binary": { + "formats": { + "aarch64-apple-darwin": "thin_macho64_arm64_execute", + "x86_64-unknown-linux-gnu": "elf64_little_endian_x86_64_execute_or_pie" + }, + "maximum_parse_bytes": 67108864, + "architecture": "exact_target_match", + "linkage": "parse_declared_dynamic_libraries_and_forbid_external_sqlite", + "sqlite": "one_bundled_native_linkage_under_the_separate_sqlx_only_source_contract", + "structural_smoke": "executable_type_nonzero_entrypoint_and_executable_segment", + "runtime_smoke": "bounded_help_execution_on_the_matching_native_host", + "fat_or_multi_arch": "forbidden" + }, + "oci": { + "format": "single_image_docker_archive_tar_gzip", + "platform": "linux_amd64", + "maximum_layers": 2, + "outer_archive": "descriptor_bound_bounded_safe_materialization_with_exact_inventory", + "manifest": "one_entry_exact_config_repo_tag_and_layer_references", + "config": "content_addressed_json_with_exact_rootless_runtime_and_build_labels", + "license": "Cargo.toml package license derived AGPL-3.0-or-later", + "layers": "parse_only_bounded_tar_validation_with_content_digest_reconciliation", + "entrypoint": "one_regular_executable_payload_at_the_configured_store_path", + "unpacking": "forbidden" + }, + "maximums": { + "outer_compressed_bytes": 2147483648, + "outer_expanded_bytes": 17179869184, + "outer_members": 65536, + "outer_member_bytes": 17179869184, + "json_bytes": 1048576, + "layer_members": 65536, + "layer_payload_bytes": 17179869184, + "path_bytes": 4096, + "depth": 64, + "runtime_seconds": 10, + "runtime_stream_bytes": 65536 + }, + "required_negative_vectors": [ + "arbitrary_binary_bytes", + "wrong_binary_architecture", + "fat_macho", + "missing_binary_entrypoint", + "external_sqlite_linkage", + "runtime_smoke_failure", + "unsafe_outer_tar_member", + "wrong_oci_license", + "multiple_manifest_entries", + "config_digest_mismatch", + "wrong_oci_platform", + "wrong_rootless_config", + "unexpected_label", + "missing_layer", + "layer_digest_mismatch", + "unsafe_layer_path", + "missing_entrypoint_payload" + ], + "nonclaims": [ + "Linux artifact build on a macOS host without a Linux builder", + "signature notarization publication deployment or production activation" + ] +} diff --git a/contracts/architecture/decisions/services_hardening_release_artifacts.v3.json b/contracts/architecture/decisions/services_hardening_release_artifacts.v3.json @@ -0,0 +1,45 @@ +{ + "schema": "radroots.services-hardening.release-artifacts-decisions.v3", + "contract_version": 3, + "decision_state": "active", + "predecessor": { + "schema": "radroots.services-hardening.release-artifacts-decisions.v2", + "filename": "services_hardening_release_artifacts.v2.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"], + "service_license_path": "Cargo.toml.workspace.package.license_or_package.license", + "artifact_admission_contract": "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json", + "supported_targets": ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"], + "binary_admission": "exact_format_architecture_linkage_structural_and_native_bounded_help_smoke", + "oci_admission": "safe_materialization_exact_manifest_config_AGPL_labels_layers_and_entrypoint", + "input_inventory": ["config.example.toml", "config.schema.json", "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/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -15,6 +15,7 @@ dto_bindgen_core = { workspace = true } dto_bindgen_backend_ts = { workspace = true } fs2 = { workspace = true } flate2 = { workspace = true } +goblin = { workspace = true } hex = { workspace = true } jsonschema = { workspace = true } prettyplease = { workspace = true } diff --git a/tools/xtask/src/artifact_admission.rs b/tools/xtask/src/artifact_admission.rs @@ -0,0 +1,893 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, fs, + io::{Seek as _, SeekFrom}, + path::{Component, Path}, + time::Duration, +}; + +use goblin::{ + Object, + elf::{header::EM_X86_64, program_header::PF_X}, + mach::{Mach, constants::cputype::CPU_TYPE_ARM64, header::MH_EXECUTE}, +}; +use serde_json::{Map, Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::{bounded_process, safe_artifact_io}; +use safe_artifact_io::TarGzipLimits; + +const CONTRACT_RELATIVE: &str = + "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json"; +const MAX_CONTRACT_BYTES: u64 = 65_536; +const MAX_BINARY_PARSE_BYTES: u64 = 67_108_864; +const MAX_OCI_BYTES: u64 = 2_147_483_648; +const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184; +const MAX_ARCHIVE_MEMBERS: u64 = 65_536; +const MAX_JSON_BYTES: u64 = 1_048_576; +const MAX_PATH_BYTES: usize = 4_096; +const MAX_DEPTH: usize = 64; +const MAX_LAYERS: usize = 2; +const MAX_RUNTIME_STREAM_BYTES: usize = 65_536; +const RUNTIME_DEADLINE: Duration = Duration::from_secs(10); +const AGPL_LICENSE: &str = "AGPL-3.0-or-later"; +const LINUX_TARGET: &str = "x86_64-unknown-linux-gnu"; +const MACOS_TARGET: &str = "aarch64-apple-darwin"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AdmissionError { + InvalidContract, + InvalidBinary, + BinarySmokeFailure, + InvalidOci, +} + +impl fmt::Display for AdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidContract => "artifact admission contract is invalid", + Self::InvalidBinary => "service binary admission failed", + Self::BinarySmokeFailure => "service binary smoke admission failed", + Self::InvalidOci => "service OCI admission failed", + }) + } +} + +impl std::error::Error for AdmissionError {} + +#[derive(Clone, Copy)] +pub(crate) struct ContractVersions { + pub(crate) admin: u32, + pub(crate) config: u32, + pub(crate) provider: u32, + pub(crate) state: u32, + pub(crate) status: u32, +} + +pub(crate) struct OciExpectation<'a> { + pub(crate) service: &'a str, + pub(crate) binary_name: &'a str, + pub(crate) version: &'a str, + pub(crate) service_revision: &'a str, + pub(crate) lib_revision: &'a str, + pub(crate) license: &'a str, + pub(crate) contract_versions: ContractVersions, +} + +pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> { + let bytes = safe_artifact_io::read_regular_path( + &workspace_root.join(CONTRACT_RELATIVE), + MAX_CONTRACT_BYTES, + ) + .map_err(|_| AdmissionError::InvalidContract.to_string())?; + let observed = serde_json::from_slice::<Value>(&bytes) + .map_err(|_| AdmissionError::InvalidContract.to_string())?; + if observed == expected_contract() { + Ok(()) + } else { + Err(AdmissionError::InvalidContract.to_string()) + } +} + +fn expected_contract() -> Value { + json!({ + "schema": "radroots.services-hardening.artifact-admission-decisions.v1", + "contract_version": 1, + "decision_state": "active", + "owner_step": 304, + "command_owner": "tools/xtask", + "supported_binary_targets": [MACOS_TARGET, LINUX_TARGET], + "binary": { + "formats": { + MACOS_TARGET: "thin_macho64_arm64_execute", + LINUX_TARGET: "elf64_little_endian_x86_64_execute_or_pie" + }, + "maximum_parse_bytes": MAX_BINARY_PARSE_BYTES, + "architecture": "exact_target_match", + "linkage": "parse_declared_dynamic_libraries_and_forbid_external_sqlite", + "sqlite": "one_bundled_native_linkage_under_the_separate_sqlx_only_source_contract", + "structural_smoke": "executable_type_nonzero_entrypoint_and_executable_segment", + "runtime_smoke": "bounded_help_execution_on_the_matching_native_host", + "fat_or_multi_arch": "forbidden" + }, + "oci": { + "format": "single_image_docker_archive_tar_gzip", + "platform": "linux_amd64", + "maximum_layers": MAX_LAYERS, + "outer_archive": "descriptor_bound_bounded_safe_materialization_with_exact_inventory", + "manifest": "one_entry_exact_config_repo_tag_and_layer_references", + "config": "content_addressed_json_with_exact_rootless_runtime_and_build_labels", + "license": "Cargo.toml package license derived AGPL-3.0-or-later", + "layers": "parse_only_bounded_tar_validation_with_content_digest_reconciliation", + "entrypoint": "one_regular_executable_payload_at_the_configured_store_path", + "unpacking": "forbidden" + }, + "maximums": { + "outer_compressed_bytes": MAX_OCI_BYTES, + "outer_expanded_bytes": MAX_ARCHIVE_EXPANDED_BYTES, + "outer_members": MAX_ARCHIVE_MEMBERS, + "outer_member_bytes": MAX_ARCHIVE_EXPANDED_BYTES, + "json_bytes": MAX_JSON_BYTES, + "layer_members": MAX_ARCHIVE_MEMBERS, + "layer_payload_bytes": MAX_ARCHIVE_EXPANDED_BYTES, + "path_bytes": MAX_PATH_BYTES, + "depth": MAX_DEPTH, + "runtime_seconds": RUNTIME_DEADLINE.as_secs(), + "runtime_stream_bytes": MAX_RUNTIME_STREAM_BYTES + }, + "required_negative_vectors": [ + "arbitrary_binary_bytes", "wrong_binary_architecture", "fat_macho", + "missing_binary_entrypoint", "external_sqlite_linkage", "runtime_smoke_failure", + "unsafe_outer_tar_member", "wrong_oci_license", "multiple_manifest_entries", + "config_digest_mismatch", "wrong_oci_platform", "wrong_rootless_config", + "unexpected_label", "missing_layer", "layer_digest_mismatch", + "unsafe_layer_path", "missing_entrypoint_payload" + ], + "nonclaims": [ + "Linux artifact build on a macOS host without a Linux builder", + "signature notarization publication deployment or production activation" + ] + }) +} + +pub(crate) fn admit_binary(path: &Path, target: &str, runtime_smoke: bool) -> Result<(), String> { + admit_binary_inner(path, target, runtime_smoke).map_err(|error| error.to_string()) +} + +fn admit_binary_inner( + path: &Path, + target: &str, + runtime_smoke: bool, +) -> Result<(), AdmissionError> { + if ![MACOS_TARGET, LINUX_TARGET].contains(&target) { + return Err(AdmissionError::InvalidBinary); + } + let bytes = safe_artifact_io::read_regular_path(path, MAX_BINARY_PARSE_BYTES) + .map_err(|_| AdmissionError::InvalidBinary)?; + let libraries = match ( + target, + Object::parse(&bytes).map_err(|_| AdmissionError::InvalidBinary)?, + ) { + (LINUX_TARGET, Object::Elf(binary)) => { + if binary.header.e_machine != EM_X86_64 + || !matches!( + binary.header.e_type, + goblin::elf::header::ET_EXEC | goblin::elf::header::ET_DYN + ) + || binary.entry == 0 + || !binary.program_headers.iter().any(|header| { + header.p_flags & PF_X != 0 + && binary.entry >= header.p_vaddr + && binary.entry < header.p_vaddr.saturating_add(header.p_memsz) + }) + { + return Err(AdmissionError::InvalidBinary); + } + binary + .libraries + .iter() + .map(|value| (*value).to_owned()) + .collect::<Vec<_>>() + } + (MACOS_TARGET, Object::Mach(Mach::Binary(binary))) => { + if binary.header.cputype != CPU_TYPE_ARM64 + || binary.header.filetype != MH_EXECUTE + || binary.entry == 0 + || !binary.segments.iter().any(|segment| { + segment.initprot & 0x4 != 0 + && binary.entry >= segment.vmaddr + && binary.entry < segment.vmaddr.saturating_add(segment.vmsize) + }) + { + return Err(AdmissionError::InvalidBinary); + } + binary + .libs + .iter() + .map(|value| (*value).to_owned()) + .collect::<Vec<_>>() + } + _ => return Err(AdmissionError::InvalidBinary), + }; + validate_dynamic_libraries(&libraries)?; + if runtime_smoke && target_matches_host(target) { + runtime_help_smoke(path)?; + } + Ok(()) +} + +fn validate_dynamic_libraries(libraries: &[String]) -> Result<(), AdmissionError> { + if libraries.iter().any(|library| { + library.is_empty() + || library.len() > 1_024 + || library.contains(['\n', '\r', '\0']) + || library.to_ascii_lowercase().contains("sqlite") + }) { + Err(AdmissionError::InvalidBinary) + } else { + Ok(()) + } +} + +fn target_matches_host(target: &str) -> bool { + matches!( + (target, std::env::consts::OS, std::env::consts::ARCH), + (MACOS_TARGET, "macos", "aarch64") | (LINUX_TARGET, "linux", "x86_64") + ) +} + +fn runtime_help_smoke(path: &Path) -> Result<(), AdmissionError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o500)) + .map_err(|_| AdmissionError::BinarySmokeFailure)?; + } + let parent = path.parent().ok_or(AdmissionError::BinarySmokeFailure)?; + let output = bounded_process::run( + &bounded_process::ProcessRequest::new(path.as_os_str()) + .arg("--help") + .current_dir(parent) + .deadline(RUNTIME_DEADLINE) + .output_limits(MAX_RUNTIME_STREAM_BYTES, MAX_RUNTIME_STREAM_BYTES), + ) + .map_err(|_| AdmissionError::BinarySmokeFailure)?; + if output.status().success() { + Ok(()) + } else { + Err(AdmissionError::BinarySmokeFailure) + } +} + +pub(crate) fn admit_oci( + path: &Path, + trusted_parent: &Path, + expected: &OciExpectation<'_>, +) -> Result<(), String> { + admit_oci_inner(path, trusted_parent, expected).map_err(|error| error.to_string()) +} + +fn admit_oci_inner( + path: &Path, + trusted_parent: &Path, + expected: &OciExpectation<'_>, +) -> Result<(), AdmissionError> { + if expected.license != AGPL_LICENSE + || !valid_identifier(expected.service) + || !valid_binary_name(expected.binary_name) + || !valid_hex(expected.service_revision, 40) + || !valid_hex(expected.lib_revision, 40) + { + return Err(AdmissionError::InvalidOci); + } + let limits = TarGzipLimits { + max_compressed_bytes: MAX_OCI_BYTES, + max_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES, + max_members: MAX_ARCHIVE_MEMBERS, + max_member_bytes: MAX_ARCHIVE_EXPANDED_BYTES, + max_payload_bytes: MAX_ARCHIVE_EXPANDED_BYTES, + max_depth: MAX_DEPTH, + max_path_bytes: MAX_PATH_BYTES, + }; + let materialized = safe_artifact_io::materialize_tar_gzip_path(path, trusted_parent, limits) + .map_err(|_| AdmissionError::InvalidOci)?; + let snapshot = materialized.snapshot(); + let manifest = read_json_member(snapshot, "manifest.json")?; + let manifest = manifest.as_array().ok_or(AdmissionError::InvalidOci)?; + if manifest.len() != 1 { + return Err(AdmissionError::InvalidOci); + } + let record = manifest[0].as_object().ok_or(AdmissionError::InvalidOci)?; + require_exact_keys(record, &["Config", "Layers", "RepoTags"])?; + let config_name = json_string(record, "Config")?; + if !config_name.ends_with(".json") || !valid_hex(config_name.trim_end_matches(".json"), 64) { + return Err(AdmissionError::InvalidOci); + } + let image_name = expected.service.replace('_', "-"); + if json_string_array(record, "RepoTags")? != [format!("{image_name}:{}", expected.version)] { + return Err(AdmissionError::InvalidOci); + } + let layers = json_string_array(record, "Layers")?; + if layers.is_empty() || layers.len() > MAX_LAYERS || !all_unique(&layers) { + return Err(AdmissionError::InvalidOci); + } + for layer in &layers { + validate_layer_name(layer)?; + } + let config_bytes = read_member(snapshot, config_name, MAX_JSON_BYTES)?; + if sha256(&config_bytes) != config_name.trim_end_matches(".json") { + return Err(AdmissionError::InvalidOci); + } + let config = + serde_json::from_slice::<Value>(&config_bytes).map_err(|_| AdmissionError::InvalidOci)?; + let entrypoint = validate_config(&config, expected)?; + validate_repositories(snapshot, &image_name, expected.version, &layers)?; + validate_outer_inventory(snapshot, config_name, &layers)?; + let rootfs = config + .get("rootfs") + .and_then(Value::as_object) + .ok_or(AdmissionError::InvalidOci)?; + require_exact_keys(rootfs, &["diff_ids", "type"])?; + if json_string(rootfs, "type")? != "layers" { + return Err(AdmissionError::InvalidOci); + } + let diff_ids = json_string_array(rootfs, "diff_ids")?; + if diff_ids.len() != layers.len() { + return Err(AdmissionError::InvalidOci); + } + let mut entrypoint_count = 0_u32; + for (layer, diff_id) in layers.iter().zip(diff_ids) { + let evidence = snapshot + .hash( + snapshot_member(snapshot, layer)?, + MAX_ARCHIVE_EXPANDED_BYTES, + ) + .map_err(|_| AdmissionError::InvalidOci)?; + if diff_id != format!("sha256:{}", evidence.sha256) { + return Err(AdmissionError::InvalidOci); + } + entrypoint_count = entrypoint_count + .checked_add(validate_layer_tar(materialized.root(), layer, &entrypoint)?) + .ok_or(AdmissionError::InvalidOci)?; + } + materialized + .revalidate() + .map_err(|_| AdmissionError::InvalidOci)?; + if entrypoint_count == 1 { + Ok(()) + } else { + Err(AdmissionError::InvalidOci) + } +} + +fn validate_config( + config: &Value, + expected: &OciExpectation<'_>, +) -> Result<String, AdmissionError> { + let object = config.as_object().ok_or(AdmissionError::InvalidOci)?; + if json_string(object, "architecture")? != "amd64" + || json_string(object, "os")? != "linux" + || json_string(object, "created")? != "1970-01-01T00:00:01+00:00" + { + return Err(AdmissionError::InvalidOci); + } + let runtime = object + .get("config") + .and_then(Value::as_object) + .ok_or(AdmissionError::InvalidOci)?; + require_exact_keys( + runtime, + &[ + "Entrypoint", + "Env", + "Labels", + "StopSignal", + "User", + "WorkingDir", + ], + )?; + if json_string(runtime, "User")? != "65532:65532" + || json_string(runtime, "WorkingDir")? != "/" + || json_string(runtime, "StopSignal")? != "SIGTERM" + || json_string_array(runtime, "Env")? + != ["SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"] + { + return Err(AdmissionError::InvalidOci); + } + let entrypoints = json_string_array(runtime, "Entrypoint")?; + if entrypoints.len() != 1 { + return Err(AdmissionError::InvalidOci); + } + let entrypoint = entrypoints[0].clone(); + if !entrypoint.starts_with("/nix/store/") + || !entrypoint.ends_with(&format!("/bin/{}", expected.binary_name)) + || !valid_absolute_path(&entrypoint) + { + return Err(AdmissionError::InvalidOci); + } + let labels = runtime + .get("Labels") + .and_then(Value::as_object) + .ok_or(AdmissionError::InvalidOci)?; + let expected_labels = expected_labels(expected); + if labels.len() != expected_labels.len() + || expected_labels + .iter() + .any(|(key, value)| labels.get(key).and_then(Value::as_str) != Some(value)) + { + return Err(AdmissionError::InvalidOci); + } + Ok(entrypoint) +} + +fn expected_labels(expected: &OciExpectation<'_>) -> BTreeMap<String, String> { + let mut labels = BTreeMap::new(); + for (key, value) in [ + ( + "dev.radroots.build.feature-profile", + "service-host".to_owned(), + ), + ( + "dev.radroots.build.lib-revision", + expected.lib_revision.to_owned(), + ), + ("dev.radroots.build.rust-version", "1.97.1".to_owned()), + ("dev.radroots.build.target", LINUX_TARGET.to_owned()), + ( + "dev.radroots.contract.admin-version", + expected.contract_versions.admin.to_string(), + ), + ( + "dev.radroots.contract.config-version", + expected.contract_versions.config.to_string(), + ), + ( + "dev.radroots.contract.provider-version", + expected.contract_versions.provider.to_string(), + ), + ( + "dev.radroots.contract.state-version", + expected.contract_versions.state.to_string(), + ), + ( + "dev.radroots.contract.status-version", + expected.contract_versions.status.to_string(), + ), + ( + "dev.radroots.mount.config", + format!("/etc/radroots/services/{}", expected.service), + ), + ("dev.radroots.mount.config.mode", "read-only".to_owned()), + ( + "dev.radroots.mount.credentials", + format!("/etc/radroots/secrets/services/{}", expected.service), + ), + ( + "dev.radroots.mount.credentials.mode", + "read-only".to_owned(), + ), + ( + "dev.radroots.mount.runtime", + format!("/run/radroots/services/{}", expected.service), + ), + ("dev.radroots.mount.runtime.mode", "read-write".to_owned()), + ( + "dev.radroots.mount.state", + format!("/var/lib/radroots/services/{}", expected.service), + ), + ("dev.radroots.mount.state.mode", "read-write".to_owned()), + ("dev.radroots.rootfs", "read-only-compatible".to_owned()), + ( + "org.opencontainers.image.description", + format!("Hardened {} service image", expected.service), + ), + ( + "org.opencontainers.image.licenses", + expected.license.to_owned(), + ), + ( + "org.opencontainers.image.revision", + expected.service_revision.to_owned(), + ), + ( + "org.opencontainers.image.title", + expected.service.to_owned(), + ), + ( + "org.opencontainers.image.version", + expected.version.to_owned(), + ), + ] { + labels.insert(key.to_owned(), value); + } + labels +} + +#[cfg(test)] +pub(crate) fn fixture_labels(expected: &OciExpectation<'_>) -> BTreeMap<String, String> { + expected_labels(expected) +} + +fn validate_repositories( + snapshot: &safe_artifact_io::TraversalSnapshot, + image_name: &str, + version: &str, + layers: &[String], +) -> Result<(), AdmissionError> { + let repositories = read_json_member(snapshot, "repositories")?; + let last_layer = layers + .last() + .and_then(|path| path.split('/').next()) + .ok_or(AdmissionError::InvalidOci)?; + if repositories == json!({ image_name: { version: last_layer } }) { + Ok(()) + } else { + Err(AdmissionError::InvalidOci) + } +} + +fn validate_outer_inventory( + snapshot: &safe_artifact_io::TraversalSnapshot, + config_name: &str, + layers: &[String], +) -> Result<(), AdmissionError> { + let mut expected_files = BTreeSet::from([ + "manifest.json".to_owned(), + "repositories".to_owned(), + config_name.to_owned(), + ]); + let mut expected_directories = BTreeSet::new(); + for layer in layers { + let directory = layer.split('/').next().ok_or(AdmissionError::InvalidOci)?; + expected_directories.insert(directory.to_owned()); + expected_files.insert(format!("{directory}/VERSION")); + expected_files.insert(format!("{directory}/json")); + expected_files.insert(layer.clone()); + } + let observed_files = snapshot + .files() + .iter() + .map(|file| { + file.relative_path() + .to_str() + .map(str::to_owned) + .ok_or(AdmissionError::InvalidOci) + }) + .collect::<Result<BTreeSet<_>, _>>()?; + let observed_directories = snapshot + .directories() + .filter_map(|(path, _)| (!path.as_os_str().is_empty()).then_some(path)) + .map(|path| { + path.to_str() + .map(str::to_owned) + .ok_or(AdmissionError::InvalidOci) + }) + .collect::<Result<BTreeSet<_>, _>>()?; + if observed_files != expected_files || observed_directories != expected_directories { + return Err(AdmissionError::InvalidOci); + } + for layer in layers { + let directory = layer.split('/').next().ok_or(AdmissionError::InvalidOci)?; + if read_member(snapshot, &format!("{directory}/VERSION"), 16)? != b"1.0" { + return Err(AdmissionError::InvalidOci); + } + serde_json::from_slice::<Value>(&read_member( + snapshot, + &format!("{directory}/json"), + MAX_JSON_BYTES, + )?) + .map_err(|_| AdmissionError::InvalidOci)?; + } + Ok(()) +} + +fn validate_layer_tar( + materialized_root: &Path, + relative: &str, + entrypoint: &str, +) -> Result<u32, AdmissionError> { + let path = materialized_root.join(relative); + let mut file = fs::File::open(&path).map_err(|_| AdmissionError::InvalidOci)?; + let length = file + .metadata() + .map_err(|_| AdmissionError::InvalidOci)? + .len(); + if length == 0 || length > MAX_ARCHIVE_EXPANDED_BYTES { + return Err(AdmissionError::InvalidOci); + } + file.seek(SeekFrom::Start(0)) + .map_err(|_| AdmissionError::InvalidOci)?; + let mut archive = tar::Archive::new(file); + let mut count = 0_u64; + let mut payload = 0_u64; + let mut paths = BTreeSet::new(); + let mut entrypoint_count = 0_u32; + let expected_entrypoint = entrypoint.trim_start_matches('/').as_bytes(); + for entry in archive.entries().map_err(|_| AdmissionError::InvalidOci)? { + let mut entry = entry.map_err(|_| AdmissionError::InvalidOci)?; + count = count.checked_add(1).ok_or(AdmissionError::InvalidOci)?; + if count > MAX_ARCHIVE_MEMBERS { + return Err(AdmissionError::InvalidOci); + } + let path = entry.path_bytes(); + validate_relative_path(&path)?; + let normalized = path.strip_suffix(b"/").unwrap_or(&path).to_vec(); + if !paths.insert(normalized.clone()) { + return Err(AdmissionError::InvalidOci); + } + let kind = entry.header().entry_type(); + if kind.is_file() { + payload = payload + .checked_add(entry.size()) + .ok_or(AdmissionError::InvalidOci)?; + if payload > MAX_ARCHIVE_EXPANDED_BYTES { + return Err(AdmissionError::InvalidOci); + } + if normalized == expected_entrypoint { + if entry + .header() + .mode() + .map_err(|_| AdmissionError::InvalidOci)? + & 0o111 + == 0 + { + return Err(AdmissionError::InvalidOci); + } + entrypoint_count = entrypoint_count + .checked_add(1) + .ok_or(AdmissionError::InvalidOci)?; + } + std::io::copy(&mut entry, &mut std::io::sink()) + .map_err(|_| AdmissionError::InvalidOci)?; + } else if kind.is_dir() { + if !path.ends_with(b"/") || entry.size() != 0 { + return Err(AdmissionError::InvalidOci); + } + } else if kind.is_symlink() || kind.is_hard_link() { + if entry.size() != 0 { + return Err(AdmissionError::InvalidOci); + } + let target = entry.link_name_bytes().ok_or(AdmissionError::InvalidOci)?; + validate_link_target(&normalized, &target)?; + } else { + return Err(AdmissionError::InvalidOci); + } + } + if count == 0 { + Err(AdmissionError::InvalidOci) + } else { + Ok(entrypoint_count) + } +} + +fn validate_layer_name(path: &str) -> Result<(), AdmissionError> { + let Some((directory, leaf)) = path.split_once('/') else { + return Err(AdmissionError::InvalidOci); + }; + if leaf == "layer.tar" && valid_hex(directory, 64) { + Ok(()) + } else { + Err(AdmissionError::InvalidOci) + } +} + +fn validate_relative_path(path: &[u8]) -> Result<(), AdmissionError> { + if path.is_empty() + || path.len() > MAX_PATH_BYTES + || path.starts_with(b"/") + || path.contains(&0) + || path.contains(&b'\\') + || std::str::from_utf8(path).is_err() + { + return Err(AdmissionError::InvalidOci); + } + let path = path.strip_suffix(b"/").unwrap_or(path); + let mut depth = 0_usize; + for component in path.split(|byte| *byte == b'/') { + depth = depth.checked_add(1).ok_or(AdmissionError::InvalidOci)?; + if component.is_empty() || matches!(component, b"." | b"..") || depth > MAX_DEPTH { + return Err(AdmissionError::InvalidOci); + } + } + Ok(()) +} + +fn validate_link_target(path: &[u8], target: &[u8]) -> Result<(), AdmissionError> { + if target.is_empty() + || target.len() > MAX_PATH_BYTES + || target.starts_with(b"/") + || target.contains(&0) + || target.contains(&b'\\') + || std::str::from_utf8(target).is_err() + { + return Err(AdmissionError::InvalidOci); + } + let mut depth = path.split(|byte| *byte == b'/').count().saturating_sub(1); + for component in target.split(|byte| *byte == b'/') { + match component { + b"" | b"." => {} + b".." => depth = depth.checked_sub(1).ok_or(AdmissionError::InvalidOci)?, + _ => { + depth = depth.checked_add(1).ok_or(AdmissionError::InvalidOci)?; + if depth > MAX_DEPTH { + return Err(AdmissionError::InvalidOci); + } + } + } + } + Ok(()) +} + +fn valid_absolute_path(value: &str) -> bool { + let path = Path::new(value); + path.is_absolute() + && value.len() <= MAX_PATH_BYTES + && path + .components() + .all(|component| matches!(component, Component::RootDir | Component::Normal(_))) +} +fn read_json_member( + snapshot: &safe_artifact_io::TraversalSnapshot, + name: &str, +) -> Result<Value, AdmissionError> { + serde_json::from_slice(&read_member(snapshot, name, MAX_JSON_BYTES)?) + .map_err(|_| AdmissionError::InvalidOci) +} +fn read_member( + snapshot: &safe_artifact_io::TraversalSnapshot, + name: &str, + maximum: u64, +) -> Result<Vec<u8>, AdmissionError> { + snapshot + .read(snapshot_member(snapshot, name)?, maximum) + .map_err(|_| AdmissionError::InvalidOci) +} +fn snapshot_member<'a>( + snapshot: &'a safe_artifact_io::TraversalSnapshot, + name: &str, +) -> Result<&'a safe_artifact_io::TraversedFile, AdmissionError> { + snapshot + .files() + .iter() + .find(|file| file.relative_path() == Path::new(name)) + .ok_or(AdmissionError::InvalidOci) +} +fn require_exact_keys( + object: &Map<String, Value>, + expected: &[&str], +) -> Result<(), AdmissionError> { + let observed = object.keys().map(String::as_str).collect::<BTreeSet<_>>(); + if observed == expected.iter().copied().collect() { + Ok(()) + } else { + Err(AdmissionError::InvalidOci) + } +} +fn json_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a str, AdmissionError> { + object + .get(key) + .and_then(Value::as_str) + .ok_or(AdmissionError::InvalidOci) +} +fn json_string_array( + object: &Map<String, Value>, + key: &str, +) -> Result<Vec<String>, AdmissionError> { + object + .get(key) + .and_then(Value::as_array) + .ok_or(AdmissionError::InvalidOci)? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or(AdmissionError::InvalidOci) + }) + .collect() +} +fn all_unique(values: &[String]) -> bool { + values.iter().collect::<BTreeSet<_>>().len() == values.len() +} +fn valid_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} +fn valid_identifier(value: &str) -> bool { + value.len() <= 128 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} +fn valid_binary_name(value: &str) -> bool { + value.len() <= 128 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') + }) +} +fn sha256(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_is_exact() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("root"); + validate_contract(root).expect("artifact admission contract"); + } + + #[test] + fn native_binary_and_negative_formats_are_bounded() { + let target = if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + MACOS_TARGET + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + LINUX_TARGET + } else { + return; + }; + let binary = std::env::current_exe().expect("test binary"); + admit_binary_inner(&binary, target, false).expect("native admission"); + let wrong = if target == MACOS_TARGET { + LINUX_TARGET + } else { + MACOS_TARGET + }; + assert_eq!( + admit_binary_inner(&binary, wrong, false), + Err(AdmissionError::InvalidBinary) + ); + let root = tempfile::tempdir().expect("tempdir"); + let arbitrary = root.path().join("binary"); + fs::write(&arbitrary, b"arbitrary bytes").expect("fixture"); + assert_eq!( + admit_binary_inner(&arbitrary, target, false), + Err(AdmissionError::InvalidBinary) + ); + assert_eq!( + validate_dynamic_libraries(&["libsqlite3.so.0".to_owned()]), + Err(AdmissionError::InvalidBinary) + ); + } + + #[test] + fn archive_paths_and_agpl_labels_fail_closed() { + assert!(validate_relative_path(b"nix/store/hash/bin/service").is_ok()); + assert!(validate_relative_path(b"../escape").is_err()); + assert!(validate_link_target(b"nix/store/hash/lib/link", b"../target").is_ok()); + assert!(validate_link_target(b"link", b"../escape").is_err()); + let expected = OciExpectation { + service: "fixture_service", + binary_name: "fixture-service", + version: "0.1.0-alpha", + service_revision: "1111111111111111111111111111111111111111", + lib_revision: "2222222222222222222222222222222222222222", + license: AGPL_LICENSE, + contract_versions: ContractVersions { + admin: 3, + config: 1, + provider: 5, + state: 2, + status: 4, + }, + }; + let labels = expected_labels(&expected); + assert_eq!(labels["org.opencontainers.image.licenses"], AGPL_LICENSE); + assert_eq!(labels.len(), 23); + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -12,6 +12,7 @@ mod advisory_snapshot; mod api_qualification; #[cfg_attr(coverage_nightly, coverage(off))] mod architecture; +mod artifact_admission; mod bounded_process; #[cfg_attr(coverage_nightly, coverage(off))] mod build_control; @@ -446,6 +447,7 @@ fn validate_protocol_contracts() -> Result<(), String> { fn validate_contract() -> Result<(), String> { validate_protocol_contracts()?; let root = workspace_root(); + artifact_admission::validate_contract(&root)?; service_source_lock::validate_contract(&root)?; service_build_qualification::validate_contract(&root)?; service_release_artifacts::validate_contract(&root)?; diff --git a/tools/xtask/src/safe_artifact_io.rs b/tools/xtask/src/safe_artifact_io.rs @@ -314,6 +314,7 @@ impl TraversalSnapshot { trusted_parent, limits, Some(&file.identity), + TarGzipPolicy::DeterministicSnapshot, ) } @@ -1089,6 +1090,22 @@ pub(crate) fn admit_tar_gzip_path( admit_tar_gzip_relative(&root, &relative, limits, None, TarGzipPolicy::Generic) } +pub(crate) fn materialize_tar_gzip_path( + path: &Path, + trusted_parent: &Path, + limits: TarGzipLimits, +) -> Result<MaterializedArchive, ArtifactIoError> { + let (root, relative) = split_absolute_file(path)?; + materialize_tar_gzip_relative( + &root, + &relative, + trusted_parent, + limits, + None, + TarGzipPolicy::Generic, + ) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TarGzipPolicy { Generic, @@ -1129,6 +1146,7 @@ fn materialize_tar_gzip_relative( trusted_parent: &Path, limits: TarGzipLimits, expected: Option<&FileIdentity>, + policy: TarGzipPolicy, ) -> Result<MaterializedArchive, ArtifactIoError> { validate_archive_limits(limits)?; let (parent_descriptor, parent_chain) = open_trusted_output_directory(trusted_parent)?; @@ -1185,12 +1203,7 @@ fn materialize_tar_gzip_relative( expected, || {}, |file, admitted_length| { - let evidence = admit_tar_gzip_reader( - file, - limits, - TarGzipPolicy::DeterministicSnapshot, - Some(&output), - )?; + let evidence = admit_tar_gzip_reader(file, limits, policy, Some(&output))?; require_observed_length(evidence.compressed.byte_length, admitted_length)?; output .sync_all() @@ -3384,6 +3397,7 @@ mod step_294_tests { &parent_root, archive_limits(), None, + TarGzipPolicy::DeterministicSnapshot, ) .expect("descriptor-bound materialization"); let snapshot = materialized.snapshot(); diff --git a/tools/xtask/src/service_release_artifacts.rs b/tools/xtask/src/service_release_artifacts.rs @@ -13,14 +13,15 @@ use sha2::{Digest as _, Sha256}; use tar::{Builder as TarBuilder, Header as TarHeader}; use tempfile::TempDir; -use crate::safe_artifact_io::{self, TarGzipLimits, TraversalLimits}; +use crate::safe_artifact_io::{TarGzipLimits, TraversalLimits}; use crate::service_source_lock::{ LIB_REPOSITORY, LOCK_FILENAME, NixMaterialState, PREDECESSOR_LOCK_FILENAME, ServiceSourceLockV2, validate_deferred_nix_material, }; +use crate::{artifact_admission, safe_artifact_io}; const CONTRACT_RELATIVE: &str = - "contracts/architecture/decisions/services_hardening_release_artifacts.v2.json"; + "contracts/architecture/decisions/services_hardening_release_artifacts.v3.json"; const INPUT_NAMES: [&str; 8] = [ "config.example.toml", "config.schema.json", @@ -51,7 +52,7 @@ const OUTPUT_NAMES: [&str; 18] = [ "source-bundles.v2.json", "systemd.service", ]; -const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]; +const SUPPORTED_TARGETS: [&str; 2] = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]; const SECRET_PATTERNS: [&[u8]; 7] = [ b"-----BEGIN PRIVATE KEY-----", b"-----BEGIN RSA PRIVATE KEY-----", @@ -77,8 +78,6 @@ const MAX_PACKAGES: usize = 8_192; const MAX_WORKSPACE_PACKAGES: usize = 64; const MAX_TEXT_FIELD_BYTES: usize = 512; const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184; -const MAX_ARCHIVE_MEMBERS: u64 = 65_536; -const MAX_ARCHIVE_DEPTH: usize = 64; const MAX_ARCHIVE_PATH_BYTES: usize = 4_096; const MAX_RELEASE_TREE_BYTES: u64 = 68_719_476_736; const FILE_MODE: u32 = 0o644; @@ -156,6 +155,8 @@ struct ReleaseMetadata { service_package: String, binary_name: String, version: String, + #[serde(skip)] + license: String, } #[derive(Debug, Deserialize)] @@ -344,7 +345,11 @@ struct ReleaseDecision { required_arguments: Vec<String>, service_metadata_path: String, service_metadata_fields: Vec<String>, + service_license_path: String, + artifact_admission_contract: String, supported_targets: Vec<String>, + binary_admission: String, + oci_admission: String, input_inventory: Vec<String>, excluded_parent_owned_inputs: Vec<String>, service_root_inventory: Vec<String>, @@ -475,6 +480,7 @@ fn run_inner( "service-binary", &staging.path().join("binary.tar.gz"), &metadata.binary_name, + target, source_date_epoch, )?; let oci = copy_snapshot_file( @@ -483,7 +489,27 @@ fn run_inner( &staging.path().join("oci-image.tar.gz"), MAX_OCI_BYTES, )?; - admit_oci_archive(&staging.path().join("oci-image.tar.gz"))?; + let versions = source_lock.contract_versions(); + artifact_admission::admit_oci( + &staging.path().join("oci-image.tar.gz"), + staging.path(), + &artifact_admission::OciExpectation { + service: &metadata.service, + binary_name: &metadata.binary_name, + version: &metadata.version, + service_revision: &initial_head, + lib_revision: source_lock.revision(), + license: &metadata.license, + contract_versions: artifact_admission::ContractVersions { + admin: versions.admin(), + config: versions.config(), + provider: versions.provider(), + state: versions.state(), + status: versions.status(), + }, + }, + ) + .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; let service_source = copy_snapshot_file( &input_snapshot, "service-source.bundle", @@ -639,13 +665,22 @@ fn read_release_metadata(root: &Path) -> Result<ReleaseMetadata, ReleaseArtifact .and_then(|value| value.get("service_release")) .cloned() .ok_or(ReleaseArtifactError::InvalidServiceMetadata)?; - let metadata = release + let mut metadata = release .try_into::<ReleaseMetadata>() .map_err(|_| ReleaseArtifactError::InvalidServiceMetadata)?; + metadata.license = value + .get("workspace") + .and_then(|workspace| workspace.get("package")) + .or_else(|| value.get("package")) + .and_then(|package| package.get("license")) + .and_then(toml::Value::as_str) + .ok_or(ReleaseArtifactError::InvalidServiceMetadata)? + .to_owned(); if !valid_snake_identifier(&metadata.service) || !valid_kebab_identifier(&metadata.service_package) || !valid_kebab_identifier(&metadata.binary_name) || metadata.version.len() > 128 + || metadata.license != "AGPL-3.0-or-later" || !matches!( semver::Version::parse(&metadata.version), Ok(version) if version.to_string() == metadata.version @@ -1091,6 +1126,7 @@ fn create_binary_archive_from_snapshot( source_name: &str, output: &Path, binary_name: &str, + target: &str, source_date_epoch: u32, ) -> Result<FileEvidence, ReleaseArtifactError> { let source = snapshot_file(snapshot, source_name)?; @@ -1101,6 +1137,8 @@ fn create_binary_archive_from_snapshot( if source_evidence.byte_length == 0 { return Err(ReleaseArtifactError::InvalidInputArtifact); } + artifact_admission::admit_binary(&stable_source, target, true) + .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?; write_binary_archive( &stable_source, output, @@ -1224,21 +1262,6 @@ fn admit_binary_archive(path: &Path) -> Result<(), ReleaseArtifactError> { .map_err(|_| ReleaseArtifactError::InvalidInputArtifact) } -fn admit_oci_archive(path: &Path) -> Result<(), ReleaseArtifactError> { - let limits = TarGzipLimits { - max_compressed_bytes: MAX_OCI_BYTES, - max_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES, - max_members: MAX_ARCHIVE_MEMBERS, - max_member_bytes: MAX_ARCHIVE_EXPANDED_BYTES, - max_payload_bytes: MAX_ARCHIVE_EXPANDED_BYTES, - max_depth: MAX_ARCHIVE_DEPTH, - max_path_bytes: MAX_ARCHIVE_PATH_BYTES, - }; - safe_artifact_io::admit_tar_gzip_path(path, limits) - .map(|_| ()) - .map_err(|_| ReleaseArtifactError::InvalidInputArtifact) -} - #[derive(Default)] struct SecretScanner { tail: Vec<u8>, @@ -1777,12 +1800,12 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr ReleaseArtifactError::StaleOutput, ReleaseArtifactError::GenerationFailure, ]; - if decision.schema != "radroots.services-hardening.release-artifacts-decisions.v2" - || decision.contract_version != 2 + if decision.schema != "radroots.services-hardening.release-artifacts-decisions.v3" + || decision.contract_version != 3 || decision.decision_state != "active" || decision.predecessor.schema - != "radroots.services-hardening.release-artifacts-decisions.v1" - || decision.predecessor.filename != "services_hardening_release_artifacts.v1.json" + != "radroots.services-hardening.release-artifacts-decisions.v2" + || decision.predecessor.filename != "services_hardening_release_artifacts.v2.json" || decision.predecessor.transition != "forward_only_replace" || decision.command != "cargo xtask service-release-artifacts" || decision.modes != ["check", "write"] @@ -1799,7 +1822,15 @@ fn validate_decision(decision: &ReleaseDecision) -> Result<(), ReleaseArtifactEr != "Cargo.toml.workspace.metadata.radroots.service_release" || decision.service_metadata_fields != ["service", "service_package", "binary_name", "version"] + || decision.service_license_path + != "Cargo.toml.workspace.package.license_or_package.license" + || decision.artifact_admission_contract + != "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json" || decision.supported_targets != SUPPORTED_TARGETS + || decision.binary_admission + != "exact_format_architecture_linkage_structural_and_native_bounded_help_smoke" + || decision.oci_admission + != "safe_materialization_exact_manifest_config_AGPL_labels_layers_and_entrypoint" || decision.input_inventory != INPUT_NAMES || decision.excluded_parent_owned_inputs != ["backup_restore_runbook", "operator_runbook"] || decision.service_root_inventory != ["LICENSE-APACHE", "LICENSE-MIT", LOCK_FILENAME] @@ -1878,6 +1909,7 @@ mod tests { name = "fixture-service" version = "0.1.0-alpha" edition = "2024" +license = "AGPL-3.0-or-later" [[bin]] name = "fixture-service" @@ -1941,6 +1973,7 @@ version = "0.1.0-alpha" .expect("source lock"); write_file(&service.join(LOCK_FILENAME), source_lock.canonical_bytes()); initialize_git(&service, "https://github.com/radrootslabs/fixture-service"); + let service_revision = git_output(&service, &["rev-parse", "HEAD"]); create_bundle(&service, &input.join("service-source.bundle")); for (name, bytes) in [ @@ -1954,11 +1987,16 @@ version = "0.1.0-alpha" ] { write_file(&input.join(name), bytes); } - write_file( - &input.join("service-binary"), - b"fixture service executable\0\xff", + fs::copy( + std::env::current_exe().expect("current test executable"), + input.join("service-binary"), + ) + .expect("copy fixture service binary"); + create_oci_fixture( + &input.join("oci-image.tar.gz"), + &service_revision, + &lib_revision, ); - create_oci_fixture(&input.join("oci-image.tar.gz")); Self { output_a: canonical_root.join("release-a"), @@ -1975,7 +2013,7 @@ version = "0.1.0-alpha" &self.service, &self.input, output, - "x86_64-unknown-linux-gnu", + native_fixture_target(), 1_700_000_000, ) } @@ -1986,7 +2024,7 @@ version = "0.1.0-alpha" &self.service, &self.input, output, - "x86_64-unknown-linux-gnu", + native_fixture_target(), 1_700_000_000, ) } @@ -2018,6 +2056,13 @@ version = "0.1.0-alpha" fs::remove_file(self.input.join("service-source.bundle")) .expect("remove prior service bundle"); create_bundle(&self.service, &self.input.join("service-source.bundle")); + fs::remove_file(self.input.join("oci-image.tar.gz")) + .expect("remove prior OCI fixture"); + create_oci_fixture( + &self.input.join("oci-image.tar.gz"), + &git_output(&self.service, &["rev-parse", "HEAD"]), + current.revision(), + ); } } @@ -2068,24 +2113,113 @@ version = "0.1.0-alpha" assert!(status.success()); } - fn create_oci_fixture(output: &Path) { + fn native_fixture_target() -> &'static str { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "aarch64-apple-darwin" + } else { + "x86_64-unknown-linux-gnu" + } + } + + fn create_oci_fixture(output: &Path, service_revision: &str, lib_revision: &str) { + let entrypoint = + "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-fixture-service/bin/fixture-service"; + let mut layer = Vec::new(); + { + let mut tar = TarBuilder::new(&mut layer); + let bytes = b"fixture executable"; + let mut header = TarHeader::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_cksum(); + tar.append_data( + &mut header, + entrypoint.trim_start_matches('/'), + bytes.as_slice(), + ) + .expect("write layer entrypoint"); + tar.finish().expect("finish layer"); + } + let layer_digest = sha256_bytes(&layer); + let layer_name = format!("{layer_digest}/layer.tar"); + let labels = artifact_admission::OciExpectation { + service: "fixture_service", + binary_name: "fixture-service", + version: "0.1.0-alpha", + service_revision, + lib_revision, + license: "AGPL-3.0-or-later", + contract_versions: artifact_admission::ContractVersions { + admin: 1, + config: 1, + provider: 1, + state: 1, + status: 1, + }, + }; + let labels = artifact_admission::fixture_labels(&labels); + let config = serde_json::to_vec(&serde_json::json!({ + "architecture": "amd64", + "config": { + "Entrypoint": [entrypoint], + "Env": ["SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"], + "Labels": labels, + "StopSignal": "SIGTERM", + "User": "65532:65532", + "WorkingDir": "/" + }, + "created": "1970-01-01T00:00:01+00:00", + "os": "linux", + "rootfs": {"diff_ids": [format!("sha256:{layer_digest}")], "type": "layers"} + })) + .expect("serialize config"); + let config_name = format!("{}.json", sha256_bytes(&config)); + let manifest = serde_json::to_vec(&serde_json::json!([{ + "Config": config_name, + "Layers": [layer_name], + "RepoTags": ["fixture-service:0.1.0-alpha"] + }])) + .expect("serialize manifest"); + let repositories = serde_json::to_vec(&serde_json::json!({ + "fixture-service": {"0.1.0-alpha": layer_digest} + })) + .expect("serialize repositories"); + let mut members = vec![ + (config_name, false, config), + (format!("{layer_digest}/"), true, Vec::new()), + (format!("{layer_digest}/VERSION"), false, b"1.0".to_vec()), + (format!("{layer_digest}/json"), false, b"{}".to_vec()), + (layer_name, false, layer), + ("manifest.json".to_owned(), false, manifest), + ("repositories".to_owned(), false, repositories), + ]; + members.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes())); let output_file = fs::File::create(output).expect("create OCI fixture"); let encoder = GzBuilder::new() .mtime(0) .operating_system(255) .write(output_file, Compression::best()); let mut archive = TarBuilder::new(encoder); - let bytes = b"{}"; - let mut header = TarHeader::new_gnu(); - header.set_size(bytes.len() as u64); - header.set_mode(0o644); - header.set_uid(0); - header.set_gid(0); - header.set_mtime(0); - header.set_cksum(); - archive - .append_data(&mut header, "index.json", bytes.as_slice()) - .expect("write OCI fixture member"); + for (name, directory, bytes) in members { + let mut header = TarHeader::new_gnu(); + header.set_entry_type(if directory { + tar::EntryType::Directory + } else { + tar::EntryType::Regular + }); + header.set_size(bytes.len() as u64); + header.set_mode(if directory { 0o755 } else { 0o644 }); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_cksum(); + archive + .append_data(&mut header, name, bytes.as_slice()) + .expect("write OCI member"); + } let encoder = archive.into_inner().expect("finish OCI fixture tar"); let file = encoder.finish().expect("finish OCI fixture gzip"); file.sync_all().expect("sync OCI fixture"); @@ -2097,6 +2231,7 @@ version = "0.1.0-alpha" service_package: "fixture-service".to_owned(), binary_name: "fixture-service".to_owned(), version: "0.1.0-alpha".to_owned(), + license: "AGPL-3.0-or-later".to_owned(), } }