//! Immutable remote bundle identity and readiness manifest. //! //! Section 7 of REMOTE-KERNELS.md: //! Key readiness on the complete relevant identity: //! runner/SDK/relay protocol, skill source revision, dependency lock, //! interpreter/ABI, target platform/native closure, and bootstrap recipe. //! Do not reuse a Mac environment on Linux. //! Secrets and transient session settings are not embedded in shared bundles. //! //! Reuses the content-addressing convention from `klbr-runtime/src/behavior.rs`: //! sorted POSIX names, u64BE length-prefixed name and content into SHA-256. use std::{ fs, path::{Path, PathBuf}, }; use anyhow::{ensure, Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::target::EnvironmentFingerprint; /// Complete specifications governing the remote execution environment. /// Any change to any of these fields produces a distinct candidate bundle. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BundleSpec { pub protocol_version: u32, pub target_platform: String, pub interpreter_abi: String, pub runner_hash: String, pub skill_revision: String, pub dependency_lock_hash: String, pub recipe_version: u32, } impl BundleSpec { pub fn new( protocol_version: u32, target_platform: impl Into, interpreter_abi: impl Into, runner_hash: impl Into, skill_revision: impl Into, dependency_lock_hash: impl Into, recipe_version: u32, ) -> Self { Self { protocol_version, target_platform: target_platform.into(), interpreter_abi: interpreter_abi.into(), runner_hash: runner_hash.into(), skill_revision: skill_revision.into(), dependency_lock_hash: dependency_lock_hash.into(), recipe_version, } } /// Computes the content-addressed bundle ID using the behavior.rs discipline. #[must_use] pub fn compute_bundle_id(&self) -> String { let mut entries = vec![ ( "dependency_lock_hash", self.dependency_lock_hash.as_bytes().to_vec(), ), ("interpreter_abi", self.interpreter_abi.as_bytes().to_vec()), ( "protocol_version", self.protocol_version.to_be_bytes().to_vec(), ), ("recipe_version", self.recipe_version.to_be_bytes().to_vec()), ("runner_hash", self.runner_hash.as_bytes().to_vec()), ("skill_revision", self.skill_revision.as_bytes().to_vec()), ("target_platform", self.target_platform.as_bytes().to_vec()), ]; entries.sort_by(|a, b| a.0.cmp(b.0)); let mut digest = Sha256::new(); for (name, content) in entries { digest.update((name.len() as u64).to_be_bytes()); digest.update(name.as_bytes()); digest.update((content.len() as u64).to_be_bytes()); digest.update(&content); } format!("bndl-{:x}", digest.finalize()) } /// Returns the environment fingerprint string for target identity binding. #[must_use] pub fn fingerprint(&self) -> EnvironmentFingerprint { EnvironmentFingerprint::parse(self.compute_bundle_id()) .expect("bundle id is valid identifier") } } /// Atomic readiness manifest stored as `manifest.json` inside a published bundle directory. /// /// Directory existence or a `.ready` marker file is explicitly NOT proof of readiness; /// only an atomically published, intact manifest passing integrity checks certifies readiness. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReadinessManifest { pub bundle_id: String, pub fingerprint: EnvironmentFingerprint, pub spec: BundleSpec, pub python_path: String, pub runner_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_path: Option, pub capabilities: Vec, pub unavailable_capabilities: Vec, pub created_ms: i64, pub verified_ms: i64, pub smoke_test_output: String, } impl ReadinessManifest { /// Validates that the manifest accurately reflects the requested specification, /// that the platform matches the target without cross-platform reuse, /// and that the declared executables and runners actually exist on disk. pub fn validate_integrity(&self, bundle_dir: &Path) -> Result<()> { let computed_id = self.spec.compute_bundle_id(); ensure!( self.bundle_id == computed_id, "manifest bundle_id mismatch: declared {}, computed {}", self.bundle_id, computed_id ); ensure!( self.fingerprint == self.spec.fingerprint(), "manifest fingerprint mismatch" ); let runner = PathBuf::from(&self.runner_path); let python = PathBuf::from(&self.python_path); ensure!( runner.exists() || bundle_dir.join(&runner).exists(), "bundle runner path does not exist: {}", self.runner_path ); ensure!( python.exists() || bundle_dir.join(&python).exists(), "bundle python interpreter path does not exist: {}", self.python_path ); Ok(()) } /// Checks if this manifest is compatible with a requested spec, /// explicitly forbidding cross-platform reuse (e.g. Darwin bundle on Linux). pub fn is_compatible_with(&self, requested_spec: &BundleSpec) -> bool { self.spec == *requested_spec } /// Serializes manifest to JSON bytes for atomic publication. pub fn to_bytes(&self) -> Result> { serde_json::to_vec_pretty(self).map_err(Into::into) } /// Reads and deserializes `manifest.json` from a candidate or published bundle path. pub fn load_from_dir(bundle_dir: &Path) -> Result { let manifest_path = bundle_dir.join("manifest.json"); ensure!( manifest_path.is_file(), "missing manifest.json in bundle directory: {}", bundle_dir.display() ); let bytes = fs::read(&manifest_path) .with_context(|| format!("cannot read {}", manifest_path.display()))?; let manifest: Self = serde_json::from_slice(&bytes) .with_context(|| format!("invalid JSON in {}", manifest_path.display()))?; manifest.validate_integrity(bundle_dir)?; Ok(manifest) } } #[cfg(test)] mod tests { use super::*; fn sample_spec() -> BundleSpec { BundleSpec::new( 1, "Darwin", "cpython-311", "runner_hash_abc", "skill_rev_123", "dep_lock_xyz", 1, ) } #[test] fn bundle_id_deterministic() { let s1 = sample_spec(); let s2 = sample_spec(); assert_eq!(s1.compute_bundle_id(), s2.compute_bundle_id()); assert!(s1.compute_bundle_id().starts_with("bndl-")); } #[test] fn platform_change_produces_distinct_bundle_id() { let s1 = sample_spec(); let mut s2 = sample_spec(); s2.target_platform = "Linux".to_string(); assert_ne!(s1.compute_bundle_id(), s2.compute_bundle_id()); } #[test] fn skill_change_produces_distinct_bundle_id() { let s1 = sample_spec(); let mut s2 = sample_spec(); s2.skill_revision = "skill_rev_456".to_string(); assert_ne!(s1.compute_bundle_id(), s2.compute_bundle_id()); } #[test] fn dependency_change_produces_distinct_bundle_id() { let s1 = sample_spec(); let mut s2 = sample_spec(); s2.dependency_lock_hash = "dep_lock_different".to_string(); assert_ne!(s1.compute_bundle_id(), s2.compute_bundle_id()); } #[test] fn manifest_validation_rejects_tampered_id() { let spec = sample_spec(); let manifest = ReadinessManifest { bundle_id: "bndl-tampered".into(), fingerprint: spec.fingerprint(), spec: spec.clone(), python_path: "/usr/bin/python3".into(), runner_path: "/tmp/runner.py".into(), relay_path: None, capabilities: vec!["runner".into()], unavailable_capabilities: vec!["dill".into()], created_ms: 1000, verified_ms: 1005, smoke_test_output: "ok".into(), }; assert!(manifest.validate_integrity(Path::new("/tmp")).is_err()); } }