1#[cfg(unix)]
11pub(crate) mod beneath;
12#[cfg(target_os = "linux")]
13pub(crate) mod mounts;
14#[cfg(any(not(unix), test))]
15pub(crate) mod portable;
16
17#[cfg(unix)]
18pub(crate) use beneath as platform;
19#[cfg(not(unix))]
20pub(crate) use portable as platform;
21
22use crate::{
23 Code, Diagnostic, LoadOptions, Overlay,
24 diagnostic::{Diagnostics, Positions},
25};
26use sha2::{Digest, Sha256};
27#[cfg(all(test, target_os = "linux"))]
28use std::path::Path;
29use std::{
30 cell::Cell,
31 collections::{BTreeMap, BTreeSet},
32 io::Read as _,
33 rc::Rc,
34};
35use tilewright_jsonc::{Document, ErrorCode};
36
37#[derive(Debug)]
39pub(crate) enum Refused {
40 Link(String),
42 Missing,
43 Unexpected,
45 Io(std::io::Error),
46}
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub(crate) enum Found {
51 File,
52 Directory,
53 Other,
54 Missing,
55}
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub(crate) enum RawKind {
59 File,
60 Directory,
61 Link,
62 Other,
63}
64
65#[derive(Debug)]
67pub(crate) struct RawEntry {
68 pub name: Option<String>,
69 pub kind: RawKind,
70}
71
72pub(crate) fn components(path: &str) -> Vec<&str> {
74 path.split('/')
75 .filter(|component| !component.is_empty())
76 .collect()
77}
78
79pub(crate) struct Source {
81 pub path: String,
82 pub document: Document,
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub(crate) enum EntryKind {
87 File,
88 Directory,
89 Other,
91}
92
93pub(crate) struct Entry {
94 pub name: String,
95 pub kind: EntryKind,
96}
97
98pub(crate) struct Tree<'o> {
99 root: platform::Root,
100 options: &'o LoadOptions,
101 overlay: &'o Overlay,
102 files: usize,
103 entries: usize,
104 bytes: u64,
105 digests: BTreeMap<String, (u64, [u8; 32])>,
106 diagnostics: Diagnostics,
107 exhausted: bool,
108}
109
110#[must_use]
114pub fn is_jsonc_path(path: &str) -> bool {
115 is_safe_relative(path) && path.as_bytes().ends_with(b".jsonc")
116}
117
118pub(crate) fn is_safe_relative(path: &str) -> bool {
121 let components: Vec<&str> = path.split('/').collect();
122 path.len() <= 1024
123 && components.len() <= 32
124 && components.iter().all(|component| {
125 let bytes = component.as_bytes();
126 !bytes.is_empty()
127 && bytes.len() <= 128
128 && (bytes[0].is_ascii_alphanumeric() || bytes[0] == b'_')
129 && bytes
130 .iter()
131 .all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(byte))
132 })
133}
134
135#[cfg(all(test, target_os = "linux"))]
137static NO_OVERLAY: Overlay = BTreeMap::new();
138
139impl<'o> Tree<'o> {
140 #[cfg(all(test, target_os = "linux"))]
141 pub fn new(root: &Path, options: &'o LoadOptions) -> Self {
142 Self::in_root(
143 platform::Root::new(root),
144 options,
145 &NO_OVERLAY,
146 Rc::new(Cell::new(0)),
147 )
148 }
149
150 pub fn in_root(
156 root: platform::Root,
157 options: &'o LoadOptions,
158 overlay: &'o Overlay,
159 total: Rc<Cell<usize>>,
160 ) -> Self {
161 Self {
162 root,
163 options,
164 overlay,
165 files: 0,
166 entries: 0,
167 bytes: 0,
168 digests: BTreeMap::new(),
169 diagnostics: Diagnostics::sharing(options.maximum_diagnostics, total),
170 exhausted: false,
171 }
172 }
173
174 fn exhaust(&mut self, path: &str) {
176 if !self.exhausted {
177 self.exhausted = true;
178 self.diagnostics.push(Diagnostic::file(
179 path,
180 Code::Budget,
181 format!(
182 "the load exceeds {} files, {} directory entries or {} bytes",
183 self.options.maximum_files,
184 self.options.maximum_entries,
185 self.options.maximum_total_bytes
186 ),
187 ));
188 }
189 }
190
191 fn within_budget(&self, length: u64) -> bool {
193 !self.exhausted
194 && self.files < self.options.maximum_files
195 && self.bytes.saturating_add(length) <= self.options.maximum_total_bytes
196 }
197
198 pub fn take_diagnostics(&mut self) -> Diagnostics {
199 let total = self.diagnostics.total();
200 std::mem::replace(
201 &mut self.diagnostics,
202 Diagnostics::sharing(self.options.maximum_diagnostics, total),
203 )
204 }
205
206 fn stopped(&self) -> bool {
208 self.diagnostics.is_full()
209 }
210
211 fn link(&mut self, path: &str) {
213 self.diagnostics.push(Diagnostic::file(
214 path,
215 Code::Symlink,
216 "symbolic links are not followed",
217 ));
218 }
219
220 pub fn kind(&mut self, path: &str) -> Option<EntryKind> {
223 match self.overlay.get(path) {
224 Some(Some(_)) => return Some(EntryKind::File),
225 Some(None) => return None,
226 None => {}
227 }
228 match self.root.kind(&components(path)) {
229 Ok(Found::File) => Some(EntryKind::File),
230 Ok(Found::Directory) => Some(EntryKind::Directory),
231 Ok(Found::Other) => Some(EntryKind::Other),
232 Ok(Found::Missing) | Err(Refused::Missing | Refused::Unexpected) => self
233 .holds_overlay_files(path)
234 .then_some(EntryKind::Directory),
235 Err(Refused::Link(link)) => {
236 self.link(&link);
237 None
238 }
239 Err(Refused::Io(error)) => {
240 self.diagnostics.push(Diagnostic::file(
241 path,
242 Code::Io,
243 format!("cannot read file type: {error}"),
244 ));
245 None
246 }
247 }
248 }
249
250 fn holds_overlay_files(&self, path: &str) -> bool {
252 overlay_beneath(self.overlay, path).any(|(_, value)| value.is_some())
253 }
254
255 pub fn container(&mut self, path: &str) -> bool {
258 match self.kind(path) {
259 Some(EntryKind::Directory) => true,
260 Some(EntryKind::File | EntryKind::Other) => {
261 self.diagnostics.push(Diagnostic::file(
262 path,
263 Code::UnexpectedFile,
264 "must be a directory",
265 ));
266 false
267 }
268 None => false,
269 }
270 }
271
272 pub fn list(&mut self, path: &str) -> Vec<Entry> {
275 if self.stopped() {
276 self.diagnostics.skip();
277 return Vec::new();
278 }
279 let mut added: BTreeMap<&str, EntryKind> = BTreeMap::new();
283 let remaining = self.options.maximum_entries.saturating_sub(self.entries);
284 for (rest, value) in overlay_beneath(self.overlay, path) {
285 match (rest.split_once('/'), value) {
286 (None, Some(_)) => {
287 added.insert(rest, EntryKind::File);
288 }
289 (Some((directory, _)), Some(_)) => {
290 added.entry(directory).or_insert(EntryKind::Directory);
291 }
292 (_, None) => {}
293 }
294 if added.len() > remaining {
297 self.exhaust(path);
298 return Vec::new();
299 }
300 }
301 let mut entries = self.list_disk(path);
305 let present: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
306 for (name, kind) in added {
307 if !present.contains(name) {
308 self.entries += 1;
309 if self.entries > self.options.maximum_entries {
310 self.exhaust(path);
311 break;
312 }
313 entries.push(Entry {
314 name: name.to_owned(),
315 kind,
316 });
317 }
318 }
319 entries.sort_by(|left, right| left.name.cmp(&right.name));
320 entries
321 }
322
323 fn list_disk(&mut self, path: &str) -> Vec<Entry> {
326 let overlay = self.overlay;
327 let directory = match self.root.list(&components(path)) {
328 Ok(directory) => directory,
329 Err(Refused::Link(link)) => {
330 self.link(&link);
331 return Vec::new();
332 }
333 Err(Refused::Missing) if self.holds_overlay_files(path) => return Vec::new(),
334 Err(refused) => {
335 let reason = match refused {
336 Refused::Io(error) => error.to_string(),
337 Refused::Missing => "it is missing".to_owned(),
338 Refused::Unexpected | Refused::Link(_) => "not a directory".to_owned(),
339 };
340 self.diagnostics.push(Diagnostic::file(
341 path,
342 Code::Io,
343 format!("cannot list directory: {reason}"),
344 ));
345 return Vec::new();
346 }
347 };
348 let mut entries = Vec::new();
349 for entry in directory {
350 if self.exhausted {
351 break;
352 }
353 self.entries += 1;
354 if self.entries > self.options.maximum_entries {
355 self.exhaust(path);
356 break;
357 }
358 let entry = match entry {
359 Ok(entry) => entry,
360 Err(error) => {
361 self.diagnostics.push(Diagnostic::file(
362 path,
363 Code::Io,
364 format!("cannot list directory: {error}"),
365 ));
366 continue;
367 }
368 };
369 let Some(name) = entry.name else {
370 self.diagnostics.push(Diagnostic::file(
371 path,
372 Code::UnexpectedFile,
373 "file name is not UTF-8",
374 ));
375 continue;
376 };
377 if overlay.contains_key(&join(path, &name)) {
378 self.entries -= 1;
380 continue;
381 }
382 let child = join(path, &name);
383 match entry.kind {
384 RawKind::Link => self.link(&child),
385 RawKind::Directory => entries.push(Entry {
386 name,
387 kind: EntryKind::Directory,
388 }),
389 RawKind::File => entries.push(Entry {
390 name,
391 kind: EntryKind::File,
392 }),
393 RawKind::Other => self.diagnostics.push(Diagnostic::file(
394 &child,
395 Code::UnexpectedFile,
396 "not a regular file or directory",
397 )),
398 }
399 }
400 entries
401 }
402
403 pub fn document(&mut self, path: &str) -> Option<Source> {
405 if self.stopped() {
406 self.diagnostics.skip();
407 return None;
408 }
409 match self.overlay.get(path) {
410 Some(Some(text)) => return self.overlay_document(path, text),
411 Some(None) => {
412 self.diagnostics
413 .push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
414 return None;
415 }
416 None => {}
417 }
418 let (file, length) = match self.root.file(&components(path)) {
421 Ok(opened) => opened,
422 Err(Refused::Link(link)) => {
423 self.link(&link);
424 return None;
425 }
426 Err(Refused::Unexpected) => {
427 self.diagnostics.push(Diagnostic::file(
428 path,
429 Code::UnexpectedFile,
430 "expected a regular file",
431 ));
432 return None;
433 }
434 Err(Refused::Missing) => {
435 self.diagnostics
436 .push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
437 return None;
438 }
439 Err(Refused::Io(error)) => {
440 self.diagnostics.push(Diagnostic::file(
441 path,
442 Code::Io,
443 format!("cannot read file: {error}"),
444 ));
445 return None;
446 }
447 };
448 let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
449 if length > limit {
450 self.diagnostics.push(Diagnostic::file(
451 path,
452 ErrorCode::InputTooLarge,
453 format!("file is larger than {limit} bytes"),
454 ));
455 return None;
456 }
457 if !self.within_budget(length) {
458 self.exhaust(path);
459 return None;
460 }
461 let remaining = self.options.maximum_total_bytes.saturating_sub(self.bytes);
465 let mut bytes = Vec::new();
466 let bytes = match file
467 .take(limit.min(remaining).saturating_add(1))
468 .read_to_end(&mut bytes)
469 {
470 Ok(_) => bytes,
471 Err(error) => {
472 self.diagnostics.push(Diagnostic::file(
473 path,
474 Code::Io,
475 format!("cannot read file: {error}"),
476 ));
477 return None;
478 }
479 };
480 let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
481 if length > limit {
482 self.diagnostics.push(Diagnostic::file(
483 path,
484 ErrorCode::InputTooLarge,
485 format!("file is larger than {limit} bytes"),
486 ));
487 return None;
488 }
489 if !self.within_budget(length) {
490 self.exhaust(path);
491 return None;
492 }
493 self.parse(path, bytes)
494 }
495
496 fn overlay_document(&mut self, path: &str, text: &str) -> Option<Source> {
499 let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
500 let length = u64::try_from(text.len()).unwrap_or(u64::MAX);
501 if length > limit {
502 self.diagnostics.push(Diagnostic::file(
503 path,
504 ErrorCode::InputTooLarge,
505 format!("file is larger than {limit} bytes"),
506 ));
507 return None;
508 }
509 if !self.within_budget(length) {
510 self.exhaust(path);
511 return None;
512 }
513 self.parse(path, text.as_bytes().to_vec())
514 }
515
516 fn parse(&mut self, path: &str, bytes: Vec<u8>) -> Option<Source> {
518 let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
519 self.files += 1;
520 self.bytes = self.bytes.saturating_add(length);
521 self.digests
522 .insert(path.to_owned(), (length, Sha256::digest(&bytes).into()));
523 let text = match String::from_utf8(bytes) {
524 Ok(text) => text,
525 Err(error) => {
526 self.diagnostics.push(Diagnostic::file(
527 path,
528 Code::Encoding,
529 format!("not UTF-8 at byte {}", error.utf8_error().valid_up_to()),
530 ));
531 return None;
532 }
533 };
534 match Document::parse(text.as_str(), &self.options.limits) {
535 Ok(document) => Some(Source {
536 path: path.to_owned(),
537 document,
538 }),
539 Err(error) => {
540 self.diagnostics.push(Diagnostic::at(
541 path,
542 &Positions::new(&text),
543 error.offset,
544 error.offset,
545 error.code,
546 "invalid JSONC",
547 ));
548 None
549 }
550 }
551 }
552
553 pub fn digest(&self) -> [u8; 32] {
557 let mut hasher = Sha256::new();
558 for (path, (length, digest)) in &self.digests {
559 hasher.update(path.as_bytes());
560 hasher.update([0]);
561 hasher.update(length.to_le_bytes());
562 hasher.update(digest);
563 }
564 hasher.finalize().into()
565 }
566}
567
568fn overlay_beneath<'a>(
571 overlay: &'a Overlay,
572 path: &str,
573) -> impl Iterator<Item = (&'a str, &'a Option<String>)> + 'a {
574 let prefix = if path.is_empty() {
575 String::new()
576 } else {
577 format!("{path}/")
578 };
579 overlay
580 .range(prefix.clone()..)
581 .map_while(move |(key, value)| key.strip_prefix(prefix.as_str()).map(|rest| (rest, value)))
582}
583
584pub(crate) fn join(directory: &str, name: &str) -> String {
585 if directory.is_empty() {
586 name.to_owned()
587 } else {
588 format!("{directory}/{name}")
589 }
590}
591
592#[cfg(all(test, unix))]
595mod tests {
596 use super::{Found, RawKind, Refused};
597 use std::{fs, os::unix::fs::symlink};
598
599 fn tree() -> tempfile::TempDir {
600 let root = tempfile::tempdir().unwrap();
601 fs::create_dir(root.path().join("dir")).unwrap();
602 fs::write(root.path().join("dir/file.jsonc"), "{}").unwrap();
603 symlink(root.path().join("dir"), root.path().join("link")).unwrap();
604 symlink(
605 root.path().join("dir/file.jsonc"),
606 root.path().join("dir/file-link.jsonc"),
607 )
608 .unwrap();
609 root
610 }
611
612 fn link<T>(result: Result<T, Refused>) -> String {
614 match result {
615 Err(Refused::Link(prefix)) => prefix,
616 Err(other) => panic!("expected a link, got {other:?}"),
617 Ok(_) => panic!("expected a link, got success"),
618 }
619 }
620
621 macro_rules! access_tests {
622 ($name:ident, $module:ident) => {
623 #[test]
624 fn $name() {
625 let directory = tree();
626 let root = super::$module::Root::new(directory.path());
627 assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
628 assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
629 assert_eq!(
630 link(root.file(&["dir", "file-link.jsonc"])),
631 "dir/file-link.jsonc"
632 );
633 assert!(matches!(root.file(&["dir"]), Err(Refused::Unexpected)));
634 assert!(matches!(root.file(&["dir", "nope"]), Err(Refused::Missing)));
635 assert!(matches!(
636 root.file(&["dir", "file.jsonc", "x"]),
637 Err(Refused::Unexpected)
638 ));
639 assert_eq!(root.kind(&["dir"]).unwrap(), Found::Directory);
640 assert_eq!(root.kind(&["dir", "file.jsonc"]).unwrap(), Found::File);
641 assert_eq!(root.kind(&["nope"]).unwrap(), Found::Missing);
642 assert_eq!(link(root.kind(&["link"])), "link");
643 assert_eq!(link(root.list(&["link"])), "link");
644 let mut entries: Vec<(Option<String>, RawKind)> = root
645 .list(&["dir"])
646 .unwrap()
647 .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
648 .collect::<Result<_, _>>()
649 .unwrap();
650 entries.sort_by(|left, right| left.0.cmp(&right.0));
651 assert_eq!(
652 entries,
653 [
654 (Some("file-link.jsonc".to_owned()), RawKind::Link),
655 (Some("file.jsonc".to_owned()), RawKind::File),
656 ]
657 );
658 }
659 };
660 }
661
662 access_tests!(beneath_refuses_links, beneath);
663 access_tests!(portable_refuses_links, portable);
664
665 macro_rules! write_tests {
666 ($name:ident, $module:ident) => {
667 #[test]
668 fn $name() {
669 let directory = tree();
670 let root = super::$module::Root::new(directory.path());
671 assert!(matches!(
673 root.directory(&["new", "deeper"], false),
674 Err(Refused::Missing)
675 ));
676 let made = root.directory(&["new", "deeper"], true).unwrap();
677 assert!(directory.path().join("new/deeper").is_dir());
678 assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
679 assert!(!directory.path().join("dir/inside").exists());
680 assert!(matches!(
681 root.directory(&["dir", "file.jsonc"], true),
682 Err(Refused::Unexpected)
683 ));
684
685 let dir = root.directory(&["dir"], false).unwrap();
688 let outside = tempfile::tempdir().unwrap();
689 let target = outside.path().join("target");
690 fs::write(&target, "outside").unwrap();
691 symlink(&target, directory.path().join("dir/staged")).unwrap();
692 dir.write("staged", None, b"new").unwrap();
693 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
694 fs::hard_link(&target, directory.path().join("dir/hard")).unwrap();
695 dir.write("hard", None, b"new").unwrap();
696 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
697 {
698 use std::os::unix::fs::PermissionsExt as _;
699 let file = directory.path().join("dir/file.jsonc");
700 fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap();
701 let like = dir.access("file.jsonc").unwrap();
702 assert!(dir.access("nope").unwrap().is_none());
703 dir.write("moded", like.as_ref(), b"x").unwrap();
704 let mode = fs::metadata(directory.path().join("dir/moded"))
705 .unwrap()
706 .permissions()
707 .mode();
708 assert_eq!(mode & 0o777, 0o640);
709 let mut own = dir.own_access().unwrap();
711 own.mode = 0o750;
712 dir.set_own_access(&own).unwrap();
713 assert_eq!(dir.own_access().unwrap(), own);
714 }
715
716 dir.rename("staged", "file.jsonc").unwrap();
717 assert_eq!(
718 fs::read_to_string(directory.path().join("dir/file.jsonc")).unwrap(),
719 "new"
720 );
721 assert!(dir.remove_file("hard").unwrap());
722 assert!(!dir.remove_file("hard").unwrap());
723 assert!(dir.remove_file("file-link.jsonc").unwrap());
724 assert!(target.exists());
725 dir.sync().unwrap();
726 made.sync().unwrap();
727
728 let top = root.directory(&["new"], false).unwrap();
729 fs::write(directory.path().join("new/deeper/x"), "").unwrap();
730 assert!(!top.remove_directory("deeper").unwrap());
731 fs::remove_file(directory.path().join("new/deeper/x")).unwrap();
732 assert!(top.remove_directory("deeper").unwrap());
733 assert!(!top.remove_directory("deeper").unwrap());
734
735 assert!(root.lock().unwrap().is_some());
736 assert!(root.make_directory(&["made"]).unwrap());
738 assert!(!root.make_directory(&["made"]).unwrap());
739 assert!(!root.make_directory(&["dir", "file.jsonc"]).unwrap());
740 assert!(matches!(
741 root.make_directory(&["nope", "deeper"]),
742 Err(Refused::Missing)
743 ));
744 let clone = root.try_clone();
746 assert_eq!(clone.kind(&["dir"]).unwrap(), Found::Directory);
747 }
748 };
749 }
750
751 write_tests!(beneath_writes_beneath_the_root, beneath);
752 write_tests!(portable_writes_beneath_the_root, portable);
753
754 #[cfg(target_os = "linux")]
755 #[test]
756 fn a_written_file_with_access_has_no_inherited_access_control_list() {
757 use std::os::unix::fs::PermissionsExt as _;
758 let directory = tree();
759 let mut acl = 2_u32.to_le_bytes().to_vec();
762 for (tag, permissions, id) in [
763 (0x01_u16, 7_u16, u32::MAX),
764 (0x02, 4, 65534),
765 (0x04, 5, u32::MAX),
766 (0x10, 5, u32::MAX),
767 (0x20, 0, u32::MAX),
768 ] {
769 acl.extend(tag.to_le_bytes());
770 acl.extend(permissions.to_le_bytes());
771 acl.extend(id.to_le_bytes());
772 }
773 let dir_path = directory.path().join("dir");
774 if rustix::fs::setxattr(
775 &dir_path,
776 "system.posix_acl_default",
777 &acl,
778 rustix::fs::XattrFlags::empty(),
779 )
780 .is_err()
781 {
782 return;
784 }
785 let root = super::beneath::Root::new(directory.path());
786 let dir = root.directory(&["dir"], false).unwrap();
787 assert!(dir.own_access().is_err());
789 let mut access = dir.access("file.jsonc").unwrap().unwrap();
790 access.mode = 0o640;
791 dir.write("new", Some(&access), b"x").unwrap();
792 let written = dir_path.join("new");
793 assert_eq!(
794 rustix::fs::getxattr(&written, "system.posix_acl_access", &mut [0_u8; 0][..]),
795 Err(rustix::io::Errno::NODATA)
796 );
797 assert_eq!(
798 fs::metadata(&written).unwrap().permissions().mode() & 0o777,
799 0o640
800 );
801 dir.write("default", None, b"x").unwrap();
803 assert!(
804 rustix::fs::getxattr(
805 dir_path.join("default"),
806 "system.posix_acl_access",
807 &mut [0_u8; 0][..]
808 )
809 .is_ok()
810 );
811 }
812
813 #[cfg(target_os = "linux")]
814 #[test]
815 fn labels_are_compared_and_never_given() {
816 use std::os::unix::fs::MetadataExt as _;
817 let label = |path: &std::path::Path, value: &[u8]| {
818 let metadata = fs::metadata(path).unwrap();
819 super::beneath::GIVEN_LABELS.with(|labels| {
820 labels
821 .borrow_mut()
822 .insert((metadata.dev(), metadata.ino()), value.to_vec())
823 });
824 };
825 let directory = tree();
826 let root = super::beneath::Root::new(directory.path());
827 let dir = root.directory(&["dir"], false).unwrap();
828 let unlabelled = dir.access("file.jsonc").unwrap().unwrap();
829 label(
830 &directory.path().join("dir/file.jsonc"),
831 b"system_u:object_r:secret_t:s0\0",
832 );
833 let labelled = dir.access("file.jsonc").unwrap().unwrap();
834 assert_eq!(labelled.mode, unlabelled.mode);
835 assert_ne!(labelled.control, unlabelled.control);
836 assert_ne!(labelled.audience(false), unlabelled.audience(false));
837 assert_eq!(labelled.new_file().control, labelled.control);
838 assert_eq!(
839 labelled.new_file().user,
840 rustix::process::geteuid().as_raw()
841 );
842 assert!(dir.write("new", Some(&labelled), b"secret").is_err());
845 assert!(!directory.path().join("dir/new").exists());
846 dir.write("new", Some(&unlabelled), b"x").unwrap();
847 label(
849 &directory.path().join("dir"),
850 b"system_u:object_r:secret_t:s0\0",
851 );
852 let own = dir.own_access().unwrap();
853 assert_ne!(own.control, [0; 32]);
854 let made = root.directory(&["made"], true).unwrap();
855 assert!(made.set_own_access(&own).is_err());
856 made.set_own_access(&made.own_access().unwrap()).unwrap();
857 }
858
859 #[cfg(target_os = "linux")]
860 #[test]
861 fn only_local_file_systems_that_show_their_access_control_lists_are_checked() {
862 let directory = tree();
863 let root = super::beneath::Root::new(directory.path());
864 let dir = root.directory(&["dir"], false).unwrap();
865 let mounts = super::beneath::Mounts::read();
869 let told = dir.unchecked_file_system(&mounts).unwrap();
870 if fs::File::open("/proc/self/mountinfo").is_err() {
871 assert!(told.unwrap().starts_with("one that could not be told: "));
875 } else {
876 let id = dir.mount_id().unwrap().unwrap();
877 let found = mounts.type_of(id).unwrap();
878 let expected = super::beneath::unchecked_file_system(found, dir.magic().unwrap());
879 assert_eq!(told, expected);
880 if matches!(found, b"ext4" | b"tmpfs" | b"xfs" | b"btrfs") {
882 assert_eq!(told, None);
883 }
884 }
885 for (found, magic, name) in [
889 ("ext4", 0xEF53, None),
890 ("xfs", 0x5846_5342, None),
891 ("btrfs", 0x9123_683E, None),
892 ("tmpfs", 0x0102_1994, None),
893 ("zfs", 0x2FC1_2FC1, None),
894 ("9p", 0xEF53, Some("9p")),
895 ("overlay", 0x794C_7630, Some("overlay")),
896 ("cifs", 0xFF53_4D42, Some("cifs")),
897 ("vboxsf", 0x786F_4256, Some("vboxsf")),
898 ("fuse.a\tb", 0x6573_5546, Some("fuse.a\\tb")),
899 (
900 "ext4",
901 0x0102_1997,
902 Some("ext4, though statfs gives type 0x1021997"),
903 ),
904 ] {
905 super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(Some((found, magic))));
906 let told = dir.unchecked_file_system(&mounts).unwrap();
907 super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(None));
908 assert_eq!(told.as_deref(), name, "{found} {magic:x}");
909 }
910 }
911
912 #[cfg(target_os = "linux")]
913 #[test]
914 fn a_file_system_that_cannot_be_told_is_not_checked() {
915 use super::mounts::Mounts;
916 let directory = tree();
917 let root = super::beneath::Root::new(directory.path());
918 let dir = root.directory(&["dir"], false).unwrap();
919 let told = |mounts: &Mounts| dir.unchecked_file_system(mounts).unwrap();
920 let real = Mounts::read();
921 if fs::File::open("/proc/self/mountinfo").is_err() {
925 assert!(
926 told(&real)
927 .unwrap()
928 .starts_with("one that could not be told: ")
929 );
930 return;
931 }
932 let id = dir.mount_id().unwrap().unwrap();
933 let line = |id: u64, rest: &str| format!("{id} 1 8:1 / /mnt rw - {rest}\n");
934 for (mounts, reason) in [
938 (
939 Mounts::failed("it is not there"),
940 "it is not there".to_owned(),
941 ),
942 (
943 Mounts::parsed(b""),
944 format!("its mount, {id}, is not in /proc/self/mountinfo"),
945 ),
946 (
947 Mounts::parsed(line(id, "ext\\9 /dev/sda1 rw").as_bytes()),
948 "its line in /proc/self/mountinfo is malformed".to_owned(),
949 ),
950 (
951 Mounts::parsed(
952 format!("{id} 1 8:1 / /{} rw - ext4 x rw\n", "a".repeat(70_000)).as_bytes(),
953 ),
954 "its line in /proc/self/mountinfo is longer than 65536 bytes".to_owned(),
955 ),
956 (
957 Mounts::parsed((line(id, "ext4 a rw") + &line(id, "ext4 b rw")).as_bytes()),
958 format!("its mount, {id}, is listed twice in /proc/self/mountinfo"),
959 ),
960 ] {
961 assert_eq!(
962 told(&mounts),
963 Some(format!("one that could not be told: {reason}"))
964 );
965 }
966 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/nonexistent/fdinfo")));
970 let without_proc = told(&Mounts::failed("it is not there"));
971 let without_fdinfo = told(&real);
972 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
973 assert_eq!(
974 without_proc.as_deref(),
975 Some("one that could not be told: it is not there")
976 );
977 let without_fdinfo = without_fdinfo.unwrap();
978 assert!(
979 without_fdinfo.starts_with(
980 "one that could not be told: statx gives no mount ID, and /proc/self/fdinfo could not be read ("
981 ),
982 "{without_fdinfo}"
983 );
984 let found = real.type_of(id).unwrap().escape_ascii().to_string();
986 let mut table = line(id + 1, "fuse.\u{7f} x rw").into_bytes();
987 table.extend(format!("{} 1 8:1 / /mnt rw - fuse.", id + 2).as_bytes());
988 table.extend([0xFF, b' ', b'x', b'\n']);
989 table.extend(line(id, &format!("{found} x rw")).as_bytes());
990 table.extend(line(id + 3, &"\\040".repeat(400)).as_bytes());
991 assert_eq!(told(&Mounts::parsed(&table)), told(&real));
992 }
993
994 #[cfg(target_os = "linux")]
995 #[test]
996 fn mountinfo_is_read_a_line_at_a_time() {
997 use super::mounts::scan;
998 let text = b"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n\
99936 22 0:32 / /mnt/with\\040space rw - 9p host rw,trans=virtio\n\
100037 22 0:33 / /mnt/\xff\xfe rw master:2 shared:3 - fuse.s\\134sh\xff host rw\n\
100138 22 8:1 / / rw ext4 /dev/sda1 rw\n\
1002x 1 8:1 / / rw - ext4 /dev/sda1 rw\n\
100339 22 8:1 / / rw -\n\
100440 22 8:1 / / rw - ext\\9 /dev/sda1 rw\n\
100541 22 8:1 / / rw - xfs a rw";
1006 let mounts = scan(std::io::BufReader::with_capacity(7, &text[..])).unwrap();
1008 assert_eq!(mounts[&22].as_deref(), Ok(&b"ext4"[..]));
1009 assert_eq!(mounts[&36].as_deref(), Ok(&b"9p"[..]));
1010 assert_eq!(mounts[&37].as_deref(), Ok(&b"fuse.s\\sh\xff"[..]));
1011 assert_eq!(mounts[&41].as_deref(), Ok(&b"xfs"[..]));
1012 for id in [38, 39, 40] {
1013 assert!(mounts[&id].is_err(), "{id}");
1014 }
1015 assert_eq!(mounts.len(), 7);
1017 for gap in [0, 8 << 10] {
1022 let mut long = b"22 1 8:1 / /".to_vec();
1023 long.resize((64 << 10) + gap, b'a');
1024 long.extend(b"99 1 8:1 / / rw - ext4 x rw\n23 1 8:1 / / rw - xfs a rw\n");
1025 for capacity in [4 << 10, 8 << 10, 64 << 10, 1 << 20] {
1026 let reader = std::io::BufReader::with_capacity(capacity, &long[..]);
1027 let mounts = scan(reader).unwrap();
1028 let at = format!("gap {gap}, buffer {capacity}");
1029 assert!(
1030 mounts[&22].as_ref().unwrap_err().contains("longer than"),
1031 "{at}"
1032 );
1033 assert_eq!(mounts[&23].as_deref(), Ok(&b"xfs"[..]), "{at}");
1034 assert_eq!(mounts.keys().copied().collect::<Vec<_>>(), [22, 23], "{at}");
1035 }
1036 }
1037 }
1038
1039 #[cfg(target_os = "linux")]
1040 #[test]
1041 fn fdinfo_gives_the_mount_statx_does() {
1042 use rustix::fs::{AtFlags, StatxFlags, statx};
1043 use std::os::fd::AsRawFd as _;
1044 if !std::path::Path::new("/proc/self/fdinfo").is_dir() {
1045 return;
1046 }
1047 let directory = tree();
1048 let file = fs::File::open(directory.path().join("dir")).unwrap();
1049 let from_statx = match statx(&file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) {
1052 Ok(found) if found.stx_mask & StatxFlags::MNT_ID.bits() != 0 => Some(found.stx_mnt_id),
1053 Ok(_) | Err(rustix::io::Errno::NOSYS) => None,
1054 Err(errno) => panic!("{errno}"),
1055 };
1056 let fdinfo = super::mounts::fdinfo_mount_id("/proc/self/fdinfo", file.as_raw_fd()).unwrap();
1057 assert!(fdinfo.is_some());
1058 if from_statx.is_some() {
1059 assert_eq!(fdinfo, from_statx);
1060 }
1061 let root = super::beneath::Root::new(directory.path());
1064 let dir = root.directory(&["dir"], false).unwrap();
1065 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/proc/self/fdinfo")));
1066 let fallback = dir.mount_id();
1067 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1068 let fallback = fallback.unwrap().unwrap();
1069 assert_eq!(Some(fallback), fdinfo);
1070 if let Some(id) = from_statx {
1071 assert_eq!(fallback, id);
1072 }
1073 }
1074
1075 #[test]
1076 fn the_lock_excludes_a_second_holder() {
1077 let directory = tree();
1078 let root = super::beneath::Root::new(directory.path());
1079 let other = super::beneath::Root::new(directory.path());
1080 let held = root.lock().unwrap();
1081 assert!(held.is_some());
1082 assert!(other.lock().unwrap().is_none());
1083 drop(held);
1084 assert!(other.lock().unwrap().is_some());
1085 }
1086}
1087
1088#[cfg(all(test, target_os = "linux"))]
1089mod selinux_tests {
1090 use super::beneath::selinux::{request, trimmed};
1091
1092 #[test]
1093 fn label_questions_are_one_line_with_the_name_encoded() {
1094 let query = request(
1095 b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023\0",
1096 b"unconfined_u:object_r:user_home_t:s0\0",
1097 7,
1098 "0_0.jsonc",
1099 )
1100 .unwrap();
1101 assert_eq!(
1102 query,
1103 b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 unconfined_u:object_r:user_home_t:s0 7 0_0.jsonc"
1104 );
1105 let odd = request(b"a", b"b", 1, "x y%+\u{e9}").unwrap();
1106 assert_eq!(odd, b"a b 1 x%20y%25%2b%c3%a9");
1107 assert!(request(b"a b", b"c", 1, "x").is_err());
1108 assert!(request(b"", b"c", 1, "x").is_err());
1109 assert_eq!(trimmed(b"label\0\n"), b"label");
1110 assert_eq!(trimmed(b"\0"), b"");
1111 }
1112}
1113
1114#[cfg(all(test, windows))]
1118mod windows_tests {
1119 use super::{Found, RawKind, Refused, portable::Root};
1120 use std::{
1121 fs,
1122 path::{Path, PathBuf},
1123 process::{Command, Stdio},
1124 };
1125
1126 fn junction(link: &Path, target: &Path) {
1128 let native = |path: &Path| path.components().collect::<PathBuf>();
1130 let status = Command::new("cmd")
1131 .args(["/C", "mklink", "/J"])
1132 .arg(native(link))
1133 .arg(native(target))
1134 .stdout(Stdio::null())
1135 .status()
1136 .unwrap();
1137 assert!(status.success());
1138 }
1139
1140 fn link<T>(result: Result<T, Refused>) -> String {
1141 match result {
1142 Err(Refused::Link(prefix)) => prefix,
1143 Err(other) => panic!("expected a link, got {other:?}"),
1144 Ok(_) => panic!("expected a link, got success"),
1145 }
1146 }
1147
1148 #[test]
1149 fn junctions_are_links() {
1150 let directory = tempfile::tempdir().unwrap();
1151 fs::create_dir(directory.path().join("dir")).unwrap();
1152 fs::write(directory.path().join("dir/file.jsonc"), "{}").unwrap();
1153 junction(
1154 &directory.path().join("link"),
1155 &directory.path().join("dir"),
1156 );
1157 let root = Root::new(directory.path());
1158 assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
1159 assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
1160 assert_eq!(link(root.kind(&["link"])), "link");
1161 assert_eq!(link(root.directory(&["link"], false)), "link");
1162 assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
1163 assert!(!directory.path().join("dir/inside").exists());
1164 let kinds: Vec<(Option<String>, RawKind)> = root
1165 .list(&[])
1166 .unwrap()
1167 .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
1168 .collect::<Result<_, _>>()
1169 .unwrap();
1170 assert!(kinds.contains(&(Some("link".to_owned()), RawKind::Link)));
1171 assert!(kinds.contains(&(Some("dir".to_owned()), RawKind::Directory)));
1172
1173 let base = root.directory(&[], false).unwrap();
1175 base.write("link", None, b"x").unwrap();
1176 assert!(directory.path().join("dir/file.jsonc").exists());
1177 assert_eq!(root.kind(&["link"]).unwrap(), Found::File);
1178 }
1179
1180 #[test]
1181 fn read_only_files_are_refused() {
1182 let directory = tempfile::tempdir().unwrap();
1183 let file = directory.path().join("file.jsonc");
1184 fs::write(&file, "{}").unwrap();
1185 let base = Root::new(directory.path()).directory(&[], false).unwrap();
1186 assert!(base.access("file.jsonc").unwrap().is_some());
1187 let mut permissions = fs::metadata(&file).unwrap().permissions();
1188 permissions.set_readonly(true);
1189 fs::set_permissions(&file, permissions).unwrap();
1190 assert!(matches!(base.access("file.jsonc"), Err(Refused::Io(_))));
1191 }
1192
1193 #[test]
1194 fn encrypted_files_and_directories_are_refused() {
1195 let directory = tempfile::tempdir().unwrap();
1196 let inside = directory.path().join("inside");
1197 fs::create_dir(&inside).unwrap();
1198 fs::write(inside.join("file.jsonc"), "{}").unwrap();
1199 let encrypted = Command::new("cipher")
1202 .arg("/e")
1203 .arg("/s:".to_owned() + &inside.display().to_string())
1204 .stdout(Stdio::null())
1205 .status()
1206 .is_ok_and(|status| status.success());
1207 let root = Root::new(directory.path());
1208 let dir = root.directory(&["inside"], false).unwrap();
1209 assert!(
1212 encrypted || std::env::var_os("CI").is_none(),
1213 "cipher /e failed on CI"
1214 );
1215 if encrypted {
1216 assert!(matches!(dir.access("file.jsonc"), Err(Refused::Io(_))));
1217 assert!(matches!(dir.own_access(), Err(Refused::Io(_))));
1218 } else {
1219 assert!(dir.access("file.jsonc").unwrap().is_some());
1220 }
1221 }
1222
1223 #[test]
1224 fn the_lock_is_a_file_held_alone() {
1225 let directory = tempfile::tempdir().unwrap();
1226 let lock_file = directory.path().join(crate::access::LOCK_NAME);
1227 let root = Root::new(directory.path());
1228 let other = Root::new(directory.path());
1229 let held = root.lock().unwrap();
1230 assert!(held.is_some());
1231 assert!(other.lock().unwrap().is_none());
1232 let base = root.directory(&[], false).unwrap();
1234 base.write(".world.jsonc.tilewright-tmp", None, b"{}")
1235 .unwrap();
1236 base.write("world.jsonc", None, b"old").unwrap();
1237 base.rename(".world.jsonc.tilewright-tmp", "world.jsonc")
1238 .unwrap();
1239 drop(held);
1241 assert!(!lock_file.exists());
1242 assert!(other.lock().unwrap().is_some());
1243 fs::write(&lock_file, "someone's").unwrap();
1245 assert!(matches!(root.lock(), Err(Refused::Io(_))));
1246 assert_eq!(fs::read_to_string(&lock_file).unwrap(), "someone's");
1247 }
1248}