feat(org-ops): manifest loader with ShellClass filtering and CID verification
GSH manifest loader reads verified entries from the manifest-{name}
ConfigMap written by the substrate-operator reconciler:
- Filters entries by session ShellClass (System hidden in App shells)
- Filters delegation-context binaries in non-delegation sessions
- compute_file_cid() verifies on-disk binary hashes against CIDs
- verify_binary_hashes() detects tampering and missing binaries
- ManifestLoadResult reports loaded/excluded with reasons
10 unit tests covering:
- Application shell excludes system binaries
- System shell loads all non-delegation entries
- Delegation-permitted sessions load delegation binaries
- Empty/invalid manifest handling
- File CID computation (SHA-256, verified against known hash)
- Hash mismatch and missing file detection
Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
parent
c68456d745
commit
5c92c027fc
2 changed files with 300 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
299
org-ops-core/src/manifest_loader.rs
Normal file
299
org-ops-core/src/manifest_loader.rs
Normal file
|
|
@ -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<LoadedBinary>,
|
||||
pub excluded: Vec<ExcludedBinary>,
|
||||
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<ManifestLoadResult, String> {
|
||||
let entries: Vec<VerifiedEntry> =
|
||||
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<String, String> {
|
||||
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<ExcludedBinary> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue