1use serde::{Deserialize, Serialize};
19use std::collections::{BTreeMap, BTreeSet};
20use tatara_lisp::DeriveTataraDomain;
21
22use crate::SpecError;
23use crate::store_layout::ParsedStorePath;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct StoreEntry {
31 pub path: std::path::PathBuf,
33 pub parsed: ParsedStorePath,
35 pub is_directory: bool,
37 pub file_count: usize,
40 pub size: u64,
43}
44
45#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
48#[tatara(keyword = "defstore-inventory-profile")]
49pub struct StoreInventoryProfile {
50 pub name: String,
52 #[serde(rename = "sourceRoot")]
54 pub source_root: String,
55 #[serde(rename = "maxEntries", default)]
57 pub max_entries: usize,
58 #[serde(rename = "skipPattern", default)]
60 pub skip_pattern: String,
61 #[serde(rename = "computeNarHash", default)]
64 pub compute_nar_hash: bool,
65}
66
67#[derive(Debug, Clone, Default)]
71pub struct StoreInventory {
72 pub root: std::path::PathBuf,
74 pub entries: BTreeMap<String, StoreEntry>,
76}
77
78impl StoreInventory {
79 pub fn walk(profile: &StoreInventoryProfile) -> Result<Self, SpecError> {
88 let root = std::path::PathBuf::from(&profile.source_root);
89 if !root.is_dir() {
90 return Err(SpecError::Interp {
91 phase: "inventory-bad-root".into(),
92 message: format!("not a directory: {}", root.display()),
93 });
94 }
95 let skip = if profile.skip_pattern.is_empty() {
96 None
97 } else {
98 Some(regex::Regex::new(&profile.skip_pattern).map_err(|e| SpecError::Interp {
99 phase: "inventory-bad-pattern".into(),
100 message: format!("invalid skip regex: {e}"),
101 })?)
102 };
103 let entries_iter = std::fs::read_dir(&root).map_err(|e| SpecError::Interp {
104 phase: "inventory-io".into(),
105 message: format!("read_dir {}: {e}", root.display()),
106 })?;
107 let mut entries: BTreeMap<String, StoreEntry> = BTreeMap::new();
108 for entry in entries_iter.flatten() {
109 if profile.max_entries > 0 && entries.len() >= profile.max_entries {
110 break;
111 }
112 let name = entry.file_name().to_string_lossy().to_string();
113 if let Some(skip) = &skip {
114 if skip.is_match(&name) {
115 continue;
116 }
117 }
118 let path = entry.path();
119 let parsed = match crate::store_layout::validate_against_canonical(
120 &path.to_string_lossy()
121 ) {
122 Ok(p) => p,
123 Err(_) => continue, };
125 let meta = std::fs::symlink_metadata(&path).ok();
126 let is_directory = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
127 let (file_count, size) = if is_directory {
130 summarize_tree(&path)
131 } else {
132 (1, meta.as_ref().map(|m| m.len()).unwrap_or(0))
133 };
134 entries.insert(name.clone(), StoreEntry {
135 path,
136 parsed,
137 is_directory,
138 file_count,
139 size,
140 });
141 }
142 Ok(Self { root, entries })
143 }
144
145 #[must_use]
147 pub fn get(&self, name: &str) -> Option<&StoreEntry> {
148 self.entries.get(name)
149 }
150
151 pub fn filter_by_name(&self, pattern: &str) -> Result<Vec<&StoreEntry>, SpecError> {
157 let re = regex::Regex::new(pattern).map_err(|e| SpecError::Interp {
158 phase: "inventory-bad-pattern".into(),
159 message: format!("invalid regex: {e}"),
160 })?;
161 Ok(self.entries.values()
162 .filter(|e| re.is_match(&e.parsed.name))
163 .collect())
164 }
165
166 #[must_use]
168 pub fn filter_by_size(&self, predicate: impl Fn(u64) -> bool) -> Vec<&StoreEntry> {
169 self.entries.values().filter(|e| predicate(e.size)).collect()
170 }
171
172 #[must_use]
174 pub fn total_size(&self) -> u64 {
175 self.entries.values().map(|e| e.size).sum()
176 }
177
178 #[must_use]
180 pub fn total_files(&self) -> usize {
181 self.entries.values().map(|e| e.file_count).sum()
182 }
183}
184
185fn summarize_tree(root: &std::path::Path) -> (usize, u64) {
186 let mut files = 0usize;
187 let mut size = 0u64;
188 fn walk(path: &std::path::Path, files: &mut usize, size: &mut u64) {
189 let Ok(meta) = std::fs::symlink_metadata(path) else { return; };
190 if meta.is_file() {
191 *files += 1;
192 *size += meta.len();
193 } else if meta.is_dir() {
194 if let Ok(entries) = std::fs::read_dir(path) {
195 for entry in entries.flatten() {
196 walk(&entry.path(), files, size);
197 }
198 }
199 }
200 }
201 walk(root, &mut files, &mut size);
202 (files, size)
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct Closure {
213 pub root: std::path::PathBuf,
214 pub paths: BTreeSet<std::path::PathBuf>,
215}
216
217impl Closure {
218 pub fn walk(root: &std::path::Path, store_root: &str) -> Result<Self, SpecError> {
228 let _ = crate::store_layout::validate_against_canonical(&root.to_string_lossy())
229 .map_err(|e| SpecError::Interp {
230 phase: "closure-bad-root".into(),
231 message: format!("{root}: {e:?}", root = root.display()),
232 })?;
233
234 let mut visited: BTreeSet<std::path::PathBuf> = BTreeSet::new();
235 let mut queue: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
236 let max_depth = 4096usize; let mut iters = 0usize;
238
239 while let Some(path) = queue.pop() {
240 iters += 1;
241 if iters > max_depth { break; }
242 if !visited.insert(path.clone()) {
243 continue;
244 }
245 let nar = match crate::nar::encode(&path) {
246 Ok(b) => b,
247 Err(_) => continue, };
249 let prefix = format!("{}/", store_root.trim_end_matches('/'));
254 let prefix_bytes = prefix.as_bytes();
255 let mut i = 0usize;
256 while i + prefix_bytes.len() < nar.len() {
257 if &nar[i..i + prefix_bytes.len()] == prefix_bytes {
258 let start = i;
259 let mut j = start + prefix_bytes.len();
260 while j < nar.len() {
261 let c = nar[j];
262 if c.is_ascii_alphanumeric() || c == b'-' || c == b'.' || c == b'+' || c == b'_' {
266 j += 1;
267 } else {
268 break;
269 }
270 }
271 if j > start + prefix_bytes.len() {
272 let s = std::str::from_utf8(&nar[start..j]).unwrap_or("");
273 let candidate = std::path::PathBuf::from(s);
277 if candidate != path
278 && crate::store_layout::validate_against_canonical(s).is_ok()
279 && !visited.contains(&candidate)
280 {
281 queue.push(candidate);
282 }
283 }
284 i = j;
285 } else {
286 i += 1;
287 }
288 }
289 }
290
291 Ok(Self {
292 root: root.to_path_buf(),
293 paths: visited,
294 })
295 }
296
297 #[must_use]
299 pub fn len(&self) -> usize { self.paths.len() }
300
301 #[must_use]
303 pub fn is_empty(&self) -> bool { self.paths.is_empty() }
304}
305
306#[derive(Debug, Clone, Default)]
312pub struct RefIndex {
313 pub references: BTreeMap<std::path::PathBuf, BTreeSet<std::path::PathBuf>>,
316 pub referrers: BTreeMap<std::path::PathBuf, BTreeSet<std::path::PathBuf>>,
319}
320
321impl RefIndex {
322 pub fn build(inv: &StoreInventory, store_root: &str) -> Result<Self, SpecError> {
330 let mut idx = RefIndex::default();
331 for entry in inv.entries.values() {
332 let nar = match crate::nar::encode(&entry.path) {
333 Ok(b) => b,
334 Err(_) => continue,
335 };
336 let mut hits: BTreeSet<std::path::PathBuf> = BTreeSet::new();
337 scan_refs(&nar, store_root, &entry.path, &mut hits);
338 for r in &hits {
339 idx.referrers.entry(r.clone()).or_default().insert(entry.path.clone());
340 }
341 idx.references.insert(entry.path.clone(), hits);
342 }
343 Ok(idx)
344 }
345
346 #[must_use]
349 pub fn refs_from(&self, path: &std::path::Path) -> &BTreeSet<std::path::PathBuf> {
350 static EMPTY: std::sync::OnceLock<BTreeSet<std::path::PathBuf>> = std::sync::OnceLock::new();
351 self.references.get(path).unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new))
352 }
353
354 #[must_use]
356 pub fn referrers_of(&self, path: &std::path::Path) -> &BTreeSet<std::path::PathBuf> {
357 static EMPTY: std::sync::OnceLock<BTreeSet<std::path::PathBuf>> = std::sync::OnceLock::new();
358 self.referrers.get(path).unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new))
359 }
360}
361
362fn scan_refs(
363 nar: &[u8],
364 store_root: &str,
365 self_path: &std::path::Path,
366 hits: &mut BTreeSet<std::path::PathBuf>,
367) {
368 let prefix = format!("{}/", store_root.trim_end_matches('/'));
369 let prefix_bytes = prefix.as_bytes();
370 let mut i = 0usize;
371 while i + prefix_bytes.len() < nar.len() {
372 if &nar[i..i + prefix_bytes.len()] == prefix_bytes {
373 let start = i;
374 let mut j = start + prefix_bytes.len();
375 while j < nar.len() {
376 let c = nar[j];
377 if c.is_ascii_alphanumeric() || c == b'-' || c == b'.' || c == b'+' || c == b'_' {
378 j += 1;
379 } else {
380 break;
381 }
382 }
383 if j > start + prefix_bytes.len() {
384 let s = std::str::from_utf8(&nar[start..j]).unwrap_or("");
385 let cand = std::path::PathBuf::from(s);
386 if cand != self_path
387 && crate::store_layout::validate_against_canonical(s).is_ok()
388 {
389 hits.insert(cand);
390 }
391 }
392 i = j;
393 } else {
394 i += 1;
395 }
396 }
397}
398
399pub const CANONICAL_STORE_INVENTORY_LISP: &str =
402 include_str!("../specs/store_inventory.lisp");
403
404pub fn load_canonical_profiles() -> Result<Vec<StoreInventoryProfile>, SpecError> {
410 crate::loader::load_all::<StoreInventoryProfile>(CANONICAL_STORE_INVENTORY_LISP)
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn canonical_profiles_parse() {
419 let profiles = load_canonical_profiles().unwrap();
420 assert!(!profiles.is_empty());
421 }
422
423 #[test]
424 fn inventory_walk_skips_pattern() {
425 let tmp = std::env::temp_dir().join("sui-inv-test");
426 let _ = std::fs::remove_dir_all(&tmp);
427 std::fs::create_dir_all(&tmp).unwrap();
428 std::fs::write(tmp.join("00000000000000000000000000000000-source"), b"x").unwrap();
430 std::fs::write(tmp.join("11111111111111111111111111111111-source"), b"y").unwrap();
431 std::fs::write(tmp.join("22222222222222222222222222222222-skip-me"), b"z").unwrap();
432 let profile = StoreInventoryProfile {
438 name: "test".into(),
439 source_root: tmp.display().to_string(),
440 max_entries: 0,
441 skip_pattern: ".*skip-me$".into(),
442 compute_nar_hash: false,
443 };
444 let inv = StoreInventory::walk(&profile);
448 let _ = inv;
451 let _ = std::fs::remove_dir_all(&tmp);
452 }
453}