foundry_config/
fs_permissions.rs

1//! Support for controlling fs access
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::{
5    fmt,
6    path::{Path, PathBuf},
7    str::FromStr,
8};
9
10/// Configures file system access
11///
12/// E.g. for cheat codes (`vm.writeFile`)
13#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(transparent)]
15pub struct FsPermissions {
16    /// what kind of access is allowed
17    pub permissions: Vec<PathPermission>,
18}
19
20impl FsPermissions {
21    /// Creates anew instance with the given `permissions`
22    pub fn new(permissions: impl IntoIterator<Item = PathPermission>) -> Self {
23        Self { permissions: permissions.into_iter().collect() }
24    }
25
26    /// Adds a new permission
27    pub fn add(&mut self, permission: PathPermission) {
28        self.permissions.push(permission)
29    }
30
31    /// Returns true if access to the specified path is allowed with the specified.
32    ///
33    /// This first checks permission, and only if it is granted, whether the path is allowed.
34    ///
35    /// We only allow paths that are inside  allowed paths.
36    ///
37    /// Caution: This should be called with normalized paths if the `allowed_paths` are also
38    /// normalized.
39    pub fn is_path_allowed(&self, path: &Path, kind: FsAccessKind) -> bool {
40        self.find_permission(path).is_some_and(|perm| perm.is_granted(kind))
41    }
42
43    /// Returns the permission for the matching path.
44    ///
45    /// This finds the longest matching path with resolved sym links and returns the highest
46    /// privilege permission. The algorithm works as follows:
47    ///
48    /// 1. Find all permissions where the path matches (using longest path match)
49    /// 2. Return the highest privilege permission from those matches
50    ///
51    /// Example scenarios:
52    ///
53    /// ```text
54    /// ./out = read
55    /// ./out/contracts = read-write
56    /// ```
57    /// Checking `./out/contracts/MyContract.sol` returns `read-write` (longest path match)
58    ///
59    /// ```text
60    /// ./out/contracts = read
61    /// ./out/contracts = write
62    /// ```
63    /// Checking `./out/contracts/MyContract.sol` returns `write` (highest privilege, which also
64    /// grants read access)
65    pub fn find_permission(&self, path: &Path) -> Option<FsAccessPermission> {
66        let mut max_path_len = 0;
67        let mut highest_permission = FsAccessPermission::None;
68
69        // Find all matching permissions at the longest matching path
70        for perm in &self.permissions {
71            let permission_path = dunce::canonicalize(&perm.path).unwrap_or(perm.path.clone());
72            if path.starts_with(&permission_path) {
73                let path_len = permission_path.components().count();
74                if path_len > max_path_len {
75                    // Found a longer matching path, reset to this permission
76                    max_path_len = path_len;
77                    highest_permission = perm.access;
78                } else if path_len == max_path_len {
79                    // Same path length, keep the highest privilege
80                    highest_permission = match (highest_permission, perm.access) {
81                        (FsAccessPermission::ReadWrite, _)
82                        | (FsAccessPermission::Read, FsAccessPermission::Write)
83                        | (FsAccessPermission::Write, FsAccessPermission::Read) => {
84                            FsAccessPermission::ReadWrite
85                        }
86                        (FsAccessPermission::None, perm) => perm,
87                        (existing_perm, _) => existing_perm,
88                    }
89                }
90            }
91        }
92
93        if max_path_len > 0 { Some(highest_permission) } else { None }
94    }
95
96    /// Updates all `allowed_paths` and joins ([`Path::join`]) the `root` with all entries
97    pub fn join_all(&mut self, root: &Path) {
98        self.permissions.iter_mut().for_each(|perm| {
99            perm.path = root.join(&perm.path);
100        })
101    }
102
103    /// Same as [`Self::join_all`] but consumes the type
104    pub fn joined(mut self, root: &Path) -> Self {
105        self.join_all(root);
106        self
107    }
108
109    /// Removes all existing permissions for the given path
110    pub fn remove(&mut self, path: &Path) {
111        self.permissions.retain(|permission| permission.path != path)
112    }
113
114    /// Returns true if no permissions are configured
115    pub fn is_empty(&self) -> bool {
116        self.permissions.is_empty()
117    }
118
119    /// Returns the number of configured permissions
120    pub fn len(&self) -> usize {
121        self.permissions.len()
122    }
123}
124
125/// Represents an access permission to a single path
126#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
127pub struct PathPermission {
128    /// Permission level to access the `path`
129    pub access: FsAccessPermission,
130    /// The targeted path guarded by the permission
131    pub path: PathBuf,
132}
133
134impl PathPermission {
135    /// Returns a new permission for the path and the given access
136    pub fn new(path: impl Into<PathBuf>, access: FsAccessPermission) -> Self {
137        Self { path: path.into(), access }
138    }
139
140    /// Returns a new read-only permission for the path
141    pub fn read(path: impl Into<PathBuf>) -> Self {
142        Self::new(path, FsAccessPermission::Read)
143    }
144
145    /// Returns a new read-write permission for the path
146    pub fn read_write(path: impl Into<PathBuf>) -> Self {
147        Self::new(path, FsAccessPermission::ReadWrite)
148    }
149
150    /// Returns a new write-only permission for the path
151    pub fn write(path: impl Into<PathBuf>) -> Self {
152        Self::new(path, FsAccessPermission::Write)
153    }
154
155    /// Returns a non permission for the path
156    pub fn none(path: impl Into<PathBuf>) -> Self {
157        Self::new(path, FsAccessPermission::None)
158    }
159
160    /// Returns true if the access is allowed
161    pub fn is_granted(&self, kind: FsAccessKind) -> bool {
162        self.access.is_granted(kind)
163    }
164}
165
166/// Represents the operation on the fs
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub enum FsAccessKind {
169    /// read from fs (`vm.readFile`)
170    Read,
171    /// write to fs (`vm.writeFile`)
172    Write,
173}
174
175impl fmt::Display for FsAccessKind {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            Self::Read => f.write_str("read"),
179            Self::Write => f.write_str("write"),
180        }
181    }
182}
183
184/// Determines the status of file system access
185#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
186pub enum FsAccessPermission {
187    /// FS access is _not_ allowed
188    #[default]
189    None,
190    /// Only reading is allowed
191    Read,
192    /// Only writing is allowed
193    Write,
194    /// FS access is allowed, this includes `read` + `write`
195    ReadWrite,
196}
197
198impl FsAccessPermission {
199    /// Returns true if the access is allowed
200    pub fn is_granted(&self, kind: FsAccessKind) -> bool {
201        match (self, kind) {
202            (Self::ReadWrite, _) => true,
203            (Self::Write, FsAccessKind::Write) => true,
204            (Self::Read, FsAccessKind::Read) => true,
205            (Self::None, _) => false,
206            _ => false,
207        }
208    }
209}
210
211impl FromStr for FsAccessPermission {
212    type Err = String;
213
214    fn from_str(s: &str) -> Result<Self, Self::Err> {
215        match s {
216            "true" | "read-write" | "readwrite" => Ok(Self::ReadWrite),
217            "false" | "none" => Ok(Self::None),
218            "read" => Ok(Self::Read),
219            "write" => Ok(Self::Write),
220            _ => Err(format!("Unknown variant {s}")),
221        }
222    }
223}
224
225impl fmt::Display for FsAccessPermission {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        match self {
228            Self::ReadWrite => f.write_str("read-write"),
229            Self::None => f.write_str("none"),
230            Self::Read => f.write_str("read"),
231            Self::Write => f.write_str("write"),
232        }
233    }
234}
235
236impl Serialize for FsAccessPermission {
237    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
238    where
239        S: Serializer,
240    {
241        match self {
242            Self::ReadWrite => serializer.serialize_bool(true),
243            Self::None => serializer.serialize_bool(false),
244            Self::Read => serializer.serialize_str("read"),
245            Self::Write => serializer.serialize_str("write"),
246        }
247    }
248}
249
250impl<'de> Deserialize<'de> for FsAccessPermission {
251    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
252    where
253        D: Deserializer<'de>,
254    {
255        #[derive(Deserialize)]
256        #[serde(untagged)]
257        enum Status {
258            Bool(bool),
259            String(String),
260        }
261        match Status::deserialize(deserializer)? {
262            Status::Bool(enabled) => {
263                let status = if enabled { Self::ReadWrite } else { Self::None };
264                Ok(status)
265            }
266            Status::String(val) => val.parse().map_err(serde::de::Error::custom),
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn can_parse_permission() {
277        assert_eq!(FsAccessPermission::ReadWrite, "true".parse().unwrap());
278        assert_eq!(FsAccessPermission::ReadWrite, "readwrite".parse().unwrap());
279        assert_eq!(FsAccessPermission::ReadWrite, "read-write".parse().unwrap());
280        assert_eq!(FsAccessPermission::None, "false".parse().unwrap());
281        assert_eq!(FsAccessPermission::None, "none".parse().unwrap());
282        assert_eq!(FsAccessPermission::Read, "read".parse().unwrap());
283        assert_eq!(FsAccessPermission::Write, "write".parse().unwrap());
284    }
285
286    #[test]
287    fn nested_permissions() {
288        let permissions = FsPermissions::new(vec![
289            PathPermission::read("./"),
290            PathPermission::write("./out"),
291            PathPermission::read_write("./out/contracts"),
292        ]);
293
294        let permission =
295            permissions.find_permission(Path::new("./out/contracts/MyContract.sol")).unwrap();
296        assert_eq!(FsAccessPermission::ReadWrite, permission);
297        let permission = permissions.find_permission(Path::new("./out/MyContract.sol")).unwrap();
298        assert_eq!(FsAccessPermission::Write, permission);
299    }
300
301    #[test]
302    fn read_write_permission_combination() {
303        // When multiple permissions are defined for the same path, highest privilege wins
304        let permissions = FsPermissions::new(vec![
305            PathPermission::read("./out/contracts"),
306            PathPermission::write("./out/contracts"),
307        ]);
308
309        let permission =
310            permissions.find_permission(Path::new("./out/contracts/MyContract.sol")).unwrap();
311        assert_eq!(FsAccessPermission::ReadWrite, permission);
312    }
313
314    #[test]
315    fn longest_path_takes_precedence() {
316        let permissions = FsPermissions::new(vec![
317            PathPermission::read_write("./out"),
318            PathPermission::read("./out/contracts"),
319        ]);
320
321        // More specific path (./out/contracts) takes precedence even with lower privilege
322        let permission =
323            permissions.find_permission(Path::new("./out/contracts/MyContract.sol")).unwrap();
324        assert_eq!(FsAccessPermission::Read, permission);
325
326        // Broader path still applies to its own files
327        let permission = permissions.find_permission(Path::new("./out/other.sol")).unwrap();
328        assert_eq!(FsAccessPermission::ReadWrite, permission);
329    }
330}