Skip to main content

mcd_core/
package.rs

1//! Safe package archive reading.
2
3use std::{
4    collections::{HashMap, HashSet},
5    fs,
6    io::{Cursor, Read, Seek},
7    path::Path,
8};
9
10use camino::Utf8PathBuf;
11use zip::ZipArchive;
12
13use crate::{
14    errors::{Diagnostic, McdError, Result},
15    manifest::Manifest,
16    search::{SearchHit, SearchOptions},
17};
18
19/// Required MCD package media type.
20pub const MCD_MIMETYPE: &str = "application/vnd.mcd+zip";
21
22const MAX_FILE_COUNT: usize = 10_000;
23const MAX_SINGLE_FILE_SIZE: u64 = 64 * 1024 * 1024;
24const MAX_TOTAL_DECOMPRESSED_SIZE: u64 = 512 * 1024 * 1024;
25
26/// An opened MCD package with validated internal paths.
27#[derive(Debug, Clone)]
28pub struct McdPackage {
29    entries: HashMap<String, Vec<u8>>,
30}
31
32impl McdPackage {
33    /// Open a package from a filesystem path.
34    pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
35        let bytes = fs::read(path)?;
36        Self::from_bytes(&bytes)
37    }
38
39    /// Open a package from in-memory bytes.
40    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
41        match Self::from_reader(Cursor::new(bytes)) {
42            Ok(package) => Ok(package),
43            Err(err) if is_plain_markdown_candidate(bytes) => {
44                let markdown = std::str::from_utf8(bytes).map_err(|_| err)?;
45                Ok(Self::from_markdown(markdown))
46            }
47            Err(err) => Err(err),
48        }
49    }
50
51    /// Build a minimal package from a standalone Markdown document.
52    #[must_use]
53    pub fn from_markdown(markdown: &str) -> Self {
54        let mut entries = HashMap::new();
55        entries.insert(
56            "mimetype".to_owned(),
57            format!("{MCD_MIMETYPE}\n").into_bytes(),
58        );
59        entries.insert(
60            "manifest.json".to_owned(),
61            br#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md"}"#
62                .to_vec(),
63        );
64        entries.insert("content/main.md".to_owned(), markdown.as_bytes().to_vec());
65        Self { entries }
66    }
67
68    /// Open a package from any readable and seekable ZIP stream.
69    pub fn from_reader<R>(reader: R) -> Result<Self>
70    where
71        R: Read + Seek,
72    {
73        let mut archive = ZipArchive::new(reader)?;
74        if archive.len() > MAX_FILE_COUNT {
75            return Err(McdError::from_diagnostic(Diagnostic::error(
76                "package.file_count.exceeded",
77                format!("Package contains more than {MAX_FILE_COUNT} entries."),
78            )));
79        }
80
81        let mut entries = HashMap::new();
82        let mut seen = HashSet::new();
83        let mut total_size = 0_u64;
84
85        for index in 0..archive.len() {
86            let mut file = archive.by_index(index)?;
87            if file.is_dir() {
88                continue;
89            }
90
91            let name = file.name().to_owned();
92            let normalized = validate_internal_path(&name)?;
93
94            let duplicate_key = normalized.to_ascii_lowercase();
95            if !seen.insert(duplicate_key) {
96                return Err(McdError::from_diagnostic(
97                    Diagnostic::error(
98                        "security.path.duplicate",
99                        format!("Duplicate normalized package path '{normalized}'."),
100                    )
101                    .with_source(name),
102                ));
103            }
104
105            let size = file.size();
106            if size > MAX_SINGLE_FILE_SIZE {
107                return Err(McdError::from_diagnostic(
108                    Diagnostic::error(
109                        "package.file_size.exceeded",
110                        format!("Package entry '{normalized}' exceeds the single-file size limit."),
111                    )
112                    .with_source(normalized),
113                ));
114            }
115
116            total_size = total_size.checked_add(size).ok_or_else(|| {
117                McdError::from_diagnostic(Diagnostic::error(
118                    "package.total_size.overflow",
119                    "Package decompressed size overflowed.",
120                ))
121            })?;
122            if total_size > MAX_TOTAL_DECOMPRESSED_SIZE {
123                return Err(McdError::from_diagnostic(Diagnostic::error(
124                    "package.total_size.exceeded",
125                    format!(
126                        "Package exceeds the total decompressed size limit of {MAX_TOTAL_DECOMPRESSED_SIZE} bytes."
127                    ),
128                )));
129            }
130
131            let mut bytes = Vec::with_capacity(size.try_into().unwrap_or(0));
132            file.read_to_end(&mut bytes)?;
133            entries.insert(normalized, bytes);
134        }
135
136        let package = Self { entries };
137        package.validate_mimetype()?;
138        Ok(package)
139    }
140
141    /// Return sorted package entry paths.
142    #[must_use]
143    pub fn entry_paths(&self) -> Vec<&str> {
144        let mut paths = self.entries.keys().map(String::as_str).collect::<Vec<_>>();
145        paths.sort_unstable();
146        paths
147    }
148
149    /// Check if the package has a path.
150    #[must_use]
151    pub fn contains(&self, path: &str) -> bool {
152        self.entries.contains_key(path)
153    }
154
155    /// Read package bytes for an internal path.
156    pub fn read(&self, path: &str) -> Result<&[u8]> {
157        let normalized = validate_internal_path(path)?;
158        self.entries
159            .get(&normalized)
160            .map(Vec::as_slice)
161            .ok_or_else(|| {
162                McdError::from_diagnostic(
163                    Diagnostic::error(
164                        "package.entry.missing",
165                        format!("Package entry '{normalized}' is missing."),
166                    )
167                    .with_source(normalized),
168                )
169            })
170    }
171
172    /// Read a package entry as UTF-8 text.
173    pub fn read_to_string(&self, path: &str) -> Result<String> {
174        String::from_utf8(self.read(path)?.to_vec()).map_err(McdError::from)
175    }
176
177    /// Parse the root manifest.
178    pub fn manifest(&self) -> Result<Manifest> {
179        let bytes = self.read("manifest.json").map_err(|err| {
180            if err.diagnostic().is_some() {
181                McdError::from_diagnostic(
182                    Diagnostic::error("manifest.missing", "Package is missing manifest.json.")
183                        .with_source("manifest.json"),
184                )
185            } else {
186                err
187            }
188        })?;
189        Manifest::from_slice(bytes)
190    }
191
192    /// Search package content and metadata with the built-in BM25 index.
193    pub fn search(&self, query: &str, options: SearchOptions) -> Result<Vec<SearchHit>> {
194        crate::search::search_package(self, query, options)
195    }
196
197    /// Validate the root `mimetype` entry.
198    pub fn validate_mimetype(&self) -> Result<()> {
199        let bytes = self.entries.get("mimetype").ok_or_else(|| {
200            McdError::from_diagnostic(
201                Diagnostic::error(
202                    "package.mimetype.missing",
203                    "Package is missing root mimetype.",
204                )
205                .with_source("mimetype"),
206            )
207        })?;
208        let mimetype = std::str::from_utf8(bytes).map_err(|_| {
209            McdError::from_diagnostic(
210                Diagnostic::error(
211                    "package.mimetype.utf8",
212                    "Package mimetype is not valid UTF-8.",
213                )
214                .with_source("mimetype"),
215            )
216        })?;
217        if mimetype.trim_end_matches(['\r', '\n']) != MCD_MIMETYPE {
218            return Err(McdError::from_diagnostic(
219                Diagnostic::error(
220                    "package.mimetype.invalid",
221                    format!("Package mimetype must be '{MCD_MIMETYPE}'."),
222                )
223                .with_source("mimetype"),
224            ));
225        }
226        Ok(())
227    }
228}
229
230fn is_plain_markdown_candidate(bytes: &[u8]) -> bool {
231    !bytes.starts_with(b"PK") && std::str::from_utf8(bytes).is_ok()
232}
233
234/// Validate and normalize a package-internal path.
235pub fn validate_internal_path(path: &str) -> Result<String> {
236    if path.is_empty()
237        || path.starts_with('/')
238        || path.starts_with('\\')
239        || path.contains('\\')
240        || path.contains(':')
241        || path.contains('\0')
242    {
243        return Err(invalid_path(path));
244    }
245
246    let mut normalized = Utf8PathBuf::new();
247    for component in path.split('/') {
248        if component.is_empty() || component == "." || component == ".." {
249            return Err(invalid_path(path));
250        }
251        normalized.push(component);
252    }
253
254    let normalized = normalized.as_str().replace('\\', "/");
255    if normalized != path {
256        return Err(invalid_path(path));
257    }
258
259    Ok(normalized)
260}
261
262fn invalid_path(path: &str) -> McdError {
263    McdError::from_diagnostic(
264        Diagnostic::error(
265            "security.path.invalid",
266            format!("Package path '{path}' is not a safe relative path."),
267        )
268        .with_source(path.to_owned()),
269    )
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use proptest::prelude::*;
276    use std::io::Write;
277    use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
278
279    #[test]
280    fn validates_safe_paths() {
281        assert_eq!(
282            validate_internal_path("content/main.md").expect("valid path"),
283            "content/main.md"
284        );
285    }
286
287    #[test]
288    fn rejects_traversal() {
289        let err = validate_internal_path("content/../manifest.json").expect_err("invalid path");
290        assert_eq!(
291            err.diagnostic().map(|d| d.code.as_str()),
292            Some("security.path.invalid")
293        );
294    }
295
296    #[test]
297    fn rejects_windows_separator() {
298        let err = validate_internal_path("content\\main.md").expect_err("invalid path");
299        assert_eq!(
300            err.diagnostic().map(|d| d.code.as_str()),
301            Some("security.path.invalid")
302        );
303    }
304
305    #[test]
306    fn opens_valid_minimal_package() {
307        let package = McdPackage::from_bytes(&zip_bytes(&[
308            ("mimetype", MCD_MIMETYPE),
309            (
310                "manifest.json",
311                r#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md"}"#,
312            ),
313            ("content/main.md", "# Minimal\n"),
314        ]))
315        .expect("package opens");
316
317        assert_eq!(
318            package.manifest().expect("manifest").entrypoint,
319            "content/main.md"
320        );
321    }
322
323    #[test]
324    fn opens_plain_markdown_as_minimal_package() {
325        let markdown = "# Plain Markdown\n\nThis file was renamed to .mcd.\n";
326        let package = McdPackage::from_bytes(markdown.as_bytes()).expect("markdown opens");
327
328        assert_eq!(
329            package.entry_paths(),
330            vec!["content/main.md", "manifest.json", "mimetype"]
331        );
332        assert_eq!(
333            package.manifest().expect("manifest").entrypoint,
334            "content/main.md"
335        );
336        assert_eq!(
337            package
338                .read_to_string("content/main.md")
339                .expect("entrypoint markdown"),
340            markdown
341        );
342    }
343
344    #[test]
345    fn missing_mimetype_fails_with_diagnostic() {
346        let err = McdPackage::from_bytes(&zip_bytes(&[(
347            "manifest.json",
348            r#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md"}"#,
349        )]))
350        .expect_err("missing mimetype should fail");
351
352        assert_eq!(
353            err.diagnostic().map(|d| d.code.as_str()),
354            Some("package.mimetype.missing")
355        );
356    }
357
358    #[test]
359    fn bad_mimetype_fails_with_diagnostic() {
360        let err = McdPackage::from_bytes(&zip_bytes(&[("mimetype", "text/plain")]))
361            .expect_err("bad mimetype should fail");
362
363        assert_eq!(
364            err.diagnostic().map(|d| d.code.as_str()),
365            Some("package.mimetype.invalid")
366        );
367    }
368
369    #[test]
370    fn missing_manifest_fails_with_diagnostic() {
371        let package =
372            McdPackage::from_bytes(&zip_bytes(&[("mimetype", MCD_MIMETYPE)])).expect("opens");
373        let err = package
374            .manifest()
375            .expect_err("missing manifest should fail");
376
377        assert_eq!(
378            err.diagnostic().map(|d| d.code.as_str()),
379            Some("manifest.missing")
380        );
381    }
382
383    #[test]
384    fn path_traversal_fixture_fails() {
385        let err = McdPackage::from_bytes(&zip_bytes(&[
386            ("mimetype", MCD_MIMETYPE),
387            ("../manifest.json", "{}"),
388        ]))
389        .expect_err("traversal should fail");
390
391        assert_eq!(
392            err.diagnostic().map(|d| d.code.as_str()),
393            Some("security.path.invalid")
394        );
395    }
396
397    #[test]
398    fn duplicate_normalized_path_fails() {
399        let err = McdPackage::from_bytes(&zip_bytes(&[
400            ("mimetype", MCD_MIMETYPE),
401            ("manifest.json", "{}"),
402            ("Manifest.json", "{}"),
403        ]))
404        .expect_err("duplicate path should fail");
405
406        assert_eq!(
407            err.diagnostic().map(|d| d.code.as_str()),
408            Some("security.path.duplicate")
409        );
410    }
411
412    proptest! {
413        #[test]
414        fn validates_generated_safe_relative_paths(segments in prop::collection::vec("[A-Za-z0-9_-]{1,12}", 1..5)) {
415            let path = segments.join("/");
416            let normalized = validate_internal_path(&path).expect("safe relative path should validate");
417            prop_assert_eq!(normalized, path);
418        }
419
420        #[test]
421        fn rejects_generated_traversal_paths(prefix in "[A-Za-z0-9_-]{1,12}", suffix in "[A-Za-z0-9_-]{1,12}") {
422            let path = format!("{prefix}/../{suffix}");
423            let err = validate_internal_path(&path).expect_err("traversal path should fail");
424            prop_assert_eq!(
425                err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
426                Some("security.path.invalid")
427            );
428        }
429    }
430
431    fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
432        let cursor = Cursor::new(Vec::new());
433        let mut writer = ZipWriter::new(cursor);
434        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
435
436        for (path, content) in entries {
437            writer.start_file(*path, options).expect("start file");
438            writer.write_all(content.as_bytes()).expect("write file");
439        }
440
441        writer.finish().expect("finish zip").into_inner()
442    }
443}