Skip to main content

asciidoc_parser/parser/
path_resolver.rs

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