Skip to main content

mdzip_core/
archive.rs

1//! Reading `.mdz` archives safely (Sections 4, 5, 13).
2
3use std::collections::HashMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use crate::entrypoint::resolve_entry_point;
8use crate::error::{ErrorCode, MdzError, Result, err};
9use crate::links::{FilesystemHost, HostKind, LinkHost, LinkSource, LinkTarget, LinkUse, resolve_link};
10use crate::manifest::{Manifest, Mode, manifest_mode, parse_manifest, validate_manifest};
11use crate::paths::{is_markdown, nfc, strip_dot_slash, validate_entry_set, validate_path};
12use crate::zip::{self, METHOD_DEFLATE, METHOD_STORED, ZipEntry};
13
14/// The manifest's entry name (Section 6).
15pub const MANIFEST_NAME: &str = "manifest.json";
16
17/// Resource limits applied when opening and reading archives (Section 13.1).
18///
19/// Declared sizes in the ZIP directory are checked when the archive is
20/// opened; actual decompressed bytes are checked again while reading, since
21/// declared sizes can lie. The defaults match the spec's reference limits.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct Limits {
24    /// Most entries an archive may declare. Default 100,000.
25    pub max_entries: u64,
26    /// Most bytes all file entries may declare together. Default 4 GiB.
27    pub max_total_uncompressed: u64,
28    /// Most bytes one entry may hold. Default 1 GiB.
29    pub max_entry_uncompressed: u64,
30    /// Highest uncompressed-to-compressed ratio for one entry. Default 1000.
31    pub max_compression_ratio: f64,
32    /// The ratio applies to entries declaring more than this. Default 1 MiB.
33    pub ratio_applies_above: u64,
34}
35
36impl Limits {
37    /// The default limits.
38    pub const DEFAULT: Limits = Limits {
39        max_entries: 100_000,
40        max_total_uncompressed: 4 * 1024 * 1024 * 1024,
41        max_entry_uncompressed: 1024 * 1024 * 1024,
42        max_compression_ratio: 1000.0,
43        ratio_applies_above: 1024 * 1024,
44    };
45}
46
47impl Default for Limits {
48    fn default() -> Self {
49        Limits::DEFAULT
50    }
51}
52
53/// How serious a [`Diagnostic`] is.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
55pub enum Level {
56    /// The archive doesn't conform; for example its entry point can't be resolved.
57    Error,
58    /// The archive is usable, but something was ignored or is missing.
59    Warning,
60    /// Not a problem; for example an archive without a manifest.
61    Info,
62}
63
64impl fmt::Display for Level {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(match self {
67            Level::Error => "error",
68            Level::Warning => "warning",
69            Level::Info => "info",
70        })
71    }
72}
73
74/// A finding from opening or validating an archive.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct Diagnostic {
77    /// How serious it is.
78    pub level: Level,
79    /// A stable code: an `ERR_` code from [`ErrorCode`], or a `WARN_`/`INFO_`
80    /// code such as `WARN_FIELD_IGNORED`.
81    pub code: &'static str,
82    /// A human-readable message.
83    pub message: String,
84}
85
86impl Diagnostic {
87    fn new(level: Level, code: &'static str, message: impl Into<String>) -> Self {
88        Diagnostic {
89            level,
90            code,
91            message: message.into(),
92        }
93    }
94}
95
96/// ZIP metadata of one entry.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct EntryInfo {
99    /// The path as stored.
100    pub name: String,
101    /// Compression method: 0 (stored) or 8 (DEFLATE).
102    pub compression_method: u16,
103    /// Declared uncompressed size in bytes.
104    pub size: u64,
105    /// Stored (compressed) size in bytes.
106    pub compressed_size: u64,
107    /// CRC-32 of the uncompressed bytes.
108    pub crc32: u32,
109    /// Modification time as stored: `(MS-DOS date, MS-DOS time)`.
110    pub dos_date_time: (u16, u16),
111    /// Unix mode bits, if the entry was written by a Unix-style tool.
112    pub unix_mode: Option<u32>,
113}
114
115impl From<&ZipEntry> for EntryInfo {
116    fn from(e: &ZipEntry) -> Self {
117        let mode = e.external_attributes >> 16;
118        EntryInfo {
119            name: e.name.clone(),
120            compression_method: e.method,
121            size: e.size,
122            compressed_size: e.compressed_size,
123            crc32: e.crc32,
124            dos_date_time: (e.dos_date, e.dos_time),
125            unix_mode: (mode != 0).then_some(mode),
126        }
127    }
128}
129
130/// An opened, read-only `.mdz` archive.
131///
132/// The archive bytes are kept in memory; nothing is extracted to disk.
133/// Opening is safe for untrusted input: see [`MdzArchive::open`].
134#[derive(Clone)]
135pub struct MdzArchive {
136    data: Vec<u8>,
137    path: Option<PathBuf>,
138    limits: Limits,
139    pub(crate) files: Vec<ZipEntry>,
140    pub(crate) directories: Vec<ZipEntry>,
141    pub(crate) special: Vec<ZipEntry>,
142    file_index: HashMap<String, usize>,
143    manifest: Option<Manifest>,
144    mode: Mode,
145    ignored_fields: Vec<&'static str>,
146    warnings: Vec<Diagnostic>,
147}
148
149impl fmt::Debug for MdzArchive {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.debug_struct("MdzArchive")
152            .field("path", &self.path)
153            .field("files", &self.files().collect::<Vec<_>>())
154            .field("mode", &self.mode)
155            .finish_non_exhaustive()
156    }
157}
158
159impl MdzArchive {
160    /// Open an archive from its bytes, with the default [`Limits`].
161    ///
162    /// Fails, with a stable [`ErrorCode`], for:
163    /// - an invalid ZIP, an encrypted entry, or a compression method other
164    ///   than stored or DEFLATE ([`ErrorCode::ZipInvalid`],
165    ///   [`ErrorCode::ZipEncrypted`]);
166    /// - entry names that are absolute, contain `..`, empty or `.` segments,
167    ///   reserved or control characters, or that duplicate or conflict with
168    ///   another entry after NFC normalization (the `ERR_PATH_*`,
169    ///   [`ErrorCode::DuplicatePath`] and [`ErrorCode::PathConflict`] codes);
170    /// - entries whose data overlaps another's, or that exceed the limits
171    ///   ([`ErrorCode::LimitExceeded`]);
172    /// - an unreadable manifest, a newer major `spec.version`, or an
173    ///   unrecognized `mode`.
174    ///
175    /// Symbolic link entries are never followed or read: they're listed in
176    /// [`special_files`](Self::special_files), with a warning.
177    pub fn open(data: impl Into<Vec<u8>>) -> Result<Self> {
178        Self::open_with_limits(data, Limits::DEFAULT)
179    }
180
181    /// Open an archive from its bytes with custom limits.
182    pub fn open_with_limits(data: impl Into<Vec<u8>>, limits: Limits) -> Result<Self> {
183        Self::from_bytes(data.into(), None, limits)
184    }
185
186    /// Read and open the archive at `path`.
187    pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
188        Self::open_path_with_limits(path, Limits::DEFAULT)
189    }
190
191    /// Read and open the archive at `path` with custom limits.
192    pub fn open_path_with_limits(path: impl AsRef<Path>, limits: Limits) -> Result<Self> {
193        let path = path.as_ref();
194        let data = std::fs::read(path)
195            .map_err(|e| MdzError::new(ErrorCode::Io, format!("can't read '{}': {e}", path.display())))?;
196        Self::from_bytes(data, Some(path.to_path_buf()), limits)
197    }
198
199    fn from_bytes(data: Vec<u8>, path: Option<PathBuf>, limits: Limits) -> Result<Self> {
200        let mut entries = zip::read_directory(&data, limits.max_entries)?;
201        for e in &entries {
202            validate_path(&e.name)?;
203        }
204        // Duplicates and file/directory conflicts are rejected, never resolved by picking one (Section 13.1).
205        validate_entry_set(entries.iter().map(|e| e.name.as_str()))?;
206
207        let mut warnings = Vec::new();
208        let mut total: u64 = 0;
209        for e in &entries {
210            if zip::is_encrypted(e.flags) {
211                return err(ErrorCode::ZipEncrypted, format!("entry '{}' is encrypted", e.name));
212            }
213            if e.name.ends_with('/') {
214                continue;
215            }
216            if e.method != METHOD_STORED && e.method != METHOD_DEFLATE {
217                return err(
218                    ErrorCode::ZipInvalid,
219                    format!(
220                        "entry '{}' uses compression method {}; MDZip allows 0 (stored) and 8 (DEFLATE)",
221                        e.name, e.method
222                    ),
223                );
224            }
225            check_declared_size(e, &limits)?;
226            if e.is_symlink() {
227                warnings.push(Diagnostic::new(
228                    Level::Warning,
229                    ErrorCode::PathInvalid.as_str(),
230                    format!(
231                        "symbolic link '{}' ignored; an update keeps it unless asked to drop it (Sections 4, 13.1)",
232                        e.name
233                    ),
234                ));
235                continue;
236            }
237            total = total.saturating_add(e.size);
238            if total > limits.max_total_uncompressed {
239                return err(
240                    ErrorCode::LimitExceeded,
241                    format!(
242                        "total uncompressed size exceeds {} bytes",
243                        limits.max_total_uncompressed
244                    ),
245                );
246            }
247        }
248        zip::locate_data(&data, &mut entries)?;
249
250        let mut archive = MdzArchive {
251            data,
252            path,
253            limits,
254            files: Vec::new(),
255            directories: Vec::new(),
256            special: Vec::new(),
257            file_index: HashMap::new(),
258            manifest: None,
259            mode: Mode::Document,
260            ignored_fields: Vec::new(),
261            warnings,
262        };
263        for e in entries {
264            if e.name.ends_with('/') {
265                archive.directories.push(e);
266            } else if e.is_symlink() {
267                archive.special.push(e);
268            } else {
269                archive.file_index.insert(nfc(&e.name), archive.files.len());
270                archive.files.push(e);
271            }
272        }
273
274        if archive.find(MANIFEST_NAME).is_some() {
275            let parsed = parse_manifest(&archive.read_bytes(MANIFEST_NAME)?)?;
276            for member in &parsed.duplicate_members {
277                archive.warnings.push(Diagnostic::new(
278                    Level::Warning,
279                    "WARN_DUPLICATE_MEMBER",
280                    format!("manifest.json repeats member '{member}'; the last one is used (Section 6.4)"),
281                ));
282            }
283            // entryPoint is checked when the entry point is resolved, so the rest stays readable.
284            archive.ignored_fields = validate_manifest(&parsed.manifest, false)?;
285            for name in &archive.ignored_fields {
286                archive.warnings.push(Diagnostic::new(
287                    Level::Warning,
288                    "WARN_FIELD_IGNORED",
289                    format!(
290                        "manifest field '{name}' has the wrong type or an invalid value and is ignored (Section 6.4)"
291                    ),
292                ));
293            }
294            archive.mode = manifest_mode(Some(&parsed.manifest))?;
295            archive.manifest = Some(parsed.manifest);
296        }
297        Ok(archive)
298    }
299
300    // ---- The archive ----
301
302    /// The archive bytes.
303    pub fn as_bytes(&self) -> &[u8] {
304        &self.data
305    }
306
307    /// The archive bytes, consuming the archive.
308    pub fn into_bytes(self) -> Vec<u8> {
309        self.data
310    }
311
312    /// The file the archive was opened from, if any.
313    pub fn path(&self) -> Option<&Path> {
314        self.path.as_deref()
315    }
316
317    /// The limits the archive was opened with.
318    pub fn limits(&self) -> &Limits {
319        &self.limits
320    }
321
322    // ---- Entries ----
323
324    /// File entry paths, as stored, in archive order. Directory entries and
325    /// symbolic links aren't included.
326    pub fn files(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
327        self.files.iter().map(|e| e.name.as_str())
328    }
329
330    /// Directory entries the archive stores, such as an empty folder (each
331    /// ends in `/`). A folder only implied by a file path like `docs/a.md`
332    /// isn't listed.
333    pub fn directories(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
334        self.directories.iter().map(|e| e.name.as_str())
335    }
336
337    /// Symbolic link entries, as stored. Never followed or read (Section 13.1).
338    pub fn special_files(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
339        self.special.iter().map(|e| e.name.as_str())
340    }
341
342    /// Markdown file entries, sorted.
343    pub fn documents(&self) -> Vec<&str> {
344        let mut docs: Vec<&str> = self.files().filter(|p| is_markdown(p)).collect();
345        docs.sort_unstable();
346        docs
347    }
348
349    /// The stored file path matching `path` (NFC, case-sensitive; Section 5.4.1).
350    pub fn find(&self, path: &str) -> Option<&str> {
351        self.file_index.get(&nfc(path)).map(|&i| self.files[i].name.as_str())
352    }
353
354    /// Whether a file matching `path` exists (Section 5.4.1).
355    pub fn contains(&self, path: &str) -> bool {
356        self.find(path).is_some()
357    }
358
359    /// The stored directory entry matching `path`, with or without its
360    /// trailing `/`.
361    pub fn find_directory(&self, path: &str) -> Option<&str> {
362        let wanted = nfc(&format!("{}/", path.trim_end_matches('/')));
363        self.directories
364            .iter()
365            .find(|e| nfc(&e.name) == wanted)
366            .map(|e| e.name.as_str())
367    }
368
369    pub(crate) fn entry(&self, path: &str) -> Option<&ZipEntry> {
370        self.file_index.get(&nfc(path)).map(|&i| &self.files[i])
371    }
372
373    /// ZIP metadata of the file matching `path`.
374    pub fn entry_info(&self, path: &str) -> Option<EntryInfo> {
375        self.entry(path).map(EntryInfo::from)
376    }
377
378    /// A file's bytes. Fails with [`ErrorCode::NotFound`] if there's no such
379    /// file, [`ErrorCode::ZipInvalid`] if its data is corrupt, and
380    /// [`ErrorCode::LimitExceeded`] if it decompresses past its declared size
381    /// or the limits.
382    pub fn read_bytes(&self, path: &str) -> Result<Vec<u8>> {
383        let e = self
384            .entry(path)
385            .ok_or_else(|| MdzError::new(ErrorCode::NotFound, format!("'{path}' is not in the archive")))?;
386        self.read_entry(e)
387    }
388
389    pub(crate) fn read_entry(&self, e: &ZipEntry) -> Result<Vec<u8>> {
390        zip::read_entry(&self.data, e, self.limits.max_entry_uncompressed)
391    }
392
393    /// A file's text, decoded as UTF-8 (Section 7.1), with a leading BOM
394    /// removed. Line endings are left as written. Fails with
395    /// [`ErrorCode::TextInvalid`] if the file isn't UTF-8.
396    pub fn read_text(&self, path: &str) -> Result<String> {
397        let bytes = self.read_bytes(path)?;
398        let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").map(<[u8]>::to_vec).unwrap_or(bytes);
399        String::from_utf8(bytes)
400            .map_err(|e| MdzError::new(ErrorCode::TextInvalid, format!("'{path}' is not valid UTF-8: {e}")))
401    }
402
403    // ---- Entry point and metadata ----
404
405    /// The primary Markdown file (Section 5.5). Fails with
406    /// [`ErrorCode::EntryPointInvalid`] or [`ErrorCode::EntryPointMissing`]
407    /// for a bad manifest `entryPoint` (never a silent fallback), or
408    /// [`ErrorCode::EntryPointUnresolved`].
409    pub fn entry_point(&self) -> Result<String> {
410        resolve_entry_point(self.files(), self.manifest.as_ref())
411    }
412
413    /// The parsed `manifest.json`, with every field kept, or `None`.
414    pub fn manifest(&self) -> Option<&Manifest> {
415        self.manifest.as_ref()
416    }
417
418    /// `document` or `project` (Section 5.7).
419    pub fn mode(&self) -> Mode {
420        self.mode
421    }
422
423    /// The manifest title, if it's a non-empty string.
424    pub fn title(&self) -> Option<&str> {
425        self.manifest.as_ref()?.get("title")?.as_str().filter(|s| !s.is_empty())
426    }
427
428    /// Descriptive manifest fields with a wrong type or value, which are
429    /// ignored (Section 6.4).
430    pub fn ignored_fields(&self) -> &[&'static str] {
431        &self.ignored_fields
432    }
433
434    /// Warnings from opening the archive: symbolic links, repeated manifest
435    /// members, ignored manifest fields.
436    pub fn warnings(&self) -> &[Diagnostic] {
437        &self.warnings
438    }
439
440    // ---- Links ----
441
442    /// Resolve `link` written in `document` of this archive (Sections 9.3–9.6).
443    ///
444    /// Opened from a file, links may reach files in the archive's own
445    /// directory and below it, and into archives there. Opened from bytes,
446    /// links that leave the archive are unresolved
447    /// ([`UnresolvedReason::NoHostLocation`](crate::UnresolvedReason::NoHostLocation)).
448    /// For other hosts, use [`resolve_link`] with a [`LinkHost`].
449    pub fn resolve_link(&self, document: &str, link: &str, use_: LinkUse) -> LinkTarget {
450        let host = DefaultHost::new(self);
451        let archive = host.name.clone();
452        resolve_link(
453            LinkSource::Archive {
454                archive: &archive,
455                path: document,
456            },
457            link,
458            &host,
459            use_,
460        )
461    }
462
463    // ---- Validation ----
464
465    /// Conformance findings for this archive. Never fails.
466    ///
467    /// Opening already rejects what makes an archive unreadable; this adds
468    /// the entry point, producer-manifest and cover checks to the warnings
469    /// from opening.
470    pub fn validate(&self) -> Vec<Diagnostic> {
471        let mut found = self.warnings.clone();
472        if let Err(e) = self.entry_point() {
473            found.push(Diagnostic::new(Level::Error, e.code().as_str(), e.message()));
474        }
475        match &self.manifest {
476            None => found.push(Diagnostic::new(
477                Level::Info,
478                "INFO_NO_MANIFEST",
479                "no manifest.json; valid, but a conforming producer would write one (Section 14.1)",
480            )),
481            Some(m) => {
482                if m.get("spec").and_then(|s| s.get("version")).is_none() {
483                    found.push(Diagnostic::new(
484                        Level::Warning,
485                        ErrorCode::ManifestInvalid.as_str(),
486                        "manifest has no spec.version (required in producer-written manifests)",
487                    ));
488                }
489                if !m.contains_key("entryPoint") {
490                    found.push(Diagnostic::new(
491                        Level::Warning,
492                        ErrorCode::ManifestInvalid.as_str(),
493                        "manifest has no entryPoint (required in producer-written manifests)",
494                    ));
495                }
496                if let Some(cover) = m.get("cover").and_then(|c| c.as_str()) {
497                    if !self.ignored_fields.contains(&"cover") && self.find(strip_dot_slash(cover)).is_none() {
498                        found.push(Diagnostic::new(
499                            Level::Warning,
500                            "WARN_COVER_MISSING",
501                            format!("cover '{cover}' is not in the archive"),
502                        ));
503                    }
504                }
505            }
506        }
507        found
508    }
509}
510
511fn check_declared_size(e: &ZipEntry, limits: &Limits) -> Result<()> {
512    if e.size > limits.max_entry_uncompressed {
513        return err(
514            ErrorCode::LimitExceeded,
515            format!(
516                "'{}' is {} bytes (limit {})",
517                e.name, e.size, limits.max_entry_uncompressed
518            ),
519        );
520    }
521    if e.size > limits.ratio_applies_above {
522        if e.compressed_size == 0 {
523            return err(
524                ErrorCode::LimitExceeded,
525                format!("'{}' declares data but stores none", e.name),
526            );
527        }
528        let ratio = e.size as f64 / e.compressed_size as f64;
529        if ratio > limits.max_compression_ratio {
530            return err(
531                ErrorCode::LimitExceeded,
532                format!(
533                    "'{}' has compression ratio {ratio:.0}:1 (limit {:.0}:1)",
534                    e.name, limits.max_compression_ratio
535                ),
536            );
537        }
538    }
539    Ok(())
540}
541
542/// The host [`MdzArchive::resolve_link`] uses: the archive itself, plus the
543/// directory it was opened from, if any.
544struct DefaultHost<'a> {
545    archive: &'a MdzArchive,
546    name: String,
547    fs: Option<FilesystemHost>,
548}
549
550impl<'a> DefaultHost<'a> {
551    fn new(archive: &'a MdzArchive) -> Self {
552        let name = archive
553            .path
554            .as_deref()
555            .and_then(Path::file_name)
556            .map(|n| n.to_string_lossy().into_owned())
557            .unwrap_or_else(|| "archive.mdz".into());
558        let fs = archive.path.as_deref().map(|p| {
559            let dir = std::path::absolute(p)
560                .ok()
561                .and_then(|a| a.parent().map(Path::to_path_buf))
562                .unwrap_or_default();
563            FilesystemHost::new(dir)
564        });
565        DefaultHost { archive, name, fs }
566    }
567}
568
569impl LinkHost for DefaultHost<'_> {
570    fn kind(&self, path: &str) -> Option<HostKind> {
571        self.fs.as_ref()?.kind(path)
572    }
573    fn find_entry(&self, archive: &str, entry: &str) -> Option<String> {
574        if archive == self.name {
575            return self.archive.find(entry).map(str::to_owned);
576        }
577        self.fs.as_ref()?.find_entry(archive, entry)
578    }
579    fn entry_point(&self, archive: &str) -> Option<String> {
580        if archive == self.name {
581            return self.archive.entry_point().ok();
582        }
583        self.fs.as_ref()?.entry_point(archive)
584    }
585    fn host_location(&self) -> bool {
586        self.fs.is_some()
587    }
588}