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        if !(start.is_none() || self.is_web_root(&target)) {
74            (target, uri_prefix) = extract_uri_prefix(&format!(
75                "{start}{maybe_add_slash}{target}",
76                start = start.as_deref().unwrap_or_default(),
77                maybe_add_slash = start
78                    .as_ref()
79                    .map(|s| if s.ends_with("/") { "" } else { "/" })
80                    .unwrap_or_default()
81            ));
82        }
83
84        let (target_segments, target_root) = self.partition_path(&target, WebPath(true));
85
86        let mut resolved_segments: Vec<String> = vec![];
87
88        for segment in target_segments {
89            if segment == ".." {
90                if resolved_segments.is_empty() {
91                    if let Some(target_root) = target_root.as_ref()
92                        && target_root != "./"
93                    {
94                        // Do nothing.
95                    } else {
96                        resolved_segments.push(segment);
97                    }
98                } else if let Some(last_segment) = resolved_segments.last()
99                    && last_segment == ".."
100                {
101                    resolved_segments.push(segment);
102                } else {
103                    resolved_segments.pop();
104                }
105            } else {
106                resolved_segments.push(segment);
107            }
108        }
109
110        let resolved_path = self
111            .join_path(&resolved_segments, target_root.as_deref())
112            .replace(" ", "%20");
113
114        format!(
115            "{uri_prefix}{resolved_path}",
116            uri_prefix = uri_prefix.unwrap_or_default()
117        )
118    }
119
120    /// Partition the path into path segments and remove self references (`.`)
121    /// and the trailing slash, if present. Prior to being partitioned, the path
122    /// is converted to a Posix path.
123    ///
124    /// Parent references are not resolved by this method since the caller often
125    /// needs to handle this resolution in a certain context (checking for the
126    /// breach of a jail, for instance).
127    ///
128    /// Returns a 2-item tuple containing a `Vec<String>` of path segments and
129    /// an optional path root (e.g., `/`, `./`, `c:/`, or `//`), which is only
130    /// present if the path is absolute.
131    fn partition_path(&self, path: &str, web: WebPath) -> (Vec<String>, Option<String>) {
132        // Ruby memoizes partition results per (path, web) in a hash. That cache
133        // exists to avoid Ruby's comparatively expensive string work; here the
134        // partitioning is cheap and the resolver takes `&self`, so caching would
135        // require interior mutability that the derived `Clone`/`Eq` would then
136        // have to skip. Deliberately not ported.
137        let posix_path = self.posixify(path);
138
139        let root: Option<String> = if web.0 {
140            if self.is_web_root(&posix_path) {
141                Some("/".to_owned())
142            } else if posix_path.starts_with("./") {
143                Some("./".to_owned())
144            } else {
145                None
146            }
147        } else if self.is_root(&posix_path) {
148            if self.is_unc(&posix_path) {
149                // ex. //sample/path
150                Some(DOUBLE_SLASH.to_owned())
151            } else if posix_path.starts_with('/') {
152                // ex. /sample/path
153                Some("/".to_owned())
154            } else if posix_path.starts_with(URI_CLASSLOADER) {
155                // ex. uri:classloader:sample/path (or uri:classloader:/sample/path)
156                Some(URI_CLASSLOADER.to_owned())
157            } else {
158                // ex. C:/sample/path (or file:///sample/path in browser
159                // environment). The root is everything up to and including the
160                // first slash. `is_root` guarantees a slash for the drive-letter
161                // case, so `None` here is unreachable in practice.
162                posix_path
163                    .find('/')
164                    .map(|slash| posix_path[..=slash].to_owned())
165            }
166        } else if posix_path.starts_with("./") {
167            // ex. ./sample/path
168            Some("./".to_owned())
169        } else {
170            // otherwise ex. sample/path
171            None
172        };
173
174        let path_after_root = if let Some(root) = &root {
175            &posix_path[root.len()..]
176        } else {
177            &posix_path
178        };
179
180        let path_segments: Vec<String> = path_after_root
181            .split('/')
182            .filter(|s| *s != ".")
183            .map(|s| s.to_owned())
184            .collect();
185
186        (path_segments, root)
187    }
188
189    /// Join the segments using the Posix file separator (since this crate knows
190    /// how to work with paths specified this way, regardless of OS). Use the
191    /// `root`, if specified, to construct an absolute path. Otherwise join the
192    /// segments as a relative path.
193    fn join_path(&self, segments: &[String], root: Option<&str>) -> String {
194        format!(
195            "{root}{segments}",
196            root = root.unwrap_or_default(),
197            segments = segments.join("/"),
198        )
199    }
200
201    /// Return `true` if the path is an absolute (root) web path (i.e. starts
202    /// with a `'/'`.
203    pub fn is_web_root(&self, path: &str) -> bool {
204        path.starts_with('/')
205    }
206
207    /// Return `true` if the path is an absolute (rooted) system path. A path is
208    /// absolute if it starts with `'/'` or, on a Windows-style resolver, if it
209    /// starts with a drive letter or slash root (e.g. `C:/` or `\`).
210    fn is_absolute_path(&self, path: &str) -> bool {
211        path.starts_with('/') || (self.file_separator == '\\' && WINDOWS_ROOT.is_match(path))
212    }
213
214    /// Return `true` if the path has a root, meaning it is either an absolute
215    /// path or a `uri:classloader:` path.
216    fn is_root(&self, path: &str) -> bool {
217        self.is_absolute_path(path) || path.starts_with(URI_CLASSLOADER)
218    }
219
220    /// Return `true` if the path is a UNC path (i.e. starts with `'//'`).
221    fn is_unc(&self, path: &str) -> bool {
222        path.starts_with(DOUBLE_SLASH)
223    }
224}
225
226/// Efficiently extracts the URI prefix from the specified string if the string
227/// is a URI.
228///
229/// Attempts to match the URI prefix in the specified string (e.g., `http://`). If present, the prefix is removed.
230///
231/// Returns a tuple containing the specified string without the URI prefix, if
232/// present, and the extracted URI prefix if found.
233fn extract_uri_prefix(s: &str) -> (String, Option<String>) {
234    if s.contains(':')
235        && let Some(prefix) = URI_SNIFF.find(s)
236    {
237        (
238            s[prefix.len()..].to_string(),
239            Some(prefix.as_str().to_owned()),
240        )
241    } else {
242        (s.to_string(), None)
243    }
244}
245
246// Also: Place this at module scope:
247static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
248    #[allow(clippy::unwrap_used)]
249    Regex::new(
250        r#"(?x)
251        ^                   # Anchor: start of string
252
253        \p{Alphabetic}      # First character: a Unicode letter
254
255        [\p{Alphabetic}     # Followed by one or more of:
256        \p{Number}         #   - Unicode letters or numbers
257        .                  #   - Period
258        \+                 #   - Plus sign
259        \-                 #   - Hyphen
260        ]+                  # One or more of the above
261
262        :                   # Followed by a literal colon
263
264        /{0,2}              # Followed by zero, one, or two literal slashes
265    "#,
266    )
267    .unwrap()
268});
269
270/// Path root marker for a UNC path (e.g. `//sample/path`).
271const DOUBLE_SLASH: &str = "//";
272
273/// Path root marker for a JRuby classloader URI (e.g.
274/// `uri:classloader:/sample/path`).
275const URI_CLASSLOADER: &str = "uri:classloader:";
276
277/// Matches the root of a Windows-style path: an optional drive letter followed
278/// by a leading slash (either separator). Mirrors Ruby Asciidoctor's
279/// `WindowsRootRx`.
280static WINDOWS_ROOT: LazyLock<Regex> = LazyLock::new(|| {
281    #[allow(clippy::unwrap_used)]
282    Regex::new(r"^(?:[a-zA-Z]:)?[\\/]").unwrap()
283});
284
285#[derive(Clone, Debug, Eq, PartialEq)]
286pub(crate) struct WebPath(pub(crate) bool);
287
288#[cfg(test)]
289mod tests {
290    #![allow(clippy::unwrap_used)]
291
292    use crate::parser::PathResolver;
293
294    mod posixify {
295        use crate::parser::PathResolver;
296
297        #[test]
298        fn replaces_backslashes_if_windowsish() {
299            let pr = PathResolver {
300                file_separator: '\\',
301            };
302
303            assert_eq!(pr.posixify("abc/def\\ghi"), "abc/def/ghi");
304        }
305
306        #[test]
307        fn doesnt_replace_backslashes_if_posixish() {
308            let pr = PathResolver {
309                file_separator: '/',
310            };
311
312            assert_eq!(pr.posixify("abc/def\\ghi"), "abc/def\\ghi");
313        }
314
315        #[test]
316        fn doesnt_replace_backslashes_if_none_exist() {
317            let pr = PathResolver {
318                file_separator: '\\',
319            };
320
321            assert_eq!(pr.posixify("abc/def"), "abc/def");
322        }
323    }
324
325    mod web_path {
326        use crate::parser::PathResolver;
327
328        #[test]
329        fn test_cases_from_asciidoctor_rb() {
330            let pr = PathResolver::default();
331
332            assert_eq!(pr.web_path("images", None), "images");
333            assert_eq!(pr.web_path("./images", None), "./images");
334            assert_eq!(pr.web_path("/images", None), "/images");
335
336            assert_eq!(
337                pr.web_path("./images/../assets/images", None),
338                "./assets/images"
339            );
340
341            assert_eq!(pr.web_path("/../images", None), "/images");
342
343            assert_eq!(pr.web_path("/../images", Some("assets")), "/images");
344            assert_eq!(pr.web_path("../images", Some("./")), "./../images");
345            assert_eq!(pr.web_path("../../images", Some("./")), "./../../images");
346
347            assert_eq!(
348                pr.web_path("tiger.png", Some("../assets/images")),
349                "../assets/images/tiger.png"
350            );
351
352            // Basic relative path resolution.
353            assert_eq!(
354                pr.web_path("images/photo.jpg", Some("docs/guide")),
355                "docs/guide/images/photo.jpg"
356            );
357            assert_eq!(pr.web_path("photo.jpg", Some("images")), "images/photo.jpg");
358            assert_eq!(
359                pr.web_path("../photo.jpg", Some("images/folder")),
360                "images/photo.jpg"
361            );
362            assert_eq!(
363                pr.web_path("../../photo.jpg", Some("docs/images/folder")),
364                "docs/photo.jpg"
365            );
366
367            // URI-based scenarios (triggers `extract_uri_prefix`).
368            assert_eq!(
369                pr.web_path("images/photo.jpg", Some("http://example.com/base")),
370                "http://example.com/base/images/photo.jpg"
371            );
372            assert_eq!(
373                pr.web_path("../images/logo.png", Some("https://cdn.example.com/assets")),
374                "https://cdn.example.com/images/logo.png"
375            );
376            assert_eq!(
377                pr.web_path("docs/guide.pdf", Some("file:///Users/docs")),
378                "file:///Users/docs/docs/guide.pdf"
379            );
380            assert_eq!(
381                pr.web_path("assets/style.css", Some("ftp://files.example.com/web")),
382                "ftp://files.example.com/web/assets/style.css"
383            );
384
385            // Web root scenarios (start parameter ignored).
386            assert_eq!(
387                pr.web_path("/absolute/path.jpg", Some("http://example.com/base")),
388                "/absolute/path.jpg"
389            );
390            assert_eq!(
391                pr.web_path("/images/photo.jpg", Some("docs/guide")),
392                "/images/photo.jpg"
393            );
394            assert_eq!(pr.web_path("/", Some("any/path")), "/");
395
396            // No start path scenarios.
397            assert_eq!(pr.web_path("images/photo.jpg", None), "images/photo.jpg");
398            assert_eq!(pr.web_path("../photo.jpg", None), "../photo.jpg");
399
400            // Path normalization with dots.
401            assert_eq!(
402                pr.web_path("./photo.jpg", Some("images")),
403                "images/photo.jpg"
404            );
405            assert_eq!(
406                pr.web_path("folder/./photo.jpg", Some("images")),
407                "images/folder/photo.jpg"
408            );
409            assert_eq!(
410                pr.web_path("folder/../photo.jpg", Some("images")),
411                "images/photo.jpg"
412            );
413
414            // Complex path resolution.
415            assert_eq!(
416                pr.web_path("../../../photo.jpg", Some("docs/images/folder/sub")),
417                "docs/photo.jpg"
418            );
419            assert_eq!(
420                pr.web_path("folder/../../photo.jpg", Some("docs/images")),
421                "docs/photo.jpg"
422            );
423            assert_eq!(
424                pr.web_path("./folder/../photo.jpg", Some("images")),
425                "images/photo.jpg"
426            );
427
428            // Edge cases with trailing slashes.
429            assert_eq!(
430                pr.web_path("photo.jpg", Some("images/")),
431                "images/photo.jpg"
432            );
433            assert_eq!(pr.web_path("photo.jpg", Some("images")), "images/photo.jpg");
434
435            // URLs with paths and parent references.
436            assert_eq!(
437                pr.web_path("../styles/main.css", Some("https://example.com/assets/css")),
438                "https://example.com/assets/styles/main.css"
439            );
440            assert_eq!(
441                pr.web_path(
442                    "../../images/logo.png",
443                    Some("http://site.com/docs/guide/examples")
444                ),
445                "http://site.com/docs/images/logo.png"
446            );
447
448            // Space handling (gets URL encoded).
449            assert_eq!(
450                pr.web_path("my file.jpg", Some("images")),
451                "images/my%20file.jpg"
452            );
453            assert_eq!(
454                pr.web_path("folder with spaces/file.jpg", Some("docs")),
455                "docs/folder%20with%20spaces/file.jpg"
456            );
457
458            // Protocol-less absolute paths.
459            assert_eq!(
460                pr.web_path(
461                    "//cdn.example.com/assets/image.jpg",
462                    Some("http://example.com")
463                ),
464                "//cdn.example.com/assets/image.jpg"
465            );
466
467            // Mixed scenarios.
468            assert_eq!(pr.web_path("", Some("docs/images")), "docs/images/");
469            assert_eq!(pr.web_path("", Some("")), "/");
470            assert_eq!(pr.web_path("", None), "");
471
472            // Complex URI scenarios.
473            assert_eq!(
474                pr.web_path("api/v1/data", Some("https://api.example.com:8080/base")),
475                "https://api.example.com:8080/base/api/v1/data"
476            );
477            assert_eq!(
478                pr.web_path("../v2/data", Some("https://api.example.com/api/v1")),
479                "https://api.example.com/api/v2/data"
480            );
481
482            // File protocol variations.
483            assert_eq!(
484                pr.web_path("document.pdf", Some("file:///C:/Users/docs")),
485                "file:///C:/Users/docs/document.pdf"
486            );
487            assert_eq!(
488                pr.web_path("../shared/doc.pdf", Some("file:///home/user/documents")),
489                "file:///home/user/shared/doc.pdf"
490            );
491        }
492    }
493
494    #[test]
495    fn is_web_root() {
496        let pr = PathResolver::default();
497        assert!(pr.is_web_root("/blah"));
498        assert!(!pr.is_web_root(""));
499        assert!(!pr.is_web_root("./blah"));
500    }
501
502    mod partition_path {
503        use super::super::WebPath;
504        use crate::parser::PathResolver;
505
506        fn seg(items: &[&str]) -> Vec<String> {
507            items.iter().map(|s| (*s).to_owned()).collect()
508        }
509
510        #[test]
511        fn relative_system_path() {
512            let pr = PathResolver::default();
513            assert_eq!(
514                pr.partition_path("sample/path", WebPath(false)),
515                (seg(&["sample", "path"]), None)
516            );
517        }
518
519        #[test]
520        fn dot_slash_system_path() {
521            let pr = PathResolver::default();
522            assert_eq!(
523                pr.partition_path("./sample/path", WebPath(false)),
524                (seg(&["sample", "path"]), Some("./".to_owned()))
525            );
526        }
527
528        #[test]
529        fn self_references_are_removed() {
530            let pr = PathResolver::default();
531            assert_eq!(
532                pr.partition_path("sample/./path", WebPath(false)),
533                (seg(&["sample", "path"]), None)
534            );
535        }
536
537        #[test]
538        fn absolute_system_path() {
539            let pr = PathResolver::default();
540            assert_eq!(
541                pr.partition_path("/sample/path", WebPath(false)),
542                (seg(&["sample", "path"]), Some("/".to_owned()))
543            );
544        }
545
546        #[test]
547        fn unc_path() {
548            let pr = PathResolver::default();
549            assert_eq!(
550                pr.partition_path("//server/share/path", WebPath(false)),
551                (seg(&["server", "share", "path"]), Some("//".to_owned()))
552            );
553        }
554
555        #[test]
556        fn uri_classloader_rooted() {
557            let pr = PathResolver::default();
558            assert_eq!(
559                pr.partition_path("uri:classloader:/sample/path", WebPath(false)),
560                (
561                    seg(&["", "sample", "path"]),
562                    Some("uri:classloader:".to_owned())
563                )
564            );
565        }
566
567        #[test]
568        fn uri_classloader_relative() {
569            let pr = PathResolver::default();
570            assert_eq!(
571                pr.partition_path("uri:classloader:sample/path", WebPath(false)),
572                (
573                    seg(&["sample", "path"]),
574                    Some("uri:classloader:".to_owned())
575                )
576            );
577        }
578
579        #[test]
580        fn windows_drive_letter() {
581            let pr = PathResolver {
582                file_separator: '\\',
583            };
584
585            assert_eq!(
586                pr.partition_path("C:\\sample\\path", WebPath(false)),
587                (seg(&["sample", "path"]), Some("C:/".to_owned()))
588            );
589        }
590
591        #[test]
592        fn windows_drive_letter_forward_slashes() {
593            let pr = PathResolver {
594                file_separator: '\\',
595            };
596
597            assert_eq!(
598                pr.partition_path("C:/sample/path", WebPath(false)),
599                (seg(&["sample", "path"]), Some("C:/".to_owned()))
600            );
601        }
602
603        #[test]
604        fn drive_letter_is_relative_on_posix_resolver() {
605            // Without a Windows-style separator, a drive-letter prefix is not
606            // treated as a root. (Constructed explicitly rather than via
607            // `default()`, whose separator is platform-dependent.)
608            let pr = PathResolver {
609                file_separator: '/',
610            };
611
612            assert_eq!(
613                pr.partition_path("C:/sample/path", WebPath(false)),
614                (seg(&["C:", "sample", "path"]), None)
615            );
616        }
617    }
618}