Skip to main content

mdzip_core/
links.rs

1//! Link resolution (Sections 9.3–9.6): links inside an archive, links that
2//! leave it for files next to the `.mdz`, and links into (other) archives.
3//!
4//! Resolution needs a *host*: something that knows what files and archives
5//! exist around the archive. [`FilesystemHost`] uses a directory on disk;
6//! applications and tests can implement [`LinkHost`] themselves.
7
8use std::collections::HashMap;
9use std::fmt;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12use std::time::SystemTime;
13
14use crate::archive::MdzArchive;
15use crate::paths::find_path;
16
17/// What exists at a host path.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum HostKind {
20    /// An ordinary file.
21    File,
22    /// A directory (including one named like `Folder.mdz`).
23    Directory,
24    /// An MDZip archive.
25    Archive,
26}
27
28/// Whether a link is followed by the user or loaded automatically.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
30pub enum LinkUse {
31    /// A link the user follows.
32    #[default]
33    Navigate,
34    /// A resource the document loads automatically: an image, a stylesheet.
35    Embed,
36}
37
38/// Why a link didn't resolve.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum UnresolvedReason {
41    /// Nothing exists at the target.
42    NotFound,
43    /// The link is malformed: an absolute path, a backslash, a bad
44    /// percent-escape, or `..` above an archive's root.
45    Invalid,
46    /// The host doesn't support this kind of link (Sections 9.4, 9.5).
47    Unsupported,
48    /// The archive has no location on a host, so links can't leave it.
49    NoHostLocation,
50    /// The link goes into an archive inside another archive.
51    NestedArchive,
52    /// The target is outside the trust boundary, or an embed would load a
53    /// resource from outside the archive (Section 9.6).
54    Denied,
55}
56
57impl UnresolvedReason {
58    /// The reason as the spec's fixtures write it, for example `"not-found"`.
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            UnresolvedReason::NotFound => "not-found",
62            UnresolvedReason::Invalid => "invalid",
63            UnresolvedReason::Unsupported => "unsupported",
64            UnresolvedReason::NoHostLocation => "no-host-location",
65            UnresolvedReason::NestedArchive => "nested-archive",
66            UnresolvedReason::Denied => "denied",
67        }
68    }
69}
70
71impl fmt::Display for UnresolvedReason {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(self.as_str())
74    }
75}
76
77/// Where a link points. Fragments (`#section`) are kept, without the `#`.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum LinkTarget {
80    /// An entry in the source archive.
81    Internal {
82        /// Stored entry path.
83        entry: String,
84        /// Fragment, if any.
85        fragment: Option<String>,
86    },
87    /// A file on the host, outside any archive.
88    Host {
89        /// Host path, `/`-separated.
90        path: String,
91        /// Fragment, if any.
92        fragment: Option<String>,
93    },
94    /// An entry in an archive addressed through the host (possibly the
95    /// source archive itself).
96    Archive {
97        /// Host path of the archive.
98        archive: String,
99        /// Stored entry path.
100        entry: String,
101        /// Fragment, if any.
102        fragment: Option<String>,
103    },
104    /// An external URL such as `https:` or `mailto:`, not resolved as a path.
105    Url,
106    /// The link didn't resolve.
107    Unresolved(UnresolvedReason),
108}
109
110impl LinkTarget {
111    /// `"internal"`, `"host"`, `"archive"`, `"url"` or `"unresolved"`.
112    pub fn kind(&self) -> &'static str {
113        match self {
114            LinkTarget::Internal { .. } => "internal",
115            LinkTarget::Host { .. } => "host",
116            LinkTarget::Archive { .. } => "archive",
117            LinkTarget::Url => "url",
118            LinkTarget::Unresolved(_) => "unresolved",
119        }
120    }
121
122    fn with_fragment(mut self, frag: &Option<String>) -> Self {
123        if let LinkTarget::Internal { fragment, .. }
124        | LinkTarget::Host { fragment, .. }
125        | LinkTarget::Archive { fragment, .. } = &mut self
126        {
127            fragment.clone_from(frag);
128        }
129        self
130    }
131}
132
133/// Where a link was written.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum LinkSource<'a> {
136    /// In document `path` of the archive at host path `archive`.
137    Archive {
138        /// Host path of the archive.
139        archive: &'a str,
140        /// Entry path of the document.
141        path: &'a str,
142    },
143    /// In an ordinary file at host path `host`.
144    Host {
145        /// Host path of the file.
146        host: &'a str,
147    },
148}
149
150/// What link resolution needs to know about the files around an archive.
151///
152/// Host paths are `/`-separated and relative to the host's root. The
153/// capability and trust methods have defaults matching the spec: links may
154/// leave archives and go into them, the whole host is trusted, and embeds
155/// never load resources from outside an archive.
156pub trait LinkHost {
157    /// What exists at `path`, or `None`.
158    fn kind(&self, path: &str) -> Option<HostKind>;
159    /// The stored entry path in `archive` matching `entry` (NFC,
160    /// case-sensitive), or `None`.
161    fn find_entry(&self, archive: &str, entry: &str) -> Option<String>;
162    /// The entry point of `archive` (Section 5.5), or `None`.
163    fn entry_point(&self, archive: &str) -> Option<String>;
164
165    /// Whether the source archive has a location on this host (Section 9.4).
166    fn host_location(&self) -> bool {
167        true
168    }
169    /// Whether links may leave an archive (Section 9.4).
170    fn outside_links(&self) -> bool {
171        true
172    }
173    /// Whether links may point into archives (Section 9.5).
174    fn links_into_archives(&self) -> bool {
175        true
176    }
177    /// The trust boundary (Section 9.6): targets outside it are denied.
178    /// `Some("")` trusts the whole host; `None` trusts no location at all.
179    fn trust_root(&self) -> Option<&str> {
180        Some("")
181    }
182    /// Host paths outside the boundary that the user explicitly allowed.
183    fn is_allowed(&self, _path: &str) -> bool {
184        false
185    }
186    /// Whether embeds may load resources from outside an archive (inside the
187    /// trust boundary only).
188    fn load_outside_resources(&self) -> bool {
189        false
190    }
191}
192
193/// True for a name with an `.mdz` extension, compared ASCII case-insensitively.
194pub fn is_mdz_name(name: &str) -> bool {
195    name.to_ascii_lowercase().ends_with(".mdz")
196}
197
198fn has_scheme(link: &str) -> bool {
199    let mut chars = link.chars();
200    if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) {
201        return false;
202    }
203    for c in chars {
204        if c == ':' {
205            return true;
206        }
207        if !(c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-')) {
208            return false;
209        }
210    }
211    false
212}
213
214fn dir_segments(path: &str) -> Vec<String> {
215    let mut segs: Vec<String> = path.split('/').map(str::to_owned).collect();
216    segs.pop();
217    segs.retain(|s| !s.is_empty());
218    segs
219}
220
221fn hex(b: u8) -> Option<u8> {
222    (b as char).to_digit(16).map(|d| d as u8)
223}
224
225/// Percent-decode exactly once; invalid escapes and non-UTF-8 results fail.
226fn percent_decode(raw: &str) -> Option<String> {
227    if !raw.contains('%') {
228        return Some(raw.to_owned());
229    }
230    let b = raw.as_bytes();
231    let mut out = Vec::with_capacity(b.len());
232    let mut i = 0;
233    while i < b.len() {
234        if b[i] == b'%' {
235            let hi = b.get(i + 1).copied().and_then(hex)?;
236            let lo = b.get(i + 2).copied().and_then(hex)?;
237            out.push(hi * 16 + lo);
238            i += 3;
239        } else {
240            out.push(b[i]);
241            i += 1;
242        }
243    }
244    String::from_utf8(out).ok()
245}
246
247/// Destination to decoded segments (Section 9.3), or `None` if invalid.
248fn segments(dest: &str) -> Option<Vec<String>> {
249    if dest.starts_with('/') || dest.contains('\\') {
250        return None;
251    }
252    dest.split('/')
253        .map(|raw| {
254            let s = percent_decode(raw)?;
255            let bad = s
256                .chars()
257                .any(|c| c == '/' || c == '\\' || (c as u32) < 0x20 || c as u32 == 0x7f);
258            (!bad).then_some(s)
259        })
260        .collect()
261}
262
263fn permitted(host: &dyn LinkHost, path: &str) -> bool {
264    if host.is_allowed(path) {
265        return true;
266    }
267    match host.trust_root() {
268        None => false,
269        Some("") => true,
270        Some(root) => path == root || path.starts_with(&format!("{}/", root.trim_end_matches('/'))),
271    }
272}
273
274/// Resolve `link` written in `source` (Sections 9.3–9.6).
275///
276/// Host targets outside the host's trust boundary are
277/// [`UnresolvedReason::Denied`] whether or not they exist, so a link can't
278/// probe for files. Embeds that leave an archive are denied unless the host
279/// enables [`LinkHost::load_outside_resources`]. Nothing is ever loaded from
280/// the network: a link with a URL scheme is [`LinkTarget::Url`].
281pub fn resolve_link(source: LinkSource<'_>, link: &str, host: &dyn LinkHost, use_: LinkUse) -> LinkTarget {
282    if has_scheme(link) {
283        return LinkTarget::Url;
284    }
285    // Split at the first "#" before removing the query: a "?" in the fragment stays there.
286    let (dest, fragment) = match link.split_once('#') {
287        Some((d, f)) => (d, (!f.is_empty()).then(|| f.to_owned())),
288        None => (link, None),
289    };
290    let dest = dest.split_once('?').map_or(dest, |(d, _)| d);
291    // A trailing "/" names a directory: never an entry or a host file (only an archive).
292    let directory = dest.ends_with('/');
293
294    if dest.is_empty() {
295        return match source {
296            LinkSource::Archive { path, .. } => LinkTarget::Internal {
297                entry: path.to_owned(),
298                fragment,
299            },
300            LinkSource::Host { host: path } => LinkTarget::Host {
301                path: path.to_owned(),
302                fragment,
303            },
304        };
305    }
306    let Some(segs) = segments(dest) else {
307        return LinkTarget::Unresolved(UnresolvedReason::Invalid);
308    };
309
310    let mut i = 0;
311    let mut stack = match source {
312        LinkSource::Archive { archive, path } => {
313            let mut stack = dir_segments(path);
314            let mut left = false;
315            while i < segs.len() {
316                let s = &segs[i];
317                i += 1;
318                match s.as_str() {
319                    "" | "." => {}
320                    ".." => {
321                        if stack.pop().is_none() {
322                            left = true;
323                            break;
324                        }
325                    }
326                    _ => stack.push(s.clone()),
327                }
328            }
329            if !left {
330                if directory {
331                    return LinkTarget::Unresolved(UnresolvedReason::NotFound);
332                }
333                return match host.find_entry(archive, &stack.join("/")) {
334                    Some(entry) => LinkTarget::Internal { entry, fragment: None }.with_fragment(&fragment),
335                    None => LinkTarget::Unresolved(UnresolvedReason::NotFound),
336                };
337            }
338            if !host.outside_links() {
339                return LinkTarget::Unresolved(UnresolvedReason::Unsupported);
340            }
341            if !host.host_location() {
342                return LinkTarget::Unresolved(UnresolvedReason::NoHostLocation);
343            }
344            if use_ == LinkUse::Embed && !host.load_outside_resources() {
345                return LinkTarget::Unresolved(UnresolvedReason::Denied);
346            }
347            dir_segments(archive)
348        }
349        LinkSource::Host { host: path } => dir_segments(path),
350    };
351
352    while i < segs.len() {
353        let s = &segs[i];
354        i += 1;
355        match s.as_str() {
356            "" | "." => continue,
357            ".." => {
358                if stack.pop().is_none() {
359                    return LinkTarget::Unresolved(UnresolvedReason::NotFound);
360                }
361                continue;
362            }
363            _ => stack.push(s.clone()),
364        }
365        let host_path = stack.join("/");
366        if is_mdz_name(s) && permitted(host, &host_path) && host.kind(&host_path) == Some(HostKind::Archive) {
367            if !host.links_into_archives() {
368                return LinkTarget::Unresolved(UnresolvedReason::Unsupported);
369            }
370            if let LinkSource::Archive { archive, .. } = source {
371                if host_path != archive && use_ == LinkUse::Embed && !host.load_outside_resources() {
372                    return LinkTarget::Unresolved(UnresolvedReason::Denied);
373                }
374            }
375            return inside_archive(&host_path, &segs[i..], host, &fragment, directory);
376        }
377    }
378
379    let host_path = stack.join("/");
380    // Checked before existence, so a link can't probe for files outside the boundary.
381    if !permitted(host, &host_path) {
382        return LinkTarget::Unresolved(UnresolvedReason::Denied);
383    }
384    if directory || host.kind(&host_path) != Some(HostKind::File) {
385        return LinkTarget::Unresolved(UnresolvedReason::NotFound);
386    }
387    LinkTarget::Host {
388        path: host_path,
389        fragment,
390    }
391}
392
393fn inside_archive(
394    archive: &str,
395    segs: &[String],
396    host: &dyn LinkHost,
397    fragment: &Option<String>,
398    directory: bool,
399) -> LinkTarget {
400    let mut inner: Vec<&str> = Vec::new();
401    for (j, s) in segs.iter().enumerate() {
402        match s.as_str() {
403            "" | "." => continue,
404            ".." => {
405                if inner.pop().is_none() {
406                    return LinkTarget::Unresolved(UnresolvedReason::Invalid);
407                }
408                continue;
409            }
410            _ => inner.push(s),
411        }
412        if is_mdz_name(s) && j < segs.len() - 1 {
413            return LinkTarget::Unresolved(UnresolvedReason::NestedArchive);
414        }
415    }
416    let entry = if inner.is_empty() {
417        host.entry_point(archive)
418    } else if directory {
419        None
420    } else {
421        host.find_entry(archive, &inner.join("/"))
422    };
423    match entry {
424        Some(entry) => LinkTarget::Archive {
425            archive: archive.to_owned(),
426            entry,
427            fragment: None,
428        }
429        .with_fragment(fragment),
430        None => LinkTarget::Unresolved(UnresolvedReason::NotFound),
431    }
432}
433
434/// Options for [`FilesystemHost`].
435#[derive(Clone, Debug)]
436pub struct HostOptions {
437    /// Whether links may leave an archive (Section 9.4). Default `true`.
438    pub outside_links: bool,
439    /// Whether links may point into archives (Section 9.5). Default `true`.
440    pub links_into_archives: bool,
441    /// Trust boundary relative to the host root (Section 9.6). `Some("")`,
442    /// the default, trusts the whole root; `None` trusts nothing.
443    pub trust_root: Option<String>,
444    /// Host paths outside the boundary the user explicitly allowed.
445    pub allowed_paths: Vec<String>,
446    /// Whether embeds may load resources from outside an archive. Default `false`.
447    pub load_outside_resources: bool,
448}
449
450impl Default for HostOptions {
451    fn default() -> Self {
452        HostOptions {
453            outside_links: true,
454            links_into_archives: true,
455            trust_root: Some(String::new()),
456            allowed_paths: Vec::new(),
457            load_outside_resources: false,
458        }
459    }
460}
461
462type ArchiveCache = HashMap<String, (Option<SystemTime>, Option<Arc<MdzArchive>>)>;
463
464/// A [`LinkHost`] backed by a directory on disk.
465///
466/// Host paths are relative to `root`; links can't climb above it. Archives
467/// are opened read-only on demand, and cached by modification time, to look
468/// up entries. Nothing is extracted or written (Section 9.6).
469#[derive(Debug)]
470pub struct FilesystemHost {
471    root: PathBuf,
472    options: HostOptions,
473    cache: Mutex<ArchiveCache>,
474}
475
476impl FilesystemHost {
477    /// A host rooted at `root`, with default options.
478    pub fn new(root: impl Into<PathBuf>) -> Self {
479        Self::with_options(root, HostOptions::default())
480    }
481
482    /// A host rooted at `root`.
483    pub fn with_options(root: impl Into<PathBuf>, options: HostOptions) -> Self {
484        FilesystemHost {
485            root: root.into(),
486            options,
487            cache: Mutex::new(HashMap::new()),
488        }
489    }
490
491    fn full(&self, path: &str) -> PathBuf {
492        let mut full = self.root.clone();
493        full.extend(path.split('/').filter(|s| !s.is_empty()));
494        full
495    }
496
497    fn archive(&self, path: &str) -> Option<Arc<MdzArchive>> {
498        let full = self.full(path);
499        let modified = std::fs::metadata(&full).ok()?.modified().ok();
500        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
501        if let Some((when, archive)) = cache.get(path) {
502            if *when == modified && modified.is_some() {
503                return archive.clone();
504            }
505        }
506        let archive = MdzArchive::open_path(&full).ok().map(Arc::new);
507        cache.insert(path.to_owned(), (modified, archive.clone()));
508        archive
509    }
510}
511
512fn starts_like_zip(path: &Path) -> bool {
513    use std::io::Read;
514    let mut head = [0u8; 4];
515    std::fs::File::open(path)
516        .and_then(|mut f| f.read_exact(&mut head))
517        .is_ok_and(|_| crate::zip::looks_like_zip(&head))
518}
519
520impl LinkHost for FilesystemHost {
521    fn kind(&self, path: &str) -> Option<HostKind> {
522        let full = self.full(path);
523        let meta = std::fs::metadata(&full).ok()?;
524        if meta.is_dir() {
525            Some(HostKind::Directory)
526        } else if meta.is_file() {
527            if is_mdz_name(path) && starts_like_zip(&full) {
528                Some(HostKind::Archive)
529            } else {
530                Some(HostKind::File)
531            }
532        } else {
533            None
534        }
535    }
536
537    fn find_entry(&self, archive: &str, entry: &str) -> Option<String> {
538        let a = self.archive(archive)?;
539        find_path(a.files(), entry).map(str::to_owned)
540    }
541
542    fn entry_point(&self, archive: &str) -> Option<String> {
543        self.archive(archive)?.entry_point().ok()
544    }
545
546    fn outside_links(&self) -> bool {
547        self.options.outside_links
548    }
549    fn links_into_archives(&self) -> bool {
550        self.options.links_into_archives
551    }
552    fn trust_root(&self) -> Option<&str> {
553        self.options.trust_root.as_deref()
554    }
555    fn is_allowed(&self, path: &str) -> bool {
556        self.options.allowed_paths.iter().any(|p| p == path)
557    }
558    fn load_outside_resources(&self) -> bool {
559        self.options.load_outside_resources
560    }
561}