1#[cfg(unix)]
11pub(crate) mod beneath;
12#[cfg(target_os = "linux")]
13pub(crate) mod mounts;
14#[cfg(any(not(unix), test))]
15pub(crate) mod portable;
16
17#[cfg(unix)]
18pub(crate) use beneath as platform;
19#[cfg(not(unix))]
20pub(crate) use portable as platform;
21
22use crate::{
23 Code, Diagnostic, LoadOptions, Overlay,
24 diagnostic::{Diagnostics, Positions},
25};
26use sha2::{Digest, Sha256};
27#[cfg(all(test, target_os = "linux"))]
28use std::path::Path;
29use std::{
30 cell::Cell,
31 collections::{BTreeMap, BTreeSet},
32 io::Read as _,
33 rc::Rc,
34};
35use tilewright_jsonc::{Document, ErrorCode};
36
37#[derive(Debug)]
39pub(crate) enum Refused {
40 Link(String),
42 Missing,
43 Unexpected,
45 Io(std::io::Error),
46}
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub(crate) enum Found {
51 File,
52 Directory,
53 Other,
54 Missing,
55}
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub(crate) enum RawKind {
59 File,
60 Directory,
61 Link,
62 Other,
63}
64
65#[derive(Debug)]
67pub(crate) struct RawEntry {
68 pub name: Option<String>,
69 pub kind: RawKind,
70}
71
72pub(crate) fn components(path: &str) -> Vec<&str> {
74 path.split('/')
75 .filter(|component| !component.is_empty())
76 .collect()
77}
78
79pub(crate) struct Source {
81 pub path: String,
82 pub document: Document,
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub(crate) enum EntryKind {
87 File,
88 Directory,
89 Other,
91}
92
93pub(crate) struct Entry {
94 pub name: String,
95 pub kind: EntryKind,
96}
97
98pub(crate) struct Tree<'o> {
99 root: platform::Root,
100 options: &'o LoadOptions,
101 overlay: &'o Overlay,
102 files: usize,
103 entries: usize,
104 bytes: u64,
105 digests: BTreeMap<String, (u64, [u8; 32])>,
106 diagnostics: Diagnostics,
107 exhausted: bool,
108}
109
110const PATH_COMPONENTS: usize = 32;
114const PATH_BYTES: usize = 1_024;
115
116pub(crate) type Holding = Result<(), Option<String>>;
119
120#[must_use]
124pub fn is_jsonc_path(path: &str) -> bool {
125 is_safe_relative(path) && path.as_bytes().ends_with(b".jsonc")
126}
127
128pub(crate) fn is_safe_relative(path: &str) -> bool {
131 let components: Vec<&str> = path.split('/').collect();
132 path.len() <= 1024
133 && components.len() <= 32
134 && components.iter().all(|component| {
135 let bytes = component.as_bytes();
136 !bytes.is_empty()
137 && bytes.len() <= 128
138 && (bytes[0].is_ascii_alphanumeric() || bytes[0] == b'_')
139 && bytes
140 .iter()
141 .all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(byte))
142 })
143}
144
145#[cfg(all(test, target_os = "linux"))]
147static NO_OVERLAY: Overlay = BTreeMap::new();
148
149impl<'o> Tree<'o> {
150 #[cfg(all(test, target_os = "linux"))]
151 pub fn new(root: &Path, options: &'o LoadOptions) -> Self {
152 Self::in_root(
153 platform::Root::new(root),
154 options,
155 &NO_OVERLAY,
156 Rc::new(Cell::new(0)),
157 )
158 }
159
160 pub fn in_root(
166 root: platform::Root,
167 options: &'o LoadOptions,
168 overlay: &'o Overlay,
169 total: Rc<Cell<usize>>,
170 ) -> Self {
171 Self {
172 root,
173 options,
174 overlay,
175 files: 0,
176 entries: 0,
177 bytes: 0,
178 digests: BTreeMap::new(),
179 diagnostics: Diagnostics::sharing(options.maximum_diagnostics, total),
180 exhausted: false,
181 }
182 }
183
184 fn exhaust(&mut self, path: &str) {
186 if !self.exhausted {
187 self.exhausted = true;
188 self.diagnostics.push(Diagnostic::file(
189 path,
190 Code::Budget,
191 format!(
192 "the load exceeds {} files, {} directory entries or {} bytes",
193 self.options.maximum_files,
194 self.options.maximum_entries,
195 self.options.maximum_total_bytes
196 ),
197 ));
198 }
199 }
200
201 fn within_budget(&self, length: u64) -> bool {
203 !self.exhausted
204 && self.files < self.options.maximum_files
205 && self.bytes.saturating_add(length) <= self.options.maximum_total_bytes
206 }
207
208 pub fn take_diagnostics(&mut self) -> Diagnostics {
209 let total = self.diagnostics.total();
210 std::mem::replace(
211 &mut self.diagnostics,
212 Diagnostics::sharing(self.options.maximum_diagnostics, total),
213 )
214 }
215
216 fn stopped(&self) -> bool {
218 self.diagnostics.is_full()
219 }
220
221 fn link(&mut self, path: &str) {
223 self.diagnostics.push(Diagnostic::file(
224 path,
225 Code::Symlink,
226 "symbolic links are not followed",
227 ));
228 }
229
230 pub fn kind(&mut self, path: &str) -> Option<EntryKind> {
233 match self.overlay.get(path) {
234 Some(Some(_)) => return Some(EntryKind::File),
235 Some(None) => return None,
236 None => {}
237 }
238 match self.root.kind(&components(path)) {
239 Ok(Found::File) => Some(EntryKind::File),
240 Ok(Found::Directory) => Some(EntryKind::Directory),
241 Ok(Found::Other) => Some(EntryKind::Other),
242 Ok(Found::Missing) | Err(Refused::Missing | Refused::Unexpected) => self
243 .holds_overlay_files(path)
244 .then_some(EntryKind::Directory),
245 Err(Refused::Link(link)) => {
246 self.link(&link);
247 None
248 }
249 Err(Refused::Io(error)) => {
250 self.diagnostics.push(Diagnostic::file(
251 path,
252 Code::Io,
253 format!("cannot read file type: {error}"),
254 ));
255 None
256 }
257 }
258 }
259
260 fn holds_overlay_files(&self, path: &str) -> bool {
262 overlay_beneath(self.overlay, path).any(|(_, value)| value.is_some())
263 }
264
265 pub fn container(&mut self, path: &str) -> bool {
268 match self.kind(path) {
269 Some(EntryKind::Directory) => true,
270 Some(EntryKind::File | EntryKind::Other) => {
271 self.diagnostics.push(Diagnostic::file(
272 path,
273 Code::UnexpectedFile,
274 "must be a directory",
275 ));
276 false
277 }
278 None => false,
279 }
280 }
281
282 pub fn list(&mut self, path: &str) -> Vec<Entry> {
285 self.listing(path, true).0
286 }
287
288 pub fn holds_no_file(&mut self, path: &str) -> Holding {
300 let mut current = path.to_owned();
301 let mut levels: Vec<(Vec<String>, usize)> = Vec::new();
304 loop {
305 if current.len() > PATH_BYTES || current.split('/').count() > PATH_COMPONENTS {
306 return Err(Some(format!(
307 "a directory deeper than a document's path may be, at {current}"
308 )));
309 }
310 let (entries, other) = self.listing(¤t, false);
311 if let Some(other) = other {
312 return Err(Some(other));
313 }
314 let mut names = Vec::with_capacity(entries.len());
315 for entry in entries {
316 if entry.kind != EntryKind::Directory {
317 return Err(None);
318 }
319 names.push(entry.name);
320 }
321 names.reverse();
322 levels.push((names, current.len()));
323 loop {
325 let Some((names, length)) = levels.last_mut() else {
326 return Ok(());
327 };
328 if let Some(name) = names.pop() {
329 current.truncate(*length);
330 current.push('/');
331 current.push_str(&name);
332 break;
333 }
334 levels.pop();
335 }
336 }
337 }
338
339 fn listing(&mut self, path: &str, report: bool) -> (Vec<Entry>, Option<String>) {
345 let cut_short = || Some(format!("a directory not listed in full, at {path}"));
346 if self.stopped() {
347 if report {
348 self.diagnostics.skip();
349 }
350 return (Vec::new(), cut_short());
351 }
352 let mut added: BTreeMap<&str, EntryKind> = BTreeMap::new();
356 let remaining = self.options.maximum_entries.saturating_sub(self.entries);
357 for (rest, value) in overlay_beneath(self.overlay, path) {
358 match (rest.split_once('/'), value) {
359 (None, Some(_)) => {
360 added.insert(rest, EntryKind::File);
361 }
362 (Some((directory, _)), Some(_)) => {
363 added.entry(directory).or_insert(EntryKind::Directory);
364 }
365 (_, None) => {}
366 }
367 if added.len() > remaining {
370 self.exhaust(path);
371 return (Vec::new(), cut_short());
372 }
373 }
374 let (mut entries, mut other) = self.list_disk(path, report);
378 let present: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
379 for (name, kind) in added {
380 if !present.contains(name) {
381 self.entries += 1;
382 if self.entries > self.options.maximum_entries {
383 self.exhaust(path);
384 other = cut_short();
385 break;
386 }
387 entries.push(Entry {
388 name: name.to_owned(),
389 kind,
390 });
391 }
392 }
393 entries.sort_by(|left, right| left.name.cmp(&right.name));
394 (entries, other)
395 }
396
397 fn open_listing(
401 &mut self,
402 path: &str,
403 report: bool,
404 ) -> Result<Option<platform::Listing>, String> {
405 match self.root.list(&components(path)) {
406 Ok(directory) => Ok(Some(directory)),
407 Err(Refused::Link(link)) => {
408 if report {
409 self.link(&link);
410 }
411 Err(format!("a symbolic link, at {link}"))
412 }
413 Err(Refused::Missing) if self.holds_overlay_files(path) => Ok(None),
414 Err(refused) => {
415 let reason = match refused {
416 Refused::Io(error) => error.to_string(),
417 Refused::Missing => "it is missing".to_owned(),
418 Refused::Unexpected | Refused::Link(_) => "not a directory".to_owned(),
419 };
420 if report {
421 self.diagnostics.push(Diagnostic::file(
422 path,
423 Code::Io,
424 format!("cannot list directory: {reason}"),
425 ));
426 }
427 Err(format!("a directory that cannot be listed, at {path}"))
428 }
429 }
430 }
431
432 fn list_disk(&mut self, path: &str, report: bool) -> (Vec<Entry>, Option<String>) {
438 fn first(other: &mut Option<String>, found: String) {
440 if other.as_ref().is_none_or(|other| found < *other) {
441 *other = Some(found);
442 }
443 }
444 let overlay = self.overlay;
445 let directory = match self.open_listing(path, report) {
446 Ok(Some(directory)) => directory,
447 Ok(None) => return (Vec::new(), None),
448 Err(found) => return (Vec::new(), Some(found)),
449 };
450 let mut entries = Vec::new();
451 let mut other = None;
452 for entry in directory {
453 if self.exhausted {
454 first(
455 &mut other,
456 format!("a directory not listed in full, at {path}"),
457 );
458 break;
459 }
460 let entry = match entry {
461 Ok(entry) => entry,
462 Err(error) => {
463 first(
464 &mut other,
465 format!("a directory that cannot be listed in full, at {path}"),
466 );
467 if report {
468 self.diagnostics.push(Diagnostic::file(
469 path,
470 Code::Io,
471 format!("cannot list directory: {error}"),
472 ));
473 }
474 continue;
475 }
476 };
477 if let Some(name) = &entry.name
481 && overlay.contains_key(&join(path, name))
482 {
483 continue;
484 }
485 self.entries += 1;
486 if self.entries > self.options.maximum_entries {
487 self.exhaust(path);
488 first(
489 &mut other,
490 format!("a directory not listed in full, at {path}"),
491 );
492 break;
493 }
494 let Some(name) = entry.name else {
495 first(
496 &mut other,
497 format!("an entry whose name is not UTF-8, in {path}"),
498 );
499 if report {
500 self.diagnostics.push(Diagnostic::file(
501 path,
502 Code::UnexpectedFile,
503 "file name is not UTF-8",
504 ));
505 }
506 continue;
507 };
508 let child = join(path, &name);
509 match entry.kind {
510 RawKind::Link => {
511 first(&mut other, format!("a symbolic link, at {child}"));
512 if report {
513 self.link(&child);
514 }
515 }
516 RawKind::Directory => entries.push(Entry {
517 name,
518 kind: EntryKind::Directory,
519 }),
520 RawKind::File => entries.push(Entry {
521 name,
522 kind: EntryKind::File,
523 }),
524 RawKind::Other => {
525 first(&mut other, format!("a special file, at {child}"));
526 if report {
527 self.diagnostics.push(Diagnostic::file(
528 &child,
529 Code::UnexpectedFile,
530 "not a regular file or directory",
531 ));
532 }
533 }
534 }
535 }
536 (entries, other)
537 }
538
539 pub fn document(&mut self, path: &str) -> Option<Source> {
541 if self.stopped() {
542 self.diagnostics.skip();
543 return None;
544 }
545 match self.overlay.get(path) {
546 Some(Some(text)) => return self.overlay_document(path, text),
547 Some(None) => {
548 self.diagnostics
549 .push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
550 return None;
551 }
552 None => {}
553 }
554 let (file, length) = match self.root.file(&components(path)) {
557 Ok(opened) => opened,
558 Err(Refused::Link(link)) => {
559 self.link(&link);
560 return None;
561 }
562 Err(Refused::Unexpected) => {
563 self.diagnostics.push(Diagnostic::file(
564 path,
565 Code::UnexpectedFile,
566 "expected a regular file",
567 ));
568 return None;
569 }
570 Err(Refused::Missing) => {
571 self.diagnostics
572 .push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
573 return None;
574 }
575 Err(Refused::Io(error)) => {
576 self.diagnostics.push(Diagnostic::file(
577 path,
578 Code::Io,
579 format!("cannot read file: {error}"),
580 ));
581 return None;
582 }
583 };
584 let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
585 if length > limit {
586 self.diagnostics.push(Diagnostic::file(
587 path,
588 ErrorCode::InputTooLarge,
589 format!("file is larger than {limit} bytes"),
590 ));
591 return None;
592 }
593 if !self.within_budget(length) {
594 self.exhaust(path);
595 return None;
596 }
597 let remaining = self.options.maximum_total_bytes.saturating_sub(self.bytes);
601 let mut bytes = Vec::new();
602 let bytes = match file
603 .take(limit.min(remaining).saturating_add(1))
604 .read_to_end(&mut bytes)
605 {
606 Ok(_) => bytes,
607 Err(error) => {
608 self.diagnostics.push(Diagnostic::file(
609 path,
610 Code::Io,
611 format!("cannot read file: {error}"),
612 ));
613 return None;
614 }
615 };
616 let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
617 if length > limit {
618 self.diagnostics.push(Diagnostic::file(
619 path,
620 ErrorCode::InputTooLarge,
621 format!("file is larger than {limit} bytes"),
622 ));
623 return None;
624 }
625 if !self.within_budget(length) {
626 self.exhaust(path);
627 return None;
628 }
629 self.parse(path, bytes)
630 }
631
632 fn overlay_document(&mut self, path: &str, text: &str) -> Option<Source> {
635 let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
636 let length = u64::try_from(text.len()).unwrap_or(u64::MAX);
637 if length > limit {
638 self.diagnostics.push(Diagnostic::file(
639 path,
640 ErrorCode::InputTooLarge,
641 format!("file is larger than {limit} bytes"),
642 ));
643 return None;
644 }
645 if !self.within_budget(length) {
646 self.exhaust(path);
647 return None;
648 }
649 self.parse(path, text.as_bytes().to_vec())
650 }
651
652 fn parse(&mut self, path: &str, bytes: Vec<u8>) -> Option<Source> {
654 let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
655 self.files += 1;
656 self.bytes = self.bytes.saturating_add(length);
657 self.digests
658 .insert(path.to_owned(), (length, Sha256::digest(&bytes).into()));
659 let text = match String::from_utf8(bytes) {
660 Ok(text) => text,
661 Err(error) => {
662 self.diagnostics.push(Diagnostic::file(
663 path,
664 Code::Encoding,
665 format!("not UTF-8 at byte {}", error.utf8_error().valid_up_to()),
666 ));
667 return None;
668 }
669 };
670 match Document::parse(text.as_str(), &self.options.limits) {
671 Ok(document) => Some(Source {
672 path: path.to_owned(),
673 document,
674 }),
675 Err(error) => {
676 self.diagnostics.push(Diagnostic::at(
677 path,
678 &Positions::new(&text),
679 error.offset,
680 error.offset,
681 error.code,
682 "invalid JSONC",
683 ));
684 None
685 }
686 }
687 }
688
689 pub fn digest(&self) -> [u8; 32] {
693 let mut hasher = Sha256::new();
694 for (path, (length, digest)) in &self.digests {
695 hasher.update(path.as_bytes());
696 hasher.update([0]);
697 hasher.update(length.to_le_bytes());
698 hasher.update(digest);
699 }
700 hasher.finalize().into()
701 }
702}
703
704fn overlay_beneath<'a>(
707 overlay: &'a Overlay,
708 path: &str,
709) -> impl Iterator<Item = (&'a str, &'a Option<String>)> + 'a {
710 let prefix = if path.is_empty() {
711 String::new()
712 } else {
713 format!("{path}/")
714 };
715 overlay
716 .range(prefix.clone()..)
717 .map_while(move |(key, value)| key.strip_prefix(prefix.as_str()).map(|rest| (rest, value)))
718}
719
720pub(crate) fn ignored(
732 tree: &mut Tree<'_>,
733 path: &str,
734 entry: &Entry,
735 documents: bool,
736) -> Result<(), String> {
737 let ends = |suffix: &str| {
738 let (name, suffix) = (entry.name.as_bytes(), suffix.as_bytes());
739 name.len() >= suffix.len() && name[name.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
740 };
741 let staging = entry.name.starts_with('.') && ends(".tilewright-tmp");
742 if entry.kind != EntryKind::Directory || (documents && ends(".jsonc")) || staging {
743 return Err(String::new());
744 }
745 tree.holds_no_file(path).map_err(|found| {
746 found.map_or_else(String::new, |found| {
747 format!("; it is not ignored as a directory without files, since it holds {found}")
748 })
749 })
750}
751
752pub(crate) fn join(directory: &str, name: &str) -> String {
753 if directory.is_empty() {
754 name.to_owned()
755 } else {
756 format!("{directory}/{name}")
757 }
758}
759
760#[cfg(all(test, unix))]
763mod tests {
764 use super::{Found, RawKind, Refused};
765 use std::{fs, os::unix::fs::symlink};
766
767 #[test]
768 fn an_entry_the_overlay_removes_is_never_counted() {
769 use super::{LoadOptions, Overlay, Tree, platform};
770 use std::{cell::Cell, rc::Rc};
771 let root = tempfile::tempdir().unwrap();
772 for index in 0..10 {
773 fs::write(root.path().join(format!("{index}_0.jsonc")), "{}").unwrap();
774 }
775 let mut listing = fs::read_dir(root.path())
778 .unwrap()
779 .map(|entry| entry.unwrap().file_name().into_string().unwrap());
780 let first = listing.next().unwrap();
781 let overlay: Overlay = listing.map(|name| (name, None)).collect();
782 let options = LoadOptions {
783 maximum_entries: 1,
784 ..LoadOptions::default()
785 };
786 let total = Rc::new(Cell::new(0));
787 let mut tree = Tree::in_root(platform::Root::new(root.path()), &options, &overlay, total);
788 let listed = tree.list("");
789 assert_eq!(listed.len(), 1);
790 assert_eq!(listed[0].name, first);
791 assert!(!tree.exhausted);
792 }
793
794 fn tree() -> tempfile::TempDir {
795 let root = tempfile::tempdir().unwrap();
796 fs::create_dir(root.path().join("dir")).unwrap();
797 fs::write(root.path().join("dir/file.jsonc"), "{}").unwrap();
798 symlink(root.path().join("dir"), root.path().join("link")).unwrap();
799 symlink(
800 root.path().join("dir/file.jsonc"),
801 root.path().join("dir/file-link.jsonc"),
802 )
803 .unwrap();
804 root
805 }
806
807 fn link<T>(result: Result<T, Refused>) -> String {
809 match result {
810 Err(Refused::Link(prefix)) => prefix,
811 Err(other) => panic!("expected a link, got {other:?}"),
812 Ok(_) => panic!("expected a link, got success"),
813 }
814 }
815
816 macro_rules! access_tests {
817 ($name:ident, $module:ident) => {
818 #[test]
819 fn $name() {
820 let directory = tree();
821 let root = super::$module::Root::new(directory.path());
822 assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
823 assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
824 assert_eq!(
825 link(root.file(&["dir", "file-link.jsonc"])),
826 "dir/file-link.jsonc"
827 );
828 assert!(matches!(root.file(&["dir"]), Err(Refused::Unexpected)));
829 assert!(matches!(root.file(&["dir", "nope"]), Err(Refused::Missing)));
830 assert!(matches!(
831 root.file(&["dir", "file.jsonc", "x"]),
832 Err(Refused::Unexpected)
833 ));
834 assert_eq!(root.kind(&["dir"]).unwrap(), Found::Directory);
835 assert_eq!(root.kind(&["dir", "file.jsonc"]).unwrap(), Found::File);
836 assert_eq!(root.kind(&["nope"]).unwrap(), Found::Missing);
837 assert_eq!(link(root.kind(&["link"])), "link");
838 assert_eq!(link(root.list(&["link"])), "link");
839 let mut entries: Vec<(Option<String>, RawKind)> = root
840 .list(&["dir"])
841 .unwrap()
842 .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
843 .collect::<Result<_, _>>()
844 .unwrap();
845 entries.sort_by(|left, right| left.0.cmp(&right.0));
846 assert_eq!(
847 entries,
848 [
849 (Some("file-link.jsonc".to_owned()), RawKind::Link),
850 (Some("file.jsonc".to_owned()), RawKind::File),
851 ]
852 );
853 }
854 };
855 }
856
857 access_tests!(beneath_refuses_links, beneath);
858 access_tests!(portable_refuses_links, portable);
859
860 macro_rules! write_tests {
861 ($name:ident, $module:ident) => {
862 #[test]
863 fn $name() {
864 let directory = tree();
865 let root = super::$module::Root::new(directory.path());
866 assert!(matches!(
868 root.directory(&["new", "deeper"], false),
869 Err(Refused::Missing)
870 ));
871 let made = root.directory(&["new", "deeper"], true).unwrap();
872 assert!(directory.path().join("new/deeper").is_dir());
873 assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
874 assert!(!directory.path().join("dir/inside").exists());
875 assert!(matches!(
876 root.directory(&["dir", "file.jsonc"], true),
877 Err(Refused::Unexpected)
878 ));
879
880 let dir = root.directory(&["dir"], false).unwrap();
883 let outside = tempfile::tempdir().unwrap();
884 let target = outside.path().join("target");
885 fs::write(&target, "outside").unwrap();
886 symlink(&target, directory.path().join("dir/staged")).unwrap();
887 dir.write("staged", None, b"new").unwrap();
888 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
889 fs::hard_link(&target, directory.path().join("dir/hard")).unwrap();
890 dir.write("hard", None, b"new").unwrap();
891 assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
892 {
893 use std::os::unix::fs::PermissionsExt as _;
894 let file = directory.path().join("dir/file.jsonc");
895 fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap();
896 let like = dir.access("file.jsonc").unwrap();
897 assert!(dir.access("nope").unwrap().is_none());
898 dir.write("moded", like.as_ref(), b"x").unwrap();
899 let mode = fs::metadata(directory.path().join("dir/moded"))
900 .unwrap()
901 .permissions()
902 .mode();
903 assert_eq!(mode & 0o777, 0o640);
904 let mut own = dir.own_access().unwrap();
906 own.mode = 0o750;
907 dir.set_own_access(&own).unwrap();
908 assert_eq!(dir.own_access().unwrap(), own);
909 }
910
911 dir.rename("staged", "file.jsonc").unwrap();
912 assert_eq!(
913 fs::read_to_string(directory.path().join("dir/file.jsonc")).unwrap(),
914 "new"
915 );
916 assert!(dir.remove_file("hard").unwrap());
917 assert!(!dir.remove_file("hard").unwrap());
918 assert!(dir.remove_file("file-link.jsonc").unwrap());
919 assert!(target.exists());
920 dir.sync().unwrap();
921 made.sync().unwrap();
922
923 let top = root.directory(&["new"], false).unwrap();
924 fs::write(directory.path().join("new/deeper/x"), "").unwrap();
925 assert!(!top.remove_directory("deeper").unwrap());
926 fs::remove_file(directory.path().join("new/deeper/x")).unwrap();
927 assert!(top.remove_directory("deeper").unwrap());
928 assert!(!top.remove_directory("deeper").unwrap());
929
930 assert!(root.lock().unwrap().is_some());
931 assert!(root.make_directory(&["made"]).unwrap());
933 assert!(!root.make_directory(&["made"]).unwrap());
934 assert!(!root.make_directory(&["dir", "file.jsonc"]).unwrap());
935 assert!(matches!(
936 root.make_directory(&["nope", "deeper"]),
937 Err(Refused::Missing)
938 ));
939 let clone = root.try_clone();
941 assert_eq!(clone.kind(&["dir"]).unwrap(), Found::Directory);
942 }
943 };
944 }
945
946 write_tests!(beneath_writes_beneath_the_root, beneath);
947 write_tests!(portable_writes_beneath_the_root, portable);
948
949 #[cfg(target_os = "linux")]
950 #[test]
951 fn a_written_file_with_access_has_no_inherited_access_control_list() {
952 use std::os::unix::fs::PermissionsExt as _;
953 let directory = tree();
954 let mut acl = 2_u32.to_le_bytes().to_vec();
957 for (tag, permissions, id) in [
958 (0x01_u16, 7_u16, u32::MAX),
959 (0x02, 4, 65534),
960 (0x04, 5, u32::MAX),
961 (0x10, 5, u32::MAX),
962 (0x20, 0, u32::MAX),
963 ] {
964 acl.extend(tag.to_le_bytes());
965 acl.extend(permissions.to_le_bytes());
966 acl.extend(id.to_le_bytes());
967 }
968 let dir_path = directory.path().join("dir");
969 if rustix::fs::setxattr(
970 &dir_path,
971 "system.posix_acl_default",
972 &acl,
973 rustix::fs::XattrFlags::empty(),
974 )
975 .is_err()
976 {
977 return;
979 }
980 let root = super::beneath::Root::new(directory.path());
981 let dir = root.directory(&["dir"], false).unwrap();
982 assert!(dir.own_access().is_err());
984 let mut access = dir.access("file.jsonc").unwrap().unwrap();
985 access.mode = 0o640;
986 dir.write("new", Some(&access), b"x").unwrap();
987 let written = dir_path.join("new");
988 assert_eq!(
989 rustix::fs::getxattr(&written, "system.posix_acl_access", &mut [0_u8; 0][..]),
990 Err(rustix::io::Errno::NODATA)
991 );
992 assert_eq!(
993 fs::metadata(&written).unwrap().permissions().mode() & 0o777,
994 0o640
995 );
996 dir.write("default", None, b"x").unwrap();
998 assert!(
999 rustix::fs::getxattr(
1000 dir_path.join("default"),
1001 "system.posix_acl_access",
1002 &mut [0_u8; 0][..]
1003 )
1004 .is_ok()
1005 );
1006 }
1007
1008 #[cfg(target_os = "linux")]
1009 #[test]
1010 fn labels_are_compared_and_never_given() {
1011 use std::os::unix::fs::MetadataExt as _;
1012 let label = |path: &std::path::Path, value: &[u8]| {
1013 let metadata = fs::metadata(path).unwrap();
1014 super::beneath::GIVEN_LABELS.with(|labels| {
1015 labels
1016 .borrow_mut()
1017 .insert((metadata.dev(), metadata.ino()), value.to_vec())
1018 });
1019 };
1020 let directory = tree();
1021 let root = super::beneath::Root::new(directory.path());
1022 let dir = root.directory(&["dir"], false).unwrap();
1023 let unlabelled = dir.access("file.jsonc").unwrap().unwrap();
1024 label(
1025 &directory.path().join("dir/file.jsonc"),
1026 b"system_u:object_r:secret_t:s0\0",
1027 );
1028 let labelled = dir.access("file.jsonc").unwrap().unwrap();
1029 assert_eq!(labelled.mode, unlabelled.mode);
1030 assert_ne!(labelled.control, unlabelled.control);
1031 assert_ne!(labelled.audience(false), unlabelled.audience(false));
1032 assert_eq!(labelled.new_file().control, labelled.control);
1033 assert_eq!(
1034 labelled.new_file().user,
1035 rustix::process::geteuid().as_raw()
1036 );
1037 assert!(dir.write("new", Some(&labelled), b"secret").is_err());
1040 assert!(!directory.path().join("dir/new").exists());
1041 dir.write("new", Some(&unlabelled), b"x").unwrap();
1042 label(
1044 &directory.path().join("dir"),
1045 b"system_u:object_r:secret_t:s0\0",
1046 );
1047 let own = dir.own_access().unwrap();
1048 assert_ne!(own.control, [0; 32]);
1049 let made = root.directory(&["made"], true).unwrap();
1050 assert!(made.set_own_access(&own).is_err());
1051 made.set_own_access(&made.own_access().unwrap()).unwrap();
1052 }
1053
1054 #[cfg(target_os = "linux")]
1055 #[test]
1056 fn only_local_file_systems_that_show_their_access_control_lists_are_checked() {
1057 let directory = tree();
1058 let root = super::beneath::Root::new(directory.path());
1059 let dir = root.directory(&["dir"], false).unwrap();
1060 let mounts = super::beneath::Mounts::read();
1064 let told = dir.unchecked_file_system(&mounts).unwrap();
1065 if fs::File::open("/proc/self/mountinfo").is_err() {
1066 assert!(told.unwrap().starts_with("one that could not be told: "));
1070 } else {
1071 let id = dir.mount_id().unwrap().unwrap();
1072 let found = mounts.type_of(id).unwrap();
1073 let expected = super::beneath::unchecked_file_system(found, dir.magic().unwrap());
1074 assert_eq!(told, expected);
1075 if matches!(found, b"ext4" | b"tmpfs" | b"xfs" | b"btrfs") {
1077 assert_eq!(told, None);
1078 }
1079 }
1080 for (found, magic, name) in [
1084 ("ext4", 0xEF53, None),
1085 ("xfs", 0x5846_5342, None),
1086 ("btrfs", 0x9123_683E, None),
1087 ("tmpfs", 0x0102_1994, None),
1088 ("zfs", 0x2FC1_2FC1, None),
1089 ("9p", 0xEF53, Some("9p")),
1090 ("overlay", 0x794C_7630, Some("overlay")),
1091 ("cifs", 0xFF53_4D42, Some("cifs")),
1092 ("vboxsf", 0x786F_4256, Some("vboxsf")),
1093 ("fuse.a\tb", 0x6573_5546, Some("fuse.a\\tb")),
1094 (
1095 "ext4",
1096 0x0102_1997,
1097 Some("ext4, though statfs gives type 0x1021997"),
1098 ),
1099 ] {
1100 super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(Some((found, magic))));
1101 let told = dir.unchecked_file_system(&mounts).unwrap();
1102 super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(None));
1103 assert_eq!(told.as_deref(), name, "{found} {magic:x}");
1104 }
1105 }
1106
1107 #[cfg(target_os = "linux")]
1108 #[test]
1109 fn a_file_system_that_cannot_be_told_is_not_checked() {
1110 use super::mounts::Mounts;
1111 let directory = tree();
1112 let root = super::beneath::Root::new(directory.path());
1113 let dir = root.directory(&["dir"], false).unwrap();
1114 let told = |mounts: &Mounts| dir.unchecked_file_system(mounts).unwrap();
1115 let real = Mounts::read();
1116 if fs::File::open("/proc/self/mountinfo").is_err() {
1120 assert!(
1121 told(&real)
1122 .unwrap()
1123 .starts_with("one that could not be told: ")
1124 );
1125 return;
1126 }
1127 let id = dir.mount_id().unwrap().unwrap();
1128 let line = |id: u64, rest: &str| format!("{id} 1 8:1 / /mnt rw - {rest}\n");
1129 for (mounts, reason) in [
1133 (
1134 Mounts::failed("it is not there"),
1135 "it is not there".to_owned(),
1136 ),
1137 (
1138 Mounts::parsed(b""),
1139 format!("its mount, {id}, is not in /proc/self/mountinfo"),
1140 ),
1141 (
1142 Mounts::parsed(line(id, "ext\\9 /dev/sda1 rw").as_bytes()),
1143 "its line in /proc/self/mountinfo is malformed".to_owned(),
1144 ),
1145 (
1146 Mounts::parsed(
1147 format!("{id} 1 8:1 / /{} rw - ext4 x rw\n", "a".repeat(70_000)).as_bytes(),
1148 ),
1149 "its line in /proc/self/mountinfo is longer than 65536 bytes".to_owned(),
1150 ),
1151 (
1152 Mounts::parsed((line(id, "ext4 a rw") + &line(id, "ext4 b rw")).as_bytes()),
1153 format!("its mount, {id}, is listed twice in /proc/self/mountinfo"),
1154 ),
1155 ] {
1156 assert_eq!(
1157 told(&mounts),
1158 Some(format!("one that could not be told: {reason}"))
1159 );
1160 }
1161 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/nonexistent/fdinfo")));
1165 let without_proc = told(&Mounts::failed("it is not there"));
1166 let without_fdinfo = told(&real);
1167 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1168 assert_eq!(
1169 without_proc.as_deref(),
1170 Some("one that could not be told: it is not there")
1171 );
1172 let without_fdinfo = without_fdinfo.unwrap();
1173 assert!(
1174 without_fdinfo.starts_with(
1175 "one that could not be told: statx gives no mount ID, and /proc/self/fdinfo could not be read ("
1176 ),
1177 "{without_fdinfo}"
1178 );
1179 let found = real.type_of(id).unwrap().escape_ascii().to_string();
1181 let mut table = line(id + 1, "fuse.\u{7f} x rw").into_bytes();
1182 table.extend(format!("{} 1 8:1 / /mnt rw - fuse.", id + 2).as_bytes());
1183 table.extend([0xFF, b' ', b'x', b'\n']);
1184 table.extend(line(id, &format!("{found} x rw")).as_bytes());
1185 table.extend(line(id + 3, &"\\040".repeat(400)).as_bytes());
1186 assert_eq!(told(&Mounts::parsed(&table)), told(&real));
1187 }
1188
1189 #[cfg(target_os = "linux")]
1190 #[test]
1191 fn mountinfo_is_read_a_line_at_a_time() {
1192 use super::mounts::scan;
1193 let text = b"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n\
119436 22 0:32 / /mnt/with\\040space rw - 9p host rw,trans=virtio\n\
119537 22 0:33 / /mnt/\xff\xfe rw master:2 shared:3 - fuse.s\\134sh\xff host rw\n\
119638 22 8:1 / / rw ext4 /dev/sda1 rw\n\
1197x 1 8:1 / / rw - ext4 /dev/sda1 rw\n\
119839 22 8:1 / / rw -\n\
119940 22 8:1 / / rw - ext\\9 /dev/sda1 rw\n\
120041 22 8:1 / / rw - xfs a rw";
1201 let mounts = scan(std::io::BufReader::with_capacity(7, &text[..])).unwrap();
1203 assert_eq!(mounts[&22].as_deref(), Ok(&b"ext4"[..]));
1204 assert_eq!(mounts[&36].as_deref(), Ok(&b"9p"[..]));
1205 assert_eq!(mounts[&37].as_deref(), Ok(&b"fuse.s\\sh\xff"[..]));
1206 assert_eq!(mounts[&41].as_deref(), Ok(&b"xfs"[..]));
1207 for id in [38, 39, 40] {
1208 assert!(mounts[&id].is_err(), "{id}");
1209 }
1210 assert_eq!(mounts.len(), 7);
1212 for gap in [0, 8 << 10] {
1217 let mut long = b"22 1 8:1 / /".to_vec();
1218 long.resize((64 << 10) + gap, b'a');
1219 long.extend(b"99 1 8:1 / / rw - ext4 x rw\n23 1 8:1 / / rw - xfs a rw\n");
1220 for capacity in [4 << 10, 8 << 10, 64 << 10, 1 << 20] {
1221 let reader = std::io::BufReader::with_capacity(capacity, &long[..]);
1222 let mounts = scan(reader).unwrap();
1223 let at = format!("gap {gap}, buffer {capacity}");
1224 assert!(
1225 mounts[&22].as_ref().unwrap_err().contains("longer than"),
1226 "{at}"
1227 );
1228 assert_eq!(mounts[&23].as_deref(), Ok(&b"xfs"[..]), "{at}");
1229 assert_eq!(mounts.keys().copied().collect::<Vec<_>>(), [22, 23], "{at}");
1230 }
1231 }
1232 }
1233
1234 #[cfg(target_os = "linux")]
1235 #[test]
1236 fn fdinfo_gives_the_mount_statx_does() {
1237 use rustix::fs::{AtFlags, StatxFlags, statx};
1238 use std::os::fd::AsRawFd as _;
1239 if !std::path::Path::new("/proc/self/fdinfo").is_dir() {
1240 return;
1241 }
1242 let directory = tree();
1243 let file = fs::File::open(directory.path().join("dir")).unwrap();
1244 let from_statx = match statx(&file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) {
1247 Ok(found) if found.stx_mask & StatxFlags::MNT_ID.bits() != 0 => Some(found.stx_mnt_id),
1248 Ok(_) | Err(rustix::io::Errno::NOSYS) => None,
1249 Err(errno) => panic!("{errno}"),
1250 };
1251 let fdinfo = super::mounts::fdinfo_mount_id("/proc/self/fdinfo", file.as_raw_fd()).unwrap();
1252 assert!(fdinfo.is_some());
1253 if from_statx.is_some() {
1254 assert_eq!(fdinfo, from_statx);
1255 }
1256 let root = super::beneath::Root::new(directory.path());
1259 let dir = root.directory(&["dir"], false).unwrap();
1260 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/proc/self/fdinfo")));
1261 let fallback = dir.mount_id();
1262 super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1263 let fallback = fallback.unwrap().unwrap();
1264 assert_eq!(Some(fallback), fdinfo);
1265 if let Some(id) = from_statx {
1266 assert_eq!(fallback, id);
1267 }
1268 }
1269
1270 #[test]
1271 fn the_lock_excludes_a_second_holder() {
1272 let directory = tree();
1273 let root = super::beneath::Root::new(directory.path());
1274 let other = super::beneath::Root::new(directory.path());
1275 let held = root.lock().unwrap();
1276 assert!(held.is_some());
1277 assert!(other.lock().unwrap().is_none());
1278 drop(held);
1279 assert!(other.lock().unwrap().is_some());
1280 }
1281}
1282
1283#[cfg(all(test, target_os = "linux"))]
1284mod selinux_tests {
1285 use super::beneath::selinux::{request, trimmed};
1286
1287 #[test]
1288 fn label_questions_are_one_line_with_the_name_encoded() {
1289 let query = request(
1290 b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023\0",
1291 b"unconfined_u:object_r:user_home_t:s0\0",
1292 7,
1293 "0_0.jsonc",
1294 )
1295 .unwrap();
1296 assert_eq!(
1297 query,
1298 b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 unconfined_u:object_r:user_home_t:s0 7 0_0.jsonc"
1299 );
1300 let odd = request(b"a", b"b", 1, "x y%+\u{e9}").unwrap();
1301 assert_eq!(odd, b"a b 1 x%20y%25%2b%c3%a9");
1302 assert!(request(b"a b", b"c", 1, "x").is_err());
1303 assert!(request(b"", b"c", 1, "x").is_err());
1304 assert_eq!(trimmed(b"label\0\n"), b"label");
1305 assert_eq!(trimmed(b"\0"), b"");
1306 }
1307}
1308
1309#[cfg(all(test, windows))]
1313mod windows_tests {
1314 use super::{Found, RawKind, Refused, portable::Root};
1315 use std::{
1316 fs,
1317 path::{Path, PathBuf},
1318 process::{Command, Stdio},
1319 };
1320
1321 fn junction(link: &Path, target: &Path) {
1323 let native = |path: &Path| path.components().collect::<PathBuf>();
1325 let status = Command::new("cmd")
1326 .args(["/C", "mklink", "/J"])
1327 .arg(native(link))
1328 .arg(native(target))
1329 .stdout(Stdio::null())
1330 .status()
1331 .unwrap();
1332 assert!(status.success());
1333 }
1334
1335 fn link<T>(result: Result<T, Refused>) -> String {
1336 match result {
1337 Err(Refused::Link(prefix)) => prefix,
1338 Err(other) => panic!("expected a link, got {other:?}"),
1339 Ok(_) => panic!("expected a link, got success"),
1340 }
1341 }
1342
1343 #[test]
1344 fn junctions_are_links() {
1345 let directory = tempfile::tempdir().unwrap();
1346 fs::create_dir(directory.path().join("dir")).unwrap();
1347 fs::write(directory.path().join("dir/file.jsonc"), "{}").unwrap();
1348 junction(
1349 &directory.path().join("link"),
1350 &directory.path().join("dir"),
1351 );
1352 let root = Root::new(directory.path());
1353 assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
1354 assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
1355 assert_eq!(link(root.kind(&["link"])), "link");
1356 assert_eq!(link(root.directory(&["link"], false)), "link");
1357 assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
1358 assert!(!directory.path().join("dir/inside").exists());
1359 let kinds: Vec<(Option<String>, RawKind)> = root
1360 .list(&[])
1361 .unwrap()
1362 .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
1363 .collect::<Result<_, _>>()
1364 .unwrap();
1365 assert!(kinds.contains(&(Some("link".to_owned()), RawKind::Link)));
1366 assert!(kinds.contains(&(Some("dir".to_owned()), RawKind::Directory)));
1367
1368 let base = root.directory(&[], false).unwrap();
1370 base.write("link", None, b"x").unwrap();
1371 assert!(directory.path().join("dir/file.jsonc").exists());
1372 assert_eq!(root.kind(&["link"]).unwrap(), Found::File);
1373 }
1374
1375 #[test]
1376 fn read_only_files_are_refused() {
1377 let directory = tempfile::tempdir().unwrap();
1378 let file = directory.path().join("file.jsonc");
1379 fs::write(&file, "{}").unwrap();
1380 let base = Root::new(directory.path()).directory(&[], false).unwrap();
1381 assert!(base.access("file.jsonc").unwrap().is_some());
1382 let mut permissions = fs::metadata(&file).unwrap().permissions();
1383 permissions.set_readonly(true);
1384 fs::set_permissions(&file, permissions).unwrap();
1385 assert!(matches!(base.access("file.jsonc"), Err(Refused::Io(_))));
1386 }
1387
1388 #[test]
1389 fn encrypted_files_and_directories_are_refused() {
1390 let directory = tempfile::tempdir().unwrap();
1391 let inside = directory.path().join("inside");
1392 fs::create_dir(&inside).unwrap();
1393 fs::write(inside.join("file.jsonc"), "{}").unwrap();
1394 let encrypted = Command::new("cipher")
1397 .arg("/e")
1398 .arg("/s:".to_owned() + &inside.display().to_string())
1399 .stdout(Stdio::null())
1400 .status()
1401 .is_ok_and(|status| status.success());
1402 let root = Root::new(directory.path());
1403 let dir = root.directory(&["inside"], false).unwrap();
1404 assert!(
1407 encrypted || std::env::var_os("CI").is_none(),
1408 "cipher /e failed on CI"
1409 );
1410 if encrypted {
1411 assert!(matches!(dir.access("file.jsonc"), Err(Refused::Io(_))));
1412 assert!(matches!(dir.own_access(), Err(Refused::Io(_))));
1413 } else {
1414 assert!(dir.access("file.jsonc").unwrap().is_some());
1415 }
1416 }
1417
1418 #[test]
1419 fn the_lock_is_a_file_held_alone() {
1420 let directory = tempfile::tempdir().unwrap();
1421 let lock_file = directory.path().join(crate::access::LOCK_NAME);
1422 let root = Root::new(directory.path());
1423 let other = Root::new(directory.path());
1424 let held = root.lock().unwrap();
1425 assert!(held.is_some());
1426 assert!(other.lock().unwrap().is_none());
1427 let base = root.directory(&[], false).unwrap();
1429 base.write(".world.jsonc.tilewright-tmp", None, b"{}")
1430 .unwrap();
1431 base.write("world.jsonc", None, b"old").unwrap();
1432 base.rename(".world.jsonc.tilewright-tmp", "world.jsonc")
1433 .unwrap();
1434 drop(held);
1436 assert!(!lock_file.exists());
1437 assert!(other.lock().unwrap().is_some());
1438 fs::write(&lock_file, "someone's").unwrap();
1440 assert!(matches!(root.lock(), Err(Refused::Io(_))));
1441 assert_eq!(fs::read_to_string(&lock_file).unwrap(), "someone's");
1442 }
1443}