1use 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
48pub 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
101fn 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 if s.contains('\\') || s.contains('\0') {
122 return Err(OutputError::BadComponent(s.to_string()));
123 }
124 out.push(s);
125 }
126 Component::CurDir => {} 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}