1use serde::{Deserialize, Serialize};
23use tatara_lisp::DeriveTataraDomain;
24
25use crate::SpecError;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ParsedNar {
33 pub root: NarNode,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum NarNode {
39 File {
40 executable: bool,
41 contents: Vec<u8>,
42 },
43 Directory {
44 entries: Vec<(String, NarNode)>,
46 },
47 Symlink {
48 target: String,
49 },
50}
51
52impl NarNode {
53 pub fn walk<F: FnMut(&str, &NarNode)>(&self, visitor: &mut F) {
56 self.walk_with_prefix("", visitor);
57 }
58
59 fn walk_with_prefix<F: FnMut(&str, &NarNode)>(&self, prefix: &str, visitor: &mut F) {
60 visitor(prefix, self);
61 if let NarNode::Directory { entries } = self {
62 for (name, child) in entries {
63 let child_path = if prefix.is_empty() {
64 name.clone()
65 } else {
66 format!("{prefix}/{name}")
67 };
68 child.walk_with_prefix(&child_path, visitor);
69 }
70 }
71 }
72
73 #[must_use]
75 pub fn file_count(&self) -> usize {
76 let mut count = 0;
77 self.walk(&mut |_, n| {
78 if matches!(n, NarNode::File { .. }) {
79 count += 1;
80 }
81 });
82 count
83 }
84
85 #[must_use]
87 pub fn total_bytes(&self) -> u64 {
88 let mut total = 0u64;
89 self.walk(&mut |_, n| {
90 if let NarNode::File { contents, .. } = n {
91 total += contents.len() as u64;
92 }
93 });
94 total
95 }
96
97 #[must_use]
100 pub fn at_path(&self, path: &str) -> Option<&NarNode> {
101 if path.is_empty() {
102 return Some(self);
103 }
104 let (head, rest) = match path.split_once('/') {
105 Some((h, r)) => (h, r),
106 None => (path, ""),
107 };
108 match self {
109 NarNode::Directory { entries } => {
110 let (_, child) = entries.iter().find(|(n, _)| n == head)?;
111 child.at_path(rest)
112 }
113 _ => None,
114 }
115 }
116}
117
118impl ParsedNar {
119 pub fn parse(bytes: &[u8]) -> Result<Self, crate::nar::NarDecodeError> {
127 use std::sync::atomic::{AtomicU64, Ordering};
144 static PARSE_SEQ: AtomicU64 = AtomicU64::new(0);
145 let seq = PARSE_SEQ.fetch_add(1, Ordering::Relaxed);
146 let tmp = std::env::temp_dir().join(format!(
147 "sui-parsed-nar-{}-{}-{seq}",
148 std::process::id(),
149 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
150 .map(|d| d.as_nanos()).unwrap_or(0),
151 ));
152 let _ = std::fs::remove_dir_all(&tmp);
153 crate::nar::decode(bytes, &tmp)?;
154 let root = read_node(&tmp)
155 .map_err(|e| crate::nar::NarDecodeError::Io(e.to_string()))?;
156 let _ = std::fs::remove_dir_all(&tmp);
157 Ok(ParsedNar { root })
158 }
159
160 #[must_use]
163 pub fn serialize(&self) -> Vec<u8> {
164 let mut buf = Vec::new();
165 crate::nar::write_string_for_test(&mut buf, b"nix-archive-1");
166 write_node(&mut buf, &self.root);
167 buf
168 }
169}
170
171fn read_node(path: &std::path::Path) -> std::io::Result<NarNode> {
172 let meta = std::fs::symlink_metadata(path)?;
173 if meta.file_type().is_symlink() {
174 let target = std::fs::read_link(path)?;
175 return Ok(NarNode::Symlink {
176 target: target.to_string_lossy().to_string(),
177 });
178 }
179 if meta.is_file() {
180 let contents = std::fs::read(path)?;
181 #[cfg(unix)]
182 let executable = {
183 use std::os::unix::fs::PermissionsExt;
184 meta.permissions().mode() & 0o111 != 0
185 };
186 #[cfg(not(unix))]
187 let executable = false;
188 return Ok(NarNode::File { executable, contents });
189 }
190 let mut entries: Vec<_> = std::fs::read_dir(path)?
192 .filter_map(Result::ok)
193 .collect();
194 entries.sort_by_key(|e| e.file_name());
195 let mut children = Vec::with_capacity(entries.len());
196 for entry in entries {
197 let name = entry.file_name().to_string_lossy().to_string();
198 let child = read_node(&entry.path())?;
199 children.push((name, child));
200 }
201 Ok(NarNode::Directory { entries: children })
202}
203
204fn write_node(buf: &mut Vec<u8>, node: &NarNode) {
205 crate::nar::write_string_for_test(buf, b"(");
206 match node {
207 NarNode::File { executable, contents } => {
208 crate::nar::write_string_for_test(buf, b"type");
209 crate::nar::write_string_for_test(buf, b"regular");
210 if *executable {
211 crate::nar::write_string_for_test(buf, b"executable");
212 crate::nar::write_string_for_test(buf, b"");
213 }
214 crate::nar::write_string_for_test(buf, b"contents");
215 crate::nar::write_string_for_test(buf, contents);
216 }
217 NarNode::Directory { entries } => {
218 crate::nar::write_string_for_test(buf, b"type");
219 crate::nar::write_string_for_test(buf, b"directory");
220 for (name, child) in entries {
221 crate::nar::write_string_for_test(buf, b"entry");
222 crate::nar::write_string_for_test(buf, b"(");
223 crate::nar::write_string_for_test(buf, b"name");
224 crate::nar::write_string_for_test(buf, name.as_bytes());
225 crate::nar::write_string_for_test(buf, b"node");
226 write_node(buf, child);
227 crate::nar::write_string_for_test(buf, b")");
228 }
229 }
230 NarNode::Symlink { target } => {
231 crate::nar::write_string_for_test(buf, b"type");
232 crate::nar::write_string_for_test(buf, b"symlink");
233 crate::nar::write_string_for_test(buf, b"target");
234 crate::nar::write_string_for_test(buf, target.as_bytes());
235 }
236 }
237 crate::nar::write_string_for_test(buf, b")");
238}
239
240#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
245#[tatara(keyword = "defstore-slice")]
246pub struct StoreSlice {
247 pub name: String,
249 #[serde(rename = "sourceRoot")]
251 pub source_root: String,
252 #[serde(rename = "namePattern")]
255 pub name_pattern: String,
256 #[serde(rename = "maxEntries")]
259 pub max_entries: usize,
260 #[serde(rename = "maxSizeBytes", default)]
263 pub max_size_bytes: u64,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct MaterializationPlan {
269 pub source: std::path::PathBuf,
271 pub dest: std::path::PathBuf,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct MaterializationOutcome {
278 pub source: std::path::PathBuf,
279 pub dest: std::path::PathBuf,
280 pub source_nar_sha256: String,
281 pub dest_nar_sha256: String,
282 pub byte_equivalent: bool,
283 pub source_size: u64,
284 pub file_count: usize,
285}
286
287pub fn build_materialization_plan(
296 slice: &StoreSlice,
297 dest_root: &std::path::Path,
298) -> Result<Vec<MaterializationPlan>, SpecError> {
299 let pattern = regex::Regex::new(&slice.name_pattern).map_err(|e| SpecError::Interp {
300 phase: "slice-pattern".into(),
301 message: format!("invalid name regex: {e}"),
302 })?;
303 let source_root = std::path::Path::new(&slice.source_root);
304 let entries = std::fs::read_dir(source_root).map_err(|e| SpecError::Interp {
305 phase: "slice-scan".into(),
306 message: format!("read_dir {}: {e}", source_root.display()),
307 })?;
308
309 let mut plans: Vec<MaterializationPlan> = Vec::new();
310 for entry in entries.flatten() {
311 if plans.len() >= slice.max_entries {
312 break;
313 }
314 let name = entry.file_name().to_string_lossy().to_string();
315 if !pattern.is_match(&name) {
316 continue;
317 }
318 if slice.max_size_bytes > 0 {
321 if let Ok(meta) = entry.metadata() {
322 if meta.is_file() && meta.len() > slice.max_size_bytes {
323 continue;
324 }
325 }
326 }
327 plans.push(MaterializationPlan {
328 source: entry.path(),
329 dest: dest_root.join(&name),
330 });
331 }
332 Ok(plans)
333}
334
335pub fn run_materialization(
343 plan: &MaterializationPlan,
344) -> Result<MaterializationOutcome, SpecError> {
345 use sha2::Digest;
346
347 let source_nar = crate::nar::encode(&plan.source).map_err(|e| SpecError::Interp {
348 phase: "materialize-encode".into(),
349 message: format!("encode {}: {e}", plan.source.display()),
350 })?;
351 let source_hash = sha2::Sha256::digest(&source_nar);
352 let source_hash_hex = hex_encode(&source_hash);
353
354 if plan.dest.exists() {
355 std::fs::remove_dir_all(&plan.dest).ok();
356 }
357 crate::nar::decode(&source_nar, &plan.dest).map_err(|e| SpecError::Interp {
358 phase: "materialize-decode".into(),
359 message: format!("decode → {}: {e}", plan.dest.display()),
360 })?;
361
362 let dest_nar = crate::nar::encode(&plan.dest).map_err(|e| SpecError::Interp {
363 phase: "materialize-encode".into(),
364 message: format!("encode {}: {e}", plan.dest.display()),
365 })?;
366 let dest_hash = sha2::Sha256::digest(&dest_nar);
367 let dest_hash_hex = hex_encode(&dest_hash);
368
369 let parsed = ParsedNar::parse(&source_nar).map_err(|e| SpecError::Interp {
370 phase: "materialize-parse".into(),
371 message: format!("parse: {e}"),
372 })?;
373
374 Ok(MaterializationOutcome {
375 source: plan.source.clone(),
376 dest: plan.dest.clone(),
377 source_nar_sha256: source_hash_hex.clone(),
378 dest_nar_sha256: dest_hash_hex.clone(),
379 byte_equivalent: source_hash_hex == dest_hash_hex,
380 source_size: parsed.root.total_bytes(),
381 file_count: parsed.root.file_count(),
382 })
383}
384
385fn hex_encode(bytes: &[u8]) -> String {
386 let mut s = String::with_capacity(bytes.len() * 2);
387 for b in bytes {
388 s.push_str(&format!("{b:02x}"));
389 }
390 s
391}
392
393pub const CANONICAL_STORE_OPS_LISP: &str =
396 include_str!("../specs/store_ops.lisp");
397
398pub fn load_canonical_slices() -> Result<Vec<StoreSlice>, SpecError> {
404 crate::loader::load_all::<StoreSlice>(CANONICAL_STORE_OPS_LISP)
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn parsed_nar_walk_counts_correctly() {
413 let tmp = std::env::temp_dir().join("sui-parsed-nar-walk-test");
414 let _ = std::fs::remove_dir_all(&tmp);
415 std::fs::create_dir_all(&tmp).unwrap();
416 std::fs::write(tmp.join("a"), b"aaaa").unwrap();
417 std::fs::write(tmp.join("b"), b"bb").unwrap();
418 std::fs::create_dir_all(tmp.join("d")).unwrap();
419 std::fs::write(tmp.join("d/c"), b"ccc").unwrap();
420 let nar = crate::nar::encode(&tmp).unwrap();
421 let parsed = ParsedNar::parse(&nar).unwrap();
422 assert_eq!(parsed.root.file_count(), 3);
423 assert_eq!(parsed.root.total_bytes(), 4 + 2 + 3);
424 let _ = std::fs::remove_dir_all(&tmp);
425 }
426
427 #[test]
428 fn parsed_nar_at_path_finds_nested_file() {
429 let tmp = std::env::temp_dir().join("sui-parsed-nar-at-test");
430 let _ = std::fs::remove_dir_all(&tmp);
431 std::fs::create_dir_all(tmp.join("sub")).unwrap();
432 std::fs::write(tmp.join("sub/hello"), b"world").unwrap();
433 let nar = crate::nar::encode(&tmp).unwrap();
434 let parsed = ParsedNar::parse(&nar).unwrap();
435 let node = parsed.root.at_path("sub/hello").unwrap();
436 assert!(matches!(node, NarNode::File { .. }));
437 let _ = std::fs::remove_dir_all(&tmp);
438 }
439
440 #[test]
441 fn canonical_slices_parse() {
442 let slices = load_canonical_slices().unwrap();
443 assert!(!slices.is_empty(), "at least one canonical slice authored");
444 }
445}