1use std::collections::HashMap;
6
7use crate::fxar::{FxarManifest, FxarManifestEntry};
8
9const ROOT_IDX: usize = 0;
10
11#[derive(Debug, Clone)]
13pub struct ArchiveEntry {
14 pub name: String,
15 pub full_path: String,
17 pub is_dir: bool,
18 pub size: u64,
20 pub mtime: i64,
22 pub mode: u32,
23 pub blake3: String,
24 pub chunk_count: usize,
26 pub xattr: HashMap<String, String>,
27 pub children: Vec<usize>,
29 pub parent: usize,
31 pub manifest_index: Option<usize>,
33}
34
35pub struct ArchiveNavigator {
37 pub entries: Vec<ArchiveEntry>,
39 pub manifest: FxarManifest,
40 pub selected: usize,
42}
43
44impl ArchiveNavigator {
45 pub fn from_manifest(manifest: FxarManifest) -> std::io::Result<Self> {
47 for (i, entry) in manifest.files.iter().enumerate() {
49 validate_path(&entry.path).map_err(|msg| {
50 std::io::Error::new(
51 std::io::ErrorKind::InvalidData,
52 format!("manifest entry {i}: {msg}"),
53 )
54 })?;
55 }
56
57 let mut entries = Vec::new();
59
60 entries.push(ArchiveEntry {
62 name: "/".into(),
63 full_path: String::new(),
64 is_dir: true,
65 size: 0,
66 mtime: 0,
67 mode: 0o40755,
68 blake3: String::new(),
69 chunk_count: 0,
70 xattr: HashMap::new(),
71 children: Vec::new(),
72 parent: usize::MAX,
73 manifest_index: None,
74 });
75
76 let mut path_cache: HashMap<String, usize> = HashMap::new();
78
79 for (mi, file_entry) in manifest.files.iter().enumerate() {
80 let components: Vec<&str> = file_entry.path.split('/').collect();
81 let mut parent_idx = ROOT_IDX;
82 let mut accumulated_path = String::new();
83
84 for (ci, component) in components.iter().enumerate() {
85 let is_last = ci == components.len() - 1;
86
87 if accumulated_path.is_empty() {
88 accumulated_path = (*component).to_string();
89 } else {
90 accumulated_path = format!("{accumulated_path}/{component}");
91 }
92
93 if is_last {
94 let idx = entries.len();
96 entries.push(ArchiveEntry {
97 name: (*component).to_string(),
98 full_path: accumulated_path.clone(),
99 is_dir: false,
100 size: file_entry.size,
101 mtime: file_entry.mtime,
102 mode: file_entry.mode,
103 blake3: file_entry.blake3.clone(),
104 chunk_count: file_entry.chunks.len(),
105 xattr: file_entry.xattr.clone(),
106 children: Vec::new(),
107 parent: parent_idx,
108 manifest_index: Some(mi),
109 });
110 entries[parent_idx].children.push(idx);
111 parent_idx = idx;
112 } else if let Some(&existing_idx) = path_cache.get(&accumulated_path) {
113 parent_idx = existing_idx;
115 } else {
116 let idx = entries.len();
118 entries.push(ArchiveEntry {
119 name: (*component).to_string(),
120 full_path: accumulated_path.clone(),
121 is_dir: true,
122 size: 0,
123 mtime: 0,
124 mode: 0o40755,
125 blake3: String::new(),
126 chunk_count: 0,
127 xattr: HashMap::new(),
128 children: Vec::new(),
129 parent: parent_idx,
130 manifest_index: None,
131 });
132 entries[parent_idx].children.push(idx);
133 path_cache.insert(accumulated_path.clone(), idx);
134 parent_idx = idx;
135 }
136 }
137 }
138
139 for i in 0..entries.len() {
141 if entries[i].is_dir && !entries[i].children.is_empty() {
142 let children = entries[i].children.clone();
143 let mut dirs: Vec<usize> = Vec::new();
144 let mut files: Vec<usize> = Vec::new();
145 for &child_idx in &children {
146 if entries[child_idx].is_dir {
147 dirs.push(child_idx);
148 } else {
149 files.push(child_idx);
150 }
151 }
152 dirs.sort_by(|&a, &b| entries[a].name.cmp(&entries[b].name));
153 files.sort_by(|&a, &b| entries[a].name.cmp(&entries[b].name));
154 dirs.extend(files);
155 entries[i].children = dirs;
156 }
157 }
158
159 Ok(Self {
160 entries,
161 manifest,
162 selected: 0,
163 })
164 }
165
166 pub fn root(&self) -> &ArchiveEntry {
167 &self.entries[ROOT_IDX]
168 }
169
170 pub fn entry_at(&self, idx: usize) -> Option<&ArchiveEntry> {
171 self.entries.get(idx)
172 }
173
174 pub fn children_of(&self, idx: usize) -> &[usize] {
175 self.entries
176 .get(idx)
177 .map(|e| e.children.as_slice())
178 .unwrap_or(&[])
179 }
180
181 pub fn parent_of(&self, idx: usize) -> Option<usize> {
182 let e = self.entries.get(idx)?;
183 if e.parent == usize::MAX {
184 None
185 } else {
186 Some(e.parent)
187 }
188 }
189
190 pub fn manifest_entry_for(&self, idx: usize) -> Option<&FxarManifestEntry> {
191 let e = self.entries.get(idx)?;
192 let mi = e.manifest_index?;
193 self.manifest.files.get(mi)
194 }
195
196 pub fn select_next(&mut self) {
197 if self.selected + 1 < self.entries.len() {
198 self.selected += 1;
199 }
200 }
201
202 pub fn select_prev(&mut self) {
203 if self.selected > 0 {
204 self.selected -= 1;
205 }
206 }
207
208 pub fn selected_entry(&self) -> Option<&ArchiveEntry> {
209 self.entries.get(self.selected)
210 }
211
212 pub fn list_snapshots(&self) -> &[String] {
214 &self.manifest.snapshots
215 }
216
217 pub fn snapshot_entry_count(&self, snapshot: &str) -> usize {
219 let prefix = format!("{}/", snapshot);
220 self.manifest
221 .files
222 .iter()
223 .filter(|f| f.path.starts_with(&prefix) || f.path == snapshot)
224 .count()
225 }
226
227 pub fn visible_entry_indices(&self, snapshot_filter: Option<&str>) -> Vec<usize> {
231 match snapshot_filter {
232 None => (0..self.entries.len()).collect(),
233 Some(snap) => {
234 let mut visible = vec![ROOT_IDX]; let prefix = format!("{}/", snap);
236 for (idx, entry) in self.entries.iter().enumerate() {
237 if idx == ROOT_IDX {
238 continue;
239 }
240 if entry.full_path == snap || entry.full_path.starts_with(&prefix) {
241 visible.push(idx);
242 }
243 }
244 visible
245 }
246 }
247 }
248}
249
250fn validate_path(path: &str) -> Result<(), String> {
252 if path.is_empty() {
253 return Err("path is empty".into());
254 }
255 if path.starts_with('/') {
256 return Err(format!("path starts with '/': {path:?}"));
257 }
258 if path.contains('\0') {
259 return Err(format!("path contains null byte: {path:?}"));
260 }
261 for component in path.split('/') {
262 if component == ".." {
263 return Err(format!("path contains '..': {path:?}"));
264 }
265 if component == "." {
266 return Err(format!("path contains '.': {path:?}"));
267 }
268 }
269 Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
275 use super::*;
276
277 fn make_entry(path: &str, size: u64) -> FxarManifestEntry {
278 FxarManifestEntry {
279 path: path.into(),
280 size,
281 mode: 0o100644,
282 mtime: 1714348800,
283 uid: 1000,
284 gid: 1000,
285 blake3: format!("hash_{path}"),
286 chunks: vec![0, size],
287 xattr: HashMap::new(),
288 }
289 }
290
291 #[test]
292 fn test_archive_navigator_tree() {
293 let manifest = FxarManifest {
294 version: 2,
295 created: "2026-04-29T00:00:00Z".into(),
296 files: vec![
297 make_entry("snap1/tree/src/main.rs", 100),
298 make_entry("snap1/tree/README.md", 50),
299 make_entry("snap1/tree/src/lib.rs", 200),
300 make_entry("snap2/tree/config.toml", 30),
301 make_entry("snap2/tree/data.bin", 5000),
302 ],
303 snapshots: vec!["snap1".into(), "snap2".into()],
304 };
305
306 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
307
308 let root_children = nav.children_of(ROOT_IDX);
310 assert_eq!(root_children.len(), 2, "root should have 2 children");
311
312 let snap1_idx = root_children[0];
313 let snap2_idx = root_children[1];
314 assert_eq!(nav.entry_at(snap1_idx).unwrap().name, "snap1");
315 assert_eq!(nav.entry_at(snap2_idx).unwrap().name, "snap2");
316 assert!(nav.entry_at(snap1_idx).unwrap().is_dir);
317
318 let snap1_children = nav.children_of(snap1_idx);
320 assert_eq!(snap1_children.len(), 1);
321 let tree1_idx = snap1_children[0];
322 assert_eq!(nav.entry_at(tree1_idx).unwrap().name, "tree");
323
324 let tree1_children = nav.children_of(tree1_idx);
326 assert_eq!(tree1_children.len(), 2);
327 let src_idx = tree1_children[0];
328 let readme_idx = tree1_children[1];
329 assert_eq!(nav.entry_at(src_idx).unwrap().name, "src");
330 assert!(nav.entry_at(src_idx).unwrap().is_dir);
331 assert_eq!(nav.entry_at(readme_idx).unwrap().name, "README.md");
332 assert!(!nav.entry_at(readme_idx).unwrap().is_dir);
333
334 let src_children = nav.children_of(src_idx);
336 assert_eq!(src_children.len(), 2);
337 assert_eq!(nav.entry_at(src_children[0]).unwrap().name, "lib.rs");
338 assert_eq!(nav.entry_at(src_children[1]).unwrap().name, "main.rs");
339
340 assert!(nav.manifest_entry_for(src_idx).is_none());
342
343 let main_rs_idx = src_children[1];
345 let me = nav.manifest_entry_for(main_rs_idx).unwrap();
346 assert_eq!(me.size, 100);
347 assert_eq!(me.path, "snap1/tree/src/main.rs");
348
349 let snap2_tree_idx = nav.children_of(snap2_idx)[0];
351 let snap2_tree_children = nav.children_of(snap2_tree_idx);
352 assert_eq!(snap2_tree_children.len(), 2);
353 assert_eq!(
354 nav.entry_at(snap2_tree_children[0]).unwrap().name,
355 "config.toml"
356 );
357 assert_eq!(
358 nav.entry_at(snap2_tree_children[1]).unwrap().name,
359 "data.bin"
360 );
361
362 assert_eq!(nav.parent_of(main_rs_idx), Some(src_idx));
364 assert_eq!(nav.parent_of(src_idx), Some(tree1_idx));
365 assert_eq!(nav.parent_of(snap1_idx), Some(ROOT_IDX));
366 assert!(nav.parent_of(ROOT_IDX).is_none());
367 }
368
369 #[test]
370 fn test_archive_navigator_path_validation() {
371 let manifest = FxarManifest {
373 version: 2,
374 created: "2026-04-29T00:00:00Z".into(),
375 files: vec![make_entry("/etc/passwd", 100)],
376 snapshots: vec![],
377 };
378 assert!(ArchiveNavigator::from_manifest(manifest).is_err());
379
380 let manifest = FxarManifest {
382 version: 2,
383 created: "2026-04-29T00:00:00Z".into(),
384 files: vec![make_entry("snap1/../../../etc/passwd", 100)],
385 snapshots: vec![],
386 };
387 assert!(ArchiveNavigator::from_manifest(manifest).is_err());
388
389 let manifest = FxarManifest {
391 version: 2,
392 created: "2026-04-29T00:00:00Z".into(),
393 files: vec![make_entry("", 100)],
394 snapshots: vec![],
395 };
396 assert!(ArchiveNavigator::from_manifest(manifest).is_err());
397
398 let manifest = FxarManifest {
400 version: 2,
401 created: "2026-04-29T00:00:00Z".into(),
402 files: vec![make_entry("snap1/tree/file.txt", 42)],
403 snapshots: vec![],
404 };
405 assert!(ArchiveNavigator::from_manifest(manifest).is_ok());
406 }
407
408 #[test]
409 fn test_archive_navigator_navigation() {
410 let manifest = FxarManifest {
411 version: 2,
412 created: "2026-04-29T00:00:00Z".into(),
413 files: vec![
414 make_entry("a/x.txt", 10),
415 make_entry("a/y.txt", 20),
416 make_entry("b/z.txt", 30),
417 ],
418 snapshots: vec![],
419 };
420 let mut nav = ArchiveNavigator::from_manifest(manifest).unwrap();
421
422 assert_eq!(nav.selected, 0);
424 assert_eq!(nav.selected_entry().unwrap().name, "/");
425
426 nav.select_next();
428 assert_eq!(nav.selected, 1);
429
430 nav.select_next();
431 assert_eq!(nav.selected, 2);
432
433 nav.select_prev();
435 assert_eq!(nav.selected, 1);
436
437 nav.select_prev();
439 assert_eq!(nav.selected, 0);
440 nav.select_prev();
441 assert_eq!(nav.selected, 0);
442
443 let max = nav.entries.len() - 1;
445 for _ in 0..nav.entries.len() + 5 {
446 nav.select_next();
447 }
448 assert_eq!(nav.selected, max);
449 }
450
451 #[test]
452 fn test_snapshot_list() {
453 let manifest = FxarManifest {
454 version: 2,
455 created: "2026-04-29T00:00:00Z".into(),
456 files: vec![make_entry("snap1/tree/file.txt", 100)],
457 snapshots: vec!["snap1".into(), "snap2".into()],
458 };
459 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
460 assert_eq!(nav.list_snapshots(), &["snap1", "snap2"]);
461 }
462
463 #[test]
464 fn test_snapshot_filter() {
465 let manifest = FxarManifest {
466 version: 2,
467 created: "2026-04-29T00:00:00Z".into(),
468 files: vec![
469 make_entry("snap1/tree/a.txt", 100),
470 make_entry("snap1/tree/b.txt", 200),
471 make_entry("snap2/tree/c.txt", 300),
472 ],
473 snapshots: vec!["snap1".into(), "snap2".into()],
474 };
475 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
476
477 let visible = nav.visible_entry_indices(Some("snap1"));
478 assert!(visible.contains(&0));
479
480 for &idx in &visible {
481 if let Some(entry) = nav.entry_at(idx)
482 && !entry.full_path.is_empty() {
483 assert!(
484 entry.full_path == "snap1" || entry.full_path.starts_with("snap1/"),
485 "Unexpected entry in snap1 filter: {}",
486 entry.full_path
487 );
488 }
489 }
490
491 assert_eq!(visible.len(), 5);
493 }
494
495 #[test]
496 fn test_snapshot_filter_all() {
497 let manifest = FxarManifest {
498 version: 2,
499 created: "2026-04-29T00:00:00Z".into(),
500 files: vec![
501 make_entry("snap1/tree/a.txt", 100),
502 make_entry("snap2/tree/b.txt", 200),
503 ],
504 snapshots: vec!["snap1".into(), "snap2".into()],
505 };
506 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
507 let all = nav.visible_entry_indices(None);
508 assert_eq!(all.len(), nav.entries.len());
509 }
510}