mur_common/muragent/
reader.rs1use crate::muragent::MuragentError;
4use flate2::read::GzDecoder;
5use std::collections::BTreeMap;
6use std::io::Read;
7use std::path::Path;
8use tar::Archive;
9
10const MAX_ENTRIES: usize = 10_000;
16const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024; #[derive(Debug)]
20pub struct MuragentArchive {
21 pub files: BTreeMap<String, Vec<u8>>,
23}
24
25impl MuragentArchive {
26 pub fn read(path: &Path) -> Result<Self, MuragentError> {
28 Self::read_with_limits(path, MAX_ENTRIES, MAX_FILE_BYTES, MAX_TOTAL_BYTES)
29 }
30
31 pub fn read_from_bytes(data: &[u8]) -> Result<Self, MuragentError> {
34 let gz = GzDecoder::new(data);
35 let mut archive = Archive::new(gz);
36 let mut files = BTreeMap::new();
37 let mut entry_count = 0usize;
38 let mut total_bytes = 0u64;
39
40 let max_entries = MAX_ENTRIES;
41 let max_file_bytes = MAX_FILE_BYTES;
42 let max_total_bytes = MAX_TOTAL_BYTES;
43
44 for entry in archive
45 .entries()
46 .map_err(|e| MuragentError::Other(format!("tar entries: {e}")))?
47 {
48 entry_count += 1;
49 if entry_count > max_entries {
50 return Err(MuragentError::Other(format!(
51 "too many entries in .muragent (>{max_entries})"
52 )));
53 }
54 let mut entry = entry.map_err(|e| MuragentError::Other(format!("tar entry: {e}")))?;
55
56 let entry_path = entry
57 .path()
58 .map_err(|e| MuragentError::Other(format!("entry path: {e}")))?
59 .to_str()
60 .ok_or_else(|| MuragentError::Other("non-UTF-8 path in tarball".into()))?
61 .to_string();
62
63 let entry_type = entry.header().entry_type();
64 if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
65 return Err(MuragentError::ExecutableContent(format!(
66 "symlinks not allowed in .muragent: {entry_path}"
67 )));
68 }
69
70 if entry_type != tar::EntryType::Regular
71 && entry_type != tar::EntryType::Directory
72 && entry_type != tar::EntryType::GNULongName
73 && entry_type != tar::EntryType::GNULongLink
74 {
75 return Err(MuragentError::ExecutableContent(format!(
76 "tar entry type {:?} not allowed: {entry_path}",
77 entry_type
78 )));
79 }
80
81 if entry_type == tar::EntryType::Directory {
82 continue;
83 }
84
85 crate::muragent::jcs_canonical::validate_tarball_path(&entry_path)
86 .map_err(|e| MuragentError::Other(e.to_string()))?;
87
88 let mode = entry.header().mode().unwrap_or(0o644);
89 crate::muragent::executable_ban::check_mode_bits(mode, false)
90 .map_err(MuragentError::ExecutableContent)?;
91
92 let mut data = Vec::new();
93 entry
94 .by_ref()
95 .take(max_file_bytes + 1)
96 .read_to_end(&mut data)
97 .map_err(MuragentError::Io)?;
98 if data.len() as u64 > max_file_bytes {
99 return Err(MuragentError::Other(format!(
100 "file exceeds {max_file_bytes} bytes in .muragent: {entry_path}"
101 )));
102 }
103 total_bytes += data.len() as u64;
104 if total_bytes > max_total_bytes {
105 return Err(MuragentError::Other(format!(
106 "decompressed .muragent exceeds {max_total_bytes} bytes total"
107 )));
108 }
109
110 files.insert(entry_path, data);
111 }
112
113 Ok(Self { files })
114 }
115
116 fn read_with_limits(
119 path: &Path,
120 max_entries: usize,
121 max_file_bytes: u64,
122 max_total_bytes: u64,
123 ) -> Result<Self, MuragentError> {
124 let file = std::fs::File::open(path).map_err(MuragentError::Io)?;
125 let gz = GzDecoder::new(file);
126 let mut archive = Archive::new(gz);
127 let mut files = BTreeMap::new();
128 let mut entry_count = 0usize;
129 let mut total_bytes = 0u64;
130
131 for entry in archive
132 .entries()
133 .map_err(|e| MuragentError::Other(format!("tar entries: {e}")))?
134 {
135 entry_count += 1;
136 if entry_count > max_entries {
137 return Err(MuragentError::Other(format!(
138 "too many entries in .muragent (>{max_entries})"
139 )));
140 }
141 let mut entry = entry.map_err(|e| MuragentError::Other(format!("tar entry: {e}")))?;
142
143 let entry_path = entry
144 .path()
145 .map_err(|e| MuragentError::Other(format!("entry path: {e}")))?
146 .to_str()
147 .ok_or_else(|| MuragentError::Other("non-UTF-8 path in tarball".into()))?
148 .to_string();
149
150 let entry_type = entry.header().entry_type();
151 if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
152 return Err(MuragentError::ExecutableContent(format!(
153 "symlinks not allowed in .muragent: {entry_path}"
154 )));
155 }
156
157 if entry_type != tar::EntryType::Regular
158 && entry_type != tar::EntryType::Directory
159 && entry_type != tar::EntryType::GNULongName
160 && entry_type != tar::EntryType::GNULongLink
161 {
162 return Err(MuragentError::ExecutableContent(format!(
163 "tar entry type {:?} not allowed: {entry_path}",
164 entry_type
165 )));
166 }
167
168 if entry_type == tar::EntryType::Directory {
170 continue;
171 }
172
173 crate::muragent::jcs_canonical::validate_tarball_path(&entry_path)
174 .map_err(|e| MuragentError::Other(e.to_string()))?;
175
176 let mode = entry.header().mode().unwrap_or(0o644);
178 crate::muragent::executable_ban::check_mode_bits(mode, false)
179 .map_err(MuragentError::ExecutableContent)?;
180
181 let mut data = Vec::new();
186 entry
187 .by_ref()
188 .take(max_file_bytes + 1)
189 .read_to_end(&mut data)
190 .map_err(MuragentError::Io)?;
191 if data.len() as u64 > max_file_bytes {
192 return Err(MuragentError::Other(format!(
193 "file exceeds {max_file_bytes} bytes in .muragent: {entry_path}"
194 )));
195 }
196 total_bytes += data.len() as u64;
197 if total_bytes > max_total_bytes {
198 return Err(MuragentError::Other(format!(
199 "decompressed .muragent exceeds {max_total_bytes} bytes total"
200 )));
201 }
202
203 files.insert(entry_path, data);
204 }
205
206 Ok(Self { files })
207 }
208
209 pub fn get(&self, path: &str) -> Option<&[u8]> {
210 self.files.get(path).map(|v| v.as_slice())
211 }
212
213 pub fn get_str(&self, path: &str) -> Result<&str, MuragentError> {
214 let bytes = self
215 .get(path)
216 .ok_or_else(|| MuragentError::Other(format!("file not found: {path}")))?;
217 std::str::from_utf8(bytes)
218 .map_err(|e| MuragentError::Other(format!("{path} is not valid UTF-8: {e}")))
219 }
220
221 pub fn files_as_vec(&self) -> Vec<(String, Vec<u8>)> {
222 self.files
223 .iter()
224 .map(|(k, v)| (k.clone(), v.clone()))
225 .collect()
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::io::Write;
233
234 fn make_targz(files: &[(&str, &[u8])]) -> std::path::PathBuf {
236 let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
237 Vec::new(),
238 flate2::Compression::fast(),
239 ));
240 for (name, data) in files {
241 let mut header = tar::Header::new_gnu();
242 header.set_size(data.len() as u64);
243 header.set_mode(0o644);
244 header.set_cksum();
245 builder.append_data(&mut header, name, *data).unwrap();
246 }
247 let gz = builder.into_inner().unwrap().finish().unwrap();
248 let dir = tempfile::tempdir().unwrap();
249 let path = dir.path().join("t.muragent");
250 std::mem::forget(dir);
252 let mut f = std::fs::File::create(&path).unwrap();
253 f.write_all(&gz).unwrap();
254 path
255 }
256
257 #[test]
258 fn within_limits_reads_ok() {
259 let p = make_targz(&[("a.txt", b"hello"), ("b.txt", b"world")]);
260 let arc = MuragentArchive::read_with_limits(&p, 10, 1024, 4096).unwrap();
261 assert_eq!(arc.files.len(), 2);
262 }
263
264 #[test]
265 fn rejects_oversized_single_file() {
266 let p = make_targz(&[("big.bin", &[0u8; 200])]);
267 let err = MuragentArchive::read_with_limits(&p, 10, 100, 1_000_000).unwrap_err();
268 assert!(format!("{err}").contains("exceeds"), "got: {err}");
269 }
270
271 #[test]
272 fn rejects_oversized_total() {
273 let p = make_targz(&[("a.bin", &[0u8; 100]), ("b.bin", &[0u8; 100])]);
274 let err = MuragentArchive::read_with_limits(&p, 10, 1024, 150).unwrap_err();
275 assert!(format!("{err}").contains("total"), "got: {err}");
276 }
277
278 #[test]
279 fn rejects_too_many_entries() {
280 let p = make_targz(&[("a", b"1"), ("b", b"2"), ("c", b"3")]);
281 let err = MuragentArchive::read_with_limits(&p, 2, 1024, 4096).unwrap_err();
282 assert!(format!("{err}").contains("too many entries"), "got: {err}");
283 }
284}