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 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}