Skip to main content

nex_pkg/
armory_store.rs

1use std::fs::File;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{bail, Context, Result};
7
8use crate::armory_lock::{self, LockedPackage, PackageLock};
9
10#[derive(Debug, Clone)]
11pub struct StoreRecord {
12    pub package_ref: String,
13    pub path: PathBuf,
14    pub verified: bool,
15}
16
17pub fn materialize_lock() -> Result<Vec<StoreRecord>> {
18    let mut lock = armory_lock::read_package_lock()?;
19    let records = materialize_package_lock(&mut lock)?;
20    armory_lock::write_package_lock(&lock)?;
21    if let Some(activation_lock) = armory_lock::activation_lock_for_package_lock(&lock)? {
22        armory_lock::write_activation_lock(&activation_lock)?;
23    }
24    Ok(records)
25}
26
27pub fn materialize_package_lock(lock: &mut PackageLock) -> Result<Vec<StoreRecord>> {
28    enforce_trust_policy(lock)?;
29    let mut records = Vec::new();
30    for package in &mut lock.packages {
31        let record = materialize_package(package)?;
32        package.path = Some(record.path.display().to_string());
33        package.verified = record.verified;
34        package.installed_at = Some(current_timestamp());
35        records.push(record);
36    }
37    Ok(records)
38}
39
40fn enforce_trust_policy(lock: &PackageLock) -> Result<()> {
41    // Phase 3 has digest verification but no signature/attestation contract yet.
42    // Signed registries therefore fail closed instead of pretending digest-only
43    // verification satisfies a signed-trust policy.
44    for registry in &lock.registries {
45        if registry
46            .trust
47            .as_deref()
48            .is_some_and(|trust| trust.eq_ignore_ascii_case("signed"))
49        {
50            bail!(
51                "registry {} requires signed packages, but signature verification is not implemented yet",
52                registry.name
53            );
54        }
55    }
56    Ok(())
57}
58
59fn materialize_package(package: &LockedPackage) -> Result<StoreRecord> {
60    let Some(oci_ref) = &package.oci_ref else {
61        bail!(
62            "package {} has no ociRef; cannot materialize in store phase",
63            package.package_ref
64        );
65    };
66    let Some(digest) = &package.digest else {
67        bail!(
68            "package {} has no digest; refusing unverified materialization",
69            package.package_ref
70        );
71    };
72
73    let path = match package.path.as_deref() {
74        Some(path) => PathBuf::from(path),
75        None => store_path_for_digest(digest)?,
76    };
77    if path.exists() {
78        let actual = compute_path_sha256(&path)?;
79        if actual != *digest {
80            bail!(
81                "digest mismatch for existing store path {}: lock declares {}, computed {}",
82                path.display(),
83                digest,
84                actual
85            );
86        }
87        validate_package(package, &path)?;
88        return Ok(StoreRecord {
89            package_ref: package.package_ref.clone(),
90            path,
91            verified: true,
92        });
93    }
94
95    std::fs::create_dir_all(&path)?;
96    fetch_oci(oci_ref, digest, &path)?;
97    let actual = compute_path_sha256(&path)?;
98    if actual != *digest {
99        let _ = std::fs::remove_dir_all(&path);
100        bail!(
101            "digest mismatch for {}: registry declared {}, fetched {}",
102            package.package_ref,
103            digest,
104            actual
105        );
106    }
107    validate_package(package, &path)?;
108
109    Ok(StoreRecord {
110        package_ref: package.package_ref.clone(),
111        path,
112        verified: true,
113    })
114}
115
116fn fetch_oci(oci_ref: &str, digest: &str, output: &Path) -> Result<()> {
117    let status = Command::new("oras")
118        .args(["pull", oci_ref, "--output"])
119        .arg(output)
120        .status()
121        .context("oras is required for Armory OCI materialization; install it with `brew install oras`, `nix profile install nixpkgs#oras`, or your system package manager")?;
122    if !status.success() {
123        bail!("oras pull failed for {oci_ref}");
124    }
125
126    if !digest.starts_with("sha256:") {
127        bail!("unsupported digest format {digest}; expected sha256:<hex>");
128    }
129    Ok(())
130}
131
132fn validate_package(package: &LockedPackage, path: &Path) -> Result<()> {
133    let kind = package
134        .package_ref
135        .split_once('/')
136        .map(|(kind, _)| kind)
137        .unwrap_or_default();
138    match kind {
139        "machine-profile" | "materialization-payload" => {
140            let report = crate::artifact::check_artifact_dir(path);
141            if !report.ok {
142                bail!(
143                    "artifact validation failed for {} at {}",
144                    package.package_ref,
145                    path.display()
146                );
147            }
148        }
149        "forge-template" => bail!(
150            "forge-template package validation is not supported yet for {}; refusing to mark installed",
151            package.package_ref
152        ),
153        _ => {}
154    }
155    Ok(())
156}
157
158fn compute_path_sha256(path: &Path) -> Result<String> {
159    if path.is_file() {
160        return compute_file_sha256(path);
161    }
162    if path.is_dir() {
163        let mut files = Vec::new();
164        collect_files(path, path, &mut files)?;
165        let mut content = String::new();
166        for (relative, digest) in files {
167            content.push_str(&digest);
168            content.push_str("  ");
169            content.push_str(&relative);
170            content.push('\n');
171        }
172        return Ok(format!("sha256:{}", sha256_hex(content.as_bytes())));
173    }
174    bail!(
175        "cannot compute digest for non-file/non-directory path {}",
176        path.display()
177    )
178}
179
180fn collect_files(root: &Path, path: &Path, files: &mut Vec<(String, String)>) -> Result<()> {
181    let mut entries = std::fs::read_dir(path)
182        .with_context(|| format!("reading {}", path.display()))?
183        .collect::<std::result::Result<Vec<_>, _>>()?;
184    entries.sort_by_key(|entry| entry.path());
185    for entry in entries {
186        let path = entry.path();
187        let metadata = entry.metadata()?;
188        if metadata.is_dir() {
189            collect_files(root, &path, files)?;
190        } else if metadata.is_file() {
191            let relative = path
192                .strip_prefix(root)?
193                .to_string_lossy()
194                .replace('\\', "/");
195            files.push((relative, compute_file_sha256(&path)?));
196        }
197    }
198    Ok(())
199}
200
201fn compute_file_sha256(path: &Path) -> Result<String> {
202    let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
203    let mut bytes = Vec::new();
204    file.read_to_end(&mut bytes)?;
205    Ok(format!("sha256:{}", sha256_hex(&bytes)))
206}
207
208fn sha256_hex(bytes: &[u8]) -> String {
209    use sha2::{Digest, Sha256};
210    let digest = Sha256::digest(bytes);
211    hex::encode(digest)
212}
213
214fn current_timestamp() -> String {
215    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
216        Ok(duration) => duration.as_secs().to_string(),
217        Err(_) => "0".to_string(),
218    }
219}
220
221pub fn store_path_for_digest(digest: &str) -> Result<PathBuf> {
222    let Some(hex) = digest.strip_prefix("sha256:") else {
223        bail!("unsupported digest format {digest}; expected sha256:<hex>");
224    };
225    if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
226        bail!("invalid sha256 digest {digest}");
227    }
228    Ok(store_dir()?.join(format!("sha256-{hex}")))
229}
230
231fn store_dir() -> Result<PathBuf> {
232    let home = std::env::var_os("HOME").context("HOME is not set")?;
233    Ok(PathBuf::from(home).join(".local/share/nex/store"))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::{compute_path_sha256, materialize_package_lock, store_path_for_digest};
239    use crate::armory_lock::{
240        LockedPackage, LockedRegistry, LockedRoot, PackageLock, PACKAGE_LOCK_SCHEMA,
241    };
242
243    #[test]
244    fn computes_content_addressed_store_path() {
245        let path = store_path_for_digest("sha256:abcdef").expect("store path");
246        assert!(path.ends_with(".local/share/nex/store/sha256-abcdef"));
247    }
248
249    #[test]
250    fn rejects_non_sha256_digest() {
251        let error = store_path_for_digest("sha512:abcdef").expect_err("digest rejected");
252        assert!(format!("{error:#}").contains("unsupported digest"));
253    }
254
255    #[test]
256    fn computes_file_sha256_digest() {
257        let dir = tempfile::tempdir().expect("temp dir");
258        let file = dir.path().join("payload.txt");
259        std::fs::write(&file, b"abc").expect("write payload");
260        assert_eq!(
261            compute_path_sha256(&file).expect("digest"),
262            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
263        );
264    }
265
266    #[test]
267    fn computes_directory_digest_from_sorted_file_digests() {
268        let dir = tempfile::tempdir().expect("temp dir");
269        std::fs::write(dir.path().join("b.txt"), b"b").expect("write b");
270        std::fs::write(dir.path().join("a.txt"), b"a").expect("write a");
271        let digest = compute_path_sha256(dir.path()).expect("digest");
272        assert!(digest.starts_with("sha256:"));
273    }
274
275    #[test]
276    fn materialization_updates_lock_entries() {
277        let dir = tempfile::tempdir().expect("temp dir");
278        let digest = compute_path_sha256(dir.path()).expect("digest");
279        let mut lock = PackageLock {
280            schema: PACKAGE_LOCK_SCHEMA.to_string(),
281            registries: vec![LockedRegistry {
282                name: "test".to_string(),
283                url: "https://example.test/index.json".to_string(),
284                trust: None,
285            }],
286            roots: vec![LockedRoot {
287                package_ref: "profile/root".to_string(),
288            }],
289            packages: vec![LockedPackage {
290                package_ref: "profile/root".to_string(),
291                version: Some("1.0.0".to_string()),
292                registry: "test".to_string(),
293                oci_ref: Some("oci://example/root".to_string()),
294                digest: Some(digest),
295                dependencies: Vec::new(),
296                path: Some(dir.path().display().to_string()),
297                verified: false,
298                installed_at: None,
299            }],
300        };
301
302        let records = materialize_package_lock(&mut lock).expect("materialize existing path");
303
304        assert_eq!(records.len(), 1);
305        assert!(lock.packages[0].verified);
306        assert!(lock.packages[0].installed_at.is_some());
307    }
308
309    #[test]
310    fn signed_registry_fails_closed_until_signature_verification_exists() {
311        let mut lock = PackageLock {
312            schema: PACKAGE_LOCK_SCHEMA.to_string(),
313            registries: vec![LockedRegistry {
314                name: "signed".to_string(),
315                url: "https://example.test/index.json".to_string(),
316                trust: Some("signed".to_string()),
317            }],
318            roots: vec![LockedRoot {
319                package_ref: "profile/root".to_string(),
320            }],
321            packages: Vec::new(),
322        };
323
324        let error = materialize_package_lock(&mut lock).expect_err("signed registry rejected");
325
326        assert!(format!("{error:#}").contains("signature verification is not implemented"));
327    }
328
329    #[test]
330    fn forge_template_validation_fails_closed() {
331        let dir = tempfile::tempdir().expect("temp dir");
332        let package = LockedPackage {
333            package_ref: "forge-template/base".to_string(),
334            version: Some("1.0.0".to_string()),
335            registry: "test".to_string(),
336            oci_ref: Some("oci://example/base".to_string()),
337            digest: Some(compute_path_sha256(dir.path()).expect("digest")),
338            dependencies: Vec::new(),
339            path: Some(dir.path().display().to_string()),
340            verified: false,
341            installed_at: None,
342        };
343
344        let error = super::validate_package(&package, dir.path()).expect_err("unsupported");
345
346        assert!(format!("{error:#}").contains("forge-template package validation is not supported"));
347    }
348}