use std::path::Path;
use anyhow::{Context, bail};
use astrid_capsule::capsule::CapsuleId;
use astrid_core::dirs::AstridHome;
use super::lock::{DistroLock, DistroLockMeta, LockedCapsule, manifest_hash, write_lock};
use super::manifest::parse_manifest;
use super::{shuttle, trust};
use crate::commands::init::InitOpts;
use crate::theme::Theme;
#[allow(
clippy::too_many_lines,
reason = "intentional linear unpack→verify→install→lock pipeline; \
the security ordering is clearer kept in one place"
)]
pub(crate) fn install_from_shuttle(shuttle_path: &Path, opts: &InitOpts) -> anyhow::Result<()> {
let home = AstridHome::resolve()?;
home.ensure()?;
if !shuttle_path.is_file() {
bail!("shuttle archive not found: {}", shuttle_path.display());
}
let mirror_tmp = tempfile::tempdir().context("failed to create shuttle mirror dir")?;
let mirror = mirror_tmp.path();
shuttle::unpack(shuttle_path, mirror)?;
let manifest_bytes = std::fs::read(mirror.join(shuttle::MANIFEST_NAME))
.context("shuttle is missing Distro.toml")?;
let manifest_text =
std::str::from_utf8(&manifest_bytes).context("Distro.toml is not valid UTF-8")?;
let manifest = parse_manifest(manifest_text)?;
let lock_text = std::fs::read_to_string(mirror.join(shuttle::LOCK_NAME))
.context("shuttle is missing Distro.lock")?;
let lock: DistroLock =
toml::from_str(&lock_text).context("failed to parse Distro.lock from shuttle")?;
let sig = match std::fs::read_to_string(mirror.join(shuttle::SIG_NAME)) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(e).context("failed to read Distro.sig"),
};
let distro_id = manifest.distro.id.clone();
let (signer, signature) =
if let (Some(signing), Some(sig_hex)) = (&manifest.distro.signing, &sig) {
let outcome = trust::verify_and_pin(
&home,
&distro_id,
&signing.pubkey,
sig_hex,
&lock,
opts.accept_new_key,
)?;
report_trust(&outcome);
(Some(outcome.key_str), Some(sig_hex.trim().to_string()))
} else {
if !opts.allow_unsigned {
bail!(
"shuttle for '{distro_id}' is unsigned (no [distro.signing] or Distro.sig) — \
refusing. Re-run with --allow-unsigned to install anyway."
);
}
eprintln!(
"{}",
Theme::warning(&format!(
"installing UNSIGNED distro '{distro_id}' (--allow-unsigned)"
))
);
(None, None)
};
check_manifest_binding(&distro_id, signer.is_some(), &lock, &manifest_bytes)?;
eprintln!(
"{}",
Theme::header(&format!(
"Installing {} {} (offline)",
manifest
.distro
.pretty_name
.as_deref()
.unwrap_or(&manifest.distro.name),
manifest.distro.version,
))
);
verify_capsule_hashes(mirror, &lock)?;
let principal = opts.target_principal.clone();
let variables = manifest.variables.clone();
let selected = crate::commands::init::select_capsules(manifest.capsules.clone(), opts.yes)?;
let vars =
crate::commands::init::collect_variables(&variables, &selected, opts.yes, &opts.vars)?;
crate::commands::init::write_env_files(&home, &principal, &selected, &vars)?;
let sealed_capsules: std::collections::HashMap<&str, &LockedCapsule> =
lock.capsules.iter().map(|c| (c.name.as_str(), c)).collect();
let locked = install_selected_capsules(
&home,
&principal,
mirror,
&selected,
&sealed_capsules,
signer.as_deref(),
signature.as_deref(),
)?;
let lock_path = home
.principal_home(&principal)
.config_dir()
.join("distro.lock");
let user_lock = DistroLock {
schema_version: manifest.schema_version,
distro: DistroLockMeta {
id: distro_id,
version: manifest.distro.version,
resolved_at: chrono::Utc::now().to_rfc3339(),
},
capsules: locked,
manifest_hash: lock.manifest_hash,
};
write_lock(&lock_path, &user_lock)?;
eprintln!();
eprintln!("{}", Theme::success("Offline installation complete."));
Ok(())
}
fn install_selected_capsules(
home: &AstridHome,
principal: &astrid_core::PrincipalId,
mirror: &Path,
selected: &[super::manifest::DistroCapsule],
sealed_capsules: &std::collections::HashMap<&str, &LockedCapsule>,
signer: Option<&str>,
signature: Option<&str>,
) -> anyhow::Result<Vec<LockedCapsule>> {
let mut locked: Vec<LockedCapsule> = Vec::with_capacity(selected.len());
for cap in selected {
let file = shuttle::capsule_mirror_path(mirror, &cap.name);
if !file.is_file() {
bail!(
"capsule '{}' is not present in the shuttle (offline install cannot fetch it)",
cap.name
);
}
let sealed = sealed_capsules.get(cap.name.as_str());
let resolved_ref = sealed.and_then(|c| c.resolved_ref.clone());
let expected = CapsuleId::new(cap.name.clone())?;
let expected_version = (!cap.version.trim().is_empty()).then_some(cap.version.trim());
let installed = crate::commands::capsule::install::install_offline_capsule(
&file,
home,
&expected,
expected_version,
crate::commands::capsule::install::OfflineCapsuleProvenance {
original_source: &cap.source,
resolved_ref: resolved_ref.as_deref(),
signer,
signature,
},
principal,
)
.with_context(|| format!("failed to install capsule {}", cap.name))?;
locked.push(LockedCapsule {
name: cap.name.clone(),
version: installed.version,
source: cap.source.clone(),
hash: installed
.wasm_hash
.map(|hash| format!("blake3:{hash}"))
.unwrap_or_default(),
resolved_ref,
});
eprintln!(" installed {}", cap.name);
}
Ok(locked)
}
fn check_manifest_binding(
distro_id: &str,
signed: bool,
lock: &DistroLock,
manifest_bytes: &[u8],
) -> anyhow::Result<()> {
let actual = manifest_hash(manifest_bytes);
match &lock.manifest_hash {
Some(recorded) => {
if recorded != &actual {
bail!(
"manifest hash mismatch: lock records {recorded}, archive Distro.toml hashes \
to {actual} — the shuttle is inconsistent or tampered"
);
}
Ok(())
},
None if signed => bail!(
"signed shuttle for '{distro_id}' is missing its manifest_hash binding — refusing. \
The signature covers the lock, not Distro.toml; without manifest_hash the manifest \
(env/selection) is unauthenticated and could be swapped."
),
None => Ok(()),
}
}
fn verify_capsule_hashes(mirror: &Path, lock: &DistroLock) -> anyhow::Result<()> {
for entry in &lock.capsules {
let file = shuttle::capsule_mirror_path(mirror, &entry.name);
if !file.is_file() {
bail!(
"capsule '{}' is missing from the shuttle mirror",
entry.name
);
}
let bytes = std::fs::read(&file)
.with_context(|| format!("failed to read mirrored capsule {}", entry.name))?;
let actual = format!("blake3:{}", blake3::hash(&bytes).to_hex());
if entry.hash != actual {
bail!(
"capsule '{}' hash mismatch: lock has {}, archive has {actual}",
entry.name,
entry.hash
);
}
}
Ok(())
}
fn report_trust(outcome: &trust::TrustOutcome) {
let msg = match outcome.action {
trust::TrustAction::PinnedMatch => {
format!("signature verified against pinned key {}", outcome.key_str)
},
trust::TrustAction::OfficialPinned => {
format!("verified and pinned official key {}", outcome.key_str)
},
trust::TrustAction::ToFuTrusted => format!(
"trusting key {} on first use — verify it out of band",
outcome.key_str
),
trust::TrustAction::NewKeyAccepted => {
format!(
"re-pinned to new key {} (--accept-new-key)",
outcome.key_str
)
},
};
eprintln!("{}", Theme::info(&msg));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::distro::sign;
use astrid_crypto::KeyPair;
fn make_signed_shuttle(dir: &Path, capsule_bytes: &[u8]) -> (std::path::PathBuf, KeyPair) {
let kp = KeyPair::generate();
let pubkey = sign::pubkey_to_wire(&kp.export_public_key());
let manifest = format!(
"schema-version = 1\n\n\
[distro]\nid = \"test\"\nname = \"Test\"\nversion = \"0.1.0\"\n\n\
[distro.signing]\npubkey = \"{pubkey}\"\n\n\
[[capsule]]\nname = \"astrid-capsule-cli\"\nsource = \"@org/cli\"\n\
version = \"0.1.0\"\nrole = \"uplink\"\n"
);
let manifest_bytes = manifest.into_bytes();
let cap_hash = format!("blake3:{}", blake3::hash(capsule_bytes).to_hex());
let lock = DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "test".into(),
version: "0.1.0".into(),
resolved_at: "1970-01-01T00:00:00+00:00".into(),
},
capsules: vec![LockedCapsule {
name: "astrid-capsule-cli".into(),
version: "0.1.0".into(),
source: "@org/cli".into(),
hash: cap_hash,
resolved_ref: Some("v0.1.0".into()),
}],
manifest_hash: Some(manifest_hash(&manifest_bytes)),
};
let sig = sign::sign_lock(&lock, &kp).unwrap();
let lock_toml = toml::to_string_pretty(&lock).unwrap();
let entries = vec![
shuttle::ShuttleEntry {
path: shuttle::MANIFEST_NAME.into(),
content: shuttle::ShuttleContent::Bytes(manifest_bytes),
},
shuttle::ShuttleEntry {
path: shuttle::LOCK_NAME.into(),
content: shuttle::ShuttleContent::Bytes(lock_toml.into_bytes()),
},
shuttle::ShuttleEntry {
path: shuttle::SIG_NAME.into(),
content: shuttle::ShuttleContent::Bytes(sig.into_bytes()),
},
shuttle::ShuttleEntry {
path: shuttle::capsule_member_path("astrid-capsule-cli"),
content: shuttle::ShuttleContent::Bytes(capsule_bytes.to_vec()),
},
];
let out = dir.join("test.shuttle");
shuttle::pack(&out, entries).unwrap();
(out, kp)
}
fn load_mirror(shuttle_path: &Path, dir: &Path) -> (DistroLock, std::path::PathBuf) {
let mirror = dir.join("mirror");
shuttle::unpack(shuttle_path, &mirror).unwrap();
let lock_text = std::fs::read_to_string(mirror.join(shuttle::LOCK_NAME)).unwrap();
let lock: DistroLock = toml::from_str(&lock_text).unwrap();
(lock, mirror)
}
#[test]
fn valid_shuttle_passes_all_gates() {
let dir = tempfile::tempdir().unwrap();
let (shuttle_path, kp) = make_signed_shuttle(dir.path(), b"FAKE CAPSULE");
let (lock, mirror) = load_mirror(&shuttle_path, dir.path());
let manifest_bytes = std::fs::read(mirror.join(shuttle::MANIFEST_NAME)).unwrap();
assert_eq!(
lock.manifest_hash.as_deref().unwrap(),
manifest_hash(&manifest_bytes)
);
let sig = std::fs::read_to_string(mirror.join(shuttle::SIG_NAME)).unwrap();
assert!(sign::verify_lock(&lock, &sig, &kp.export_public_key()).is_ok());
assert!(verify_capsule_hashes(&mirror, &lock).is_ok());
}
#[test]
fn capsule_hash_mismatch_is_detected() {
let dir = tempfile::tempdir().unwrap();
let (shuttle_path, _kp) = make_signed_shuttle(dir.path(), b"FAKE CAPSULE");
let (lock, mirror) = load_mirror(&shuttle_path, dir.path());
std::fs::write(
shuttle::capsule_mirror_path(&mirror, "astrid-capsule-cli"),
b"TAMPERED",
)
.unwrap();
let err = verify_capsule_hashes(&mirror, &lock).unwrap_err();
assert!(err.to_string().contains("hash mismatch"), "got: {err}");
}
#[test]
fn missing_capsule_in_mirror_is_detected() {
let dir = tempfile::tempdir().unwrap();
let (shuttle_path, _kp) = make_signed_shuttle(dir.path(), b"FAKE CAPSULE");
let (lock, mirror) = load_mirror(&shuttle_path, dir.path());
std::fs::remove_file(shuttle::capsule_mirror_path(&mirror, "astrid-capsule-cli")).unwrap();
let err = verify_capsule_hashes(&mirror, &lock).unwrap_err();
assert!(err.to_string().contains("missing"), "got: {err}");
}
#[test]
fn signature_fails_under_wrong_key() {
let dir = tempfile::tempdir().unwrap();
let (shuttle_path, _kp) = make_signed_shuttle(dir.path(), b"FAKE CAPSULE");
let (lock, mirror) = load_mirror(&shuttle_path, dir.path());
let sig = std::fs::read_to_string(mirror.join(shuttle::SIG_NAME)).unwrap();
let attacker = KeyPair::generate();
assert!(sign::verify_lock(&lock, &sig, &attacker.export_public_key()).is_err());
}
fn lock_with_manifest_hash(manifest_hash: Option<String>) -> DistroLock {
DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "test".into(),
version: "0.1.0".into(),
resolved_at: "1970-01-01T00:00:00+00:00".into(),
},
capsules: vec![],
manifest_hash,
}
}
#[test]
fn signed_shuttle_without_manifest_hash_hard_fails() {
let manifest_bytes = b"schema-version = 1\n";
let lock = lock_with_manifest_hash(None);
let err = check_manifest_binding("test", true, &lock, manifest_bytes).unwrap_err();
assert!(
err.to_string().contains("manifest_hash binding"),
"got: {err}"
);
}
#[test]
fn signed_shuttle_with_matching_manifest_hash_passes() {
let manifest_bytes = b"schema-version = 1\n";
let lock = lock_with_manifest_hash(Some(manifest_hash(manifest_bytes)));
assert!(check_manifest_binding("test", true, &lock, manifest_bytes).is_ok());
}
#[test]
fn signed_shuttle_with_wrong_manifest_hash_fails() {
let lock = lock_with_manifest_hash(Some(manifest_hash(b"original")));
let err = check_manifest_binding("test", true, &lock, b"TAMPERED").unwrap_err();
assert!(
err.to_string().contains("manifest hash mismatch"),
"got: {err}"
);
}
#[test]
fn unsigned_shuttle_without_manifest_hash_is_tolerated() {
let lock = lock_with_manifest_hash(None);
assert!(check_manifest_binding("test", false, &lock, b"anything").is_ok());
}
#[test]
fn unsigned_shuttle_with_present_manifest_hash_still_checked() {
let lock = lock_with_manifest_hash(Some(manifest_hash(b"original")));
let err = check_manifest_binding("test", false, &lock, b"TAMPERED").unwrap_err();
assert!(
err.to_string().contains("manifest hash mismatch"),
"got: {err}"
);
}
#[test]
fn offline_carries_sealed_resolved_ref_not_a_guess() {
let sealed = DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "test".into(),
version: "0.1.0".into(),
resolved_at: "1970-01-01T00:00:00+00:00".into(),
},
capsules: vec![LockedCapsule {
name: "astrid-capsule-cli".into(),
version: "0.1.0".into(),
source: "@org/cli".into(),
hash: "blake3:abc".into(),
resolved_ref: Some("v0.1.0-actually-resolved".into()),
}],
manifest_hash: Some("blake3:def".into()),
};
let sealed_capsules: std::collections::HashMap<&str, &LockedCapsule> = sealed
.capsules
.iter()
.map(|c| (c.name.as_str(), c))
.collect();
assert_eq!(
sealed_capsules
.get("astrid-capsule-cli")
.and_then(|c| c.resolved_ref.as_deref()),
Some("v0.1.0-actually-resolved"),
);
}
}