rshr_202_step_300_gate.rs (18079B)
1 use std::env; 2 use std::fs; 3 use std::path::{Path, PathBuf}; 4 use std::process::{Command, Output}; 5 6 use serde_json::{Value, json}; 7 use sha2::{Digest, Sha256}; 8 9 const STEP: u16 = 300; 10 const GATE_DIGEST: &str = "0293aa6fc55f8e03e0eddf11bb8d28a89272cca330854aa3fd7a68633c2850f5"; 11 const LIB_REVISION: &str = "055096853fca95e15d0f813d33a14aca13be3881"; 12 const NIX_SHA256: &str = "a59ab70f97f6d571642d13c7506aafec0a4275520d53daee2d8451be7c495cd1"; 13 const NIX_VERSION_SHA256: &str = "6db806391ffaea4cdb08ade0031feac399c0cd08474b3bfde8cb33f88a36c8e1"; 14 const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; 15 16 const EXACT_SOURCES: &[(&str, &str)] = &[ 17 ( 18 "Cargo.lock", 19 "ccb7593a1cecf74e1caf6fafaf0ba57ac9df6b6e566d522621ef4bbb1665d42f", 20 ), 21 ( 22 "Cargo.toml", 23 "77cc84e4d873036bbe429fa1bc6e3363d804f6a274b167449f68774b815d73d8", 24 ), 25 ( 26 "contracts/release/myc-artifact-contract.v3.json", 27 "edde8d77b701bb6e086b928c61fc003b5c3572669d2945ee4679485f23a2bddc", 28 ), 29 ( 30 "flake.lock", 31 "5d5b11622c341292f429a5f93e55f38a3506431b139720ff62f983b35bf7d55f", 32 ), 33 ( 34 "flake.nix", 35 "906b1cb7176e421d6bf067e76d6da2860a4863468651e9d768bc0933a1635eb4", 36 ), 37 ( 38 "radroots.service.source-lock.v3.toml", 39 "070a210d79ecd385ae4beafe1dc1b16fd374f9564afc7d224e00be462c58b732", 40 ), 41 ]; 42 43 pub(crate) struct Arguments { 44 pub(crate) step: u16, 45 pub(crate) check_id: String, 46 pub(crate) source_revision: String, 47 pub(crate) source_tree: String, 48 pub(crate) candidate_digest: String, 49 pub(crate) platform: String, 50 pub(crate) execution_request_sha256: String, 51 } 52 53 fn root() -> PathBuf { 54 Path::new(env!("CARGO_MANIFEST_DIR")) 55 .parent() 56 .and_then(Path::parent) 57 .expect("xtask must remain under tools/xtask") 58 .to_path_buf() 59 } 60 61 fn sha256(bytes: &[u8]) -> String { 62 hex::encode(Sha256::digest(bytes)) 63 } 64 65 fn canonical(value: &Value) -> Result<Vec<u8>, String> { 66 serde_json::to_vec(value).map_err(|_| "Step 300 JSON encoding failed".to_owned()) 67 } 68 69 fn execute(command: &mut Command, label: &str) -> Result<Output, String> { 70 let output = command 71 .current_dir(root()) 72 .env("CARGO_NET_OFFLINE", "true") 73 .env("CARGO_TERM_COLOR", "never") 74 .output() 75 .map_err(|_| format!("{label} could not start"))?; 76 if output.stdout.len() > MAX_OUTPUT_BYTES || output.stderr.len() > MAX_OUTPUT_BYTES { 77 return Err(format!("{label} exceeded its output bound")); 78 } 79 Ok(output) 80 } 81 82 fn bounded(command: &mut Command, label: &str) -> Result<Output, String> { 83 let output = execute(command, label)?; 84 if !output.status.success() { 85 return Err(format!("{label} failed")); 86 } 87 Ok(output) 88 } 89 90 fn rejected(command: &mut Command, label: &str) -> Result<(), String> { 91 if execute(command, label)?.status.success() { 92 return Err(format!("{label} unexpectedly succeeded")); 93 } 94 Ok(()) 95 } 96 97 fn resolve_nix() -> Result<PathBuf, String> { 98 if let Some(explicit) = env::var_os("RSHR_NIX_EXECUTABLE") { 99 return fs::canonicalize(explicit) 100 .map_err(|_| "Step 300 Nix client is unavailable".to_owned()); 101 } 102 let path = env::var_os("PATH").ok_or_else(|| "Step 300 PATH is absent".to_owned())?; 103 env::split_paths(&path) 104 .map(|directory| directory.join("nix")) 105 .find(|candidate| candidate.is_file()) 106 .and_then(|candidate| fs::canonicalize(candidate).ok()) 107 .ok_or_else(|| "Step 300 Nix client is unavailable".to_owned()) 108 } 109 110 fn object_keys(value: &Value, label: &str) -> Result<Vec<String>, String> { 111 let mut keys = value 112 .as_object() 113 .ok_or_else(|| format!("Step 300 {label} is not an object"))? 114 .keys() 115 .cloned() 116 .collect::<Vec<_>>(); 117 keys.sort_unstable(); 118 Ok(keys) 119 } 120 121 fn require_source_lock() -> Result<(), String> { 122 let root = root(); 123 let lock_bytes = fs::read(root.join("radroots.service.source-lock.v3.toml")) 124 .map_err(|_| "Step 300 source lock is unreadable".to_owned())?; 125 let lock: toml::Value = toml::from_str( 126 std::str::from_utf8(&lock_bytes) 127 .map_err(|_| "Step 300 source lock is not UTF-8".to_owned())?, 128 ) 129 .map_err(|_| "Step 300 source lock is invalid".to_owned())?; 130 let cargo_sha = sha256( 131 &fs::read(root.join("Cargo.lock")) 132 .map_err(|_| "Step 300 Cargo lock is unreadable".to_owned())?, 133 ); 134 let flake_sha = sha256( 135 &fs::read(root.join("flake.lock")) 136 .map_err(|_| "Step 300 flake lock is unreadable".to_owned())?, 137 ); 138 let artifact_sha = sha256( 139 &fs::read(root.join("contracts/release/myc-artifact-contract.v3.json")) 140 .map_err(|_| "Step 300 artifact contract is unreadable".to_owned())?, 141 ); 142 if lock.get("schema").and_then(toml::Value::as_str) != Some("radroots.service.source-lock.v3") 143 || lock 144 .get("contract_version") 145 .and_then(toml::Value::as_integer) 146 != Some(3) 147 || lock.get("revision").and_then(toml::Value::as_str) != Some(LIB_REVISION) 148 || lock.get("cargo_lock_sha256").and_then(toml::Value::as_str) != Some(&cargo_sha) 149 || lock["nix"]["material"].as_str() != Some("qualified") 150 || lock["nix"]["lib_revision"].as_str() != Some(LIB_REVISION) 151 || lock["nix"]["public_input_lock"]["sha256"].as_str() != Some(&flake_sha) 152 || lock["artifact_contract"]["sha256"].as_str() != Some(&artifact_sha) 153 || lock["sqlite"]["high_level_authority"].as_str() != Some("sqlx_only") 154 || lock["sqlite"]["native_linkage_count"].as_integer() != Some(1) 155 { 156 return Err("Step 300 source lock differs".to_owned()); 157 } 158 159 let flake_lock: Value = serde_json::from_slice( 160 &fs::read(root.join("flake.lock")) 161 .map_err(|_| "Step 300 flake lock is unreadable".to_owned())?, 162 ) 163 .map_err(|_| "Step 300 flake lock is invalid".to_owned())?; 164 if flake_lock.pointer("/nodes/root/inputs/lib") != Some(&json!("lib")) 165 || flake_lock.pointer("/nodes/lib/locked/rev") != Some(&json!(LIB_REVISION)) 166 || flake_lock.pointer("/nodes/lib/original/rev") != Some(&json!(LIB_REVISION)) 167 { 168 return Err("Step 300 exact Lib input differs".to_owned()); 169 } 170 Ok(()) 171 } 172 173 fn require_single_sqlite() -> Result<(), String> { 174 let metadata = bounded( 175 Command::new("cargo").args([ 176 "+1.97.1", 177 "metadata", 178 "--offline", 179 "--locked", 180 "--format-version", 181 "1", 182 ]), 183 "Step 300 Cargo metadata", 184 )?; 185 let value: Value = serde_json::from_slice(&metadata.stdout) 186 .map_err(|_| "Step 300 Cargo metadata is invalid".to_owned())?; 187 let packages = value["packages"] 188 .as_array() 189 .ok_or_else(|| "Step 300 Cargo package inventory is absent".to_owned())?; 190 let sqlite_ids = packages 191 .iter() 192 .filter(|package| package["name"] == "libsqlite3-sys") 193 .filter_map(|package| package["id"].as_str()) 194 .collect::<Vec<_>>(); 195 if sqlite_ids.len() != 1 || packages.iter().any(|package| package["name"] == "rusqlite") { 196 return Err("Step 300 native SQLite package inventory differs".to_owned()); 197 } 198 let nodes = value["resolve"]["nodes"] 199 .as_array() 200 .ok_or_else(|| "Step 300 Cargo resolve inventory is absent".to_owned())?; 201 let sqlite_node = nodes 202 .iter() 203 .find(|node| node["id"] == sqlite_ids[0]) 204 .ok_or_else(|| "Step 300 SQLite resolve node is absent".to_owned())?; 205 let features = sqlite_node["features"] 206 .as_array() 207 .ok_or_else(|| "Step 300 SQLite features are absent".to_owned())?; 208 if !features.iter().any(|feature| feature == "bundled") { 209 return Err("Step 300 bundled SQLite feature is absent".to_owned()); 210 } 211 Ok(()) 212 } 213 214 fn require_outputs(nix: &Path) -> Result<(), String> { 215 let show = bounded( 216 Command::new(nix).args([ 217 "--offline", 218 "flake", 219 "show", 220 "--json", 221 "--all-systems", 222 "--no-write-lock-file", 223 ]), 224 "Step 300 Nix output inventory", 225 )?; 226 let inventory: Value = serde_json::from_slice(&show.stdout) 227 .map_err(|_| "Step 300 Nix output inventory is invalid".to_owned())?; 228 let systems = ["aarch64-darwin", "x86_64-linux"]; 229 for family in ["apps", "checks", "devShells", "packages"] { 230 if object_keys(&inventory[family], family)? != systems { 231 return Err(format!("Step 300 {family} systems differ")); 232 } 233 } 234 if object_keys(&inventory["nixosModules"], "nixosModules")? != ["default"] 235 || inventory.pointer("/nixosModules/default/type") != Some(&json!("nixos-module")) 236 { 237 return Err("Step 300 NixOS module inventory differs".to_owned()); 238 } 239 let expected_checks = [ 240 "check", 241 "clippy", 242 "config", 243 "docs", 244 "fmt", 245 "integration", 246 "package", 247 "source-lock", 248 "sqlx", 249 "test", 250 ]; 251 for system in systems { 252 if object_keys(&inventory["apps"][system], "apps")? != ["default", "release-acceptance"] 253 || object_keys(&inventory["checks"][system], "checks")? != expected_checks 254 || object_keys(&inventory["devShells"][system], "devShells")? != ["default"] 255 || inventory["packages"][system]["default"]["name"] != "myc-0.1.0" 256 || inventory["apps"][system]["default"]["description"] != "Run the built myc service" 257 { 258 return Err("Step 300 service output inventory differs".to_owned()); 259 } 260 } 261 if object_keys(&inventory["packages"]["aarch64-darwin"], "Darwin packages")? != ["default"] 262 || object_keys(&inventory["packages"]["x86_64-linux"], "Linux packages")? 263 != ["default", "oci"] 264 || inventory["packages"]["x86_64-linux"]["oci"]["name"] != "myc.tar.gz" 265 { 266 return Err("Step 300 platform-specific package inventory differs".to_owned()); 267 } 268 for system in ["x86_64-darwin", "aarch64-linux", "x86_64-windows"] { 269 rejected( 270 Command::new(nix).args([ 271 "--offline", 272 "eval", 273 "--raw", 274 &format!(".#packages.{system}.default.name"), 275 ]), 276 "Step 300 excluded-system evaluation", 277 )?; 278 } 279 rejected( 280 Command::new(nix).args([ 281 "--offline", 282 "eval", 283 "--raw", 284 ".#packages.aarch64-darwin.oci.name", 285 ]), 286 "Step 300 Darwin OCI evaluation", 287 )?; 288 Ok(()) 289 } 290 291 fn require_nix() -> Result<(), String> { 292 let executable = resolve_nix()?; 293 if sha256(&fs::read(&executable).map_err(|_| "Step 300 Nix client is unreadable")?) 294 != NIX_SHA256 295 { 296 return Err("Step 300 Nix client identity differs".to_owned()); 297 } 298 let version = bounded( 299 Command::new(&executable).arg("--version"), 300 "Step 300 Nix version", 301 )?; 302 if sha256(&version.stdout) != NIX_VERSION_SHA256 { 303 return Err("Step 300 Nix version differs".to_owned()); 304 } 305 bounded( 306 Command::new(&executable).args([ 307 "--offline", 308 "flake", 309 "check", 310 "--all-systems", 311 "--no-build", 312 "--no-write-lock-file", 313 ]), 314 "Step 300 Nix evaluation", 315 )?; 316 require_outputs(&executable) 317 } 318 319 fn expected_contract(verifier_sha256: &str) -> Value { 320 json!({ 321 "argv_template": [ 322 "cargo", "extbuild", "run", "--", "cargo", "run", "--offline", "--locked", 323 "-q", "-p", "myc_xtask", "--", "rshr-step-300-gate", "--step={step}", 324 "--check-id={check_id}", "--source-revision={source_revision}", 325 "--source-tree={source_tree}", "--candidate-digest={candidate_digest}", 326 "--platform=macos_aarch64", 327 "--execution-request-sha256={execution_request_sha256}" 328 ], 329 "assertion_id": [format!("step_300_gate_01_{GATE_DIGEST}")], 330 "check_id": format!("gate-01-{GATE_DIGEST}"), 331 "environment_authority": { 332 "cache_policy_id": "rshr-200-step-287-cache-policy.v1", 333 "cache_policy_sha256": "3e81d178bce97b6c349dfbb00c68fd6f620ac00b1a1c8d37b12e9998f3c9eaaa", 334 "cadence_policy_id": "rshr-200-step-287-cadence-policy.v1", 335 "cadence_policy_sha256": "d24903df8659ee3772297c84994911efe7d21cb8b988320ddc6ddce0431892a1", 336 "isolation": "extbuild_host_constrained", 337 "network": "disabled", 338 "network_policy_id": "none", 339 "network_policy_sha256": "none", 340 "resource_policy_id": "rshr-200-step-287-resource-policy.v1", 341 "resource_policy_sha256": "05d3c7a89185d3c55678d97955193fce2ed92b1eee5af99083d77ea64c98d14e" 342 }, 343 "environment_names": [ 344 "EXT_BUILD_CONFIG", "EXT_BUILD_MACHINE_CONFIG", "EXT_BUILD_ROOT", "HOME", "PATH", 345 "RUSTUP_TOOLCHAIN", "TMPDIR" 346 ], 347 "gate_definition_sha256": GATE_DIGEST, 348 "required_platforms": ["macos_aarch64"], 349 "required_tools": ["rustc"], 350 "result_schema": "radroots.services-hardening.rshr-200-step-check-result.v1", 351 "schema": "radroots.services-hardening.rshr-200-step-check-command.v1", 352 "step": STEP, 353 "verifier_path": "tools/xtask/src/rshr_202_step_300_gate.rs", 354 "verifier_sha256": verifier_sha256 355 }) 356 } 357 358 pub(crate) fn run(arguments: Arguments) -> Result<(), String> { 359 let check_id = format!("gate-01-{GATE_DIGEST}"); 360 if arguments.step != STEP 361 || arguments.check_id != check_id 362 || arguments.candidate_digest != "none" 363 || arguments.platform != "macos_aarch64" 364 || arguments.source_revision.len() != 40 365 || arguments.source_tree.len() != 40 366 || arguments.execution_request_sha256.len() != 64 367 || !arguments 368 .source_revision 369 .bytes() 370 .chain(arguments.source_tree.bytes()) 371 .chain(arguments.execution_request_sha256.bytes()) 372 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) 373 { 374 return Err("Step 300 gate arguments differ".to_owned()); 375 } 376 let root = root(); 377 if root.join(".github").exists() || root.join("radroots.service.source-lock.v2.toml").exists() { 378 return Err("Step 300 forbidden legacy surface is present".to_owned()); 379 } 380 for (relative, expected) in EXACT_SOURCES { 381 let bytes = fs::read(root.join(relative)) 382 .map_err(|_| "Step 300 governed source is unreadable".to_owned())?; 383 if sha256(&bytes) != *expected { 384 return Err("Step 300 governed source bytes differ".to_owned()); 385 } 386 } 387 let flake_source = fs::read_to_string(root.join("flake.nix")) 388 .map_err(|_| "Step 300 flake source is unreadable".to_owned())?; 389 for forbidden in [ 390 "pkgs.clang", 391 "libclang", 392 "libsodium", 393 "pkgs.openssl", 394 "pkgs.pkg-config", 395 "pkgs.sqlite", 396 ] { 397 if flake_source.contains(forbidden) { 398 return Err("Step 300 forbidden native dependency is present".to_owned()); 399 } 400 } 401 402 let verifier_path = root.join("tools/xtask/src/rshr_202_step_300_gate.rs"); 403 let verifier_sha256 = 404 sha256(&fs::read(verifier_path).map_err(|_| "Step 300 verifier is unreadable")?); 405 let authority_path = root.join("contracts/rshr-202-step-300-gates.v1.json"); 406 let authority_bytes = 407 fs::read(authority_path).map_err(|_| "Step 300 gate authority is unreadable")?; 408 let authority: Value = serde_json::from_slice(&authority_bytes) 409 .map_err(|_| "Step 300 gate authority is invalid".to_owned())?; 410 let mut canonical_authority = canonical(&authority)?; 411 canonical_authority.push(b'\n'); 412 let contracts = authority 413 .get("gate_command_contract") 414 .and_then(Value::as_array) 415 .ok_or_else(|| "Step 300 gate contract is absent".to_owned())?; 416 if authority_bytes != canonical_authority 417 || authority.get("schema") 418 != Some(&Value::String( 419 "radroots.myc.rshr-202-step-300-gates.v1".to_owned(), 420 )) 421 || authority.get("step") != Some(&json!([STEP])) 422 || contracts.as_slice() != [expected_contract(&verifier_sha256)] 423 { 424 return Err("Step 300 gate authority differs".to_owned()); 425 } 426 427 require_source_lock()?; 428 bounded( 429 Command::new("cargo").args(["+1.97.1", "fmt", "--all", "--", "--check"]), 430 "Step 300 formatting", 431 )?; 432 bounded( 433 Command::new("cargo").args([ 434 "+1.97.1", 435 "check", 436 "--offline", 437 "--locked", 438 "--workspace", 439 "--all-targets", 440 ]), 441 "Step 300 Cargo check", 442 )?; 443 require_single_sqlite()?; 444 require_nix()?; 445 446 let contract = &contracts[0]; 447 let assertion = json!([{ 448 "id": format!("step_300_gate_01_{GATE_DIGEST}"), 449 "result": "pass" 450 }]); 451 let result = json!({ 452 "schema": "radroots.services-hardening.rshr-200-step-check-result.v1", 453 "step": STEP, 454 "check_id": check_id, 455 "gate_definition_sha256": GATE_DIGEST, 456 "source_revision": arguments.source_revision, 457 "source_tree": arguments.source_tree, 458 "candidate_generation": 0, 459 "candidate_digest": "none", 460 "command_contract_sha256": sha256(&canonical(contract)?), 461 "verifier_sha256": verifier_sha256, 462 "execution_request": [{ 463 "platform": arguments.platform, 464 "sha256": arguments.execution_request_sha256 465 }], 466 "assertion_inventory_sha256": sha256(&canonical(&assertion)?), 467 "assertion": assertion, 468 "result": "pass" 469 }); 470 let mut bytes = canonical(&result)?; 471 bytes.push(b'\n'); 472 std::io::Write::write_all(&mut std::io::stdout().lock(), &bytes) 473 .map_err(|_| "Step 300 result write failed".to_owned()) 474 }