1use std::sync::LazyLock;
2
3use regex::Regex;
4
5#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct PathResolver {
25 pub file_separator: char,
28 }
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 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 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 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 } 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 fn partition_path(&self, path: &str, web: WebPath) -> (Vec<String>, Option<String>) {
136 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 Some(DOUBLE_SLASH.to_owned())
155 } else if posix_path.starts_with('/') {
156 Some("/".to_owned())
158 } else if posix_path.starts_with(URI_CLASSLOADER) {
159 Some(URI_CLASSLOADER.to_owned())
161 } else {
162 posix_path
167 .find('/')
168 .map(|slash| posix_path[..=slash].to_owned())
169 }
170 } else if posix_path.starts_with("./") {
171 Some("./".to_owned())
173 } else {
174 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 while path_segments.last().is_some_and(|s| s.is_empty()) {
196 path_segments.pop();
197 }
198
199 (path_segments, root)
200 }
201
202 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 pub fn is_web_root(&self, path: &str) -> bool {
217 path.starts_with('/')
218 }
219
220 fn is_absolute_path(&self, path: &str) -> bool {
224 path.starts_with('/') || (self.file_separator == '\\' && WINDOWS_ROOT.is_match(path))
225 }
226
227 fn is_root(&self, path: &str) -> bool {
230 self.is_absolute_path(path) || path.starts_with(URI_CLASSLOADER)
231 }
232
233 fn is_unc(&self, path: &str) -> bool {
235 path.starts_with(DOUBLE_SLASH)
236 }
237}
238
239fn 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
259static 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
283const DOUBLE_SLASH: &str = "//";
285
286const URI_CLASSLOADER: &str = "uri:classloader:";
289
290static 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}