Skip to main content

asciidoc_parser/parser/
safe_mode.rs

1use crate::document::InterpretedValue;
2
3/// Describes the safe mode under which a document is parsed and rendered.
4///
5/// Safe modes provide a security model that controls how much a document is
6/// allowed to reach outside of itself. They mirror the safe modes defined by
7/// [Ruby Asciidoctor], and the discriminant values are chosen so that the
8/// modes compare in order of increasing safety (`Unsafe` < `Safe` < `Server` <
9/// `Secure`). Features that could expose the host environment (for example,
10/// embedding the contents of a file directly in the output) are only enabled
11/// when the safe mode is below a threshold.
12///
13/// The default safe mode is [`SafeMode::Secure`], matching the most
14/// conservative setting. A client may relax it via
15/// [`Parser::with_safe_mode`](crate::Parser::with_safe_mode).
16///
17/// [Ruby Asciidoctor]: https://docs.asciidoctor.org/asciidoc/latest/safe-modes/
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
19pub enum SafeMode {
20    /// A safe mode level that disables any of the security features enforced by
21    /// Asciidoctor (Ruby or otherwise). This mode is intended for use when the
22    /// document is entirely trusted.
23    Unsafe = 0,
24
25    /// A safe mode level that closely parallels [`Unsafe`](Self::Unsafe),
26    /// except it prevents access to files which reside outside of the
27    /// parent directory of the source file.
28    Safe = 1,
29
30    /// A safe mode level that disallows the document from attempting to read
31    /// files from the file system and including their contents into the
32    /// document. It also disables certain macros that pose a security risk.
33    ///
34    /// This is the most fitting safe mode for server deployments (hence the
35    /// name).
36    Server = 10,
37
38    /// A safe mode level that disallows the document from attempting to read
39    /// files from the file system and including their contents into the
40    /// document, and it prevents access to file system paths.
41    ///
42    /// This mode allows the AsciiDoc document to be processed in a shared,
43    /// server-side environment, such as a wiki, where the document should not
44    /// be able to embed the contents of arbitrary files.
45    ///
46    /// This is the default safe mode.
47    #[default]
48    Secure = 20,
49}
50
51impl SafeMode {
52    /// The lowercase name of this safe mode (`unsafe`, `safe`, `server`,
53    /// `secure`).
54    ///
55    /// This is the value exposed through the `safe-mode-name` intrinsic
56    /// attribute and is also used to build the `safe-mode-<name>` flag
57    /// attribute. It matches the (lowercased) name reported by Ruby
58    /// Asciidoctor.
59    pub(crate) fn name(self) -> &'static str {
60        match self {
61            Self::Unsafe => "unsafe",
62            Self::Safe => "safe",
63            Self::Server => "server",
64            Self::Secure => "secure",
65        }
66    }
67
68    /// The numeric level of this safe mode (`0`, `1`, `10`, or `20`).
69    ///
70    /// This is the value exposed through the `safe-mode-level` intrinsic
71    /// attribute. Higher numbers indicate a more restrictive (safer) mode.
72    pub(crate) fn level(self) -> u8 {
73        self as u8
74    }
75}
76
77/// Applies Ruby Asciidoctor's `SafeMode::Server`-and-greater masking of the
78/// `docdir` / `docfile` intrinsic attributes for a *read*.
79///
80/// Returns `Some(masked)` only when `name` is `docdir` or `docfile` and that
81/// attribute is currently set to a plain value (as reported by `raw_set_value`,
82/// which yields the *unmasked* stored value or `None` when the attribute is
83/// unset):
84///
85/// * `docdir` is masked to an empty value, so the host directory never leaks
86///   into rendered output.
87/// * `docfile` is relativized against `docdir` (see [`relativize_docfile`]),
88///   matching Asciidoctor's `docfile[(docdir.length + 1)..]` for the usual case
89///   where `docfile` sits under `docdir`, and falling back to the base name
90///   otherwise.
91///
92/// Returns `None` for any other name, and for an *unset* `docdir` / `docfile`
93/// (so a reference to one still resolves as missing rather than empty). Because
94/// the computation reads the *raw* stored values, the API-provided attributes
95/// are left untouched — a non-`Server` parser still reads them back verbatim.
96///
97/// Both [`Parser`](crate::Parser) and its
98/// [`ResolvedAttributes`](crate::parser::ResolvedAttributes) snapshot funnel
99/// their `docdir` / `docfile` reads through this one function (after confirming
100/// `safe >= SafeMode::Server`), so the two report identical values.
101pub(crate) fn masked_doc_path(
102    name: &str,
103    raw_set_value: impl Fn(&str) -> Option<String>,
104) -> Option<InterpretedValue> {
105    match name {
106        // `docdir` is blanked whenever it is set, regardless of its value.
107        "docdir" => raw_set_value("docdir").map(|_| InterpretedValue::Value(String::new())),
108
109        "docfile" => {
110            let docfile = raw_set_value("docfile")?;
111            let relative = relativize_docfile(&docfile, raw_set_value("docdir").as_deref());
112            Some(InterpretedValue::Value(relative))
113        }
114
115        _ => None,
116    }
117}
118
119/// Relativizes `docfile` against `docdir` for `SafeMode::Server` masking.
120///
121/// When `docfile` sits directly under `docdir` — i.e. it begins with the exact
122/// `docdir` prefix followed by a path separator — the prefix and separator are
123/// stripped, matching Ruby Asciidoctor's `docfile[(docdir.length + 1)..-1]`
124/// (which keeps any intermediate sub-directories, not just the base name). This
125/// is the normal case, since Asciidoctor derives `docdir` from `docfile`.
126///
127/// A trailing separator on `docdir` (e.g. `/some/dir/`) is ignored so the match
128/// still lands on a path-component boundary and nested components are
129/// preserved.
130///
131/// Unlike Asciidoctor, this crate exposes `docdir` and `docfile` as independent
132/// API attributes, so a caller can pair them inconsistently. Rather than slice
133/// at an unrelated byte offset (truncating the path, or dropping the first byte
134/// when `docdir` is empty), any `docdir` that is absent, empty, or not an
135/// actual prefix falls back to the file's base name (its trailing path
136/// segment).
137fn relativize_docfile(docfile: &str, docdir: Option<&str>) -> String {
138    // Normalize away any trailing separator(s) on `docdir` so a directory
139    // written as `/some/dir/` matches at the same component boundary as
140    // `/some/dir`; without this the relative remainder loses its leading
141    // separator and nested components would collapse to the base name.
142    if let Some(docdir) = docdir.map(|d| d.trim_end_matches(['/', '\\']))
143        && !docdir.is_empty()
144        && let Some(rest) = docfile.strip_prefix(docdir)
145        && let Some(after) = rest.strip_prefix(['/', '\\'])
146    {
147        return after.to_owned();
148    }
149
150    // No usable `docdir` prefix: use the base name (trailing path segment).
151    docfile
152        .rsplit(['/', '\\'])
153        .next()
154        .unwrap_or(docfile)
155        .to_owned()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::relativize_docfile;
161
162    #[test]
163    fn strips_exact_docdir_prefix() {
164        assert_eq!(
165            relativize_docfile("/some/dir/sample.adoc", Some("/some/dir")),
166            "sample.adoc"
167        );
168    }
169
170    #[test]
171    fn keeps_subdirectories_below_docdir() {
172        assert_eq!(
173            relativize_docfile("/some/dir/sub/sample.adoc", Some("/some/dir")),
174            "sub/sample.adoc"
175        );
176    }
177
178    #[test]
179    fn strips_a_backslash_separated_prefix() {
180        assert_eq!(
181            relativize_docfile(r"C:\some\dir\sample.adoc", Some(r"C:\some\dir")),
182            "sample.adoc"
183        );
184    }
185
186    #[test]
187    fn falls_back_to_base_name_when_docfile_is_not_under_docdir() {
188        // A `docfile` outside `docdir` must not be truncated at an unrelated
189        // offset; it relativizes to its base name instead.
190        assert_eq!(
191            relativize_docfile("/some/different/file.adoc", Some("/some/dir")),
192            "file.adoc"
193        );
194    }
195
196    #[test]
197    fn falls_back_to_base_name_when_prefix_is_not_separator_aligned() {
198        // `docdir` is a leading substring of `docfile` but not a path component
199        // (no separator follows), so the slice would corrupt the name.
200        assert_eq!(
201            relativize_docfile("/some/dirfile.adoc", Some("/some/dir")),
202            "dirfile.adoc"
203        );
204    }
205
206    #[test]
207    fn ignores_a_trailing_separator_on_docdir() {
208        // A `docdir` written with a trailing separator still relativizes to the
209        // same component boundary, preserving nested path components.
210        assert_eq!(
211            relativize_docfile("/some/dir/sub/sample.adoc", Some("/some/dir/")),
212            "sub/sample.adoc"
213        );
214        assert_eq!(
215            relativize_docfile("/some/dir/sample.adoc", Some("/some/dir/")),
216            "sample.adoc"
217        );
218        // Multiple trailing separators, and the Windows separator, too.
219        assert_eq!(
220            relativize_docfile("/some/dir/sub/sample.adoc", Some("/some/dir///")),
221            "sub/sample.adoc"
222        );
223        assert_eq!(
224            relativize_docfile(r"C:\some\dir\sub\sample.adoc", Some(r"C:\some\dir\")),
225            r"sub\sample.adoc"
226        );
227    }
228
229    #[test]
230    fn treats_empty_docdir_as_no_prefix() {
231        // An empty `docdir` must not drop the first byte of `docfile`.
232        assert_eq!(
233            relativize_docfile("/some/dir/sample.adoc", Some("")),
234            "sample.adoc"
235        );
236    }
237
238    #[test]
239    fn falls_back_to_base_name_without_a_docdir() {
240        assert_eq!(
241            relativize_docfile("/some/dir/sample.adoc", None),
242            "sample.adoc"
243        );
244    }
245
246    #[test]
247    fn returns_a_bare_docfile_unchanged() {
248        // A `docfile` with no directory component is its own base name.
249        assert_eq!(relativize_docfile("sample.adoc", None), "sample.adoc");
250        assert_eq!(
251            relativize_docfile("sample.adoc", Some("/some/dir")),
252            "sample.adoc"
253        );
254    }
255}