use std::path::Path;
use anyhow::Context;
use astrid_core::PrincipalId;
use astrid_core::kernel_api::{
AdminRequestKind, AdminResponseBody, DistroCapsuleProvenance, DistroProvenance,
};
use serde::{Deserialize, Serialize};
use super::manifest::DistroManifest;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct DistroLock {
pub(crate) schema_version: u32,
pub(crate) distro: DistroLockMeta,
#[serde(default, rename = "capsule")]
pub(crate) capsules: Vec<LockedCapsule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) manifest_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct DistroLockMeta {
pub(crate) id: String,
pub(crate) version: String,
pub(crate) resolved_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct LockedCapsule {
pub(crate) name: String,
pub(crate) version: String,
pub(crate) source: String,
pub(crate) hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) resolved_ref: Option<String>,
}
pub(crate) fn load_lock(path: &Path) -> anyhow::Result<Option<DistroLock>> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())),
};
let lock: DistroLock = toml::from_str(&content).context("failed to parse Distro.lock")?;
Ok(Some(lock))
}
pub(crate) fn write_lock(path: &Path, lock: &DistroLock) -> anyhow::Result<()> {
let content = toml::to_string_pretty(lock).context("failed to serialize Distro.lock")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap_or(Path::new(".")))
.context("failed to create temp file for Distro.lock")?;
std::io::Write::write_all(&mut tmp, content.as_bytes())
.context("failed to write Distro.lock staging")?;
tmp.persist(path)
.map_err(|e| anyhow::anyhow!("failed to persist {}: {e}", path.display()))?;
Ok(())
}
pub(crate) fn to_provenance(lock: &DistroLock) -> DistroProvenance {
DistroProvenance {
schema_version: lock.schema_version,
distro_id: lock.distro.id.clone(),
distro_version: lock.distro.version.clone(),
resolved_at: lock.distro.resolved_at.clone(),
capsules: lock
.capsules
.iter()
.map(|capsule| DistroCapsuleProvenance {
name: capsule.name.clone(),
version: capsule.version.clone(),
source: capsule.source.clone(),
hash: capsule.hash.clone(),
resolved_ref: capsule.resolved_ref.clone(),
})
.collect(),
manifest_hash: lock.manifest_hash.clone(),
}
}
pub(crate) fn from_provenance(provenance: DistroProvenance) -> DistroLock {
DistroLock {
schema_version: provenance.schema_version,
distro: DistroLockMeta {
id: provenance.distro_id,
version: provenance.distro_version,
resolved_at: provenance.resolved_at,
},
capsules: provenance
.capsules
.into_iter()
.map(|capsule| LockedCapsule {
name: capsule.name,
version: capsule.version,
source: capsule.source,
hash: capsule.hash,
resolved_ref: capsule.resolved_ref,
})
.collect(),
manifest_hash: provenance.manifest_hash,
}
}
pub(crate) fn provenance_digest(provenance: &DistroProvenance) -> anyhow::Result<String> {
let bytes = serde_json::to_vec(provenance).context("encode distro provenance")?;
Ok(format!("blake3:{}", blake3::hash(&bytes).to_hex()))
}
pub(crate) async fn load_lock_from_daemon(
principal: &PrincipalId,
) -> anyhow::Result<Option<DistroLock>> {
let mut client = crate::admin_client::connect_as_active_agent().await?;
let response = client
.request(AdminRequestKind::DistroLockGet {
principal: principal.clone(),
})
.await?;
match response {
AdminResponseBody::DistroLock(lock) => Ok((*lock).map(from_provenance)),
AdminResponseBody::Error(error) => Err(anyhow::anyhow!(error)),
other => Err(anyhow::anyhow!(
"unexpected distro lock response: {other:?}"
)),
}
}
pub(crate) async fn write_lock_to_daemon(
principal: &PrincipalId,
lock: &DistroLock,
) -> anyhow::Result<()> {
let mut client = crate::admin_client::connect_as_active_agent().await?;
let current = client
.request(AdminRequestKind::DistroLockGet {
principal: principal.clone(),
})
.await?;
let current = match current {
AdminResponseBody::DistroLock(lock) => *lock,
AdminResponseBody::Error(error) => return Err(anyhow::anyhow!(error)),
other => {
return Err(anyhow::anyhow!(
"unexpected distro lock response: {other:?}"
));
},
};
let expected_hash = current.as_ref().map(provenance_digest).transpose()?;
let response = client
.request(AdminRequestKind::DistroLockSet {
principal: principal.clone(),
lock: to_provenance(lock),
expected_hash,
})
.await?;
match response {
AdminResponseBody::Success(_) => Ok(()),
AdminResponseBody::Error(error) => Err(anyhow::anyhow!(error)),
other => Err(anyhow::anyhow!(
"unexpected distro lock response: {other:?}"
)),
}
}
pub(crate) fn is_lock_fresh(
lock: &DistroLock,
manifest: &DistroManifest,
manifest_hash: &str,
) -> bool {
lock.distro.id == manifest.distro.id
&& lock.distro.version == manifest.distro.version
&& lock.manifest_hash.as_deref() == Some(manifest_hash)
&& lock.capsules.len() == manifest.capsules.len()
&& manifest.capsules.iter().all(|declared| {
lock.capsules.iter().any(|locked| {
locked.name == declared.name
&& locked.version == declared.version
&& locked.source == declared.source
})
})
}
pub(crate) fn create_lock(
manifest: &DistroManifest,
manifest_hash: &str,
capsules: Vec<LockedCapsule>,
) -> DistroLock {
DistroLock {
schema_version: manifest.schema_version,
distro: DistroLockMeta {
id: manifest.distro.id.clone(),
version: manifest.distro.version.clone(),
resolved_at: chrono::Utc::now().to_rfc3339(),
},
capsules,
manifest_hash: Some(manifest_hash.to_string()),
}
}
pub(crate) fn manifest_hash(toml_bytes: &[u8]) -> String {
format!("blake3:{}", blake3::hash(toml_bytes).to_hex())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_and_load_lock_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("distro.lock");
let lock = DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "test".into(),
version: "0.1.0".into(),
resolved_at: "2026-03-21T14:30:00Z".into(),
},
capsules: vec![LockedCapsule {
name: "astrid-capsule-cli".into(),
version: "0.1.0".into(),
source: "@example-org/capsule-cli".into(),
hash: "blake3:abc123".into(),
resolved_ref: Some("v0.1.0".into()),
}],
manifest_hash: Some("blake3:deadbeef".into()),
};
write_lock(&path, &lock).unwrap();
let loaded = load_lock(&path).unwrap().expect("lock should exist");
assert_eq!(loaded.schema_version, 1);
assert_eq!(loaded.distro.id, "test");
assert_eq!(loaded.distro.version, "0.1.0");
assert_eq!(loaded.capsules.len(), 1);
assert_eq!(loaded.capsules[0].hash, "blake3:abc123");
assert_eq!(loaded.capsules[0].resolved_ref.as_deref(), Some("v0.1.0"));
assert_eq!(loaded.manifest_hash.as_deref(), Some("blake3:deadbeef"));
}
#[test]
fn manifest_hash_is_stable_and_prefixed() {
let bytes = b"schema-version = 1\n";
let h1 = manifest_hash(bytes);
let h2 = manifest_hash(bytes);
assert_eq!(h1, h2);
assert!(h1.starts_with("blake3:"));
assert_ne!(manifest_hash(bytes), manifest_hash(b"different"));
}
#[test]
fn load_lock_returns_none_when_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.lock");
assert!(load_lock(&path).unwrap().is_none());
}
#[test]
fn is_lock_fresh_matches() {
let manifest = super::super::manifest::parse_manifest(
r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
"#,
)
.unwrap();
let lock = DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "test".into(),
version: "0.1.0".into(),
resolved_at: "2026-01-01T00:00:00Z".into(),
},
capsules: vec![],
manifest_hash: None,
};
assert!(!is_lock_fresh(&lock, &manifest, "blake3:manifest"));
}
#[test]
fn is_lock_stale_on_version_mismatch() {
let manifest = super::super::manifest::parse_manifest(
r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.2.0"
[[capsule]]
name = "cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
"#,
)
.unwrap();
let lock = DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "test".into(),
version: "0.1.0".into(),
resolved_at: "2026-01-01T00:00:00Z".into(),
},
capsules: vec![],
manifest_hash: None,
};
assert!(!is_lock_fresh(&lock, &manifest, "blake3:manifest"));
}
#[test]
fn lock_is_fresh_only_for_matching_manifest_bytes() {
let manifest = super::super::manifest::parse_manifest(
r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
"#,
)
.unwrap();
let bytes = b"schema-version = 1\n";
let hash = manifest_hash(bytes);
let mut lock = create_lock(
&manifest,
&hash,
vec![LockedCapsule {
name: "cli".into(),
version: "0.1.0".into(),
source: "@org/cli".into(),
hash: String::new(),
resolved_ref: None,
}],
);
assert_eq!(lock.manifest_hash.as_deref(), Some(hash.as_str()));
assert!(is_lock_fresh(&lock, &manifest, &hash));
lock.manifest_hash = Some("blake3:tampered".into());
assert!(!is_lock_fresh(&lock, &manifest, &hash));
}
#[test]
fn partial_lock_is_never_fresh_even_when_distro_hash_matches() {
let manifest = super::super::manifest::parse_manifest(
r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
[[capsule]]
name = "worker"
source = "@org/worker"
version = "0.1.0"
"#,
)
.unwrap();
let hash = manifest_hash(b"manifest");
let lock = create_lock(
&manifest,
&hash,
vec![LockedCapsule {
name: "cli".into(),
version: "0.1.0".into(),
source: "@org/cli".into(),
hash: String::new(),
resolved_ref: None,
}],
);
assert!(!is_lock_fresh(&lock, &manifest, &hash));
}
#[test]
fn daemon_provenance_roundtrip_preserves_lock_identity() {
let lock = DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "example-distro".into(),
version: "1.2.3".into(),
resolved_at: "2026-01-01T00:00:00Z".into(),
},
capsules: vec![LockedCapsule {
name: "cli".into(),
version: "2.0.0".into(),
source: "https://example.invalid/cli.capsule".into(),
hash: format!("blake3:{}", "a".repeat(64)),
resolved_ref: Some("v2.0.0".into()),
}],
manifest_hash: Some(format!("blake3:{}", "b".repeat(64))),
};
let provenance = to_provenance(&lock);
assert_eq!(from_provenance(provenance.clone()), lock);
assert!(
provenance_digest(&provenance)
.expect("digest")
.starts_with("blake3:")
);
}
}