diff --git a/org-ops-core/src/lib.rs b/org-ops-core/src/lib.rs index a3fb40a..22db738 100644 --- a/org-ops-core/src/lib.rs +++ b/org-ops-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod chronicle_client; pub mod config; pub mod delegation; pub mod git_hash; +pub mod manifest_loader; pub mod test_evidence; pub mod display; pub mod git_commands; diff --git a/org-ops-core/src/manifest_loader.rs b/org-ops-core/src/manifest_loader.rs new file mode 100644 index 0000000..b2e61e9 --- /dev/null +++ b/org-ops-core/src/manifest_loader.rs @@ -0,0 +1,299 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + +//! Manifest loader — reads verified manifest entries and filters them +//! by the session's ShellClass and delegation scope. +//! +//! Called at GSH session start to determine which binaries are authorized. +//! Reads the `manifest-{name}` ConfigMap output written by the +//! substrate-operator's manifest reconciler. + +use crate::session::SessionContext; +use crate::shell_class::ShellClass; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// A verified manifest entry (matches the reconciler's output format). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifiedEntry { + pub binary_name: String, + pub cid: String, + pub shell_class: String, + pub delegation_context: bool, + pub capability_mask: i64, + pub tier: String, +} + +/// A binary that was loaded (authorized for this session). +#[derive(Debug, Clone)] +pub struct LoadedBinary { + pub name: String, + pub cid: String, + pub shell_class: String, +} + +/// A binary that was excluded (not authorized for this session). +#[derive(Debug, Clone)] +pub struct ExcludedBinary { + pub name: String, + pub reason: String, +} + +/// Result of loading and filtering a manifest for a session. +#[derive(Debug)] +pub struct ManifestLoadResult { + pub loaded: Vec, + pub excluded: Vec, + pub manifest_cid: String, +} + +/// Load and filter manifest entries for the current session. +/// +/// Reads the manifest JSON (from ConfigMap mount or API), filters +/// entries by the session's ShellClass and delegation scope, and +/// optionally verifies on-disk binary hashes. +pub fn load_manifest( + ctx: &SessionContext, + manifest_json: &str, +) -> Result { + let entries: Vec = + serde_json::from_str(manifest_json).map_err(|e| format!("manifest parse error: {e}"))?; + + let manifest_cid = format!( + "sha256:{}", + hex::encode(Sha256::digest(manifest_json.as_bytes())) + ); + + let mut loaded = Vec::new(); + let mut excluded = Vec::new(); + + for entry in &entries { + // Filter by ShellClass + let entry_class = parse_shell_class(&entry.shell_class); + if !ctx.shell_class.satisfies(entry_class) { + excluded.push(ExcludedBinary { + name: entry.binary_name.clone(), + reason: format!("requires {} shell", entry.shell_class), + }); + continue; + } + + // Filter delegation-context binaries in non-delegation sessions + if entry.delegation_context && !ctx.delegation_scope.permitted { + excluded.push(ExcludedBinary { + name: entry.binary_name.clone(), + reason: "requires delegation authority".into(), + }); + continue; + } + + loaded.push(LoadedBinary { + name: entry.binary_name.clone(), + cid: entry.cid.clone(), + shell_class: entry.shell_class.clone(), + }); + } + + Ok(ManifestLoadResult { + loaded, + excluded, + manifest_cid, + }) +} + +/// Compute a CID (SHA-256) for a file on disk. +/// +/// Matches the `compute_cid()` format in gsap_client.rs. +pub fn compute_file_cid(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(&bytes)))) +} + +/// Verify that on-disk binaries match their manifest CIDs. +/// +/// Returns a list of mismatched entries. Empty = all verified. +pub fn verify_binary_hashes( + loaded: &[LoadedBinary], + bin_dir: &std::path::Path, +) -> Vec { + let mut mismatches = Vec::new(); + for binary in loaded { + let path = bin_dir.join(&binary.name); + match compute_file_cid(&path) { + Ok(on_disk_cid) => { + if on_disk_cid != binary.cid { + mismatches.push(ExcludedBinary { + name: binary.name.clone(), + reason: format!( + "hash mismatch: expected {}, found {}", + binary.cid, on_disk_cid + ), + }); + } + } + Err(reason) => { + mismatches.push(ExcludedBinary { + name: binary.name.clone(), + reason, + }); + } + } + } + mismatches +} + +fn parse_shell_class(s: &str) -> ShellClass { + match s { + "system" => ShellClass::System, + _ => ShellClass::Application, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::delegation::DelegationScope; + + fn ctx(shell_class: ShellClass, delegation_permitted: bool) -> SessionContext { + SessionContext { + org_name: "test".into(), + trust_domain: "test.io".into(), + bascule_endpoint: "localhost".into(), + shell_class, + posture_level: 5, + delegation_scope: DelegationScope { + permitted: delegation_permitted, + target_hosts: vec![], + max_delegated_class: ShellClass::System, + }, + } + } + + fn manifest_json() -> String { + serde_json::to_string(&vec![ + VerifiedEntry { + binary_name: "kubectl".into(), + cid: "sha256:aaa".into(), + shell_class: "application".into(), + delegation_context: false, + capability_mask: 1, + tier: "a".into(), + }, + VerifiedEntry { + binary_name: "firmware-update".into(), + cid: "sha256:bbb".into(), + shell_class: "system".into(), + delegation_context: false, + capability_mask: 7, + tier: "c".into(), + }, + VerifiedEntry { + binary_name: "ansible-playbook".into(), + cid: "sha256:ccc".into(), + shell_class: "application".into(), + delegation_context: true, + capability_mask: 3, + tier: "a".into(), + }, + ]) + .unwrap() + } + + #[test] + fn application_shell_excludes_system_binaries() { + let result = load_manifest(&ctx(ShellClass::Application, false), &manifest_json()).unwrap(); + assert_eq!(result.loaded.len(), 1); // only kubectl (ansible needs delegation) + assert_eq!(result.loaded[0].name, "kubectl"); + assert_eq!(result.excluded.len(), 2); + } + + #[test] + fn system_shell_loads_all_non_delegation() { + let result = load_manifest(&ctx(ShellClass::System, false), &manifest_json()).unwrap(); + // kubectl + firmware-update loaded; ansible excluded (delegation_context) + assert_eq!(result.loaded.len(), 2); + assert_eq!(result.excluded.len(), 1); + assert_eq!(result.excluded[0].name, "ansible-playbook"); + } + + #[test] + fn delegation_permitted_loads_delegation_binaries() { + let result = load_manifest(&ctx(ShellClass::Application, true), &manifest_json()).unwrap(); + // kubectl + ansible loaded; firmware-update excluded (system) + assert_eq!(result.loaded.len(), 2); + let names: Vec<&str> = result.loaded.iter().map(|b| b.name.as_str()).collect(); + assert!(names.contains(&"kubectl")); + assert!(names.contains(&"ansible-playbook")); + assert_eq!(result.excluded.len(), 1); + assert_eq!(result.excluded[0].name, "firmware-update"); + } + + #[test] + fn system_shell_with_delegation_loads_all() { + let result = load_manifest(&ctx(ShellClass::System, true), &manifest_json()).unwrap(); + assert_eq!(result.loaded.len(), 3); + assert_eq!(result.excluded.len(), 0); + } + + #[test] + fn empty_manifest_returns_empty() { + let result = load_manifest(&ctx(ShellClass::Application, false), "[]").unwrap(); + assert_eq!(result.loaded.len(), 0); + assert_eq!(result.excluded.len(), 0); + } + + #[test] + fn invalid_json_returns_error() { + let result = load_manifest(&ctx(ShellClass::Application, false), "not json"); + assert!(result.is_err()); + } + + #[test] + fn manifest_cid_is_computed() { + let result = load_manifest(&ctx(ShellClass::System, true), &manifest_json()).unwrap(); + assert!(result.manifest_cid.starts_with("sha256:")); + assert_eq!(result.manifest_cid.len(), 7 + 64); // "sha256:" + 64 hex chars + } + + #[test] + fn file_cid_computation() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test-binary"); + std::fs::write(&path, b"hello world").unwrap(); + let cid = compute_file_cid(&path).unwrap(); + assert!(cid.starts_with("sha256:")); + // SHA-256 of "hello world" is known + assert_eq!( + cid, + "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + #[test] + fn verify_hashes_detects_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test-bin"); + std::fs::write(&path, b"actual content").unwrap(); + + let loaded = vec![LoadedBinary { + name: "test-bin".into(), + cid: "sha256:wrong".into(), + shell_class: "application".into(), + }]; + let mismatches = verify_binary_hashes(&loaded, dir.path()); + assert_eq!(mismatches.len(), 1); + assert!(mismatches[0].reason.contains("hash mismatch")); + } + + #[test] + fn verify_hashes_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let loaded = vec![LoadedBinary { + name: "nonexistent".into(), + cid: "sha256:abc".into(), + shell_class: "application".into(), + }]; + let mismatches = verify_binary_hashes(&loaded, dir.path()); + assert_eq!(mismatches.len(), 1); + } +}