Skip to main content

forge_host/
filesystem.rs

1//! Output guard: validates files returned from a generator before they hit
2//! disk.
3//!
4//! Rules:
5//!
6//! * Paths are *relative*. Absolute paths and Windows drive letters are
7//!   rejected.
8//! * No `..` segments. The host normalises and confirms the result stays
9//!   inside the output directory.
10//! * No duplicate paths within a single generator output.
11//! * Per-file and total byte caps.
12//!
13//! Generators write into a temp dir adjacent to the output dir; on success
14//! the host renames into place. The rename is atomic on the same
15//! filesystem.
16
17use std::collections::HashSet;
18use std::path::{Component, Path, PathBuf};
19
20use thiserror::Error;
21
22use crate::runtime::OutputFile;
23
24#[derive(Debug, Error, PartialEq, Eq)]
25pub enum OutputError {
26    #[error("path traversal in plugin output: {0:?}")]
27    Traversal(String),
28    #[error("absolute path in plugin output: {0:?}")]
29    Absolute(String),
30    #[error("empty path in plugin output")]
31    Empty,
32    #[error("duplicate output path: {0:?}")]
33    Duplicate(String),
34    #[error("file too large: {path:?} ({bytes} > {limit})")]
35    TooLargeFile {
36        path: String,
37        bytes: u64,
38        limit: u64,
39    },
40    #[error("total output too large: {bytes} > {limit}")]
41    TooLargeTotal { bytes: u64, limit: u64 },
42    #[error("too many output files: {count} > {limit}")]
43    TooMany { count: u32, limit: u32 },
44    #[error("path contains a non-utf8 component: {0:?}")]
45    BadComponent(String),
46}
47
48/// Validate a list of output files against the limits encoded in
49/// [`Caps`].
50pub fn validate_output(files: &[OutputFile], caps: Caps) -> Result<(), OutputError> {
51    if files.len() as u64 > caps.max_files as u64 {
52        return Err(OutputError::TooMany {
53            count: files.len() as u32,
54            limit: caps.max_files,
55        });
56    }
57    let mut seen: HashSet<String> = HashSet::with_capacity(files.len());
58    let mut total: u64 = 0;
59    for f in files {
60        let normalized = sanitize_path(&f.path)?;
61        let key = normalized.to_string_lossy().into_owned();
62        if !seen.insert(key.clone()) {
63            return Err(OutputError::Duplicate(key));
64        }
65        let bytes = f.content.len() as u64;
66        if bytes > caps.max_per_file_bytes {
67            return Err(OutputError::TooLargeFile {
68                path: f.path.clone(),
69                bytes,
70                limit: caps.max_per_file_bytes,
71            });
72        }
73        total = total.saturating_add(bytes);
74        if total > caps.max_total_bytes {
75            return Err(OutputError::TooLargeTotal {
76                bytes: total,
77                limit: caps.max_total_bytes,
78            });
79        }
80    }
81    Ok(())
82}
83
84#[derive(Debug, Clone, Copy)]
85pub struct Caps {
86    pub max_files: u32,
87    pub max_total_bytes: u64,
88    pub max_per_file_bytes: u64,
89}
90
91impl Caps {
92    pub const fn from_limits(limits: super::Limits) -> Self {
93        Self {
94            max_files: limits.output_files_max,
95            max_total_bytes: limits.output_total_bytes_max,
96            max_per_file_bytes: limits.output_per_file_bytes_max,
97        }
98    }
99}
100
101/// Reject absolute paths and `..` traversal; normalise duplicate slashes and
102/// `.` segments. Returns the canonicalised relative path.
103fn sanitize_path(input: &str) -> Result<PathBuf, OutputError> {
104    if input.is_empty() {
105        return Err(OutputError::Empty);
106    }
107    let p = Path::new(input);
108    if p.is_absolute() {
109        return Err(OutputError::Absolute(input.to_string()));
110    }
111    let mut out = PathBuf::new();
112    for c in p.components() {
113        match c {
114            Component::Normal(s) => {
115                let s = s
116                    .to_str()
117                    .ok_or_else(|| OutputError::BadComponent(s.to_string_lossy().into_owned()))?;
118                // Reject `\` on any platform; these are path separators on
119                // Windows that look like literal characters on Unix and
120                // create surprising behaviour on round-trip.
121                if s.contains('\\') || s.contains('\0') {
122                    return Err(OutputError::BadComponent(s.to_string()));
123                }
124                out.push(s);
125            }
126            Component::CurDir => {} // drop `.`
127            Component::ParentDir => {
128                return Err(OutputError::Traversal(input.to_string()));
129            }
130            Component::RootDir | Component::Prefix(_) => {
131                return Err(OutputError::Absolute(input.to_string()));
132            }
133        }
134    }
135    if out.as_os_str().is_empty() {
136        return Err(OutputError::Empty);
137    }
138    Ok(out)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::runtime::FileMode;
145
146    fn caps() -> Caps {
147        Caps {
148            max_files: 100,
149            max_total_bytes: 10 * 1024,
150            max_per_file_bytes: 1024,
151        }
152    }
153
154    fn f(path: &str, bytes: usize) -> OutputFile {
155        OutputFile {
156            path: path.into(),
157            content: vec![0; bytes],
158            mode: FileMode::Text,
159        }
160    }
161
162    #[test]
163    fn ok_simple() {
164        validate_output(&[f("a.txt", 4), f("b/c.txt", 4)], caps()).unwrap();
165    }
166
167    #[test]
168    fn traversal_rejected() {
169        assert!(matches!(
170            validate_output(&[f("../etc/passwd", 1)], caps()),
171            Err(OutputError::Traversal(_))
172        ));
173        assert!(matches!(
174            validate_output(&[f("a/../../b", 1)], caps()),
175            Err(OutputError::Traversal(_))
176        ));
177    }
178
179    #[test]
180    fn absolute_rejected() {
181        assert!(matches!(
182            validate_output(&[f("/etc/passwd", 1)], caps()),
183            Err(OutputError::Absolute(_))
184        ));
185    }
186
187    #[test]
188    fn empty_rejected() {
189        assert!(matches!(
190            validate_output(&[f("", 1)], caps()),
191            Err(OutputError::Empty)
192        ));
193        assert!(matches!(
194            validate_output(&[f("./", 1)], caps()),
195            Err(OutputError::Empty)
196        ));
197    }
198
199    #[test]
200    fn backslash_rejected() {
201        assert!(matches!(
202            validate_output(&[f("foo\\bar", 1)], caps()),
203            Err(OutputError::BadComponent(_))
204        ));
205    }
206
207    #[test]
208    fn duplicates_rejected() {
209        assert!(matches!(
210            validate_output(&[f("a.txt", 1), f("./a.txt", 1)], caps()),
211            Err(OutputError::Duplicate(_))
212        ));
213    }
214
215    #[test]
216    fn per_file_cap() {
217        let mut c = caps();
218        c.max_per_file_bytes = 4;
219        assert!(matches!(
220            validate_output(&[f("a.txt", 5)], c),
221            Err(OutputError::TooLargeFile { .. })
222        ));
223    }
224
225    #[test]
226    fn total_cap() {
227        let mut c = caps();
228        c.max_total_bytes = 8;
229        assert!(matches!(
230            validate_output(&[f("a", 5), f("b", 5)], c),
231            Err(OutputError::TooLargeTotal { .. })
232        ));
233    }
234
235    #[test]
236    fn count_cap() {
237        let mut c = caps();
238        c.max_files = 1;
239        assert!(matches!(
240            validate_output(&[f("a", 1), f("b", 1)], c),
241            Err(OutputError::TooMany { .. })
242        ));
243    }
244}