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