rshr_202_step_304_gate.rs (17672B)
1 use std::{ 2 env, fs, 3 path::{Path, PathBuf}, 4 process::{Command, Output}, 5 }; 6 7 use serde_json::{Value, json}; 8 use sha2::{Digest as _, Sha256}; 9 10 const STEP: u16 = 304; 11 const GATE_DIGEST: &str = "054db63c65f921ecba1c1bf416d680ee7ef880bdcf90cf6dc152677a03dd129a"; 12 const NIX_SHA256: &str = "a59ab70f97f6d571642d13c7506aafec0a4275520d53daee2d8451be7c495cd1"; 13 const NIX_VERSION_SHA256: &str = "6db806391ffaea4cdb08ade0031feac399c0cd08474b3bfde8cb33f88a36c8e1"; 14 const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; 15 const EXACT_SOURCES: &[(&str, &str)] = &[ 16 ( 17 "Cargo.lock", 18 "a8edafae2d2b26465b2b99038ce55d80fa603481ebb5ebebbd810708ba4a894a", 19 ), 20 ( 21 "Cargo.toml", 22 "322b975e60004df42de5b1b9460bf84255d2210e97f4f1cf1ede84a068b7519e", 23 ), 24 ( 25 "build/nix/service/fixture-service/Cargo.toml", 26 "a1f0053f34606669c2e91c69a01c51d5db0e2af290ede801df6bf4ed45f14202", 27 ), 28 ( 29 "build/nix/service/fixture.nix", 30 "1b18450ad14bfe74faf2372e2d0a396bdd5e79c257acf653ad5c45018daa573a", 31 ), 32 ( 33 "build/nix/service/oci.nix", 34 "70458d0a988098b5343da00daad1d4d4d4a7cc95646928a938e28af3dd9ccd16", 35 ), 36 ( 37 "build/nix/service/package.nix", 38 "cb6f32977bad84c3d8250d2ed806a8f8cc8b9b3d1d9d40f2b74db205a0abef00", 39 ), 40 ( 41 "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json", 42 "3b8d2bc57efb4dcce27248a6300c32ac84e39869cd73850cc4f468d911276a1d", 43 ), 44 ( 45 "contracts/architecture/decisions/services_hardening_release_artifacts.v3.json", 46 "51979f32271b5a9851ad37a5da763fd8e2cba3fe8b4c36d35fac8b28e3fe4732", 47 ), 48 ( 49 "tools/xtask/Cargo.toml", 50 "bf5442895085225a571bf8aa45b2316e732a5c9925911de26147a924466f8ba0", 51 ), 52 ( 53 "tools/xtask/src/artifact_admission.rs", 54 "87b538d5ca80dfbc3de232ded40320cc1b0ec555fa48056af79eb365cc74ea44", 55 ), 56 ( 57 "tools/xtask/src/safe_artifact_io.rs", 58 "3b361b22036ff8db5f5468e33341a7b2990d6f80aa6bdde4f791bd1320587e4d", 59 ), 60 ( 61 "tools/xtask/src/service_release_artifacts.rs", 62 "866dc107182e35c4c1492936fddc70252405abcdf5a80f19fb161e07e361dfba", 63 ), 64 ]; 65 66 pub(crate) struct Arguments { 67 pub(crate) step: u16, 68 pub(crate) check_id: String, 69 pub(crate) source_revision: String, 70 pub(crate) source_tree: String, 71 pub(crate) candidate_digest: String, 72 pub(crate) platform: String, 73 pub(crate) execution_request_sha256: String, 74 } 75 76 fn root() -> PathBuf { 77 Path::new(env!("CARGO_MANIFEST_DIR")) 78 .parent() 79 .and_then(Path::parent) 80 .expect("xtask must remain under tools/xtask") 81 .to_path_buf() 82 } 83 fn sha256(bytes: &[u8]) -> String { 84 hex::encode(Sha256::digest(bytes)) 85 } 86 fn canonical(value: &Value) -> Result<Vec<u8>, String> { 87 serde_json::to_vec(value).map_err(|_| "Step 304 JSON encoding failed".to_owned()) 88 } 89 fn execute(command: &mut Command, label: &str) -> Result<Output, String> { 90 let output = command 91 .current_dir(root()) 92 .env("CARGO_NET_OFFLINE", "true") 93 .env("CARGO_TERM_COLOR", "never") 94 .output() 95 .map_err(|_| format!("{label} could not start"))?; 96 if output.stdout.len() > MAX_OUTPUT_BYTES || output.stderr.len() > MAX_OUTPUT_BYTES { 97 return Err(format!("{label} exceeded its output bound")); 98 } 99 Ok(output) 100 } 101 fn bounded(command: &mut Command, label: &str) -> Result<Output, String> { 102 let output = execute(command, label)?; 103 if output.status.success() { 104 Ok(output) 105 } else { 106 Err(format!("{label} failed")) 107 } 108 } 109 fn resolve_nix() -> Result<PathBuf, String> { 110 if let Some(explicit) = env::var_os("RSHR_NIX_EXECUTABLE") { 111 return fs::canonicalize(explicit) 112 .map_err(|_| "Step 304 Nix client is unavailable".to_owned()); 113 } 114 let path = env::var_os("PATH").ok_or_else(|| "Step 304 PATH is absent".to_owned())?; 115 env::split_paths(&path) 116 .map(|directory| directory.join("nix")) 117 .find(|candidate| candidate.is_file()) 118 .and_then(|candidate| fs::canonicalize(candidate).ok()) 119 .ok_or_else(|| "Step 304 Nix client is unavailable".to_owned()) 120 } 121 122 fn require_nix() -> Result<(), String> { 123 let executable = resolve_nix()?; 124 if sha256(&fs::read(&executable).map_err(|_| "Step 304 Nix client is unreadable")?) 125 != NIX_SHA256 126 { 127 return Err("Step 304 Nix client identity differs".to_owned()); 128 } 129 let version = bounded( 130 Command::new(&executable).arg("--version"), 131 "Step 304 Nix version", 132 )?; 133 if sha256(&version.stdout) != NIX_VERSION_SHA256 { 134 return Err("Step 304 Nix version differs".to_owned()); 135 } 136 bounded( 137 Command::new(&executable).args([ 138 "--offline", 139 "flake", 140 "check", 141 "--all-systems", 142 "--no-build", 143 "--no-write-lock-file", 144 ]), 145 "Step 304 Nix evaluation", 146 )?; 147 bounded( 148 Command::new(&executable).args([ 149 "--offline", 150 "eval", 151 "--raw", 152 ".#checks.x86_64-linux.service-fixture-oci-image.drvPath", 153 "--no-write-lock-file", 154 ]), 155 "Step 304 OCI derivation evaluation", 156 )?; 157 Ok(()) 158 } 159 160 fn expected_contract(verifier_sha256: &str) -> Value { 161 json!({ 162 "argv_template": [ 163 "cargo", "extbuild", "run", "--", "cargo", "run", "--offline", "--locked", 164 "-q", "-p", "xtask", "--", "rshr-step-304-gate", "--step={step}", 165 "--check-id={check_id}", "--source-revision={source_revision}", 166 "--source-tree={source_tree}", "--candidate-digest={candidate_digest}", 167 "--platform=macos_aarch64", "--execution-request-sha256={execution_request_sha256}" 168 ], 169 "assertion_id": [format!("step_304_gate_01_{GATE_DIGEST}")], 170 "check_id": format!("gate-01-{GATE_DIGEST}"), 171 "environment_authority": { 172 "cache_policy_id": "rshr-200-step-287-cache-policy.v1", 173 "cache_policy_sha256": "3e81d178bce97b6c349dfbb00c68fd6f620ac00b1a1c8d37b12e9998f3c9eaaa", 174 "cadence_policy_id": "rshr-200-step-287-cadence-policy.v1", 175 "cadence_policy_sha256": "d24903df8659ee3772297c84994911efe7d21cb8b988320ddc6ddce0431892a1", 176 "isolation": "extbuild_host_constrained", "network": "disabled", 177 "network_policy_id": "none", "network_policy_sha256": "none", 178 "resource_policy_id": "rshr-200-step-287-resource-policy.v1", 179 "resource_policy_sha256": "05d3c7a89185d3c55678d97955193fce2ed92b1eee5af99083d77ea64c98d14e" 180 }, 181 "environment_names": ["EXT_BUILD_CONFIG", "EXT_BUILD_MACHINE_CONFIG", "EXT_BUILD_ROOT", "HOME", "PATH", "RUSTUP_TOOLCHAIN", "TMPDIR"], 182 "gate_definition_sha256": GATE_DIGEST, 183 "required_platforms": ["macos_aarch64"], 184 "required_tools": ["rustc"], 185 "result_schema": "radroots.services-hardening.rshr-200-step-check-result.v1", 186 "schema": "radroots.services-hardening.rshr-200-step-check-command.v1", 187 "step": STEP, 188 "verifier_path": "tools/xtask/src/rshr_202_step_304_gate.rs", 189 "verifier_sha256": verifier_sha256 190 }) 191 } 192 193 fn validate_arguments(arguments: &Arguments) -> Result<String, String> { 194 let check_id = format!("gate-01-{GATE_DIGEST}"); 195 if arguments.step != STEP 196 || arguments.check_id != check_id 197 || arguments.candidate_digest != "none" 198 || arguments.platform != "macos_aarch64" 199 || arguments.source_revision.len() != 40 200 || arguments.source_tree.len() != 40 201 || arguments.execution_request_sha256.len() != 64 202 || !arguments 203 .source_revision 204 .bytes() 205 .chain(arguments.source_tree.bytes()) 206 .chain(arguments.execution_request_sha256.bytes()) 207 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) 208 { 209 return Err("Step 304 gate arguments differ".to_owned()); 210 } 211 Ok(check_id) 212 } 213 214 pub(crate) fn run(arguments: Arguments) -> Result<(), String> { 215 let check_id = validate_arguments(&arguments)?; 216 let root = root(); 217 if root.join(".github").exists() { 218 return Err("forbidden .github surface is present".to_owned()); 219 } 220 for (relative, expected) in EXACT_SOURCES { 221 if sha256( 222 &fs::read(root.join(relative)).map_err(|_| "Step 304 governed source is unreadable")?, 223 ) != *expected 224 { 225 return Err("Step 304 governed source bytes differ".to_owned()); 226 } 227 } 228 let verifier_path = root.join("tools/xtask/src/rshr_202_step_304_gate.rs"); 229 let verifier_sha256 = 230 sha256(&fs::read(verifier_path).map_err(|_| "Step 304 verifier is unreadable")?); 231 let authority_bytes = fs::read(root.join("contracts/rshr-202-step-304-gates.v1.json")) 232 .map_err(|_| "Step 304 gate authority is unreadable".to_owned())?; 233 let contract = validate_authority(&authority_bytes, &verifier_sha256)?; 234 bounded( 235 Command::new("cargo").args(["+1.97.1", "fmt", "--all", "--", "--check"]), 236 "Step 304 formatting", 237 )?; 238 bounded( 239 Command::new("cargo").args(["+1.97.1", "check", "--offline", "--locked", "-p", "xtask"]), 240 "Step 304 verifier check", 241 )?; 242 bounded( 243 Command::new("cargo").args([ 244 "+1.97.1", 245 "test", 246 "--offline", 247 "--locked", 248 "-p", 249 "xtask", 250 "artifact_admission::tests", 251 ]), 252 "Step 304 adversarial admission tests", 253 )?; 254 bounded( 255 Command::new("cargo").args([ 256 "+1.97.1", 257 "test", 258 "--offline", 259 "--locked", 260 "-p", 261 "xtask", 262 "service_release_artifacts::tests", 263 ]), 264 "Step 304 release integration tests", 265 )?; 266 bounded( 267 Command::new("cargo").args([ 268 "+1.97.1", 269 "clippy", 270 "--offline", 271 "--locked", 272 "-p", 273 "xtask", 274 "--all-targets", 275 "--", 276 "-D", 277 "warnings", 278 ]), 279 "Step 304 clippy", 280 )?; 281 bounded( 282 Command::new("cargo").args([ 283 "+1.97.1", 284 "run", 285 "--offline", 286 "--locked", 287 "-q", 288 "-p", 289 "xtask", 290 "--", 291 "contract", 292 "validate", 293 ]), 294 "Step 304 contracts", 295 )?; 296 require_nix()?; 297 let bytes = result_bytes(&arguments, &check_id, &verifier_sha256, &contract)?; 298 std::io::Write::write_all(&mut std::io::stdout().lock(), &bytes) 299 .map_err(|_| "Step 304 result write failed".to_owned()) 300 } 301 302 fn validate_authority(authority_bytes: &[u8], verifier_sha256: &str) -> Result<Value, String> { 303 let authority: Value = serde_json::from_slice(authority_bytes) 304 .map_err(|_| "Step 304 gate authority is invalid".to_owned())?; 305 let mut canonical_authority = canonical(&authority)?; 306 canonical_authority.push(b'\n'); 307 let contracts = authority 308 .get("gate_command_contract") 309 .and_then(Value::as_array) 310 .ok_or_else(|| "Step 304 gate contract is absent".to_owned())?; 311 if authority_bytes != canonical_authority 312 || authority.get("schema") != Some(&json!("radroots.lib.rshr-202-step-304-gates.v1")) 313 || authority.get("step") != Some(&json!([STEP])) 314 || contracts.as_slice() != [expected_contract(verifier_sha256)] 315 { 316 return Err("Step 304 gate authority differs".to_owned()); 317 } 318 Ok(contracts[0].clone()) 319 } 320 321 fn result_bytes( 322 arguments: &Arguments, 323 check_id: &str, 324 verifier_sha256: &str, 325 contract: &Value, 326 ) -> Result<Vec<u8>, String> { 327 let assertion = json!([{ "id": format!("step_304_gate_01_{GATE_DIGEST}"), "result": "pass" }]); 328 let result = json!({ 329 "schema": "radroots.services-hardening.rshr-200-step-check-result.v1", "step": STEP, 330 "check_id": check_id, "gate_definition_sha256": GATE_DIGEST, 331 "source_revision": arguments.source_revision, "source_tree": arguments.source_tree, 332 "candidate_generation": 0, "candidate_digest": "none", 333 "command_contract_sha256": sha256(&canonical(contract)?), "verifier_sha256": verifier_sha256, 334 "execution_request": [{"platform": arguments.platform, "sha256": arguments.execution_request_sha256}], 335 "assertion_inventory_sha256": sha256(&canonical(&assertion)?), "assertion": assertion, "result": "pass" 336 }); 337 let mut bytes = canonical(&result)?; 338 bytes.push(b'\n'); 339 Ok(bytes) 340 } 341 342 #[cfg(test)] 343 mod tests { 344 use super::*; 345 346 #[test] 347 fn command_capture_preserves_status_streams_and_enforces_both_output_limits() { 348 let output = bounded( 349 Command::new("/bin/sh").args(["-c", "printf output; printf diagnostic >&2"]), 350 "fixture", 351 ) 352 .unwrap(); 353 assert_eq!(output.stdout, b"output"); 354 assert_eq!(output.stderr, b"diagnostic"); 355 assert_eq!( 356 bounded(Command::new("/bin/sh").args(["-c", "exit 7"]), "fixture").unwrap_err(), 357 "fixture failed" 358 ); 359 let missing = tempfile::TempDir::new().unwrap(); 360 assert_eq!( 361 bounded(&mut Command::new(missing.path().join("absent")), "fixture").unwrap_err(), 362 "fixture could not start" 363 ); 364 for script in ["head -c \"$1\" /dev/zero", "head -c \"$1\" /dev/zero >&2"] { 365 let maximum = MAX_OUTPUT_BYTES.to_string(); 366 let output = bounded( 367 Command::new("/bin/sh").args(["-c", script, "fixture", &maximum]), 368 "fixture", 369 ) 370 .unwrap(); 371 assert_eq!(output.stdout.len() + output.stderr.len(), MAX_OUTPUT_BYTES); 372 let oversized = (MAX_OUTPUT_BYTES + 1).to_string(); 373 assert_eq!( 374 bounded( 375 Command::new("/bin/sh").args(["-c", script, "fixture", &oversized]), 376 "fixture" 377 ) 378 .unwrap_err(), 379 "fixture exceeded its output bound" 380 ); 381 } 382 } 383 384 fn arguments() -> Arguments { 385 Arguments { 386 step: STEP, 387 check_id: format!("gate-01-{GATE_DIGEST}"), 388 source_revision: "a".repeat(40), 389 source_tree: "0".repeat(40), 390 candidate_digest: "none".into(), 391 platform: "macos_aarch64".into(), 392 execution_request_sha256: "1".repeat(64), 393 } 394 } 395 396 #[test] 397 fn invalid_gate_arguments_are_rejected_before_external_work() { 398 assert_eq!( 399 validate_arguments(&arguments()).unwrap(), 400 format!("gate-01-{GATE_DIGEST}") 401 ); 402 for field in 0..8 { 403 let mut invalid = arguments(); 404 match field { 405 0 => invalid.step = 0, 406 1 => invalid.check_id.clear(), 407 2 => invalid.candidate_digest = "unbound".into(), 408 3 => invalid.platform = "linux".into(), 409 4 => invalid.source_revision.clear(), 410 5 => invalid.source_tree.clear(), 411 6 => invalid.execution_request_sha256.clear(), 412 _ => invalid.source_revision = "A".repeat(40), 413 } 414 assert_eq!(run(invalid).unwrap_err(), "Step 304 gate arguments differ"); 415 } 416 let mut invalid = arguments(); 417 invalid.source_tree = "g".repeat(40); 418 assert!(validate_arguments(&invalid).is_err()); 419 } 420 421 #[test] 422 fn authority_requires_canonical_bytes_and_exact_retained_bindings() { 423 let raw = include_bytes!("../../../contracts/rshr-202-step-304-gates.v1.json"); 424 let authority: Value = serde_json::from_slice(raw).unwrap(); 425 let verifier = authority["gate_command_contract"][0]["verifier_sha256"] 426 .as_str() 427 .unwrap(); 428 let contract = validate_authority(raw, verifier).unwrap(); 429 assert!(validate_authority(raw, &"f".repeat(64)).is_err()); 430 assert!(validate_authority(b"invalid", verifier).is_err()); 431 assert!(validate_authority(b"{}\n", verifier).is_err()); 432 assert!( 433 validate_authority(&serde_json::to_vec_pretty(&authority).unwrap(), verifier).is_err() 434 ); 435 for (pointer, value) in [ 436 ("/schema", json!("other")), 437 ("/step", json!([0])), 438 ("/gate_command_contract", json!([])), 439 ] { 440 let mut changed = authority.clone(); 441 *changed.pointer_mut(pointer).unwrap() = value; 442 let mut bytes = canonical(&changed).unwrap(); 443 bytes.push(b'\n'); 444 assert!(validate_authority(&bytes, verifier).is_err()); 445 } 446 // Encoding fixtures is not a historical gate execution or qualification. 447 let args = arguments(); 448 let bytes = result_bytes(&args, &args.check_id, verifier, &contract).unwrap(); 449 let result: Value = serde_json::from_slice(&bytes).unwrap(); 450 assert!(bytes.ends_with(b"\n")); 451 assert_eq!(result["source_revision"], args.source_revision); 452 assert_eq!(result["source_tree"], args.source_tree); 453 assert_eq!( 454 result["execution_request"][0]["sha256"], 455 args.execution_request_sha256 456 ); 457 assert_eq!( 458 result["command_contract_sha256"], 459 sha256(&canonical(&contract).unwrap()) 460 ); 461 assert_eq!( 462 result["assertion_inventory_sha256"], 463 sha256(&canonical(&result["assertion"]).unwrap()) 464 ); 465 } 466 }