1use std::borrow::Cow;
13
14use crate::SafeMode;
15
16#[derive(Debug, PartialEq, Eq)]
18pub struct SystemPath {
19 pub path: String,
20 pub warnings: Vec<String>,
21}
22
23pub(crate) type Resolved = SystemPath;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub(crate) enum PathStyle {
29 Posix,
30 Windows,
31}
32
33impl PathStyle {
34 pub(crate) const HOST: Self = if cfg!(windows) {
36 Self::Windows
37 } else {
38 Self::Posix
39 };
40
41 fn posixify(self, path: &str) -> Cow<'_, str> {
43 if self == Self::Windows && path.contains('\\') {
44 Cow::Owned(path.replace('\\', "/"))
45 } else {
46 Cow::Borrowed(path)
47 }
48 }
49
50 fn is_root(self, path: &str) -> bool {
53 path.starts_with('/')
54 || self == Self::Windows && {
55 let bytes = path.as_bytes();
56 let rest = match bytes {
57 [drive, b':', rest @ ..] if drive.is_ascii_alphabetic() => rest,
58 _ => bytes,
59 };
60 matches!(rest.first(), Some(b'/' | b'\\'))
61 }
62 }
63}
64
65pub fn is_root_path(path: &str) -> bool {
68 PathStyle::HOST.is_root(path)
69}
70
71pub fn posixify_path(path: &str) -> String {
74 PathStyle::HOST.posixify(path).into_owned()
75}
76
77pub fn parent_system_dir(path: &str) -> String {
80 parent_dir(path, PathStyle::HOST)
81}
82
83fn parent_dir(path: &str, style: PathStyle) -> String {
84 let path = style.posixify(path);
85 let (_, root) = partition(&path, style);
86 let body = path[root.map_or(0, str::len)..].trim_end_matches('/');
87 match (body.rfind('/'), root) {
88 (Some(slash), root) => format!("{}{}", root.unwrap_or(""), &body[..slash]),
89 (None, Some(root)) if root != "./" => root.to_owned(),
90 (None, _) => ".".to_owned(),
91 }
92}
93
94pub fn resolve_system_path(
101 target: &str,
102 start: &str,
103 jail: Option<&str>,
104 target_name: &str,
105 recover: bool,
106) -> Result<SystemPath, String> {
107 resolve(
108 target,
109 Some(start),
110 jail,
111 target_name,
112 start,
113 recover,
114 PathStyle::HOST,
115 )
116}
117
118fn partition(path: &str, style: PathStyle) -> (Vec<&str>, Option<&str>) {
122 let root = if path.starts_with("//") {
123 Some("//")
124 } else if path.starts_with('/') {
125 Some("/")
126 } else if style.is_root(path) {
127 path.find('/').map(|slash| &path[..=slash])
129 } else if path.starts_with("./") {
130 Some("./")
131 } else {
132 None
133 };
134 let rest = &path[root.map_or(0, str::len)..];
135 let mut segments: Vec<&str> = rest.split('/').collect();
137 while segments.last() == Some(&"") {
138 segments.pop();
139 }
140 if rest.is_empty() {
141 segments.clear();
142 }
143 segments.retain(|segment| *segment != ".");
144 (segments, root)
145}
146
147fn join(segments: &[&str], root: Option<&str>) -> String {
148 format!("{}{}", root.unwrap_or(""), segments.join("/"))
149}
150
151fn descends_from(path: &str, base: &str) -> bool {
153 base == path
154 || if base == "/" {
155 path.starts_with('/')
156 } else {
157 path.strip_prefix(base)
158 .is_some_and(|rest| rest.starts_with('/'))
159 }
160}
161
162fn expand(path: &str, style: PathStyle) -> String {
164 let (segments, root) = partition(path, style);
165 let mut resolved = Vec::new();
166 for segment in segments {
167 if segment == ".." {
168 resolved.pop();
169 } else {
170 resolved.push(segment);
171 }
172 }
173 join(&resolved, root)
174}
175
176pub(crate) fn system_path(
180 target: &str,
181 start: Option<&str>,
182 jail: Option<&str>,
183 target_name: &str,
184 working_dir: &str,
185) -> Resolved {
186 resolve(
187 target,
188 start,
189 jail,
190 target_name,
191 working_dir,
192 true,
193 PathStyle::HOST,
194 )
195 .expect("recovering resolution never fails")
196}
197
198fn resolve(
199 target: &str,
200 start: Option<&str>,
201 jail: Option<&str>,
202 target_name: &str,
203 working_dir: &str,
204 recover: bool,
205 style: PathStyle,
206) -> Result<Resolved, String> {
207 let mut warnings = Vec::new();
208 let original_target = target;
210 let target = style.posixify(target);
211 let target = target.as_ref();
212 let start = start.map(|start| style.posixify(start));
213 let start = start.as_deref();
214 let jail = jail.map(|jail| style.posixify(jail));
215 let jail = jail.as_deref();
216 let working_dir = style.posixify(working_dir);
217 let working_dir = working_dir.as_ref();
218 let outside = |warnings: &mut Vec<String>, jail: &str| {
219 if !recover {
220 return Err(format!(
221 "{target_name} {original_target} is outside of jail: {jail} (disallowed in safe mode)"
222 ));
223 }
224 warnings.push(format!(
225 "{target_name} is outside of jail; recovering automatically"
226 ));
227 Ok(())
228 };
229 if style.is_root(target) {
230 let target_path = expand(target, style);
231 if let Some(jail) = jail.filter(|jail| !descends_from(&target_path, jail)) {
232 outside(&mut warnings, jail)?;
233 let (target_segments, _) = partition(&target_path, style);
234 let (mut jail_segments, jail_root) = partition(jail, style);
235 jail_segments.extend(target_segments);
236 return Ok(Resolved {
237 path: join(&jail_segments, jail_root),
238 warnings,
239 });
240 }
241 return Ok(Resolved {
242 path: target_path,
243 warnings,
244 });
245 }
246 let fallback = jail.unwrap_or(working_dir);
247 let mut target_segments = partition(target, style).0;
248 let start = match start.filter(|start| !start.is_empty()) {
249 _ if target_segments.is_empty() => match start.filter(|start| !start.is_empty()) {
250 None => {
251 return Ok(Resolved {
252 path: fallback.to_owned(),
253 warnings,
254 });
255 }
256 Some(start) if style.is_root(start) => {
257 if jail.is_none() {
258 return Ok(Resolved {
259 path: expand(start, style),
260 warnings,
261 });
262 }
263 start.to_owned()
264 }
265 Some(start) => {
266 target_segments = partition(start, style).0;
267 fallback.to_owned()
268 }
269 },
270 None => fallback.to_owned(),
271 Some(start) if style.is_root(start) => start.to_owned(),
272 Some(start) => format!("{}/{start}", fallback.trim_end_matches('/')),
273 };
274 let mut recheck = jail.is_some_and(|jail| !descends_from(&start, jail));
275 let (mut start_segments, mut root) = partition(&start, style);
276 if let (true, PathStyle::Windows, Some(jail)) = (recheck, style, jail) {
279 let (jail_segments, jail_root) = partition(jail, style);
280 if root != jail_root {
281 if !recover {
282 return Err(format!(
283 "start path for {target_name} {start} refers to location outside jail root: {jail} (disallowed in safe mode)"
284 ));
285 }
286 warnings.push(format!(
287 "start path for {target_name} is outside of jail root; recovering automatically"
288 ));
289 start_segments = jail_segments;
290 root = jail_root;
291 recheck = false;
292 }
293 }
294 let mut resolved: Vec<&str> = start_segments;
295 resolved.extend(target_segments.iter().copied());
296 if resolved.contains(&"..") {
297 let unresolved = std::mem::take(&mut resolved);
298 if let Some(jail) = jail {
299 let jail_depth = partition(jail, style).0.len();
300 let mut warned = false;
301 for segment in unresolved {
302 if segment != ".." {
303 resolved.push(segment);
304 } else if resolved.len() > jail_depth {
305 resolved.pop();
306 } else if !recover {
307 return Err(format!(
308 "{target_name} {original_target} refers to location outside jail: {jail} (disallowed in safe mode)"
309 ));
310 } else if !warned {
311 warnings.push(format!(
312 "{target_name} has illegal reference to ancestor of jail; recovering automatically"
313 ));
314 warned = true;
315 }
316 }
317 } else {
318 for segment in unresolved {
319 if segment == ".." {
320 resolved.pop();
321 } else {
322 resolved.push(segment);
323 }
324 }
325 }
326 }
327 if let (true, Some(jail)) = (recheck, jail) {
328 let target_path = join(&resolved, root);
329 if descends_from(&target_path, jail) {
330 return Ok(Resolved {
331 path: target_path,
332 warnings,
333 });
334 }
335 outside(&mut warnings, jail)?;
336 let (mut jail_segments, jail_root) = partition(jail, style);
337 jail_segments.extend(target_segments.iter().copied());
338 return Ok(Resolved {
339 path: join(&jail_segments, jail_root),
340 warnings,
341 });
342 }
343 Ok(Resolved {
344 path: join(&resolved, root),
345 warnings,
346 })
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct AssetPath {
355 pub path: String,
356 pub request: String,
357 pub warnings: Vec<String>,
358}
359
360pub fn asset_path(
366 target: &str,
367 start: Option<&str>,
368 base_dir: Option<&str>,
369 referrer_dir: Option<&str>,
370 safe: SafeMode,
371 target_name: &str,
372) -> AssetPath {
373 if let Some(base_dir) = base_dir {
374 let resolved = normalize_system_path(target, start, base_dir, safe, target_name);
375 let from = referrer_dir.unwrap_or(base_dir);
376 let request = crate::xref_target::relative_path(
377 &format!("{}/_", from.trim_end_matches('/')),
378 &resolved.path,
379 );
380 return AssetPath {
381 path: resolved.path,
382 request,
383 warnings: resolved.warnings,
384 };
385 }
386 let path = match start.filter(|dir| !dir.is_empty() && !is_root_path(target)) {
387 Some(dir) => format!("{}/{target}", dir.trim_end_matches('/')),
388 None => target.to_owned(),
389 };
390 AssetPath {
391 request: path.clone(),
392 path,
393 warnings: Vec::new(),
394 }
395}
396
397pub(crate) fn normalize_system_path(
402 target: &str,
403 start: Option<&str>,
404 base_dir: &str,
405 safe: SafeMode,
406 target_name: &str,
407) -> Resolved {
408 if safe < SafeMode::Safe {
409 let start = match start {
410 Some(start) if is_root_path(start) => start.to_owned(),
411 Some(start) => format!("{base_dir}/{start}"),
412 None => base_dir.to_owned(),
413 };
414 system_path(target, Some(&start), None, target_name, base_dir)
415 } else {
416 system_path(
417 target,
418 Some(start.unwrap_or(base_dir)),
419 Some(base_dir),
420 target_name,
421 base_dir,
422 )
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 fn resolve(target: &str, start: &str, jail: Option<&str>) -> (String, Vec<String>) {
431 let resolved = system_path(target, Some(start), jail, "path", "/cwd");
432 (resolved.path, resolved.warnings)
433 }
434
435 #[test]
438 fn unrecovered_jail_escapes_are_errors() {
439 let strict = |target: &str, start: &str, jail: Option<&str>| {
440 resolve_system_path(target, start, jail, "to_dir", false)
441 };
442 assert_eq!(
443 strict("/etc", "/doc", Some("/doc")),
444 Err("to_dir /etc is outside of jail: /doc (disallowed in safe mode)".to_owned())
445 );
446 assert_eq!(
447 strict("../a.html", "/doc", Some("/doc")),
448 Err(
449 "to_dir ../a.html refers to location outside jail: /doc (disallowed in safe mode)"
450 .to_owned()
451 )
452 );
453 assert_eq!(
454 strict("y/b.htm", "/out", Some("/doc")),
455 Err("to_dir y/b.htm is outside of jail: /doc (disallowed in safe mode)".to_owned())
456 );
457 assert_eq!(
458 strict("out/../b.html", "/doc", Some("/doc")).map(|p| p.path),
459 Ok("/doc/b.html".to_owned())
460 );
461 assert_eq!(
462 strict("/etc/x", "/doc", None).map(|p| p.path),
463 Ok("/etc/x".to_owned())
464 );
465 let recovered = resolve_system_path("/etc", "/doc", Some("/doc"), "path", true).unwrap();
467 assert_eq!(recovered.path, "/doc/etc");
468 assert_eq!(
469 recovered.warnings,
470 ["path is outside of jail; recovering automatically"]
471 );
472 }
473
474 #[test]
475 fn relative_targets_resolve_against_the_start() {
476 assert_eq!(resolve("images/a.png", "/doc", None).0, "/doc/images/a.png");
478 assert_eq!(resolve("../a.png", "/doc/sub", None).0, "/doc/a.png");
479 assert_eq!(resolve("./a/./b", "/doc", None).0, "/doc/a/b");
480 assert_eq!(resolve("a", "/doc", Some("/doc")).0, "/doc/a");
481 assert_eq!(resolve("", "/doc", None).0, "/doc");
482 }
483
484 #[test]
485 fn ancestors_of_the_jail_are_recovered_once_with_a_warning() {
486 let (path, warnings) = resolve("../../../etc/passwd", "/doc/sub", Some("/doc"));
487 assert_eq!(path, "/doc/etc/passwd");
488 assert_eq!(
489 warnings,
490 ["path has illegal reference to ancestor of jail; recovering automatically"]
491 );
492 assert_eq!(
494 resolve("../a", "/doc/sub", Some("/doc")),
495 ("/doc/a".to_owned(), vec![])
496 );
497 }
498
499 #[test]
500 fn absolute_targets_outside_the_jail_move_inside_it() {
501 let (path, warnings) = resolve("/tmp/x/a.adoc", "/doc", Some("/doc"));
502 assert_eq!(path, "/doc/tmp/x/a.adoc");
503 assert_eq!(
504 warnings,
505 ["path is outside of jail; recovering automatically"]
506 );
507 assert_eq!(
508 resolve("/doc/a.adoc", "/doc", Some("/doc")).0,
509 "/doc/a.adoc"
510 );
511 assert_eq!(resolve("/x/../y", "/doc", None).0, "/y");
512 }
513
514 #[test]
515 fn a_start_outside_the_jail_is_rechecked() {
516 let (path, warnings) = resolve("a.adoc", "/elsewhere", Some("/doc"));
517 assert_eq!(path, "/doc/a.adoc");
518 assert_eq!(
519 warnings,
520 ["path is outside of jail; recovering automatically"]
521 );
522 }
523
524 fn on_windows(
525 target: &str,
526 start: &str,
527 jail: Option<&str>,
528 recover: bool,
529 ) -> Result<(String, Vec<String>), String> {
530 super::resolve(
531 target,
532 Some(start),
533 jail,
534 "to_dir",
535 start,
536 recover,
537 PathStyle::Windows,
538 )
539 .map(|resolved| (resolved.path, resolved.warnings))
540 }
541
542 #[test]
546 fn windows_drive_roots_are_absolute_and_posixified() {
547 let style = PathStyle::Windows;
548 for root in ["C:/x", "c:\\x", "\\x", "/x", "//server/share"] {
549 assert!(style.is_root(root), "{root}");
550 }
551 for relative in ["C:x", "x", "1:/x", "./x"] {
552 assert!(!style.is_root(relative), "{relative}");
553 }
554 assert!(!PathStyle::Posix.is_root("c:/x"));
556 assert!(!PathStyle::Posix.is_root("c:\\x"));
557 assert_eq!(partition("C:/a/./b", style), (vec!["a", "b"], Some("C:/")));
558 assert_eq!(
559 on_windows("c:\\tmp\\sdc.docx", "C:/Work/doc", None, false),
560 Ok(("c:/tmp/sdc.docx".to_owned(), vec![]))
561 );
562 assert_eq!(
563 on_windows("out\\..\\a.docx", "C:/Work/doc", None, false),
564 Ok(("C:/Work/doc/a.docx".to_owned(), vec![]))
565 );
566 assert_eq!(
567 on_windows("a.docx", "C:\\Work\\doc", None, false),
568 Ok(("C:/Work/doc/a.docx".to_owned(), vec![]))
569 );
570 assert_eq!(
571 on_windows("C:/x/../../y", "D:/doc", None, false),
572 Ok(("C:/y".to_owned(), vec![]))
573 );
574 assert_eq!(
576 super::resolve(
577 "c:/tmp/a",
578 Some("/doc"),
579 None,
580 "p",
581 "/doc",
582 false,
583 PathStyle::Posix
584 )
585 .map(|resolved| resolved.path),
586 Ok("/doc/c:/tmp/a".to_owned())
587 );
588 }
589
590 #[test]
591 fn windows_jails_compare_posixified_paths_and_drive_roots() {
592 assert_eq!(
593 on_windows("c:\\tmp\\sdc.docx", "C:/Work/doc", Some("C:/Work/doc"), false),
594 Err("to_dir c:\\tmp\\sdc.docx is outside of jail: C:/Work/doc (disallowed in safe mode)".to_owned())
595 );
596 assert_eq!(
597 on_windows(
598 "C:\\Work\\doc\\out\\a.docx",
599 "C:/Work/doc",
600 Some("C:\\Work\\doc"),
601 false
602 ),
603 Ok(("C:/Work/doc/out/a.docx".to_owned(), vec![]))
604 );
605 assert_eq!(
606 on_windows("D:/x/a", "C:/doc", Some("C:/doc"), true),
607 Ok((
608 "C:/doc/x/a".to_owned(),
609 vec!["to_dir is outside of jail; recovering automatically".to_owned()]
610 ))
611 );
612 assert_eq!(
614 on_windows("a", "D:/elsewhere", Some("C:/doc"), true),
615 Ok((
616 "C:/doc/a".to_owned(),
617 vec![
618 "start path for to_dir is outside of jail root; recovering automatically"
619 .to_owned()
620 ]
621 ))
622 );
623 assert_eq!(
624 on_windows("a", "D:/elsewhere", Some("C:/doc"), false),
625 Err("start path for to_dir D:/elsewhere refers to location outside jail root: C:/doc (disallowed in safe mode)".to_owned())
626 );
627 assert_eq!(
629 on_windows("a", "C:/elsewhere", Some("C:/doc"), true),
630 Ok((
631 "C:/doc/a".to_owned(),
632 vec!["to_dir is outside of jail; recovering automatically".to_owned()]
633 ))
634 );
635 }
636
637 #[test]
639 fn parent_directories_keep_their_roots() {
640 let posix = |path| parent_dir(path, PathStyle::Posix);
641 assert_eq!(posix("/doc/a.html"), "/doc");
642 assert_eq!(posix("/a.html"), "/");
643 assert_eq!(posix("/doc/out/"), "/doc");
644 assert_eq!(posix("a.html"), ".");
645 assert_eq!(posix("out/a.html"), "out");
646 assert_eq!(posix("C:/a.html"), "C:");
647 let windows = |path| parent_dir(path, PathStyle::Windows);
648 assert_eq!(windows("C:/tmp/a.docx"), "C:/tmp");
649 assert_eq!(windows("c:\\tmp\\a.docx"), "c:/tmp");
650 assert_eq!(windows("C:/a.docx"), "C:/");
651 assert_eq!(windows("C:/"), "C:/");
652 assert_eq!(windows("//server/share/a"), "//server/share");
653 assert_eq!(parent_system_dir("/doc/a.html"), "/doc");
654 }
655
656 #[test]
657 fn normalize_uses_the_base_directory_as_start_and_jail() {
658 let safe = normalize_system_path("../x", None, "/doc", SafeMode::Safe, "include file");
659 assert_eq!(safe.path, "/doc/x");
660 assert_eq!(safe.warnings.len(), 1);
661 let unsafe_ = normalize_system_path("../x", None, "/doc", SafeMode::Unsafe, "include file");
662 assert_eq!(unsafe_.path, "/x");
663 assert!(unsafe_.warnings.is_empty());
664 let relative = normalize_system_path("a", Some("img"), "/doc", SafeMode::Unsafe, "image");
665 assert_eq!(relative.path, "/doc/img/a");
666 }
667}