sandbox_runtime/sandbox/linux/
filesystem.rs

1//! Filesystem bind mount generation for bubblewrap.
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use crate::config::{FilesystemConfig, RipgrepConfig, DANGEROUS_DIRECTORIES, DANGEROUS_FILES};
7use crate::error::SandboxError;
8use crate::utils::{
9    contains_glob_chars, find_dangerous_files, is_symlink_outside_boundary,
10    normalize_path_for_sandbox, remove_trailing_glob_suffix,
11};
12
13/// Bind mount specification.
14#[derive(Debug, Clone)]
15pub struct BindMount {
16    /// Source path on host.
17    pub source: PathBuf,
18    /// Target path in sandbox (usually same as source).
19    pub target: PathBuf,
20    /// Whether the mount is read-only.
21    pub readonly: bool,
22    /// Whether to create the path with dev-null if it doesn't exist.
23    pub dev_null: bool,
24}
25
26impl BindMount {
27    /// Create a new read-only bind mount.
28    pub fn readonly(path: impl Into<PathBuf>) -> Self {
29        let path = path.into();
30        Self {
31            source: path.clone(),
32            target: path,
33            readonly: true,
34            dev_null: false,
35        }
36    }
37
38    /// Create a new writable bind mount.
39    pub fn writable(path: impl Into<PathBuf>) -> Self {
40        let path = path.into();
41        Self {
42            source: path.clone(),
43            target: path,
44            readonly: false,
45            dev_null: false,
46        }
47    }
48
49    /// Create a dev-null mount to block a path.
50    pub fn block(path: impl Into<PathBuf>) -> Self {
51        let path = path.into();
52        Self {
53            source: PathBuf::from("/dev/null"),
54            target: path,
55            readonly: true,
56            dev_null: true,
57        }
58    }
59
60    /// Convert to bwrap arguments.
61    pub fn to_bwrap_args(&self) -> Vec<String> {
62        if self.dev_null {
63            vec![
64                "--ro-bind".to_string(),
65                "/dev/null".to_string(),
66                self.target.display().to_string(),
67            ]
68        } else if self.readonly {
69            vec![
70                "--ro-bind".to_string(),
71                self.source.display().to_string(),
72                self.target.display().to_string(),
73            ]
74        } else {
75            vec![
76                "--bind".to_string(),
77                self.source.display().to_string(),
78                self.target.display().to_string(),
79            ]
80        }
81    }
82}
83
84/// Generate bind mounts for the filesystem configuration.
85pub fn generate_bind_mounts(
86    config: &FilesystemConfig,
87    cwd: &Path,
88    ripgrep_config: Option<&RipgrepConfig>,
89    max_depth: Option<u32>,
90) -> Result<(Vec<BindMount>, Vec<String>), SandboxError> {
91    let mut mounts = Vec::new();
92    let mut warnings = Vec::new();
93
94    // Collect all paths that need to be writable
95    let mut writable_paths: HashSet<PathBuf> = HashSet::new();
96    for path in &config.allow_write {
97        // Handle glob patterns
98        if contains_glob_chars(path) {
99            warnings.push(format!(
100                "Glob pattern '{}' is not supported on Linux; ignoring",
101                path
102            ));
103            continue;
104        }
105
106        let normalized = normalize_path_for_sandbox(path);
107        let path = PathBuf::from(&normalized);
108
109        if path.exists() {
110            writable_paths.insert(path);
111        } else {
112            warnings.push(format!("Write path '{}' does not exist", normalized));
113        }
114    }
115
116    // Collect all paths that need to be denied write access
117    let mut deny_paths: HashSet<PathBuf> = HashSet::new();
118    for path in &config.deny_write {
119        if contains_glob_chars(path) {
120            warnings.push(format!(
121                "Glob pattern '{}' is not supported on Linux; ignoring",
122                path
123            ));
124            continue;
125        }
126
127        let normalized = normalize_path_for_sandbox(path);
128        deny_paths.insert(PathBuf::from(&normalized));
129    }
130
131    // Find dangerous files using ripgrep
132    let dangerous_files = find_dangerous_files(cwd, ripgrep_config, max_depth).unwrap_or_default();
133    for file in dangerous_files {
134        deny_paths.insert(PathBuf::from(file));
135    }
136
137    // Add mandatory deny paths
138    for dir in DANGEROUS_DIRECTORIES {
139        // Check in cwd
140        let path = cwd.join(dir);
141        if path.exists() {
142            deny_paths.insert(path);
143        }
144
145        // Check in home
146        if let Some(home) = dirs::home_dir() {
147            let path = home.join(dir);
148            if path.exists() {
149                deny_paths.insert(path);
150            }
151        }
152    }
153
154    for file in DANGEROUS_FILES {
155        // Skip .gitconfig if allowed
156        if *file == ".gitconfig" && config.allow_git_config.unwrap_or(false) {
157            continue;
158        }
159
160        if let Some(home) = dirs::home_dir() {
161            let path = home.join(file);
162            if path.exists() {
163                deny_paths.insert(path);
164            }
165        }
166    }
167
168    // Generate mounts
169    // First, add writable mounts
170    for path in &writable_paths {
171        // Check for symlinks that might escape
172        if let Ok(resolved) = std::fs::canonicalize(path) {
173            if is_symlink_outside_boundary(path, &resolved) {
174                mounts.push(BindMount::block(path.clone()));
175                continue;
176            }
177        }
178
179        mounts.push(BindMount::writable(path.clone()));
180    }
181
182    // Then, add deny mounts (these override writable mounts)
183    for path in &deny_paths {
184        if path.exists() {
185            mounts.push(BindMount::readonly(path.clone()));
186        } else {
187            // Block non-existent paths with dev-null
188            mounts.push(BindMount::block(path.clone()));
189        }
190    }
191
192    Ok((mounts, warnings))
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_bind_mount_to_bwrap_args() {
201        let mount = BindMount::readonly("/path/to/file");
202        let args = mount.to_bwrap_args();
203        assert_eq!(args, vec!["--ro-bind", "/path/to/file", "/path/to/file"]);
204
205        let mount = BindMount::writable("/path/to/dir");
206        let args = mount.to_bwrap_args();
207        assert_eq!(args, vec!["--bind", "/path/to/dir", "/path/to/dir"]);
208
209        let mount = BindMount::block("/path/to/blocked");
210        let args = mount.to_bwrap_args();
211        assert_eq!(args, vec!["--ro-bind", "/dev/null", "/path/to/blocked"]);
212    }
213}