#[cfg(unix)]
pub(crate) mod beneath;
#[cfg(target_os = "linux")]
pub(crate) mod mounts;
#[cfg(any(not(unix), test))]
pub(crate) mod portable;
#[cfg(unix)]
pub(crate) use beneath as platform;
#[cfg(not(unix))]
pub(crate) use portable as platform;
use crate::{
Code, Diagnostic, LoadOptions, Overlay,
diagnostic::{Diagnostics, Positions},
};
use sha2::{Digest, Sha256};
#[cfg(all(test, target_os = "linux"))]
use std::path::Path;
use std::{
cell::Cell,
collections::{BTreeMap, BTreeSet},
io::Read as _,
rc::Rc,
};
use tilewright_jsonc::{Document, ErrorCode};
#[derive(Debug)]
pub(crate) enum Refused {
Link(String),
Missing,
Unexpected,
Io(std::io::Error),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Found {
File,
Directory,
Other,
Missing,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RawKind {
File,
Directory,
Link,
Other,
}
#[derive(Debug)]
pub(crate) struct RawEntry {
pub name: Option<String>,
pub kind: RawKind,
}
pub(crate) fn components(path: &str) -> Vec<&str> {
path.split('/')
.filter(|component| !component.is_empty())
.collect()
}
pub(crate) struct Source {
pub path: String,
pub document: Document,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EntryKind {
File,
Directory,
Other,
}
pub(crate) struct Entry {
pub name: String,
pub kind: EntryKind,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SourceFile {
length: u64,
sha256: [u8; 32],
}
impl SourceFile {
#[must_use]
pub const fn length(&self) -> u64 {
self.length
}
#[must_use]
pub const fn sha256(&self) -> [u8; 32] {
self.sha256
}
}
pub(crate) struct Tree<'o> {
root: platform::Root,
options: &'o LoadOptions,
overlay: &'o Overlay,
files: usize,
entries: usize,
bytes: u64,
digests: BTreeMap<String, SourceFile>,
diagnostics: Diagnostics,
exhausted: bool,
}
const PATH_COMPONENTS: usize = 32;
const PATH_BYTES: usize = 1_024;
pub(crate) type Holding = Result<(), Option<String>>;
#[must_use]
pub fn is_jsonc_path(path: &str) -> bool {
is_safe_relative(path) && path.as_bytes().ends_with(b".jsonc")
}
pub(crate) fn is_safe_relative(path: &str) -> bool {
let components: Vec<&str> = path.split('/').collect();
path.len() <= 1024
&& components.len() <= 32
&& components.iter().all(|component| {
let bytes = component.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 128
&& (bytes[0].is_ascii_alphanumeric() || bytes[0] == b'_')
&& bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(byte))
})
}
#[cfg(all(test, target_os = "linux"))]
static NO_OVERLAY: Overlay = BTreeMap::new();
impl<'o> Tree<'o> {
#[cfg(all(test, target_os = "linux"))]
pub fn new(root: &Path, options: &'o LoadOptions) -> Self {
Self::in_root(
platform::Root::new(root),
options,
&NO_OVERLAY,
Rc::new(Cell::new(0)),
)
}
pub fn in_root(
root: platform::Root,
options: &'o LoadOptions,
overlay: &'o Overlay,
total: Rc<Cell<usize>>,
) -> Self {
Self {
root,
options,
overlay,
files: 0,
entries: 0,
bytes: 0,
digests: BTreeMap::new(),
diagnostics: Diagnostics::sharing(options.maximum_diagnostics, total),
exhausted: false,
}
}
fn exhaust(&mut self, path: &str) {
if !self.exhausted {
self.exhausted = true;
self.diagnostics.push(Diagnostic::file(
path,
Code::Budget,
format!(
"the load exceeds {} files, {} directory entries or {} bytes",
self.options.maximum_files,
self.options.maximum_entries,
self.options.maximum_total_bytes
),
));
}
}
fn within_budget(&self, length: u64) -> bool {
!self.exhausted
&& self.files < self.options.maximum_files
&& self.bytes.saturating_add(length) <= self.options.maximum_total_bytes
}
pub fn take_diagnostics(&mut self) -> Diagnostics {
let total = self.diagnostics.total();
std::mem::replace(
&mut self.diagnostics,
Diagnostics::sharing(self.options.maximum_diagnostics, total),
)
}
fn stopped(&self) -> bool {
self.diagnostics.is_full()
}
fn link(&mut self, path: &str) {
self.diagnostics.push(Diagnostic::file(
path,
Code::Symlink,
"symbolic links are not followed",
));
}
pub fn kind(&mut self, path: &str) -> Option<EntryKind> {
match self.overlay.get(path) {
Some(Some(_)) => return Some(EntryKind::File),
Some(None) => return None,
None => {}
}
match self.root.kind(&components(path)) {
Ok(Found::File) => Some(EntryKind::File),
Ok(Found::Directory) => Some(EntryKind::Directory),
Ok(Found::Other) => Some(EntryKind::Other),
Ok(Found::Missing) | Err(Refused::Missing | Refused::Unexpected) => self
.holds_overlay_files(path)
.then_some(EntryKind::Directory),
Err(Refused::Link(link)) => {
self.link(&link);
None
}
Err(Refused::Io(error)) => {
self.diagnostics.push(Diagnostic::file(
path,
Code::Io,
format!("cannot read file type: {error}"),
));
None
}
}
}
fn holds_overlay_files(&self, path: &str) -> bool {
overlay_beneath(self.overlay, path).any(|(_, value)| value.is_some())
}
pub fn container(&mut self, path: &str) -> bool {
match self.kind(path) {
Some(EntryKind::Directory) => true,
Some(EntryKind::File | EntryKind::Other) => {
self.diagnostics.push(Diagnostic::file(
path,
Code::UnexpectedFile,
"must be a directory",
));
false
}
None => false,
}
}
pub fn list(&mut self, path: &str) -> Vec<Entry> {
self.listing(path, true).0
}
pub fn holds_no_file(&mut self, path: &str) -> Holding {
let mut current = path.to_owned();
let mut levels: Vec<(Vec<String>, usize)> = Vec::new();
loop {
if current.len() > PATH_BYTES || current.split('/').count() > PATH_COMPONENTS {
return Err(Some(format!(
"a directory deeper than a document's path may be, at {current}"
)));
}
let (entries, other) = self.listing(¤t, false);
if let Some(other) = other {
return Err(Some(other));
}
let mut names = Vec::with_capacity(entries.len());
for entry in entries {
if entry.kind != EntryKind::Directory {
return Err(None);
}
names.push(entry.name);
}
names.reverse();
levels.push((names, current.len()));
loop {
let Some((names, length)) = levels.last_mut() else {
return Ok(());
};
if let Some(name) = names.pop() {
current.truncate(*length);
current.push('/');
current.push_str(&name);
break;
}
levels.pop();
}
}
}
fn listing(&mut self, path: &str, report: bool) -> (Vec<Entry>, Option<String>) {
let cut_short = || Some(format!("a directory not listed in full, at {path}"));
if self.stopped() {
if report {
self.diagnostics.skip();
}
return (Vec::new(), cut_short());
}
let mut added: BTreeMap<&str, EntryKind> = BTreeMap::new();
let remaining = self.options.maximum_entries.saturating_sub(self.entries);
for (rest, value) in overlay_beneath(self.overlay, path) {
match (rest.split_once('/'), value) {
(None, Some(_)) => {
added.insert(rest, EntryKind::File);
}
(Some((directory, _)), Some(_)) => {
added.entry(directory).or_insert(EntryKind::Directory);
}
(_, None) => {}
}
if added.len() > remaining {
self.exhaust(path);
return (Vec::new(), cut_short());
}
}
let (mut entries, mut other) = self.list_disk(path, report);
let present: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
for (name, kind) in added {
if !present.contains(name) {
self.entries += 1;
if self.entries > self.options.maximum_entries {
self.exhaust(path);
other = cut_short();
break;
}
entries.push(Entry {
name: name.to_owned(),
kind,
});
}
}
entries.sort_by(|left, right| left.name.cmp(&right.name));
(entries, other)
}
fn open_listing(
&mut self,
path: &str,
report: bool,
) -> Result<Option<platform::Listing>, String> {
match self.root.list(&components(path)) {
Ok(directory) => Ok(Some(directory)),
Err(Refused::Link(link)) => {
if report {
self.link(&link);
}
Err(format!("a symbolic link, at {link}"))
}
Err(Refused::Missing) if self.holds_overlay_files(path) => Ok(None),
Err(refused) => {
let reason = match refused {
Refused::Io(error) => error.to_string(),
Refused::Missing => "it is missing".to_owned(),
Refused::Unexpected | Refused::Link(_) => "not a directory".to_owned(),
};
if report {
self.diagnostics.push(Diagnostic::file(
path,
Code::Io,
format!("cannot list directory: {reason}"),
));
}
Err(format!("a directory that cannot be listed, at {path}"))
}
}
}
fn list_disk(&mut self, path: &str, report: bool) -> (Vec<Entry>, Option<String>) {
fn first(other: &mut Option<String>, found: String) {
if other.as_ref().is_none_or(|other| found < *other) {
*other = Some(found);
}
}
let overlay = self.overlay;
let directory = match self.open_listing(path, report) {
Ok(Some(directory)) => directory,
Ok(None) => return (Vec::new(), None),
Err(found) => return (Vec::new(), Some(found)),
};
let mut entries = Vec::new();
let mut other = None;
for entry in directory {
if self.exhausted {
first(
&mut other,
format!("a directory not listed in full, at {path}"),
);
break;
}
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
first(
&mut other,
format!("a directory that cannot be listed in full, at {path}"),
);
if report {
self.diagnostics.push(Diagnostic::file(
path,
Code::Io,
format!("cannot list directory: {error}"),
));
}
continue;
}
};
if let Some(name) = &entry.name
&& overlay.contains_key(&join(path, name))
{
continue;
}
self.entries += 1;
if self.entries > self.options.maximum_entries {
self.exhaust(path);
first(
&mut other,
format!("a directory not listed in full, at {path}"),
);
break;
}
let Some(name) = entry.name else {
first(
&mut other,
format!("an entry whose name is not UTF-8, in {path}"),
);
if report {
self.diagnostics.push(Diagnostic::file(
path,
Code::UnexpectedFile,
"file name is not UTF-8",
));
}
continue;
};
let child = join(path, &name);
match entry.kind {
RawKind::Link => {
first(&mut other, format!("a symbolic link, at {child}"));
if report {
self.link(&child);
}
}
RawKind::Directory => entries.push(Entry {
name,
kind: EntryKind::Directory,
}),
RawKind::File => entries.push(Entry {
name,
kind: EntryKind::File,
}),
RawKind::Other => {
first(&mut other, format!("a special file, at {child}"));
if report {
self.diagnostics.push(Diagnostic::file(
&child,
Code::UnexpectedFile,
"not a regular file or directory",
));
}
}
}
}
(entries, other)
}
pub fn document(&mut self, path: &str) -> Option<Source> {
if self.stopped() {
self.diagnostics.skip();
return None;
}
match self.overlay.get(path) {
Some(Some(text)) => return self.overlay_document(path, text),
Some(None) => {
self.diagnostics
.push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
return None;
}
None => {}
}
let (file, length) = match self.root.file(&components(path)) {
Ok(opened) => opened,
Err(Refused::Link(link)) => {
self.link(&link);
return None;
}
Err(Refused::Unexpected) => {
self.diagnostics.push(Diagnostic::file(
path,
Code::UnexpectedFile,
"expected a regular file",
));
return None;
}
Err(Refused::Missing) => {
self.diagnostics
.push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
return None;
}
Err(Refused::Io(error)) => {
self.diagnostics.push(Diagnostic::file(
path,
Code::Io,
format!("cannot read file: {error}"),
));
return None;
}
};
let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
if length > limit {
self.diagnostics.push(Diagnostic::file(
path,
ErrorCode::InputTooLarge,
format!("file is larger than {limit} bytes"),
));
return None;
}
if !self.within_budget(length) {
self.exhaust(path);
return None;
}
let remaining = self.options.maximum_total_bytes.saturating_sub(self.bytes);
let mut bytes = Vec::new();
let bytes = match file
.take(limit.min(remaining).saturating_add(1))
.read_to_end(&mut bytes)
{
Ok(_) => bytes,
Err(error) => {
self.diagnostics.push(Diagnostic::file(
path,
Code::Io,
format!("cannot read file: {error}"),
));
return None;
}
};
let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
if length > limit {
self.diagnostics.push(Diagnostic::file(
path,
ErrorCode::InputTooLarge,
format!("file is larger than {limit} bytes"),
));
return None;
}
if !self.within_budget(length) {
self.exhaust(path);
return None;
}
self.parse(path, bytes)
}
fn overlay_document(&mut self, path: &str, text: &str) -> Option<Source> {
let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
let length = u64::try_from(text.len()).unwrap_or(u64::MAX);
if length > limit {
self.diagnostics.push(Diagnostic::file(
path,
ErrorCode::InputTooLarge,
format!("file is larger than {limit} bytes"),
));
return None;
}
if !self.within_budget(length) {
self.exhaust(path);
return None;
}
self.parse(path, text.as_bytes().to_vec())
}
fn parse(&mut self, path: &str, bytes: Vec<u8>) -> Option<Source> {
let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
self.files += 1;
self.bytes = self.bytes.saturating_add(length);
self.digests.insert(
path.to_owned(),
SourceFile {
length,
sha256: Sha256::digest(&bytes).into(),
},
);
let text = match String::from_utf8(bytes) {
Ok(text) => text,
Err(error) => {
self.diagnostics.push(Diagnostic::file(
path,
Code::Encoding,
format!("not UTF-8 at byte {}", error.utf8_error().valid_up_to()),
));
return None;
}
};
match Document::parse(text.as_str(), &self.options.limits) {
Ok(document) => Some(Source {
path: path.to_owned(),
document,
}),
Err(error) => {
self.diagnostics.push(Diagnostic::at(
path,
&Positions::new(&text),
error.offset,
error.offset,
error.code,
"invalid JSONC",
));
None
}
}
}
pub fn digest(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
for (path, file) in &self.digests {
hasher.update(path.as_bytes());
hasher.update([0]);
hasher.update(file.length.to_le_bytes());
hasher.update(file.sha256);
}
hasher.finalize().into()
}
pub fn take_files(&mut self) -> BTreeMap<String, SourceFile> {
std::mem::take(&mut self.digests)
}
}
fn overlay_beneath<'a>(
overlay: &'a Overlay,
path: &str,
) -> impl Iterator<Item = (&'a str, &'a Option<String>)> + 'a {
let prefix = if path.is_empty() {
String::new()
} else {
format!("{path}/")
};
overlay
.range(prefix.clone()..)
.map_while(move |(key, value)| key.strip_prefix(prefix.as_str()).map(|rest| (rest, value)))
}
pub(crate) fn ignored(
tree: &mut Tree<'_>,
path: &str,
entry: &Entry,
documents: bool,
) -> Result<(), String> {
let ends = |suffix: &str| {
let (name, suffix) = (entry.name.as_bytes(), suffix.as_bytes());
name.len() >= suffix.len() && name[name.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
};
let staging = entry.name.starts_with('.') && ends(".tilewright-tmp");
if entry.kind != EntryKind::Directory || (documents && ends(".jsonc")) || staging {
return Err(String::new());
}
tree.holds_no_file(path).map_err(|found| {
found.map_or_else(String::new, |found| {
format!("; it is not ignored as a directory without files, since it holds {found}")
})
})
}
pub(crate) fn join(directory: &str, name: &str) -> String {
if directory.is_empty() {
name.to_owned()
} else {
format!("{directory}/{name}")
}
}
#[cfg(all(test, unix))]
mod tests {
use super::{Found, RawKind, Refused};
use std::{fs, os::unix::fs::symlink};
#[test]
fn an_entry_the_overlay_removes_is_never_counted() {
use super::{LoadOptions, Overlay, Tree, platform};
use std::{cell::Cell, rc::Rc};
let root = tempfile::tempdir().unwrap();
for index in 0..10 {
fs::write(root.path().join(format!("{index}_0.jsonc")), "{}").unwrap();
}
let mut listing = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap());
let first = listing.next().unwrap();
let overlay: Overlay = listing.map(|name| (name, None)).collect();
let options = LoadOptions {
maximum_entries: 1,
..LoadOptions::default()
};
let total = Rc::new(Cell::new(0));
let mut tree = Tree::in_root(platform::Root::new(root.path()), &options, &overlay, total);
let listed = tree.list("");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, first);
assert!(!tree.exhausted);
}
fn tree() -> tempfile::TempDir {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("dir")).unwrap();
fs::write(root.path().join("dir/file.jsonc"), "{}").unwrap();
symlink(root.path().join("dir"), root.path().join("link")).unwrap();
symlink(
root.path().join("dir/file.jsonc"),
root.path().join("dir/file-link.jsonc"),
)
.unwrap();
root
}
fn link<T>(result: Result<T, Refused>) -> String {
match result {
Err(Refused::Link(prefix)) => prefix,
Err(other) => panic!("expected a link, got {other:?}"),
Ok(_) => panic!("expected a link, got success"),
}
}
macro_rules! access_tests {
($name:ident, $module:ident) => {
#[test]
fn $name() {
let directory = tree();
let root = super::$module::Root::new(directory.path());
assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
assert_eq!(
link(root.file(&["dir", "file-link.jsonc"])),
"dir/file-link.jsonc"
);
assert!(matches!(root.file(&["dir"]), Err(Refused::Unexpected)));
assert!(matches!(root.file(&["dir", "nope"]), Err(Refused::Missing)));
assert!(matches!(
root.file(&["dir", "file.jsonc", "x"]),
Err(Refused::Unexpected)
));
assert_eq!(root.kind(&["dir"]).unwrap(), Found::Directory);
assert_eq!(root.kind(&["dir", "file.jsonc"]).unwrap(), Found::File);
assert_eq!(root.kind(&["nope"]).unwrap(), Found::Missing);
assert_eq!(link(root.kind(&["link"])), "link");
assert_eq!(link(root.list(&["link"])), "link");
let mut entries: Vec<(Option<String>, RawKind)> = root
.list(&["dir"])
.unwrap()
.map(|entry| entry.map(|entry| (entry.name, entry.kind)))
.collect::<Result<_, _>>()
.unwrap();
entries.sort_by(|left, right| left.0.cmp(&right.0));
assert_eq!(
entries,
[
(Some("file-link.jsonc".to_owned()), RawKind::Link),
(Some("file.jsonc".to_owned()), RawKind::File),
]
);
}
};
}
access_tests!(beneath_refuses_links, beneath);
access_tests!(portable_refuses_links, portable);
macro_rules! write_tests {
($name:ident, $module:ident) => {
#[test]
fn $name() {
let directory = tree();
let root = super::$module::Root::new(directory.path());
assert!(matches!(
root.directory(&["new", "deeper"], false),
Err(Refused::Missing)
));
let made = root.directory(&["new", "deeper"], true).unwrap();
assert!(directory.path().join("new/deeper").is_dir());
assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
assert!(!directory.path().join("dir/inside").exists());
assert!(matches!(
root.directory(&["dir", "file.jsonc"], true),
Err(Refused::Unexpected)
));
let dir = root.directory(&["dir"], false).unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("target");
fs::write(&target, "outside").unwrap();
symlink(&target, directory.path().join("dir/staged")).unwrap();
dir.write("staged", None, b"new").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
fs::hard_link(&target, directory.path().join("dir/hard")).unwrap();
dir.write("hard", None, b"new").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
{
use std::os::unix::fs::PermissionsExt as _;
let file = directory.path().join("dir/file.jsonc");
fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap();
let like = dir.access("file.jsonc").unwrap();
assert!(dir.access("nope").unwrap().is_none());
dir.write("moded", like.as_ref(), b"x").unwrap();
let mode = fs::metadata(directory.path().join("dir/moded"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o640);
let mut own = dir.own_access().unwrap();
own.mode = 0o750;
dir.set_own_access(&own).unwrap();
assert_eq!(dir.own_access().unwrap(), own);
}
dir.rename("staged", "file.jsonc").unwrap();
assert_eq!(
fs::read_to_string(directory.path().join("dir/file.jsonc")).unwrap(),
"new"
);
assert!(dir.remove_file("hard").unwrap());
assert!(!dir.remove_file("hard").unwrap());
assert!(dir.remove_file("file-link.jsonc").unwrap());
assert!(target.exists());
dir.sync().unwrap();
made.sync().unwrap();
let top = root.directory(&["new"], false).unwrap();
fs::write(directory.path().join("new/deeper/x"), "").unwrap();
assert!(!top.remove_directory("deeper").unwrap());
fs::remove_file(directory.path().join("new/deeper/x")).unwrap();
assert!(top.remove_directory("deeper").unwrap());
assert!(!top.remove_directory("deeper").unwrap());
assert!(root.lock().unwrap().is_some());
assert!(root.make_directory(&["made"]).unwrap());
assert!(!root.make_directory(&["made"]).unwrap());
assert!(!root.make_directory(&["dir", "file.jsonc"]).unwrap());
assert!(matches!(
root.make_directory(&["nope", "deeper"]),
Err(Refused::Missing)
));
let clone = root.try_clone();
assert_eq!(clone.kind(&["dir"]).unwrap(), Found::Directory);
}
};
}
write_tests!(beneath_writes_beneath_the_root, beneath);
write_tests!(portable_writes_beneath_the_root, portable);
#[cfg(target_os = "linux")]
#[test]
fn a_written_file_with_access_has_no_inherited_access_control_list() {
use std::os::unix::fs::PermissionsExt as _;
let directory = tree();
let mut acl = 2_u32.to_le_bytes().to_vec();
for (tag, permissions, id) in [
(0x01_u16, 7_u16, u32::MAX),
(0x02, 4, 65534),
(0x04, 5, u32::MAX),
(0x10, 5, u32::MAX),
(0x20, 0, u32::MAX),
] {
acl.extend(tag.to_le_bytes());
acl.extend(permissions.to_le_bytes());
acl.extend(id.to_le_bytes());
}
let dir_path = directory.path().join("dir");
if rustix::fs::setxattr(
&dir_path,
"system.posix_acl_default",
&acl,
rustix::fs::XattrFlags::empty(),
)
.is_err()
{
return;
}
let root = super::beneath::Root::new(directory.path());
let dir = root.directory(&["dir"], false).unwrap();
assert!(dir.own_access().is_err());
let mut access = dir.access("file.jsonc").unwrap().unwrap();
access.mode = 0o640;
dir.write("new", Some(&access), b"x").unwrap();
let written = dir_path.join("new");
assert_eq!(
rustix::fs::getxattr(&written, "system.posix_acl_access", &mut [0_u8; 0][..]),
Err(rustix::io::Errno::NODATA)
);
assert_eq!(
fs::metadata(&written).unwrap().permissions().mode() & 0o777,
0o640
);
dir.write("default", None, b"x").unwrap();
assert!(
rustix::fs::getxattr(
dir_path.join("default"),
"system.posix_acl_access",
&mut [0_u8; 0][..]
)
.is_ok()
);
}
#[cfg(target_os = "linux")]
#[test]
fn labels_are_compared_and_never_given() {
use std::os::unix::fs::MetadataExt as _;
let label = |path: &std::path::Path, value: &[u8]| {
let metadata = fs::metadata(path).unwrap();
super::beneath::GIVEN_LABELS.with(|labels| {
labels
.borrow_mut()
.insert((metadata.dev(), metadata.ino()), value.to_vec())
});
};
let directory = tree();
let root = super::beneath::Root::new(directory.path());
let dir = root.directory(&["dir"], false).unwrap();
let unlabelled = dir.access("file.jsonc").unwrap().unwrap();
label(
&directory.path().join("dir/file.jsonc"),
b"system_u:object_r:secret_t:s0\0",
);
let labelled = dir.access("file.jsonc").unwrap().unwrap();
assert_eq!(labelled.mode, unlabelled.mode);
assert_ne!(labelled.control, unlabelled.control);
assert_ne!(labelled.audience(false), unlabelled.audience(false));
assert_eq!(labelled.new_file().control, labelled.control);
assert_eq!(
labelled.new_file().user,
rustix::process::geteuid().as_raw()
);
assert!(dir.write("new", Some(&labelled), b"secret").is_err());
assert!(!directory.path().join("dir/new").exists());
dir.write("new", Some(&unlabelled), b"x").unwrap();
label(
&directory.path().join("dir"),
b"system_u:object_r:secret_t:s0\0",
);
let own = dir.own_access().unwrap();
assert_ne!(own.control, [0; 32]);
let made = root.directory(&["made"], true).unwrap();
assert!(made.set_own_access(&own).is_err());
made.set_own_access(&made.own_access().unwrap()).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn only_local_file_systems_that_show_their_access_control_lists_are_checked() {
let directory = tree();
let root = super::beneath::Root::new(directory.path());
let dir = root.directory(&["dir"], false).unwrap();
let mounts = super::beneath::Mounts::read();
let told = dir.unchecked_file_system(&mounts).unwrap();
if fs::File::open("/proc/self/mountinfo").is_err() {
assert!(told.unwrap().starts_with("one that could not be told: "));
} else {
let id = dir.mount_id().unwrap().unwrap();
let found = mounts.type_of(id).unwrap();
let expected = super::beneath::unchecked_file_system(found, dir.magic().unwrap());
assert_eq!(told, expected);
if matches!(found, b"ext4" | b"tmpfs" | b"xfs" | b"btrfs") {
assert_eq!(told, None);
}
}
for (found, magic, name) in [
("ext4", 0xEF53, None),
("xfs", 0x5846_5342, None),
("btrfs", 0x9123_683E, None),
("tmpfs", 0x0102_1994, None),
("zfs", 0x2FC1_2FC1, None),
("9p", 0xEF53, Some("9p")),
("overlay", 0x794C_7630, Some("overlay")),
("cifs", 0xFF53_4D42, Some("cifs")),
("vboxsf", 0x786F_4256, Some("vboxsf")),
("fuse.a\tb", 0x6573_5546, Some("fuse.a\\tb")),
(
"ext4",
0x0102_1997,
Some("ext4, though statfs gives type 0x1021997"),
),
] {
super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(Some((found, magic))));
let told = dir.unchecked_file_system(&mounts).unwrap();
super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(None));
assert_eq!(told.as_deref(), name, "{found} {magic:x}");
}
}
#[cfg(target_os = "linux")]
#[test]
fn a_file_system_that_cannot_be_told_is_not_checked() {
use super::mounts::Mounts;
let directory = tree();
let root = super::beneath::Root::new(directory.path());
let dir = root.directory(&["dir"], false).unwrap();
let told = |mounts: &Mounts| dir.unchecked_file_system(mounts).unwrap();
let real = Mounts::read();
if fs::File::open("/proc/self/mountinfo").is_err() {
assert!(
told(&real)
.unwrap()
.starts_with("one that could not be told: ")
);
return;
}
let id = dir.mount_id().unwrap().unwrap();
let line = |id: u64, rest: &str| format!("{id} 1 8:1 / /mnt rw - {rest}\n");
for (mounts, reason) in [
(
Mounts::failed("it is not there"),
"it is not there".to_owned(),
),
(
Mounts::parsed(b""),
format!("its mount, {id}, is not in /proc/self/mountinfo"),
),
(
Mounts::parsed(line(id, "ext\\9 /dev/sda1 rw").as_bytes()),
"its line in /proc/self/mountinfo is malformed".to_owned(),
),
(
Mounts::parsed(
format!("{id} 1 8:1 / /{} rw - ext4 x rw\n", "a".repeat(70_000)).as_bytes(),
),
"its line in /proc/self/mountinfo is longer than 65536 bytes".to_owned(),
),
(
Mounts::parsed((line(id, "ext4 a rw") + &line(id, "ext4 b rw")).as_bytes()),
format!("its mount, {id}, is listed twice in /proc/self/mountinfo"),
),
] {
assert_eq!(
told(&mounts),
Some(format!("one that could not be told: {reason}"))
);
}
super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/nonexistent/fdinfo")));
let without_proc = told(&Mounts::failed("it is not there"));
let without_fdinfo = told(&real);
super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
assert_eq!(
without_proc.as_deref(),
Some("one that could not be told: it is not there")
);
let without_fdinfo = without_fdinfo.unwrap();
assert!(
without_fdinfo.starts_with(
"one that could not be told: statx gives no mount ID, and /proc/self/fdinfo could not be read ("
),
"{without_fdinfo}"
);
let found = real.type_of(id).unwrap().escape_ascii().to_string();
let mut table = line(id + 1, "fuse.\u{7f} x rw").into_bytes();
table.extend(format!("{} 1 8:1 / /mnt rw - fuse.", id + 2).as_bytes());
table.extend([0xFF, b' ', b'x', b'\n']);
table.extend(line(id, &format!("{found} x rw")).as_bytes());
table.extend(line(id + 3, &"\\040".repeat(400)).as_bytes());
assert_eq!(told(&Mounts::parsed(&table)), told(&real));
}
#[cfg(target_os = "linux")]
#[test]
fn mountinfo_is_read_a_line_at_a_time() {
use super::mounts::scan;
let text = b"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n\
36 22 0:32 / /mnt/with\\040space rw - 9p host rw,trans=virtio\n\
37 22 0:33 / /mnt/\xff\xfe rw master:2 shared:3 - fuse.s\\134sh\xff host rw\n\
38 22 8:1 / / rw ext4 /dev/sda1 rw\n\
x 1 8:1 / / rw - ext4 /dev/sda1 rw\n\
39 22 8:1 / / rw -\n\
40 22 8:1 / / rw - ext\\9 /dev/sda1 rw\n\
41 22 8:1 / / rw - xfs a rw";
let mounts = scan(std::io::BufReader::with_capacity(7, &text[..])).unwrap();
assert_eq!(mounts[&22].as_deref(), Ok(&b"ext4"[..]));
assert_eq!(mounts[&36].as_deref(), Ok(&b"9p"[..]));
assert_eq!(mounts[&37].as_deref(), Ok(&b"fuse.s\\sh\xff"[..]));
assert_eq!(mounts[&41].as_deref(), Ok(&b"xfs"[..]));
for id in [38, 39, 40] {
assert!(mounts[&id].is_err(), "{id}");
}
assert_eq!(mounts.len(), 7);
for gap in [0, 8 << 10] {
let mut long = b"22 1 8:1 / /".to_vec();
long.resize((64 << 10) + gap, b'a');
long.extend(b"99 1 8:1 / / rw - ext4 x rw\n23 1 8:1 / / rw - xfs a rw\n");
for capacity in [4 << 10, 8 << 10, 64 << 10, 1 << 20] {
let reader = std::io::BufReader::with_capacity(capacity, &long[..]);
let mounts = scan(reader).unwrap();
let at = format!("gap {gap}, buffer {capacity}");
assert!(
mounts[&22].as_ref().unwrap_err().contains("longer than"),
"{at}"
);
assert_eq!(mounts[&23].as_deref(), Ok(&b"xfs"[..]), "{at}");
assert_eq!(mounts.keys().copied().collect::<Vec<_>>(), [22, 23], "{at}");
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn fdinfo_gives_the_mount_statx_does() {
use rustix::fs::{AtFlags, StatxFlags, statx};
use std::os::fd::AsRawFd as _;
if !std::path::Path::new("/proc/self/fdinfo").is_dir() {
return;
}
let directory = tree();
let file = fs::File::open(directory.path().join("dir")).unwrap();
let from_statx = match statx(&file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) {
Ok(found) if found.stx_mask & StatxFlags::MNT_ID.bits() != 0 => Some(found.stx_mnt_id),
Ok(_) | Err(rustix::io::Errno::NOSYS) => None,
Err(errno) => panic!("{errno}"),
};
let fdinfo = super::mounts::fdinfo_mount_id("/proc/self/fdinfo", file.as_raw_fd()).unwrap();
assert!(fdinfo.is_some());
if from_statx.is_some() {
assert_eq!(fdinfo, from_statx);
}
let root = super::beneath::Root::new(directory.path());
let dir = root.directory(&["dir"], false).unwrap();
super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/proc/self/fdinfo")));
let fallback = dir.mount_id();
super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
let fallback = fallback.unwrap().unwrap();
assert_eq!(Some(fallback), fdinfo);
if let Some(id) = from_statx {
assert_eq!(fallback, id);
}
}
#[test]
fn the_lock_excludes_a_second_holder() {
let directory = tree();
let root = super::beneath::Root::new(directory.path());
let other = super::beneath::Root::new(directory.path());
let held = root.lock().unwrap();
assert!(held.is_some());
assert!(other.lock().unwrap().is_none());
drop(held);
assert!(other.lock().unwrap().is_some());
}
}
#[cfg(all(test, target_os = "linux"))]
mod selinux_tests {
use super::beneath::selinux::{request, trimmed};
#[test]
fn label_questions_are_one_line_with_the_name_encoded() {
let query = request(
b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023\0",
b"unconfined_u:object_r:user_home_t:s0\0",
7,
"0_0.jsonc",
)
.unwrap();
assert_eq!(
query,
b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 unconfined_u:object_r:user_home_t:s0 7 0_0.jsonc"
);
let odd = request(b"a", b"b", 1, "x y%+\u{e9}").unwrap();
assert_eq!(odd, b"a b 1 x%20y%25%2b%c3%a9");
assert!(request(b"a b", b"c", 1, "x").is_err());
assert!(request(b"", b"c", 1, "x").is_err());
assert_eq!(trimmed(b"label\0\n"), b"label");
assert_eq!(trimmed(b"\0"), b"");
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::{Found, RawKind, Refused, portable::Root};
use std::{
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
};
fn junction(link: &Path, target: &Path) {
let native = |path: &Path| path.components().collect::<PathBuf>();
let status = Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(native(link))
.arg(native(target))
.stdout(Stdio::null())
.status()
.unwrap();
assert!(status.success());
}
fn link<T>(result: Result<T, Refused>) -> String {
match result {
Err(Refused::Link(prefix)) => prefix,
Err(other) => panic!("expected a link, got {other:?}"),
Ok(_) => panic!("expected a link, got success"),
}
}
#[test]
fn junctions_are_links() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("dir")).unwrap();
fs::write(directory.path().join("dir/file.jsonc"), "{}").unwrap();
junction(
&directory.path().join("link"),
&directory.path().join("dir"),
);
let root = Root::new(directory.path());
assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
assert_eq!(link(root.kind(&["link"])), "link");
assert_eq!(link(root.directory(&["link"], false)), "link");
assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
assert!(!directory.path().join("dir/inside").exists());
let kinds: Vec<(Option<String>, RawKind)> = root
.list(&[])
.unwrap()
.map(|entry| entry.map(|entry| (entry.name, entry.kind)))
.collect::<Result<_, _>>()
.unwrap();
assert!(kinds.contains(&(Some("link".to_owned()), RawKind::Link)));
assert!(kinds.contains(&(Some("dir".to_owned()), RawKind::Directory)));
let base = root.directory(&[], false).unwrap();
base.write("link", None, b"x").unwrap();
assert!(directory.path().join("dir/file.jsonc").exists());
assert_eq!(root.kind(&["link"]).unwrap(), Found::File);
}
#[test]
fn read_only_files_are_refused() {
let directory = tempfile::tempdir().unwrap();
let file = directory.path().join("file.jsonc");
fs::write(&file, "{}").unwrap();
let base = Root::new(directory.path()).directory(&[], false).unwrap();
assert!(base.access("file.jsonc").unwrap().is_some());
let mut permissions = fs::metadata(&file).unwrap().permissions();
permissions.set_readonly(true);
fs::set_permissions(&file, permissions).unwrap();
assert!(matches!(base.access("file.jsonc"), Err(Refused::Io(_))));
}
#[test]
fn encrypted_files_and_directories_are_refused() {
let directory = tempfile::tempdir().unwrap();
let inside = directory.path().join("inside");
fs::create_dir(&inside).unwrap();
fs::write(inside.join("file.jsonc"), "{}").unwrap();
let encrypted = Command::new("cipher")
.arg("/e")
.arg("/s:".to_owned() + &inside.display().to_string())
.stdout(Stdio::null())
.status()
.is_ok_and(|status| status.success());
let root = Root::new(directory.path());
let dir = root.directory(&["inside"], false).unwrap();
assert!(
encrypted || std::env::var_os("CI").is_none(),
"cipher /e failed on CI"
);
if encrypted {
assert!(matches!(dir.access("file.jsonc"), Err(Refused::Io(_))));
assert!(matches!(dir.own_access(), Err(Refused::Io(_))));
} else {
assert!(dir.access("file.jsonc").unwrap().is_some());
}
}
#[test]
fn the_lock_is_a_file_held_alone() {
let directory = tempfile::tempdir().unwrap();
let lock_file = directory.path().join(crate::access::LOCK_NAME);
let root = Root::new(directory.path());
let other = Root::new(directory.path());
let held = root.lock().unwrap();
assert!(held.is_some());
assert!(other.lock().unwrap().is_none());
let base = root.directory(&[], false).unwrap();
base.write(".world.jsonc.tilewright-tmp", None, b"{}")
.unwrap();
base.write("world.jsonc", None, b"old").unwrap();
base.rename(".world.jsonc.tilewright-tmp", "world.jsonc")
.unwrap();
drop(held);
assert!(!lock_file.exists());
assert!(other.lock().unwrap().is_some());
fs::write(&lock_file, "someone's").unwrap();
assert!(matches!(root.lock(), Err(Refused::Io(_))));
assert_eq!(fs::read_to_string(&lock_file).unwrap(), "someone's");
}
}