//! 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<String>,
interpreter_abi: impl Into<String>,
runner_hash: impl Into<String>,
skill_revision: impl Into<String>,
dependency_lock_hash: impl Into<String>,
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<String>,
pub capabilities: Vec<String>,
pub unavailable_capabilities: Vec<String>,
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<Vec<u8>> {
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<Self> {
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());
}
}