1use crate::plain::{canonicalize_plain, plain_path};
51use std::ffi::{OsStr, OsString};
52use std::fmt;
53use std::io;
54use std::path::{Component, Path, PathBuf};
55
56#[derive(Debug)]
58#[non_exhaustive]
59pub enum ContainError {
60 InvalidRelative,
64 NotAbsolute,
66 Escapes,
69 Symlink,
71 NotAFile,
73 HardLinked,
76 Io(io::Error),
79}
80
81impl fmt::Display for ContainError {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 Self::InvalidRelative => f.write_str("the path is not a plain relative path"),
85 Self::NotAbsolute => f.write_str("the path is not absolute"),
86 Self::Escapes => f.write_str("the path resolves outside the root"),
87 Self::Symlink => f.write_str("the path passes through a symbolic link"),
88 Self::NotAFile => f.write_str("the path is not a regular file"),
89 Self::HardLinked => f.write_str("the file has more than one hard link"),
90 Self::Io(e) => write!(f, "the path cannot be resolved: {e}"),
91 }
92 }
93}
94
95impl std::error::Error for ContainError {
96 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
97 match self {
98 Self::Io(e) => Some(e),
99 _ => None,
100 }
101 }
102}
103
104impl From<io::Error> for ContainError {
105 fn from(e: io::Error) -> Self {
106 Self::Io(e)
107 }
108}
109
110impl From<ContainError> for io::Error {
113 fn from(e: ContainError) -> Self {
114 if let ContainError::Io(inner) = e {
115 return inner;
116 }
117 let kind = match &e {
118 ContainError::InvalidRelative
119 | ContainError::NotAbsolute
120 | ContainError::Symlink
121 | ContainError::NotAFile => io::ErrorKind::InvalidInput,
122 ContainError::Escapes | ContainError::HardLinked => io::ErrorKind::PermissionDenied,
123 ContainError::Io(_) => io::ErrorKind::Other,
124 };
125 io::Error::new(kind, e)
126 }
127}
128
129fn unusable_new_name(name: &OsStr) -> bool {
137 cfg!(windows)
138 && name
139 .to_str()
140 .is_none_or(crate::names::is_unsafe_windows_component)
141}
142
143fn refuse_dot_dot_in_missing_part(path: &Path) -> Result<(), ContainError> {
147 let mut cur = PathBuf::new();
148 let mut exists = true;
149 for component in path.components() {
150 match component {
151 Component::ParentDir if !exists => return Err(ContainError::InvalidRelative),
152 Component::ParentDir => cur.push(".."),
153 other => {
154 cur.push(other.as_os_str());
155 if exists && matches!(other, Component::Normal(_)) {
156 exists = std::fs::symlink_metadata(&cur).is_ok();
157 }
158 }
159 }
160 }
161 Ok(())
162}
163
164fn resolve_lenient(path: &Path) -> Result<PathBuf, ContainError> {
170 refuse_dot_dot_in_missing_part(path)?;
171 let mut tail: Vec<OsString> = Vec::new();
172 let mut head = path.to_path_buf();
173 loop {
174 let e = match canonicalize_plain(&head) {
175 Ok(mut real) => {
176 if !tail.is_empty() && !real.is_dir() {
180 return Err(ContainError::Io(io::Error::new(
181 io::ErrorKind::InvalidInput,
182 "a path component is not a directory",
183 )));
184 }
185 real.extend(tail.iter().rev());
186 return Ok(plain_path(real));
187 }
188 Err(e) => e,
189 };
190 match std::fs::symlink_metadata(&head) {
193 Ok(_) => return Err(ContainError::Io(e)),
194 Err(m) if m.kind() == io::ErrorKind::NotFound => {}
195 Err(m) => return Err(ContainError::Io(m)),
196 }
197 match head.file_name() {
199 Some(name) if unusable_new_name(name) => return Err(ContainError::InvalidRelative),
200 Some(name) => tail.push(name.to_os_string()),
201 None if head.components().next_back() == Some(Component::ParentDir) => {
202 return Err(ContainError::InvalidRelative)
203 }
204 None => return Err(ContainError::Io(e)),
205 }
206 if !head.pop() {
207 return Err(ContainError::Io(e));
208 }
209 }
210}
211
212#[cfg(feature = "fs")]
216pub(crate) fn resolved_within(root: &Path, path: &Path) -> Result<bool, ContainError> {
217 let root = resolve_lenient(root)?;
218 let path = resolve_lenient(path)?;
219 Ok(is_below(&root, &path))
220}
221
222fn fold_unc(text: &str) -> String {
224 match text.strip_prefix(r"\\?\UNC\") {
225 Some(rest) => format!(r"\\{rest}"),
226 None => text.to_string(),
227 }
228}
229
230#[cfg(any(windows, target_vendor = "apple"))]
231const CASE_INSENSITIVE: bool = true;
232#[cfg(not(any(windows, target_vendor = "apple")))]
233const CASE_INSENSITIVE: bool = false;
234
235fn comparable(path: &Path, fold_case: bool) -> Vec<OsString> {
239 let folded: PathBuf = match path.to_str() {
240 Some(text) => PathBuf::from(fold_unc(text)),
241 None => path.to_path_buf(),
242 };
243 folded
244 .components()
245 .map(|c| {
246 let name = c.as_os_str();
247 match name.to_str() {
248 Some(text) if fold_case => OsString::from(text.to_lowercase()),
249 _ => name.to_os_string(),
250 }
251 })
252 .collect()
253}
254
255fn is_below_with(root: &Path, path: &Path, fold_case: bool) -> bool {
257 let (root, path) = (comparable(root, fold_case), comparable(path, fold_case));
258 path.len() >= root.len() && path[..root.len()] == root[..]
259}
260
261#[cfg(feature = "fs")]
262fn normalized(path: &Path) -> PathBuf {
264 match path.to_str() {
265 Some(text) => PathBuf::from(fold_unc(text)),
266 None => path.to_path_buf(),
267 }
268}
269
270fn is_below(root: &Path, path: &Path) -> bool {
279 if is_below_with(root, path, false) {
280 return true;
281 }
282 if !CASE_INSENSITIVE || !is_below_with(root, path, true) {
283 return false;
284 }
285 same_directory_prefix(root, path)
286}
287
288#[cfg(feature = "fs")]
289fn same_directory_prefix(root: &Path, path: &Path) -> bool {
290 let count = normalized(root).components().count();
291 let prefix: PathBuf = normalized(path).components().take(count).collect();
292 match (
293 crate::file_key::FileKey::of_path(root),
294 crate::file_key::FileKey::of_path(&prefix),
295 ) {
296 (Ok(a), Ok(b)) => a == b,
297 _ => false,
298 }
299}
300
301#[cfg(not(feature = "fs"))]
302fn same_directory_prefix(_root: &Path, _path: &Path) -> bool {
303 false
304}
305
306fn inside(root: &Path, resolved: PathBuf) -> Result<PathBuf, ContainError> {
307 if is_below(root, &resolved) {
308 Ok(resolved)
309 } else {
310 Err(ContainError::Escapes)
311 }
312}
313
314fn plain_components(rel: &Path) -> Result<Vec<&OsStr>, ContainError> {
316 if rel.as_os_str().as_encoded_bytes().contains(&0) {
317 return Err(ContainError::InvalidRelative);
318 }
319 let mut names = Vec::new();
320 for component in rel.components() {
321 match component {
322 Component::Normal(name) => {
323 if unusable_new_name(name) {
324 return Err(ContainError::InvalidRelative);
325 }
326 names.push(name);
327 }
328 Component::CurDir => {}
329 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
330 return Err(ContainError::InvalidRelative)
331 }
332 }
333 }
334 if names.is_empty() {
335 return Err(ContainError::InvalidRelative);
336 }
337 Ok(names)
338}
339
340pub fn is_within(root: &Path, path: &Path) -> bool {
358 if !root.is_absolute() || !path.is_absolute() {
359 return false;
360 }
361 match (resolve_lenient(root), resolve_lenient(path)) {
362 (Ok(root), Ok(path)) => is_below(&root, &path),
363 _ => false,
364 }
365}
366
367pub fn contain(root: &Path, rel: impl AsRef<Path>) -> Result<PathBuf, ContainError> {
372 let names = plain_components(rel.as_ref())?;
373 let root = canonicalize_plain(root).map_err(ContainError::Io)?;
374 let mut joined = root.clone();
375 joined.extend(names);
376 let full = canonicalize_plain(&joined).map_err(ContainError::Io)?;
377 inside(&root, full)
378}
379
380pub fn contain_lenient(root: &Path, rel: impl AsRef<Path>) -> Result<PathBuf, ContainError> {
385 let names = plain_components(rel.as_ref())?;
386 let root = canonicalize_plain(root).map_err(ContainError::Io)?;
387 let mut head = root.clone();
388 head.extend(names);
389 inside(&root, resolve_lenient(&head)?)
390}
391
392#[derive(Debug, Clone, Default)]
403pub struct Roots {
404 roots: Vec<PathBuf>,
405 #[cfg(feature = "fs")]
407 keys: Vec<Option<crate::file_key::FileKey>>,
408}
409
410impl Roots {
411 pub fn new() -> Self {
413 Self::default()
414 }
415
416 pub fn with_roots<I, P>(roots: I) -> Result<Self, ContainError>
418 where
419 I: IntoIterator<Item = P>,
420 P: AsRef<Path>,
421 {
422 let mut set = Self::new();
423 for root in roots {
424 set.add(root)?;
425 }
426 Ok(set)
427 }
428
429 pub fn add(&mut self, root: impl AsRef<Path>) -> Result<(), ContainError> {
431 let root = root.as_ref();
432 if !root.is_absolute() {
433 return Err(ContainError::NotAbsolute);
434 }
435 let real = canonicalize_plain(root)?;
436 if !std::fs::metadata(&real)?.is_dir() {
437 return Err(ContainError::Io(io::Error::new(
438 io::ErrorKind::InvalidInput,
439 "an approved root must be a directory",
440 )));
441 }
442 if !self.roots.contains(&real) {
443 #[cfg(feature = "fs")]
444 self.keys
445 .push(crate::file_key::FileKey::of_path(&real).ok());
446 self.roots.push(real);
447 }
448 Ok(())
449 }
450
451 pub fn roots(&self) -> &[PathBuf] {
453 &self.roots
454 }
455
456 pub fn is_empty(&self) -> bool {
458 self.roots.is_empty()
459 }
460
461 pub fn contains(&self, path: &Path) -> bool {
463 path.is_absolute() && self.resolve_confined_lenient(path).is_ok()
464 }
465
466 pub fn resolve_confined(&self, path: &Path) -> Result<PathBuf, ContainError> {
470 if !path.is_absolute() {
471 return Err(ContainError::NotAbsolute);
472 }
473 self.within_any(canonicalize_plain(path)?)
474 }
475
476 pub fn resolve_confined_lenient(&self, path: &Path) -> Result<PathBuf, ContainError> {
479 if !path.is_absolute() {
480 return Err(ContainError::NotAbsolute);
481 }
482 self.within_any(resolve_lenient(path)?)
483 }
484
485 fn within_any(&self, resolved: PathBuf) -> Result<PathBuf, ContainError> {
486 match self.roots.iter().find(|root| is_below(root, &resolved)) {
487 Some(_) => Ok(resolved),
488 None => Err(ContainError::Escapes),
489 }
490 }
491
492 #[cfg(feature = "fs")]
496 fn locate(&self, path: &Path) -> Result<(usize, Vec<OsString>), ContainError> {
497 if !path.is_absolute() {
498 return Err(ContainError::NotAbsolute);
499 }
500 if path.as_os_str().as_encoded_bytes().contains(&0) {
501 return Err(ContainError::InvalidRelative);
502 }
503 let name = match path.components().next_back() {
504 Some(Component::Normal(name)) => name.to_os_string(),
505 _ => return Err(ContainError::InvalidRelative),
506 };
507 if unusable_new_name(&name) {
508 return Err(ContainError::InvalidRelative);
509 }
510 let parent = path.parent().ok_or(ContainError::InvalidRelative)?;
511 let parent = resolve_lenient(parent)?;
512 let index = self
513 .roots
514 .iter()
515 .position(|root| is_below(root, &parent))
516 .ok_or(ContainError::Escapes)?;
517 let skip = self.roots[index].components().count();
518 let mut names: Vec<OsString> = parent
519 .components()
520 .skip(skip)
521 .map(|c| c.as_os_str().to_os_string())
522 .collect();
523 names.push(name);
524 Ok((index, names))
525 }
526
527 #[cfg(feature = "fs")]
534 pub fn open_confined(
535 &self,
536 path: &Path,
537 options: &ConfinedOptions,
538 ) -> Result<std::fs::File, ContainError> {
539 let (index, names) = self.locate(path)?;
540 open_strict(&self.roots[index], &names, options, self.keys[index])
541 }
542}
543
544#[cfg(feature = "fs")]
552#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
553pub struct ConfinedOptions {
554 read: bool,
555 write: bool,
556 append: bool,
557 create: bool,
558 create_new: bool,
559 truncate: bool,
560 require_single_link: bool,
561 share_read_only: bool,
562}
563
564#[cfg(feature = "fs")]
565impl ConfinedOptions {
566 pub fn read_only() -> Self {
568 Self::default().read(true)
569 }
570 pub fn read(mut self, yes: bool) -> Self {
572 self.read = yes;
573 self
574 }
575 pub fn write(mut self, yes: bool) -> Self {
577 self.write = yes;
578 self
579 }
580 pub fn append(mut self, yes: bool) -> Self {
582 self.append = yes;
583 self
584 }
585 pub fn create(mut self, yes: bool) -> Self {
587 self.create = yes;
588 self
589 }
590 pub fn create_new(mut self, yes: bool) -> Self {
592 self.create_new = yes;
593 self
594 }
595 pub fn truncate(mut self, yes: bool) -> Self {
597 self.truncate = yes;
598 self
599 }
600 pub fn require_single_link(mut self, yes: bool) -> Self {
604 self.require_single_link = yes;
605 self
606 }
607 pub fn share_read_only(mut self, yes: bool) -> Self {
610 self.share_read_only = yes;
611 self
612 }
613
614 fn check(&self) -> Result<(), ContainError> {
615 let bad = |why: &'static str| {
616 Err(ContainError::Io(io::Error::new(
617 io::ErrorKind::InvalidInput,
618 why,
619 )))
620 };
621 if !(self.read || self.write || self.append) {
622 return bad("no access mode set");
623 }
624 if (self.create || self.create_new) && !(self.write || self.append) {
625 return bad("create needs write or append");
626 }
627 if self.truncate && !self.write {
628 return bad("truncate needs write");
629 }
630 if self.share_read_only && (self.write || self.append) {
631 return bad("share_read_only is for a read-only open");
632 }
633 Ok(())
634 }
635}
636
637#[cfg(feature = "fs")]
647pub fn open_confined(
648 root: &Path,
649 rel: impl AsRef<Path>,
650 options: &ConfinedOptions,
651) -> Result<std::fs::File, ContainError> {
652 let names: Vec<OsString> = plain_components(rel.as_ref())?
653 .into_iter()
654 .map(OsStr::to_os_string)
655 .collect();
656 let root = canonicalize_plain(root)?;
657 open_strict(&root, &names, options, None)
660}
661
662#[cfg(all(feature = "fs", unix))]
663fn open_strict(
664 root: &Path,
665 names: &[OsString],
666 options: &ConfinedOptions,
667 expected_root: Option<crate::file_key::FileKey>,
668) -> Result<std::fs::File, ContainError> {
669 use std::ffi::CString;
670 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
671 use std::os::unix::ffi::OsStrExt;
672 use std::os::unix::fs::MetadataExt;
673
674 options.check()?;
675 let cstr = |bytes: &[u8]| {
676 CString::new(bytes).map_err(|_| {
677 ContainError::Io(io::Error::new(
678 io::ErrorKind::InvalidInput,
679 "path contains a NUL byte",
680 ))
681 })
682 };
683 let (last, parents) = names.split_last().ok_or(ContainError::InvalidRelative)?;
684
685 let root_c = cstr(root.as_os_str().as_bytes())?;
686 let fd = unsafe {
689 libc::open(
690 root_c.as_ptr(),
691 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
692 )
693 };
694 if fd < 0 {
695 let e = io::Error::last_os_error();
696 return Err(if e.raw_os_error() == Some(libc::ELOOP) {
697 ContainError::Symlink
698 } else {
699 ContainError::Io(e)
700 });
701 }
702 let mut dir = unsafe { OwnedFd::from_raw_fd(fd) };
704 if let Some(expected) = expected_root {
705 let mut st: libc::stat = unsafe { std::mem::zeroed() };
707 if unsafe { libc::fstat(dir.as_raw_fd(), &mut st) } != 0 {
708 return Err(ContainError::Io(io::Error::last_os_error()));
709 }
710 if st.st_dev as u64 != expected.volume || st.st_ino as u64 != expected.index {
711 return Err(ContainError::Escapes);
712 }
713 }
714
715 let refuse = |dir: &OwnedFd, name: &std::ffi::CStr, err: io::Error| -> ContainError {
717 let mut st: libc::stat = unsafe { std::mem::zeroed() };
719 let rc = unsafe {
720 libc::fstatat(
721 dir.as_raw_fd(),
722 name.as_ptr(),
723 &mut st,
724 libc::AT_SYMLINK_NOFOLLOW,
725 )
726 };
727 if rc == 0 && (st.st_mode & libc::S_IFMT) == libc::S_IFLNK {
728 ContainError::Symlink
729 } else {
730 ContainError::Io(err)
731 }
732 };
733
734 for name in parents {
735 let c = cstr(name.as_bytes())?;
736 let fd = unsafe {
738 libc::openat(
739 dir.as_raw_fd(),
740 c.as_ptr(),
741 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
742 )
743 };
744 if fd < 0 {
745 return Err(refuse(&dir, c.as_c_str(), io::Error::last_os_error()));
746 }
747 dir = unsafe { OwnedFd::from_raw_fd(fd) };
749 }
750
751 let c = cstr(last.as_bytes())?;
752 let mut flags = match (options.read, options.write || options.append) {
753 (true, false) => libc::O_RDONLY,
754 (false, true) => libc::O_WRONLY,
755 _ => libc::O_RDWR,
756 };
757 flags |= libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK;
760 if options.append {
761 flags |= libc::O_APPEND;
762 }
763 if options.create_new {
764 flags |= libc::O_CREAT | libc::O_EXCL;
765 } else if options.create {
766 flags |= libc::O_CREAT;
767 }
768 let fd = unsafe { libc::openat(dir.as_raw_fd(), c.as_ptr(), flags, 0o600 as libc::c_uint) };
772 if fd < 0 {
773 return Err(refuse(&dir, c.as_c_str(), io::Error::last_os_error()));
774 }
775 let file = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) });
777 let meta = file.metadata()?;
778 if !meta.is_file() {
779 return Err(ContainError::NotAFile);
780 }
781 crate::handle::clear_nonblock(&file)?;
782 if options.require_single_link && meta.nlink() != 1 {
783 return Err(ContainError::HardLinked);
784 }
785 if options.truncate {
786 file.set_len(0)?;
787 }
788 Ok(file)
789}
790
791#[cfg(all(feature = "fs", windows))]
792fn open_strict(
793 root: &Path,
794 names: &[OsString],
795 options: &ConfinedOptions,
796 _expected_root: Option<crate::file_key::FileKey>,
797) -> Result<std::fs::File, ContainError> {
798 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
799
800 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
802 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
804 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
805 const FILE_SHARE_READ: u32 = 0x1;
806 const FILE_SHARE_PIN: u32 = 0x3;
809 const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x7;
810
811 options.check()?;
812 let (last, parents) = names.split_last().ok_or(ContainError::InvalidRelative)?;
813
814 let pin = |path: &Path| -> Result<std::fs::File, ContainError> {
819 let dir = std::fs::OpenOptions::new()
820 .read(true)
821 .share_mode(FILE_SHARE_PIN)
822 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
823 .open(path)?;
824 let meta = dir.metadata()?;
825 if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
826 return Err(ContainError::Symlink);
827 }
828 if !meta.is_dir() {
829 return Err(ContainError::Io(io::Error::new(
830 io::ErrorKind::NotADirectory,
831 "a path component is not a directory",
832 )));
833 }
834 if !is_below(root, &crate::handle::final_path(&dir)?) {
835 return Err(ContainError::Escapes);
836 }
837 Ok(dir)
838 };
839 let mut pins = vec![pin(root)?];
840 let mut cur = root.to_path_buf();
841 for name in parents {
842 cur.push(name);
843 pins.push(pin(&cur)?);
844 }
845 cur.push(last);
846 let file = std::fs::OpenOptions::new()
848 .read(options.read)
849 .write(options.write)
850 .append(options.append)
851 .create(options.create)
852 .create_new(options.create_new)
853 .share_mode(if options.share_read_only {
854 FILE_SHARE_READ
855 } else {
856 FILE_SHARE_READ_WRITE_DELETE
857 })
858 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
859 .open(&cur)?;
860 let meta = file.metadata()?;
861 if meta.file_type().is_symlink() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
862 return Err(ContainError::Symlink);
863 }
864 if !meta.is_file() {
865 return Err(ContainError::NotAFile);
866 }
867 let real = crate::handle::final_path(&file)?;
869 if !is_below(root, &real) {
870 return Err(ContainError::Escapes);
871 }
872 if options.require_single_link && crate::handle::link_count(&file)? != 1 {
873 return Err(ContainError::HardLinked);
874 }
875 if options.truncate {
876 file.set_len(0)?;
877 }
878 drop(pins);
879 Ok(file)
880}
881
882#[cfg(all(feature = "fs", not(any(unix, windows))))]
883fn open_strict(
884 _root: &Path,
885 _names: &[OsString],
886 _options: &ConfinedOptions,
887 _expected_root: Option<crate::file_key::FileKey>,
888) -> Result<std::fs::File, ContainError> {
889 Err(ContainError::Io(io::Error::new(
890 io::ErrorKind::Unsupported,
891 "a confined open is not available on this platform",
892 )))
893}
894
895#[cfg(test)]
896mod contain_tests {
897 use super::*;
898 use std::fs;
899
900 fn tree() -> tempfile::TempDir {
901 let dir = tempfile::tempdir().unwrap();
902 fs::create_dir_all(dir.path().join("root/sub")).unwrap();
903 fs::write(dir.path().join("root/sub/a.txt"), b"a").unwrap();
904 fs::create_dir_all(dir.path().join("outside")).unwrap();
905 fs::write(dir.path().join("outside/secret.txt"), b"s").unwrap();
906 dir
907 }
908
909 #[test]
910 fn existing_file_resolves_inside() {
911 let t = tree();
912 let root = t.path().join("root");
913 let got = contain(&root, "sub/a.txt").unwrap();
914 assert_eq!(got, canonicalize_plain(root.join("sub/a.txt")).unwrap());
915 assert_eq!(contain(&root, "./sub/./a.txt").unwrap(), got);
916 }
917
918 #[test]
919 fn invalid_relative_forms_are_refused() {
920 let t = tree();
921 let root = t.path().join("root");
922 for rel in [
923 "",
924 ".",
925 "../outside/secret.txt",
926 "sub/../../outside",
927 "sub/..",
928 "/etc/passwd",
929 "a\0b",
930 "sub/a.txt\0",
931 ] {
932 assert!(
933 matches!(contain(&root, rel), Err(ContainError::InvalidRelative)),
934 "{rel:?}"
935 );
936 assert!(
937 matches!(
938 contain_lenient(&root, rel),
939 Err(ContainError::InvalidRelative)
940 ),
941 "{rel:?}"
942 );
943 }
944 }
945
946 #[test]
947 fn an_absolute_rel_path_pointing_inside_the_root_is_still_refused() {
948 let t = tree();
949 let root = t.path().join("root");
950 let inside = root.join("sub/a.txt");
951 assert!(matches!(
952 contain(&root, &inside),
953 Err(ContainError::InvalidRelative)
954 ));
955 }
956
957 #[test]
958 fn missing_leaf_is_io_for_strict_and_ok_for_lenient() {
959 let t = tree();
960 let root = t.path().join("root");
961 assert!(matches!(
962 contain(&root, "sub/new.txt"),
963 Err(ContainError::Io(_))
964 ));
965 let got = contain_lenient(&root, "sub/new/deeper/new.txt").unwrap();
966 let base = canonicalize_plain(root.join("sub")).unwrap();
967 assert_eq!(got, base.join("new").join("deeper").join("new.txt"));
968 assert_eq!(
969 contain_lenient(&root, "sub/a.txt").unwrap(),
970 contain(&root, "sub/a.txt").unwrap()
971 );
972 }
973
974 #[test]
975 fn missing_root_is_io() {
976 let t = tree();
977 assert!(matches!(
978 contain(&t.path().join("nope"), "a"),
979 Err(ContainError::Io(_))
980 ));
981 assert!(matches!(
982 contain_lenient(&t.path().join("nope"), "a"),
983 Err(ContainError::Io(_))
984 ));
985 }
986
987 #[test]
988 fn file_in_the_middle_is_an_error() {
989 let t = tree();
990 let root = t.path().join("root");
991 assert!(contain_lenient(&root, "sub/a.txt/x").is_err());
992 }
993
994 #[test]
995 fn windows_name_rules_apply_only_where_windows_applies_them() {
996 let t = tree();
997 let root = t.path().join("root");
998 for rel in [
999 "CON",
1000 "sub/nul.txt",
1001 "sub/COM1",
1002 "a:stream",
1003 "trail.",
1004 "sp ",
1005 "a*b",
1006 "q?",
1007 ] {
1008 let strict = contain_lenient(&root, rel);
1009 if cfg!(windows) {
1010 assert!(
1011 matches!(strict, Err(ContainError::InvalidRelative)),
1012 "{rel:?}: {strict:?}"
1013 );
1014 } else if !rel.contains('/') && !rel.contains('\\') {
1015 assert!(strict.is_ok(), "{rel:?}: {strict:?}");
1017 }
1018 }
1019 for rel in ["CON", "nul.txt", "COM1", "a:stream", "trail.", "sp ", "a*b"] {
1021 assert!(crate::names::is_unsafe_windows_component(rel), "{rel:?}");
1022 }
1023 }
1024
1025 #[test]
1026 fn a_not_yet_existing_name_that_windows_cannot_use_is_refused_by_is_within() {
1027 let t = tree();
1028 let root = t.path().join("root");
1029 let device = root.join("sub").join("NUL");
1030 assert_eq!(is_within(&root, &device), !cfg!(windows));
1031 let stream = root.join("sub").join("a.txt:stream");
1032 assert_eq!(is_within(&root, &stream), !cfg!(windows));
1033 }
1034
1035 #[cfg(unix)]
1036 mod unix {
1037 use super::*;
1038 use std::os::unix::fs::symlink;
1039
1040 #[test]
1041 fn symlink_out_is_an_escape() {
1042 let t = tree();
1043 let root = t.path().join("root");
1044 symlink(t.path().join("outside"), root.join("link")).unwrap();
1045 assert!(matches!(
1046 contain(&root, "link/secret.txt"),
1047 Err(ContainError::Escapes)
1048 ));
1049 assert!(matches!(contain(&root, "link"), Err(ContainError::Escapes)));
1050 assert!(matches!(
1052 contain_lenient(&root, "link/new.txt"),
1053 Err(ContainError::Escapes)
1054 ));
1055 }
1056
1057 #[test]
1058 fn symlink_within_root_is_followed() {
1059 let t = tree();
1060 let root = t.path().join("root");
1061 symlink(root.join("sub"), root.join("alias")).unwrap();
1062 let got = contain(&root, "alias/a.txt").unwrap();
1063 assert_eq!(got, canonicalize_plain(root.join("sub/a.txt")).unwrap());
1064 }
1065
1066 #[test]
1067 fn dangling_symlink_is_refused_even_when_lenient() {
1068 let t = tree();
1069 let root = t.path().join("root");
1070 symlink(t.path().join("outside/not-yet.txt"), root.join("dangling")).unwrap();
1072 assert!(matches!(
1073 contain_lenient(&root, "dangling"),
1074 Err(ContainError::Io(_))
1075 ));
1076 assert!(matches!(
1077 contain_lenient(&root, "dangling/x"),
1078 Err(ContainError::Io(_))
1079 ));
1080 assert!(contain(&root, "dangling").is_err());
1081 }
1082
1083 #[test]
1084 fn symlink_loop_is_refused() {
1085 let t = tree();
1086 let root = t.path().join("root");
1087 symlink(root.join("b"), root.join("a")).unwrap();
1088 symlink(root.join("a"), root.join("b")).unwrap();
1089 assert!(contain_lenient(&root, "a/x").is_err());
1090 assert!(contain(&root, "a").is_err());
1091 }
1092
1093 #[test]
1094 fn symlinked_root_is_resolved() {
1095 let t = tree();
1096 let alias = t.path().join("rootlink");
1097 symlink(t.path().join("root"), &alias).unwrap();
1098 let got = contain(&alias, "sub/a.txt").unwrap();
1099 assert_eq!(
1100 got,
1101 canonicalize_plain(t.path().join("root/sub/a.txt")).unwrap()
1102 );
1103 }
1104
1105 #[test]
1106 fn sibling_sharing_a_name_prefix_is_not_inside() {
1107 let t = tree();
1109 let root = t.path().join("root");
1110 fs::create_dir(t.path().join("root-evil")).unwrap();
1111 symlink(t.path().join("root-evil"), root.join("l")).unwrap();
1112 assert!(matches!(
1113 contain_lenient(&root, "l/x"),
1114 Err(ContainError::Escapes)
1115 ));
1116 }
1117
1118 #[test]
1119 fn names_that_differ_only_in_invalid_utf8_are_never_equal() {
1120 use std::os::unix::ffi::OsStringExt;
1121 let a = PathBuf::from(OsString::from_vec(b"/data/\xff".to_vec()));
1123 let b = PathBuf::from(OsString::from_vec(b"/data/\xfe".to_vec()));
1124 assert!(!is_below_with(&a, &b, false));
1125 assert!(!is_below_with(&a, &b, true));
1126 assert!(is_below_with(&a, &a.join("x"), true));
1127 }
1128 }
1129
1130 #[test]
1131 fn case_folding_is_applied_only_when_asked() {
1132 let root = Path::new("/Data/App");
1133 let path = Path::new("/data/app/x");
1134 assert!(is_below_with(root, path, true));
1135 assert!(!is_below_with(root, path, false));
1136 assert!(!is_below_with(
1137 Path::new("/data/app"),
1138 Path::new("/data/app2"),
1139 true
1140 ));
1141 }
1142
1143 #[test]
1144 fn verbatim_unc_folds_to_the_plain_unc_form() {
1145 assert_eq!(fold_unc(r"\\?\UNC\srv\share\x"), r"\\srv\share\x");
1146 assert_eq!(fold_unc(r"\\srv\share\x"), r"\\srv\share\x");
1147 assert_eq!(fold_unc(r"\\?\C:\x"), r"\\?\C:\x");
1148 }
1149
1150 #[test]
1151 fn contain_error_converts_to_io_error_kinds() {
1152 let kind = |e: ContainError| io::Error::from(e).kind();
1153 assert_eq!(
1154 kind(ContainError::InvalidRelative),
1155 io::ErrorKind::InvalidInput
1156 );
1157 assert_eq!(kind(ContainError::Escapes), io::ErrorKind::PermissionDenied);
1158 assert_eq!(
1159 kind(ContainError::HardLinked),
1160 io::ErrorKind::PermissionDenied
1161 );
1162 assert_eq!(
1163 kind(ContainError::Io(io::Error::from(io::ErrorKind::NotFound))),
1164 io::ErrorKind::NotFound
1165 );
1166 }
1167}
1168
1169#[cfg(test)]
1170mod within_tests {
1171 use super::*;
1172 use std::fs;
1173
1174 fn root() -> (tempfile::TempDir, PathBuf) {
1175 let tmp = tempfile::tempdir().unwrap();
1176 let root = tmp.path().join("store");
1177 fs::create_dir_all(root.join("sub")).unwrap();
1178 (tmp, root)
1179 }
1180
1181 #[test]
1182 fn same_path_and_children_are_within() {
1183 let (_t, root) = root();
1184 assert!(is_within(&root, &root));
1185 assert!(is_within(&root, &root.join("sub")));
1186 assert!(is_within(&root, &root.join("sub").join("file.db")));
1187 }
1188
1189 #[test]
1190 fn a_missing_tail_is_resolved_through_the_existing_ancestor() {
1191 let (_t, root) = root();
1192 assert!(is_within(
1193 &root,
1194 &root.join("not").join("yet").join("made.db")
1195 ));
1196 assert!(!is_within(&root.join("sub"), &root.join("not").join("yet")));
1197 }
1198
1199 #[test]
1200 fn a_sibling_sharing_a_name_prefix_is_not_within() {
1201 let (t, root) = root();
1202 let sibling = t.path().join("store2");
1203 fs::create_dir_all(&sibling).unwrap();
1204 assert!(!is_within(&root, &sibling));
1205 assert!(!is_within(&root, &sibling.join("x")));
1206 assert!(!is_within(&root, t.path()));
1207 }
1208
1209 #[test]
1210 fn a_dot_dot_in_the_missing_part_is_refused() {
1211 let (t, root) = root();
1212 let escape = root.join("missing").join("..").join("..").join("elsewhere");
1213 assert!(!is_within(&root, &escape));
1214 assert!(!is_within(&root, &root.join("missing").join("..")));
1215 let _ = t;
1216 }
1217
1218 #[test]
1219 fn a_dot_dot_through_existing_directories_is_resolved() {
1220 let (t, root) = root();
1221 assert!(is_within(&root, &root.join("sub").join("..").join("sub")));
1222 assert!(!is_within(&root, &root.join("sub").join("..").join("..")));
1223 let _ = t;
1224 }
1225
1226 #[test]
1227 fn relative_paths_and_nul_bytes_are_refused() {
1228 let (_t, root) = root();
1229 assert!(!is_within(Path::new("store"), &root));
1230 assert!(!is_within(&root, Path::new("store/sub")));
1231 assert!(!is_within(Path::new(""), Path::new("")));
1232 assert!(!is_within(&root, &root.join("a\0b")));
1233 }
1234
1235 #[cfg(any(windows, target_vendor = "apple"))]
1236 #[test]
1237 fn case_differences_are_ignored_on_windows_and_macos() {
1238 let (_t, root) = root();
1239 let upper = PathBuf::from(root.to_string_lossy().to_uppercase());
1240 assert!(is_within(&upper, &root.join("sub")));
1241 assert!(is_within(&root, &upper.join("Sub").join("New.db")));
1242 }
1243
1244 #[cfg(not(any(windows, target_vendor = "apple")))]
1245 #[test]
1246 fn case_matters_elsewhere() {
1247 let (t, root) = root();
1248 let other = t.path().join("STORE");
1249 assert!(!is_within(&root, &other.join("x")));
1250 assert!(!is_within(&other, &root));
1251 }
1252
1253 #[cfg(unix)]
1254 #[test]
1255 fn a_symlink_out_of_the_root_is_not_within_and_into_it_is() {
1256 let (t, root) = root();
1257 let outside = t.path().join("outside");
1258 fs::create_dir_all(&outside).unwrap();
1259 std::os::unix::fs::symlink(&outside, root.join("out")).unwrap();
1260 assert!(!is_within(&root, &root.join("out").join("f.db")));
1261 let link_in = t.path().join("alias");
1262 std::os::unix::fs::symlink(&root, &link_in).unwrap();
1263 assert!(is_within(&root, &link_in.join("sub").join("f.db")));
1264 assert!(is_within(&link_in, &root.join("sub")));
1265 }
1266
1267 #[cfg(unix)]
1268 #[test]
1269 fn a_dangling_symlink_is_refused_not_treated_as_missing() {
1270 let (t, root) = root();
1271 let outside = t.path().join("nowhere");
1272 std::os::unix::fs::symlink(&outside, root.join("dangling")).unwrap();
1273 assert!(!is_within(&root, &root.join("dangling").join("f.db")));
1274 assert!(!is_within(&root, &root.join("dangling")));
1275 }
1276
1277 #[cfg(windows)]
1278 #[test]
1279 fn verbatim_and_plain_drive_paths_compare_equal() {
1280 let (_t, root) = root();
1281 let verbatim = fs::canonicalize(&root).unwrap();
1282 assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
1283 let plain = plain_path(verbatim.clone());
1284 assert!(is_within(&verbatim, &plain.join("sub")));
1285 assert!(is_within(&plain, &verbatim.join("sub").join("new.db")));
1286 }
1287}
1288
1289#[cfg(test)]
1290mod roots_tests {
1291 use super::*;
1292 use std::fs;
1293
1294 fn two_roots() -> (tempfile::TempDir, PathBuf, PathBuf, Roots) {
1295 let t = tempfile::tempdir().unwrap();
1296 let a = t.path().join("a");
1297 let b = t.path().join("b");
1298 fs::create_dir_all(a.join("sub")).unwrap();
1299 fs::create_dir_all(&b).unwrap();
1300 fs::write(a.join("sub/f.txt"), b"f").unwrap();
1301 fs::write(t.path().join("outside.txt"), b"o").unwrap();
1302 let roots = Roots::with_roots([&a, &b]).unwrap();
1303 (t, a, b, roots)
1304 }
1305
1306 #[test]
1307 fn empty_set_refuses_everything() {
1308 let t = tempfile::tempdir().unwrap();
1309 let roots = Roots::new();
1310 assert!(roots.is_empty());
1311 assert!(!roots.contains(t.path()));
1312 assert!(matches!(
1313 roots.resolve_confined(t.path()),
1314 Err(ContainError::Escapes)
1315 ));
1316 }
1317
1318 #[test]
1319 fn add_requires_an_absolute_existing_directory() {
1320 let t = tempfile::tempdir().unwrap();
1321 let mut roots = Roots::new();
1322 assert!(matches!(
1323 roots.add("relative"),
1324 Err(ContainError::NotAbsolute)
1325 ));
1326 assert!(matches!(
1327 roots.add(t.path().join("missing")),
1328 Err(ContainError::Io(_))
1329 ));
1330 fs::write(t.path().join("file"), b"x").unwrap();
1331 assert!(matches!(
1332 roots.add(t.path().join("file")),
1333 Err(ContainError::Io(_))
1334 ));
1335 roots.add(t.path()).unwrap();
1336 roots.add(t.path()).unwrap();
1337 assert_eq!(roots.roots().len(), 1);
1338 }
1339
1340 #[test]
1341 fn paths_under_any_root_resolve_and_others_escape() {
1342 let (t, a, b, roots) = two_roots();
1343 assert!(roots.contains(&a.join("sub/f.txt")));
1344 assert!(roots.contains(&b.join("new/tail.txt")));
1345 assert!(!roots.contains(&t.path().join("outside.txt")));
1346 assert!(!roots.contains(t.path()));
1347 assert_eq!(
1348 roots.resolve_confined(&a.join("sub/f.txt")).unwrap(),
1349 canonicalize_plain(a.join("sub/f.txt")).unwrap()
1350 );
1351 assert!(matches!(
1352 roots.resolve_confined(&t.path().join("outside.txt")),
1353 Err(ContainError::Escapes)
1354 ));
1355 assert!(matches!(
1357 roots.resolve_confined(&a.join("nope")),
1358 Err(ContainError::Io(_))
1359 ));
1360 assert!(roots
1361 .resolve_confined_lenient(&a.join("nope/deeper.txt"))
1362 .is_ok());
1363 }
1364
1365 #[test]
1366 fn relative_and_dot_dot_paths_are_refused() {
1367 let (_t, a, _b, roots) = two_roots();
1368 assert!(matches!(
1369 roots.resolve_confined(Path::new("sub/f.txt")),
1370 Err(ContainError::NotAbsolute)
1371 ));
1372 let sneaky = a.join("missing").join("..").join("..").join("outside.txt");
1373 assert!(roots.resolve_confined_lenient(&sneaky).is_err());
1374 assert!(!roots.contains(&sneaky));
1375 }
1376
1377 #[test]
1378 fn a_name_prefix_sibling_of_a_root_is_outside() {
1379 let (t, _a, _b, roots) = two_roots();
1380 fs::create_dir_all(t.path().join("a2")).unwrap();
1381 assert!(!roots.contains(&t.path().join("a2").join("x")));
1382 }
1383
1384 #[cfg(unix)]
1385 #[test]
1386 fn a_link_out_escapes_and_a_link_within_is_followed() {
1387 let (t, a, _b, roots) = two_roots();
1388 std::os::unix::fs::symlink(t.path().join("outside.txt"), a.join("out")).unwrap();
1389 std::os::unix::fs::symlink(a.join("sub"), a.join("in")).unwrap();
1390 assert!(matches!(
1391 roots.resolve_confined(&a.join("out")),
1392 Err(ContainError::Escapes)
1393 ));
1394 assert!(roots.resolve_confined(&a.join("in/f.txt")).is_ok());
1395 std::os::unix::fs::symlink(t.path().join("gone"), a.join("dangling")).unwrap();
1396 assert!(roots.resolve_confined_lenient(&a.join("dangling")).is_err());
1397 }
1398}
1399
1400#[cfg(all(test, feature = "fs"))]
1401mod confined_tests {
1402 use super::*;
1403 use std::fs;
1404 use std::io::{Read, Write};
1405
1406 fn tree() -> (tempfile::TempDir, PathBuf) {
1407 let t = tempfile::tempdir().unwrap();
1408 let root = t.path().join("root");
1409 fs::create_dir_all(root.join("sub/deeper")).unwrap();
1410 fs::write(root.join("sub/a.txt"), b"alpha").unwrap();
1411 fs::create_dir_all(t.path().join("outside")).unwrap();
1412 fs::write(t.path().join("outside/secret.txt"), b"secret").unwrap();
1413 (t, root)
1414 }
1415
1416 fn read_all(mut f: fs::File) -> String {
1417 let mut s = String::new();
1418 f.read_to_string(&mut s).unwrap();
1419 s
1420 }
1421
1422 #[test]
1423 fn reads_a_file_under_the_root() {
1424 let (_t, root) = tree();
1425 let f = open_confined(&root, "sub/a.txt", &ConfinedOptions::read_only()).unwrap();
1426 assert_eq!(read_all(f), "alpha");
1427 let f = open_confined(&root, "./sub/./a.txt", &ConfinedOptions::read_only()).unwrap();
1428 assert_eq!(read_all(f), "alpha");
1429 }
1430
1431 #[test]
1432 fn invalid_relative_paths_are_refused_before_touching_the_disk() {
1433 let (_t, root) = tree();
1434 for rel in [
1435 "",
1436 ".",
1437 "../outside/secret.txt",
1438 "sub/../../outside/secret.txt",
1439 "/etc/passwd",
1440 "sub/a.txt\0",
1441 ] {
1442 let err = open_confined(&root, rel, &ConfinedOptions::read_only()).unwrap_err();
1443 assert!(
1444 matches!(err, ContainError::InvalidRelative),
1445 "{rel:?}: {err}"
1446 );
1447 }
1448 }
1449
1450 #[test]
1451 fn an_option_set_without_access_is_refused() {
1452 let (_t, root) = tree();
1453 let none = ConfinedOptions::default();
1454 assert!(matches!(
1455 open_confined(&root, "sub/a.txt", &none),
1456 Err(ContainError::Io(_))
1457 ));
1458 let create_read = ConfinedOptions::read_only().create(true);
1459 assert!(matches!(
1460 open_confined(&root, "sub/a.txt", &create_read),
1461 Err(ContainError::Io(_))
1462 ));
1463 let truncate_append = ConfinedOptions::default().append(true).truncate(true);
1464 assert!(matches!(
1465 open_confined(&root, "sub/a.txt", &truncate_append),
1466 Err(ContainError::Io(_))
1467 ));
1468 let share_write = ConfinedOptions::default().write(true).share_read_only(true);
1469 assert!(matches!(
1470 open_confined(&root, "sub/a.txt", &share_write),
1471 Err(ContainError::Io(_))
1472 ));
1473 }
1474
1475 #[test]
1476 fn creates_new_files_and_refuses_to_overwrite_with_create_new() {
1477 let (_t, root) = tree();
1478 let make = ConfinedOptions::default().write(true).create_new(true);
1479 let mut f = open_confined(&root, "sub/deeper/new.txt", &make).unwrap();
1480 f.write_all(b"fresh").unwrap();
1481 drop(f);
1482 assert_eq!(fs::read(root.join("sub/deeper/new.txt")).unwrap(), b"fresh");
1483 let again = open_confined(&root, "sub/deeper/new.txt", &make).unwrap_err();
1484 assert!(
1485 matches!(again, ContainError::Io(ref e) if e.kind() == io::ErrorKind::AlreadyExists)
1486 );
1487 }
1488
1489 #[test]
1490 fn a_missing_parent_or_file_is_io_not_a_silent_create() {
1491 let (_t, root) = tree();
1492 let read = ConfinedOptions::read_only();
1493 assert!(matches!(
1494 open_confined(&root, "nope/a.txt", &read),
1495 Err(ContainError::Io(_))
1496 ));
1497 assert!(matches!(
1498 open_confined(&root, "sub/missing.txt", &read),
1499 Err(ContainError::Io(_))
1500 ));
1501 assert!(!root.join("sub/missing.txt").exists());
1502 }
1503
1504 #[test]
1505 fn a_directory_is_not_a_file() {
1506 let (_t, root) = tree();
1507 let err = open_confined(&root, "sub", &ConfinedOptions::read_only()).unwrap_err();
1508 assert!(
1509 matches!(err, ContainError::NotAFile | ContainError::Io(_)),
1510 "{err}"
1511 );
1512 }
1513
1514 #[test]
1515 fn a_hard_linked_file_is_refused_only_when_a_single_link_is_required() {
1516 let (t, root) = tree();
1517 fs::hard_link(t.path().join("outside/secret.txt"), root.join("sub/alias")).unwrap();
1519 let plain = ConfinedOptions::read_only();
1520 assert_eq!(
1521 read_all(open_confined(&root, "sub/alias", &plain).unwrap()),
1522 "secret"
1523 );
1524 let strict = ConfinedOptions::read_only().require_single_link(true);
1525 let err = open_confined(&root, "sub/alias", &strict).unwrap_err();
1526 assert!(matches!(err, ContainError::HardLinked), "{err}");
1527 assert_eq!(
1528 read_all(open_confined(&root, "sub/a.txt", &strict).unwrap()),
1529 "alpha"
1530 );
1531 }
1532
1533 #[test]
1534 fn a_refused_hard_link_is_never_truncated() {
1535 let (t, root) = tree();
1536 let outside = t.path().join("outside/secret.txt");
1537 fs::hard_link(&outside, root.join("sub/alias")).unwrap();
1538 let strict = ConfinedOptions::default()
1539 .write(true)
1540 .truncate(true)
1541 .require_single_link(true);
1542 let err = open_confined(&root, "sub/alias", &strict).unwrap_err();
1543 assert!(matches!(err, ContainError::HardLinked), "{err}");
1544 assert_eq!(fs::read(&outside).unwrap(), b"secret");
1545 }
1546
1547 #[test]
1548 fn a_refused_directory_or_non_file_is_never_truncated() {
1549 let (_t, root) = tree();
1550 let trunc = ConfinedOptions::default().write(true).truncate(true);
1551 assert!(open_confined(&root, "sub", &trunc).is_err());
1552 assert_eq!(fs::read(root.join("sub/a.txt")).unwrap(), b"alpha");
1553 }
1554
1555 #[test]
1556 fn truncation_applies_after_validation_when_the_open_is_accepted() {
1557 let (_t, root) = tree();
1558 let trunc = ConfinedOptions::default().write(true).truncate(true);
1559 let f = open_confined(&root, "sub/a.txt", &trunc).unwrap();
1560 drop(f);
1561 assert_eq!(fs::read(root.join("sub/a.txt")).unwrap(), b"");
1562 }
1563
1564 #[test]
1565 fn a_case_only_difference_is_inside_only_when_the_filesystem_agrees() {
1566 let t = tempfile::tempdir().unwrap();
1567 let base = canonicalize_plain(t.path()).unwrap();
1568 fs::create_dir(base.join("root")).unwrap();
1569 let sensitive = fs::create_dir(base.join("Root")).is_ok();
1570 let a = base.join("root");
1571 let b = base.join("Root");
1572 if sensitive {
1573 assert!(!is_below(&a, &b.join("x")));
1575 assert!(!is_below(&b, &a.join("x")));
1576 assert!(!is_within(&a, &b.join("x")));
1577 let roots = Roots::with_roots([&a]).unwrap();
1578 assert!(!roots.contains(&b.join("x")));
1579 assert!(matches!(
1580 roots.open_confined(&b.join("x"), &ConfinedOptions::read_only()),
1581 Err(ContainError::Escapes)
1582 ));
1583 } else if CASE_INSENSITIVE {
1584 assert!(is_below(&a, &b.join("x")));
1585 }
1586 assert!(!is_below(&a, &base.join("ROOT2").join("x")));
1588 }
1589
1590 #[test]
1591 fn roots_open_picks_the_matching_root_and_refuses_the_rest() {
1592 let (t, root) = tree();
1593 let other = t.path().join("other");
1594 fs::create_dir_all(&other).unwrap();
1595 fs::write(other.join("o.txt"), b"other").unwrap();
1596 let roots = Roots::with_roots([&root, &other]).unwrap();
1597 let read = ConfinedOptions::read_only();
1598 assert_eq!(
1599 read_all(roots.open_confined(&root.join("sub/a.txt"), &read).unwrap()),
1600 "alpha"
1601 );
1602 assert_eq!(
1603 read_all(roots.open_confined(&other.join("o.txt"), &read).unwrap()),
1604 "other"
1605 );
1606 let err = roots
1607 .open_confined(&t.path().join("outside/secret.txt"), &read)
1608 .unwrap_err();
1609 assert!(matches!(err, ContainError::Escapes), "{err}");
1610 let err = roots
1611 .open_confined(Path::new("sub/a.txt"), &read)
1612 .unwrap_err();
1613 assert!(matches!(err, ContainError::NotAbsolute), "{err}");
1614 let err = roots
1615 .open_confined(&root.join("sub").join(".."), &read)
1616 .unwrap_err();
1617 assert!(matches!(err, ContainError::InvalidRelative), "{err}");
1618 let err = roots
1619 .open_confined(&root.join("sub").join("a\0b"), &read)
1620 .unwrap_err();
1621 assert!(matches!(err, ContainError::InvalidRelative), "{err}");
1622 }
1623
1624 #[test]
1625 fn roots_open_can_create_in_an_existing_directory_of_a_root() {
1626 let (_t, root) = tree();
1627 let roots = Roots::with_roots([&root]).unwrap();
1628 let make = ConfinedOptions::default().write(true).create_new(true);
1629 let mut f = roots
1630 .open_confined(&root.join("sub/made.txt"), &make)
1631 .unwrap();
1632 f.write_all(b"made").unwrap();
1633 drop(f);
1634 assert_eq!(fs::read(root.join("sub/made.txt")).unwrap(), b"made");
1635 }
1636
1637 #[cfg(unix)]
1638 mod unix {
1639 use super::*;
1640 use std::os::unix::fs::{symlink, PermissionsExt};
1641
1642 #[test]
1643 fn a_leaf_symlink_is_refused_even_when_it_points_inside() {
1644 let (_t, root) = tree();
1645 symlink(root.join("sub/a.txt"), root.join("inside-link")).unwrap();
1646 let err =
1647 open_confined(&root, "inside-link", &ConfinedOptions::read_only()).unwrap_err();
1648 assert!(matches!(err, ContainError::Symlink), "{err}");
1649 }
1650
1651 #[test]
1652 fn a_leaf_symlink_out_of_the_root_is_refused_and_never_followed() {
1653 let (t, root) = tree();
1654 symlink(t.path().join("outside/secret.txt"), root.join("out-link")).unwrap();
1655 let err = open_confined(&root, "out-link", &ConfinedOptions::read_only()).unwrap_err();
1656 assert!(matches!(err, ContainError::Symlink), "{err}");
1657 }
1658
1659 #[test]
1660 fn a_directory_symlink_on_the_way_is_refused() {
1661 let (t, root) = tree();
1662 symlink(t.path().join("outside"), root.join("dir-link")).unwrap();
1663 let err = open_confined(&root, "dir-link/secret.txt", &ConfinedOptions::read_only())
1664 .unwrap_err();
1665 assert!(matches!(err, ContainError::Symlink), "{err}");
1666 symlink(root.join("sub"), root.join("sub-link")).unwrap();
1667 let err =
1668 open_confined(&root, "sub-link/a.txt", &ConfinedOptions::read_only()).unwrap_err();
1669 assert!(matches!(err, ContainError::Symlink), "{err}");
1670 }
1671
1672 #[test]
1673 fn a_dangling_leaf_link_is_never_created_through() {
1674 let (t, root) = tree();
1675 let target = t.path().join("outside/new.txt");
1676 symlink(&target, root.join("dangling")).unwrap();
1677 let make = ConfinedOptions::default().write(true).create(true);
1678 let err = open_confined(&root, "dangling", &make).unwrap_err();
1679 assert!(matches!(err, ContainError::Symlink), "{err}");
1680 assert!(!target.exists(), "a file was created outside the root");
1681 }
1682
1683 #[test]
1684 fn the_root_itself_may_be_reached_through_a_link() {
1685 let (t, root) = tree();
1686 let alias = t.path().join("alias");
1687 symlink(&root, &alias).unwrap();
1688 let f = open_confined(&alias, "sub/a.txt", &ConfinedOptions::read_only()).unwrap();
1689 assert_eq!(read_all(f), "alpha");
1690 }
1691
1692 #[test]
1693 fn a_created_file_is_owner_only() {
1694 let (_t, root) = tree();
1695 let make = ConfinedOptions::default().write(true).create_new(true);
1696 open_confined(&root, "private.txt", &make).unwrap();
1697 let mode = fs::metadata(root.join("private.txt"))
1698 .unwrap()
1699 .permissions()
1700 .mode();
1701 assert_eq!(mode & 0o077, 0, "mode {mode:o}");
1702 }
1703
1704 #[test]
1705 fn a_fifo_is_refused_and_never_waited_on() {
1706 use std::ffi::CString;
1707 use std::os::unix::ffi::OsStrExt;
1708 let (_t, root) = tree();
1709 let path = root.join("pipe");
1710 let c = CString::new(path.as_os_str().as_bytes()).unwrap();
1711 assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0);
1713 let err = open_confined(&root, "pipe", &ConfinedOptions::read_only()).unwrap_err();
1714 assert!(matches!(err, ContainError::NotAFile), "{err}");
1715 let write = ConfinedOptions::default().write(true);
1716 assert!(open_confined(&root, "pipe", &write).is_err());
1718 }
1719
1720 #[test]
1721 fn roots_open_follows_links_in_parents_that_stay_inside_but_not_the_leaf() {
1722 let (t, root) = tree();
1723 let roots = Roots::with_roots([&root]).unwrap();
1724 symlink(root.join("sub"), root.join("sub-link")).unwrap();
1725 let f = roots
1727 .open_confined(&root.join("sub-link/a.txt"), &ConfinedOptions::read_only())
1728 .unwrap();
1729 assert_eq!(read_all(f), "alpha");
1730 symlink(t.path().join("outside"), root.join("out-dir")).unwrap();
1732 let err = roots
1733 .open_confined(
1734 &root.join("out-dir/secret.txt"),
1735 &ConfinedOptions::read_only(),
1736 )
1737 .unwrap_err();
1738 assert!(matches!(err, ContainError::Escapes), "{err}");
1739 symlink(root.join("sub/a.txt"), root.join("leaf")).unwrap();
1741 let err = roots
1742 .open_confined(&root.join("leaf"), &ConfinedOptions::read_only())
1743 .unwrap_err();
1744 assert!(matches!(err, ContainError::Symlink), "{err}");
1745 }
1746
1747 #[test]
1748 fn a_root_replaced_after_approval_is_refused() {
1749 let (t, root) = tree();
1750 let roots = Roots::with_roots([&root]).unwrap();
1751 let read = ConfinedOptions::read_only();
1752 assert!(roots.open_confined(&root.join("sub/a.txt"), &read).is_ok());
1753 let moved = t.path().join("moved");
1755 fs::rename(&root, &moved).unwrap();
1756 fs::create_dir_all(root.join("sub")).unwrap();
1757 fs::write(root.join("sub/a.txt"), b"impostor").unwrap();
1758 let err = roots
1759 .open_confined(&root.join("sub/a.txt"), &read)
1760 .unwrap_err();
1761 assert!(matches!(err, ContainError::Escapes), "{err}");
1762 }
1763 }
1764
1765 #[cfg(windows)]
1766 mod windows {
1767 use super::*;
1768
1769 #[test]
1770 fn case_differences_in_the_requested_path_open_the_same_file() {
1771 let (_t, root) = tree();
1772 let roots = Roots::with_roots([&root]).unwrap();
1773 let upper = PathBuf::from(
1774 root.join("SUB")
1775 .join("A.TXT")
1776 .to_string_lossy()
1777 .to_uppercase(),
1778 );
1779 let f = roots
1780 .open_confined(&upper, &ConfinedOptions::read_only())
1781 .unwrap();
1782 assert_eq!(read_all(f), "alpha");
1783 }
1784
1785 #[test]
1786 fn a_reserved_device_name_is_refused_before_any_open() {
1787 let (_t, root) = tree();
1788 for rel in ["NUL", "sub/CON", "COM1.txt", "sub/a.txt:stream"] {
1789 let err = open_confined(&root, rel, &ConfinedOptions::read_only()).unwrap_err();
1790 assert!(matches!(err, ContainError::InvalidRelative), "{rel}: {err}");
1791 }
1792 }
1793 }
1794}