Skip to main content

asciidoc_parser/parser/
path_resolver.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4
5/// A `PathResolver` handles all operations for resolving, cleaning, and joining
6/// paths. This struct includes operations for handling both web paths (request
7/// URIs) and system paths.
8///
9/// The main emphasis of the struct is on creating clean and secure paths. Clean
10/// paths are void of duplicate parent and current directory references in the
11/// path name. Secure paths are paths which are restricted from accessing
12/// directories outside of a jail path, if specified.
13///
14/// Since joining two paths can result in an insecure path, this struct also
15/// handles the task of joining a parent (start) and child (target) path.
16///
17/// Like its counterpart in the Ruby Asciidoctor implementation, this struct
18/// makes no use of path utilities from the underlying Rust libraries. Instead,
19/// it handles all aspects of path manipulation. The main benefit of
20/// internalizing these operations is that the struct is able to handle both
21/// Posix and Windows paths independent of the operating system on which it
22/// runs. This makes the class both deterministic and easier to test.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct PathResolver {
25    /// File separator to use for path operations. (Defaults to
26    /// platform-appropriate separator.)
27    pub file_separator: char,
28    // Ruby's `PathResolver` also carries a `working_dir`, but it is read only by
29    // `system_path` — the filesystem-resolution + safe-mode-jail routine — as a
30    // fallback base directory, and Ruby always seeds it from `Dir.pwd`. This
31    // crate performs no filesystem I/O: `include::` and asset resolution are
32    // delegated to the client via `IncludeFileHandler`, so there is no consumer
33    // for `working_dir`, and a `Dir.pwd`-derived base would contradict this
34    // type's deterministic, OS-independent design. Intentionally not ported; if
35    // a built-in filesystem handler ever ports `system_path`, prefer an explicit
36    // caller-supplied base over an implicit working directory.
37}
38
39impl Default for PathResolver {
40    fn default() -> Self {
41        Self {
42            file_separator: std::path::MAIN_SEPARATOR,
43        }
44    }
45}
46
47impl PathResolver {
48    /// Normalize path by converting any backslashes to forward slashes.
49    pub fn posixify(&self, path: &str) -> String {
50        if self.file_separator == '\\' && path.contains('\\') {
51            path.replace('\\', "/")
52        } else {
53            path.to_string()
54        }
55    }
56
57    /// Resolve a web path from the target and start paths.
58    ///
59    /// The main function of this operation is to resolve any parent references
60    /// and remove any self references.
61    ///
62    /// The target is assumed to be a path, not a qualified URI. That check
63    /// should happen before this method is invoked.
64    ///
65    /// Returns a path that joins the target path with the start path with any
66    /// parent references resolved and self references removed.
67    pub fn web_path(&self, target: &str, start: Option<&str>) -> String {
68        let mut target = self.posixify(target);
69        let start = start.map(|start| self.posixify(start));
70
71        let mut uri_prefix: Option<String> = None;
72
73        // Ruby treats a `nil` *or* empty `start` the same (`start.nil_or_empty?`):
74        // in both cases the target is used as-is, with no start path prepended.
75        let start_is_empty = start.as_deref().is_none_or(str::is_empty);
76
77        if !(start_is_empty || self.is_web_root(&target)) {
78            (target, uri_prefix) = extract_uri_prefix(&format!(
79                "{start}{maybe_add_slash}{target}",
80                start = start.as_deref().unwrap_or_default(),
81                maybe_add_slash = start
82                    .as_ref()
83                    .map(|s| if s.ends_with("/") { "" } else { "/" })
84                    .unwrap_or_default()
85            ));
86        }
87
88        let (target_segments, target_root) = self.partition_path(&target, WebPath(true));
89
90        let mut resolved_segments: Vec<String> = vec![];
91
92        for segment in target_segments {
93            if segment == ".." {
94                if resolved_segments.is_empty() {
95                    if let Some(target_root) = target_root.as_ref()
96                        && target_root != "./"
97                    {
98                        // Do nothing.
99                    } else {
100                        resolved_segments.push(segment);
101                    }
102                } else if let Some(last_segment) = resolved_segments.last()
103                    && last_segment == ".."
104                {
105                    resolved_segments.push(segment);
106                } else {
107                    resolved_segments.pop();
108                }
109            } else {
110                resolved_segments.push(segment);
111            }
112        }
113
114        let resolved_path = self
115            .join_path(&resolved_segments, target_root.as_deref())
116            .replace(" ", "%20");
117
118        format!(
119            "{uri_prefix}{resolved_path}",
120            uri_prefix = uri_prefix.unwrap_or_default()
121        )
122    }
123
124    /// Partition the path into path segments and remove self references (`.`)
125    /// and the trailing slash, if present. Prior to being partitioned, the path
126    /// is converted to a Posix path.
127    ///
128    /// Parent references are not resolved by this method since the caller often
129    /// needs to handle this resolution in a certain context (checking for the
130    /// breach of a jail, for instance).
131    ///
132    /// Returns a 2-item tuple containing a `Vec<String>` of path segments and
133    /// an optional path root (e.g., `/`, `./`, `c:/`, or `//`), which is only
134    /// present if the path is absolute.
135    fn partition_path(&self, path: &str, web: WebPath) -> (Vec<String>, Option<String>) {
136        // Ruby memoizes partition results per (path, web) in a hash. That cache
137        // exists to avoid Ruby's comparatively expensive string work; here the
138        // partitioning is cheap and the resolver takes `&self`, so caching would
139        // require interior mutability that the derived `Clone`/`Eq` would then
140        // have to skip. Deliberately not ported.
141        let posix_path = self.posixify(path);
142
143        let root: Option<String> = if web.0 {
144            if self.is_web_root(&posix_path) {
145                Some("/".to_owned())
146            } else if posix_path.starts_with("./") {
147                Some("./".to_owned())
148            } else {
149                None
150            }
151        } else if self.is_root(&posix_path) {
152            if self.is_unc(&posix_path) {
153                // ex. //sample/path
154                Some(DOUBLE_SLASH.to_owned())
155            } else if posix_path.starts_with('/') {
156                // ex. /sample/path
157                Some("/".to_owned())
158            } else if posix_path.starts_with(URI_CLASSLOADER) {
159                // ex. uri:classloader:sample/path (or uri:classloader:/sample/path)
160                Some(URI_CLASSLOADER.to_owned())
161            } else {
162                // ex. C:/sample/path (or file:///sample/path in browser
163                // environment). The root is everything up to and including the
164                // first slash. `is_root` guarantees a slash for the drive-letter
165                // case, so `None` here is unreachable in practice.
166                posix_path
167                    .find('/')
168                    .map(|slash| posix_path[..=slash].to_owned())
169            }
170        } else if posix_path.starts_with("./") {
171            // ex. ./sample/path
172            Some("./".to_owned())
173        } else {
174            // otherwise ex. sample/path
175            None
176        };
177
178        let path_after_root = if let Some(root) = &root {
179            &posix_path[root.len()..]
180        } else {
181            &posix_path
182        };
183
184        let mut path_segments: Vec<String> = path_after_root
185            .split('/')
186            .filter(|s| *s != ".")
187            .map(|s| s.to_owned())
188            .collect();
189
190        // Ruby builds these segments with `posix_path.split SLASH`, and Ruby's
191        // `String#split` drops trailing empty fields (e.g. `'a/b/'.split '/'` is
192        // `['a', 'b']`, and `''.split '/'` is `[]`). Rust's `str::split` keeps
193        // them, so strip trailing empties to match — otherwise a trailing slash
194        // would survive as a spurious empty final segment.
195        while path_segments.last().is_some_and(|s| s.is_empty()) {
196            path_segments.pop();
197        }
198
199        (path_segments, root)
200    }
201
202    /// Join the segments using the Posix file separator (since this crate knows
203    /// how to work with paths specified this way, regardless of OS). Use the
204    /// `root`, if specified, to construct an absolute path. Otherwise join the
205    /// segments as a relative path.
206    fn join_path(&self, segments: &[String], root: Option<&str>) -> String {
207        format!(
208            "{root}{segments}",
209            root = root.unwrap_or_default(),
210            segments = segments.join("/"),
211        )
212    }
213
214    /// Return `true` if the path is an absolute (root) web path (i.e. starts
215    /// with a `'/'`.
216    pub fn is_web_root(&self, path: &str) -> bool {
217        path.starts_with('/')
218    }
219
220    /// Return `true` if the path is an absolute (rooted) system path. A path is
221    /// absolute if it starts with `'/'` or, on a Windows-style resolver, if it
222    /// starts with a drive letter or slash root (e.g. `C:/` or `\`).
223    fn is_absolute_path(&self, path: &str) -> bool {
224        path.starts_with('/') || (self.file_separator == '\\' && WINDOWS_ROOT.is_match(path))
225    }
226
227    /// Return `true` if the path has a root, meaning it is either an absolute
228    /// path or a `uri:classloader:` path.
229    fn is_root(&self, path: &str) -> bool {
230        self.is_absolute_path(path) || path.starts_with(URI_CLASSLOADER)
231    }
232
233    /// Return `true` if the path is a UNC path (i.e. starts with `'//'`).
234    fn is_unc(&self, path: &str) -> bool {
235        path.starts_with(DOUBLE_SLASH)
236    }
237}
238
239/// Efficiently extracts the URI prefix from the specified string if the string
240/// is a URI.
241///
242/// Attempts to match the URI prefix in the specified string (e.g., `http://`). If present, the prefix is removed.
243///
244/// Returns a tuple containing the specified string without the URI prefix, if
245/// present, and the extracted URI prefix if found.
246fn extract_uri_prefix(s: &str) -> (String, Option<String>) {
247    if s.contains(':')
248        && let Some(prefix) = URI_SNIFF.find(s)
249    {
250        (
251            s[prefix.len()..].to_string(),
252            Some(prefix.as_str().to_owned()),
253        )
254    } else {
255        (s.to_string(), None)
256    }
257}
258
259// Also: Place this at module scope:
260static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
261    #[allow(clippy::unwrap_used)]
262    Regex::new(
263        r#"(?x)
264        ^                   # Anchor: start of string
265
266        \p{Alphabetic}      # First character: a Unicode letter
267
268        [\p{Alphabetic}     # Followed by one or more of:
269        \p{Number}         #   - Unicode letters or numbers
270        .                  #   - Period
271        \+                 #   - Plus sign
272        \-                 #   - Hyphen
273        ]+                  # One or more of the above
274
275        :                   # Followed by a literal colon
276
277        /{0,2}              # Followed by zero, one, or two literal slashes
278    "#,
279    )
280    .unwrap()
281});
282
283/// Path root marker for a UNC path (e.g. `//sample/path`).
284const DOUBLE_SLASH: &str = "//";
285
286/// Path root marker for a JRuby classloader URI (e.g.
287/// `uri:classloader:/sample/path`).
288const URI_CLASSLOADER: &str = "uri:classloader:";
289
290/// Matches the root of a Windows-style path: an optional drive letter followed
291/// by a leading slash (either separator). Mirrors Ruby Asciidoctor's
292/// `WindowsRootRx`.
293static WINDOWS_ROOT: LazyLock<Regex> = LazyLock::new(|| {
294    #[allow(clippy::unwrap_used)]
295    Regex::new(r"^(?:[a-zA-Z]:)?[\\/]").unwrap()
296});
297
298#[derive(Clone, Debug, Eq, PartialEq)]
299pub(crate) struct WebPath(pub(crate) bool);
300
301#[cfg(test)]
302mod tests {
303    #![allow(clippy::unwrap_used)]
304
305    use crate::parser::PathResolver;
306
307    mod posixify {
308        use crate::parser::PathResolver;
309
310        #[test]
311        fn replaces_backslashes_if_windowsish() {
312            let pr = PathResolver {
313                file_separator: '\\',
314            };
315
316            assert_eq!(pr.posixify("abc/def\\ghi"), "abc/def/ghi");
317        }
318
319        #[test]
320        fn doesnt_replace_backslashes_if_posixish() {
321            let pr = PathResolver {
322                file_separator: '/',
323            };
324
325            assert_eq!(pr.posixify("abc/def\\ghi"), "abc/def\\ghi");
326        }
327
328        #[test]
329        fn doesnt_replace_backslashes_if_none_exist() {
330            let pr = PathResolver {
331                file_separator: '\\',
332            };
333
334            assert_eq!(pr.posixify("abc/def"), "abc/def");
335        }
336    }
337
338    mod web_path {
339        use crate::parser::PathResolver;
340
341        #[test]
342        fn test_cases_from_asciidoctor_rb() {
343            let pr = PathResolver::default();
344
345            assert_eq!(pr.web_path("images", None), "images");
346            assert_eq!(pr.web_path("./images", None), "./images");
347            assert_eq!(pr.web_path("/images", None), "/images");
348
349            assert_eq!(
350                pr.web_path("./images/../assets/images", None),
351                "./assets/images"
352            );
353
354            assert_eq!(pr.web_path("/../images", None), "/images");
355
356            assert_eq!(pr.web_path("/../images", Some("assets")), "/images");
357            assert_eq!(pr.web_path("../images", Some("./")), "./../images");
358            assert_eq!(pr.web_path("../../images", Some("./")), "./../../images");
359
360            assert_eq!(
361                pr.web_path("tiger.png", Some("../assets/images")),
362                "../assets/images/tiger.png"
363            );
364
365            // Basic relative path resolution.
366            assert_eq!(
367                pr.web_path("images/photo.jpg", Some("docs/guide")),
368                "docs/guide/images/photo.jpg"
369            );
370            assert_eq!(pr.web_path("photo.jpg", Some("images")), "images/photo.jpg");
371            assert_eq!(
372                pr.web_path("../photo.jpg", Some("images/folder")),
373                "images/photo.jpg"
374            );
375            assert_eq!(
376                pr.web_path("../../photo.jpg", Some("docs/images/folder")),
377                "docs/photo.jpg"
378            );
379
380            // URI-based scenarios (triggers `extract_uri_prefix`).
381            assert_eq!(
382                pr.web_path("images/photo.jpg", Some("http://example.com/base")),
383                "http://example.com/base/images/photo.jpg"
384            );
385            assert_eq!(
386                pr.web_path("../images/logo.png", Some("https://cdn.example.com/assets")),
387                "https://cdn.example.com/images/logo.png"
388            );
389            assert_eq!(
390                pr.web_path("docs/guide.pdf", Some("file:///Users/docs")),
391                "file:///Users/docs/docs/guide.pdf"
392            );
393            assert_eq!(
394                pr.web_path("assets/style.css", Some("ftp://files.example.com/web")),
395                "ftp://files.example.com/web/assets/style.css"
396            );
397
398            // Web root scenarios (start parameter ignored).
399            assert_eq!(
400                pr.web_path("/absolute/path.jpg", Some("http://example.com/base")),
401                "/absolute/path.jpg"
402            );
403            assert_eq!(
404                pr.web_path("/images/photo.jpg", Some("docs/guide")),
405                "/images/photo.jpg"
406            );
407            assert_eq!(pr.web_path("/", Some("any/path")), "/");
408
409            // No start path scenarios.
410            assert_eq!(pr.web_path("images/photo.jpg", None), "images/photo.jpg");
411            assert_eq!(pr.web_path("../photo.jpg", None), "../photo.jpg");
412
413            // Path normalization with dots.
414            assert_eq!(
415                pr.web_path("./photo.jpg", Some("images")),
416                "images/photo.jpg"
417            );
418            assert_eq!(
419                pr.web_path("folder/./photo.jpg", Some("images")),
420                "images/folder/photo.jpg"
421            );
422            assert_eq!(
423                pr.web_path("folder/../photo.jpg", Some("images")),
424                "images/photo.jpg"
425            );
426
427            // Complex path resolution.
428            assert_eq!(
429                pr.web_path("../../../photo.jpg", Some("docs/images/folder/sub")),
430                "docs/photo.jpg"
431            );
432            assert_eq!(
433                pr.web_path("folder/../../photo.jpg", Some("docs/images")),
434                "docs/photo.jpg"
435            );
436            assert_eq!(
437                pr.web_path("./folder/../photo.jpg", Some("images")),
438                "images/photo.jpg"
439            );
440
441            // Edge cases with trailing slashes.
442            assert_eq!(
443                pr.web_path("photo.jpg", Some("images/")),
444                "images/photo.jpg"
445            );
446            assert_eq!(pr.web_path("photo.jpg", Some("images")), "images/photo.jpg");
447
448            // URLs with paths and parent references.
449            assert_eq!(
450                pr.web_path("../styles/main.css", Some("https://example.com/assets/css")),
451                "https://example.com/assets/styles/main.css"
452            );
453            assert_eq!(
454                pr.web_path(
455                    "../../images/logo.png",
456                    Some("http://site.com/docs/guide/examples")
457                ),
458                "http://site.com/docs/images/logo.png"
459            );
460
461            // Space handling (gets URL encoded).
462            assert_eq!(
463                pr.web_path("my file.jpg", Some("images")),
464                "images/my%20file.jpg"
465            );
466            assert_eq!(
467                pr.web_path("folder with spaces/file.jpg", Some("docs")),
468                "docs/folder%20with%20spaces/file.jpg"
469            );
470
471            // Protocol-less absolute paths.
472            assert_eq!(
473                pr.web_path(
474                    "//cdn.example.com/assets/image.jpg",
475                    Some("http://example.com")
476                ),
477                "//cdn.example.com/assets/image.jpg"
478            );
479
480            // Mixed scenarios. (An empty target resolves to the start path with
481            // no spurious trailing slash, and an empty `start` is treated like
482            // `None` — matching Ruby's `web_path`; see `paths_test.rb`.)
483            assert_eq!(pr.web_path("", Some("docs/images")), "docs/images");
484            assert_eq!(pr.web_path("", Some("")), "");
485            assert_eq!(pr.web_path("", None), "");
486
487            // Complex URI scenarios.
488            assert_eq!(
489                pr.web_path("api/v1/data", Some("https://api.example.com:8080/base")),
490                "https://api.example.com:8080/base/api/v1/data"
491            );
492            assert_eq!(
493                pr.web_path("../v2/data", Some("https://api.example.com/api/v1")),
494                "https://api.example.com/api/v2/data"
495            );
496
497            // File protocol variations.
498            assert_eq!(
499                pr.web_path("document.pdf", Some("file:///C:/Users/docs")),
500                "file:///C:/Users/docs/document.pdf"
501            );
502            assert_eq!(
503                pr.web_path("../shared/doc.pdf", Some("file:///home/user/documents")),
504                "file:///home/user/shared/doc.pdf"
505            );
506        }
507
508        #[test]
509        fn all_slash_target_collapses_to_root() {
510            // An all-slash web target normalizes to a single `/`. Ruby's
511            // `String#split('/')` drops *all* trailing empty fields, so
512            // `partition_path` yields no segments and the rooted path collapses
513            // to `/`. This guards the trailing-empty-segment stripping in
514            // `partition_path` against regressing to a one-shot pop (which would
515            // leave `//`, `///`, etc.).
516            let pr = PathResolver::default();
517
518            assert_eq!(pr.web_path("/", None), "/");
519            assert_eq!(pr.web_path("//", None), "/");
520            assert_eq!(pr.web_path("///", None), "/");
521            assert_eq!(pr.web_path("////", None), "/");
522            assert_eq!(pr.web_path("///", Some("assets")), "/");
523        }
524    }
525
526    #[test]
527    fn is_web_root() {
528        let pr = PathResolver::default();
529        assert!(pr.is_web_root("/blah"));
530        assert!(!pr.is_web_root(""));
531        assert!(!pr.is_web_root("./blah"));
532    }
533
534    mod partition_path {
535        use super::super::WebPath;
536        use crate::parser::PathResolver;
537
538        fn seg(items: &[&str]) -> Vec<String> {
539            items.iter().map(|s| (*s).to_owned()).collect()
540        }
541
542        #[test]
543        fn relative_system_path() {
544            let pr = PathResolver::default();
545            assert_eq!(
546                pr.partition_path("sample/path", WebPath(false)),
547                (seg(&["sample", "path"]), None)
548            );
549        }
550
551        #[test]
552        fn dot_slash_system_path() {
553            let pr = PathResolver::default();
554            assert_eq!(
555                pr.partition_path("./sample/path", WebPath(false)),
556                (seg(&["sample", "path"]), Some("./".to_owned()))
557            );
558        }
559
560        #[test]
561        fn self_references_are_removed() {
562            let pr = PathResolver::default();
563            assert_eq!(
564                pr.partition_path("sample/./path", WebPath(false)),
565                (seg(&["sample", "path"]), None)
566            );
567        }
568
569        #[test]
570        fn absolute_system_path() {
571            let pr = PathResolver::default();
572            assert_eq!(
573                pr.partition_path("/sample/path", WebPath(false)),
574                (seg(&["sample", "path"]), Some("/".to_owned()))
575            );
576        }
577
578        #[test]
579        fn unc_path() {
580            let pr = PathResolver::default();
581            assert_eq!(
582                pr.partition_path("//server/share/path", WebPath(false)),
583                (seg(&["server", "share", "path"]), Some("//".to_owned()))
584            );
585        }
586
587        #[test]
588        fn uri_classloader_rooted() {
589            let pr = PathResolver::default();
590            assert_eq!(
591                pr.partition_path("uri:classloader:/sample/path", WebPath(false)),
592                (
593                    seg(&["", "sample", "path"]),
594                    Some("uri:classloader:".to_owned())
595                )
596            );
597        }
598
599        #[test]
600        fn uri_classloader_relative() {
601            let pr = PathResolver::default();
602            assert_eq!(
603                pr.partition_path("uri:classloader:sample/path", WebPath(false)),
604                (
605                    seg(&["sample", "path"]),
606                    Some("uri:classloader:".to_owned())
607                )
608            );
609        }
610
611        #[test]
612        fn windows_drive_letter() {
613            let pr = PathResolver {
614                file_separator: '\\',
615            };
616
617            assert_eq!(
618                pr.partition_path("C:\\sample\\path", WebPath(false)),
619                (seg(&["sample", "path"]), Some("C:/".to_owned()))
620            );
621        }
622
623        #[test]
624        fn windows_drive_letter_forward_slashes() {
625            let pr = PathResolver {
626                file_separator: '\\',
627            };
628
629            assert_eq!(
630                pr.partition_path("C:/sample/path", WebPath(false)),
631                (seg(&["sample", "path"]), Some("C:/".to_owned()))
632            );
633        }
634
635        #[test]
636        fn drive_letter_is_relative_on_posix_resolver() {
637            // Without a Windows-style separator, a drive-letter prefix is not
638            // treated as a root. (Constructed explicitly rather than via
639            // `default()`, whose separator is platform-dependent.)
640            let pr = PathResolver {
641                file_separator: '/',
642            };
643
644            assert_eq!(
645                pr.partition_path("C:/sample/path", WebPath(false)),
646                (seg(&["C:", "sample", "path"]), None)
647            );
648        }
649    }
650}