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(all(test, unix))]
795mod tests {
796 use super::{Found, RawKind, Refused};
797 use std::{fs, os::unix::fs::symlink};
798
799 #[test]
800 fn an_entry_the_overlay_removes_is_never_counted() {
801 use super::{LoadOptions, Overlay, Tree, platform};
802 use std::{cell::Cell, rc::Rc};
803 let root = tempfile::tempdir().unwrap();
804 for index in 0..10 {
805 fs::write(root.path().join(format!("{index}_0.jsonc")), "{}").unwrap();
806 }
807 let mut listing = fs::read_dir(root.path())
810 .unwrap()
811 .map(|entry| entry.unwrap().file_name().into_string().unwrap());
812 let first = listing.next().unwrap();
813 let overlay: Overlay = listing.map(|name| (name, None)).collect();
814 let options = LoadOptions {
815 maximum_entries: 1,
816 ..LoadOptions::default()
817 };
818 let total = Rc::new(Cell::new(0));
819 let mut tree = Tree::in_root(platform::Root::new(root.path()), &options, &overlay, total);
820 let listed = tree.list("");
821 assert_eq!(listed.len(), 1);
822 assert_eq!(listed[0].name, first);
823 assert!(!tree.exhausted);
824 }
825
826 fn tree() -> tempfile::TempDir {
827 let root = tempfile::tempdir().unwrap();
828 fs::create_dir(root.path().join("dir")).unwrap();
829 fs::write(root.path().join("dir/file.jsonc"), "{}").unwrap();
830 symlink(root.path().join("dir"), root.path().join("link")).unwrap();
831 symlink(
832 root.path().join("dir/file.jsonc"),
833 root.path().join("dir/file-link.jsonc"),
834 )
835 .unwrap();
836 root
837 }
838
839 fn link<T>(result: Result<T, Refused>) -> String {
841 match result {
842 Err(Refused::Link(prefix)) => prefix,
843 Err(other) => panic!("expected a link, got {other:?}"),
844 Ok(_) => panic!("expected a link, got success"),
845 }
846 }
847
848 macro_rules! access_tests {
849 ($name:ident, $module:ident) => {
850 #[test]
851 fn $name() {
852 let directory = tree();
853 let root = super::$module::Root::new(directory.path());
854 assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
855 assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
856 assert_eq!(
857 link(root.file(&["dir", "file-link.jsonc"])),
858 "dir/file-link.jsonc"
859 );
860 assert!(matches!(root.file(&["dir"]), Err(Refused::Unexpected)));
861 assert!(matches!(root.file(&["dir", "nope"]), Err(Refused::Missing)));
862 assert!(matches!(
863 root.file(&["dir", "file.jsonc", "x"]),
864 Err(Refused::Unexpected)
865 ));
866 assert_eq!(root.kind(&["dir"]).unwrap(), Found::Directory);
867 assert_eq!(root.kind(&["dir", "file.jsonc"]).unwrap(), Found::File);
868 assert_eq!(root.kind(&["nope"]).unwrap(), Found::Missing);
869 assert_eq!(link(root.kind(&["link"])), "link");
870 assert_eq!(link(root.list(&["link"])), "link");
871 let mut entries: Vec<(Option<String>, RawKind)> = root
872 .list(&["dir"])
873 .unwrap()
874 .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
875 .collect::<Result<_, _>>()
876 .unwrap();
877 entries.sort_by(|left, right| left.0.cmp(&right.0));
878 assert_eq!(
879 entries,
880 [
881 (Some("file-link.jsonc".to_owned()), RawKind::Link),
882 (Some("file.jsonc".to_owned()), RawKind::File),
883 ]
884 );
885 }
886 };
887 }
888
889 access_tests!(beneath_refuses_links, beneath);
890 access_tests!(portable_refuses_links, portable);
891
892 macro_rules! write_tests {
893 ($name:ident, $module:ident) => {
894 #[test]
895 fn $name() {
896 let directory = tree();
897 let root = super::$module::Root::new(directory.path());
898 assert!(matches!(
900 root.directory(&["new", "deeper"], false),
901 Err(Refused::Missing)
902 ));
903 let made = root.directory(&["new", "deeper"], true).unwrap();
904 assert!(directory.path().join("new/deeper").is_dir());
905 assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
906 assert!(!directory.path().join("dir/inside").exists());
907 assert!(matches!(
908 root.directory(&["dir", "file.jsonc"], true),
909 Err(Refused::Unexpected)
910 ));
911
912 let dir = root.directory(&["dir"], false).unwrap();
915 let outside = tempfile::tempdir().unwrap();
916 let target = outside.path().join("target");
917 fs::write(&target, "outside").unwrap();
918 symlink(&target, directory.path().join("dir/staged")).unwrap();
919 dir.write("staged", None, b"new").unwrap();
920 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
921 fs::hard_link(&target, directory.path().join("dir/hard")).unwrap();
922 dir.write("hard", None, b"new").unwrap();
923 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
924 {
925 use std::os::unix::fs::PermissionsExt as _;
926 let file = directory.path().join("dir/file.jsonc");
927 fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap();
928 let like = dir.access("file.jsonc").unwrap();
929 assert!(dir.access("nope").unwrap().is_none());
930 dir.write("moded", like.as_ref(), b"x").unwrap();
931 let mode = fs::metadata(directory.path().join("dir/moded"))
932 .unwrap()
933 .permissions()
934 .mode();
935 assert_eq!(mode & 0o777, 0o640);
936 let mut own = dir.own_access().unwrap();
938 own.mode = 0o750;
939 dir.set_own_access(&own).unwrap();
940 assert_eq!(dir.own_access().unwrap(), own);
941 }
942
943 dir.rename("staged", "file.jsonc").unwrap();
944 assert_eq!(
945 fs::read_to_string(directory.path().join("dir/file.jsonc")).unwrap(),
946 "new"
947 );
948 assert!(dir.remove_file("hard").unwrap());
949 assert!(!dir.remove_file("hard").unwrap());
950 assert!(dir.remove_file("file-link.jsonc").unwrap());
951 assert!(target.exists());
952 dir.sync().unwrap();
953 made.sync().unwrap();
954
955 let top = root.directory(&["new"], false).unwrap();
956 fs::write(directory.path().join("new/deeper/x"), "").unwrap();
957 assert!(!top.remove_directory("deeper").unwrap());
958 fs::remove_file(directory.path().join("new/deeper/x")).unwrap();
959 assert!(top.remove_directory("deeper").unwrap());
960 assert!(!top.remove_directory("deeper").unwrap());
961
962 assert!(root.lock().unwrap().is_some());
963 assert!(root.make_directory(&["made"]).unwrap());
965 assert!(!root.make_directory(&["made"]).unwrap());
966 assert!(!root.make_directory(&["dir", "file.jsonc"]).unwrap());
967 assert!(matches!(
968 root.make_directory(&["nope", "deeper"]),
969 Err(Refused::Missing)
970 ));
971 let clone = root.try_clone();
973 assert_eq!(clone.kind(&["dir"]).unwrap(), Found::Directory);
974 }
975 };
976 }
977
978 write_tests!(beneath_writes_beneath_the_root, beneath);
979 write_tests!(portable_writes_beneath_the_root, portable);
980
981 #[cfg(target_os = "linux")]
982 #[test]
983 fn a_written_file_with_access_has_no_inherited_access_control_list() {
984 use std::os::unix::fs::PermissionsExt as _;
985 let directory = tree();
986 let mut acl = 2_u32.to_le_bytes().to_vec();
989 for (tag, permissions, id) in [
990 (0x01_u16, 7_u16, u32::MAX),
991 (0x02, 4, 65534),
992 (0x04, 5, u32::MAX),
993 (0x10, 5, u32::MAX),
994 (0x20, 0, u32::MAX),
995 ] {
996 acl.extend(tag.to_le_bytes());
997 acl.extend(permissions.to_le_bytes());
998 acl.extend(id.to_le_bytes());
999 }
1000 let dir_path = directory.path().join("dir");
1001 if rustix::fs::setxattr(
1002 &dir_path,
1003 "system.posix_acl_default",
1004 &acl,
1005 rustix::fs::XattrFlags::empty(),
1006 )
1007 .is_err()
1008 {
1009 return;
1011 }
1012 let root = super::beneath::Root::new(directory.path());
1013 let dir = root.directory(&["dir"], false).unwrap();
1014 assert!(dir.own_access().is_err());
1016 let mut access = dir.access("file.jsonc").unwrap().unwrap();
1017 access.mode = 0o640;
1018 dir.write("new", Some(&access), b"x").unwrap();
1019 let written = dir_path.join("new");
1020 assert_eq!(
1021 rustix::fs::getxattr(&written, "system.posix_acl_access", &mut [0_u8; 0][..]),
1022 Err(rustix::io::Errno::NODATA)
1023 );
1024 assert_eq!(
1025 fs::metadata(&written).unwrap().permissions().mode() & 0o777,
1026 0o640
1027 );
1028 dir.write("default", None, b"x").unwrap();
1030 assert!(
1031 rustix::fs::getxattr(
1032 dir_path.join("default"),
1033 "system.posix_acl_access",
1034 &mut [0_u8; 0][..]
1035 )
1036 .is_ok()
1037 );
1038 }
1039
1040 #[cfg(target_os = "linux")]
1041 #[test]
1042 fn labels_are_compared_and_never_given() {
1043 use std::os::unix::fs::MetadataExt as _;
1044 let label = |path: &std::path::Path, value: &[u8]| {
1045 let metadata = fs::metadata(path).unwrap();
1046 super::beneath::GIVEN_LABELS.with(|labels| {
1047 labels
1048 .borrow_mut()
1049 .insert((metadata.dev(), metadata.ino()), value.to_vec())
1050 });
1051 };
1052 let directory = tree();
1053 let root = super::beneath::Root::new(directory.path());
1054 let dir = root.directory(&["dir"], false).unwrap();
1055 let unlabelled = dir.access("file.jsonc").unwrap().unwrap();
1056 label(
1057 &directory.path().join("dir/file.jsonc"),
1058 b"system_u:object_r:secret_t:s0\0",
1059 );
1060 let labelled = dir.access("file.jsonc").unwrap().unwrap();
1061 assert_eq!(labelled.mode, unlabelled.mode);
1062 assert_ne!(labelled.control, unlabelled.control);
1063 assert_ne!(labelled.audience(false), unlabelled.audience(false));
1064 assert_eq!(labelled.new_file().control, labelled.control);
1065 assert_eq!(
1066 labelled.new_file().user,
1067 rustix::process::geteuid().as_raw()
1068 );
1069 assert!(dir.write("new", Some(&labelled), b"secret").is_err());
1072 assert!(!directory.path().join("dir/new").exists());
1073 dir.write("new", Some(&unlabelled), b"x").unwrap();
1074 label(
1076 &directory.path().join("dir"),
1077 b"system_u:object_r:secret_t:s0\0",
1078 );
1079 let own = dir.own_access().unwrap();
1080 assert_ne!(own.control, [0; 32]);
1081 let made = root.directory(&["made"], true).unwrap();
1082 assert!(made.set_own_access(&own).is_err());
1083 made.set_own_access(&made.own_access().unwrap()).unwrap();
1084 }
1085
1086 #[cfg(target_os = "linux")]
1087 #[test]
1088 fn only_local_file_systems_that_show_their_access_control_lists_are_checked() {
1089 let directory = tree();
1090 let root = super::beneath::Root::new(directory.path());
1091 let dir = root.directory(&["dir"], false).unwrap();
1092 let mounts = super::beneath::Mounts::read();
1096 let told = dir.unchecked_file_system(&mounts).unwrap();
1097 if fs::File::open("/proc/self/mountinfo").is_err() {
1098 assert!(told.unwrap().starts_with("one that could not be told: "));
1102 } else {
1103 let id = dir.mount_id().unwrap().unwrap();
1104 let found = mounts.type_of(id).unwrap();
1105 let expected = super::beneath::unchecked_file_system(found, dir.magic().unwrap());
1106 assert_eq!(told, expected);
1107 if matches!(found, b"ext4" | b"tmpfs" | b"xfs" | b"btrfs") {
1109 assert_eq!(told, None);
1110 }
1111 }
1112 for (found, magic, name) in [
1116 ("ext4", 0xEF53, None),
1117 ("xfs", 0x5846_5342, None),
1118 ("btrfs", 0x9123_683E, None),
1119 ("tmpfs", 0x0102_1994, None),
1120 ("zfs", 0x2FC1_2FC1, None),
1121 ("9p", 0xEF53, Some("9p")),
1122 ("overlay", 0x794C_7630, Some("overlay")),
1123 ("cifs", 0xFF53_4D42, Some("cifs")),
1124 ("vboxsf", 0x786F_4256, Some("vboxsf")),
1125 ("fuse.a\tb", 0x6573_5546, Some("fuse.a\\tb")),
1126 (
1127 "ext4",
1128 0x0102_1997,
1129 Some("ext4, though statfs gives type 0x1021997"),
1130 ),
1131 ] {
1132 super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(Some((found, magic))));
1133 let told = dir.unchecked_file_system(&mounts).unwrap();
1134 super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(None));
1135 assert_eq!(told.as_deref(), name, "{found} {magic:x}");
1136 }
1137 }
1138
1139 #[cfg(target_os = "linux")]
1140 #[test]
1141 fn a_file_system_that_cannot_be_told_is_not_checked() {
1142 use super::mounts::Mounts;
1143 let directory = tree();
1144 let root = super::beneath::Root::new(directory.path());
1145 let dir = root.directory(&["dir"], false).unwrap();
1146 let told = |mounts: &Mounts| dir.unchecked_file_system(mounts).unwrap();
1147 let real = Mounts::read();
1148 if fs::File::open("/proc/self/mountinfo").is_err() {
1152 assert!(
1153 told(&real)
1154 .unwrap()
1155 .starts_with("one that could not be told: ")
1156 );
1157 return;
1158 }
1159 let id = dir.mount_id().unwrap().unwrap();
1160 let line = |id: u64, rest: &str| format!("{id} 1 8:1 / /mnt rw - {rest}\n");
1161 for (mounts, reason) in [
1165 (
1166 Mounts::failed("it is not there"),
1167 "it is not there".to_owned(),
1168 ),
1169 (
1170 Mounts::parsed(b""),
1171 format!("its mount, {id}, is not in /proc/self/mountinfo"),
1172 ),
1173 (
1174 Mounts::parsed(line(id, "ext\\9 /dev/sda1 rw").as_bytes()),
1175 "its line in /proc/self/mountinfo is malformed".to_owned(),
1176 ),
1177 (
1178 Mounts::parsed(
1179 format!("{id} 1 8:1 / /{} rw - ext4 x rw\n", "a".repeat(70_000)).as_bytes(),
1180 ),
1181 "its line in /proc/self/mountinfo is longer than 65536 bytes".to_owned(),
1182 ),
1183 (
1184 Mounts::parsed((line(id, "ext4 a rw") + &line(id, "ext4 b rw")).as_bytes()),
1185 format!("its mount, {id}, is listed twice in /proc/self/mountinfo"),
1186 ),
1187 ] {
1188 assert_eq!(
1189 told(&mounts),
1190 Some(format!("one that could not be told: {reason}"))
1191 );
1192 }
1193 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/nonexistent/fdinfo")));
1197 let without_proc = told(&Mounts::failed("it is not there"));
1198 let without_fdinfo = told(&real);
1199 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1200 assert_eq!(
1201 without_proc.as_deref(),
1202 Some("one that could not be told: it is not there")
1203 );
1204 let without_fdinfo = without_fdinfo.unwrap();
1205 assert!(
1206 without_fdinfo.starts_with(
1207 "one that could not be told: statx gives no mount ID, and /proc/self/fdinfo could not be read ("
1208 ),
1209 "{without_fdinfo}"
1210 );
1211 let found = real.type_of(id).unwrap().escape_ascii().to_string();
1213 let mut table = line(id + 1, "fuse.\u{7f} x rw").into_bytes();
1214 table.extend(format!("{} 1 8:1 / /mnt rw - fuse.", id + 2).as_bytes());
1215 table.extend([0xFF, b' ', b'x', b'\n']);
1216 table.extend(line(id, &format!("{found} x rw")).as_bytes());
1217 table.extend(line(id + 3, &"\\040".repeat(400)).as_bytes());
1218 assert_eq!(told(&Mounts::parsed(&table)), told(&real));
1219 }
1220
1221 #[cfg(target_os = "linux")]
1222 #[test]
1223 fn mountinfo_is_read_a_line_at_a_time() {
1224 use super::mounts::scan;
1225 let text = b"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n\
122636 22 0:32 / /mnt/with\\040space rw - 9p host rw,trans=virtio\n\
122737 22 0:33 / /mnt/\xff\xfe rw master:2 shared:3 - fuse.s\\134sh\xff host rw\n\
122838 22 8:1 / / rw ext4 /dev/sda1 rw\n\
1229x 1 8:1 / / rw - ext4 /dev/sda1 rw\n\
123039 22 8:1 / / rw -\n\
123140 22 8:1 / / rw - ext\\9 /dev/sda1 rw\n\
123241 22 8:1 / / rw - xfs a rw";
1233 let mounts = scan(std::io::BufReader::with_capacity(7, &text[..])).unwrap();
1235 assert_eq!(mounts[&22].as_deref(), Ok(&b"ext4"[..]));
1236 assert_eq!(mounts[&36].as_deref(), Ok(&b"9p"[..]));
1237 assert_eq!(mounts[&37].as_deref(), Ok(&b"fuse.s\\sh\xff"[..]));
1238 assert_eq!(mounts[&41].as_deref(), Ok(&b"xfs"[..]));
1239 for id in [38, 39, 40] {
1240 assert!(mounts[&id].is_err(), "{id}");
1241 }
1242 assert_eq!(mounts.len(), 7);
1244 for gap in [0, 8 << 10] {
1249 let mut long = b"22 1 8:1 / /".to_vec();
1250 long.resize((64 << 10) + gap, b'a');
1251 long.extend(b"99 1 8:1 / / rw - ext4 x rw\n23 1 8:1 / / rw - xfs a rw\n");
1252 for capacity in [4 << 10, 8 << 10, 64 << 10, 1 << 20] {
1253 let reader = std::io::BufReader::with_capacity(capacity, &long[..]);
1254 let mounts = scan(reader).unwrap();
1255 let at = format!("gap {gap}, buffer {capacity}");
1256 assert!(
1257 mounts[&22].as_ref().unwrap_err().contains("longer than"),
1258 "{at}"
1259 );
1260 assert_eq!(mounts[&23].as_deref(), Ok(&b"xfs"[..]), "{at}");
1261 assert_eq!(mounts.keys().copied().collect::<Vec<_>>(), [22, 23], "{at}");
1262 }
1263 }
1264 }
1265
1266 #[cfg(target_os = "linux")]
1267 #[test]
1268 fn fdinfo_gives_the_mount_statx_does() {
1269 use rustix::fs::{AtFlags, StatxFlags, statx};
1270 use std::os::fd::AsRawFd as _;
1271 if !std::path::Path::new("/proc/self/fdinfo").is_dir() {
1272 return;
1273 }
1274 let directory = tree();
1275 let file = fs::File::open(directory.path().join("dir")).unwrap();
1276 let from_statx = match statx(&file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) {
1279 Ok(found) if found.stx_mask & StatxFlags::MNT_ID.bits() != 0 => Some(found.stx_mnt_id),
1280 Ok(_) | Err(rustix::io::Errno::NOSYS) => None,
1281 Err(errno) => panic!("{errno}"),
1282 };
1283 let fdinfo = super::mounts::fdinfo_mount_id("/proc/self/fdinfo", file.as_raw_fd()).unwrap();
1284 assert!(fdinfo.is_some());
1285 if from_statx.is_some() {
1286 assert_eq!(fdinfo, from_statx);
1287 }
1288 let root = super::beneath::Root::new(directory.path());
1291 let dir = root.directory(&["dir"], false).unwrap();
1292 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/proc/self/fdinfo")));
1293 let fallback = dir.mount_id();
1294 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1295 let fallback = fallback.unwrap().unwrap();
1296 assert_eq!(Some(fallback), fdinfo);
1297 if let Some(id) = from_statx {
1298 assert_eq!(fallback, id);
1299 }
1300 }
1301
1302 #[test]
1303 fn the_lock_excludes_a_second_holder() {
1304 let directory = tree();
1305 let root = super::beneath::Root::new(directory.path());
1306 let other = super::beneath::Root::new(directory.path());
1307 let held = root.lock().unwrap();
1308 assert!(held.is_some());
1309 assert!(other.lock().unwrap().is_none());
1310 drop(held);
1311 assert!(other.lock().unwrap().is_some());
1312 }
1313}
1314
1315#[cfg(all(test, target_os = "linux"))]
1316mod selinux_tests {
1317 use super::beneath::selinux::{request, trimmed};
1318
1319 #[test]
1320 fn label_questions_are_one_line_with_the_name_encoded() {
1321 let query = request(
1322 b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023\0",
1323 b"unconfined_u:object_r:user_home_t:s0\0",
1324 7,
1325 "0_0.jsonc",
1326 )
1327 .unwrap();
1328 assert_eq!(
1329 query,
1330 b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 unconfined_u:object_r:user_home_t:s0 7 0_0.jsonc"
1331 );
1332 let odd = request(b"a", b"b", 1, "x y%+\u{e9}").unwrap();
1333 assert_eq!(odd, b"a b 1 x%20y%25%2b%c3%a9");
1334 assert!(request(b"a b", b"c", 1, "x").is_err());
1335 assert!(request(b"", b"c", 1, "x").is_err());
1336 assert_eq!(trimmed(b"label\0\n"), b"label");
1337 assert_eq!(trimmed(b"\0"), b"");
1338 }
1339}
1340
1341#[cfg(all(test, windows))]
1345mod windows_tests {
1346 use super::{Found, RawKind, Refused, portable::Root};
1347 use std::{
1348 fs,
1349 path::{Path, PathBuf},
1350 process::{Command, Stdio},
1351 };
1352
1353 fn junction(link: &Path, target: &Path) {
1355 let native = |path: &Path| path.components().collect::<PathBuf>();
1357 let status = Command::new("cmd")
1358 .args(["/C", "mklink", "/J"])
1359 .arg(native(link))
1360 .arg(native(target))
1361 .stdout(Stdio::null())
1362 .status()
1363 .unwrap();
1364 assert!(status.success());
1365 }
1366
1367 fn link<T>(result: Result<T, Refused>) -> String {
1368 match result {
1369 Err(Refused::Link(prefix)) => prefix,
1370 Err(other) => panic!("expected a link, got {other:?}"),
1371 Ok(_) => panic!("expected a link, got success"),
1372 }
1373 }
1374
1375 #[test]
1376 fn junctions_are_links() {
1377 let directory = tempfile::tempdir().unwrap();
1378 fs::create_dir(directory.path().join("dir")).unwrap();
1379 fs::write(directory.path().join("dir/file.jsonc"), "{}").unwrap();
1380 junction(
1381 &directory.path().join("link"),
1382 &directory.path().join("dir"),
1383 );
1384 let root = Root::new(directory.path());
1385 assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
1386 assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
1387 assert_eq!(link(root.kind(&["link"])), "link");
1388 assert_eq!(link(root.directory(&["link"], false)), "link");
1389 assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
1390 assert!(!directory.path().join("dir/inside").exists());
1391 let kinds: Vec<(Option<String>, RawKind)> = root
1392 .list(&[])
1393 .unwrap()
1394 .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
1395 .collect::<Result<_, _>>()
1396 .unwrap();
1397 assert!(kinds.contains(&(Some("link".to_owned()), RawKind::Link)));
1398 assert!(kinds.contains(&(Some("dir".to_owned()), RawKind::Directory)));
1399
1400 let base = root.directory(&[], false).unwrap();
1402 base.write("link", None, b"x").unwrap();
1403 assert!(directory.path().join("dir/file.jsonc").exists());
1404 assert_eq!(root.kind(&["link"]).unwrap(), Found::File);
1405 }
1406
1407 #[test]
1408 fn read_only_files_are_refused() {
1409 let directory = tempfile::tempdir().unwrap();
1410 let file = directory.path().join("file.jsonc");
1411 fs::write(&file, "{}").unwrap();
1412 let base = Root::new(directory.path()).directory(&[], false).unwrap();
1413 assert!(base.access("file.jsonc").unwrap().is_some());
1414 let mut permissions = fs::metadata(&file).unwrap().permissions();
1415 permissions.set_readonly(true);
1416 fs::set_permissions(&file, permissions).unwrap();
1417 assert!(matches!(base.access("file.jsonc"), Err(Refused::Io(_))));
1418 }
1419
1420 #[test]
1421 fn encrypted_files_and_directories_are_refused() {
1422 let directory = tempfile::tempdir().unwrap();
1423 let inside = directory.path().join("inside");
1424 fs::create_dir(&inside).unwrap();
1425 fs::write(inside.join("file.jsonc"), "{}").unwrap();
1426 let encrypted = Command::new("cipher")
1429 .arg("/e")
1430 .arg("/s:".to_owned() + &inside.display().to_string())
1431 .stdout(Stdio::null())
1432 .status()
1433 .is_ok_and(|status| status.success());
1434 let root = Root::new(directory.path());
1435 let dir = root.directory(&["inside"], false).unwrap();
1436 assert!(
1439 encrypted || std::env::var_os("CI").is_none(),
1440 "cipher /e failed on CI"
1441 );
1442 if encrypted {
1443 assert!(matches!(dir.access("file.jsonc"), Err(Refused::Io(_))));
1444 assert!(matches!(dir.own_access(), Err(Refused::Io(_))));
1445 } else {
1446 assert!(dir.access("file.jsonc").unwrap().is_some());
1447 }
1448 }
1449
1450 #[test]
1451 fn the_lock_is_a_file_held_alone() {
1452 let directory = tempfile::tempdir().unwrap();
1453 let lock_file = directory.path().join(crate::access::LOCK_NAME);
1454 let root = Root::new(directory.path());
1455 let other = Root::new(directory.path());
1456 let held = root.lock().unwrap();
1457 assert!(held.is_some());
1458 assert!(other.lock().unwrap().is_none());
1459 let base = root.directory(&[], false).unwrap();
1461 base.write(".world.jsonc.tilewright-tmp", None, b"{}")
1462 .unwrap();
1463 base.write("world.jsonc", None, b"old").unwrap();
1464 base.rename(".world.jsonc.tilewright-tmp", "world.jsonc")
1465 .unwrap();
1466 drop(held);
1468 assert!(!lock_file.exists());
1469 assert!(other.lock().unwrap().is_some());
1470 fs::write(&lock_file, "someone's").unwrap();
1472 assert!(matches!(root.lock(), Err(Refused::Io(_))));
1473 assert_eq!(fs::read_to_string(&lock_file).unwrap(), "someone's");
1474 }
1475}