Skip to main content

fxcp_core/browser/
archive_navigator.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3// fxcp-core/src/browser/archive_navigator.rs  --  Virtual directory tree from FXAR manifest
4
5use std::collections::HashMap;
6
7use crate::fxar::{FxarManifest, FxarManifestEntry};
8
9const ROOT_IDX: usize = 0;
10
11/// A single node in the virtual archive tree (file or virtual directory).
12#[derive(Debug, Clone)]
13pub struct ArchiveEntry {
14    pub name: String,
15    /// Full path from archive root, e.g. "snap1/tree/src/main.rs"
16    pub full_path: String,
17    pub is_dir: bool,
18    /// 0 for virtual directory nodes (not in manifest)
19    pub size: u64,
20    /// Seconds since UNIX epoch. 0 for virtual dirs.
21    pub mtime: i64,
22    pub mode: u32,
23    pub blake3: String,
24    /// Number of content chunks. 0 for virtual dirs.
25    pub chunk_count: usize,
26    pub xattr: HashMap<String, String>,
27    /// Indices into ArchiveNavigator.entries for child nodes.
28    pub children: Vec<usize>,
29    /// Index of parent node (ROOT_IDX for root's children, usize::MAX for root itself).
30    pub parent: usize,
31    /// Index into the original manifest.files Vec. None for virtual directory nodes.
32    pub manifest_index: Option<usize>,
33}
34
35/// Builds and navigates a virtual directory tree from a flat FXAR manifest.
36pub struct ArchiveNavigator {
37    /// Flat array of all nodes (root at index 0, then others).
38    pub entries: Vec<ArchiveEntry>,
39    pub manifest: FxarManifest,
40    /// Selected node index (for TUI cursor).
41    pub selected: usize,
42}
43
44impl ArchiveNavigator {
45    /// Open the manifest and build the virtual tree.
46    pub fn from_manifest(manifest: FxarManifest) -> std::io::Result<Self> {
47        // Phase 1: Validate all paths
48        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        // Phase 2: Build tree
58        let mut entries = Vec::new();
59
60        // Root node at index 0
61        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        // path->index cache for intermediate directories
77        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                    // Leaf node (file)
95                    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                    // Reuse existing directory node
114                    parent_idx = existing_idx;
115                } else {
116                    // Create new virtual directory node
117                    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        // Phase 3: Sort children in each directory: directories first (alpha), then files (alpha)
140        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    /// Returns the list of snapshot names from the manifest.
213    pub fn list_snapshots(&self) -> &[String] {
214        &self.manifest.snapshots
215    }
216
217    /// Count entries (files and dirs) whose path starts with `snapshot/` or equals `snapshot`.
218    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    /// Returns the set of entry indices visible under a snapshot filter.
228    /// If `snapshot_filter` is None, returns all entry indices.
229    /// If Some(snap), returns: root (0) + snap node + all nodes under snap.
230    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]; // always include root
235                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
250/// Validate a manifest path: reject absolute, traversal, empty, and null-byte paths.
251fn 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        // Root has 2 children: snap1, snap2
309        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        // snap1 -> tree
319        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        // snap1/tree -> children sorted: dirs first (src), then files (README.md)
325        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        // snap1/tree/src -> children sorted: lib.rs, main.rs
335        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        // Virtual dir has no manifest entry
341        assert!(nav.manifest_entry_for(src_idx).is_none());
342
343        // File has manifest entry with correct size
344        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        // snap2 -> tree -> config.toml (file), data.bin (file)
350        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        // Parent traversal
363        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        // Absolute path
372        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        // Traversal
381        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        // Empty path
390        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        // Valid path succeeds
399        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        // Starts at root (index 0)
423        assert_eq!(nav.selected, 0);
424        assert_eq!(nav.selected_entry().unwrap().name, "/");
425
426        // Move forward
427        nav.select_next();
428        assert_eq!(nav.selected, 1);
429
430        nav.select_next();
431        assert_eq!(nav.selected, 2);
432
433        // Move backward
434        nav.select_prev();
435        assert_eq!(nav.selected, 1);
436
437        // Can't go below 0
438        nav.select_prev();
439        assert_eq!(nav.selected, 0);
440        nav.select_prev();
441        assert_eq!(nav.selected, 0);
442
443        // Can't go past end
444        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        // snap1 node + snap1/tree + snap1/tree/a.txt + snap1/tree/b.txt + root = 5
492        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}