Skip to main content

adoctra_core/
system_path.rs

1//! Ruby's `PathResolver#system_path` and `AbstractNode#normalize_system_path`:
2//! lexical resolution of a target against a start directory, confined to a
3//! jail (the document's base directory from safe mode up) with Ruby's
4//! automatic recovery and its warnings. No I/O: the result names the path
5//! Ruby reads and reports (e.g. "include file not found: <path>"); the host
6//! provider reads it.
7//!
8//! Like Ruby's resolver, whose `file_separator` is the host's
9//! (`File::ALT_SEPARATOR` on Windows), Windows hosts also accept drive roots
10//! (`C:/`, `C:\`) and backslashes, which are posixified to `/`; results
11//! always use `/` (e.g. `C:/doc/a.adoc`).
12use std::borrow::Cow;
13
14use crate::SafeMode;
15
16/// A resolved path and the warnings Ruby logs while resolving it.
17#[derive(Debug, PartialEq, Eq)]
18pub struct SystemPath {
19    pub path: String,
20    pub warnings: Vec<String>,
21}
22
23pub(crate) type Resolved = SystemPath;
24
25/// Ruby's `PathResolver` file separator: `\` (Windows) also accepts drive
26/// roots and backslashes, `/` only POSIX paths.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub(crate) enum PathStyle {
29    Posix,
30    Windows,
31}
32
33impl PathStyle {
34    /// The host's style, as Ruby's `File::ALT_SEPARATOR || File::SEPARATOR`.
35    pub(crate) const HOST: Self = if cfg!(windows) {
36        Self::Windows
37    } else {
38        Self::Posix
39    };
40
41    /// Ruby's `posixify`: backslashes to slashes on Windows.
42    fn posixify(self, path: &str) -> Cow<'_, str> {
43        if self == Self::Windows && path.contains('\\') {
44            Cow::Owned(path.replace('\\', "/"))
45        } else {
46            Cow::Borrowed(path)
47        }
48    }
49
50    /// Ruby's `root?` (`absolute_path?`): a leading slash, or on Windows
51    /// `WindowsRootRx` (`^(?:[a-zA-Z]:)?[\\/]`).
52    fn is_root(self, path: &str) -> bool {
53        path.starts_with('/')
54            || self == Self::Windows && {
55                let bytes = path.as_bytes();
56                let rest = match bytes {
57                    [drive, b':', rest @ ..] if drive.is_ascii_alphabetic() => rest,
58                    _ => bytes,
59                };
60                matches!(rest.first(), Some(b'/' | b'\\'))
61            }
62    }
63}
64
65/// Ruby's `PathResolver#root?` for this host: `/x`, and on Windows also
66/// `C:/x`, `C:\x` and `\x`.
67pub fn is_root_path(path: &str) -> bool {
68    PathStyle::HOST.is_root(path)
69}
70
71/// Ruby's `PathResolver#posixify` for this host: on Windows backslashes
72/// become slashes; elsewhere the path is unchanged.
73pub fn posixify_path(path: &str) -> String {
74    PathStyle::HOST.posixify(path).into_owned()
75}
76
77/// Ruby's `File.dirname` of a posixified absolute or relative path: the
78/// root (`/`, or `C:/` on Windows) stays a root, a bare name gives `.`.
79pub fn parent_system_dir(path: &str) -> String {
80    parent_dir(path, PathStyle::HOST)
81}
82
83fn parent_dir(path: &str, style: PathStyle) -> String {
84    let path = style.posixify(path);
85    let (_, root) = partition(&path, style);
86    let body = path[root.map_or(0, str::len)..].trim_end_matches('/');
87    match (body.rfind('/'), root) {
88        (Some(slash), root) => format!("{}{}", root.unwrap_or(""), &body[..slash]),
89        (None, Some(root)) if root != "./" => root.to_owned(),
90        (None, _) => ".".to_owned(),
91    }
92}
93
94/// Ruby's `PathResolver#system_path(target, start, jail, target_name:,
95/// recover:)` for a host (e.g. a CLI resolving its output paths): `start`
96/// is absolute, `jail` (if any) absolute. With `recover` an escape from the
97/// jail is moved inside it with Ruby's warnings; without it the result is
98/// Ruby's `SecurityError` message, e.g. `to_dir /x is outside of jail: /doc
99/// (disallowed in safe mode)`.
100pub fn resolve_system_path(
101    target: &str,
102    start: &str,
103    jail: Option<&str>,
104    target_name: &str,
105    recover: bool,
106) -> Result<SystemPath, String> {
107    resolve(
108        target,
109        Some(start),
110        jail,
111        target_name,
112        start,
113        recover,
114        PathStyle::HOST,
115    )
116}
117
118/// Ruby's `partition_path` (no web paths) of a posixified path: segments
119/// without `.` entries and the root (`/`, `//`, `./` or on Windows `C:/`),
120/// if any.
121fn partition(path: &str, style: PathStyle) -> (Vec<&str>, Option<&str>) {
122    let root = if path.starts_with("//") {
123        Some("//")
124    } else if path.starts_with('/') {
125        Some("/")
126    } else if style.is_root(path) {
127        // A drive root, through its first slash.
128        path.find('/').map(|slash| &path[..=slash])
129    } else if path.starts_with("./") {
130        Some("./")
131    } else {
132        None
133    };
134    let rest = &path[root.map_or(0, str::len)..];
135    // Ruby's String#split drops trailing empty fields.
136    let mut segments: Vec<&str> = rest.split('/').collect();
137    while segments.last() == Some(&"") {
138        segments.pop();
139    }
140    if rest.is_empty() {
141        segments.clear();
142    }
143    segments.retain(|segment| *segment != ".");
144    (segments, root)
145}
146
147fn join(segments: &[&str], root: Option<&str>) -> String {
148    format!("{}{}", root.unwrap_or(""), segments.join("/"))
149}
150
151/// Ruby's `descends_from?`.
152fn descends_from(path: &str, base: &str) -> bool {
153    base == path
154        || if base == "/" {
155            path.starts_with('/')
156        } else {
157            path.strip_prefix(base)
158                .is_some_and(|rest| rest.starts_with('/'))
159        }
160}
161
162/// Ruby's `expand_path` (lexical) of a posixified path.
163fn expand(path: &str, style: PathStyle) -> String {
164    let (segments, root) = partition(path, style);
165    let mut resolved = Vec::new();
166    for segment in segments {
167        if segment == ".." {
168            resolved.pop();
169        } else {
170            resolved.push(segment);
171        }
172    }
173    join(&resolved, root)
174}
175
176/// Ruby's `system_path(target, start, jail, target_name:)` with recovery.
177/// `working_dir` is Ruby's `@working_dir` (the process directory); the
178/// callers pass a start, so it is only reached for an empty start.
179pub(crate) fn system_path(
180    target: &str,
181    start: Option<&str>,
182    jail: Option<&str>,
183    target_name: &str,
184    working_dir: &str,
185) -> Resolved {
186    resolve(
187        target,
188        start,
189        jail,
190        target_name,
191        working_dir,
192        true,
193        PathStyle::HOST,
194    )
195    .expect("recovering resolution never fails")
196}
197
198fn resolve(
199    target: &str,
200    start: Option<&str>,
201    jail: Option<&str>,
202    target_name: &str,
203    working_dir: &str,
204    recover: bool,
205    style: PathStyle,
206) -> Result<Resolved, String> {
207    let mut warnings = Vec::new();
208    // Messages name the target as given and the posixified jail.
209    let original_target = target;
210    let target = style.posixify(target);
211    let target = target.as_ref();
212    let start = start.map(|start| style.posixify(start));
213    let start = start.as_deref();
214    let jail = jail.map(|jail| style.posixify(jail));
215    let jail = jail.as_deref();
216    let working_dir = style.posixify(working_dir);
217    let working_dir = working_dir.as_ref();
218    let outside = |warnings: &mut Vec<String>, jail: &str| {
219        if !recover {
220            return Err(format!(
221                "{target_name} {original_target} is outside of jail: {jail} (disallowed in safe mode)"
222            ));
223        }
224        warnings.push(format!(
225            "{target_name} is outside of jail; recovering automatically"
226        ));
227        Ok(())
228    };
229    if style.is_root(target) {
230        let target_path = expand(target, style);
231        if let Some(jail) = jail.filter(|jail| !descends_from(&target_path, jail)) {
232            outside(&mut warnings, jail)?;
233            let (target_segments, _) = partition(&target_path, style);
234            let (mut jail_segments, jail_root) = partition(jail, style);
235            jail_segments.extend(target_segments);
236            return Ok(Resolved {
237                path: join(&jail_segments, jail_root),
238                warnings,
239            });
240        }
241        return Ok(Resolved {
242            path: target_path,
243            warnings,
244        });
245    }
246    let fallback = jail.unwrap_or(working_dir);
247    let mut target_segments = partition(target, style).0;
248    let start = match start.filter(|start| !start.is_empty()) {
249        _ if target_segments.is_empty() => match start.filter(|start| !start.is_empty()) {
250            None => {
251                return Ok(Resolved {
252                    path: fallback.to_owned(),
253                    warnings,
254                });
255            }
256            Some(start) if style.is_root(start) => {
257                if jail.is_none() {
258                    return Ok(Resolved {
259                        path: expand(start, style),
260                        warnings,
261                    });
262                }
263                start.to_owned()
264            }
265            Some(start) => {
266                target_segments = partition(start, style).0;
267                fallback.to_owned()
268            }
269        },
270        None => fallback.to_owned(),
271        Some(start) if style.is_root(start) => start.to_owned(),
272        Some(start) => format!("{}/{start}", fallback.trim_end_matches('/')),
273    };
274    let mut recheck = jail.is_some_and(|jail| !descends_from(&start, jail));
275    let (mut start_segments, mut root) = partition(&start, style);
276    // On Windows a start on another drive than the jail cannot be
277    // rechecked: Ruby starts from the jail instead.
278    if let (true, PathStyle::Windows, Some(jail)) = (recheck, style, jail) {
279        let (jail_segments, jail_root) = partition(jail, style);
280        if root != jail_root {
281            if !recover {
282                return Err(format!(
283                    "start path for {target_name} {start} refers to location outside jail root: {jail} (disallowed in safe mode)"
284                ));
285            }
286            warnings.push(format!(
287                "start path for {target_name} is outside of jail root; recovering automatically"
288            ));
289            start_segments = jail_segments;
290            root = jail_root;
291            recheck = false;
292        }
293    }
294    let mut resolved: Vec<&str> = start_segments;
295    resolved.extend(target_segments.iter().copied());
296    if resolved.contains(&"..") {
297        let unresolved = std::mem::take(&mut resolved);
298        if let Some(jail) = jail {
299            let jail_depth = partition(jail, style).0.len();
300            let mut warned = false;
301            for segment in unresolved {
302                if segment != ".." {
303                    resolved.push(segment);
304                } else if resolved.len() > jail_depth {
305                    resolved.pop();
306                } else if !recover {
307                    return Err(format!(
308                        "{target_name} {original_target} refers to location outside jail: {jail} (disallowed in safe mode)"
309                    ));
310                } else if !warned {
311                    warnings.push(format!(
312                        "{target_name} has illegal reference to ancestor of jail; recovering automatically"
313                    ));
314                    warned = true;
315                }
316            }
317        } else {
318            for segment in unresolved {
319                if segment == ".." {
320                    resolved.pop();
321                } else {
322                    resolved.push(segment);
323                }
324            }
325        }
326    }
327    if let (true, Some(jail)) = (recheck, jail) {
328        let target_path = join(&resolved, root);
329        if descends_from(&target_path, jail) {
330            return Ok(Resolved {
331                path: target_path,
332                warnings,
333            });
334        }
335        outside(&mut warnings, jail)?;
336        let (mut jail_segments, jail_root) = partition(jail, style);
337        jail_segments.extend(target_segments.iter().copied());
338        return Ok(Resolved {
339            path: join(&jail_segments, jail_root),
340            warnings,
341        });
342    }
343    Ok(Resolved {
344        path: join(&resolved, root),
345        warnings,
346    })
347}
348
349/// Where a converter reads an asset (docinfo, a stylesheet, an embedded
350/// image): `path` is Ruby's system path (its messages name it), `request` the
351/// same path for the resource provider, relative to the primary document's
352/// directory, and `warnings` Ruby's jail recovery messages.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct AssetPath {
355    pub path: String,
356    pub request: String,
357    pub warnings: Vec<String>,
358}
359
360/// Ruby's `normalize_system_path(target, start)` for an asset a converter
361/// reads: resolved from `base_dir` (the document's, jailed from safe mode
362/// up) when it is known, requested relative to `referrer_dir` (the primary
363/// document's directory, else `base_dir`). Without a base directory,
364/// `target` joined to a relative `start` for both.
365pub fn asset_path(
366    target: &str,
367    start: Option<&str>,
368    base_dir: Option<&str>,
369    referrer_dir: Option<&str>,
370    safe: SafeMode,
371    target_name: &str,
372) -> AssetPath {
373    if let Some(base_dir) = base_dir {
374        let resolved = normalize_system_path(target, start, base_dir, safe, target_name);
375        let from = referrer_dir.unwrap_or(base_dir);
376        let request = crate::xref_target::relative_path(
377            &format!("{}/_", from.trim_end_matches('/')),
378            &resolved.path,
379        );
380        return AssetPath {
381            path: resolved.path,
382            request,
383            warnings: resolved.warnings,
384        };
385    }
386    let path = match start.filter(|dir| !dir.is_empty() && !is_root_path(target)) {
387        Some(dir) => format!("{}/{target}", dir.trim_end_matches('/')),
388        None => target.to_owned(),
389    };
390    AssetPath {
391        request: path.clone(),
392        path,
393        warnings: Vec::new(),
394    }
395}
396
397/// Ruby's `normalize_system_path(target, start, nil, target_name:)` for a
398/// document whose base directory is `base_dir`: below safe mode a relative
399/// start joins the base directory and there is no jail; from safe mode up
400/// the base directory is the default start and the jail.
401pub(crate) fn normalize_system_path(
402    target: &str,
403    start: Option<&str>,
404    base_dir: &str,
405    safe: SafeMode,
406    target_name: &str,
407) -> Resolved {
408    if safe < SafeMode::Safe {
409        let start = match start {
410            Some(start) if is_root_path(start) => start.to_owned(),
411            Some(start) => format!("{base_dir}/{start}"),
412            None => base_dir.to_owned(),
413        };
414        system_path(target, Some(&start), None, target_name, base_dir)
415    } else {
416        system_path(
417            target,
418            Some(start.unwrap_or(base_dir)),
419            Some(base_dir),
420            target_name,
421            base_dir,
422        )
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn resolve(target: &str, start: &str, jail: Option<&str>) -> (String, Vec<String>) {
431        let resolved = system_path(target, Some(start), jail, "path", "/cwd");
432        (resolved.path, resolved.warnings)
433    }
434
435    /// Ruby's `recover: false` (Asciidoctor.convert resolving `:to_dir` and
436    /// `:to_file`): each jail escape is a SecurityError message instead.
437    #[test]
438    fn unrecovered_jail_escapes_are_errors() {
439        let strict = |target: &str, start: &str, jail: Option<&str>| {
440            resolve_system_path(target, start, jail, "to_dir", false)
441        };
442        assert_eq!(
443            strict("/etc", "/doc", Some("/doc")),
444            Err("to_dir /etc is outside of jail: /doc (disallowed in safe mode)".to_owned())
445        );
446        assert_eq!(
447            strict("../a.html", "/doc", Some("/doc")),
448            Err(
449                "to_dir ../a.html refers to location outside jail: /doc (disallowed in safe mode)"
450                    .to_owned()
451            )
452        );
453        assert_eq!(
454            strict("y/b.htm", "/out", Some("/doc")),
455            Err("to_dir y/b.htm is outside of jail: /doc (disallowed in safe mode)".to_owned())
456        );
457        assert_eq!(
458            strict("out/../b.html", "/doc", Some("/doc")).map(|p| p.path),
459            Ok("/doc/b.html".to_owned())
460        );
461        assert_eq!(
462            strict("/etc/x", "/doc", None).map(|p| p.path),
463            Ok("/etc/x".to_owned())
464        );
465        // Recovering keeps the warnings.
466        let recovered = resolve_system_path("/etc", "/doc", Some("/doc"), "path", true).unwrap();
467        assert_eq!(recovered.path, "/doc/etc");
468        assert_eq!(
469            recovered.warnings,
470            ["path is outside of jail; recovering automatically"]
471        );
472    }
473
474    #[test]
475    fn relative_targets_resolve_against_the_start() {
476        // path_resolver_test.rb (paths_test.rb) expectations.
477        assert_eq!(resolve("images/a.png", "/doc", None).0, "/doc/images/a.png");
478        assert_eq!(resolve("../a.png", "/doc/sub", None).0, "/doc/a.png");
479        assert_eq!(resolve("./a/./b", "/doc", None).0, "/doc/a/b");
480        assert_eq!(resolve("a", "/doc", Some("/doc")).0, "/doc/a");
481        assert_eq!(resolve("", "/doc", None).0, "/doc");
482    }
483
484    #[test]
485    fn ancestors_of_the_jail_are_recovered_once_with_a_warning() {
486        let (path, warnings) = resolve("../../../etc/passwd", "/doc/sub", Some("/doc"));
487        assert_eq!(path, "/doc/etc/passwd");
488        assert_eq!(
489            warnings,
490            ["path has illegal reference to ancestor of jail; recovering automatically"]
491        );
492        // Inside the jail `..` is plain resolution.
493        assert_eq!(
494            resolve("../a", "/doc/sub", Some("/doc")),
495            ("/doc/a".to_owned(), vec![])
496        );
497    }
498
499    #[test]
500    fn absolute_targets_outside_the_jail_move_inside_it() {
501        let (path, warnings) = resolve("/tmp/x/a.adoc", "/doc", Some("/doc"));
502        assert_eq!(path, "/doc/tmp/x/a.adoc");
503        assert_eq!(
504            warnings,
505            ["path is outside of jail; recovering automatically"]
506        );
507        assert_eq!(
508            resolve("/doc/a.adoc", "/doc", Some("/doc")).0,
509            "/doc/a.adoc"
510        );
511        assert_eq!(resolve("/x/../y", "/doc", None).0, "/y");
512    }
513
514    #[test]
515    fn a_start_outside_the_jail_is_rechecked() {
516        let (path, warnings) = resolve("a.adoc", "/elsewhere", Some("/doc"));
517        assert_eq!(path, "/doc/a.adoc");
518        assert_eq!(
519            warnings,
520            ["path is outside of jail; recovering automatically"]
521        );
522    }
523
524    fn on_windows(
525        target: &str,
526        start: &str,
527        jail: Option<&str>,
528        recover: bool,
529    ) -> Result<(String, Vec<String>), String> {
530        super::resolve(
531            target,
532            Some(start),
533            jail,
534            "to_dir",
535            start,
536            recover,
537            PathStyle::Windows,
538        )
539        .map(|resolved| (resolved.path, resolved.warnings))
540    }
541
542    /// Ruby's `PathResolver` with the Windows file separator: drive roots
543    /// and backslashes, posixified (regression: `-o c:\tmp\a.docx` was
544    /// joined to the base directory as a relative `c:` segment).
545    #[test]
546    fn windows_drive_roots_are_absolute_and_posixified() {
547        let style = PathStyle::Windows;
548        for root in ["C:/x", "c:\\x", "\\x", "/x", "//server/share"] {
549            assert!(style.is_root(root), "{root}");
550        }
551        for relative in ["C:x", "x", "1:/x", "./x"] {
552            assert!(!style.is_root(relative), "{relative}");
553        }
554        // POSIX hosts keep Ruby's view: `c:/x` is a relative name.
555        assert!(!PathStyle::Posix.is_root("c:/x"));
556        assert!(!PathStyle::Posix.is_root("c:\\x"));
557        assert_eq!(partition("C:/a/./b", style), (vec!["a", "b"], Some("C:/")));
558        assert_eq!(
559            on_windows("c:\\tmp\\sdc.docx", "C:/Work/doc", None, false),
560            Ok(("c:/tmp/sdc.docx".to_owned(), vec![]))
561        );
562        assert_eq!(
563            on_windows("out\\..\\a.docx", "C:/Work/doc", None, false),
564            Ok(("C:/Work/doc/a.docx".to_owned(), vec![]))
565        );
566        assert_eq!(
567            on_windows("a.docx", "C:\\Work\\doc", None, false),
568            Ok(("C:/Work/doc/a.docx".to_owned(), vec![]))
569        );
570        assert_eq!(
571            on_windows("C:/x/../../y", "D:/doc", None, false),
572            Ok(("C:/y".to_owned(), vec![]))
573        );
574        // Posix hosts join the same target as a relative name (Ruby too).
575        assert_eq!(
576            super::resolve(
577                "c:/tmp/a",
578                Some("/doc"),
579                None,
580                "p",
581                "/doc",
582                false,
583                PathStyle::Posix
584            )
585            .map(|resolved| resolved.path),
586            Ok("/doc/c:/tmp/a".to_owned())
587        );
588    }
589
590    #[test]
591    fn windows_jails_compare_posixified_paths_and_drive_roots() {
592        assert_eq!(
593            on_windows("c:\\tmp\\sdc.docx", "C:/Work/doc", Some("C:/Work/doc"), false),
594            Err("to_dir c:\\tmp\\sdc.docx is outside of jail: C:/Work/doc (disallowed in safe mode)".to_owned())
595        );
596        assert_eq!(
597            on_windows(
598                "C:\\Work\\doc\\out\\a.docx",
599                "C:/Work/doc",
600                Some("C:\\Work\\doc"),
601                false
602            ),
603            Ok(("C:/Work/doc/out/a.docx".to_owned(), vec![]))
604        );
605        assert_eq!(
606            on_windows("D:/x/a", "C:/doc", Some("C:/doc"), true),
607            Ok((
608                "C:/doc/x/a".to_owned(),
609                vec!["to_dir is outside of jail; recovering automatically".to_owned()]
610            ))
611        );
612        // A start on another drive than the jail starts from the jail.
613        assert_eq!(
614            on_windows("a", "D:/elsewhere", Some("C:/doc"), true),
615            Ok((
616                "C:/doc/a".to_owned(),
617                vec![
618                    "start path for to_dir is outside of jail root; recovering automatically"
619                        .to_owned()
620                ]
621            ))
622        );
623        assert_eq!(
624            on_windows("a", "D:/elsewhere", Some("C:/doc"), false),
625            Err("start path for to_dir D:/elsewhere refers to location outside jail root: C:/doc (disallowed in safe mode)".to_owned())
626        );
627        // The same drive is rechecked like a POSIX start.
628        assert_eq!(
629            on_windows("a", "C:/elsewhere", Some("C:/doc"), true),
630            Ok((
631                "C:/doc/a".to_owned(),
632                vec!["to_dir is outside of jail; recovering automatically".to_owned()]
633            ))
634        );
635    }
636
637    /// Ruby's `File.dirname`, including drive roots on Windows.
638    #[test]
639    fn parent_directories_keep_their_roots() {
640        let posix = |path| parent_dir(path, PathStyle::Posix);
641        assert_eq!(posix("/doc/a.html"), "/doc");
642        assert_eq!(posix("/a.html"), "/");
643        assert_eq!(posix("/doc/out/"), "/doc");
644        assert_eq!(posix("a.html"), ".");
645        assert_eq!(posix("out/a.html"), "out");
646        assert_eq!(posix("C:/a.html"), "C:");
647        let windows = |path| parent_dir(path, PathStyle::Windows);
648        assert_eq!(windows("C:/tmp/a.docx"), "C:/tmp");
649        assert_eq!(windows("c:\\tmp\\a.docx"), "c:/tmp");
650        assert_eq!(windows("C:/a.docx"), "C:/");
651        assert_eq!(windows("C:/"), "C:/");
652        assert_eq!(windows("//server/share/a"), "//server/share");
653        assert_eq!(parent_system_dir("/doc/a.html"), "/doc");
654    }
655
656    #[test]
657    fn normalize_uses_the_base_directory_as_start_and_jail() {
658        let safe = normalize_system_path("../x", None, "/doc", SafeMode::Safe, "include file");
659        assert_eq!(safe.path, "/doc/x");
660        assert_eq!(safe.warnings.len(), 1);
661        let unsafe_ = normalize_system_path("../x", None, "/doc", SafeMode::Unsafe, "include file");
662        assert_eq!(unsafe_.path, "/x");
663        assert!(unsafe_.warnings.is_empty());
664        let relative = normalize_system_path("a", Some("img"), "/doc", SafeMode::Unsafe, "image");
665        assert_eq!(relative.path, "/doc/img/a");
666    }
667}