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 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 } 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 fn partition_path(&self, path: &str, web: WebPath) -> (Vec<String>, Option<String>) {
132 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 Some(DOUBLE_SLASH.to_owned())
151 } else if posix_path.starts_with('/') {
152 Some("/".to_owned())
154 } else if posix_path.starts_with(URI_CLASSLOADER) {
155 Some(URI_CLASSLOADER.to_owned())
157 } else {
158 posix_path
163 .find('/')
164 .map(|slash| posix_path[..=slash].to_owned())
165 }
166 } else if posix_path.starts_with("./") {
167 Some("./".to_owned())
169 } else {
170 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 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 pub fn is_web_root(&self, path: &str) -> bool {
204 path.starts_with('/')
205 }
206
207 fn is_absolute_path(&self, path: &str) -> bool {
211 path.starts_with('/') || (self.file_separator == '\\' && WINDOWS_ROOT.is_match(path))
212 }
213
214 fn is_root(&self, path: &str) -> bool {
217 self.is_absolute_path(path) || path.starts_with(URI_CLASSLOADER)
218 }
219
220 fn is_unc(&self, path: &str) -> bool {
222 path.starts_with(DOUBLE_SLASH)
223 }
224}
225
226fn 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
246static 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
270const DOUBLE_SLASH: &str = "//";
272
273const URI_CLASSLOADER: &str = "uri:classloader:";
276
277static 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}