Skip to main content

mur_common/muragent/
reader.rs

1//! `.muragent` reader — extract and inspect a signed agent package.
2
3use crate::muragent::MuragentError;
4use flate2::read::GzDecoder;
5use std::collections::BTreeMap;
6use std::io::Read;
7use std::path::Path;
8use tar::Archive;
9
10/// Resource bounds for an untrusted `.muragent`. A `.muragent` is a tar.gz from
11/// an untrusted source (a friend, a download), so the reader must not let a tiny
12/// archive decompress into unbounded memory (a gzip/tar bomb). These caps are
13/// generous for a real agent bundle (manifest + a few icons/skills) but stop a
14/// malicious package from OOM-ing the host on import.
15const MAX_ENTRIES: usize = 10_000;
16const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB per file
17const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB decompressed total
18
19#[derive(Debug)]
20pub struct MuragentArchive {
21    /// All files in the tarball keyed by path → raw bytes.
22    pub files: BTreeMap<String, Vec<u8>>,
23}
24
25impl MuragentArchive {
26    /// Read and extract all files from a `.muragent` tar.gz.
27    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    /// Read and extract all files from `.muragent` bytes (in-memory gzip).
32    /// Used when fetching official packages that are not yet written to disk.
33    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    /// Implementation of [`read`](Self::read) with explicit resource caps, so
117    /// the bomb defenses can be exercised with small limits in tests.
118    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            // Skip directories — we don't need them in the map
169            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            // Check mode bits — regular files must not be executable
177            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            // Read with a per-file cap (read one byte past the limit to detect
182            // overflow), then enforce the running decompressed-total cap. This
183            // is what actually stops a gzip/tar bomb — the header size field is
184            // attacker-controlled and cannot be trusted.
185            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    /// Build an in-memory `.muragent`-shaped tar.gz from (path, bytes) pairs.
235    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        // Keep the tempdir alive by leaking it — fine for a unit test.
251        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}