Skip to main content

vfs/posix/
tar_fs.rs

1#![allow(unsafe_code)]
2
3use super::vfs::{
4    normalize_path, VfsError, VfsResult, VirtualDirEntry, VirtualFileSystem, VirtualStat,
5    VirtualUtimeSpec,
6};
7use crate::package_format::{
8    generated::v1::{self, TarEntryKind},
9    parse_aospkg_header, validate_mount_range, AospkgHeader,
10};
11use memmap2::Mmap;
12use std::collections::{BTreeMap, BTreeSet};
13use std::fs::File;
14use std::hash::{Hash, Hasher};
15use std::io;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex, OnceLock, Weak};
18
19const MAX_TAR_INDEX_ENTRIES: usize = 200_000;
20const MAX_TAR_CACHE_ARCHIVES: usize = 64;
21const MAX_TAR_SYMLINKS: usize = 40;
22
23/// Read-only filesystem backed by the mount chunk of a `.aospkg` package.
24///
25/// The package container is `header + manifest + mount index + mount.tar`.
26/// `TarFileSystem::open` decodes only the precomputed mount index and serves
27/// reads from the uncompressed `mount.tar` chunk at `mountBase + offset`.
28/// Extraction would create a duplicate host tree, thousands of physical inodes,
29/// and a cleanup problem before reading the same bytes again.
30///
31/// File reads are an O(log n) index lookup plus a page-cache-backed memory
32/// slice; metadata and directory listings come from the in-memory index. The
33/// index must be pre-sorted by canonical path, and load performs a release-safe
34/// adjacent-order check before any binary-search-dependent path can run. The
35/// mmap is keyed by file identity and shared across VMs, so RSS follows the
36/// pages actually touched rather than the full archive size. This open path is
37/// on VM startup (`configure_vm`), so it must stay O(index decode): never parse
38/// tar headers, read/hash the whole archive, or recover metadata from legacy
39/// in-archive JSON here.
40///
41/// This filesystem is mounted only as a granular package-version leaf such as
42/// `/opt/agentos/pkgs/<pkg>/<version>`. Managed commands and `current` aliases
43/// are separate symlink leaf mounts, while parent directories stay writable
44/// overlay directories so user-installed files can coexist with managed ones.
45#[derive(Clone)]
46pub struct TarFileSystem {
47    archive: Arc<CachedTarArchive>,
48    root: String,
49}
50
51impl TarFileSystem {
52    pub fn open(path: impl AsRef<Path>) -> VfsResult<Self> {
53        Self::open_at(path, "/")
54    }
55
56    pub fn open_at(path: impl AsRef<Path>, root: &str) -> VfsResult<Self> {
57        let path = path.as_ref().to_path_buf();
58        let archive = cached_archive(path)?;
59        let root = normalize_path(root);
60        let node = archive.node(&root)?;
61        if !matches!(node.kind, TarNodeKind::Directory) {
62            return Err(VfsError::new(
63                "ENOTDIR",
64                format!("tar mount root is not a directory: {root}"),
65            ));
66        }
67        Ok(Self { archive, root })
68    }
69
70    #[doc(hidden)]
71    pub fn archive_ptr(&self) -> usize {
72        Arc::as_ptr(&self.archive) as usize
73    }
74
75    pub fn source_path(&self) -> &Path {
76        &self.archive.path
77    }
78
79    pub fn archive_root(&self) -> &str {
80        &self.root
81    }
82
83    fn to_archive_path(&self, path: &str) -> String {
84        let normalized = normalize_path(path);
85        if self.root == "/" {
86            normalized
87        } else if normalized == "/" {
88            self.root.clone()
89        } else {
90            normalize_path(&format!(
91                "{}/{}",
92                self.root,
93                normalized.trim_start_matches('/')
94            ))
95        }
96    }
97
98    fn to_guest_path(&self, archive_path: &str) -> VfsResult<String> {
99        if self.root == "/" {
100            return Ok(archive_path.to_owned());
101        }
102        if archive_path == self.root {
103            return Ok(String::from("/"));
104        }
105        let prefix = format!("{}/", self.root.trim_end_matches('/'));
106        let suffix = archive_path.strip_prefix(&prefix).ok_or_else(|| {
107            VfsError::new(
108                "EXDEV",
109                format!("tar symlink resolved outside mounted subtree: {archive_path}"),
110            )
111        })?;
112        Ok(format!("/{suffix}"))
113    }
114
115    fn ensure_within_root(&self, archive_path: &str) -> VfsResult<()> {
116        if self.root == "/" || archive_path == self.root {
117            return Ok(());
118        }
119        let prefix = format!("{}/", self.root.trim_end_matches('/'));
120        if archive_path.starts_with(&prefix) {
121            Ok(())
122        } else {
123            Err(VfsError::new(
124                "EXDEV",
125                format!("tar path resolved outside mounted subtree: {archive_path}"),
126            ))
127        }
128    }
129
130    fn resolve_path(&self, path: &str, follow_final_symlink: bool) -> VfsResult<String> {
131        let normalized = self.to_archive_path(path);
132        if normalized == "/" {
133            return Ok(normalized);
134        }
135
136        let mut pending = path_components(&normalized);
137        let mut current = String::from("/");
138        let mut followed = 0usize;
139
140        while let Some(component) = pending.pop_front() {
141            let candidate = join_path(&current, &component);
142            let node = self.archive.node(&candidate)?;
143            let should_follow = follow_final_symlink || !pending.is_empty();
144
145            if should_follow {
146                if let TarNodeKind::Symlink { target } = &node.kind {
147                    followed += 1;
148                    if followed > MAX_TAR_SYMLINKS {
149                        return Err(VfsError::new(
150                            "ELOOP",
151                            format!("too many levels of symbolic links, '{path}'"),
152                        ));
153                    }
154                    let target_path = if target.starts_with('/') {
155                        normalize_path(target)
156                    } else {
157                        normalize_path(&format!("{}/{}", parent_path(&candidate), target))
158                    };
159                    ensure_archive_path(&target_path)?;
160                    let mut target_components = path_components(&target_path);
161                    target_components.extend(pending);
162                    pending = target_components;
163                    current = String::from("/");
164                    continue;
165                }
166            }
167
168            if !pending.is_empty() && !matches!(node.kind, TarNodeKind::Directory) {
169                return Err(VfsError::new(
170                    "ENOTDIR",
171                    format!("not a directory, realpath '{candidate}'"),
172                ));
173            }
174
175            current = candidate;
176        }
177
178        self.ensure_within_root(&current)?;
179        Ok(current)
180    }
181
182    fn readonly_error(op: &str, path: &str) -> VfsError {
183        VfsError::new("EROFS", format!("read-only tar filesystem, {op} '{path}'"))
184    }
185}
186
187impl VirtualFileSystem for TarFileSystem {
188    fn read_file(&mut self, path: &str) -> VfsResult<Vec<u8>> {
189        let resolved = self.resolve_path(path, true)?;
190        let node = self.archive.node(&resolved)?;
191        let TarNodeKind::File { offset, size } = node.kind else {
192            return Err(if matches!(node.kind, TarNodeKind::Directory) {
193                VfsError::new(
194                    "EISDIR",
195                    format!("illegal operation on a directory, read '{path}'"),
196                )
197            } else {
198                VfsError::new("EINVAL", format!("not a regular file, read '{path}'"))
199            });
200        };
201        self.archive.validate_backing_file()?;
202        let range = validate_mount_range(&self.archive.container, offset, size)?;
203        Ok(self.archive.mmap[range].to_vec())
204    }
205
206    fn read_dir(&mut self, path: &str) -> VfsResult<Vec<String>> {
207        Ok(self
208            .read_dir_with_types(path)?
209            .into_iter()
210            .map(|entry| entry.name)
211            .collect())
212    }
213
214    fn read_dir_with_types(&mut self, path: &str) -> VfsResult<Vec<VirtualDirEntry>> {
215        let resolved = self.resolve_path(path, true)?;
216        let node = self.archive.node(&resolved)?;
217        if !matches!(node.kind, TarNodeKind::Directory) {
218            return Err(VfsError::new(
219                "ENOTDIR",
220                format!("not a directory, readdir '{path}'"),
221            ));
222        }
223
224        let children = self
225            .archive
226            .children
227            .get(&resolved)
228            .cloned()
229            .unwrap_or_default();
230        Ok(children
231            .into_iter()
232            .filter_map(|name| {
233                let child_path = join_path(&resolved, &name);
234                self.archive
235                    .nodes
236                    .get(&child_path)
237                    .map(|child| VirtualDirEntry {
238                        name,
239                        is_directory: matches!(child.kind, TarNodeKind::Directory),
240                        is_symbolic_link: matches!(child.kind, TarNodeKind::Symlink { .. }),
241                    })
242            })
243            .collect())
244    }
245
246    fn write_file(&mut self, path: &str, _content: impl Into<Vec<u8>>) -> VfsResult<()> {
247        Err(Self::readonly_error("write", path))
248    }
249
250    fn create_dir(&mut self, path: &str) -> VfsResult<()> {
251        Err(Self::readonly_error("mkdir", path))
252    }
253
254    fn mkdir(&mut self, path: &str, _recursive: bool) -> VfsResult<()> {
255        Err(Self::readonly_error("mkdir", path))
256    }
257
258    fn exists(&self, path: &str) -> bool {
259        self.resolve_path(path, true)
260            .map(|resolved| self.archive.nodes.contains_key(&resolved))
261            .unwrap_or(false)
262    }
263
264    fn stat(&mut self, path: &str) -> VfsResult<VirtualStat> {
265        let resolved = self.resolve_path(path, true)?;
266        Ok(self.archive.node(&resolved)?.stat())
267    }
268
269    fn remove_file(&mut self, path: &str) -> VfsResult<()> {
270        Err(Self::readonly_error("unlink", path))
271    }
272
273    fn remove_dir(&mut self, path: &str) -> VfsResult<()> {
274        Err(Self::readonly_error("rmdir", path))
275    }
276
277    fn rename(&mut self, old_path: &str, new_path: &str) -> VfsResult<()> {
278        Err(VfsError::new(
279            "EROFS",
280            format!("read-only tar filesystem, rename '{old_path}' to '{new_path}'"),
281        ))
282    }
283
284    fn realpath(&self, path: &str) -> VfsResult<String> {
285        let resolved = self.resolve_path(path, true)?;
286        self.to_guest_path(&resolved)
287    }
288
289    fn symlink(&mut self, target: &str, link_path: &str) -> VfsResult<()> {
290        Err(VfsError::new(
291            "EROFS",
292            format!("read-only tar filesystem, symlink '{link_path}' -> '{target}'"),
293        ))
294    }
295
296    fn read_link(&self, path: &str) -> VfsResult<String> {
297        let normalized = self.resolve_path(path, false)?;
298        match &self.archive.node(&normalized)?.kind {
299            TarNodeKind::Symlink { target } => Ok(target.clone()),
300            _ => Err(VfsError::new(
301                "EINVAL",
302                format!("not a symlink, readlink '{path}'"),
303            )),
304        }
305    }
306
307    fn lstat(&self, path: &str) -> VfsResult<VirtualStat> {
308        let normalized = self.resolve_path(path, false)?;
309        Ok(self.archive.node(&normalized)?.stat())
310    }
311
312    fn link(&mut self, old_path: &str, new_path: &str) -> VfsResult<()> {
313        Err(VfsError::new(
314            "EROFS",
315            format!("read-only tar filesystem, link '{old_path}' to '{new_path}'"),
316        ))
317    }
318
319    fn chmod(&mut self, path: &str, _mode: u32) -> VfsResult<()> {
320        Err(Self::readonly_error("chmod", path))
321    }
322
323    fn chown(&mut self, path: &str, _uid: u32, _gid: u32) -> VfsResult<()> {
324        Err(Self::readonly_error("chown", path))
325    }
326
327    fn utimes(&mut self, path: &str, _atime_ms: u64, _mtime_ms: u64) -> VfsResult<()> {
328        Err(Self::readonly_error("utimes", path))
329    }
330
331    fn utimes_spec(
332        &mut self,
333        path: &str,
334        _atime: VirtualUtimeSpec,
335        _mtime: VirtualUtimeSpec,
336        _follow_symlinks: bool,
337    ) -> VfsResult<()> {
338        Err(Self::readonly_error("utimes", path))
339    }
340
341    fn truncate(&mut self, path: &str, _length: u64) -> VfsResult<()> {
342        Err(Self::readonly_error("truncate", path))
343    }
344
345    fn pread(&mut self, path: &str, offset: u64, length: usize) -> VfsResult<Vec<u8>> {
346        let resolved = self.resolve_path(path, true)?;
347        let node = self.archive.node(&resolved)?;
348        let TarNodeKind::File {
349            offset: file_offset,
350            size,
351        } = node.kind
352        else {
353            return Err(if matches!(node.kind, TarNodeKind::Directory) {
354                VfsError::new(
355                    "EISDIR",
356                    format!("illegal operation on a directory, pread '{path}'"),
357                )
358            } else {
359                VfsError::new("EINVAL", format!("not a regular file, pread '{path}'"))
360            });
361        };
362        if offset >= size {
363            return Ok(Vec::new());
364        }
365        let readable = (size - offset).min(length as u64);
366        self.archive.validate_backing_file()?;
367        let range = validate_mount_range(
368            &self.archive.container,
369            file_offset
370                .checked_add(offset)
371                .ok_or_else(|| VfsError::new("EOVERFLOW", "pread offset overflows u64"))?,
372            readable,
373        )?;
374        Ok(self.archive.mmap[range].to_vec())
375    }
376}
377
378struct CachedTarArchive {
379    path: PathBuf,
380    file: File,
381    mmap: Mmap,
382    container: AospkgHeader,
383    identity: FileIdentity,
384    nodes: BTreeMap<String, TarNode>,
385    children: BTreeMap<String, BTreeSet<String>>,
386}
387
388impl CachedTarArchive {
389    fn node(&self, path: &str) -> VfsResult<&TarNode> {
390        self.nodes
391            .get(path)
392            .ok_or_else(|| VfsError::new("ENOENT", format!("no such file or directory, '{path}'")))
393    }
394
395    fn validate_backing_file(&self) -> VfsResult<()> {
396        let current = FileIdentity::from_file(&self.file)?;
397        if current != self.identity {
398            return Err(VfsError::new(
399                "ESTALE",
400                format!(
401                    "tar archive backing file changed while mounted: {}",
402                    self.path.display()
403                ),
404            ));
405        }
406        Ok(())
407    }
408}
409
410#[derive(Debug, Clone)]
411struct TarNode {
412    kind: TarNodeKind,
413    mode: u32,
414    uid: u32,
415    gid: u32,
416    mtime_ms: u64,
417    ino: u64,
418    dev: u64,
419}
420
421impl TarNode {
422    fn stat(&self) -> VirtualStat {
423        let size = match &self.kind {
424            TarNodeKind::File { size, .. } => *size,
425            TarNodeKind::Directory => 4096,
426            TarNodeKind::Symlink { target } => target.len() as u64,
427        };
428        VirtualStat {
429            mode: self.mode,
430            size,
431            blocks: size.div_ceil(512),
432            dev: self.dev,
433            rdev: 0,
434            is_directory: matches!(self.kind, TarNodeKind::Directory),
435            is_symbolic_link: matches!(self.kind, TarNodeKind::Symlink { .. }),
436            atime_ms: self.mtime_ms,
437            atime_nsec: 0,
438            mtime_ms: self.mtime_ms,
439            mtime_nsec: 0,
440            ctime_ms: self.mtime_ms,
441            ctime_nsec: 0,
442            birthtime_ms: self.mtime_ms,
443            ino: self.ino,
444            nlink: 1,
445            uid: self.uid,
446            gid: self.gid,
447        }
448    }
449}
450
451#[derive(Debug, Clone)]
452enum TarNodeKind {
453    File { offset: u64, size: u64 },
454    Directory,
455    Symlink { target: String },
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
459struct FileIdentity {
460    len: u64,
461    dev: u64,
462    ino: u64,
463    mtime_nsec: i128,
464    ctime_nsec: i128,
465}
466
467impl FileIdentity {
468    fn from_file(file: &File) -> VfsResult<Self> {
469        Self::from_metadata(file.metadata().map_err(io_to_vfs)?)
470    }
471
472    fn from_metadata(metadata: std::fs::Metadata) -> VfsResult<Self> {
473        #[cfg(unix)]
474        let (dev, ino, mtime_nsec, ctime_nsec) = {
475            use std::os::unix::fs::MetadataExt;
476            (
477                metadata.dev(),
478                metadata.ino(),
479                unix_time_nsec(metadata.mtime(), metadata.mtime_nsec()),
480                unix_time_nsec(metadata.ctime(), metadata.ctime_nsec()),
481            )
482        };
483        #[cfg(not(unix))]
484        let (dev, ino, mtime_nsec, ctime_nsec) = {
485            let modified = metadata
486                .modified()
487                .ok()
488                .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
489                .map(|duration| duration.as_nanos() as i128)
490                .unwrap_or_default();
491            (0, 0, modified, 0)
492        };
493        Ok(Self {
494            len: metadata.len(),
495            dev,
496            ino,
497            mtime_nsec,
498            ctime_nsec,
499        })
500    }
501}
502
503#[cfg(unix)]
504fn unix_time_nsec(sec: i64, nsec: i64) -> i128 {
505    i128::from(sec) * 1_000_000_000 + i128::from(nsec)
506}
507
508fn cached_archive(path: PathBuf) -> VfsResult<Arc<CachedTarArchive>> {
509    let file = File::open(&path).map_err(io_to_vfs)?;
510    let identity = FileIdentity::from_file(&file)?;
511    let cache = archive_cache();
512    let mut guard = cache
513        .lock()
514        .map_err(|_| VfsError::new("EIO", "tar archive cache mutex poisoned"))?;
515
516    // A cache hit means the Weak upgraded to a live archive that still holds
517    // the source fd open. On Unix that fd pins the inode, so `(dev, ino)` cannot
518    // be reused for a different file while the cached entry is alive. Including
519    // `ctime_nsec` catches in-place rewrites even when build tools normalize or
520    // preserve mtime; including nanosecond mtime avoids the old millisecond
521    // collision window. The mutex covers lookup, load, and insert.
522    if let Some(existing) = guard.archives.get(&identity).and_then(Weak::upgrade) {
523        if existing.path == path {
524            return Ok(existing);
525        }
526        return Err(VfsError::new(
527            "EINVAL",
528            format!(
529                "tar identity collision or moved source: identity {identity:?} already maps to {} not {}",
530                existing.path.display(),
531                path.display()
532            ),
533        ));
534    }
535
536    guard.archives.retain(|key, weak| {
537        let live = weak.strong_count() > 0;
538        if !live {
539            tracing::warn!(
540                identity = ?key,
541                "evicting unused tar archive cache entry"
542            );
543        }
544        live
545    });
546
547    if guard.archives.len() >= MAX_TAR_CACHE_ARCHIVES {
548        return Err(VfsError::new(
549            "ENOMEM",
550            format!(
551                "tar archive cache entries exceeded: {} entries > {} entries (raise via invariant.tarArchiveCacheEntries)",
552                guard.archives.len() + 1,
553                MAX_TAR_CACHE_ARCHIVES
554            ),
555        ));
556    }
557
558    let archive = Arc::new(load_archive(path, file, identity)?);
559    guard.archives.insert(identity, Arc::downgrade(&archive));
560    Ok(archive)
561}
562
563fn archive_cache() -> &'static Mutex<TarArchiveCache> {
564    static CACHE: OnceLock<Mutex<TarArchiveCache>> = OnceLock::new();
565    CACHE.get_or_init(|| Mutex::new(TarArchiveCache::default()))
566}
567
568#[derive(Default)]
569struct TarArchiveCache {
570    archives: BTreeMap<FileIdentity, Weak<CachedTarArchive>>,
571}
572
573fn load_archive(path: PathBuf, file: File, identity: FileIdentity) -> VfsResult<CachedTarArchive> {
574    let mmap = unsafe {
575        // SAFETY: TarFileSystem is only constructed for immutable package tar
576        // artifacts. We hold the opened file for the lifetime of the mmap and
577        // validate size/identity before reading from mapped member ranges. A
578        // caller that truncates the same inode while a VM is live violates the
579        // package-store lifecycle documented on TarFileSystem.
580        Mmap::map(&file)
581    }
582    .map_err(io_to_vfs)?;
583
584    let container = parse_aospkg_header(&mmap)?;
585    let index = crate::package_format::versioned::decode_mount_index(&mmap[container.index.clone()])
586        .map_err(|error| VfsError::new("EINVAL", format!("decode .aospkg mount index: {error}")))?;
587    validate_sorted_entries(&index.tar_entries)?;
588
589    let mut nodes = BTreeMap::new();
590    let mut children = BTreeMap::<String, BTreeSet<String>>::new();
591    let dev = identity_device(&identity);
592    let mut next_ino = 1u64;
593
594    for entry in index.tar_entries {
595        let path = entry.path;
596        ensure_archive_path(&path)?;
597        ensure_index_capacity(nodes.len() + 1)?;
598        if matches!(entry.kind, TarEntryKind::File) {
599            validate_mount_range(&container, entry.offset, entry.size)?;
600        }
601        let kind = match entry.kind {
602            TarEntryKind::File => TarNodeKind::File {
603                offset: entry.offset,
604                size: entry.size,
605            },
606            TarEntryKind::Directory => TarNodeKind::Directory,
607            TarEntryKind::Symlink => TarNodeKind::Symlink {
608                target: entry.link_target.ok_or_else(|| {
609                    VfsError::new("EINVAL", format!("missing linkTarget for symlink {path}"))
610                })?,
611            },
612        };
613        let mtime_ms = u64::try_from(entry.mtime)
614            .map_err(|_| VfsError::new("EINVAL", format!("negative mtime for {path}")))?
615            .checked_mul(1_000)
616            .ok_or_else(|| VfsError::new("EOVERFLOW", format!("mtime overflows ms for {path}")))?;
617        nodes.insert(
618            path.clone(),
619            TarNode {
620                kind,
621                mode: entry.mode,
622                uid: entry.uid,
623                gid: entry.gid,
624                mtime_ms,
625                ino: next_ino,
626                dev,
627            },
628        );
629        next_ino += 1;
630        add_child(&path, &mut children);
631        if matches!(
632            nodes.get(&path).map(|node| &node.kind),
633            Some(TarNodeKind::Directory)
634        ) {
635            children.entry(path).or_default();
636        }
637    }
638
639    Ok(CachedTarArchive {
640        path,
641        file,
642        mmap,
643        container,
644        identity,
645        nodes,
646        children,
647    })
648}
649
650fn add_child(path: &str, children: &mut BTreeMap<String, BTreeSet<String>>) {
651    if path == "/" {
652        children.entry(String::from("/")).or_default();
653        return;
654    }
655    let parent = parent_path(path);
656    let name = basename(path);
657    children.entry(parent).or_default().insert(name);
658}
659
660fn ensure_index_capacity(observed: usize) -> VfsResult<()> {
661    if observed > MAX_TAR_INDEX_ENTRIES {
662        return Err(VfsError::new(
663            "ENOMEM",
664            format!(
665                "tar filesystem index entries exceeded: {observed} entries > {MAX_TAR_INDEX_ENTRIES} entries (raise via invariant.tarFilesystemIndexEntries)"
666            ),
667        ));
668    }
669    Ok(())
670}
671
672fn validate_sorted_entries(entries: &[v1::TarEntry]) -> VfsResult<()> {
673    for pair in entries.windows(2) {
674        let [previous, current] = pair else {
675            continue;
676        };
677        if previous.path >= current.path {
678            return Err(VfsError::new(
679                "EINVAL",
680                format!(
681                    ".aospkg mount index is not sorted by canonical path: {:?} before {:?}",
682                    previous.path, current.path
683                ),
684            ));
685        }
686    }
687    Ok(())
688}
689
690fn ensure_archive_path(path: &str) -> VfsResult<()> {
691    let normalized = normalize_path(path);
692    if normalized != path {
693        return Err(VfsError::new(
694            "EINVAL",
695            format!("path normalization mismatch in tar filesystem: {path}"),
696        ));
697    }
698    Ok(())
699}
700
701fn path_components(path: &str) -> std::collections::VecDeque<String> {
702    normalize_path(path)
703        .split('/')
704        .filter(|part| !part.is_empty())
705        .map(String::from)
706        .collect()
707}
708
709fn join_path(parent: &str, child: &str) -> String {
710    if parent == "/" {
711        format!("/{child}")
712    } else {
713        format!("{parent}/{child}")
714    }
715}
716
717fn parent_path(path: &str) -> String {
718    let normalized = normalize_path(path);
719    let parent = Path::new(&normalized)
720        .parent()
721        .unwrap_or_else(|| Path::new("/"));
722    let value = parent.to_string_lossy();
723    if value.is_empty() {
724        String::from("/")
725    } else {
726        value.into_owned()
727    }
728}
729
730fn basename(path: &str) -> String {
731    let normalized = normalize_path(path);
732    Path::new(&normalized)
733        .file_name()
734        .map(|name| name.to_string_lossy().into_owned())
735        .unwrap_or_else(|| String::from("/"))
736}
737
738fn identity_device(identity: &FileIdentity) -> u64 {
739    // Guest-visible `st_dev` must be stable for repeated opens of the same
740    // package and practically distinct across package files. Derive it from
741    // the host file identity rather than archive bytes so VM startup never
742    // reintroduces a whole-tar read/hash.
743    let mut hasher = std::collections::hash_map::DefaultHasher::new();
744    identity.dev.hash(&mut hasher);
745    identity.ino.hash(&mut hasher);
746    hasher.finish().max(1)
747}
748
749fn io_to_vfs(error: io::Error) -> VfsError {
750    let code = match error.kind() {
751        io::ErrorKind::NotFound => "ENOENT",
752        io::ErrorKind::PermissionDenied => "EACCES",
753        io::ErrorKind::AlreadyExists => "EEXIST",
754        io::ErrorKind::InvalidInput | io::ErrorKind::InvalidData => "EINVAL",
755        io::ErrorKind::UnexpectedEof => "EIO",
756        _ => "EIO",
757    };
758    VfsError::new(code, error.to_string())
759}