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 let tmp = std::env::temp_dir().join(format!(
132 "sui-parsed-nar-{}-{}",
133 std::process::id(),
134 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
135 .map(|d| d.as_nanos()).unwrap_or(0),
136 ));
137 let _ = std::fs::remove_dir_all(&tmp);
138 crate::nar::decode(bytes, &tmp)?;
139 let root = read_node(&tmp)
140 .map_err(|e| crate::nar::NarDecodeError::Io(e.to_string()))?;
141 let _ = std::fs::remove_dir_all(&tmp);
142 Ok(ParsedNar { root })
143 }
144
145 #[must_use]
148 pub fn serialize(&self) -> Vec<u8> {
149 let mut buf = Vec::new();
150 crate::nar::write_string_for_test(&mut buf, b"nix-archive-1");
151 write_node(&mut buf, &self.root);
152 buf
153 }
154}
155
156fn read_node(path: &std::path::Path) -> std::io::Result<NarNode> {
157 let meta = std::fs::symlink_metadata(path)?;
158 if meta.file_type().is_symlink() {
159 let target = std::fs::read_link(path)?;
160 return Ok(NarNode::Symlink {
161 target: target.to_string_lossy().to_string(),
162 });
163 }
164 if meta.is_file() {
165 let contents = std::fs::read(path)?;
166 #[cfg(unix)]
167 let executable = {
168 use std::os::unix::fs::PermissionsExt;
169 meta.permissions().mode() & 0o111 != 0
170 };
171 #[cfg(not(unix))]
172 let executable = false;
173 return Ok(NarNode::File { executable, contents });
174 }
175 let mut entries: Vec<_> = std::fs::read_dir(path)?
177 .filter_map(Result::ok)
178 .collect();
179 entries.sort_by_key(|e| e.file_name());
180 let mut children = Vec::with_capacity(entries.len());
181 for entry in entries {
182 let name = entry.file_name().to_string_lossy().to_string();
183 let child = read_node(&entry.path())?;
184 children.push((name, child));
185 }
186 Ok(NarNode::Directory { entries: children })
187}
188
189fn write_node(buf: &mut Vec<u8>, node: &NarNode) {
190 crate::nar::write_string_for_test(buf, b"(");
191 match node {
192 NarNode::File { executable, contents } => {
193 crate::nar::write_string_for_test(buf, b"type");
194 crate::nar::write_string_for_test(buf, b"regular");
195 if *executable {
196 crate::nar::write_string_for_test(buf, b"executable");
197 crate::nar::write_string_for_test(buf, b"");
198 }
199 crate::nar::write_string_for_test(buf, b"contents");
200 crate::nar::write_string_for_test(buf, contents);
201 }
202 NarNode::Directory { entries } => {
203 crate::nar::write_string_for_test(buf, b"type");
204 crate::nar::write_string_for_test(buf, b"directory");
205 for (name, child) in entries {
206 crate::nar::write_string_for_test(buf, b"entry");
207 crate::nar::write_string_for_test(buf, b"(");
208 crate::nar::write_string_for_test(buf, b"name");
209 crate::nar::write_string_for_test(buf, name.as_bytes());
210 crate::nar::write_string_for_test(buf, b"node");
211 write_node(buf, child);
212 crate::nar::write_string_for_test(buf, b")");
213 }
214 }
215 NarNode::Symlink { target } => {
216 crate::nar::write_string_for_test(buf, b"type");
217 crate::nar::write_string_for_test(buf, b"symlink");
218 crate::nar::write_string_for_test(buf, b"target");
219 crate::nar::write_string_for_test(buf, target.as_bytes());
220 }
221 }
222 crate::nar::write_string_for_test(buf, b")");
223}
224
225#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
230#[tatara(keyword = "defstore-slice")]
231pub struct StoreSlice {
232 pub name: String,
234 #[serde(rename = "sourceRoot")]
236 pub source_root: String,
237 #[serde(rename = "namePattern")]
240 pub name_pattern: String,
241 #[serde(rename = "maxEntries")]
244 pub max_entries: usize,
245 #[serde(rename = "maxSizeBytes", default)]
248 pub max_size_bytes: u64,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct MaterializationPlan {
254 pub source: std::path::PathBuf,
256 pub dest: std::path::PathBuf,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct MaterializationOutcome {
263 pub source: std::path::PathBuf,
264 pub dest: std::path::PathBuf,
265 pub source_nar_sha256: String,
266 pub dest_nar_sha256: String,
267 pub byte_equivalent: bool,
268 pub source_size: u64,
269 pub file_count: usize,
270}
271
272pub fn build_materialization_plan(
281 slice: &StoreSlice,
282 dest_root: &std::path::Path,
283) -> Result<Vec<MaterializationPlan>, SpecError> {
284 let pattern = regex::Regex::new(&slice.name_pattern).map_err(|e| SpecError::Interp {
285 phase: "slice-pattern".into(),
286 message: format!("invalid name regex: {e}"),
287 })?;
288 let source_root = std::path::Path::new(&slice.source_root);
289 let entries = std::fs::read_dir(source_root).map_err(|e| SpecError::Interp {
290 phase: "slice-scan".into(),
291 message: format!("read_dir {}: {e}", source_root.display()),
292 })?;
293
294 let mut plans: Vec<MaterializationPlan> = Vec::new();
295 for entry in entries.flatten() {
296 if plans.len() >= slice.max_entries {
297 break;
298 }
299 let name = entry.file_name().to_string_lossy().to_string();
300 if !pattern.is_match(&name) {
301 continue;
302 }
303 if slice.max_size_bytes > 0 {
306 if let Ok(meta) = entry.metadata() {
307 if meta.is_file() && meta.len() > slice.max_size_bytes {
308 continue;
309 }
310 }
311 }
312 plans.push(MaterializationPlan {
313 source: entry.path(),
314 dest: dest_root.join(&name),
315 });
316 }
317 Ok(plans)
318}
319
320pub fn run_materialization(
328 plan: &MaterializationPlan,
329) -> Result<MaterializationOutcome, SpecError> {
330 use sha2::Digest;
331
332 let source_nar = crate::nar::encode(&plan.source).map_err(|e| SpecError::Interp {
333 phase: "materialize-encode".into(),
334 message: format!("encode {}: {e}", plan.source.display()),
335 })?;
336 let source_hash = sha2::Sha256::digest(&source_nar);
337 let source_hash_hex = hex_encode(&source_hash);
338
339 if plan.dest.exists() {
340 std::fs::remove_dir_all(&plan.dest).ok();
341 }
342 crate::nar::decode(&source_nar, &plan.dest).map_err(|e| SpecError::Interp {
343 phase: "materialize-decode".into(),
344 message: format!("decode → {}: {e}", plan.dest.display()),
345 })?;
346
347 let dest_nar = crate::nar::encode(&plan.dest).map_err(|e| SpecError::Interp {
348 phase: "materialize-encode".into(),
349 message: format!("encode {}: {e}", plan.dest.display()),
350 })?;
351 let dest_hash = sha2::Sha256::digest(&dest_nar);
352 let dest_hash_hex = hex_encode(&dest_hash);
353
354 let parsed = ParsedNar::parse(&source_nar).map_err(|e| SpecError::Interp {
355 phase: "materialize-parse".into(),
356 message: format!("parse: {e}"),
357 })?;
358
359 Ok(MaterializationOutcome {
360 source: plan.source.clone(),
361 dest: plan.dest.clone(),
362 source_nar_sha256: source_hash_hex.clone(),
363 dest_nar_sha256: dest_hash_hex.clone(),
364 byte_equivalent: source_hash_hex == dest_hash_hex,
365 source_size: parsed.root.total_bytes(),
366 file_count: parsed.root.file_count(),
367 })
368}
369
370fn hex_encode(bytes: &[u8]) -> String {
371 let mut s = String::with_capacity(bytes.len() * 2);
372 for b in bytes {
373 s.push_str(&format!("{b:02x}"));
374 }
375 s
376}
377
378pub const CANONICAL_STORE_OPS_LISP: &str =
381 include_str!("../specs/store_ops.lisp");
382
383pub fn load_canonical_slices() -> Result<Vec<StoreSlice>, SpecError> {
389 crate::loader::load_all::<StoreSlice>(CANONICAL_STORE_OPS_LISP)
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn parsed_nar_walk_counts_correctly() {
398 let tmp = std::env::temp_dir().join("sui-parsed-nar-walk-test");
399 let _ = std::fs::remove_dir_all(&tmp);
400 std::fs::create_dir_all(&tmp).unwrap();
401 std::fs::write(tmp.join("a"), b"aaaa").unwrap();
402 std::fs::write(tmp.join("b"), b"bb").unwrap();
403 std::fs::create_dir_all(tmp.join("d")).unwrap();
404 std::fs::write(tmp.join("d/c"), b"ccc").unwrap();
405 let nar = crate::nar::encode(&tmp).unwrap();
406 let parsed = ParsedNar::parse(&nar).unwrap();
407 assert_eq!(parsed.root.file_count(), 3);
408 assert_eq!(parsed.root.total_bytes(), 4 + 2 + 3);
409 let _ = std::fs::remove_dir_all(&tmp);
410 }
411
412 #[test]
413 fn parsed_nar_at_path_finds_nested_file() {
414 let tmp = std::env::temp_dir().join("sui-parsed-nar-at-test");
415 let _ = std::fs::remove_dir_all(&tmp);
416 std::fs::create_dir_all(tmp.join("sub")).unwrap();
417 std::fs::write(tmp.join("sub/hello"), b"world").unwrap();
418 let nar = crate::nar::encode(&tmp).unwrap();
419 let parsed = ParsedNar::parse(&nar).unwrap();
420 let node = parsed.root.at_path("sub/hello").unwrap();
421 assert!(matches!(node, NarNode::File { .. }));
422 let _ = std::fs::remove_dir_all(&tmp);
423 }
424
425 #[test]
426 fn canonical_slices_parse() {
427 let slices = load_canonical_slices().unwrap();
428 assert!(!slices.is_empty(), "at least one canonical slice authored");
429 }
430}