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
98#[non_exhaustive]
100#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
101pub struct SourceFile {
102 length: u64,
103 sha256: [u8; 32],
104}
105
106impl SourceFile {
107 #[must_use]
109 pub const fn length(&self) -> u64 {
110 self.length
111 }
112
113 #[must_use]
115 pub const fn sha256(&self) -> [u8; 32] {
116 self.sha256
117 }
118}
119
120pub(crate) struct Tree<'o> {
121 root: platform::Root,
122 options: &'o LoadOptions,
123 overlay: &'o Overlay,
124 files: usize,
125 entries: usize,
126 bytes: u64,
127 digests: BTreeMap<String, SourceFile>,
128 diagnostics: Diagnostics,
129 exhausted: bool,
130}
131
132const PATH_COMPONENTS: usize = 32;
136const PATH_BYTES: usize = 1_024;
137
138pub(crate) type Holding = Result<(), Option<String>>;
141
142#[must_use]
146pub fn is_jsonc_path(path: &str) -> bool {
147 is_safe_relative(path) && path.as_bytes().ends_with(b".jsonc")
148}
149
150pub(crate) fn is_safe_relative(path: &str) -> bool {
153 let components: Vec<&str> = path.split('/').collect();
154 path.len() <= 1024
155 && components.len() <= 32
156 && components.iter().all(|component| {
157 let bytes = component.as_bytes();
158 !bytes.is_empty()
159 && bytes.len() <= 128
160 && (bytes[0].is_ascii_alphanumeric() || bytes[0] == b'_')
161 && bytes
162 .iter()
163 .all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(byte))
164 })
165}
166
167#[cfg(all(test, target_os = "linux"))]
169static NO_OVERLAY: Overlay = BTreeMap::new();
170
171impl<'o> Tree<'o> {
172 #[cfg(all(test, target_os = "linux"))]
173 pub fn new(root: &Path, options: &'o LoadOptions) -> Self {
174 Self::in_root(
175 platform::Root::new(root),
176 options,
177 &NO_OVERLAY,
178 Rc::new(Cell::new(0)),
179 )
180 }
181
182 pub fn in_root(
188 root: platform::Root,
189 options: &'o LoadOptions,
190 overlay: &'o Overlay,
191 total: Rc<Cell<usize>>,
192 ) -> Self {
193 Self {
194 root,
195 options,
196 overlay,
197 files: 0,
198 entries: 0,
199 bytes: 0,
200 digests: BTreeMap::new(),
201 diagnostics: Diagnostics::sharing(options.maximum_diagnostics, total),
202 exhausted: false,
203 }
204 }
205
206 fn exhaust(&mut self, path: &str) {
208 if !self.exhausted {
209 self.exhausted = true;
210 self.diagnostics.push(Diagnostic::file(
211 path,
212 Code::Budget,
213 format!(
214 "the load exceeds {} files, {} directory entries or {} bytes",
215 self.options.maximum_files,
216 self.options.maximum_entries,
217 self.options.maximum_total_bytes
218 ),
219 ));
220 }
221 }
222
223 fn within_budget(&self, length: u64) -> bool {
225 !self.exhausted
226 && self.files < self.options.maximum_files
227 && self.bytes.saturating_add(length) <= self.options.maximum_total_bytes
228 }
229
230 pub fn take_diagnostics(&mut self) -> Diagnostics {
231 let total = self.diagnostics.total();
232 std::mem::replace(
233 &mut self.diagnostics,
234 Diagnostics::sharing(self.options.maximum_diagnostics, total),
235 )
236 }
237
238 fn stopped(&self) -> bool {
240 self.diagnostics.is_full()
241 }
242
243 fn link(&mut self, path: &str) {
245 self.diagnostics.push(Diagnostic::file(
246 path,
247 Code::Symlink,
248 "symbolic links are not followed",
249 ));
250 }
251
252 pub fn kind(&mut self, path: &str) -> Option<EntryKind> {
255 match self.overlay.get(path) {
256 Some(Some(_)) => return Some(EntryKind::File),
257 Some(None) => return None,
258 None => {}
259 }
260 match self.root.kind(&components(path)) {
261 Ok(Found::File) => Some(EntryKind::File),
262 Ok(Found::Directory) => Some(EntryKind::Directory),
263 Ok(Found::Other) => Some(EntryKind::Other),
264 Ok(Found::Missing) | Err(Refused::Missing | Refused::Unexpected) => self
265 .holds_overlay_files(path)
266 .then_some(EntryKind::Directory),
267 Err(Refused::Link(link)) => {
268 self.link(&link);
269 None
270 }
271 Err(Refused::Io(error)) => {
272 self.diagnostics.push(Diagnostic::file(
273 path,
274 Code::Io,
275 format!("cannot read file type: {error}"),
276 ));
277 None
278 }
279 }
280 }
281
282 fn holds_overlay_files(&self, path: &str) -> bool {
284 overlay_beneath(self.overlay, path).any(|(_, value)| value.is_some())
285 }
286
287 pub fn container(&mut self, path: &str) -> bool {
290 match self.kind(path) {
291 Some(EntryKind::Directory) => true,
292 Some(EntryKind::File | EntryKind::Other) => {
293 self.diagnostics.push(Diagnostic::file(
294 path,
295 Code::UnexpectedFile,
296 "must be a directory",
297 ));
298 false
299 }
300 None => false,
301 }
302 }
303
304 pub fn list(&mut self, path: &str) -> Vec<Entry> {
307 self.listing(path, true).0
308 }
309
310 pub fn holds_no_file(&mut self, path: &str) -> Holding {
322 let mut current = path.to_owned();
323 let mut levels: Vec<(Vec<String>, usize)> = Vec::new();
326 loop {
327 if current.len() > PATH_BYTES || current.split('/').count() > PATH_COMPONENTS {
328 return Err(Some(format!(
329 "a directory deeper than a document's path may be, at {current}"
330 )));
331 }
332 let (entries, other) = self.listing(¤t, false);
333 if let Some(other) = other {
334 return Err(Some(other));
335 }
336 let mut names = Vec::with_capacity(entries.len());
337 for entry in entries {
338 if entry.kind != EntryKind::Directory {
339 return Err(None);
340 }
341 names.push(entry.name);
342 }
343 names.reverse();
344 levels.push((names, current.len()));
345 loop {
347 let Some((names, length)) = levels.last_mut() else {
348 return Ok(());
349 };
350 if let Some(name) = names.pop() {
351 current.truncate(*length);
352 current.push('/');
353 current.push_str(&name);
354 break;
355 }
356 levels.pop();
357 }
358 }
359 }
360
361 fn listing(&mut self, path: &str, report: bool) -> (Vec<Entry>, Option<String>) {
367 let cut_short = || Some(format!("a directory not listed in full, at {path}"));
368 if self.stopped() {
369 if report {
370 self.diagnostics.skip();
371 }
372 return (Vec::new(), cut_short());
373 }
374 let mut added: BTreeMap<&str, EntryKind> = BTreeMap::new();
378 let remaining = self.options.maximum_entries.saturating_sub(self.entries);
379 for (rest, value) in overlay_beneath(self.overlay, path) {
380 match (rest.split_once('/'), value) {
381 (None, Some(_)) => {
382 added.insert(rest, EntryKind::File);
383 }
384 (Some((directory, _)), Some(_)) => {
385 added.entry(directory).or_insert(EntryKind::Directory);
386 }
387 (_, None) => {}
388 }
389 if added.len() > remaining {
392 self.exhaust(path);
393 return (Vec::new(), cut_short());
394 }
395 }
396 let (mut entries, mut other) = self.list_disk(path, report);
400 let present: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
401 for (name, kind) in added {
402 if !present.contains(name) {
403 self.entries += 1;
404 if self.entries > self.options.maximum_entries {
405 self.exhaust(path);
406 other = cut_short();
407 break;
408 }
409 entries.push(Entry {
410 name: name.to_owned(),
411 kind,
412 });
413 }
414 }
415 entries.sort_by(|left, right| left.name.cmp(&right.name));
416 (entries, other)
417 }
418
419 fn open_listing(
423 &mut self,
424 path: &str,
425 report: bool,
426 ) -> Result<Option<platform::Listing>, String> {
427 match self.root.list(&components(path)) {
428 Ok(directory) => Ok(Some(directory)),
429 Err(Refused::Link(link)) => {
430 if report {
431 self.link(&link);
432 }
433 Err(format!("a symbolic link, at {link}"))
434 }
435 Err(Refused::Missing) if self.holds_overlay_files(path) => Ok(None),
436 Err(refused) => {
437 let reason = match refused {
438 Refused::Io(error) => error.to_string(),
439 Refused::Missing => "it is missing".to_owned(),
440 Refused::Unexpected | Refused::Link(_) => "not a directory".to_owned(),
441 };
442 if report {
443 self.diagnostics.push(Diagnostic::file(
444 path,
445 Code::Io,
446 format!("cannot list directory: {reason}"),
447 ));
448 }
449 Err(format!("a directory that cannot be listed, at {path}"))
450 }
451 }
452 }
453
454 fn list_disk(&mut self, path: &str, report: bool) -> (Vec<Entry>, Option<String>) {
460 fn first(other: &mut Option<String>, found: String) {
462 if other.as_ref().is_none_or(|other| found < *other) {
463 *other = Some(found);
464 }
465 }
466 let overlay = self.overlay;
467 let directory = match self.open_listing(path, report) {
468 Ok(Some(directory)) => directory,
469 Ok(None) => return (Vec::new(), None),
470 Err(found) => return (Vec::new(), Some(found)),
471 };
472 let mut entries = Vec::new();
473 let mut other = None;
474 for entry in directory {
475 if self.exhausted {
476 first(
477 &mut other,
478 format!("a directory not listed in full, at {path}"),
479 );
480 break;
481 }
482 let entry = match entry {
483 Ok(entry) => entry,
484 Err(error) => {
485 first(
486 &mut other,
487 format!("a directory that cannot be listed in full, at {path}"),
488 );
489 if report {
490 self.diagnostics.push(Diagnostic::file(
491 path,
492 Code::Io,
493 format!("cannot list directory: {error}"),
494 ));
495 }
496 continue;
497 }
498 };
499 if let Some(name) = &entry.name
503 && overlay.contains_key(&join(path, name))
504 {
505 continue;
506 }
507 self.entries += 1;
508 if self.entries > self.options.maximum_entries {
509 self.exhaust(path);
510 first(
511 &mut other,
512 format!("a directory not listed in full, at {path}"),
513 );
514 break;
515 }
516 let Some(name) = entry.name else {
517 first(
518 &mut other,
519 format!("an entry whose name is not UTF-8, in {path}"),
520 );
521 if report {
522 self.diagnostics.push(Diagnostic::file(
523 path,
524 Code::UnexpectedFile,
525 "file name is not UTF-8",
526 ));
527 }
528 continue;
529 };
530 let child = join(path, &name);
531 match entry.kind {
532 RawKind::Link => {
533 first(&mut other, format!("a symbolic link, at {child}"));
534 if report {
535 self.link(&child);
536 }
537 }
538 RawKind::Directory => entries.push(Entry {
539 name,
540 kind: EntryKind::Directory,
541 }),
542 RawKind::File => entries.push(Entry {
543 name,
544 kind: EntryKind::File,
545 }),
546 RawKind::Other => {
547 first(&mut other, format!("a special file, at {child}"));
548 if report {
549 self.diagnostics.push(Diagnostic::file(
550 &child,
551 Code::UnexpectedFile,
552 "not a regular file or directory",
553 ));
554 }
555 }
556 }
557 }
558 (entries, other)
559 }
560
561 pub fn document(&mut self, path: &str) -> Option<Source> {
563 if self.stopped() {
564 self.diagnostics.skip();
565 return None;
566 }
567 match self.overlay.get(path) {
568 Some(Some(text)) => return self.overlay_document(path, text),
569 Some(None) => {
570 self.diagnostics
571 .push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
572 return None;
573 }
574 None => {}
575 }
576 let (file, length) = match self.root.file(&components(path)) {
579 Ok(opened) => opened,
580 Err(Refused::Link(link)) => {
581 self.link(&link);
582 return None;
583 }
584 Err(Refused::Unexpected) => {
585 self.diagnostics.push(Diagnostic::file(
586 path,
587 Code::UnexpectedFile,
588 "expected a regular file",
589 ));
590 return None;
591 }
592 Err(Refused::Missing) => {
593 self.diagnostics
594 .push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
595 return None;
596 }
597 Err(Refused::Io(error)) => {
598 self.diagnostics.push(Diagnostic::file(
599 path,
600 Code::Io,
601 format!("cannot read file: {error}"),
602 ));
603 return None;
604 }
605 };
606 let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
607 if length > limit {
608 self.diagnostics.push(Diagnostic::file(
609 path,
610 ErrorCode::InputTooLarge,
611 format!("file is larger than {limit} bytes"),
612 ));
613 return None;
614 }
615 if !self.within_budget(length) {
616 self.exhaust(path);
617 return None;
618 }
619 let remaining = self.options.maximum_total_bytes.saturating_sub(self.bytes);
623 let mut bytes = Vec::new();
624 let bytes = match file
625 .take(limit.min(remaining).saturating_add(1))
626 .read_to_end(&mut bytes)
627 {
628 Ok(_) => bytes,
629 Err(error) => {
630 self.diagnostics.push(Diagnostic::file(
631 path,
632 Code::Io,
633 format!("cannot read file: {error}"),
634 ));
635 return None;
636 }
637 };
638 let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
639 if length > limit {
640 self.diagnostics.push(Diagnostic::file(
641 path,
642 ErrorCode::InputTooLarge,
643 format!("file is larger than {limit} bytes"),
644 ));
645 return None;
646 }
647 if !self.within_budget(length) {
648 self.exhaust(path);
649 return None;
650 }
651 self.parse(path, bytes)
652 }
653
654 fn overlay_document(&mut self, path: &str, text: &str) -> Option<Source> {
657 let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
658 let length = u64::try_from(text.len()).unwrap_or(u64::MAX);
659 if length > limit {
660 self.diagnostics.push(Diagnostic::file(
661 path,
662 ErrorCode::InputTooLarge,
663 format!("file is larger than {limit} bytes"),
664 ));
665 return None;
666 }
667 if !self.within_budget(length) {
668 self.exhaust(path);
669 return None;
670 }
671 self.parse(path, text.as_bytes().to_vec())
672 }
673
674 fn parse(&mut self, path: &str, bytes: Vec<u8>) -> Option<Source> {
676 let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
677 self.files += 1;
678 self.bytes = self.bytes.saturating_add(length);
679 self.digests.insert(
680 path.to_owned(),
681 SourceFile {
682 length,
683 sha256: Sha256::digest(&bytes).into(),
684 },
685 );
686 let text = match String::from_utf8(bytes) {
687 Ok(text) => text,
688 Err(error) => {
689 self.diagnostics.push(Diagnostic::file(
690 path,
691 Code::Encoding,
692 format!("not UTF-8 at byte {}", error.utf8_error().valid_up_to()),
693 ));
694 return None;
695 }
696 };
697 match Document::parse(text.as_str(), &self.options.limits) {
698 Ok(document) => Some(Source {
699 path: path.to_owned(),
700 document,
701 }),
702 Err(error) => {
703 self.diagnostics.push(Diagnostic::at(
704 path,
705 &Positions::new(&text),
706 error.offset,
707 error.offset,
708 error.code,
709 "invalid JSONC",
710 ));
711 None
712 }
713 }
714 }
715
716 pub fn digest(&self) -> [u8; 32] {
720 let mut hasher = Sha256::new();
721 for (path, file) in &self.digests {
722 hasher.update(path.as_bytes());
723 hasher.update([0]);
724 hasher.update(file.length.to_le_bytes());
725 hasher.update(file.sha256);
726 }
727 hasher.finalize().into()
728 }
729
730 pub fn take_files(&mut self) -> BTreeMap<String, SourceFile> {
732 std::mem::take(&mut self.digests)
733 }
734}
735
736fn overlay_beneath<'a>(
739 overlay: &'a Overlay,
740 path: &str,
741) -> impl Iterator<Item = (&'a str, &'a Option<String>)> + 'a {
742 let prefix = if path.is_empty() {
743 String::new()
744 } else {
745 format!("{path}/")
746 };
747 overlay
748 .range(prefix.clone()..)
749 .map_while(move |(key, value)| key.strip_prefix(prefix.as_str()).map(|rest| (rest, value)))
750}
751
752pub(crate) fn ignored(
764 tree: &mut Tree<'_>,
765 path: &str,
766 entry: &Entry,
767 documents: bool,
768) -> Result<(), String> {
769 let ends = |suffix: &str| {
770 let (name, suffix) = (entry.name.as_bytes(), suffix.as_bytes());
771 name.len() >= suffix.len() && name[name.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
772 };
773 let staging = entry.name.starts_with('.') && ends(".tilewright-tmp");
774 if entry.kind != EntryKind::Directory || (documents && ends(".jsonc")) || staging {
775 return Err(String::new());
776 }
777 tree.holds_no_file(path).map_err(|found| {
778 found.map_or_else(String::new, |found| {
779 format!("; it is not ignored as a directory without files, since it holds {found}")
780 })
781 })
782}
783
784pub(crate) fn join(directory: &str, name: &str) -> String {
785 if directory.is_empty() {
786 name.to_owned()
787 } else {
788 format!("{directory}/{name}")
789 }
790}
791
792#[cfg(test)]
794pub(crate) type Hook = Box<dyn FnMut() -> std::io::Result<()>>;
795
796#[cfg(test)]
797thread_local! {
798 pub(crate) static AFTER_CREATE: std::cell::RefCell<Option<Hook>> =
802 const { std::cell::RefCell::new(None) };
803}
804
805#[cfg(test)]
806thread_local! {
807 pub(crate) static OPEN_FAILS: std::cell::Cell<Option<std::io::ErrorKind>> =
811 const { std::cell::Cell::new(None) };
812}
813
814#[cfg(test)]
816pub(crate) fn open_fails() -> Option<std::io::ErrorKind> {
817 OPEN_FAILS.with(std::cell::Cell::get)
818}
819
820#[cfg(test)]
822pub(crate) fn after_create() -> std::io::Result<()> {
823 AFTER_CREATE.with(|hook| hook.borrow_mut().as_mut().map_or(Ok(()), |hook| hook()))
824}
825
826#[cfg(all(test, unix))]
829mod tests {
830 use super::{Found, RawKind, Refused};
831 use std::{fs, os::unix::fs::symlink};
832
833 #[test]
834 fn an_entry_the_overlay_removes_is_never_counted() {
835 use super::{LoadOptions, Overlay, Tree, platform};
836 use std::{cell::Cell, rc::Rc};
837 let root = tempfile::tempdir().unwrap();
838 for index in 0..10 {
839 fs::write(root.path().join(format!("{index}_0.jsonc")), "{}").unwrap();
840 }
841 let mut listing = fs::read_dir(root.path())
844 .unwrap()
845 .map(|entry| entry.unwrap().file_name().into_string().unwrap());
846 let first = listing.next().unwrap();
847 let overlay: Overlay = listing.map(|name| (name, None)).collect();
848 let options = LoadOptions {
849 maximum_entries: 1,
850 ..LoadOptions::default()
851 };
852 let total = Rc::new(Cell::new(0));
853 let mut tree = Tree::in_root(platform::Root::new(root.path()), &options, &overlay, total);
854 let listed = tree.list("");
855 assert_eq!(listed.len(), 1);
856 assert_eq!(listed[0].name, first);
857 assert!(!tree.exhausted);
858 }
859
860 fn tree() -> tempfile::TempDir {
861 let root = tempfile::tempdir().unwrap();
862 fs::create_dir(root.path().join("dir")).unwrap();
863 fs::write(root.path().join("dir/file.jsonc"), "{}").unwrap();
864 symlink(root.path().join("dir"), root.path().join("link")).unwrap();
865 symlink(
866 root.path().join("dir/file.jsonc"),
867 root.path().join("dir/file-link.jsonc"),
868 )
869 .unwrap();
870 root
871 }
872
873 fn link<T>(result: Result<T, Refused>) -> String {
875 match result {
876 Err(Refused::Link(prefix)) => prefix,
877 Err(other) => panic!("expected a link, got {other:?}"),
878 Ok(_) => panic!("expected a link, got success"),
879 }
880 }
881
882 macro_rules! access_tests {
883 ($name:ident, $module:ident) => {
884 #[test]
885 fn $name() {
886 let directory = tree();
887 let root = super::$module::Root::new(directory.path());
888 assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
889 assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
890 assert_eq!(
891 link(root.file(&["dir", "file-link.jsonc"])),
892 "dir/file-link.jsonc"
893 );
894 assert!(matches!(root.file(&["dir"]), Err(Refused::Unexpected)));
895 assert!(matches!(root.file(&["dir", "nope"]), Err(Refused::Missing)));
896 assert!(matches!(
897 root.file(&["dir", "file.jsonc", "x"]),
898 Err(Refused::Unexpected)
899 ));
900 assert_eq!(root.kind(&["dir"]).unwrap(), Found::Directory);
901 assert_eq!(root.kind(&["dir", "file.jsonc"]).unwrap(), Found::File);
902 assert_eq!(root.kind(&["nope"]).unwrap(), Found::Missing);
903 assert_eq!(link(root.kind(&["link"])), "link");
904 assert_eq!(link(root.list(&["link"])), "link");
905 let mut entries: Vec<(Option<String>, RawKind)> = root
906 .list(&["dir"])
907 .unwrap()
908 .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
909 .collect::<Result<_, _>>()
910 .unwrap();
911 entries.sort_by(|left, right| left.0.cmp(&right.0));
912 assert_eq!(
913 entries,
914 [
915 (Some("file-link.jsonc".to_owned()), RawKind::Link),
916 (Some("file.jsonc".to_owned()), RawKind::File),
917 ]
918 );
919 }
920 };
921 }
922
923 access_tests!(beneath_refuses_links, beneath);
924 access_tests!(portable_refuses_links, portable);
925
926 macro_rules! write_tests {
927 ($name:ident, $module:ident) => {
928 #[test]
929 fn $name() {
930 let directory = tree();
931 let root = super::$module::Root::new(directory.path());
932 assert!(matches!(
934 root.directory(&["new", "deeper"], false),
935 Err(Refused::Missing)
936 ));
937 let made = root.directory(&["new", "deeper"], true).unwrap();
938 assert!(directory.path().join("new/deeper").is_dir());
939 assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
940 assert!(!directory.path().join("dir/inside").exists());
941 assert!(matches!(
942 root.directory(&["dir", "file.jsonc"], true),
943 Err(Refused::Unexpected)
944 ));
945
946 let dir = root.directory(&["dir"], false).unwrap();
949 let outside = tempfile::tempdir().unwrap();
950 let target = outside.path().join("target");
951 fs::write(&target, "outside").unwrap();
952 symlink(&target, directory.path().join("dir/staged")).unwrap();
953 dir.write("staged", None, b"new").unwrap();
954 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
955 fs::hard_link(&target, directory.path().join("dir/hard")).unwrap();
956 dir.write("hard", None, b"new").unwrap();
957 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
958 {
959 use std::os::unix::fs::PermissionsExt as _;
960 let file = directory.path().join("dir/file.jsonc");
961 fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap();
962 let like = dir.access("file.jsonc").unwrap();
963 assert!(dir.access("nope").unwrap().is_none());
964 dir.write("moded", like.as_ref(), b"x").unwrap();
965 let mode = fs::metadata(directory.path().join("dir/moded"))
966 .unwrap()
967 .permissions()
968 .mode();
969 assert_eq!(mode & 0o777, 0o640);
970 let mut own = dir.own_access().unwrap();
972 own.mode = 0o750;
973 dir.set_own_access(&own).unwrap();
974 assert_eq!(dir.own_access().unwrap(), own);
975 }
976
977 dir.rename("staged", "file.jsonc").unwrap();
978 assert_eq!(
979 fs::read_to_string(directory.path().join("dir/file.jsonc")).unwrap(),
980 "new"
981 );
982 assert!(dir.remove_file("hard").unwrap());
983 assert!(!dir.remove_file("hard").unwrap());
984 assert!(dir.remove_file("file-link.jsonc").unwrap());
985 assert!(target.exists());
986 dir.sync().unwrap();
987 made.sync().unwrap();
988
989 let top = root.directory(&["new"], false).unwrap();
990 fs::write(directory.path().join("new/deeper/x"), "").unwrap();
991 assert!(!top.remove_directory("deeper").unwrap());
992 fs::remove_file(directory.path().join("new/deeper/x")).unwrap();
993 assert!(top.remove_directory("deeper").unwrap());
994 assert!(!top.remove_directory("deeper").unwrap());
995
996 assert!(root.lock().unwrap().is_some());
997 assert!(root.make_directory(&["made"]).unwrap());
999 assert!(!root.make_directory(&["made"]).unwrap());
1000 assert!(!root.make_directory(&["dir", "file.jsonc"]).unwrap());
1001 assert!(matches!(
1002 root.make_directory(&["nope", "deeper"]),
1003 Err(Refused::Missing)
1004 ));
1005 let clone = root.try_clone();
1007 assert_eq!(clone.kind(&["dir"]).unwrap(), Found::Directory);
1008 }
1009 };
1010 }
1011
1012 write_tests!(beneath_writes_beneath_the_root, beneath);
1013 write_tests!(portable_writes_beneath_the_root, portable);
1014
1015 fn snapshot(path: &std::path::Path) -> String {
1019 use std::os::unix::fs::MetadataExt as _;
1020 let metadata = fs::symlink_metadata(path).unwrap();
1021 let content = if metadata.file_type().is_symlink() {
1022 format!("-> {}", fs::read_link(path).unwrap().display())
1023 } else if metadata.is_dir() {
1024 let mut names: Vec<_> = fs::read_dir(path)
1025 .unwrap()
1026 .map(|entry| entry.unwrap().file_name())
1027 .collect();
1028 names.sort();
1029 format!("{names:?}")
1030 } else if metadata.is_file() {
1031 format!("{:?}", fs::read(path).unwrap())
1032 } else {
1033 String::new()
1034 };
1035 format!(
1036 "{:?} {} {} {} {:o} {} {} {} {} {content}",
1037 metadata.file_type(),
1038 metadata.dev(),
1039 metadata.ino(),
1040 metadata.nlink(),
1041 metadata.mode(),
1042 metadata.len(),
1043 metadata.mtime(),
1044 metadata.mtime_nsec(),
1045 metadata.ctime_nsec(),
1046 )
1047 }
1048
1049 fn already_there<T: std::fmt::Debug>(result: Result<T, Refused>) -> bool {
1051 type Inner = dyn std::error::Error + Send + Sync;
1052 match result {
1053 Err(Refused::Io(error)) => {
1054 error.kind() == std::io::ErrorKind::AlreadyExists
1055 && error
1056 .get_ref()
1057 .is_some_and(Inner::is::<crate::access::AlreadyThere>)
1058 }
1059 other => panic!("expected something already there, got {other:?}"),
1060 }
1061 }
1062
1063 fn left(error: &std::io::Error) -> bool {
1065 type Inner = dyn std::error::Error + Send + Sync;
1066 error
1067 .get_ref()
1068 .is_some_and(Inner::is::<crate::access::LeftBehind>)
1069 }
1070
1071 struct Hooked;
1073
1074 impl Hooked {
1075 fn new(hook: impl FnMut() -> std::io::Result<()> + 'static) -> Self {
1076 super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
1077 Self
1078 }
1079 }
1080
1081 impl Drop for Hooked {
1082 fn drop(&mut self) {
1083 super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = None);
1084 }
1085 }
1086
1087 macro_rules! create_tests {
1088 ($name:ident, $module:ident) => {
1089 #[test]
1090 fn $name() {
1091 use std::os::unix::fs::PermissionsExt as _;
1092 let directory = tree();
1093 let root = super::$module::Root::new(directory.path());
1094 let dir = root.directory(&["dir"], false).unwrap();
1095 let at = |name: &str| directory.path().join("dir").join(name);
1096
1097 dir.create("new", None, b"bytes").unwrap();
1099 assert_eq!(fs::read(at("new")).unwrap(), b"bytes");
1100 fs::set_permissions(at("file.jsonc"), fs::Permissions::from_mode(0o640)).unwrap();
1101 let like = dir.access("file.jsonc").unwrap().unwrap();
1102 dir.create("moded", Some(&like), b"").unwrap();
1103 assert_eq!(fs::read(at("moded")).unwrap(), b"");
1104 assert_eq!(
1105 fs::metadata(at("moded")).unwrap().permissions().mode() & 0o7777,
1106 0o640
1107 );
1108
1109 let outside = tempfile::tempdir().unwrap();
1114 let target = outside.path().join("target");
1115 fs::write(&target, "outside").unwrap();
1116 fs::create_dir(outside.path().join("elsewhere")).unwrap();
1117 fs::write(at("file"), "keep").unwrap();
1118 fs::hard_link(&target, at("hard")).unwrap();
1119 fs::create_dir(at("empty")).unwrap();
1120 fs::create_dir(at("full")).unwrap();
1121 fs::write(at("full/inside"), "kept").unwrap();
1122 symlink(&target, at("to-file")).unwrap();
1123 symlink(outside.path().join("elsewhere"), at("to-directory")).unwrap();
1124 symlink(outside.path().join("nothing"), at("dangling")).unwrap();
1125 symlink("file", at("relative")).unwrap();
1126 let fifo = std::process::Command::new("mkfifo")
1127 .arg(at("fifo"))
1128 .status()
1129 .unwrap();
1130 assert!(fifo.success());
1131 let names = [
1132 "new",
1133 "file",
1134 "hard",
1135 "empty",
1136 "full",
1137 "to-file",
1138 "to-directory",
1139 "dangling",
1140 "relative",
1141 "fifo",
1142 "file-link.jsonc",
1143 ];
1144 let before: Vec<String> = names.iter().map(|name| snapshot(&at(name))).collect();
1145 let outside_before = snapshot(outside.path());
1146 for name in names {
1147 assert!(already_there(dir.create(name, None, b"x")), "{name}");
1148 assert!(already_there(dir.create(name, Some(&like), b"x")), "{name}");
1149 }
1150 let after: Vec<String> = names.iter().map(|name| snapshot(&at(name))).collect();
1151 assert_eq!(before, after);
1152 assert_eq!(snapshot(outside.path()), outside_before);
1153 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
1154 assert!(!outside.path().join("nothing").exists());
1155
1156 assert_eq!(link(root.directory(&["link"], false)), "link");
1158 }
1159 };
1160 }
1161
1162 macro_rules! create_failure_tests {
1163 ($name:ident, $module:ident, $removes:expr) => {
1164 #[test]
1165 fn $name() {
1166 use std::os::unix::fs::PermissionsExt as _;
1167 let directory = tree();
1168 let root = super::$module::Root::new(directory.path());
1169 let dir = root.directory(&["dir"], false).unwrap();
1170 let at = |name: &str| directory.path().join("dir").join(name);
1171 fs::set_permissions(at("file.jsonc"), fs::Permissions::from_mode(0o640)).unwrap();
1172 let like = dir.access("file.jsonc").unwrap().unwrap();
1173
1174 {
1179 let _hooked = Hooked::new(|| Err(std::io::ErrorKind::StorageFull.into()));
1180 let failed = dir.create("failed", None, b"x");
1181 let Err(Refused::Io(error)) = failed else {
1182 panic!("expected a failure, got {failed:?}");
1183 };
1184 assert_eq!(error.kind(), std::io::ErrorKind::StorageFull);
1185 let removes: bool = $removes;
1186 assert_eq!(at("failed").exists(), !removes);
1187 assert_eq!(error.to_string().contains("may be left"), !removes);
1188 assert_eq!(left(&error), !removes);
1189 }
1190 {
1191 let (moved, name) = (at("moved"), at("swapped"));
1195 let _hooked = Hooked::new(move || {
1196 fs::rename(&name, &moved)?;
1197 fs::write(&name, "someone else's")?;
1198 Err(std::io::ErrorKind::StorageFull.into())
1199 });
1200 let failed = dir.create("swapped", None, b"x");
1201 let Err(Refused::Io(error)) = failed else {
1202 panic!("expected a failure, got {failed:?}");
1203 };
1204 assert!(error.to_string().contains("may be left"), "{error}");
1205 assert!(left(&error));
1206 assert_eq!(fs::read_to_string(at("swapped")).unwrap(), "someone else's");
1207 assert!(at("moved").exists());
1208 }
1209 {
1210 let (moved, name) = (at("moved-ours"), at("linked"));
1213 let _hooked = Hooked::new(move || {
1214 fs::rename(&name, &moved)?;
1215 symlink("moved-ours", &name)?;
1216 Err(std::io::ErrorKind::StorageFull.into())
1217 });
1218 let failed = dir.create("linked", None, b"x");
1219 let Err(Refused::Io(error)) = failed else {
1220 panic!("expected a failure, got {failed:?}");
1221 };
1222 assert!(left(&error), "{error}");
1223 let linked = fs::symlink_metadata(at("linked")).unwrap();
1224 assert!(linked.file_type().is_symlink());
1225 assert!(at("moved-ours").is_file());
1226 }
1227
1228 fs::File::create(at("reference")).unwrap();
1232 let default = fs::metadata(at("reference")).unwrap().permissions().mode() & 0o7777;
1233 assert_ne!(default, 0o600, "a umask that hides the difference");
1234 for (name, access, expected) in [
1235 ("mode-default", None, default),
1236 ("mode-owner", Some(like), 0o600),
1237 ] {
1238 let seen = std::rc::Rc::new(std::cell::Cell::new(None));
1239 let (path, found) = (at(name), seen.clone());
1240 let _hooked = Hooked::new(move || {
1241 let metadata = fs::symlink_metadata(&path)?;
1242 found.set(Some((
1243 metadata.permissions().mode() & 0o7777,
1244 metadata.len(),
1245 )));
1246 Ok(())
1247 });
1248 dir.create(name, access.as_ref(), b"x").unwrap();
1249 assert_eq!(seen.get(), Some((expected, 0)), "{name}");
1250 }
1251 }
1252 };
1253 }
1254
1255 create_tests!(beneath_creates_only_new_files, beneath);
1256 create_tests!(portable_creates_only_new_files, portable);
1257 create_failure_tests!(beneath_removes_only_the_file_it_made, beneath, true);
1258 create_failure_tests!(portable_never_removes_the_file_it_made, portable, false);
1259
1260 fn taken(result: &Result<(), Refused>) -> bool {
1262 type Inner = dyn std::error::Error + Send + Sync;
1263 matches!(result, Err(Refused::Io(error))
1264 if error.get_ref().is_some_and(Inner::is::<crate::access::AlreadyThere>))
1265 }
1266
1267 #[test]
1270 fn a_portable_create_that_cannot_open_is_taken_only_if_something_is_there() {
1271 use std::os::unix::fs::PermissionsExt as _;
1272 let directory = tree();
1273 let root = super::portable::Root::new(directory.path());
1274 let dir = root.directory(&["dir"], false).unwrap();
1275 super::OPEN_FAILS.with(|fails| fails.set(Some(std::io::ErrorKind::PermissionDenied)));
1278 let free = dir.create("free", None, b"x");
1279 let held = dir.create("file.jsonc", None, b"x");
1280 super::OPEN_FAILS.with(|fails| fails.set(None));
1281 assert!(
1282 matches!(&free, Err(Refused::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied),
1283 "{free:?}"
1284 );
1285 assert!(!taken(&free));
1286 assert!(taken(&held), "{held:?}");
1287 fs::create_dir(directory.path().join("gone")).unwrap();
1290 let gone = root.directory(&["gone"], false).unwrap();
1291 fs::remove_dir(directory.path().join("gone")).unwrap();
1292 let missing = gone.create("x", None, b"x");
1293 assert!(missing.is_err() && !taken(&missing), "{missing:?}");
1294 if rustix::process::geteuid().as_raw() != 0 {
1295 let locked = directory.path().join("locked");
1296 fs::create_dir(&locked).unwrap();
1297 fs::set_permissions(&locked, fs::Permissions::from_mode(0o555)).unwrap();
1298 let refused = root
1299 .directory(&["locked"], false)
1300 .unwrap()
1301 .create("x", None, b"x");
1302 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
1303 assert!(refused.is_err() && !taken(&refused), "{refused:?}");
1304 }
1305 }
1306
1307 #[cfg(target_os = "linux")]
1308 #[test]
1309 fn a_written_file_with_access_has_no_inherited_access_control_list() {
1310 use std::os::unix::fs::PermissionsExt as _;
1311 let directory = tree();
1312 let mut acl = 2_u32.to_le_bytes().to_vec();
1315 for (tag, permissions, id) in [
1316 (0x01_u16, 7_u16, u32::MAX),
1317 (0x02, 4, 65534),
1318 (0x04, 5, u32::MAX),
1319 (0x10, 5, u32::MAX),
1320 (0x20, 0, u32::MAX),
1321 ] {
1322 acl.extend(tag.to_le_bytes());
1323 acl.extend(permissions.to_le_bytes());
1324 acl.extend(id.to_le_bytes());
1325 }
1326 let dir_path = directory.path().join("dir");
1327 if rustix::fs::setxattr(
1328 &dir_path,
1329 "system.posix_acl_default",
1330 &acl,
1331 rustix::fs::XattrFlags::empty(),
1332 )
1333 .is_err()
1334 {
1335 return;
1337 }
1338 let root = super::beneath::Root::new(directory.path());
1339 let dir = root.directory(&["dir"], false).unwrap();
1340 assert!(dir.own_access().is_err());
1342 let mut access = dir.access("file.jsonc").unwrap().unwrap();
1343 access.mode = 0o640;
1344 dir.write("new", Some(&access), b"x").unwrap();
1345 let written = dir_path.join("new");
1346 assert_eq!(
1347 rustix::fs::getxattr(&written, "system.posix_acl_access", &mut [0_u8; 0][..]),
1348 Err(rustix::io::Errno::NODATA)
1349 );
1350 assert_eq!(
1351 fs::metadata(&written).unwrap().permissions().mode() & 0o777,
1352 0o640
1353 );
1354 dir.create("created", Some(&access), b"x").unwrap();
1356 assert_eq!(
1357 rustix::fs::getxattr(
1358 dir_path.join("created"),
1359 "system.posix_acl_access",
1360 &mut [0_u8; 0][..]
1361 ),
1362 Err(rustix::io::Errno::NODATA)
1363 );
1364 dir.write("default", None, b"x").unwrap();
1366 assert!(
1367 rustix::fs::getxattr(
1368 dir_path.join("default"),
1369 "system.posix_acl_access",
1370 &mut [0_u8; 0][..]
1371 )
1372 .is_ok()
1373 );
1374 }
1375
1376 #[cfg(target_os = "linux")]
1377 #[test]
1378 fn labels_are_compared_and_never_given() {
1379 use std::os::unix::fs::MetadataExt as _;
1380 let label = |path: &std::path::Path, value: &[u8]| {
1381 let metadata = fs::metadata(path).unwrap();
1382 super::beneath::GIVEN_LABELS.with(|labels| {
1383 labels
1384 .borrow_mut()
1385 .insert((metadata.dev(), metadata.ino()), value.to_vec())
1386 });
1387 };
1388 let directory = tree();
1389 let root = super::beneath::Root::new(directory.path());
1390 let dir = root.directory(&["dir"], false).unwrap();
1391 let unlabelled = dir.access("file.jsonc").unwrap().unwrap();
1392 label(
1393 &directory.path().join("dir/file.jsonc"),
1394 b"system_u:object_r:secret_t:s0\0",
1395 );
1396 let labelled = dir.access("file.jsonc").unwrap().unwrap();
1397 assert_eq!(labelled.mode, unlabelled.mode);
1398 assert_ne!(labelled.control, unlabelled.control);
1399 assert_ne!(labelled.audience(false), unlabelled.audience(false));
1400 assert_eq!(labelled.new_file().control, labelled.control);
1401 assert_eq!(
1402 labelled.new_file().user,
1403 rustix::process::geteuid().as_raw()
1404 );
1405 assert!(dir.write("new", Some(&labelled), b"secret").is_err());
1408 assert!(!directory.path().join("dir/new").exists());
1409 dir.write("new", Some(&unlabelled), b"x").unwrap();
1410 let refused = dir.create("created", Some(&labelled), b"secret");
1412 assert!(
1413 matches!(&refused, Err(Refused::Io(error)) if !error.to_string().contains("may be left"))
1414 );
1415 assert!(!directory.path().join("dir/created").exists());
1416 dir.create("created", Some(&unlabelled), b"x").unwrap();
1417 let (made, moved) = (
1420 directory.path().join("dir/labelled"),
1421 directory.path().join("dir/moved-labelled"),
1422 );
1423 let moving = moved.clone();
1424 super::AFTER_CREATE.with(|slot| {
1425 *slot.borrow_mut() = Some(Box::new(move || fs::rename(&made, &moving)));
1426 });
1427 let refused = dir.create("labelled", Some(&labelled), b"secret");
1428 super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = None);
1429 assert!(
1430 matches!(&refused, Err(Refused::Io(error)) if left(error)),
1431 "{refused:?}"
1432 );
1433 assert_eq!(fs::read(&moved).unwrap(), b"");
1434 label(
1436 &directory.path().join("dir"),
1437 b"system_u:object_r:secret_t:s0\0",
1438 );
1439 let own = dir.own_access().unwrap();
1440 assert_ne!(own.control, [0; 32]);
1441 let made = root.directory(&["made"], true).unwrap();
1442 assert!(made.set_own_access(&own).is_err());
1443 made.set_own_access(&made.own_access().unwrap()).unwrap();
1444 }
1445
1446 #[cfg(target_os = "linux")]
1447 #[test]
1448 fn only_local_file_systems_that_show_their_access_control_lists_are_checked() {
1449 let directory = tree();
1450 let root = super::beneath::Root::new(directory.path());
1451 let dir = root.directory(&["dir"], false).unwrap();
1452 let mounts = super::beneath::Mounts::read();
1456 let told = dir.unchecked_file_system(&mounts).unwrap();
1457 if fs::File::open("/proc/self/mountinfo").is_err() {
1458 assert!(told.unwrap().starts_with("one that could not be told: "));
1462 } else {
1463 let id = dir.mount_id().unwrap().unwrap();
1464 let found = mounts.type_of(id).unwrap();
1465 let expected = super::beneath::unchecked_file_system(found, dir.magic().unwrap());
1466 assert_eq!(told, expected);
1467 if matches!(found, b"ext4" | b"tmpfs" | b"xfs" | b"btrfs") {
1469 assert_eq!(told, None);
1470 }
1471 }
1472 for (found, magic, name) in [
1476 ("ext4", 0xEF53, None),
1477 ("xfs", 0x5846_5342, None),
1478 ("btrfs", 0x9123_683E, None),
1479 ("tmpfs", 0x0102_1994, None),
1480 ("zfs", 0x2FC1_2FC1, None),
1481 ("9p", 0xEF53, Some("9p")),
1482 ("overlay", 0x794C_7630, Some("overlay")),
1483 ("cifs", 0xFF53_4D42, Some("cifs")),
1484 ("vboxsf", 0x786F_4256, Some("vboxsf")),
1485 ("fuse.a\tb", 0x6573_5546, Some("fuse.a\\tb")),
1486 (
1487 "ext4",
1488 0x0102_1997,
1489 Some("ext4, though statfs gives type 0x1021997"),
1490 ),
1491 ] {
1492 super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(Some((found, magic))));
1493 let told = dir.unchecked_file_system(&mounts).unwrap();
1494 super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(None));
1495 assert_eq!(told.as_deref(), name, "{found} {magic:x}");
1496 }
1497 }
1498
1499 #[cfg(target_os = "linux")]
1500 #[test]
1501 fn a_file_system_that_cannot_be_told_is_not_checked() {
1502 use super::mounts::Mounts;
1503 let directory = tree();
1504 let root = super::beneath::Root::new(directory.path());
1505 let dir = root.directory(&["dir"], false).unwrap();
1506 let told = |mounts: &Mounts| dir.unchecked_file_system(mounts).unwrap();
1507 let real = Mounts::read();
1508 if fs::File::open("/proc/self/mountinfo").is_err() {
1512 assert!(
1513 told(&real)
1514 .unwrap()
1515 .starts_with("one that could not be told: ")
1516 );
1517 return;
1518 }
1519 let id = dir.mount_id().unwrap().unwrap();
1520 let line = |id: u64, rest: &str| format!("{id} 1 8:1 / /mnt rw - {rest}\n");
1521 for (mounts, reason) in [
1525 (
1526 Mounts::failed("it is not there"),
1527 "it is not there".to_owned(),
1528 ),
1529 (
1530 Mounts::parsed(b""),
1531 format!("its mount, {id}, is not in /proc/self/mountinfo"),
1532 ),
1533 (
1534 Mounts::parsed(line(id, "ext\\9 /dev/sda1 rw").as_bytes()),
1535 "its line in /proc/self/mountinfo is malformed".to_owned(),
1536 ),
1537 (
1538 Mounts::parsed(
1539 format!("{id} 1 8:1 / /{} rw - ext4 x rw\n", "a".repeat(70_000)).as_bytes(),
1540 ),
1541 "its line in /proc/self/mountinfo is longer than 65536 bytes".to_owned(),
1542 ),
1543 (
1544 Mounts::parsed((line(id, "ext4 a rw") + &line(id, "ext4 b rw")).as_bytes()),
1545 format!("its mount, {id}, is listed twice in /proc/self/mountinfo"),
1546 ),
1547 ] {
1548 assert_eq!(
1549 told(&mounts),
1550 Some(format!("one that could not be told: {reason}"))
1551 );
1552 }
1553 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/nonexistent/fdinfo")));
1557 let without_proc = told(&Mounts::failed("it is not there"));
1558 let without_fdinfo = told(&real);
1559 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1560 assert_eq!(
1561 without_proc.as_deref(),
1562 Some("one that could not be told: it is not there")
1563 );
1564 let without_fdinfo = without_fdinfo.unwrap();
1565 assert!(
1566 without_fdinfo.starts_with(
1567 "one that could not be told: statx gives no mount ID, and /proc/self/fdinfo could not be read ("
1568 ),
1569 "{without_fdinfo}"
1570 );
1571 let found = real.type_of(id).unwrap().escape_ascii().to_string();
1573 let mut table = line(id + 1, "fuse.\u{7f} x rw").into_bytes();
1574 table.extend(format!("{} 1 8:1 / /mnt rw - fuse.", id + 2).as_bytes());
1575 table.extend([0xFF, b' ', b'x', b'\n']);
1576 table.extend(line(id, &format!("{found} x rw")).as_bytes());
1577 table.extend(line(id + 3, &"\\040".repeat(400)).as_bytes());
1578 assert_eq!(told(&Mounts::parsed(&table)), told(&real));
1579 }
1580
1581 #[cfg(target_os = "linux")]
1582 #[test]
1583 fn mountinfo_is_read_a_line_at_a_time() {
1584 use super::mounts::scan;
1585 let text = b"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n\
158636 22 0:32 / /mnt/with\\040space rw - 9p host rw,trans=virtio\n\
158737 22 0:33 / /mnt/\xff\xfe rw master:2 shared:3 - fuse.s\\134sh\xff host rw\n\
158838 22 8:1 / / rw ext4 /dev/sda1 rw\n\
1589x 1 8:1 / / rw - ext4 /dev/sda1 rw\n\
159039 22 8:1 / / rw -\n\
159140 22 8:1 / / rw - ext\\9 /dev/sda1 rw\n\
159241 22 8:1 / / rw - xfs a rw";
1593 let mounts = scan(std::io::BufReader::with_capacity(7, &text[..])).unwrap();
1595 assert_eq!(mounts[&22].as_deref(), Ok(&b"ext4"[..]));
1596 assert_eq!(mounts[&36].as_deref(), Ok(&b"9p"[..]));
1597 assert_eq!(mounts[&37].as_deref(), Ok(&b"fuse.s\\sh\xff"[..]));
1598 assert_eq!(mounts[&41].as_deref(), Ok(&b"xfs"[..]));
1599 for id in [38, 39, 40] {
1600 assert!(mounts[&id].is_err(), "{id}");
1601 }
1602 assert_eq!(mounts.len(), 7);
1604 for gap in [0, 8 << 10] {
1609 let mut long = b"22 1 8:1 / /".to_vec();
1610 long.resize((64 << 10) + gap, b'a');
1611 long.extend(b"99 1 8:1 / / rw - ext4 x rw\n23 1 8:1 / / rw - xfs a rw\n");
1612 for capacity in [4 << 10, 8 << 10, 64 << 10, 1 << 20] {
1613 let reader = std::io::BufReader::with_capacity(capacity, &long[..]);
1614 let mounts = scan(reader).unwrap();
1615 let at = format!("gap {gap}, buffer {capacity}");
1616 assert!(
1617 mounts[&22].as_ref().unwrap_err().contains("longer than"),
1618 "{at}"
1619 );
1620 assert_eq!(mounts[&23].as_deref(), Ok(&b"xfs"[..]), "{at}");
1621 assert_eq!(mounts.keys().copied().collect::<Vec<_>>(), [22, 23], "{at}");
1622 }
1623 }
1624 }
1625
1626 #[cfg(target_os = "linux")]
1627 #[test]
1628 fn fdinfo_gives_the_mount_statx_does() {
1629 use rustix::fs::{AtFlags, StatxFlags, statx};
1630 use std::os::fd::AsRawFd as _;
1631 if !std::path::Path::new("/proc/self/fdinfo").is_dir() {
1632 return;
1633 }
1634 let directory = tree();
1635 let file = fs::File::open(directory.path().join("dir")).unwrap();
1636 let from_statx = match statx(&file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) {
1639 Ok(found) if found.stx_mask & StatxFlags::MNT_ID.bits() != 0 => Some(found.stx_mnt_id),
1640 Ok(_) | Err(rustix::io::Errno::NOSYS) => None,
1641 Err(errno) => panic!("{errno}"),
1642 };
1643 let fdinfo = super::mounts::fdinfo_mount_id("/proc/self/fdinfo", file.as_raw_fd()).unwrap();
1644 assert!(fdinfo.is_some());
1645 if from_statx.is_some() {
1646 assert_eq!(fdinfo, from_statx);
1647 }
1648 let root = super::beneath::Root::new(directory.path());
1651 let dir = root.directory(&["dir"], false).unwrap();
1652 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/proc/self/fdinfo")));
1653 let fallback = dir.mount_id();
1654 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1655 let fallback = fallback.unwrap().unwrap();
1656 assert_eq!(Some(fallback), fdinfo);
1657 if let Some(id) = from_statx {
1658 assert_eq!(fallback, id);
1659 }
1660 }
1661
1662 #[test]
1663 fn the_lock_excludes_a_second_holder() {
1664 let directory = tree();
1665 let root = super::beneath::Root::new(directory.path());
1666 let other = super::beneath::Root::new(directory.path());
1667 let held = root.lock().unwrap();
1668 assert!(held.is_some());
1669 assert!(other.lock().unwrap().is_none());
1670 drop(held);
1671 assert!(other.lock().unwrap().is_some());
1672 }
1673}
1674
1675#[cfg(all(test, target_os = "linux"))]
1676mod selinux_tests {
1677 use super::beneath::selinux::{request, trimmed};
1678
1679 #[test]
1680 fn label_questions_are_one_line_with_the_name_encoded() {
1681 let query = request(
1682 b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023\0",
1683 b"unconfined_u:object_r:user_home_t:s0\0",
1684 7,
1685 "0_0.jsonc",
1686 )
1687 .unwrap();
1688 assert_eq!(
1689 query,
1690 b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 unconfined_u:object_r:user_home_t:s0 7 0_0.jsonc"
1691 );
1692 let odd = request(b"a", b"b", 1, "x y%+\u{e9}").unwrap();
1693 assert_eq!(odd, b"a b 1 x%20y%25%2b%c3%a9");
1694 assert!(request(b"a b", b"c", 1, "x").is_err());
1695 assert!(request(b"", b"c", 1, "x").is_err());
1696 assert_eq!(trimmed(b"label\0\n"), b"label");
1697 assert_eq!(trimmed(b"\0"), b"");
1698 }
1699}
1700
1701#[cfg(all(test, windows))]
1705mod windows_tests {
1706 use super::{Found, RawKind, Refused, portable::Root};
1707 use std::{
1708 fs,
1709 path::{Path, PathBuf},
1710 process::{Command, Stdio},
1711 };
1712
1713 fn junction(link: &Path, target: &Path) {
1715 let native = |path: &Path| path.components().collect::<PathBuf>();
1717 let status = Command::new("cmd")
1718 .args(["/C", "mklink", "/J"])
1719 .arg(native(link))
1720 .arg(native(target))
1721 .stdout(Stdio::null())
1722 .status()
1723 .unwrap();
1724 assert!(status.success());
1725 }
1726
1727 fn link<T>(result: Result<T, Refused>) -> String {
1728 match result {
1729 Err(Refused::Link(prefix)) => prefix,
1730 Err(other) => panic!("expected a link, got {other:?}"),
1731 Ok(_) => panic!("expected a link, got success"),
1732 }
1733 }
1734
1735 #[test]
1736 fn junctions_are_links() {
1737 let directory = tempfile::tempdir().unwrap();
1738 fs::create_dir(directory.path().join("dir")).unwrap();
1739 fs::write(directory.path().join("dir/file.jsonc"), "{}").unwrap();
1740 junction(
1741 &directory.path().join("link"),
1742 &directory.path().join("dir"),
1743 );
1744 let root = Root::new(directory.path());
1745 assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
1746 assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
1747 assert_eq!(link(root.kind(&["link"])), "link");
1748 assert_eq!(link(root.directory(&["link"], false)), "link");
1749 assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
1750 assert!(!directory.path().join("dir/inside").exists());
1751 let kinds: Vec<(Option<String>, RawKind)> = root
1752 .list(&[])
1753 .unwrap()
1754 .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
1755 .collect::<Result<_, _>>()
1756 .unwrap();
1757 assert!(kinds.contains(&(Some("link".to_owned()), RawKind::Link)));
1758 assert!(kinds.contains(&(Some("dir".to_owned()), RawKind::Directory)));
1759
1760 let base = root.directory(&[], false).unwrap();
1762 base.write("link", None, b"x").unwrap();
1763 assert!(directory.path().join("dir/file.jsonc").exists());
1764 assert_eq!(root.kind(&["link"]).unwrap(), Found::File);
1765 }
1766
1767 #[test]
1768 fn read_only_files_are_refused() {
1769 let directory = tempfile::tempdir().unwrap();
1770 let file = directory.path().join("file.jsonc");
1771 fs::write(&file, "{}").unwrap();
1772 let base = Root::new(directory.path()).directory(&[], false).unwrap();
1773 assert!(base.access("file.jsonc").unwrap().is_some());
1774 let mut permissions = fs::metadata(&file).unwrap().permissions();
1775 permissions.set_readonly(true);
1776 fs::set_permissions(&file, permissions).unwrap();
1777 assert!(matches!(base.access("file.jsonc"), Err(Refused::Io(_))));
1778 }
1779
1780 #[test]
1781 fn encrypted_files_and_directories_are_refused() {
1782 let directory = tempfile::tempdir().unwrap();
1783 let inside = directory.path().join("inside");
1784 fs::create_dir(&inside).unwrap();
1785 fs::write(inside.join("file.jsonc"), "{}").unwrap();
1786 let encrypted = Command::new("cipher")
1789 .arg("/e")
1790 .arg("/s:".to_owned() + &inside.display().to_string())
1791 .stdout(Stdio::null())
1792 .status()
1793 .is_ok_and(|status| status.success());
1794 let root = Root::new(directory.path());
1795 let dir = root.directory(&["inside"], false).unwrap();
1796 assert!(
1799 encrypted || std::env::var_os("CI").is_none(),
1800 "cipher /e failed on CI"
1801 );
1802 if encrypted {
1803 assert!(matches!(dir.access("file.jsonc"), Err(Refused::Io(_))));
1804 assert!(matches!(dir.own_access(), Err(Refused::Io(_))));
1805 } else {
1806 assert!(dir.access("file.jsonc").unwrap().is_some());
1807 }
1808 }
1809
1810 #[test]
1811 fn the_lock_is_a_file_held_alone() {
1812 let directory = tempfile::tempdir().unwrap();
1813 let lock_file = directory.path().join(crate::access::LOCK_NAME);
1814 let root = Root::new(directory.path());
1815 let other = Root::new(directory.path());
1816 let held = root.lock().unwrap();
1817 assert!(held.is_some());
1818 assert!(other.lock().unwrap().is_none());
1819 let base = root.directory(&[], false).unwrap();
1821 base.write(".world.jsonc.tilewright-tmp", None, b"{}")
1822 .unwrap();
1823 base.write("world.jsonc", None, b"old").unwrap();
1824 base.rename(".world.jsonc.tilewright-tmp", "world.jsonc")
1825 .unwrap();
1826 drop(held);
1828 assert!(!lock_file.exists());
1829 assert!(other.lock().unwrap().is_some());
1830 fs::write(&lock_file, "someone's").unwrap();
1832 assert!(matches!(root.lock(), Err(Refused::Io(_))));
1833 assert_eq!(fs::read_to_string(&lock_file).unwrap(), "someone's");
1834 }
1835
1836 fn snapshot(path: &Path) -> String {
1840 use std::os::windows::fs::MetadataExt as _;
1841 let metadata = fs::symlink_metadata(path).unwrap();
1842 let reparse = metadata.file_attributes() & 0x400 != 0;
1843 let content = if reparse {
1844 String::new()
1845 } else if metadata.is_dir() {
1846 let mut names: Vec<_> = fs::read_dir(path)
1847 .unwrap()
1848 .map(|entry| entry.unwrap().file_name())
1849 .collect();
1850 names.sort();
1851 format!("{names:?}")
1852 } else {
1853 format!("{:?}", fs::read(path).unwrap())
1854 };
1855 format!(
1856 "{:x} {} {} {} {content}",
1857 metadata.file_attributes(),
1858 metadata.file_size(),
1859 metadata.creation_time(),
1860 metadata.last_write_time(),
1861 )
1862 }
1863
1864 fn already_there(result: Result<(), Refused>) -> bool {
1865 type Inner = dyn std::error::Error + Send + Sync;
1866 match result {
1867 Err(Refused::Io(error)) => {
1868 error.kind() == std::io::ErrorKind::AlreadyExists
1869 && error
1870 .get_ref()
1871 .is_some_and(Inner::is::<crate::access::AlreadyThere>)
1872 }
1873 other => panic!("expected something already there, got {other:?}"),
1874 }
1875 }
1876
1877 fn hook(hook: impl FnMut() -> std::io::Result<()> + 'static) {
1878 super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
1879 }
1880
1881 fn unhook() {
1882 super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = None);
1883 }
1884
1885 #[test]
1886 fn a_create_never_replaces_or_removes_an_entry() {
1887 let directory = tempfile::tempdir().unwrap();
1888 let outside = tempfile::tempdir().unwrap();
1889 let at = |name: &str| directory.path().join(name);
1890 let root = Root::new(directory.path());
1891 let base = root.directory(&[], false).unwrap();
1892
1893 base.create("new", None, b"bytes").unwrap();
1896 assert_eq!(fs::read(at("new")).unwrap(), b"bytes");
1897 let read_only = crate::Access {
1898 mode: 0o444,
1899 user: 0,
1900 group: 0,
1901 control: [0; 32],
1902 };
1903 base.create("read-only", Some(&read_only), b"x").unwrap();
1904 assert!(
1905 fs::metadata(at("read-only"))
1906 .unwrap()
1907 .permissions()
1908 .readonly()
1909 );
1910
1911 fs::write(at("file"), "keep").unwrap();
1915 fs::create_dir(at("empty")).unwrap();
1916 fs::create_dir(at("full")).unwrap();
1917 fs::write(at("full/inside"), "kept").unwrap();
1918 fs::create_dir(outside.path().join("elsewhere")).unwrap();
1919 junction(&at("junction"), &outside.path().join("elsewhere"));
1920 fs::create_dir(outside.path().join("gone")).unwrap();
1921 junction(&at("dangling"), &outside.path().join("gone"));
1922 fs::remove_dir(outside.path().join("gone")).unwrap();
1923 let target = outside.path().join("target");
1924 fs::write(&target, "outside").unwrap();
1925 let mut names = vec![
1926 "new",
1927 "read-only",
1928 "file",
1929 "empty",
1930 "full",
1931 "junction",
1932 "dangling",
1933 ];
1934 let linked = std::os::windows::fs::symlink_file(&target, at("to-file")).is_ok();
1936 assert!(
1937 linked || std::env::var_os("CI").is_none(),
1938 "symlink_file failed on CI"
1939 );
1940 if linked {
1941 names.push("to-file");
1942 }
1943 let before: Vec<String> = names.iter().map(|name| snapshot(&at(name))).collect();
1944 for name in &names {
1945 assert!(already_there(base.create(name, None, b"x")), "{name}");
1946 assert!(
1947 already_there(base.create(name, Some(&read_only), b"x")),
1948 "{name}"
1949 );
1950 }
1951 let after: Vec<String> = names.iter().map(|name| snapshot(&at(name))).collect();
1952 assert_eq!(before, after);
1953 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
1954 assert!(!outside.path().join("gone").exists());
1955 assert_eq!(
1956 fs::read_dir(outside.path().join("elsewhere"))
1957 .unwrap()
1958 .count(),
1959 0
1960 );
1961
1962 hook(|| Err(std::io::ErrorKind::StorageFull.into()));
1965 let failed = base.create("failed", None, b"x");
1966 unhook();
1967 let Err(Refused::Io(error)) = failed else {
1968 panic!("expected a failure, got {failed:?}");
1969 };
1970 assert_eq!(error.kind(), std::io::ErrorKind::StorageFull);
1971 assert!(error.to_string().contains("may be left"), "{error}");
1972 assert!(at("failed").exists());
1973 let (moved, name) = (at("moved"), at("swapped"));
1975 hook(move || {
1976 fs::rename(&name, &moved)?;
1977 fs::write(&name, "someone else's")?;
1978 Err(std::io::ErrorKind::StorageFull.into())
1979 });
1980 let failed = base.create("swapped", None, b"x");
1981 unhook();
1982 assert!(matches!(failed, Err(Refused::Io(_))), "{failed:?}");
1983 assert_eq!(fs::read_to_string(at("swapped")).unwrap(), "someone else's");
1984 assert!(at("moved").exists());
1985 }
1986
1987 #[test]
1991 fn a_create_that_cannot_open_is_taken_only_if_something_is_there() {
1992 type Inner = dyn std::error::Error + Send + Sync;
1993 let directory = tempfile::tempdir().unwrap();
1994 let at = |name: &str| directory.path().join(name);
1995 let root = Root::new(directory.path());
1996 let base = root.directory(&[], false).unwrap();
1997 fs::write(at("file"), "keep").unwrap();
1998 super::OPEN_FAILS.with(|fails| fails.set(Some(std::io::ErrorKind::PermissionDenied)));
1999 let free = base.create("free", None, b"x");
2000 let held = base.create("file", None, b"x");
2001 super::OPEN_FAILS.with(|fails| fails.set(None));
2002 let taken = |result: &Result<(), Refused>| {
2003 matches!(result, Err(Refused::Io(error))
2004 if error.get_ref().is_some_and(Inner::is::<crate::access::AlreadyThere>))
2005 };
2006 assert!(
2007 matches!(&free, Err(Refused::Io(_))) && !taken(&free),
2008 "{free:?}"
2009 );
2010 assert!(taken(&held), "{held:?}");
2011 fs::create_dir(at("gone")).unwrap();
2012 let gone = root.directory(&["gone"], false).unwrap();
2013 fs::remove_dir(at("gone")).unwrap();
2014 let missing = gone.create("x", None, b"x");
2015 assert!(missing.is_err() && !taken(&missing), "{missing:?}");
2016 }
2017}