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:
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(),
}
}