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 crate::DirEntry;
488    use std::collections::BTreeMap;
489    use std::convert::TryFrom;
490    use tokio::io::AsyncReadExt;
491    use wasmer_package::utils::from_bytes;
492    use webc::PathSegment;
493
494    const PYTHON_WEBC: &[u8] =
495        include_bytes!("../../../wasmer-test-files/examples/python-0.1.0.wasmer");
496
497    fn symlink_fs() -> WebcVolumeFileSystem {
498        let timestamps = webc::v3::Timestamps::default();
499        let dir = webc::v3::write::Directory::new(
500            BTreeMap::from_iter([
501                (
502                    PathSegment::parse("target.txt").unwrap(),
503                    webc::v3::write::DirEntry::File(webc::v3::write::FileEntry::borrowed(
504                        b"target", timestamps,
505                    )),
506                ),
507                (
508                    PathSegment::parse("link").unwrap(),
509                    webc::v3::write::DirEntry::Symlink(webc::v3::write::SymlinkEntry::borrowed(
510                        "target.txt",
511                        timestamps,
512                    )),
513                ),
514            ]),
515            timestamps,
516        );
517        let manifest = webc::metadata::Manifest::default();
518        let mut writer = webc::v3::write::Writer::new(webc::v3::ChecksumAlgorithm::Sha256)
519            .write_manifest(&manifest)
520            .unwrap()
521            .write_atoms(BTreeMap::new())
522            .unwrap();
523        writer.write_volume("atom", dir).unwrap();
524        let webc = writer.finish(webc::v3::SignatureAlgorithm::None).unwrap();
525        let container = from_bytes(webc).unwrap();
526        let volume = container.volumes()["atom"].clone();
527
528        WebcVolumeFileSystem::new(volume)
529    }
530
531    #[test]
532    fn normalize_paths() {
533        let inputs: Vec<(&str, &[&str])> = vec![
534            ("/", &[]),
535            ("/path/to/", &["path", "to"]),
536            ("/path/to/file.txt", &["path", "to", "file.txt"]),
537            ("/folder/..", &[]),
538            ("/.hidden", &[".hidden"]),
539            ("/folder/../../../../../../../file.txt", &["file.txt"]),
540            #[cfg(windows)]
541            (r"C:\path\to\file.txt", &["path", "to", "file.txt"]),
542        ];
543
544        for (path, expected) in inputs {
545            let normalized = normalize(path.as_ref()).unwrap();
546            assert_eq!(normalized, expected.to_path_segments().unwrap());
547        }
548    }
549
550    #[test]
551    #[cfg_attr(not(windows), ignore = "Only works with PathBuf's Windows logic")]
552    fn normalize_windows_paths() {
553        let inputs: Vec<(&str, &[&str])> = vec![
554            (r"C:\path\to\file.txt", &["path", "to", "file.txt"]),
555            (r"C:/path/to/file.txt", &["path", "to", "file.txt"]),
556            (r"\\system07\C$\", &[]),
557            (r"c:\temp\test-file.txt", &["temp", "test-file.txt"]),
558            (
559                r"\\127.0.0.1\c$\temp\test-file.txt",
560                &["temp", "test-file.txt"],
561            ),
562            (r"\\.\c:\temp\test-file.txt", &["temp", "test-file.txt"]),
563            (r"\\?\c:\temp\test-file.txt", &["temp", "test-file.txt"]),
564            (
565                r"\\127.0.0.1\c$\temp\test-file.txt",
566                &["temp", "test-file.txt"],
567            ),
568            (
569                r"\\.\Volume{b75e2c83-0000-0000-0000-602f00000000}\temp\test-file.txt",
570                &["temp", "test-file.txt"],
571            ),
572        ];
573
574        for (path, expected) in inputs {
575            let normalized = normalize(path.as_ref()).unwrap();
576            assert_eq!(normalized, expected.to_path_segments().unwrap(), "{}", path);
577        }
578    }
579
580    #[test]
581    fn invalid_paths() {
582        let paths = [".", "..", "./file.txt", ""];
583
584        for path in paths {
585            assert!(normalize(path.as_ref()).is_err(), "{}", path);
586        }
587    }
588
589    #[test]
590    fn symlink_metadata_and_readlink() {
591        let fs = symlink_fs();
592
593        let link = fs.symlink_metadata("/link".as_ref()).unwrap();
594        assert!(link.ft.is_symlink());
595        assert_eq!(link.len(), "target.txt".len() as u64);
596        assert_eq!(
597            fs.readlink("/link".as_ref()).unwrap(),
598            Path::new("target.txt")
599        );
600        // open() follows the symlink to its target (unlike symlink_metadata),
601        // so opening the link succeeds and yields the target file.
602        assert!(
603            fs.new_open_options().read(true).open("/link").is_ok(),
604            "open() should follow the symlink to its target file",
605        );
606
607        let entries: Vec<_> = fs
608            .read_dir("/".as_ref())
609            .unwrap()
610            .map(|entry| entry.unwrap())
611            .collect();
612        let link_entry = entries
613            .iter()
614            .find(|entry| entry.path == Path::new("/link"))
615            .unwrap();
616        assert!(link_entry.metadata().unwrap().ft.is_symlink());
617
618        assert_eq!(
619            fs.readlink("/target.txt".as_ref()).unwrap_err(),
620            FsError::InvalidInput
621        );
622        assert_eq!(
623            fs.readlink("/missing".as_ref()).unwrap_err(),
624            FsError::EntryNotFound
625        );
626    }
627
628    /// A volume exercising the various symlink shapes `resolve_symlinks` handles:
629    /// relative, multi-hop, absolute, `..`-relative, a symlinked intermediate
630    /// directory, and a loop.
631    ///
632    /// ```text
633    /// /a.txt                       "content-a"
634    /// /rel    -> a.txt             (relative, single hop)
635    /// /hop1   -> hop2 -> a.txt     (relative, two hops)
636    /// /loop1  -> loop2 -> loop1    (cycle)
637    /// /bin/real.wasm               "\0asm-real"
638    /// /libexec/git-core/git -> ../../bin/real.wasm
639    /// /bindir -> bin              (symlinked directory)
640    /// ```
641    fn follow_symlinks_fs() -> WebcVolumeFileSystem {
642        use webc::v3::write::{DirEntry, Directory, FileEntry, SymlinkEntry};
643
644        let ts = webc::v3::Timestamps::default();
645        let file = |bytes: &'static [u8]| DirEntry::File(FileEntry::borrowed(bytes, ts));
646        let link = |target: &'static str| DirEntry::Symlink(SymlinkEntry::borrowed(target, ts));
647        let seg = |s: &str| PathSegment::parse(s).unwrap();
648
649        let git_core = Directory::new(
650            BTreeMap::from_iter([(seg("git"), link("../../bin/real.wasm"))]),
651            ts,
652        );
653        let libexec = Directory::new(
654            BTreeMap::from_iter([(seg("git-core"), DirEntry::Dir(git_core))]),
655            ts,
656        );
657        let bin = Directory::new(
658            BTreeMap::from_iter([(seg("real.wasm"), file(b"\0asm-real"))]),
659            ts,
660        );
661        let root = Directory::new(
662            BTreeMap::from_iter([
663                (seg("a.txt"), file(b"content-a")),
664                (seg("rel"), link("a.txt")),
665                (seg("hop1"), link("hop2")),
666                (seg("hop2"), link("a.txt")),
667                (seg("loop1"), link("loop2")),
668                (seg("loop2"), link("loop1")),
669                (seg("bin"), DirEntry::Dir(bin)),
670                (seg("libexec"), DirEntry::Dir(libexec)),
671                (seg("bindir"), link("bin")),
672            ]),
673            ts,
674        );
675
676        let manifest = webc::metadata::Manifest::default();
677        let mut writer = webc::v3::write::Writer::new(webc::v3::ChecksumAlgorithm::Sha256)
678            .write_manifest(&manifest)
679            .unwrap()
680            .write_atoms(BTreeMap::new())
681            .unwrap();
682        writer.write_volume("atom", root).unwrap();
683        let webc = writer.finish(webc::v3::SignatureAlgorithm::None).unwrap();
684        let container = from_bytes(webc).unwrap();
685        let volume = container.volumes()["atom"].clone();
686
687        WebcVolumeFileSystem::new(volume)
688    }
689
690    #[tokio::test]
691    async fn open_follows_symlinks() {
692        let fs = follow_symlinks_fs();
693
694        async fn read(fs: &WebcVolumeFileSystem, path: &str) -> Vec<u8> {
695            let mut f = fs
696                .new_open_options()
697                .read(true)
698                .open(path)
699                .unwrap_or_else(|e| panic!("opening {path}: {e:?}"));
700            let mut buffer = Vec::new();
701            f.read_to_end(&mut buffer).await.unwrap();
702            buffer
703        }
704
705        // Relative single-hop and multi-hop chains resolve to the same file.
706        assert_eq!(read(&fs, "/rel").await, b"content-a");
707        assert_eq!(read(&fs, "/hop1").await, b"content-a");
708        // The motivating case: a `..`-relative link deep in the tree.
709        assert_eq!(read(&fs, "/libexec/git-core/git").await, b"\0asm-real");
710        // A symlink in an intermediate directory component is followed too.
711        assert_eq!(read(&fs, "/bindir/real.wasm").await, b"\0asm-real");
712    }
713
714    #[test]
715    fn open_symlink_loop_fails() {
716        let fs = follow_symlinks_fs();
717
718        assert_eq!(
719            fs.new_open_options().read(true).open("/loop1").unwrap_err(),
720            FsError::InvalidInput,
721        );
722    }
723
724    #[test]
725    fn open_symlink_chain_respects_max_depth() {
726        use webc::v3::write::{DirEntry, Directory, FileEntry, SymlinkEntry};
727
728        // A volume with `link0 -> link1 -> ... -> link{n-1} -> target.txt`, i.e.
729        // `n` symlinks to follow before reaching the file.
730        fn chain_fs(n: usize) -> WebcVolumeFileSystem {
731            let ts = webc::v3::Timestamps::default();
732            let seg = |s: &str| PathSegment::parse(s).unwrap();
733
734            let mut children = BTreeMap::new();
735            children.insert(
736                seg("target.txt"),
737                DirEntry::File(FileEntry::borrowed(b"target", ts)),
738            );
739            for i in 0..n {
740                let target = if i + 1 == n {
741                    "target.txt".to_string()
742                } else {
743                    format!("link{}", i + 1)
744                };
745                children.insert(
746                    seg(&format!("link{i}")),
747                    DirEntry::Symlink(SymlinkEntry::owned(target, ts)),
748                );
749            }
750
751            let manifest = webc::metadata::Manifest::default();
752            let mut writer = webc::v3::write::Writer::new(webc::v3::ChecksumAlgorithm::Sha256)
753                .write_manifest(&manifest)
754                .unwrap()
755                .write_atoms(BTreeMap::new())
756                .unwrap();
757            writer
758                .write_volume("atom", Directory::new(children, ts))
759                .unwrap();
760            let webc = writer.finish(webc::v3::SignatureAlgorithm::None).unwrap();
761            let container = from_bytes(webc).unwrap();
762            WebcVolumeFileSystem::new(container.volumes()["atom"].clone())
763        }
764
765        // A chain of MAX_SYMLINK_DEPTH (40) links resolves (matches Linux
766        // MAXSYMLINKS)...
767        assert!(
768            chain_fs(40)
769                .new_open_options()
770                .read(true)
771                .open("/link0")
772                .is_ok(),
773            "a 40-link chain should resolve",
774        );
775
776        // ...but one more link is too many.
777        assert_eq!(
778            chain_fs(41)
779                .new_open_options()
780                .read(true)
781                .open("/link0")
782                .unwrap_err(),
783            FsError::InvalidInput,
784        );
785    }
786
787    #[test]
788    fn metadata_follows_symlinks() {
789        let fs = symlink_fs();
790
791        // metadata() follows the link to its target file (stat semantics)...
792        let target = fs.metadata("/link".as_ref()).unwrap();
793        assert!(target.is_file());
794        assert_eq!(target.len(), "target".len() as u64);
795
796        // ...while symlink_metadata() reports the link itself (lstat semantics).
797        let link = fs.symlink_metadata("/link".as_ref()).unwrap();
798        assert!(link.ft.is_symlink());
799        assert_eq!(link.len(), "target.txt".len() as u64);
800    }
801
802    #[test]
803    fn read_dir_follows_symlinked_directory() {
804        let fs = follow_symlinks_fs();
805
806        // /bindir -> bin, so stat sees a directory...
807        assert!(fs.metadata("/bindir".as_ref()).unwrap().is_dir());
808
809        // ...and read_dir() lists the target's contents, keeping the caller's
810        // path as the prefix.
811        let entries: Vec<_> = fs
812            .read_dir("/bindir".as_ref())
813            .unwrap()
814            .map(|entry| entry.unwrap().path)
815            .collect();
816        assert_eq!(entries, vec![PathBuf::from("/bindir/real.wasm")]);
817    }
818
819    #[test]
820    fn symlink_metadata_follows_intermediate_symlinks() {
821        let fs = follow_symlinks_fs();
822
823        // /bindir -> bin, so lstat resolves the intermediate link and finds the file...
824        let meta = fs.symlink_metadata("/bindir/real.wasm".as_ref()).unwrap();
825        assert!(meta.is_file());
826        assert_eq!(meta.len(), b"\0asm-real".len() as u64);
827
828        // ...but a trailing symlink is reported as-is, not followed.
829        assert!(
830            fs.symlink_metadata("/bindir".as_ref())
831                .unwrap()
832                .ft
833                .is_symlink()
834        );
835    }
836
837    #[tokio::test]
838    async fn write_ops_reach_permission_denied_through_symlinked_parent() {
839        let fs = follow_symlinks_fs();
840
841        // /bindir -> bin is a valid parent, so these should resolve it and reach
842        // the readonly rejection, not mistake the symlink for a non-directory.
843        assert_eq!(
844            fs.create_dir("/bindir/new".as_ref()).unwrap_err(),
845            FsError::PermissionDenied,
846        );
847        assert_eq!(
848            fs.rename("/bin/real.wasm".as_ref(), "/bindir/new.wasm".as_ref())
849                .await
850                .unwrap_err(),
851            FsError::PermissionDenied,
852        );
853    }
854
855    #[test]
856    fn mount_all_volumes_in_python() {
857        let container = from_bytes(PYTHON_WEBC).unwrap();
858
859        let fs = WebcVolumeFileSystem::mount_all(&container);
860
861        // We should now have access to the python directory
862        let lib_meta = fs.metadata("/lib/python3.6/".as_ref()).unwrap();
863        assert!(lib_meta.is_dir());
864    }
865
866    #[test]
867    fn read_dir() {
868        let container = from_bytes(PYTHON_WEBC).unwrap();
869        let volumes = container.volumes();
870        let volume = volumes["atom"].clone();
871
872        let fs = WebcVolumeFileSystem::new(volume);
873
874        let entries: Vec<_> = fs
875            .read_dir("/lib".as_ref())
876            .unwrap()
877            .map(|r| r.unwrap())
878            .collect();
879
880        let modified = get_modified(None);
881        let expected = vec![
882            DirEntry {
883                path: "/lib/.DS_Store".into(),
884                metadata: Ok(Metadata {
885                    ft: FileType {
886                        file: true,
887                        ..Default::default()
888                    },
889                    accessed: 0,
890                    created: 0,
891                    modified,
892                    len: 6148,
893                }),
894            },
895            DirEntry {
896                path: "/lib/Parser".into(),
897                metadata: Ok(Metadata {
898                    ft: FileType {
899                        dir: true,
900                        ..Default::default()
901                    },
902                    accessed: 0,
903                    created: 0,
904                    modified,
905                    len: 0,
906                }),
907            },
908            DirEntry {
909                path: "/lib/python.wasm".into(),
910                metadata: Ok(crate::Metadata {
911                    ft: crate::FileType {
912                        file: true,
913                        ..Default::default()
914                    },
915                    accessed: 0,
916                    created: 0,
917                    modified,
918                    len: 4694941,
919                }),
920            },
921            DirEntry {
922                path: "/lib/python3.6".into(),
923                metadata: Ok(crate::Metadata {
924                    ft: crate::FileType {
925                        dir: true,
926                        ..Default::default()
927                    },
928                    accessed: 0,
929                    created: 0,
930                    modified,
931                    len: 0,
932                }),
933            },
934        ];
935        assert_eq!(entries, expected);
936    }
937
938    #[test]
939    fn metadata() {
940        let container = from_bytes(PYTHON_WEBC).unwrap();
941        let volumes = container.volumes();
942        let volume = volumes["atom"].clone();
943
944        let fs = WebcVolumeFileSystem::new(volume);
945
946        let modified = get_modified(None);
947        let python_wasm = crate::Metadata {
948            ft: crate::FileType {
949                file: true,
950                ..Default::default()
951            },
952            accessed: 0,
953            created: 0,
954            modified,
955            len: 4694941,
956        };
957        assert_eq!(
958            fs.metadata("/lib/python.wasm".as_ref()).unwrap(),
959            python_wasm,
960        );
961        assert_eq!(
962            fs.metadata("/../../../../lib/python.wasm".as_ref())
963                .unwrap(),
964            python_wasm,
965        );
966        assert_eq!(
967            fs.metadata("/lib/python3.6/../python3.6/../python.wasm".as_ref())
968                .unwrap(),
969            python_wasm,
970        );
971        assert_eq!(
972            fs.metadata("/lib/python3.6".as_ref()).unwrap(),
973            crate::Metadata {
974                ft: crate::FileType {
975                    dir: true,
976                    ..Default::default()
977                },
978                accessed: 0,
979                created: 0,
980                modified,
981                len: 0,
982            },
983        );
984        assert_eq!(
985            fs.metadata("/this/does/not/exist".as_ref()).unwrap_err(),
986            FsError::EntryNotFound
987        );
988    }
989
990    #[tokio::test]
991    async fn file_opener() {
992        let container = from_bytes(PYTHON_WEBC).unwrap();
993        let volumes = container.volumes();
994        let volume = volumes["atom"].clone();
995
996        let fs = WebcVolumeFileSystem::new(volume);
997
998        assert_eq!(
999            fs.new_open_options()
1000                .create(true)
1001                .write(true)
1002                .open("/file.txt")
1003                .unwrap_err(),
1004            FsError::PermissionDenied,
1005        );
1006        assert_eq!(
1007            fs.new_open_options().read(true).open("/lib").unwrap_err(),
1008            FsError::NotAFile,
1009        );
1010        assert_eq!(
1011            fs.new_open_options()
1012                .read(true)
1013                .open("/this/does/not/exist.txt")
1014                .unwrap_err(),
1015            FsError::EntryNotFound,
1016        );
1017
1018        // We should be able to actually read the file
1019        let mut f = fs
1020            .new_open_options()
1021            .read(true)
1022            .open("/lib/python.wasm")
1023            .unwrap();
1024        let mut buffer = Vec::new();
1025        f.read_to_end(&mut buffer).await.unwrap();
1026        assert!(buffer.starts_with(b"\0asm"));
1027        assert_eq!(
1028            fs.metadata("/lib/python.wasm".as_ref()).unwrap().len(),
1029            u64::try_from(buffer.len()).unwrap(),
1030        );
1031    }
1032
1033    #[test]
1034    fn remove_dir_is_not_allowed() {
1035        let container = from_bytes(PYTHON_WEBC).unwrap();
1036        let volumes = container.volumes();
1037        let volume = volumes["atom"].clone();
1038
1039        let fs = WebcVolumeFileSystem::new(volume);
1040
1041        assert_eq!(
1042            fs.remove_dir("/lib".as_ref()).unwrap_err(),
1043            FsError::PermissionDenied,
1044        );
1045        assert_eq!(
1046            fs.remove_dir("/this/does/not/exist".as_ref()).unwrap_err(),
1047            FsError::EntryNotFound,
1048        );
1049        assert_eq!(
1050            fs.remove_dir("/lib/python.wasm".as_ref()).unwrap_err(),
1051            FsError::BaseNotDirectory,
1052        );
1053    }
1054
1055    #[test]
1056    fn remove_file_is_not_allowed() {
1057        let container = from_bytes(PYTHON_WEBC).unwrap();
1058        let volumes = container.volumes();
1059        let volume = volumes["atom"].clone();
1060
1061        let fs = WebcVolumeFileSystem::new(volume);
1062
1063        assert_eq!(
1064            fs.remove_file("/lib".as_ref()).unwrap_err(),
1065            FsError::NotAFile,
1066        );
1067        assert_eq!(
1068            fs.remove_file("/this/does/not/exist".as_ref()).unwrap_err(),
1069            FsError::EntryNotFound,
1070        );
1071        assert_eq!(
1072            fs.remove_file("/lib/python.wasm".as_ref()).unwrap_err(),
1073            FsError::PermissionDenied,
1074        );
1075    }
1076
1077    #[test]
1078    fn create_dir_is_not_allowed() {
1079        let container = from_bytes(PYTHON_WEBC).unwrap();
1080        let volumes = container.volumes();
1081        let volume = volumes["atom"].clone();
1082
1083        let fs = WebcVolumeFileSystem::new(volume);
1084
1085        assert_eq!(
1086            fs.create_dir("/lib".as_ref()).unwrap_err(),
1087            FsError::AlreadyExists,
1088        );
1089        assert_eq!(
1090            fs.create_dir("/this/does/not/exist".as_ref()).unwrap_err(),
1091            FsError::BaseNotDirectory,
1092        );
1093        assert_eq!(
1094            fs.create_dir("/lib/nested/".as_ref()).unwrap_err(),
1095            FsError::PermissionDenied,
1096        );
1097    }
1098
1099    #[tokio::test]
1100    async fn rename_is_not_allowed() {
1101        let container = from_bytes(PYTHON_WEBC).unwrap();
1102        let volumes = container.volumes();
1103        let volume = volumes["atom"].clone();
1104
1105        let fs = WebcVolumeFileSystem::new(volume);
1106
1107        assert_eq!(
1108            fs.rename("/lib".as_ref(), "/other".as_ref())
1109                .await
1110                .unwrap_err(),
1111            FsError::PermissionDenied,
1112        );
1113        assert_eq!(
1114            fs.rename("/this/does/not/exist".as_ref(), "/another".as_ref())
1115                .await
1116                .unwrap_err(),
1117            FsError::EntryNotFound,
1118        );
1119        assert_eq!(
1120            fs.rename("/lib/python.wasm".as_ref(), "/lib/another.wasm".as_ref())
1121                .await
1122                .unwrap_err(),
1123            FsError::PermissionDenied,
1124        );
1125    }
1126}