localharness/filesystem/
rooted.rs1use async_trait::async_trait;
12
13use super::{DirEntry, Filesystem, Metadata, SharedFilesystem, WalkEntry};
14use crate::error::Result;
15
16#[derive(Debug, Clone)]
19pub struct RootedFilesystem {
20 inner: SharedFilesystem,
21 base: String,
23}
24
25impl RootedFilesystem {
26 pub fn new(inner: SharedFilesystem, base: impl Into<String>) -> Self {
28 let base = base.into().replace('\\', "/");
29 let base = base.trim_end_matches('/').to_string();
30 Self { inner, base }
31 }
32
33 fn real(&self, p: &str) -> String {
43 let p = p.replace('\\', "/");
44 let mut stack: Vec<&str> = Vec::new();
45 for comp in p.split('/') {
46 match comp {
47 "" | "." => {}
48 ".." => {
51 stack.pop();
52 }
53 c => stack.push(c),
54 }
55 }
56 if stack.is_empty() {
57 return self.base.clone();
58 }
59 format!("{}/{}", self.base, stack.join("/"))
60 }
61
62 fn virt(&self, real: &str) -> String {
65 let r = real.replace('\\', "/");
66 match r.strip_prefix(&self.base) {
67 None | Some("") => "/".to_string(),
68 Some(rest) if rest.starts_with('/') => rest.to_string(),
69 Some(rest) => format!("/{rest}"),
70 }
71 }
72}
73
74#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
75#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
76impl Filesystem for RootedFilesystem {
77 async fn read(&self, path: &str) -> Result<Vec<u8>> {
78 self.inner.read(&self.real(path)).await
79 }
80 async fn write_atomic(&self, path: &str, bytes: &[u8]) -> Result<()> {
81 self.inner.write_atomic(&self.real(path), bytes).await
82 }
83 async fn metadata(&self, path: &str) -> Result<Option<Metadata>> {
84 self.inner.metadata(&self.real(path)).await
85 }
86 async fn read_dir(&self, path: &str) -> Result<Vec<DirEntry>> {
87 self.inner.read_dir(&self.real(path)).await
89 }
90 async fn walk(&self, path: &str, max_depth: Option<usize>) -> Result<Vec<WalkEntry>> {
91 let entries = self.inner.walk(&self.real(path), max_depth).await?;
92 Ok(entries
93 .into_iter()
94 .map(|e| WalkEntry { path: self.virt(&e.path), kind: e.kind, size: e.size })
95 .collect())
96 }
97 async fn delete(&self, path: &str) -> Result<()> {
98 self.inner.delete(&self.real(path)).await
99 }
100 async fn rename(&self, from: &str, to: &str) -> Result<()> {
101 self.inner.rename(&self.real(from), &self.real(to)).await
102 }
103}
104
105#[cfg(all(test, feature = "native"))]
106mod tests {
107 use super::*;
108 use crate::filesystem::NativeFilesystem;
109 use std::sync::Arc;
110
111 #[tokio::test]
112 async fn confines_reads_writes_and_walk_to_base() {
113 let dir = tempfile::tempdir().unwrap();
114 let base = dir.path().to_string_lossy().to_string();
115 let fs = RootedFilesystem::new(Arc::new(NativeFilesystem::new()), base.clone());
116
117 fs.write_atomic("/sub/a.txt", b"hello").await.unwrap();
119 assert_eq!(fs.read("/sub/a.txt").await.unwrap(), b"hello");
120 assert!(std::path::Path::new(&base).join("sub").join("a.txt").exists());
122
123 fs.write_atomic("/sub/b.txt", b"x").await.unwrap();
126 let paths: Vec<String> =
127 fs.walk("/sub", None).await.unwrap().into_iter().map(|e| e.path).collect();
128 assert!(paths.iter().all(|p| p.starts_with("/sub") && !p.contains(':')), "{paths:?}");
129 assert!(paths.iter().any(|p| p == "/sub/a.txt"));
130 assert!(paths.iter().any(|p| p == "/sub/b.txt"));
131 }
132
133 #[test]
134 fn real_and_virt_round_trip() {
135 let fs = RootedFilesystem::new(Arc::new(NativeFilesystem::new()), "/tmp/base/");
136 assert_eq!(fs.real("/"), "/tmp/base");
137 assert_eq!(fs.real("/x/y"), "/tmp/base/x/y");
138 assert_eq!(fs.virt("/tmp/base/x/y"), "/x/y");
139 assert_eq!(fs.virt("/tmp/base"), "/");
140 assert_eq!(fs.virt(r"\tmp\base\x"), "/x");
142 }
143
144 #[test]
148 fn dotdot_and_backslash_cannot_escape_base() {
149 let fs = RootedFilesystem::new(Arc::new(NativeFilesystem::new()), "/tmp/base/");
150 assert_eq!(fs.real(r"..\..\..\..\secret.key"), "/tmp/base/secret.key");
152 assert_eq!(fs.real("/../../etc/passwd"), "/tmp/base/etc/passwd");
154 assert_eq!(fs.real(r"/sub\nested/file"), "/tmp/base/sub/nested/file");
156 for p in [r"..\..\x", "/../../y", r"a\..\..\b", "////"] {
158 assert!(fs.real(p).starts_with("/tmp/base"), "escaped: {p} -> {}", fs.real(p));
159 }
160 }
161}