lib.rs (98823B)
1 use sha2::{Digest, Sha256}; 2 use std::collections::BTreeSet; 3 use std::fs::{self, OpenOptions}; 4 use std::io::{ErrorKind, Read}; 5 use std::path::{Component, Path, PathBuf}; 6 use std::process::Command as ProcessCommand; 7 use std::str::FromStr; 8 9 #[cfg(unix)] 10 use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; 11 12 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 13 pub enum Command { 14 DesignSourceAudit, 15 RepoAudit, 16 NamespaceAudit, 17 ProvenanceCheck, 18 QualificationReport, 19 } 20 21 impl FromStr for Command { 22 type Err = String; 23 24 fn from_str(value: &str) -> Result<Self, Self::Err> { 25 match value { 26 "design-source-audit" => Ok(Self::DesignSourceAudit), 27 "repo-audit" => Ok(Self::RepoAudit), 28 "namespace-audit" => Ok(Self::NamespaceAudit), 29 "provenance-check" => Ok(Self::ProvenanceCheck), 30 "qualification-report" => Ok(Self::QualificationReport), 31 _ => Err(format!("unknown xtask command: {value}")), 32 } 33 } 34 } 35 36 pub fn run(root: &Path, command: Command) -> Result<String, Vec<String>> { 37 let build_mode = 38 std::env::var("HARVESTCIRCLE_BUILD_MODE").unwrap_or_else(|_| "standalone".to_owned()); 39 if command == Command::QualificationReport 40 && !matches!(build_mode.as_str(), "standalone" | "governed") 41 { 42 return Err(vec![format!( 43 "unknown qualification build mode: {build_mode}" 44 )]); 45 } 46 let inventory = Inventory::load(root).map_err(|finding| vec![finding])?; 47 let mut findings = Vec::new(); 48 match command { 49 Command::DesignSourceAudit => design_source_audit(root, &inventory, &mut findings), 50 Command::RepoAudit => repo_audit(root, &inventory, &mut findings), 51 Command::NamespaceAudit => namespace_audit(root, &inventory, &mut findings), 52 Command::ProvenanceCheck => provenance_check(root, &inventory, &mut findings), 53 Command::QualificationReport => { 54 repo_audit(root, &inventory, &mut findings); 55 namespace_audit(root, &inventory, &mut findings); 56 provenance_check(root, &inventory, &mut findings); 57 design_source_audit(root, &inventory, &mut findings); 58 product_shell_audit(root, &inventory, &mut findings); 59 } 60 } 61 findings.sort(); 62 findings.dedup(); 63 if findings.is_empty() { 64 let inventory_kind = if inventory.git_aware { 65 "git" 66 } else { 67 "archive" 68 }; 69 let command_name = match command { 70 Command::DesignSourceAudit => "design-source-audit", 71 Command::RepoAudit => "repo-audit", 72 Command::NamespaceAudit => "namespace-audit", 73 Command::ProvenanceCheck => "provenance-check", 74 Command::QualificationReport => "qualification-report", 75 }; 76 let mode = if command == Command::QualificationReport { 77 format!("harvestcircle.build.mode={build_mode}\n") 78 } else { 79 String::new() 80 }; 81 Ok(format!( 82 "harvestcircle.xtask.command={command_name}\nharvestcircle.xtask.inventory={inventory_kind}\n{mode}harvestcircle.xtask.result=pass\n" 83 )) 84 } else { 85 Err(findings) 86 } 87 } 88 89 #[derive(Debug)] 90 struct Inventory { 91 paths: Vec<String>, 92 git_aware: bool, 93 } 94 95 impl Inventory { 96 fn load(root: &Path) -> Result<Self, String> { 97 if root.join(".git").exists() { 98 let output = ProcessCommand::new("git") 99 .args([ 100 "-C", 101 &root.to_string_lossy(), 102 "ls-files", 103 "--cached", 104 "--others", 105 "--exclude-standard", 106 "-z", 107 ]) 108 .output() 109 .map_err(|error| { 110 format!("unable to enumerate tracked HarvestCircle sources: {error}") 111 })?; 112 if !output.status.success() { 113 return Err("unable to enumerate tracked HarvestCircle sources".to_owned()); 114 } 115 let mut paths = Vec::new(); 116 for raw_path in output 117 .stdout 118 .split(|byte| *byte == 0) 119 .filter(|path| !path.is_empty()) 120 { 121 let path = String::from_utf8(raw_path.to_vec()) 122 .map_err(|_| "Git inventory path is not valid UTF-8".to_owned())?; 123 validate_git_inventory_path(root, Path::new(&path))?; 124 paths.push(path); 125 } 126 paths.sort(); 127 paths.dedup(); 128 Ok(Self { 129 paths, 130 git_aware: true, 131 }) 132 } else { 133 let mut paths = Vec::new(); 134 archive_paths(root, root, &mut paths)?; 135 paths.sort(); 136 paths.dedup(); 137 Ok(Self { 138 paths, 139 git_aware: false, 140 }) 141 } 142 } 143 } 144 145 fn validate_git_inventory_path(root: &Path, relative: &Path) -> Result<(), String> { 146 if relative.is_absolute() 147 || relative.components().next().is_none() 148 || relative 149 .components() 150 .any(|component| !matches!(component, Component::Normal(_))) 151 { 152 return Err(format!( 153 "{}: Git inventory path must be normalized and relative", 154 relative.display() 155 )); 156 } 157 let components = relative.components().collect::<Vec<_>>(); 158 let mut current = root.to_path_buf(); 159 for (index, component) in components.iter().enumerate() { 160 current.push(component.as_os_str()); 161 let metadata = match fs::symlink_metadata(¤t) { 162 Ok(metadata) => metadata, 163 Err(error) if error.kind() == ErrorKind::NotFound => { 164 return Err(format!( 165 "{}: Git inventory path is missing", 166 relative.display() 167 )); 168 } 169 Err(error) => { 170 return Err(format!( 171 "{}: unable to inspect Git inventory path: {error}", 172 relative.display() 173 )); 174 } 175 }; 176 if metadata.file_type().is_symlink() { 177 return Err(format!( 178 "{}: Git inventory path traverses a symbolic link", 179 relative.display() 180 )); 181 } 182 if index + 1 < components.len() { 183 if !metadata.is_dir() { 184 return Err(format!( 185 "{}: Git inventory path parent is not a directory", 186 relative.display() 187 )); 188 } 189 } else if !metadata.is_file() { 190 return Err(format!( 191 "{}: Git inventory path is not a regular file", 192 relative.display() 193 )); 194 } 195 } 196 Ok(()) 197 } 198 199 fn archive_paths(root: &Path, directory: &Path, paths: &mut Vec<String>) -> Result<(), String> { 200 let entries = fs::read_dir(directory).map_err(|error| { 201 format!( 202 "{}: unable to read archive inventory: {error}", 203 directory.display() 204 ) 205 })?; 206 for entry in entries { 207 let entry = entry.map_err(|error| format!("archive inventory entry failed: {error}"))?; 208 let path = entry.path(); 209 let relative = relative(root, &path)?; 210 let first = relative.split('/').next().unwrap_or_default(); 211 if matches!( 212 first, 213 ".git" | ".gradle" | ".kotlin" | ".idea" | "build" | "target" | "out" 214 ) || relative 215 .split('/') 216 .any(|part| matches!(part, "build" | "target" | "out")) 217 { 218 continue; 219 } 220 paths.push(relative); 221 if entry 222 .file_type() 223 .map_err(|error| format!("unable to classify archive entry: {error}"))? 224 .is_dir() 225 { 226 archive_paths(root, &path, paths)?; 227 } 228 } 229 Ok(()) 230 } 231 232 fn repo_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { 233 let required = [ 234 "README.md", 235 "NOTICE", 236 "CONTRIBUTING.md", 237 "SECURITY.md", 238 "LICENSE", 239 "LICENSES/GPL-3.0-only.txt", 240 "LICENSES/OFL-1.1.txt", 241 ]; 242 for required_path in required { 243 if !inventory.paths.iter().any(|path| path == required_path) { 244 findings.push(format!( 245 "{required_path}: required public repository file is missing" 246 )); 247 } 248 } 249 for path in &inventory.paths { 250 let normalized = path.to_ascii_lowercase(); 251 if is_forbidden_documentation_or_workflow_path(&normalized) { 252 findings.push(format!("{path}: forbidden repository root")); 253 } 254 if fs::symlink_metadata(root.join(path)) 255 .is_ok_and(|metadata| metadata.file_type().is_symlink()) 256 { 257 findings.push(format!( 258 "{path}: symbolic links are not allowed in public sources" 259 )); 260 } 261 if normalized.starts_with("core/target/") 262 || normalized.contains("/build/") 263 || normalized.contains("/generated/") 264 || normalized.contains("generated/uniffi") 265 || [".dylib", ".so", ".dll", ".class"] 266 .iter() 267 .any(|suffix| normalized.ends_with(suffix)) 268 { 269 findings.push(format!( 270 "{path}: generated build output must not be source controlled" 271 )); 272 } 273 if [".pem", ".key", ".p12", ".pfx", ".jks", ".keystore", ".env"] 274 .iter() 275 .any(|suffix| normalized.ends_with(suffix)) 276 || normalized.contains("/credentials/") 277 { 278 findings.push(format!("{path}: credential or secret-shaped source path")); 279 } 280 if is_text(path) { 281 let source = read_text(root, path); 282 let markers = [ 283 ["-----BEGIN ", "PRIVATE KEY-----"].concat(), 284 ["AWS_", "SECRET_ACCESS_KEY="].concat(), 285 ["gh", "p_"].concat(), 286 ["sk_", "live_"].concat(), 287 ]; 288 if markers.iter().any(|marker| source.contains(marker)) { 289 findings.push(format!( 290 "{path}: credential or private-key material in source text" 291 )); 292 } 293 } 294 } 295 git_source_policy(root, findings); 296 native_runtime_boundary(root, findings); 297 } 298 299 fn native_runtime_boundary(root: &Path, findings: &mut Vec<String>) { 300 let domain_lib = root.join("core/crates/harvestcircle_domain/src/lib.rs"); 301 if !domain_lib.is_file() { 302 return; 303 } 304 305 if root 306 .join("core/crates/harvestcircle_domain/src/relay.rs") 307 .exists() 308 || read_text(root, "core/crates/harvestcircle_domain/src/lib.rs").contains("mod relay") 309 { 310 findings 311 .push("harvestcircle_domain: duplicate relay policy surface is forbidden".to_owned()); 312 } 313 314 let nostr_manifest = read_text(root, "core/crates/harvestcircle_nostr/Cargo.toml"); 315 let production_manifest = nostr_manifest 316 .split_once("[dev-dependencies]") 317 .map_or(nostr_manifest.as_str(), |(production, _)| production); 318 if production_manifest.contains("nostr-sdk") { 319 findings.push( 320 "harvestcircle_nostr: production nostr-sdk connection authority is forbidden" 321 .to_owned(), 322 ); 323 } 324 let nostr_client = read_text(root, "core/crates/harvestcircle_nostr/src/client.rs"); 325 for required in [ 326 "radroots_transport_nostr::{Config, NostrTransport, RelayEndpoint, RelayProfile}", 327 "parse_verified_kind0", 328 "FetchBounds::new(MAX_PROFILE_EVENTS_PER_FETCH", 329 ] { 330 if !nostr_client.contains(required) { 331 findings.push(format!( 332 "harvestcircle_nostr: governed transport boundary is missing {required}" 333 )); 334 } 335 } 336 337 for (path, forbidden) in [ 338 ("core/crates/harvestcircle_ffi/src/commands.rs", "OnceLock"), 339 ( 340 "core/crates/harvestcircle_ffi/src/commands.rs", 341 "PoisonError::into_inner", 342 ), 343 ( 344 "core/crates/harvestcircle_ffi/src/observer.rs", 345 "PoisonError::into_inner", 346 ), 347 ( 348 "core/crates/harvestcircle_application/src/app_core.rs", 349 "PoisonError::into_inner", 350 ), 351 ( 352 "core/crates/harvestcircle_application/src/custody.rs", 353 "PoisonError::into_inner", 354 ), 355 ( 356 "core/crates/harvestcircle_application/src/secrets.rs", 357 "PoisonError::into_inner", 358 ), 359 ] { 360 if read_text(root, path).contains(forbidden) { 361 findings.push(format!("{path}: forbidden runtime boundary {forbidden}")); 362 } 363 } 364 365 let runtime = read_text(root, "core/crates/harvestcircle_ffi/src/host_runtime.rs"); 366 let keyring = read_text(root, "core/crates/harvestcircle_ffi/src/keyring_worker.rs"); 367 for (source, required, owner) in [ 368 (&runtime, "pub(crate) struct HostRuntime", "host runtime"), 369 (&runtime, "pub(crate) async fn shutdown", "host runtime"), 370 ( 371 &keyring, 372 "const KEYRING_QUEUE_CAPACITY: usize = 8", 373 "keyring worker", 374 ), 375 ( 376 &keyring, 377 "pub(crate) struct BoundedKeyringWorker", 378 "keyring worker", 379 ), 380 ( 381 &keyring, 382 "use tokio::sync::{oneshot, watch}", 383 "keyring worker", 384 ), 385 (&keyring, "response_receiver.await", "keyring worker"), 386 ( 387 &keyring, 388 "const KEYRING_SHUTDOWN_DEADLINE: Duration = Duration::from_secs(30)", 389 "keyring worker", 390 ), 391 (&keyring, "OPERATION_QUEUED", "keyring worker"), 392 (&keyring, "OPERATION_STARTED", "keyring worker"), 393 (&keyring, "OPERATION_COMPLETED", "keyring worker"), 394 (&keyring, "OPERATION_CANCELLED", "keyring worker"), 395 ] { 396 if !source.contains(required) { 397 findings.push(format!( 398 "harvestcircle_ffi: {owner} contract is missing {required}" 399 )); 400 } 401 } 402 if keyring.contains("response_receiver.recv") { 403 findings 404 .push("harvestcircle_ffi: keyring response blocks a Tokio runtime thread".to_owned()); 405 } 406 if keyring.contains("std::sync::mpsc::Receiver") { 407 findings.push("harvestcircle_ffi: keyring response exposes a blocking receiver".to_owned()); 408 } 409 let native_keyring = read_text(root, "core/crates/harvestcircle_storage/src/os_keyring.rs"); 410 let native_verify = native_keyring 411 .split_once(" fn verify<'a>(") 412 .and_then(|(_, source)| source.split_once("\n fn load(")) 413 .map(|(body, _)| body) 414 .unwrap_or_default(); 415 for required in [ 416 "request_id: &'a DurableRequestId", 417 "secret: SecretKeyInput", 418 "self.operation()?", 419 "Zeroizing::new(platform_read(&account).map_err(map_read_error)?)", 420 "verify_replay_binding(request_id, &secret, encoded.as_slice())", 421 ] { 422 if !native_verify.contains(required) { 423 findings.push(format!( 424 "harvestcircle_storage: read-only verification is missing {required}" 425 )); 426 } 427 } 428 for forbidden in ["platform_create(", "platform_delete(", ".put(", ".delete("] { 429 if native_verify.contains(forbidden) { 430 findings.push(format!( 431 "harvestcircle_storage: verification mutates custody through {forbidden}" 432 )); 433 } 434 } 435 let worker_verify = keyring 436 .split_once("Request::Verify(request_id, public_key, secret, phase, response) => {") 437 .and_then(|(_, source)| source.split_once("Request::Load(")) 438 .map(|(body, _)| body) 439 .unwrap_or_default(); 440 for required in [ 441 "start_operation(&phase)", 442 "store.verify(&request_id, public_key, secret).await", 443 "finish_operation(&phase)", 444 "response.send(result)", 445 ] { 446 if !worker_verify.contains(required) { 447 findings.push(format!( 448 "harvestcircle_ffi: verification lifecycle is missing {required}" 449 )); 450 } 451 } 452 let worker_submit = keyring 453 .split_once(" fn verify<'a>(") 454 .and_then(|(_, source)| source.split_once("\n fn load(")) 455 .map(|(body, _)| body) 456 .unwrap_or_default(); 457 if !worker_submit.contains("self.submit(|phase, response|") 458 || !worker_submit 459 .contains("Request::Verify(request_id.clone(), public_key, secret, phase, response)") 460 || worker_submit.contains("Request::Put(") 461 { 462 findings.push( 463 "harvestcircle_ffi: verification bypasses the bounded read-only worker submission" 464 .to_owned(), 465 ); 466 } 467 } 468 469 fn namespace_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { 470 let legacy = ["stu", "dio"].concat(); 471 let temporary_namespace = ["org", "radroots", "harvestcircle"].join("."); 472 let inherited_preferences = [ 473 ["use", "radroots", "dns"].join("_"), 474 ["use", "radroots", "subnets"].join("_"), 475 ["vpn", "on", "demand", "enabled"].join("_"), 476 ["run", "as", "exit", "node"].join("_"), 477 ["automatically", "check", "for", "updates"].join("_"), 478 ["update", "channel"].join("_"), 479 ["last", "update", "check", "summary"].join("_"), 480 ["alternate", "server", "url"].join("_"), 481 ]; 482 for path in &inventory.paths { 483 let normalized = path.to_ascii_lowercase(); 484 if normalized.contains(&legacy) { 485 findings.push(format!("{path}: legacy product name in source path")); 486 } 487 if normalized.starts_with("app/") 488 && normalized.contains("/kotlin/") 489 && normalized.ends_with(".kt") 490 { 491 let package_path = normalized 492 .split_once("/kotlin/") 493 .map(|(_, value)| value) 494 .unwrap_or_default(); 495 if !package_path.starts_with("org/harvestcircle/") { 496 findings.push(format!( 497 "{path}: Kotlin source is outside the final namespace" 498 )); 499 } 500 } 501 if !is_text(path) { 502 continue; 503 } 504 let source = read_text(root, path); 505 if source.to_ascii_lowercase().contains(&legacy) { 506 findings.push(format!("{path}: legacy product name in source text")); 507 } 508 if source.contains(&temporary_namespace) 509 || source.contains(&temporary_namespace.replace('.', "/")) 510 { 511 findings.push(format!("{path}: temporary product namespace")); 512 } 513 let production_kotlin = path.ends_with(".kt") 514 && path.starts_with("app/") 515 && ["/src/main/", "/src/commonMain/", "/src/desktopMain/"] 516 .iter() 517 .any(|segment| path.contains(segment)); 518 let bounded_health = path 519 == "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt" 520 && source.contains("HEALTH_CHECK_ARGUMENT") 521 && source.contains("withTimeout(HEALTH_TIMEOUT_MILLIS)"); 522 if production_kotlin && source.contains(&["run", "Blocking"].concat()) && !bounded_health { 523 findings.push(format!( 524 "{path}: blocking coroutine bridge in application source" 525 )); 526 } 527 if production_kotlin 528 && (source.contains(&["Atomic", "Long"].concat()) 529 || source.contains(&["desktop", "-operation:"].concat())) 530 { 531 findings.push(format!("{path}: process-local operation counter")); 532 } 533 if path.starts_with("app/shared/src/commonMain/") 534 && [ 535 ["org.harvestcircle.", "ffi"].concat(), 536 ["com.sun.", "jna"].concat(), 537 "java.".to_owned(), 538 "javax.".to_owned(), 539 ] 540 .iter() 541 .any(|marker| source.contains(marker)) 542 { 543 findings.push(format!( 544 "{path}: platform dependency in shared common source" 545 )); 546 } 547 let lowercase = source.to_ascii_lowercase(); 548 for token in &inherited_preferences { 549 if lowercase.contains(token) { 550 findings.push(format!("{path}: inherited non-product preference {token}")); 551 } 552 } 553 let secret_marker = ["nsec", "1"].concat(); 554 let inspected_secret = lowercase.replace(&format!("{secret_marker}…"), ""); 555 if production_kotlin && inspected_secret.contains(&secret_marker) { 556 findings.push(format!("{path}: secret key literal in production Kotlin")); 557 } 558 } 559 } 560 561 fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { 562 let required = [ 563 "app/shared/src/commonMain/kotlin/org/harvestcircle/product/SurfaceRegistry.kt", 564 "app/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt", 565 "app/shared/src/commonMain/kotlin/org/harvestcircle/appearance/AppearanceState.kt", 566 "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTheme.kt", 567 "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt", 568 "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/feedback/HarvestCircleBadge.kt", 569 "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrame.kt", 570 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt", 571 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt", 572 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt", 573 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt", 574 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt", 575 ]; 576 for path in required { 577 if !inventory.paths.iter().any(|candidate| candidate == path) { 578 findings.push(format!("{path}: required product-shell source is missing")); 579 } 580 } 581 let regression_matrix: &[(&str, &[&str])] = &[ 582 ( 583 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt", 584 &["hcSc001", "hcSc002"], 585 ), 586 ( 587 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt", 588 &["hcSc003", "HcSc004"], 589 ), 590 ( 591 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt", 592 &["hcSc005", "hcSc006", "hcSc007"], 593 ), 594 ( 595 "app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleColorContrastTest.kt", 596 &["hcSc008", "hcSc009"], 597 ), 598 ( 599 "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/OwnedControlsUiTest.kt", 600 &["hcSc010"], 601 ), 602 ( 603 "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt", 604 &["hcSc011"], 605 ), 606 ( 607 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt", 608 &["hcSc012", "hcSl001", "hcSl006"], 609 ), 610 ( 611 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/ReferenceInputPolicyTest.kt", 612 &["hcSl001"], 613 ), 614 ( 615 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt", 616 &[ 617 "hcSl002", "hcSl003", "hcSl004", "hcSl005", "hcEx001", "hcEx002", "hcEx003", 618 ], 619 ), 620 ( 621 "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntryTest.kt", 622 &["hcEx004"], 623 ), 624 ( 625 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt", 626 &["hcSl001", "hcSl006"], 627 ), 628 ( 629 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/ImportSecretDraftTest.kt", 630 &["hcSl005"], 631 ), 632 ( 633 "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt", 634 &["hcSl006"], 635 ), 636 ]; 637 for (path, markers) in regression_matrix { 638 let source = read_text(root, path); 639 for marker in *markers { 640 if !source.contains(marker) { 641 findings.push(format!( 642 "{path}: required shell-security regression marker is missing: {marker}" 643 )); 644 } 645 } 646 } 647 let closure_source_contract: &[(&str, &[&str])] = &[ 648 ( 649 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/ReferenceInputPolicy.kt", 650 &["data object AmbiguousHex", "hasAmbiguousHexShape"], 651 ), 652 ( 653 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/ImportSecretDraft.kt", 654 &[ 655 "class ImportSecretDraft private constructor", 656 "private var characters: CharArray?", 657 ], 658 ), 659 ( 660 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt", 661 &[ 662 "val importDraft: ImportSecretDraft", 663 "class EditImportDraft private constructor", 664 ], 665 ), 666 ( 667 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt", 668 &[ 669 "PendingRemovalLease", 670 "removalMutex", 671 "expireRemovalLease", 672 "releaseClaimedRemoval", 673 "PresenterClosePhase.TransferredToShutdown", 674 "ImportSecretDraft", 675 ], 676 ), 677 ( 678 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt", 679 &["ReferenceInputAdmission.AmbiguousHex"], 680 ), 681 ( 682 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt", 683 &["val overlayBusy = (overlay as? FoundationOverlay.ConfirmAction)?.busy == true"], 684 ), 685 ( 686 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt", 687 &[ 688 "The secret is held only for this import.", 689 "It is cleared after it is sent to the local native runtime.", 690 ], 691 ), 692 ]; 693 for (path, markers) in closure_source_contract { 694 let source = read_text(root, path); 695 for marker in *markers { 696 if !source.contains(marker) { 697 findings.push(format!( 698 "{path}: required secret-lifecycle closure source marker is missing: {marker}" 699 )); 700 } 701 } 702 } 703 let presenter_tests = read_text( 704 root, 705 "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt", 706 ); 707 for forbidden in ["Thread.sleep", "kotlinx.coroutines.delay("] { 708 if presenter_tests.contains(forbidden) { 709 findings.push(format!( 710 "automatic-expiry tests must use virtual time, not {forbidden}" 711 )); 712 } 713 } 714 let bootstrap_entry = read_text( 715 root, 716 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt", 717 ); 718 let retired_copy = [ 719 "The secret is sent directly to the local native runtime ", 720 "and is not retained in the interface.", 721 ] 722 .concat(); 723 if bootstrap_entry.contains(&retired_copy) { 724 findings.push("Bootstrap identity entry retains retired secret-custody copy".to_owned()); 725 } 726 let locked_copy = [ 727 ( 728 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt", 729 &[ 730 "Coordinate local food with clear, signed terms.", 731 "You do not need a HarvestCircle account.", 732 "Open source · Nostr-based · No managed service required", 733 ][..], 734 ), 735 ( 736 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt", 737 &[ 738 "No active commitments", 739 "Explore nearby buying circles or open a shared Nostr reference.", 740 "Not available in this build.", 741 ][..], 742 ), 743 ( 744 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt", 745 &[ 746 "Overview", 747 "Identity", 748 "Public relays", 749 "Runtime", 750 "No managed HarvestCircle service is configured.", 751 ][..], 752 ), 753 ( 754 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt", 755 &[ 756 "Appearance", 757 "Project", 758 "Theme", 759 "Text size", 760 "Motion", 761 "FFI contract", 762 "Storage schema", 763 ][..], 764 ), 765 ]; 766 for (path, expected) in locked_copy { 767 let source = read_text(root, path); 768 for text in expected { 769 if !source.contains(text) { 770 findings.push(format!( 771 "{path}: locked product-shell copy is missing: {text}" 772 )); 773 } 774 } 775 } 776 for path in &inventory.paths { 777 if !is_production_kotlin(path) { 778 continue; 779 } 780 let source = read_text(root, path); 781 let normalized_path = path.to_ascii_lowercase(); 782 let compact = source 783 .chars() 784 .filter(|character| !character.is_whitespace()) 785 .collect::<String>(); 786 for (shape, diagnostic) in [ 787 ("funShellText(", "superseded shell text adapter"), 788 ("funShellButton(", "superseded shell button adapter"), 789 ("funShellTextField(", "superseded shell field adapter"), 790 ( 791 "enumclassShellTextRole", 792 "superseded shell text-role adapter", 793 ), 794 ( 795 "enumclassShellButtonKind", 796 "superseded shell button-kind adapter", 797 ), 798 ] { 799 if compact.contains(shape) { 800 findings.push(format!("{path}: {diagnostic}")); 801 } 802 } 803 if source.contains("androidx.compose.material") { 804 findings.push(format!( 805 "{path}: Material component dependency is forbidden" 806 )); 807 } 808 for (shape, diagnostic) in [ 809 ( 810 "dataobjectConfirmIdentityRemoval", 811 "retired parameterless confirmation source shape", 812 ), 813 ( 814 "dataobjectCancelIdentityRemoval", 815 "retired parameterless confirmation source shape", 816 ), 817 ( 818 "isOverlayIntent.EditReference->classifyNostrReference(", 819 "parser-on-edit source shape", 820 ), 821 ( 822 "OverlayIntent.Open(FoundationOverlay.OpenNostrReference(", 823 "prefilled reference ingress source shape", 824 ), 825 ( 826 "selected=true,enabled=false", 827 "selected-as-disabled source shape", 828 ), 829 ( 830 "valimportDraft:String", 831 "raw String import-draft custody source shape", 832 ), 833 ( 834 "dataclassEditImportDraft", 835 "copyable import-draft intent source shape", 836 ), 837 ] { 838 if compact.contains(shape) { 839 findings.push(format!("{path}: {diagnostic}")); 840 } 841 } 842 if path 843 == "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt" 844 && compact.contains( 845 "funFoundationOverlayHost(state:OverlayState,status:ShellStatusModel,busy:Boolean", 846 ) 847 { 848 findings.push(format!( 849 "{path}: global busy state must not enter the overlay host" 850 )); 851 } 852 if path == "app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt" 853 && compact.contains("state.identity.busy") 854 { 855 findings.push(format!( 856 "{path}: unrelated identity busy state must not gate overlay admission" 857 )); 858 } 859 if normalized_path.ends_with("/harvestcirclescreen.kt") { 860 findings.push(format!("{path}: superseded product-shell screen path")); 861 } 862 for marker in [ 863 "home-screen", 864 "inactive-identities", 865 "WindowBackgroundColor", 866 "ButtonBackgroundColor", 867 "InputBackgroundColor", 868 ] { 869 if source.contains(marker) { 870 findings.push(format!("{path}: superseded product-shell marker {marker}")); 871 } 872 } 873 if is_production_compose(path, &source) 874 && source.contains("focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.None") 875 { 876 findings.push(format!( 877 "{path}: interactive control defaults to a hidden keyboard focus ring" 878 )); 879 } 880 let approved_color_adapter = path.starts_with( 881 "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/", 882 ) || path 883 == "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisuals.kt"; 884 if is_production_compose(path, &source) 885 && !approved_color_adapter 886 && contains_direct_call(&compact, "Color(") 887 { 888 findings.push(format!( 889 "{path}: hard-coded Compose color outside the theme adapter" 890 )); 891 } 892 let approved_text_primitive = matches!( 893 path.as_str(), 894 "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleText.kt" 895 | "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellText.kt" 896 ); 897 let approved_input_primitive = matches!( 898 path.as_str(), 899 "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/input/HarvestCircleTextField.kt" 900 | "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellControls.kt" 901 ); 902 if is_production_compose(path, &source) { 903 if !approved_text_primitive && contains_direct_call(&compact, "BasicText(") { 904 findings.push(format!( 905 "{path}: BasicText bypasses the shell primitive adapter" 906 )); 907 } 908 if !approved_input_primitive && contains_direct_call(&compact, "BasicTextField(") { 909 findings.push(format!( 910 "{path}: BasicTextField bypasses the shell primitive adapter" 911 )); 912 } 913 } 914 let lowercase = source.to_ascii_lowercase(); 915 for marker in [ 916 "sample farm", 917 "sample commitment", 918 "sample price", 919 "sample event", 920 "fake farm", 921 "fake commitment", 922 "pricecents", 923 "commitmentid", 924 "allocationid", 925 "fulfillmentid", 926 ] { 927 if lowercase.contains(marker) { 928 findings.push(format!( 929 "{path}: fake commercial product data marker {marker}" 930 )); 931 } 932 } 933 } 934 for forbidden in [ 935 "app/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt", 936 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt", 937 ] { 938 if inventory.paths.iter().any(|path| path == forbidden) { 939 findings.push(format!( 940 "{forbidden}: superseded product-shell authority returned" 941 )); 942 } 943 } 944 } 945 946 fn is_forbidden_documentation_or_workflow_path(path: &str) -> bool { 947 path.split('/').any(|part| { 948 matches!( 949 part, 950 "doc" | "docs" | "spec" | "specs" | ".github" | ".act" | "workflow" | "workflows" 951 ) 952 }) 953 } 954 955 fn is_production_kotlin(path: &str) -> bool { 956 path.starts_with("app/") 957 && path.ends_with(".kt") 958 && ["/src/main/", "/src/commonMain/", "/src/desktopMain/"] 959 .iter() 960 .any(|segment| path.contains(segment)) 961 } 962 963 fn is_production_compose(path: &str, source: &str) -> bool { 964 is_production_kotlin(path) 965 && (source.contains("@Composable") || source.contains("androidx.compose.")) 966 } 967 968 fn contains_direct_call(source: &str, call: &str) -> bool { 969 source.match_indices(call).any(|(index, _)| { 970 source[..index] 971 .chars() 972 .next_back() 973 .is_none_or(|character| !character.is_ascii_alphanumeric() && character != '_') 974 }) 975 } 976 977 fn manifest_declares_dependency(source: &str, dependency: &str) -> bool { 978 source.lines().map(str::trim).any(|line| { 979 if line.is_empty() || line.starts_with('#') { 980 return false; 981 } 982 if line 983 .split_once('=') 984 .is_some_and(|(key, _)| key.trim().trim_matches('"') == dependency) 985 { 986 return true; 987 } 988 line.strip_prefix('[') 989 .and_then(|value| value.strip_suffix(']')) 990 .is_some_and(|table| { 991 let segments = table.split('.').collect::<Vec<_>>(); 992 segments.contains(&"dependencies") 993 && segments 994 .last() 995 .is_some_and(|name| name.trim_matches('"') == dependency) 996 }) 997 }) 998 } 999 1000 fn sqlite_dependency_topology(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { 1001 let cargo_lock = read_text(root, "core/Cargo.lock"); 1002 let package_count = |name: &str| { 1003 let marker = format!("name = \"{name}\""); 1004 cargo_lock 1005 .lines() 1006 .filter(|line| line.trim() == marker) 1007 .count() 1008 }; 1009 if package_count("libsqlite3-sys") != 1 1010 || ["rusqlite", "refinery", "refinery-core", "refinery-macros"] 1011 .iter() 1012 .any(|name| package_count(name) != 0) 1013 { 1014 findings.push( 1015 "core/Cargo.lock: exact single SQLx-selected native SQLite topology changed".to_owned(), 1016 ); 1017 } 1018 1019 for path in inventory 1020 .paths 1021 .iter() 1022 .filter(|path| path.starts_with("core/") && path.ends_with("Cargo.toml")) 1023 { 1024 let manifest = read_text(root, path); 1025 if ["rusqlite", "refinery", "libsqlite3-sys"] 1026 .iter() 1027 .any(|dependency| manifest_declares_dependency(&manifest, dependency)) 1028 { 1029 findings.push(format!( 1030 "{path}: direct alternate or native SQLite dependency is forbidden" 1031 )); 1032 } 1033 } 1034 } 1035 1036 fn development_integration_policy(root: &Path, findings: &mut Vec<String>) { 1037 let makefile = read_text(root, "Makefile"); 1038 for required in [ 1039 "override CARGO := cargo +1.97.1", 1040 "api-check: doctor", 1041 "development-check: development-provenance-check source-check integration-check", 1042 "governed-development-check:", 1043 "governed-linux-x86_64-development-check: governed-doctor", 1044 ] { 1045 if makefile 1046 .lines() 1047 .filter(|line| line.trim() == required) 1048 .count() 1049 != 1 1050 { 1051 findings.push(format!( 1052 "Makefile: development integration boundary is missing {required}" 1053 )); 1054 } 1055 } 1056 1057 let runner = read_text(root, "tools/run-linux-x86_64-development-check.sh"); 1058 for required in [ 1059 "rust:1.97.1-slim-trixie@sha256:fc0648ac2962539be80bd424729a20fd80f7b64bfba7e90bbd642aed6c697c5a", 1060 "--platform linux/amd64", 1061 "EXT_BUILD_RUN_ACTIVE", 1062 "--env JAVA_TOOL_OPTIONS=-Duser.home=/workspace/home", 1063 "cargo deny --manifest-path core/Cargo.toml check --config core/deny.toml licenses sources", 1064 "cargo test --manifest-path core/Cargo.toml --workspace --locked", 1065 "cargo clippy --manifest-path core/Cargo.toml --workspace --all-targets --locked -- -D warnings", 1066 ":app:desktop:integrationTest", 1067 ":app:desktop:verifyUniFfiBindings", 1068 "harvestcircle.linux_x86_64.development=pass", 1069 ] { 1070 if !runner.contains(required) { 1071 findings.push(format!( 1072 "tools/run-linux-x86_64-development-check.sh: faithful runner is missing {required}" 1073 )); 1074 } 1075 } 1076 for forbidden in [ 1077 "cargo audit", 1078 " advisories", 1079 "dependencyCheck", 1080 "releaseReadiness", 1081 "unsignedReleaseReadiness", 1082 "verifyReleaseSupplyChainEvidence", 1083 "packageDmg", 1084 "packageDeb", 1085 "CycloneDX", 1086 "SLSA", 1087 ] { 1088 if runner.contains(forbidden) { 1089 findings.push(format!( 1090 "tools/run-linux-x86_64-development-check.sh: deferred release integration is active: {forbidden}" 1091 )); 1092 } 1093 } 1094 } 1095 1096 fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { 1097 const LIB_REVISION: &str = "189c49b74b4bafc142b00b76b296477931139e72"; 1098 const PROVENANCE_PATH: &str = "core/provenance/harvestcircle-v1.toml"; 1099 const SOURCE_LOCK_PATH: &str = "radroots.lib.source-lock.v1.toml"; 1100 const MAX_SOURCE_LOCK_BYTES: u64 = 1024 * 1024; 1101 const MAX_CARGO_LOCK_BYTES: u64 = 32 * 1024 * 1024; 1102 let cargo = read_text(root, "core/Cargo.toml"); 1103 for authority in [ 1104 "repository = \"https://github.com/radrootslabs/harvestcircle\"".to_owned(), 1105 format!( 1106 "radroots_event = {{ git = \"https://github.com/radrootslabs/lib\", rev = \"{LIB_REVISION}\", version = \"=0.1.0-alpha\", default-features = false }}" 1107 ), 1108 format!( 1109 "radroots_event_codec = {{ git = \"https://github.com/radrootslabs/lib\", rev = \"{LIB_REVISION}\", version = \"=0.1.0-alpha\", default-features = false }}" 1110 ), 1111 format!( 1112 "radroots_identity = {{ git = \"https://github.com/radrootslabs/lib\", rev = \"{LIB_REVISION}\", version = \"=0.1.0-alpha\", default-features = false }}" 1113 ), 1114 format!( 1115 "radroots_runtime_paths = {{ git = \"https://github.com/radrootslabs/lib\", rev = \"{LIB_REVISION}\", version = \"=0.1.0-alpha\", default-features = false }}" 1116 ), 1117 format!( 1118 "radroots_service_sqlite = {{ git = \"https://github.com/radrootslabs/lib\", rev = \"{LIB_REVISION}\", version = \"=0.1.0-alpha\", default-features = false }}" 1119 ), 1120 format!( 1121 "radroots_storage = {{ git = \"https://github.com/radrootslabs/lib\", rev = \"{LIB_REVISION}\", version = \"=0.1.0-alpha\", default-features = false }}" 1122 ), 1123 format!( 1124 "radroots_transport = {{ git = \"https://github.com/radrootslabs/lib\", rev = \"{LIB_REVISION}\", version = \"=0.1.0-alpha\", default-features = false }}" 1125 ), 1126 format!( 1127 "radroots_transport_nostr = {{ git = \"https://github.com/radrootslabs/lib\", rev = \"{LIB_REVISION}\", version = \"=0.1.0-alpha\", default-features = false }}" 1128 ), 1129 ] { 1130 if cargo 1131 .lines() 1132 .filter(|line| line.trim() == authority) 1133 .count() 1134 != 1 1135 { 1136 findings.push(format!( 1137 "core/Cargo.toml: missing exact authority: {authority}" 1138 )); 1139 } 1140 } 1141 let provenance = read_text(root, PROVENANCE_PATH); 1142 if !provenance.contains("source_product = \"HarvestCircle\"") 1143 || !provenance 1144 .contains("source_repository = \"https://github.com/radrootslabs/harvestcircle\"") 1145 || !provenance.contains(&format!("canonical_radroots_revision = \"{LIB_REVISION}\"")) 1146 || !provenance 1147 .contains("foundation_baseline = \"c08d18ea569351dddeef70d4c1410708daf067b6\"") 1148 { 1149 findings.push(format!( 1150 "{PROVENANCE_PATH}: exact source provenance changed" 1151 )); 1152 } 1153 let expected_source_lock = concat!( 1154 "schema = \"radroots.lib.source-lock.v1\"\n", 1155 "repository = \"https://github.com/radrootslabs/lib\"\n", 1156 "revision = \"189c49b74b4bafc142b00b76b296477931139e72\"\n", 1157 "architecture = \"radroots.crates.release.v2\"\n", 1158 "workspace_catalog_sha256 = \"ee295f2352e2577a4052d980624415aec9871197d4fc9910a4c21c83a9179200\"\n", 1159 "version = \"0.1.0-alpha\"\n", 1160 "source_archive_sha256 = \"c648a3ab993d10253b9073e7e86db7b8970863bdf1d394d9fc30d66825695240\"\n", 1161 "lockfile = \"core/Cargo.lock\"\n", 1162 "lockfile_sha256 = \"6a32d1de0105771158647c65116fe797dfa7db7c42db515c63047d6601b846a3\"\n", 1163 ); 1164 let source_lock_bytes = 1165 match bounded_no_follow_bytes(root, Path::new(SOURCE_LOCK_PATH), MAX_SOURCE_LOCK_BYTES) { 1166 Ok(bytes) => bytes, 1167 Err(error) => { 1168 findings.push(format!("{SOURCE_LOCK_PATH}: {error}")); 1169 Vec::new() 1170 } 1171 }; 1172 let source_lock = String::from_utf8(source_lock_bytes).unwrap_or_default(); 1173 if source_lock != expected_source_lock { 1174 findings.push(format!("{SOURCE_LOCK_PATH}: exact Lib source lock changed")); 1175 } 1176 let lockfile = exact_string_assignment(&source_lock, "lockfile"); 1177 let declared_lockfile_sha256 = exact_string_assignment(&source_lock, "lockfile_sha256"); 1178 let cargo_lock_bytes = lockfile 1179 .as_deref() 1180 .ok_or_else(|| "lockfile assignment is missing or duplicated".to_owned()) 1181 .and_then(|path| bounded_no_follow_bytes(root, Path::new(path), MAX_CARGO_LOCK_BYTES)); 1182 if let (Ok(bytes), Some(declared)) = (&cargo_lock_bytes, declared_lockfile_sha256.as_deref()) { 1183 let actual = format!("{:x}", Sha256::digest(bytes)); 1184 if actual != declared { 1185 findings.push(format!( 1186 "{SOURCE_LOCK_PATH}: lockfile_sha256 does not match actual bounded no-follow bytes" 1187 )); 1188 } 1189 } else { 1190 let error = cargo_lock_bytes 1191 .as_ref() 1192 .err() 1193 .map(String::as_str) 1194 .unwrap_or("lockfile_sha256 assignment is missing or duplicated"); 1195 findings.push(format!("{SOURCE_LOCK_PATH}: {error}")); 1196 } 1197 let cargo_lock = cargo_lock_bytes 1198 .ok() 1199 .and_then(|bytes| String::from_utf8(bytes).ok()) 1200 .unwrap_or_default(); 1201 if !cargo_lock.contains(&format!( 1202 "source = \"git+https://github.com/radrootslabs/lib?rev={LIB_REVISION}#{LIB_REVISION}\"" 1203 )) { 1204 findings.push("core/Cargo.lock: selected Lib revision is missing".to_owned()); 1205 } 1206 sqlite_dependency_topology(root, inventory, findings); 1207 development_integration_policy(root, findings); 1208 let coordinates = properties(&read_text( 1209 root, 1210 "config/product/harvestcircle-v1.properties", 1211 )); 1212 for (key, expected) in [ 1213 ("storage.service_id", "harvestcircle"), 1214 ("storage.instance_id", "desktop"), 1215 ("storage.database_filename", "state.sqlite"), 1216 ("storage.lock_filename", "state.lock"), 1217 ("storage.application_id", "1212371505"), 1218 ("storage.application_id_text", "HCR1"), 1219 ("storage.initial_schema_version", "1"), 1220 ("legacy.database.filename", "harvestcircle.sqlite3"), 1221 ("legacy.database.disposition", "untouched_and_unsupported"), 1222 ("platform.macos.architecture", "aarch64"), 1223 ("platform.linux.architecture", "x86_64"), 1224 ("limit.identities", "256"), 1225 ("limit.unfinished_durable_operations", "1024"), 1226 ("limit.preference_value_utf8_bytes", "4096"), 1227 ("limit.relay_endpoints", "16"), 1228 ("limit.relay_url_bytes", "2048"), 1229 ("limit.events_per_relay", "64"), 1230 ("limit.events_total", "1024"), 1231 ("limit.observers", "32"), 1232 ("limit.actor_mailbox", "64"), 1233 ("limit.command_deadline_min_ms", "1"), 1234 ("limit.command_deadline_max_ms", "30000"), 1235 ("backup.member_limit", "caller_supplied_positive"), 1236 ] { 1237 if coordinates.get(key).map(String::as_str) != Some(expected) { 1238 findings.push(format!( 1239 "config/product/harvestcircle-v1.properties: {key} must remain {expected}" 1240 )); 1241 } 1242 } 1243 let uniffi = read_text(root, "core/crates/harvestcircle_ffi/uniffi.toml"); 1244 let ffi_package = coordinates 1245 .get("ffi.kotlin_package") 1246 .map(String::as_str) 1247 .unwrap_or_default(); 1248 let cdylib = coordinates 1249 .get("ffi.cdylib_name") 1250 .map(String::as_str) 1251 .unwrap_or_default(); 1252 if !uniffi.contains("[crates.harvestcircle_ffi.bindings.kotlin]") 1253 || !uniffi.contains(&format!("package_name = \"{ffi_package}\"")) 1254 || !uniffi.contains(&format!("cdylib_name = \"{cdylib}\"")) 1255 { 1256 findings.push( 1257 "core/crates/harvestcircle_ffi/uniffi.toml: final FFI identity changed".to_owned(), 1258 ); 1259 } 1260 let baseline = read_text(root, "core/compatibility/harvestcircle-ffi-v4.properties"); 1261 if !baseline.contains("contract.id=harvestcircle-desktop-ffi-v4") 1262 || !baseline.contains("contract.major=4") 1263 { 1264 findings.push( 1265 "core/compatibility/harvestcircle-ffi-v4.properties: FFI v4 identity changed" 1266 .to_owned(), 1267 ); 1268 } 1269 let shared_build = read_text(root, "app/shared/build.gradle.kts"); 1270 if !shared_build.contains("id(\"org.harvestcircle.build.kmp-shared\")") 1271 || ["androidTarget", "iosArm", "iosX", "js(", "wasm"] 1272 .iter() 1273 .any(|marker| shared_build.contains(marker)) 1274 { 1275 findings.push("app/shared/build.gradle.kts: shared KMP target boundary changed".to_owned()); 1276 } 1277 const STORAGE_API_BASELINE: &str = "core/compatibility/harvestcircle-storage-api-v3.txt"; 1278 let storage_api = read_text(root, STORAGE_API_BASELINE); 1279 for required in [ 1280 "pub struct harvestcircle_storage::HarvestCircleStorageContract", 1281 "pub const harvestcircle_storage::HARVESTCIRCLE_APPLICATION_ID: u32", 1282 "pub fn harvestcircle_storage::harvestcircle_schema_catalog()", 1283 "pub struct harvestcircle_storage::Database", 1284 "pub async fn harvestcircle_storage::Database::open", 1285 "pub async fn harvestcircle_storage::Database::close", 1286 "pub async fn harvestcircle_storage::Database::retain_availability_version", 1287 "pub async fn harvestcircle_storage::Database::load_availability_version", 1288 "pub async fn harvestcircle_storage::Database::capture_online_backup", 1289 "pub async fn harvestcircle_storage::Database::restore_verified_backup", 1290 "pub struct harvestcircle_storage::VerifiedHarvestCircleBackup", 1291 "pub fn harvestcircle_storage::verify_harvestcircle_backup", 1292 "impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::Database", 1293 "harvestcircle_application::ports::BoxFuture", 1294 "pub fn harvestcircle_storage::OsKeyringSecretStore::verify<'a>(&'a self, &'a harvestcircle_application::ports::DurableRequestId, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> harvestcircle_application::ports::BoxFuture<'a", 1295 ] { 1296 if !storage_api.contains(required) { 1297 findings.push(format!("{STORAGE_API_BASELINE}: missing {required}")); 1298 } 1299 } 1300 for forbidden in [ 1301 "rusqlite::", 1302 "refinery::", 1303 "sqlx::", 1304 "OperationJournal", 1305 "harvestcircle_initial_schema_sql", 1306 "SELECT_AVAILABILITY_VERSION_SQL", 1307 "decode_availability_row", 1308 "retain_availability_version_on", 1309 "ServiceSqliteTransaction", 1310 "VerifiedServiceBackup", 1311 "StagedServiceRestore", 1312 "verify_backup_bundle", 1313 "stage_verified_restore", 1314 "finalize_staged_restore", 1315 "repair", 1316 "preflight", 1317 ] { 1318 if storage_api.contains(forbidden) { 1319 findings.push(format!( 1320 "{STORAGE_API_BASELINE}: dependency-owned API leaked: {forbidden}" 1321 )); 1322 } 1323 } 1324 for required in [ 1325 PROVENANCE_PATH, 1326 SOURCE_LOCK_PATH, 1327 STORAGE_API_BASELINE, 1328 "config/verification/lanes-v3.properties", 1329 "tools/run-linux-x86_64-development-check.sh", 1330 "tools/verify-storage-api.sh", 1331 ] { 1332 if !inventory.paths.iter().any(|path| path == required) { 1333 findings.push(format!("{required}: governed source evidence is missing")); 1334 } 1335 } 1336 } 1337 1338 fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { 1339 const PATH: &str = "config/design/harvestcircle-v1.toml"; 1340 if !inventory.paths.iter().any(|path| path == PATH) { 1341 findings.push(format!("{PATH}: design contract is missing")); 1342 return; 1343 } 1344 let source = read_text(root, PATH); 1345 let required_scalars = [ 1346 "schema = \"harvestcircle.design.v1\"", 1347 "repository = \"https://github.com/radrootslabs/harvestcircle\"", 1348 "baseline_revision = \"c08d18ea569351dddeef70d4c1410708daf067b6\"", 1349 "license = \"GPL-3.0-only\"", 1350 "golden_host = \"macos-aarch64\"", 1351 "golden_status = \"verified\"", 1352 "design_system_root = \"app/design_system\"", 1353 "design_catalog_root = \"tools/design_catalog\"", 1354 "application_shell_root = \"app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell\"", 1355 "golden_test_path = \"app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt\"", 1356 ]; 1357 for scalar in required_scalars { 1358 if source.lines().filter(|line| line.trim() == scalar).count() != 1 { 1359 findings.push(format!("{PATH}: missing or duplicate authority: {scalar}")); 1360 } 1361 } 1362 for (path, key, sha256) in [ 1363 ( 1364 "app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png", 1365 "golden_light_sha256", 1366 "96e1ef5dd8b5cb14e47471a737a1e57ab0543b7f3aa79b865051e4740a2ee57a", 1367 ), 1368 ( 1369 "app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png", 1370 "golden_dark_sha256", 1371 "6a85cd890109b11de6f647dca91cb616651e362aa0802c40aa6aee7f678451c9", 1372 ), 1373 ] { 1374 let authority = format!("{key} = \"{sha256}\""); 1375 if source 1376 .lines() 1377 .filter(|line| line.trim() == authority) 1378 .count() 1379 != 1 1380 { 1381 findings.push(format!("{PATH}: {key} must match the governed golden")); 1382 } 1383 if !inventory.paths.iter().any(|candidate| candidate == path) 1384 || sha256_file(&root.join(path)).as_deref() != Some(sha256) 1385 { 1386 findings.push(format!("{path}: macOS golden is missing or changed")); 1387 } 1388 } 1389 let golden_test = read_text( 1390 root, 1391 "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt", 1392 ); 1393 if !golden_test.contains("HarvestCircleShell(") 1394 || !golden_test.contains("liveTodayState(") 1395 || golden_test.contains("captureReferenceSurface(") 1396 { 1397 findings.push( 1398 "HarvestCircleMacGoldenTest.kt: golden must render a live application shell state" 1399 .to_owned(), 1400 ); 1401 } 1402 let catalog = read_text(root, "gradle/libs.versions.toml"); 1403 for required in [ 1404 "compose-animation = { module = \"org.jetbrains.compose.animation:animation\", version.ref = \"compose\" }", 1405 "compose-components-resources = { module = \"org.jetbrains.compose.components:components-resources\", version.ref = \"compose\" }", 1406 ] { 1407 if !catalog.contains(required) { 1408 findings.push(format!( 1409 "gradle/libs.versions.toml: missing approved design dependency: {required}" 1410 )); 1411 } 1412 } 1413 for forbidden in ["compose-material3", "platformtools", "js(", "wasm"] { 1414 if catalog.to_ascii_lowercase().contains(forbidden) { 1415 findings.push(format!( 1416 "gradle/libs.versions.toml: forbidden design dependency or target: {forbidden}" 1417 )); 1418 } 1419 } 1420 for path in &inventory.paths { 1421 if !path.starts_with("app/design_system/") && !path.starts_with("tools/design_catalog/") { 1422 continue; 1423 } 1424 let lowercase = read_text(root, path).to_ascii_lowercase(); 1425 for forbidden in [ 1426 "androidx.compose.material3".to_owned(), 1427 "io.github.kdroidfilter.platformtools".to_owned(), 1428 "com.radroots.".to_owned() + &["stu", "dio"].concat(), 1429 ] { 1430 if lowercase.contains(&forbidden) { 1431 findings.push(format!( 1432 "{path}: forbidden dependency or legacy namespace: {forbidden}" 1433 )); 1434 } 1435 } 1436 } 1437 for (path, sha256) in [ 1438 ( 1439 "app/design_system/src/commonMain/composeResources/font/inter_bold.ttf", 1440 "288316099b1e0a47a4716d159098005eef7c0066921f34e3200393dbdb01947f", 1441 ), 1442 ( 1443 "app/design_system/src/commonMain/composeResources/font/inter_medium.ttf", 1444 "97ad806f526e41546d46365bb3a393145f75b7b1568913db74549ad8b8dba872", 1445 ), 1446 ( 1447 "app/design_system/src/commonMain/composeResources/font/inter_regular.ttf", 1448 "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82", 1449 ), 1450 ( 1451 "app/design_system/src/commonMain/composeResources/font/inter_semibold.ttf", 1452 "78a843fade9d4612a5567302fb595b56976eb5fcebf4fea5a5912d638bafcde3", 1453 ), 1454 ] { 1455 if sha256_file(&root.join(path)).as_deref() != Some(sha256) { 1456 findings.push(format!( 1457 "{path}: Inter font digest differs from the baseline" 1458 )); 1459 } 1460 } 1461 let font_license = read_text(root, "LICENSES/OFL-1.1.txt"); 1462 let packaged_font_license = read_text( 1463 root, 1464 "app/design_system/src/commonMain/composeResources/files/licenses/inter-OFL-1.1.txt", 1465 ); 1466 if font_license.is_empty() || packaged_font_license != font_license { 1467 findings.push("Inter font licence is missing or differs in packaged resources".to_owned()); 1468 } 1469 } 1470 1471 fn git_source_policy(root: &Path, findings: &mut Vec<String>) { 1472 let deny = read_text(root, "core/deny.toml"); 1473 if !deny 1474 .lines() 1475 .any(|line| line.trim() == "required-git-spec = \"rev\"") 1476 { 1477 findings 1478 .push("core/deny.toml: cargo-deny must require revision-pinned Git sources".to_owned()); 1479 } 1480 let allowed_git = quoted_values(section_value(&deny, "allow-git")); 1481 if allowed_git.is_empty() { 1482 findings.push("core/deny.toml: cargo-deny Git allowlist is empty".to_owned()); 1483 } 1484 let mut inspected = false; 1485 for manifest in cargo_manifests(root.join("core")) { 1486 let source = fs::read_to_string(&manifest).unwrap_or_default(); 1487 for (index, line) in source 1488 .lines() 1489 .enumerate() 1490 .filter(|(_, line)| line.contains("git")) 1491 { 1492 let Some(git) = attribute(line, "git") else { 1493 continue; 1494 }; 1495 inspected = true; 1496 let relative_path = 1497 relative(root, &manifest).unwrap_or_else(|_| manifest.display().to_string()); 1498 if !allowed_git.contains(&git) { 1499 findings.push(format!( 1500 "{relative_path}:{}: Git dependency source is not allowlisted", 1501 index + 1 1502 )); 1503 } 1504 if line.contains("branch =") || line.contains("tag =") { 1505 findings.push(format!( 1506 "{relative_path}:{}: Git dependency uses a branch or tag", 1507 index + 1 1508 )); 1509 } 1510 let revision = attribute(line, "rev").unwrap_or_default(); 1511 if !is_lower_hex(&revision, 40) { 1512 findings.push(format!( 1513 "{relative_path}:{}: Git dependency must use one full revision pin", 1514 index + 1 1515 )); 1516 } 1517 if git == "https://github.com/rust-nostr/nostr.git" 1518 && revision != "5bba5163eb77107f82c4a8262cf29d7f33a73219" 1519 { 1520 findings.push("core/Cargo.toml: direct rust-nostr revision changed".to_owned()); 1521 } 1522 } 1523 } 1524 if !inspected { 1525 findings.push("core: no revision-pinned Git dependencies were inspected".to_owned()); 1526 } 1527 for line in read_text(root, "core/Cargo.lock") 1528 .lines() 1529 .filter(|line| line.starts_with("source = \"git+")) 1530 { 1531 let immutable = line.rsplit_once("?rev=").is_some_and(|(_, suffix)| { 1532 suffix.len() == 82 1533 && suffix.as_bytes().get(40) == Some(&b'#') 1534 && suffix.ends_with('"') 1535 && is_lower_hex(&suffix[..40], 40) 1536 && is_lower_hex(&suffix[41..81], 40) 1537 }); 1538 if !immutable { 1539 findings.push(format!( 1540 "core/Cargo.lock: Git source is not immutable: {line}" 1541 )); 1542 } 1543 } 1544 } 1545 1546 fn cargo_manifests(core: PathBuf) -> Vec<PathBuf> { 1547 let mut manifests = vec![core.join("Cargo.toml")]; 1548 if let Ok(entries) = fs::read_dir(core.join("crates")) { 1549 for entry in entries.flatten() { 1550 let manifest = entry.path().join("Cargo.toml"); 1551 if manifest.is_file() { 1552 manifests.push(manifest); 1553 } 1554 } 1555 } 1556 manifests.sort(); 1557 manifests 1558 } 1559 1560 fn properties(source: &str) -> std::collections::BTreeMap<String, String> { 1561 source 1562 .lines() 1563 .filter_map(|line| { 1564 let line = line.trim(); 1565 if line.is_empty() || line.starts_with('#') { 1566 None 1567 } else { 1568 line.split_once('=') 1569 .map(|(key, value)| (key.trim().to_owned(), value.trim().to_owned())) 1570 } 1571 }) 1572 .collect() 1573 } 1574 1575 fn section_value<'a>(source: &'a str, key: &str) -> &'a str { 1576 source 1577 .split_once(key) 1578 .map(|(_, tail)| tail.split_once(']').map_or(tail, |(value, _)| value)) 1579 .unwrap_or_default() 1580 } 1581 1582 fn quoted_values(source: &str) -> BTreeSet<String> { 1583 source 1584 .split('"') 1585 .enumerate() 1586 .filter(|(index, _)| index % 2 == 1) 1587 .map(|(_, value)| value.to_owned()) 1588 .collect() 1589 } 1590 1591 fn attribute(source: &str, key: &str) -> Option<String> { 1592 let tail = source.split_once(&format!("{key} = \""))?.1; 1593 Some(tail.split_once('"')?.0.to_owned()) 1594 } 1595 1596 fn is_lower_hex(value: &str, length: usize) -> bool { 1597 value.len() == length 1598 && value 1599 .bytes() 1600 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 1601 } 1602 1603 fn is_text(relative: &str) -> bool { 1604 let name = Path::new(relative) 1605 .file_name() 1606 .and_then(|value| value.to_str()) 1607 .unwrap_or_default(); 1608 let extension = Path::new(relative) 1609 .extension() 1610 .and_then(|value| value.to_str()) 1611 .unwrap_or_default(); 1612 [ 1613 "gradle", 1614 "json", 1615 "kt", 1616 "kts", 1617 "lock", 1618 "md", 1619 "properties", 1620 "rs", 1621 "sql", 1622 "toml", 1623 "txt", 1624 "xml", 1625 "yaml", 1626 "yml", 1627 ] 1628 .contains(&extension) 1629 || [ 1630 ".gitattributes", 1631 ".gitignore", 1632 "AGENTS.md", 1633 "LICENSE", 1634 "Makefile", 1635 "NOTICE", 1636 "gradlew", 1637 "gradlew.bat", 1638 ] 1639 .contains(&name) 1640 } 1641 1642 fn read_text(root: &Path, relative: &str) -> String { 1643 fs::read_to_string(root.join(relative)).unwrap_or_default() 1644 } 1645 1646 fn sha256_file(path: &Path) -> Option<String> { 1647 let bytes = fs::read(path).ok()?; 1648 Some(format!("{:x}", Sha256::digest(bytes))) 1649 } 1650 1651 fn exact_string_assignment(source: &str, key: &str) -> Option<String> { 1652 let prefix = format!("{key} = \""); 1653 let values = source 1654 .lines() 1655 .filter_map(|line| { 1656 let value = line.strip_prefix(&prefix)?.strip_suffix('"')?; 1657 (!value.is_empty()).then(|| value.to_owned()) 1658 }) 1659 .collect::<Vec<_>>(); 1660 (values.len() == 1).then(|| values[0].clone()) 1661 } 1662 1663 fn bounded_no_follow_bytes(root: &Path, relative: &Path, maximum: u64) -> Result<Vec<u8>, String> { 1664 if relative.is_absolute() 1665 || relative.components().next().is_none() 1666 || relative 1667 .components() 1668 .any(|component| !matches!(component, Component::Normal(_))) 1669 { 1670 return Err("path must be normalized and relative".to_owned()); 1671 } 1672 let components = relative.components().collect::<Vec<_>>(); 1673 let mut path = root.to_path_buf(); 1674 let mut admitted = None; 1675 for (index, component) in components.iter().enumerate() { 1676 path.push(component.as_os_str()); 1677 let metadata = fs::symlink_metadata(&path) 1678 .map_err(|error| format!("unable to inspect {}: {error}", relative.display()))?; 1679 if metadata.file_type().is_symlink() { 1680 return Err(format!( 1681 "path traverses a symbolic link: {}", 1682 relative.display() 1683 )); 1684 } 1685 if index + 1 == components.len() { 1686 if !metadata.is_file() { 1687 return Err(format!( 1688 "path is not a regular file: {}", 1689 relative.display() 1690 )); 1691 } 1692 if metadata.len() > maximum { 1693 return Err(format!("file exceeds byte limit: {}", relative.display())); 1694 } 1695 admitted = Some(metadata); 1696 } else if !metadata.is_dir() { 1697 return Err(format!( 1698 "path parent is not a directory: {}", 1699 relative.display() 1700 )); 1701 } 1702 } 1703 1704 let mut options = OpenOptions::new(); 1705 options.read(true); 1706 #[cfg(unix)] 1707 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); 1708 let mut file = options.open(&path).map_err(|error| { 1709 format!( 1710 "unable to open {} without following links: {error}", 1711 relative.display() 1712 ) 1713 })?; 1714 let opened = file 1715 .metadata() 1716 .map_err(|error| format!("unable to inspect opened {}: {error}", relative.display()))?; 1717 if !opened.is_file() || opened.len() > maximum { 1718 return Err(format!( 1719 "opened path is not a bounded regular file: {}", 1720 relative.display() 1721 )); 1722 } 1723 #[cfg(unix)] 1724 if admitted 1725 .as_ref() 1726 .is_some_and(|metadata| metadata.dev() != opened.dev() || metadata.ino() != opened.ino()) 1727 { 1728 return Err(format!( 1729 "path identity changed before open: {}", 1730 relative.display() 1731 )); 1732 } 1733 1734 let mut bytes = Vec::with_capacity(opened.len() as usize); 1735 file.by_ref() 1736 .take(maximum + 1) 1737 .read_to_end(&mut bytes) 1738 .map_err(|error| format!("unable to read {}: {error}", relative.display()))?; 1739 if bytes.len() as u64 > maximum { 1740 return Err(format!("file exceeds byte limit: {}", relative.display())); 1741 } 1742 let completed = file 1743 .metadata() 1744 .map_err(|error| format!("unable to revalidate {}: {error}", relative.display()))?; 1745 if completed.len() != bytes.len() as u64 { 1746 return Err(format!( 1747 "file changed while it was read: {}", 1748 relative.display() 1749 )); 1750 } 1751 #[cfg(unix)] 1752 if opened.dev() != completed.dev() || opened.ino() != completed.ino() { 1753 return Err(format!( 1754 "file identity changed while it was read: {}", 1755 relative.display() 1756 )); 1757 } 1758 Ok(bytes) 1759 } 1760 1761 fn relative(root: &Path, path: &Path) -> Result<String, String> { 1762 path.strip_prefix(root) 1763 .map(|relative| relative.to_string_lossy().replace('\\', "/")) 1764 .map_err(|error| format!("{} is outside {}: {error}", path.display(), root.display())) 1765 } 1766 1767 #[cfg(test)] 1768 mod tests { 1769 use super::*; 1770 use std::time::{SystemTime, UNIX_EPOCH}; 1771 1772 #[test] 1773 fn commands_are_exact_and_unknown_values_fail_closed() { 1774 assert_eq!( 1775 "design-source-audit".parse(), 1776 Ok(Command::DesignSourceAudit) 1777 ); 1778 assert_eq!("repo-audit".parse(), Ok(Command::RepoAudit)); 1779 assert_eq!("namespace-audit".parse(), Ok(Command::NamespaceAudit)); 1780 assert_eq!("provenance-check".parse(), Ok(Command::ProvenanceCheck)); 1781 assert_eq!( 1782 "qualification-report".parse(), 1783 Ok(Command::QualificationReport) 1784 ); 1785 assert!("all".parse::<Command>().is_err()); 1786 } 1787 1788 #[test] 1789 fn current_design_contract_is_exact() { 1790 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 1791 .parent() 1792 .and_then(Path::parent) 1793 .expect("repository root") 1794 .to_path_buf(); 1795 let inventory = Inventory::load(&root).expect("source inventory"); 1796 let mut findings = Vec::new(); 1797 design_source_audit(&root, &inventory, &mut findings); 1798 assert!(findings.is_empty(), "{findings:#?}"); 1799 } 1800 1801 #[test] 1802 fn current_source_provenance_and_lib_lock_are_exact() { 1803 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 1804 .parent() 1805 .and_then(Path::parent) 1806 .expect("repository root") 1807 .to_path_buf(); 1808 let inventory = Inventory::load(&root).expect("source inventory"); 1809 let mut findings = Vec::new(); 1810 provenance_check(&root, &inventory, &mut findings); 1811 assert!(findings.is_empty(), "{findings:#?}"); 1812 } 1813 1814 #[test] 1815 fn sqlite_topology_rejects_alternate_duplicate_and_direct_authority() { 1816 let root = fixture("sqlite-topology"); 1817 write( 1818 &root, 1819 "core/Cargo.lock", 1820 "name = \"libsqlite3-sys\"\nname = \"libsqlite3-sys\"\nname = \"rusqlite\"\n", 1821 ); 1822 write( 1823 &root, 1824 "core/crates/unsafe_storage/Cargo.toml", 1825 "[target.'cfg(unix)'.dependencies.refinery]\nversion = \"0.9\"\n", 1826 ); 1827 let inventory = Inventory::load(&root).expect("archive inventory"); 1828 let mut findings = Vec::new(); 1829 sqlite_dependency_topology(&root, &inventory, &mut findings); 1830 assert!( 1831 findings.iter().any(|finding| finding 1832 .contains("exact single SQLx-selected native SQLite topology changed")), 1833 "{findings:#?}" 1834 ); 1835 assert!( 1836 findings.iter().any(|finding| finding 1837 .contains("direct alternate or native SQLite dependency is forbidden")), 1838 "{findings:#?}" 1839 ); 1840 fs::remove_dir_all(root).expect("remove fixture"); 1841 } 1842 1843 #[test] 1844 fn development_runner_rejects_release_integration_activation() { 1845 let root = fixture("development-runner"); 1846 write( 1847 &root, 1848 "Makefile", 1849 "override CARGO := cargo +1.97.1\napi-check: doctor\ndevelopment-check: development-provenance-check source-check integration-check\ngoverned-development-check:\ngoverned-linux-x86_64-development-check: governed-doctor\n", 1850 ); 1851 write( 1852 &root, 1853 "tools/run-linux-x86_64-development-check.sh", 1854 "dependencyCheckAnalyze\n", 1855 ); 1856 let mut findings = Vec::new(); 1857 development_integration_policy(&root, &mut findings); 1858 assert!( 1859 findings 1860 .iter() 1861 .any(|finding| { finding.contains("deferred release integration is active") }), 1862 "{findings:#?}" 1863 ); 1864 fs::remove_dir_all(root).expect("remove fixture"); 1865 } 1866 1867 #[test] 1868 fn design_contract_rejects_identity_and_root_drift() { 1869 let root = fixture("design-contract"); 1870 write( 1871 &root, 1872 "config/design/harvestcircle-v1.toml", 1873 "schema = \"harvestcircle.design.v1\"\nrepository = \"https://example.invalid/other\"\ndesign_system_root = \"../escape\"\n", 1874 ); 1875 let inventory = Inventory::load(&root).expect("archive inventory"); 1876 let mut findings = Vec::new(); 1877 design_source_audit(&root, &inventory, &mut findings); 1878 assert!( 1879 findings 1880 .iter() 1881 .any(|finding| finding.contains("missing or duplicate authority")) 1882 ); 1883 fs::remove_dir_all(root).expect("remove fixture"); 1884 } 1885 1886 #[test] 1887 fn archive_inventory_excludes_outputs_and_includes_source() { 1888 let root = fixture("archive"); 1889 write(&root, "src/main.rs", "fn main() {}\n"); 1890 write(&root, "target/debug/generated.bin", "output"); 1891 write(&root, "nested/build/generated.txt", "output"); 1892 let inventory = Inventory::load(&root).expect("archive inventory"); 1893 assert!(!inventory.git_aware); 1894 assert!(inventory.paths.contains(&"src/main.rs".to_owned())); 1895 assert!( 1896 !inventory 1897 .paths 1898 .iter() 1899 .any(|path| path.contains("target/") || path.contains("/build/")) 1900 ); 1901 fs::remove_dir_all(root).expect("remove fixture"); 1902 } 1903 1904 #[test] 1905 fn repository_policy_rejects_secret_and_generated_shapes() { 1906 let root = fixture("policy"); 1907 write(&root, "README.md", "safe\n"); 1908 write(&root, "config/credentials/release.key", "fixture\n"); 1909 write(&root, "core/target/generated/native.bin", "fixture\n"); 1910 write( 1911 &root, 1912 "safe.txt", 1913 &["-----BEGIN ", "PRIVATE KEY-----"].concat(), 1914 ); 1915 write(&root, ".github/workflows/remote.yml", "fixture\n"); 1916 let inventory = Inventory { 1917 paths: vec![ 1918 "README.md".to_owned(), 1919 ".github/workflows/remote.yml".to_owned(), 1920 "config/credentials/release.key".to_owned(), 1921 "core/target/generated/native.bin".to_owned(), 1922 "safe.txt".to_owned(), 1923 ], 1924 git_aware: true, 1925 }; 1926 let mut findings = Vec::new(); 1927 repo_audit(&root, &inventory, &mut findings); 1928 assert!( 1929 findings 1930 .iter() 1931 .any(|finding| finding.contains("secret-shaped")) 1932 ); 1933 assert!( 1934 findings 1935 .iter() 1936 .any(|finding| finding.contains("generated build output")) 1937 ); 1938 assert!( 1939 findings 1940 .iter() 1941 .any(|finding| finding.contains("forbidden repository root")) 1942 ); 1943 assert!( 1944 findings 1945 .iter() 1946 .any(|finding| finding.contains("private-key material")) 1947 ); 1948 fs::remove_dir_all(root).expect("remove fixture"); 1949 } 1950 1951 #[test] 1952 fn namespace_policy_rejects_legacy_temporary_and_platform_sources() { 1953 let root = fixture("namespace"); 1954 let legacy = ["stu", "dio"].concat(); 1955 write( 1956 &root, 1957 &format!("app/{legacy}/Leak.kt"), 1958 &format!( 1959 "package {}\n", 1960 ["org", "radroots", "harvestcircle"].join(".") 1961 ), 1962 ); 1963 write( 1964 &root, 1965 "app/shared/src/commonMain/kotlin/org/harvestcircle/Leak.kt", 1966 "import com.sun.jna.Native\n", 1967 ); 1968 let inventory = Inventory::load(&root).expect("archive inventory"); 1969 let mut findings = Vec::new(); 1970 namespace_audit(&root, &inventory, &mut findings); 1971 assert!( 1972 findings 1973 .iter() 1974 .any(|finding| finding.contains("legacy product name")) 1975 ); 1976 assert!( 1977 findings 1978 .iter() 1979 .any(|finding| finding.contains("temporary product namespace")) 1980 ); 1981 assert!( 1982 findings 1983 .iter() 1984 .any(|finding| finding.contains("platform dependency")) 1985 ); 1986 fs::remove_dir_all(root).expect("remove fixture"); 1987 } 1988 1989 #[test] 1990 fn namespace_policy_rejects_transition_identity_without_exceptions() { 1991 let root = fixture("namespace-no-exceptions"); 1992 let legacy = ["stu", "dio"].concat(); 1993 let repository = format!("https://github.com/radrootslabs/{legacy}_app"); 1994 let entry = 1995 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt"; 1996 write( 1997 &root, 1998 "app/shared/src/commonMain/kotlin/org/harvestcircle/product/SurfaceRegistry.kt", 1999 &format!("val key = \"round_{legacy}_screen\"\n"), 2000 ); 2001 write(&root, entry, "val placeholder = \"nsec1…\"\n"); 2002 write( 2003 &root, 2004 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt", 2005 &format!( 2006 "val source = \"{repository}\"\nval licence = \"{repository}/blob/dev/LICENSE\"\n" 2007 ), 2008 ); 2009 let inventory = Inventory::load(&root).expect("allowlist inventory"); 2010 let mut findings = Vec::new(); 2011 namespace_audit(&root, &inventory, &mut findings); 2012 assert!( 2013 findings 2014 .iter() 2015 .filter(|finding| finding.contains("legacy product name")) 2016 .count() 2017 >= 2, 2018 "{findings:?}" 2019 ); 2020 assert!( 2021 findings 2022 .iter() 2023 .all(|finding| !finding.contains("secret key literal")), 2024 "{findings:?}" 2025 ); 2026 fs::remove_dir_all(root).expect("remove fixture"); 2027 } 2028 2029 #[test] 2030 fn product_shell_audit_requires_the_complete_source_contract() { 2031 let root = fixture("product-shell"); 2032 write(&root, "README.md", "safe\n"); 2033 let inventory = Inventory::load(&root).expect("product shell inventory"); 2034 let mut findings = Vec::new(); 2035 product_shell_audit(&root, &inventory, &mut findings); 2036 assert!( 2037 findings 2038 .iter() 2039 .any(|finding| finding.contains("required product-shell source is missing")) 2040 ); 2041 fs::remove_dir_all(root).expect("remove fixture"); 2042 } 2043 2044 #[test] 2045 fn current_product_shell_sources_pass_the_expanded_policy() { 2046 let root = Path::new(env!("CARGO_MANIFEST_DIR")) 2047 .join("../..") 2048 .canonicalize() 2049 .expect("repository root"); 2050 let inventory = Inventory::load(&root).expect("current source inventory"); 2051 let mut findings = Vec::new(); 2052 product_shell_audit(&root, &inventory, &mut findings); 2053 assert!(findings.is_empty(), "{findings:?}"); 2054 } 2055 2056 #[test] 2057 fn product_shell_audit_rejects_legacy_screen_paths_tags_and_colors() { 2058 let root = fixture("legacy-shell"); 2059 let path = "app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt"; 2060 write( 2061 &root, 2062 path, 2063 "@Composable fun Legacy() { val tag = \"home-screen\"; val color = WindowBackgroundColor }\n", 2064 ); 2065 let inventory = Inventory::load(&root).expect("legacy inventory"); 2066 let mut findings = Vec::new(); 2067 product_shell_audit(&root, &inventory, &mut findings); 2068 assert!( 2069 findings 2070 .iter() 2071 .any(|finding| finding.contains("superseded product-shell screen path")) 2072 ); 2073 assert!( 2074 findings 2075 .iter() 2076 .any(|finding| finding.contains("superseded product-shell marker home-screen")) 2077 ); 2078 assert!(findings.iter().any(|finding| { 2079 finding.contains("superseded product-shell marker WindowBackgroundColor") 2080 })); 2081 fs::remove_dir_all(root).expect("remove fixture"); 2082 } 2083 2084 #[test] 2085 fn product_shell_audit_rejects_color_and_primitive_bypasses_after_a_move() { 2086 let root = fixture("moved-compose"); 2087 let path = "app/desktop/src/main/kotlin/org/harvestcircle/desktop/MovedScreen.kt"; 2088 write( 2089 &root, 2090 path, 2091 "import androidx.compose.runtime.Composable\nimport androidx.compose.material.Button\n@Composable fun ShellButton() { Color(0xFF000000); BasicText(\"bypass\"); BasicTextField(\"\", {}) }\n", 2092 ); 2093 let inventory = Inventory::load(&root).expect("moved UI inventory"); 2094 let mut findings = Vec::new(); 2095 product_shell_audit(&root, &inventory, &mut findings); 2096 assert!( 2097 findings 2098 .iter() 2099 .any(|finding| finding 2100 .contains("hard-coded Compose color outside the theme adapter")) 2101 ); 2102 assert!( 2103 findings 2104 .iter() 2105 .any(|finding| finding.contains("BasicText bypasses the shell primitive adapter")) 2106 ); 2107 assert!(findings.iter().any(|finding| { 2108 finding.contains("BasicTextField bypasses the shell primitive adapter") 2109 })); 2110 assert!( 2111 findings 2112 .iter() 2113 .any(|finding| finding.contains("Material component dependency is forbidden")) 2114 ); 2115 assert!( 2116 findings 2117 .iter() 2118 .any(|finding| finding.contains("superseded shell button adapter")) 2119 ); 2120 fs::remove_dir_all(root).expect("remove fixture"); 2121 } 2122 2123 #[test] 2124 fn product_shell_audit_rejects_fake_commercial_data_outside_the_shell_package() { 2125 let root = fixture("commercial-data"); 2126 write( 2127 &root, 2128 "app/shared/src/commonMain/kotlin/org/harvestcircle/product/FakeData.kt", 2129 "data class FakeData(val commitmentId: String, val priceCents: Long)\n", 2130 ); 2131 let inventory = Inventory::load(&root).expect("commercial inventory"); 2132 let mut findings = Vec::new(); 2133 product_shell_audit(&root, &inventory, &mut findings); 2134 assert!(findings.iter().any(|finding| finding 2135 .contains("fake commercial product data marker commitmentid"))); 2136 assert!( 2137 findings 2138 .iter() 2139 .any(|finding| finding.contains("fake commercial product data marker pricecents")) 2140 ); 2141 fs::remove_dir_all(root).expect("remove fixture"); 2142 } 2143 2144 #[test] 2145 fn product_shell_audit_rejects_retired_security_and_selection_shapes() { 2146 let root = fixture("shell-security-shapes"); 2147 write( 2148 &root, 2149 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/LegacyConfirmation.kt", 2150 "data object ConfirmIdentityRemoval\ndata object CancelIdentityRemoval\n", 2151 ); 2152 write( 2153 &root, 2154 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/UnsafeReference.kt", 2155 "fun reduce(intent: OverlayIntent) = when (intent) { is OverlayIntent.EditReference -> classifyNostrReference(intent.value) }\n", 2156 ); 2157 write( 2158 &root, 2159 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/UnsafeIngress.kt", 2160 "val overlay = OverlayIntent.Open(FoundationOverlay.OpenNostrReference(\"prefilled\"))\n", 2161 ); 2162 write( 2163 &root, 2164 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/UnsafeSelection.kt", 2165 "ShellTab(selected = true, enabled = false)\n", 2166 ); 2167 let inventory = Inventory::load(&root).expect("security shape inventory"); 2168 let mut findings = Vec::new(); 2169 product_shell_audit(&root, &inventory, &mut findings); 2170 for expected in [ 2171 "retired parameterless confirmation source shape", 2172 "parser-on-edit source shape", 2173 "prefilled reference ingress source shape", 2174 "selected-as-disabled source shape", 2175 ] { 2176 assert!( 2177 findings.iter().any(|finding| finding.contains(expected)), 2178 "missing {expected}: {findings:?}" 2179 ); 2180 } 2181 fs::remove_dir_all(root).expect("remove fixture"); 2182 } 2183 2184 #[test] 2185 fn product_shell_audit_rejects_secret_custody_and_global_overlay_busy_shapes() { 2186 let root = fixture("secret-lifecycle-shapes"); 2187 write( 2188 &root, 2189 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/UnsafeDraft.kt", 2190 "data class EditImportDraft(val value: String)\ndata class State(val importDraft: String)\n", 2191 ); 2192 write( 2193 &root, 2194 "app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt", 2195 "fun admit(state: State) = state.identity.busy\n", 2196 ); 2197 write( 2198 &root, 2199 "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt", 2200 "fun FoundationOverlayHost(state: OverlayState, status: ShellStatusModel, busy: Boolean) = Unit\n", 2201 ); 2202 let inventory = Inventory::load(&root).expect("closure shape inventory"); 2203 let mut findings = Vec::new(); 2204 product_shell_audit(&root, &inventory, &mut findings); 2205 for expected in [ 2206 "raw String import-draft custody source shape", 2207 "copyable import-draft intent source shape", 2208 "unrelated identity busy state must not gate overlay admission", 2209 "global busy state must not enter the overlay host", 2210 ] { 2211 assert!( 2212 findings.iter().any(|finding| finding.contains(expected)), 2213 "missing {expected}: {findings:?}" 2214 ); 2215 } 2216 fs::remove_dir_all(root).expect("remove fixture"); 2217 } 2218 2219 #[test] 2220 fn repository_policy_rejects_nested_documentation_and_workflow_roots() { 2221 let root = fixture("nested-docs"); 2222 write(&root, "app/docs/notes.md", "fixture\n"); 2223 write(&root, "app/.github/workflows/remote.yml", "fixture\n"); 2224 let inventory = Inventory::load(&root).expect("nested docs inventory"); 2225 let mut findings = Vec::new(); 2226 repo_audit(&root, &inventory, &mut findings); 2227 assert!( 2228 findings 2229 .iter() 2230 .any(|finding| finding.starts_with("app/docs/")) 2231 ); 2232 assert!( 2233 findings 2234 .iter() 2235 .any(|finding| finding.starts_with("app/.github/workflows/")) 2236 ); 2237 fs::remove_dir_all(root).expect("remove fixture"); 2238 } 2239 2240 #[cfg(unix)] 2241 #[test] 2242 fn repository_policy_rejects_symbolic_links() { 2243 use std::os::unix::fs::symlink; 2244 let root = fixture("symlink"); 2245 write(&root, "outside.txt", "outside\n"); 2246 fs::create_dir_all(root.join("app")).expect("create app"); 2247 symlink(root.join("outside.txt"), root.join("app/escape.txt")).expect("create symlink"); 2248 let inventory = Inventory::load(&root).expect("archive inventory"); 2249 let mut findings = Vec::new(); 2250 repo_audit(&root, &inventory, &mut findings); 2251 assert!( 2252 findings 2253 .iter() 2254 .any(|finding| finding.contains("symbolic links")) 2255 ); 2256 fs::remove_dir_all(root).expect("remove fixture"); 2257 } 2258 2259 #[cfg(unix)] 2260 #[test] 2261 fn git_inventory_rejects_an_intermediate_symbolic_link() { 2262 use std::os::unix::fs::symlink; 2263 2264 let root = fixture("git-inventory-intermediate-symlink"); 2265 initialize_git_fixture(&root); 2266 write(&root, "tracked/file.txt", "tracked\n"); 2267 add_git_fixture_path(&root, Path::new("tracked/file.txt")); 2268 fs::rename(root.join("tracked"), root.join("actual")).expect("move tracked directory"); 2269 symlink(root.join("actual"), root.join("tracked")).expect("create intermediate symlink"); 2270 2271 let error = Inventory::load(&root).expect_err("intermediate symlink must fail closed"); 2272 assert!(error.contains("Git inventory path traverses a symbolic link")); 2273 fs::remove_dir_all(root).expect("remove fixture"); 2274 } 2275 2276 #[cfg(unix)] 2277 #[test] 2278 fn git_inventory_rejects_invalid_utf8_before_filesystem_traversal() { 2279 use std::io::Write as _; 2280 use std::process::Stdio; 2281 2282 let root = fixture("git-inventory-invalid-utf8-symlink"); 2283 initialize_git_fixture(&root); 2284 write(&root, "blob.txt", "tracked\n"); 2285 let object = ProcessCommand::new("git") 2286 .arg("-C") 2287 .arg(&root) 2288 .args(["hash-object", "-w", "blob.txt"]) 2289 .output() 2290 .expect("write fixture blob"); 2291 assert!(object.status.success()); 2292 let object = String::from_utf8(object.stdout).expect("Git object ID is UTF-8"); 2293 let mut index_entry = format!("100644 blob {}\ttracked/", object.trim()).into_bytes(); 2294 index_entry.push(0x80); 2295 index_entry.extend_from_slice(b"/file.txt\0"); 2296 let mut update = ProcessCommand::new("git") 2297 .arg("-C") 2298 .arg(&root) 2299 .args(["update-index", "-z", "--index-info"]) 2300 .stdin(Stdio::piped()) 2301 .spawn() 2302 .expect("start hostile index update"); 2303 update 2304 .stdin 2305 .take() 2306 .expect("hostile index stdin") 2307 .write_all(&index_entry) 2308 .expect("write hostile index entry"); 2309 assert!( 2310 update 2311 .wait() 2312 .expect("finish hostile index update") 2313 .success() 2314 ); 2315 let error = 2316 Inventory::load(&root).expect_err("invalid UTF-8 inventory path must fail closed"); 2317 assert_eq!(error, "Git inventory path is not valid UTF-8"); 2318 fs::remove_dir_all(root).expect("remove fixture"); 2319 } 2320 2321 #[cfg(unix)] 2322 #[test] 2323 fn git_inventory_preserves_a_literal_backslash_without_aliasing_a_separator() { 2324 let root = fixture("git-inventory-backslash"); 2325 initialize_git_fixture(&root); 2326 write(&root, r"tracked\file.txt", "literal backslash\n"); 2327 write(&root, "tracked/file.txt", "path separator\n"); 2328 add_git_fixture_path(&root, Path::new(r"tracked\file.txt")); 2329 add_git_fixture_path(&root, Path::new("tracked/file.txt")); 2330 2331 let inventory = Inventory::load(&root).expect("distinct Git paths must remain distinct"); 2332 assert!(inventory.paths.contains(&r"tracked\file.txt".to_owned())); 2333 assert!(inventory.paths.contains(&"tracked/file.txt".to_owned())); 2334 fs::remove_dir_all(root).expect("remove fixture"); 2335 } 2336 2337 #[test] 2338 fn git_inventory_rejects_a_missing_leaf() { 2339 let root = fixture("git-inventory-missing-leaf"); 2340 initialize_git_fixture(&root); 2341 write(&root, "tracked/file.txt", "tracked\n"); 2342 add_git_fixture_path(&root, Path::new("tracked/file.txt")); 2343 fs::remove_file(root.join("tracked/file.txt")).expect("remove tracked file"); 2344 2345 let error = Inventory::load(&root).expect_err("missing inventory leaf must fail closed"); 2346 assert!(error.contains("Git inventory path is missing")); 2347 fs::remove_dir_all(root).expect("remove fixture"); 2348 } 2349 2350 #[test] 2351 fn git_inventory_rejects_a_directory_leaf() { 2352 let root = fixture("git-inventory-directory-leaf"); 2353 initialize_git_fixture(&root); 2354 write(&root, "tracked/file.txt", "tracked\n"); 2355 add_git_fixture_path(&root, Path::new("tracked/file.txt")); 2356 fs::remove_file(root.join("tracked/file.txt")).expect("remove tracked file"); 2357 fs::create_dir(root.join("tracked/file.txt")).expect("create directory leaf"); 2358 2359 let error = Inventory::load(&root).expect_err("directory inventory leaf must fail closed"); 2360 assert!(error.contains("Git inventory path is not a regular file")); 2361 fs::remove_dir_all(root).expect("remove fixture"); 2362 } 2363 2364 #[cfg(unix)] 2365 #[test] 2366 fn git_inventory_rejects_a_fifo_leaf_without_opening_it() { 2367 let root = fixture("git-inventory-fifo-leaf"); 2368 initialize_git_fixture(&root); 2369 write(&root, "tracked/file.txt", "tracked\n"); 2370 add_git_fixture_path(&root, Path::new("tracked/file.txt")); 2371 fs::remove_file(root.join("tracked/file.txt")).expect("remove tracked file"); 2372 assert!( 2373 ProcessCommand::new("mkfifo") 2374 .arg(root.join("tracked/file.txt")) 2375 .status() 2376 .expect("create FIFO leaf") 2377 .success() 2378 ); 2379 2380 let error = Inventory::load(&root).expect_err("FIFO inventory leaf must fail closed"); 2381 assert!(error.contains("Git inventory path is not a regular file")); 2382 fs::remove_dir_all(root).expect("remove fixture"); 2383 } 2384 2385 #[test] 2386 fn mutable_git_dependency_and_provenance_mutation_fail_closed() { 2387 let root = fixture("provenance"); 2388 write( 2389 &root, 2390 "core/deny.toml", 2391 "required-git-spec = \"rev\"\nallow-git = [\"https://example.invalid/lib\"]\n", 2392 ); 2393 write( 2394 &root, 2395 "core/Cargo.toml", 2396 "[dependencies]\nlib = { git = \"https://example.invalid/lib\", branch = \"main\" }\n", 2397 ); 2398 write(&root, "core/Cargo.lock", ""); 2399 let mut findings = Vec::new(); 2400 git_source_policy(&root, &mut findings); 2401 assert!( 2402 findings 2403 .iter() 2404 .any(|finding| finding.contains("branch or tag")) 2405 ); 2406 assert!( 2407 findings 2408 .iter() 2409 .any(|finding| finding.contains("full revision pin")) 2410 ); 2411 2412 findings.clear(); 2413 let inventory = Inventory::load(&root).expect("archive inventory"); 2414 provenance_check(&root, &inventory, &mut findings); 2415 assert!( 2416 findings 2417 .iter() 2418 .any(|finding| finding.contains("exact source provenance changed")) 2419 ); 2420 assert!( 2421 findings 2422 .iter() 2423 .any(|finding| finding.contains("exact Lib source lock changed")) 2424 ); 2425 fs::remove_dir_all(root).expect("remove fixture"); 2426 } 2427 2428 #[test] 2429 fn source_lock_digest_rejects_actual_byte_mismatch() { 2430 let root = fixture("source-lock-digest"); 2431 write( 2432 &root, 2433 "radroots.lib.source-lock.v1.toml", 2434 concat!( 2435 "schema = \"radroots.lib.source-lock.v1\"\n", 2436 "repository = \"https://github.com/radrootslabs/lib\"\n", 2437 "revision = \"189c49b74b4bafc142b00b76b296477931139e72\"\n", 2438 "architecture = \"radroots.crates.release.v2\"\n", 2439 "workspace_catalog_sha256 = \"ee295f2352e2577a4052d980624415aec9871197d4fc9910a4c21c83a9179200\"\n", 2440 "version = \"0.1.0-alpha\"\n", 2441 "source_archive_sha256 = \"c648a3ab993d10253b9073e7e86db7b8970863bdf1d394d9fc30d66825695240\"\n", 2442 "lockfile = \"core/Cargo.lock\"\n", 2443 "lockfile_sha256 = \"6a32d1de0105771158647c65116fe797dfa7db7c42db515c63047d6601b846a3\"\n", 2444 ), 2445 ); 2446 write(&root, "core/Cargo.toml", ""); 2447 write(&root, "core/Cargo.lock", "version = 3\n"); 2448 let inventory = Inventory::load(&root).expect("source-lock inventory"); 2449 let mut findings = Vec::new(); 2450 provenance_check(&root, &inventory, &mut findings); 2451 assert!(findings.iter().any(|finding| { 2452 finding.contains("lockfile_sha256 does not match actual bounded no-follow bytes") 2453 })); 2454 fs::remove_dir_all(root).expect("remove fixture"); 2455 } 2456 2457 #[cfg(unix)] 2458 #[test] 2459 fn bounded_source_lock_reads_reject_final_and_intermediate_symlinks() { 2460 use std::os::unix::fs::symlink; 2461 2462 let root = fixture("source-lock-symlinks"); 2463 write(&root, "actual/Cargo.lock", "version = 4\n"); 2464 symlink(root.join("actual/Cargo.lock"), root.join("final.lock")) 2465 .expect("create final symlink"); 2466 assert!( 2467 bounded_no_follow_bytes(&root, Path::new("final.lock"), 1024) 2468 .expect_err("final symlink must fail") 2469 .contains("symbolic link") 2470 ); 2471 2472 symlink(root.join("actual"), root.join("core")).expect("create intermediate symlink"); 2473 assert!( 2474 bounded_no_follow_bytes(&root, Path::new("core/Cargo.lock"), 1024) 2475 .expect_err("intermediate symlink must fail") 2476 .contains("symbolic link") 2477 ); 2478 assert_eq!( 2479 bounded_no_follow_bytes(&root, Path::new("actual/Cargo.lock"), 1024) 2480 .expect("regular bounded file"), 2481 b"version = 4\n" 2482 ); 2483 assert!( 2484 bounded_no_follow_bytes(&root, Path::new("actual/Cargo.lock"), 1) 2485 .expect_err("oversize file must fail") 2486 .contains("byte limit") 2487 ); 2488 fs::remove_dir_all(root).expect("remove fixture"); 2489 } 2490 2491 fn fixture(name: &str) -> PathBuf { 2492 let nonce = SystemTime::now() 2493 .duration_since(UNIX_EPOCH) 2494 .expect("clock") 2495 .as_nanos(); 2496 let root = std::env::temp_dir().join(format!( 2497 "harvestcircle-xtask-{name}-{}-{nonce}", 2498 std::process::id() 2499 )); 2500 fs::create_dir_all(&root).expect("create fixture"); 2501 root 2502 } 2503 2504 fn initialize_git_fixture(root: &Path) { 2505 assert!( 2506 ProcessCommand::new("git") 2507 .arg("-C") 2508 .arg(root) 2509 .args(["init", "--quiet"]) 2510 .status() 2511 .expect("initialize Git fixture") 2512 .success() 2513 ); 2514 } 2515 2516 fn add_git_fixture_path(root: &Path, relative: &Path) { 2517 assert!( 2518 ProcessCommand::new("git") 2519 .arg("-C") 2520 .arg(root) 2521 .args(["add", "--"]) 2522 .arg(relative) 2523 .status() 2524 .expect("index tracked fixture") 2525 .success() 2526 ); 2527 } 2528 2529 fn write(root: &Path, relative: &str, source: &str) { 2530 let path = root.join(relative); 2531 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent"); 2532 fs::write(path, source).expect("write fixture"); 2533 } 2534 }