Skip to main content

virtual_fs/
webc_volume_fs.rs

1use std::{
2    convert::{TryFrom, TryInto},
3    io::Cursor,
4    path::{Path, PathBuf},
5    pin::Pin,
6    result::Result,
7    task::Poll,
8};
9
10use futures::future::BoxFuture;
11use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite};
12use webc::{
13    Container, Metadata as WebcMetadata, PathSegment, PathSegmentError, PathSegments,
14    ToPathSegments, Volume, compat::SharedBytes,
15};
16
17use crate::{
18    DirEntry, EmptyFileSystem, FileOpener, FileSystem, FileType, FsError, Metadata,
19    OpenOptionsConfig, OverlayFileSystem, ReadDir, VirtualFile,
20};
21
22#[derive(Debug, Clone)]
23pub struct WebcVolumeFileSystem {
24    volume: Volume,
25}
26
27impl WebcVolumeFileSystem {
28    pub fn new(volume: Volume) -> Self {
29        WebcVolumeFileSystem { volume }
30    }
31
32    pub fn volume(&self) -> &Volume {
33        &self.volume
34    }
35
36    /// Resolve `path`, following symlinks in every intermediate component.
37    /// `follow_trailing` also follows a trailing symlink (`stat`); otherwise it
38    /// is reported as-is (`lstat`). Returns the resolved path and its metadata
39    /// (`None` if missing) so the caller needn't look it up again.
40    fn resolve_symlinks(
41        &self,
42        path: &Path,
43        follow_trailing: bool,
44    ) -> Result<(PathBuf, Option<WebcMetadata>), FsError> {
45        // Maximum number of symlinks to follow before giving up, matching Linux's
46        // MAXSYMLINKS. Guards against symlink loops.
47        const MAX_SYMLINK_DEPTH: usize = 40;
48
49        let mut current = normalize(path).map_err(|_| FsError::InvalidInput)?;
50
51        for _ in 0..=MAX_SYMLINK_DEPTH {
52            match self.volume().metadata(&current) {
53                // Fully resolved: exists and isn't a symlink.
54                Some(meta) if !meta.is_symlink() => {
55                    return Ok((PathBuf::from(current.to_string()), Some(meta)));
56                }
57                // Trailing symlink; intermediates are already resolved (else
58                // metadata() would have returned None, the branch below).
59                Some(meta) => {
60                    if !follow_trailing {
61                        // lstat: keep the link itself.
62                        return Ok((PathBuf::from(current.to_string()), Some(meta)));
63                    }
64                    let segments: Vec<PathSegment> = current.iter().cloned().collect();
65                    current = self.expand_symlink_at(&segments, segments.len() - 1)?;
66                }
67                // Not directly resolvable: an intermediate component may be a
68                // symlink (metadata() stops at the first one), so expand it.
69                None => match self.expand_first_symlink(&current)? {
70                    Some(next) => current = next,
71                    None => return Ok((PathBuf::from(current.to_string()), None)), // missing
72                },
73            }
74        }
75
76        // Too many levels of symlinks.
77        Err(FsError::InvalidInput)
78    }
79
80    /// Walk `path`'s components and, if any is a symlink, return `path` with the
81    /// first such symlink replaced by its target. Returns `None` when no
82    /// component is a symlink (i.e. the path is already resolved or is genuinely missing).
83    fn expand_first_symlink(&self, path: &PathSegments) -> Result<Option<PathSegments>, FsError> {
84        let segments: Vec<PathSegment> = path.iter().cloned().collect();
85        for i in 0..segments.len() {
86            let prefix: PathSegments = segments[..=i].iter().cloned().collect();
87            match self.volume().metadata(&prefix) {
88                Some(meta) if meta.is_symlink() => {
89                    return Ok(Some(self.expand_symlink_at(&segments, i)?));
90                }
91                // A real directory/file: keep walking.
92                Some(_) => {}
93                // This component is missing, so the whole path is missing.
94                None => return Ok(None),
95            }
96        }
97        Ok(None)
98    }
99
100    /// Replace the symlink at `segments[..=i]` with its target, resolving a
101    /// relative target against the link's parent and keeping any trailing
102    /// components. The result is normalized (so `..` in the target is applied).
103    fn expand_symlink_at(
104        &self,
105        segments: &[PathSegment],
106        i: usize,
107    ) -> Result<PathSegments, FsError> {
108        let link: PathSegments = segments[..=i].iter().cloned().collect();
109        let (target, _) = self
110            .volume()
111            .read_link(&link)
112            .ok_or(FsError::EntryNotFound)?;
113
114        // webc paths are always '/'-rooted, so check the string directly rather
115        // than Path::is_absolute() (which is host-platform dependent).
116        let mut combined = String::new();
117        if target.starts_with('/') {
118            combined.push_str(&target);
119        } else {
120            // Resolve relative to the link's parent, segments[..i].
121            for segment in &segments[..i] {
122                combined.push('/');
123                combined.push_str(segment.as_str());
124            }
125            combined.push('/');
126            combined.push_str(&target);
127        }
128        for segment in &segments[i + 1..] {
129            combined.push('/');
130            combined.push_str(segment.as_str());
131        }
132
133        normalize(Path::new(&combined)).map_err(|_| FsError::InvalidInput)
134    }
135
136    /// Get a filesystem where all [`Volume`]s in a [`Container`] are mounted to
137    /// the root directory.
138    pub fn mount_all(
139        container: &Container,
140    ) -> OverlayFileSystem<EmptyFileSystem, Vec<WebcVolumeFileSystem>> {
141        let mut filesystems = Vec::new();
142
143        for volume in container.volumes().into_values() {
144            filesystems.push(WebcVolumeFileSystem::new(volume));
145        }
146
147        OverlayFileSystem::new(EmptyFileSystem::default(), filesystems)
148    }
149}
150
151impl FileSystem for WebcVolumeFileSystem {
152    fn readlink(&self, path: &Path) -> crate::Result<PathBuf> {
153        let path = normalize(path).map_err(|_| FsError::InvalidInput)?;
154
155        match self.volume().metadata(&path) {
156            Some(meta) if !meta.is_symlink() => Err(FsError::InvalidInput),
157            Some(_) => self
158                .volume()
159                .read_link(&path)
160                .map(|(target, _)| PathBuf::from(target))
161                .ok_or(FsError::EntryNotFound),
162            None => Err(FsError::EntryNotFound),
163        }
164    }
165
166    fn read_dir(&self, path: &Path) -> Result<crate::ReadDir, FsError> {
167        // opendir follows symlinks, including a symlinked directory.
168        let (resolved, meta) = self.resolve_symlinks(path, true)?;
169        let meta = meta.map(compat_meta).ok_or(FsError::EntryNotFound)?;
170
171        if !meta.is_dir() {
172            return Err(FsError::BaseNotDirectory);
173        }
174
175        // List the resolved directory, but keep the caller's path as the entry
176        // prefix (like `std::fs::read_dir`).
177        let display_path = normalize(path).map_err(|_| FsError::InvalidInput)?;
178        let resolved = normalize(resolved.as_path()).map_err(|_| FsError::InvalidInput)?;
179
180        let mut entries = Vec::new();
181
182        for (name, _, meta) in self
183            .volume()
184            .read_dir(&resolved)
185            .ok_or(FsError::EntryNotFound)?
186        {
187            let path = PathBuf::from(display_path.join(name).to_string());
188            entries.push(DirEntry {
189                path,
190                metadata: Ok(compat_meta(meta)),
191            });
192        }
193
194        Ok(ReadDir::new(entries))
195    }
196
197    fn create_dir(&self, path: &Path) -> Result<(), FsError> {
198        // The name must be free. lstat: a trailing symlink already claims it.
199        if self.symlink_metadata(path).is_ok() {
200            return Err(FsError::AlreadyExists);
201        }
202
203        // The parent must exist and be a directory. It's a traversed component,
204        // so follow symlinks to it (stat): a symlinked directory is a valid parent.
205        let parent = path.parent().unwrap_or_else(|| Path::new("/"));
206
207        match self.metadata(parent) {
208            Ok(parent_meta) if parent_meta.is_dir() => {
209                // The operation would normally be doable... but we're a readonly
210                // filesystem
211                Err(FsError::PermissionDenied)
212            }
213            Ok(_) | Err(FsError::EntryNotFound) => Err(FsError::BaseNotDirectory),
214            Err(other) => Err(other),
215        }
216    }
217
218    fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
219        // The original directory should exist. rmdir operates on the entry
220        // itself (a symlink is not a directory), so use lstat semantics.
221        let meta = self.symlink_metadata(path)?;
222
223        // and it should be a directory
224        if !meta.is_dir() {
225            return Err(FsError::BaseNotDirectory);
226        }
227
228        // but we are a readonly filesystem, so you can't modify anything
229        Err(FsError::PermissionDenied)
230    }
231
232    fn rename<'a>(&'a self, from: &'a Path, to: &'a Path) -> BoxFuture<'a, Result<(), FsError>> {
233        Box::pin(async {
234            // The source must exist. lstat: rename acts on the link, not its target.
235            let _ = self.symlink_metadata(from)?;
236
237            // The destination's parent must exist. It's a traversed component,
238            // so follow symlinks to it (stat).
239            let dest_parent = to.parent().unwrap_or_else(|| Path::new("/"));
240            let parent_meta = self.metadata(dest_parent)?;
241            if !parent_meta.is_dir() {
242                return Err(FsError::BaseNotDirectory);
243            }
244
245            // but we are a readonly filesystem, so you can't modify anything
246            Err(FsError::PermissionDenied)
247        })
248    }
249
250    fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
251        // `stat` semantics: follow symlinks and report the target.
252        let (_, meta) = self.resolve_symlinks(path, true)?;
253        meta.map(compat_meta).ok_or(FsError::EntryNotFound)
254    }
255
256    fn symlink_metadata(&self, path: &Path) -> crate::Result<Metadata> {
257        // `lstat` semantics: follow intermediate symlinks, but not a trailing one.
258        let (_, meta) = self.resolve_symlinks(path, false)?;
259        meta.map(compat_meta).ok_or(FsError::EntryNotFound)
260    }
261
262    fn remove_file(&self, path: &Path) -> Result<(), FsError> {
263        // unlink removes the entry itself; it does not follow a trailing
264        // symlink, so use lstat semantics.
265        let meta = self.symlink_metadata(path)?;
266
267        if !meta.is_file() {
268            return Err(FsError::NotAFile);
269        }
270
271        Err(FsError::PermissionDenied)
272    }
273
274    fn new_open_options(&self) -> crate::OpenOptions<'_> {
275        crate::OpenOptions::new(self)
276    }
277}
278
279impl FileOpener for WebcVolumeFileSystem {
280    fn open(
281        &self,
282        path: &Path,
283        conf: &OpenOptionsConfig,
284    ) -> crate::Result<Box<dyn crate::VirtualFile + Send + Sync + 'static>> {
285        // Follow symlinks so opening (and exec'ing) a symlinked file resolves to
286        // its target, matching a real filesystem.
287        let (resolved, resolved_meta) = self.resolve_symlinks(path, true)?;
288        let path = resolved.as_path();
289        if let Some(parent) = path.parent() {
290            let parent_meta = self.metadata(parent)?;
291            if !parent_meta.is_dir() {
292                return Err(FsError::BaseNotDirectory);
293            }
294        }
295
296        let timestamps = match resolved_meta {
297            Some(m) if m.is_file() => m.timestamps(),
298            Some(_) => return Err(FsError::NotAFile),
299            None if conf.create() || conf.create_new() => {
300                // The file would normally be created, but we are a readonly fs.
301                return Err(FsError::PermissionDenied);
302            }
303            None => return Err(FsError::EntryNotFound),
304        };
305
306        match self.volume().read_file(path) {
307            Some((bytes, _)) => Ok(Box::new(File {
308                timestamps,
309                content: Cursor::new(bytes),
310            })),
311            None => {
312                // The metadata() call should guarantee this, so something
313                // probably went wrong internally
314                Err(FsError::UnknownError)
315            }
316        }
317    }
318}
319
320#[derive(Debug, Clone, PartialEq)]
321struct File {
322    timestamps: Option<webc::Timestamps>,
323    content: Cursor<SharedBytes>,
324}
325
326impl VirtualFile for File {
327    fn last_accessed(&self) -> u64 {
328        0
329    }
330
331    fn last_modified(&self) -> u64 {
332        self.timestamps
333            .map(|t| t.modified())
334            .unwrap_or_else(|| get_modified(None))
335    }
336
337    fn created_time(&self) -> u64 {
338        0
339    }
340
341    fn size(&self) -> u64 {
342        self.content.get_ref().len().try_into().unwrap()
343    }
344
345    fn set_len(&mut self, _new_size: u64) -> crate::Result<()> {
346        Err(FsError::PermissionDenied)
347    }
348
349    fn unlink(&mut self) -> crate::Result<()> {
350        Err(FsError::PermissionDenied)
351    }
352
353    fn poll_read_ready(
354        self: Pin<&mut Self>,
355        _cx: &mut std::task::Context<'_>,
356    ) -> Poll<std::io::Result<usize>> {
357        let bytes_remaining =
358            self.content.get_ref().len() - usize::try_from(self.content.position()).unwrap();
359        Poll::Ready(Ok(bytes_remaining))
360    }
361
362    fn poll_write_ready(
363        self: Pin<&mut Self>,
364        _cx: &mut std::task::Context<'_>,
365    ) -> Poll<std::io::Result<usize>> {
366        Poll::Ready(Err(std::io::ErrorKind::PermissionDenied.into()))
367    }
368
369    fn as_owned_buffer(&self) -> Option<SharedBytes> {
370        Some(self.content.get_ref().clone())
371    }
372}
373
374impl AsyncRead for File {
375    fn poll_read(
376        mut self: Pin<&mut Self>,
377        cx: &mut std::task::Context<'_>,
378        buf: &mut tokio::io::ReadBuf<'_>,
379    ) -> Poll<std::io::Result<()>> {
380        AsyncRead::poll_read(Pin::new(&mut self.content), cx, buf)
381    }
382}
383
384impl AsyncSeek for File {
385    fn start_seek(mut self: Pin<&mut Self>, position: std::io::SeekFrom) -> std::io::Result<()> {
386        AsyncSeek::start_seek(Pin::new(&mut self.content), position)
387    }
388
389    fn poll_complete(
390        mut self: Pin<&mut Self>,
391        cx: &mut std::task::Context<'_>,
392    ) -> Poll<std::io::Result<u64>> {
393        AsyncSeek::poll_complete(Pin::new(&mut self.content), cx)
394    }
395}
396
397impl AsyncWrite for File {
398    fn poll_write(
399        self: Pin<&mut Self>,
400        _cx: &mut std::task::Context<'_>,
401        _buf: &[u8],
402    ) -> Poll<Result<usize, std::io::Error>> {
403        Poll::Ready(Err(std::io::ErrorKind::PermissionDenied.into()))
404    }
405
406    fn poll_flush(
407        self: Pin<&mut Self>,
408        _cx: &mut std::task::Context<'_>,
409    ) -> Poll<Result<(), std::io::Error>> {
410        Poll::Ready(Err(std::io::ErrorKind::PermissionDenied.into()))
411    }
412
413    fn poll_shutdown(
414        self: Pin<&mut Self>,
415        _cx: &mut std::task::Context<'_>,
416    ) -> Poll<Result<(), std::io::Error>> {
417        Poll::Ready(Err(std::io::ErrorKind::PermissionDenied.into()))
418    }
419}
420
421// HACK: WebC v2 doesn't have timestamps, and WebC v3 files sometimes
422// have directories with a zero timestamp as well. Since some programs
423// interpret a zero timestamp as the absence of a value, we return
424// 1 second past epoch instead.
425fn get_modified(timestamps: Option<webc::Timestamps>) -> u64 {
426    let modified = timestamps.map(|t| t.modified()).unwrap_or_default();
427    // 1 billion nanoseconds = 1 second
428    modified.max(1_000_000_000)
429}
430
431fn compat_meta(meta: WebcMetadata) -> Metadata {
432    match meta {
433        WebcMetadata::Dir { timestamps } => Metadata {
434            ft: FileType {
435                dir: true,
436                ..Default::default()
437            },
438            modified: get_modified(timestamps),
439            ..Default::default()
440        },
441        WebcMetadata::File {
442            length, timestamps, ..
443        } => Metadata {
444            ft: FileType {
445                file: true,
446                ..Default::default()
447            },
448            len: length.try_into().unwrap(),
449            modified: get_modified(timestamps),
450            ..Default::default()
451        },
452        WebcMetadata::Symlink {
453            target_length,
454            timestamps,
455        } => Metadata {
456            ft: FileType {
457                symlink: true,
458                ..Default::default()
459            },
460            len: target_length.try_into().unwrap(),
461            modified: get_modified(timestamps),
462            ..Default::default()
463        },
464    }
465}
466
467/// Normalize a [`Path`] into a [`PathSegments`], dealing with things like `..`
468/// and skipping `.`'s.
469fn normalize(path: &Path) -> Result<PathSegments, PathSegmentError> {
470    // normalization is handled by the ToPathSegments impl for Path
471    let result = path.to_path_segments();
472
473    if let Err(e) = &result {
474        tracing::debug!(
475            error = e as &dyn std::error::Error,
476            path=%path.display(),
477            "Unable to normalize a path",
478        );
479    }
480
481    result
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use std::collections::BTreeMap;
488    use std::convert::TryFrom;
489    use tokio::io::AsyncReadExt;
490    use wasmer_package::utils::from_bytes;
491    use webc::PathSegment;
492
493    const PYTHON_WEBC: &[u8] =
494        include_bytes!("../../../wasmer-test-files/examples/python--python@3.13.5.webc");
495
496    fn symlink_fs() -> WebcVolumeFileSystem {
497        let timestamps = webc::v3::Timestamps::default();
498        let dir = webc::v3::write::Directory::new(
499            BTreeMap::from_iter([
500                (
501                    PathSegment::parse("target.txt").unwrap(),
502                    webc::v3::write::DirEntry::File(webc::v3::write::FileEntry::borrowed(
503                        b"target", timestamps,
504                    )),
505                ),
506                (
507                    PathSegment::parse("link").unwrap(),
508                    webc::v3::write::DirEntry::Symlink(webc::v3::write::SymlinkEntry::borrowed(
509                        "target.txt",
510                        timestamps,
511                    )),
512                ),
513            ]),
514            timestamps,
515        );
516        let manifest = webc::metadata::Manifest::default();
517        let mut writer = webc::v3::write::Writer::new(webc::v3::ChecksumAlgorithm::Sha256)
518            .write_manifest(&manifest)
519            .unwrap()
520            .write_atoms(BTreeMap::new())
521            .unwrap();
522        writer.write_volume("atom", dir).unwrap();
523        let webc = writer.finish(webc::v3::SignatureAlgorithm::None).unwrap();
524        let container = from_bytes(webc).unwrap();
525        let volume = container.volumes()["atom"].clone();
526
527        WebcVolumeFileSystem::new(volume)
528    }
529
530    #[test]
531    fn normalize_paths() {
532        let inputs: Vec<(&str, &[&str])> = vec![
533            ("/", &[]),
534            ("/path/to/", &["path", "to"]),
535            ("/path/to/file.txt", &["path", "to", "file.txt"]),
536            ("/folder/..", &[]),
537            ("/.hidden", &[".hidden"]),
538            ("/folder/../../../../../../../file.txt", &["file.txt"]),
539            #[cfg(windows)]
540            (r"C:\path\to\file.txt", &["path", "to", "file.txt"]),
541        ];
542
543        for (path, expected) in inputs {
544            let normalized = normalize(path.as_ref()).unwrap();
545            assert_eq!(normalized, expected.to_path_segments().unwrap());
546        }
547    }
548
549    #[test]
550    #[cfg_attr(not(windows), ignore = "Only works with PathBuf's Windows logic")]
551    fn normalize_windows_paths() {
552        let inputs: Vec<(&str, &[&str])> = vec![
553            (r"C:\path\to\file.txt", &["path", "to", "file.txt"]),
554            (r"C:/path/to/file.txt", &["path", "to", "file.txt"]),
555            (r"\\system07\C$\", &[]),
556            (r"c:\temp\test-file.txt", &["temp", "test-file.txt"]),
557            (
558                r"\\127.0.0.1\c$\temp\test-file.txt",
559                &["temp", "test-file.txt"],
560            ),
561            (r"\\.\c:\temp\test-file.txt", &["temp", "test-file.txt"]),
562            (r"\\?\c:\temp\test-file.txt", &["temp", "test-file.txt"]),
563            (
564                r"\\127.0.0.1\c$\temp\test-file.txt",
565                &["temp", "test-file.txt"],
566            ),
567            (
568                r"\\.\Volume{b75e2c83-0000-0000-0000-602f00000000}\temp\test-file.txt",
569                &["temp", "test-file.txt"],
570            ),
571        ];
572
573        for (path, expected) in inputs {
574            let normalized = normalize(path.as_ref()).unwrap();
575            assert_eq!(normalized, expected.to_path_segments().unwrap(), "{}", path);
576        }
577    }
578
579    #[test]
580    fn invalid_paths() {
581        let paths = [".", "..", "./file.txt", ""];
582
583        for path in paths {
584            assert!(normalize(path.as_ref()).is_err(), "{}", path);
585        }
586    }
587
588    #[test]
589    fn symlink_metadata_and_readlink() {
590        let fs = symlink_fs();
591
592        let link = fs.symlink_metadata("/link".as_ref()).unwrap();
593        assert!(link.ft.is_symlink());
594        assert_eq!(link.len(), "target.txt".len() as u64);
595        assert_eq!(
596            fs.readlink("/link".as_ref()).unwrap(),
597            Path::new("target.txt")
598        );
599        // open() follows the symlink to its target (unlike symlink_metadata),
600        // so opening the link succeeds and yields the target file.
601        assert!(
602            fs.new_open_options().read(true).open("/link").is_ok(),
603            "open() should follow the symlink to its target file",
604        );
605
606        let entries: Vec<_> = fs
607            .read_dir("/".as_ref())
608            .unwrap()
609            .map(|entry| entry.unwrap())
610            .collect();
611        let link_entry = entries
612            .iter()
613            .find(|entry| entry.path == Path::new("/link"))
614            .unwrap();
615        assert!(link_entry.metadata().unwrap().ft.is_symlink());
616
617        assert_eq!(
618            fs.readlink("/target.txt".as_ref()).unwrap_err(),
619            FsError::InvalidInput
620        );
621        assert_eq!(
622            fs.readlink("/missing".as_ref()).unwrap_err(),
623            FsError::EntryNotFound
624        );
625    }
626
627    /// A volume exercising the various symlink shapes `resolve_symlinks` handles:
628    /// relative, multi-hop, absolute, `..`-relative, a symlinked intermediate
629    /// directory, and a loop.
630    ///
631    /// ```text
632    /// /a.txt                       "content-a"
633    /// /rel    -> a.txt             (relative, single hop)
634    /// /hop1   -> hop2 -> a.txt     (relative, two hops)
635    /// /loop1  -> loop2 -> loop1    (cycle)
636    /// /bin/real.wasm               "\0asm-real"
637    /// /libexec/git-core/git -> ../../bin/real.wasm
638    /// /bindir -> bin              (symlinked directory)
639    /// ```
640    fn follow_symlinks_fs() -> WebcVolumeFileSystem {
641        use webc::v3::write::{DirEntry, Directory, FileEntry, SymlinkEntry};
642
643        let ts = webc::v3::Timestamps::default();
644        let file = |bytes: &'static [u8]| DirEntry::File(FileEntry::borrowed(bytes, ts));
645        let link = |target: &'static str| DirEntry::Symlink(SymlinkEntry::borrowed(target, ts));
646        let seg = |s: &str| PathSegment::parse(s).unwrap();
647
648        let git_core = Directory::new(
649            BTreeMap::from_iter([(seg("git"), link("../../bin/real.wasm"))]),
650            ts,
651        );
652        let libexec = Directory::new(
653            BTreeMap::from_iter([(seg("git-core"), DirEntry::Dir(git_core))]),
654            ts,
655        );
656        let bin = Directory::new(
657            BTreeMap::from_iter([(seg("real.wasm"), file(b"\0asm-real"))]),
658            ts,
659        );
660        let root = Directory::new(
661            BTreeMap::from_iter([
662                (seg("a.txt"), file(b"content-a")),
663                (seg("rel"), link("a.txt")),
664                (seg("hop1"), link("hop2")),
665                (seg("hop2"), link("a.txt")),
666                (seg("loop1"), link("loop2")),
667                (seg("loop2"), link("loop1")),
668                (seg("bin"), DirEntry::Dir(bin)),
669                (seg("libexec"), DirEntry::Dir(libexec)),
670                (seg("bindir"), link("bin")),
671            ]),
672            ts,
673        );
674
675        let manifest = webc::metadata::Manifest::default();
676        let mut writer = webc::v3::write::Writer::new(webc::v3::ChecksumAlgorithm::Sha256)
677            .write_manifest(&manifest)
678            .unwrap()
679            .write_atoms(BTreeMap::new())
680            .unwrap();
681        writer.write_volume("atom", root).unwrap();
682        let webc = writer.finish(webc::v3::SignatureAlgorithm::None).unwrap();
683        let container = from_bytes(webc).unwrap();
684        let volume = container.volumes()["atom"].clone();
685
686        WebcVolumeFileSystem::new(volume)
687    }
688
689    #[tokio::test]
690    async fn open_follows_symlinks() {
691        let fs = follow_symlinks_fs();
692
693        async fn read(fs: &WebcVolumeFileSystem, path: &str) -> Vec<u8> {
694            let mut f = fs
695                .new_open_options()
696                .read(true)
697                .open(path)
698                .unwrap_or_else(|e| panic!("opening {path}: {e:?}"));
699            let mut buffer = Vec::new();
700            f.read_to_end(&mut buffer).await.unwrap();
701            buffer
702        }
703
704        // Relative single-hop and multi-hop chains resolve to the same file.
705        assert_eq!(read(&fs, "/rel").await, b"content-a");
706        assert_eq!(read(&fs, "/hop1").await, b"content-a");
707        // The motivating case: a `..`-relative link deep in the tree.
708        assert_eq!(read(&fs, "/libexec/git-core/git").await, b"\0asm-real");
709        // A symlink in an intermediate directory component is followed too.
710        assert_eq!(read(&fs, "/bindir/real.wasm").await, b"\0asm-real");
711    }
712
713    #[test]
714    fn open_symlink_loop_fails() {
715        let fs = follow_symlinks_fs();
716
717        assert_eq!(
718            fs.new_open_options().read(true).open("/loop1").unwrap_err(),
719            FsError::InvalidInput,
720        );
721    }
722
723    #[test]
724    fn open_symlink_chain_respects_max_depth() {
725        use webc::v3::write::{DirEntry, Directory, FileEntry, SymlinkEntry};
726
727        // A volume with `link0 -> link1 -> ... -> link{n-1} -> target.txt`, i.e.
728        // `n` symlinks to follow before reaching the file.
729        fn chain_fs(n: usize) -> WebcVolumeFileSystem {
730            let ts = webc::v3::Timestamps::default();
731            let seg = |s: &str| PathSegment::parse(s).unwrap();
732
733            let mut children = BTreeMap::new();
734            children.insert(
735                seg("target.txt"),
736                DirEntry::File(FileEntry::borrowed(b"target", ts)),
737            );
738            for i in 0..n {
739                let target = if i + 1 == n {
740                    "target.txt".to_string()
741                } else {
742                    format!("link{}", i + 1)
743                };
744                children.insert(
745                    seg(&format!("link{i}")),
746                    DirEntry::Symlink(SymlinkEntry::owned(target, ts)),
747                );
748            }
749
750            let manifest = webc::metadata::Manifest::default();
751            let mut writer = webc::v3::write::Writer::new(webc::v3::ChecksumAlgorithm::Sha256)
752                .write_manifest(&manifest)
753                .unwrap()
754                .write_atoms(BTreeMap::new())
755                .unwrap();
756            writer
757                .write_volume("atom", Directory::new(children, ts))
758                .unwrap();
759            let webc = writer.finish(webc::v3::SignatureAlgorithm::None).unwrap();
760            let container = from_bytes(webc).unwrap();
761            WebcVolumeFileSystem::new(container.volumes()["atom"].clone())
762        }
763
764        // A chain of MAX_SYMLINK_DEPTH (40) links resolves (matches Linux
765        // MAXSYMLINKS)...
766        assert!(
767            chain_fs(40)
768                .new_open_options()
769                .read(true)
770                .open("/link0")
771                .is_ok(),
772            "a 40-link chain should resolve",
773        );
774
775        // ...but one more link is too many.
776        assert_eq!(
777            chain_fs(41)
778                .new_open_options()
779                .read(true)
780                .open("/link0")
781                .unwrap_err(),
782            FsError::InvalidInput,
783        );
784    }
785
786    #[test]
787    fn metadata_follows_symlinks() {
788        let fs = symlink_fs();
789
790        // metadata() follows the link to its target file (stat semantics)...
791        let target = fs.metadata("/link".as_ref()).unwrap();
792        assert!(target.is_file());
793        assert_eq!(target.len(), "target".len() as u64);
794
795        // ...while symlink_metadata() reports the link itself (lstat semantics).
796        let link = fs.symlink_metadata("/link".as_ref()).unwrap();
797        assert!(link.ft.is_symlink());
798        assert_eq!(link.len(), "target.txt".len() as u64);
799    }
800
801    #[test]
802    fn read_dir_follows_symlinked_directory() {
803        let fs = follow_symlinks_fs();
804
805        // /bindir -> bin, so stat sees a directory...
806        assert!(fs.metadata("/bindir".as_ref()).unwrap().is_dir());
807
808        // ...and read_dir() lists the target's contents, keeping the caller's
809        // path as the prefix.
810        let entries: Vec<_> = fs
811            .read_dir("/bindir".as_ref())
812            .unwrap()
813            .map(|entry| entry.unwrap().path)
814            .collect();
815        assert_eq!(entries, vec![PathBuf::from("/bindir/real.wasm")]);
816    }
817
818    #[test]
819    fn symlink_metadata_follows_intermediate_symlinks() {
820        let fs = follow_symlinks_fs();
821
822        // /bindir -> bin, so lstat resolves the intermediate link and finds the file...
823        let meta = fs.symlink_metadata("/bindir/real.wasm".as_ref()).unwrap();
824        assert!(meta.is_file());
825        assert_eq!(meta.len(), b"\0asm-real".len() as u64);
826
827        // ...but a trailing symlink is reported as-is, not followed.
828        assert!(
829            fs.symlink_metadata("/bindir".as_ref())
830                .unwrap()
831                .ft
832                .is_symlink()
833        );
834    }
835
836    #[tokio::test]
837    async fn write_ops_reach_permission_denied_through_symlinked_parent() {
838        let fs = follow_symlinks_fs();
839
840        // /bindir -> bin is a valid parent, so these should resolve it and reach
841        // the readonly rejection, not mistake the symlink for a non-directory.
842        assert_eq!(
843            fs.create_dir("/bindir/new".as_ref()).unwrap_err(),
844            FsError::PermissionDenied,
845        );
846        assert_eq!(
847            fs.rename("/bin/real.wasm".as_ref(), "/bindir/new.wasm".as_ref())
848                .await
849                .unwrap_err(),
850            FsError::PermissionDenied,
851        );
852    }
853
854    #[test]
855    fn mount_all_volumes_in_python() {
856        let container = from_bytes(PYTHON_WEBC).unwrap();
857
858        let fs = WebcVolumeFileSystem::mount_all(&container);
859
860        // We should now have access to the python directory
861        let lib_meta = fs.metadata("/lib/python3.13/".as_ref()).unwrap();
862        assert!(lib_meta.is_dir());
863    }
864
865    #[test]
866    fn read_dir() {
867        let container = from_bytes(PYTHON_WEBC).unwrap();
868        let volume = container.volumes()["/root/usr/local"].clone();
869        let fs = WebcVolumeFileSystem::new(volume);
870
871        let entries: Vec<_> = fs
872            .read_dir("/lib".as_ref())
873            .unwrap()
874            .map(|r| r.unwrap())
875            .collect();
876
877        assert_eq!(
878            entries
879                .iter()
880                .map(|entry| entry.path.as_path())
881                .collect::<Vec<_>>(),
882            [Path::new("/lib/python3.13"), Path::new("/lib/wasm32-wasi"),],
883        );
884        assert!(
885            entries
886                .iter()
887                .all(|entry| entry.metadata().unwrap().is_dir())
888        );
889    }
890
891    #[tokio::test]
892    async fn file_opener() {
893        let container = from_bytes(PYTHON_WEBC).unwrap();
894        let volumes = container.volumes();
895        let volume = volumes["/root/usr/local"].clone();
896
897        let fs = WebcVolumeFileSystem::new(volume);
898
899        assert_eq!(
900            fs.new_open_options()
901                .create(true)
902                .write(true)
903                .open("/file.txt")
904                .unwrap_err(),
905            FsError::PermissionDenied,
906        );
907        assert_eq!(
908            fs.new_open_options().read(true).open("/lib").unwrap_err(),
909            FsError::NotAFile,
910        );
911        assert_eq!(
912            fs.new_open_options()
913                .read(true)
914                .open("/this/does/not/exist.txt")
915                .unwrap_err(),
916            FsError::EntryNotFound,
917        );
918
919        // We should be able to actually read the file
920        let mut f = fs
921            .new_open_options()
922            .read(true)
923            .open("/bin/python3.wasm")
924            .unwrap();
925        let mut buffer = Vec::new();
926        f.read_to_end(&mut buffer).await.unwrap();
927        assert!(buffer.starts_with(b"\0asm"));
928        assert_eq!(
929            fs.metadata("/bin/python3.wasm".as_ref()).unwrap().len(),
930            u64::try_from(buffer.len()).unwrap(),
931        );
932    }
933
934    #[test]
935    fn remove_dir_is_not_allowed() {
936        let container = from_bytes(PYTHON_WEBC).unwrap();
937        let volumes = container.volumes();
938        let volume = volumes["/root/usr/local"].clone();
939
940        let fs = WebcVolumeFileSystem::new(volume);
941
942        assert_eq!(
943            fs.remove_dir("/lib".as_ref()).unwrap_err(),
944            FsError::PermissionDenied,
945        );
946        assert_eq!(
947            fs.remove_dir("/this/does/not/exist".as_ref()).unwrap_err(),
948            FsError::EntryNotFound,
949        );
950        assert_eq!(
951            fs.remove_dir("/bin/python3.wasm".as_ref()).unwrap_err(),
952            FsError::BaseNotDirectory,
953        );
954    }
955
956    #[test]
957    fn remove_file_is_not_allowed() {
958        let container = from_bytes(PYTHON_WEBC).unwrap();
959        let volumes = container.volumes();
960        let volume = volumes["/root/usr/local"].clone();
961
962        let fs = WebcVolumeFileSystem::new(volume);
963
964        assert_eq!(
965            fs.remove_file("/lib".as_ref()).unwrap_err(),
966            FsError::NotAFile,
967        );
968        assert_eq!(
969            fs.remove_file("/this/does/not/exist".as_ref()).unwrap_err(),
970            FsError::EntryNotFound,
971        );
972        assert_eq!(
973            fs.remove_file("/bin/python3.wasm".as_ref()).unwrap_err(),
974            FsError::PermissionDenied,
975        );
976    }
977
978    #[test]
979    fn create_dir_is_not_allowed() {
980        let container = from_bytes(PYTHON_WEBC).unwrap();
981        let volumes = container.volumes();
982        let volume = volumes["/root/usr/local"].clone();
983
984        let fs = WebcVolumeFileSystem::new(volume);
985
986        assert_eq!(
987            fs.create_dir("/lib".as_ref()).unwrap_err(),
988            FsError::AlreadyExists,
989        );
990        assert_eq!(
991            fs.create_dir("/this/does/not/exist".as_ref()).unwrap_err(),
992            FsError::BaseNotDirectory,
993        );
994        assert_eq!(
995            fs.create_dir("/lib/nested/".as_ref()).unwrap_err(),
996            FsError::PermissionDenied,
997        );
998    }
999
1000    #[tokio::test]
1001    async fn rename_is_not_allowed() {
1002        let container = from_bytes(PYTHON_WEBC).unwrap();
1003        let volumes = container.volumes();
1004        let volume = volumes["/root/usr/local"].clone();
1005
1006        let fs = WebcVolumeFileSystem::new(volume);
1007
1008        assert_eq!(
1009            fs.rename("/lib".as_ref(), "/other".as_ref())
1010                .await
1011                .unwrap_err(),
1012            FsError::PermissionDenied,
1013        );
1014        assert_eq!(
1015            fs.rename("/this/does/not/exist".as_ref(), "/another".as_ref())
1016                .await
1017                .unwrap_err(),
1018            FsError::EntryNotFound,
1019        );
1020        assert_eq!(
1021            fs.rename("/bin/python3.wasm".as_ref(), "/lib/another.wasm".as_ref())
1022                .await
1023                .unwrap_err(),
1024            FsError::PermissionDenied,
1025        );
1026    }
1027}