use std::path::{Path, PathBuf};
use crate::inode::ContentHandle;
use crate::slab_source::SlabSource;
use crate::{CoreError, Inode, MetadataBlob};
pub trait LiveTreeSink {
fn on_directory(&mut self, abs_path: &Path) -> Result<(), CoreError>;
fn on_directory_inode(&mut self, abs_path: &Path, inode: &Inode) -> Result<(), CoreError> {
let _ = inode;
self.on_directory(abs_path)
}
fn on_regular_file(&mut self, abs_path: &Path, inode: &Inode) -> Result<(), CoreError>;
fn on_symlink(&mut self, abs_path: &Path, target: &str) -> Result<(), CoreError>;
fn on_symlink_inode(
&mut self,
abs_path: &Path,
inode: &Inode,
target: &str,
) -> Result<(), CoreError> {
let _ = inode;
self.on_symlink(abs_path, target)
}
fn on_other(&mut self, _abs_path: &Path, _inode: &Inode) -> Result<(), CoreError> {
Ok(())
}
}
pub fn walk_live_tree(
blob: &MetadataBlob,
root_inode_number: u64,
sink: &mut dyn LiveTreeSink,
) -> Result<(), CoreError> {
let root = blob
.inode_by_number(root_inode_number)
.ok_or_else(|| CoreError::Corrupt {
reason: format!("walk_live_tree: root inode {root_inode_number} missing"),
})?;
let mut visited: Vec<u64> = Vec::new();
walk_dir(blob, root, Path::new(""), sink, &mut visited)
}
pub fn validate_tree_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("is empty");
}
if name == "." {
return Err("is '.'");
}
if name == ".." {
return Err("is '..'");
}
if name.starts_with('/') {
return Err("is absolute");
}
if name.contains('/') {
return Err("contains a '/' separator");
}
if name.contains('\0') {
return Err("contains a NUL byte");
}
if cfg!(windows) && name.contains('\\') {
return Err("contains a '\\' separator");
}
Ok(())
}
fn walk_dir(
blob: &MetadataBlob,
dir_inode: &Inode,
dir_path: &Path,
sink: &mut dyn LiveTreeSink,
visited: &mut Vec<u64>,
) -> Result<(), CoreError> {
let hash = match &dir_inode.content_handle {
ContentHandle::Directory(h) => *h,
_ => return Ok(()),
};
if visited.contains(&dir_inode.number) {
return Ok(());
}
visited.push(dir_inode.number);
sink.on_directory_inode(dir_path, dir_inode)?;
let node = blob
.dir_node_by_hash(&hash)
.ok_or_else(|| CoreError::Corrupt {
reason: format!(
"walk_live_tree: directory node for hash {} missing",
hex_prefix(&hash)
),
})?;
for entry in &node.entries {
if let Err(reason) = validate_tree_name(&entry.name) {
return Err(CoreError::Corrupt {
reason: format!(
"walk_live_tree: directory node {} entry name {:?} {reason}",
hex_prefix(&hash),
entry.name
),
});
}
let child_path = if dir_path.as_os_str().is_empty() {
PathBuf::from(&entry.name)
} else {
dir_path.join(&entry.name)
};
let child = blob
.inode_by_number(entry.inode_number)
.ok_or_else(|| CoreError::Corrupt {
reason: format!("walk_live_tree: inode {} missing", entry.inode_number),
})?;
match &child.content_handle {
ContentHandle::Directory(_) => {
walk_dir(blob, child, &child_path, sink, visited)?;
}
ContentHandle::InlineData(_) | ContentHandle::SliceMap(_) => {
sink.on_regular_file(&child_path, child)?;
}
ContentHandle::Symlink(target) => {
sink.on_symlink_inode(&child_path, child, target)?;
}
_ => {
sink.on_other(&child_path, child)?;
}
}
}
Ok(())
}
fn hex_prefix(bytes: &[u8]) -> String {
bytes
.iter()
.take(4)
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join("")
}
fn decode_slice<S: SlabSource + ?Sized>(
store: &S,
slice: &crate::inode::SliceRef,
) -> Result<Vec<u8>, CoreError> {
let plaintext = store
.plaintext_for(slice.drop_id.as_bytes())
.ok_or_else(|| CoreError::Corrupt {
reason: format!(
"file_plaintext: drop {:02x?} not in any slab",
&slice.drop_id.as_bytes()[..4]
),
})?
.map_err(|e| CoreError::Corrupt {
reason: format!("file_plaintext: decompress: {e}"),
})?;
let start = usize::try_from(slice.drop_byte_start).unwrap_or(0);
let len = usize::try_from(slice.drop_byte_len).unwrap_or(plaintext.len());
let end = start.saturating_add(len).min(plaintext.len());
if start > plaintext.len() || end > plaintext.len() {
return Err(CoreError::Corrupt {
reason: format!(
"file_plaintext: slice range {start}..{end} outside drop of len {}",
plaintext.len()
),
});
}
Ok(plaintext[start..end].to_vec())
}
const DECODE_BATCH_SLICES: usize = 64;
const DECODE_PARALLEL_THRESHOLD: usize = 8;
pub struct FileChunks<'a, S: SlabSource + ?Sized> {
inode: &'a Inode,
store: Option<&'a S>,
idx: usize,
inline: Option<Vec<u8>>,
done: bool,
}
impl<'a, S: SlabSource + ?Sized> FileChunks<'a, S> {
#[must_use]
pub fn new(inode: &'a Inode, store: Option<&'a S>) -> Self {
match &inode.content_handle {
ContentHandle::InlineData(data) => Self {
inode,
store,
idx: 0,
inline: Some(data.clone()),
done: false,
},
ContentHandle::SliceMap(_) => Self {
inode,
store,
idx: 0,
inline: None,
done: false,
},
_ => Self {
inode,
store,
idx: 0,
inline: None,
done: true,
},
}
}
}
impl<S: SlabSource + ?Sized> Iterator for FileChunks<'_, S> {
type Item = Result<Vec<u8>, CoreError>;
fn next(&mut self) -> Option<Self::Item> {
if self.done {
return None;
}
if let Some(data) = self.inline.take() {
self.done = true;
return Some(Ok(data));
}
let ContentHandle::SliceMap(slices) = &self.inode.content_handle else {
self.done = true;
return None;
};
if self.idx >= slices.len() {
self.done = true;
return None;
}
let store = match self.store {
Some(s) => s,
None => {
self.done = true;
return Some(Err(CoreError::Corrupt {
reason: "file_chunks: slice-backed file but no slab store provided".into(),
}));
}
};
let slice = &slices[self.idx];
self.idx += 1;
Some(decode_slice(store, slice))
}
}
pub fn file_chunks<'a, S: SlabSource + ?Sized>(
inode: &'a Inode,
store: Option<&'a S>,
) -> FileChunks<'a, S> {
FileChunks::new(inode, store)
}
pub fn file_plaintext<S: SlabSource + ?Sized>(
inode: &Inode,
slab_store: Option<&S>,
) -> Result<Vec<u8>, CoreError> {
match &inode.content_handle {
ContentHandle::InlineData(data) => Ok(data.clone()),
ContentHandle::SliceMap(slices) => {
let store = slab_store.ok_or_else(|| CoreError::Corrupt {
reason: "file_plaintext: slice-backed file but no slab store provided".into(),
})?;
let mut out = Vec::new();
if slices.len() >= DECODE_PARALLEL_THRESHOLD {
use rayon::prelude::*;
for batch in slices.chunks(DECODE_BATCH_SLICES) {
let parts: Result<Vec<Vec<u8>>, CoreError> =
batch.par_iter().map(|s| decode_slice(store, s)).collect();
for part in parts? {
out.extend_from_slice(&part);
}
}
return Ok(out);
}
for slice in slices {
out.extend_from_slice(&decode_slice(store, slice)?);
}
Ok(out)
}
_ => Ok(Vec::new()),
}
}
#[cfg(unix)]
pub fn apply_unix_identity(path: &Path, mode: u32, mtime_ns: u64) {
use std::os::unix::fs::PermissionsExt as _;
let secs = mtime_ns / 1_000_000_000;
let nanos = u32::try_from(mtime_ns % 1_000_000_000).unwrap_or(0);
if let Some(t) =
std::time::SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::new(secs, nanos))
{
let times = std::fs::FileTimes::new().set_modified(t);
let file = std::fs::File::options()
.read(true)
.write(true)
.open(path)
.or_else(|_| std::fs::File::open(path));
if let Ok(file) = file {
let _ = file.set_times(times);
}
}
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode & 0o7777));
}
#[cfg(not(unix))]
pub fn apply_unix_identity(path: &Path, _mode: u32, mtime_ns: u64) {
let secs = mtime_ns / 1_000_000_000;
let nanos = u32::try_from(mtime_ns % 1_000_000_000).unwrap_or(0);
let Some(t) =
std::time::SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::new(secs, nanos))
else {
return;
};
if let Ok(f) = std::fs::File::options().write(true).read(true).open(path) {
let _ = f.set_modified(t);
}
}
pub struct FilesystemSink<'a> {
root: &'a Path,
slab_store: Option<&'a dyn SlabSource>,
dirs: Vec<DirIdentity>,
seen_inodes: std::collections::HashMap<u64, PathBuf>,
}
impl<'a> FilesystemSink<'a> {
#[must_use]
pub fn new(root: &'a Path, slab_store: Option<&'a dyn SlabSource>) -> Self {
Self {
root,
slab_store,
dirs: Vec::new(),
seen_inodes: std::collections::HashMap::new(),
}
}
pub fn finish(&mut self) -> &[DirIdentity] {
for dir in &self.dirs {
apply_unix_identity(&dir.path, dir.mode, dir.mtime_ns);
}
&self.dirs
}
}
impl<'a> LiveTreeSink for FilesystemSink<'a> {
fn on_directory(&mut self, abs_path: &Path) -> Result<(), CoreError> {
let path = if abs_path.as_os_str().is_empty() {
self.root.to_path_buf()
} else {
self.root.join(abs_path)
};
std::fs::create_dir_all(&path).map_err(io_to_core)
}
fn on_directory_inode(&mut self, abs_path: &Path, inode: &Inode) -> Result<(), CoreError> {
self.on_directory(abs_path)?;
let path = if abs_path.as_os_str().is_empty() {
self.root.to_path_buf()
} else {
self.root.join(abs_path)
};
self.dirs.push(DirIdentity {
path,
mode: inode.mode,
mtime_ns: inode.mtime_ns,
xattrs: inode.xattrs.clone(),
});
Ok(())
}
fn on_regular_file(&mut self, abs_path: &Path, inode: &Inode) -> Result<(), CoreError> {
let path = self.root.join(abs_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(io_to_core)?;
}
if let Some(first) = self.seen_inodes.get(&inode.number) {
if std::fs::hard_link(first, &path).is_ok() {
return Ok(());
}
} else {
self.seen_inodes.insert(inode.number, path.clone());
}
use std::io::Write as _;
let mut out = std::io::BufWriter::new(std::fs::File::create(&path).map_err(io_to_core)?);
for chunk in file_chunks(inode, self.slab_store) {
let chunk = chunk?;
out.write_all(&chunk).map_err(io_to_core)?;
}
out.flush().map_err(io_to_core)?;
drop(out);
apply_unix_identity(&path, inode.mode, inode.mtime_ns);
Ok(())
}
fn on_symlink(&mut self, abs_path: &Path, target: &str) -> Result<(), CoreError> {
let path = self.root.join(abs_path);
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, &path).map_err(io_to_core)
}
#[cfg(windows)]
{
create_symlink_best_effort(&path, target)
}
#[cfg(not(any(unix, windows)))]
{
let _ = path;
let _ = target;
Ok(())
}
}
}
pub struct DropIdCollectorSink {
pub drop_ids: std::collections::HashSet<[u8; 32]>,
}
impl Default for DropIdCollectorSink {
fn default() -> Self {
Self {
drop_ids: std::collections::HashSet::new(),
}
}
}
impl LiveTreeSink for DropIdCollectorSink {
fn on_directory(&mut self, _abs_path: &Path) -> Result<(), CoreError> {
Ok(())
}
fn on_regular_file(&mut self, _abs_path: &Path, inode: &Inode) -> Result<(), CoreError> {
if let ContentHandle::SliceMap(slices) = &inode.content_handle {
for slice in slices {
self.drop_ids.insert(*slice.drop_id.as_bytes());
}
}
Ok(())
}
fn on_symlink(&mut self, _abs_path: &Path, _target: &str) -> Result<(), CoreError> {
Ok(())
}
}
pub struct DirIdentity {
pub path: PathBuf,
pub mode: u32,
pub mtime_ns: u64,
pub xattrs: Vec<crate::inode::XAttr>,
}
pub struct ParallelExtractSink<'a> {
root: &'a Path,
pub tasks: Vec<(PathBuf, Inode)>,
pub dirs: Vec<DirIdentity>,
pub links: Vec<(PathBuf, PathBuf)>,
seen_inodes: std::collections::HashMap<u64, PathBuf>,
pub dir_count: usize,
}
impl<'a> ParallelExtractSink<'a> {
#[must_use]
pub fn new(root: &'a Path) -> Self {
Self {
root,
tasks: Vec::new(),
dirs: Vec::new(),
links: Vec::new(),
seen_inodes: std::collections::HashMap::new(),
dir_count: 0,
}
}
}
impl<'a> LiveTreeSink for ParallelExtractSink<'a> {
fn on_directory(&mut self, abs_path: &Path) -> Result<(), CoreError> {
let path = if abs_path.as_os_str().is_empty() {
self.root.to_path_buf()
} else {
self.root.join(abs_path)
};
std::fs::create_dir_all(&path).map_err(io_to_core)?;
self.dir_count += 1;
Ok(())
}
fn on_directory_inode(&mut self, abs_path: &Path, inode: &Inode) -> Result<(), CoreError> {
self.on_directory(abs_path)?;
let path = if abs_path.as_os_str().is_empty() {
self.root.to_path_buf()
} else {
self.root.join(abs_path)
};
self.dirs.push(DirIdentity {
path,
mode: inode.mode,
mtime_ns: inode.mtime_ns,
xattrs: inode.xattrs.clone(),
});
Ok(())
}
fn on_regular_file(&mut self, abs_path: &Path, inode: &Inode) -> Result<(), CoreError> {
if let Some(first) = self.seen_inodes.get(&inode.number) {
self.links.push((self.root.join(abs_path), first.clone()));
return Ok(());
}
self.seen_inodes
.insert(inode.number, self.root.join(abs_path));
self.tasks.push((self.root.join(abs_path), inode.clone()));
Ok(())
}
fn on_symlink(&mut self, abs_path: &Path, target: &str) -> Result<(), CoreError> {
let path = self.root.join(abs_path);
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, &path).map_err(io_to_core)?;
}
#[cfg(windows)]
{
create_symlink_best_effort(&path, target)?;
}
#[cfg(not(any(unix, windows)))]
{
let _ = (path, target);
}
Ok(())
}
}
#[cfg(windows)]
fn create_symlink_best_effort(path: &Path, target: &str) -> Result<(), CoreError> {
use std::os::windows::fs as win_fs;
if win_fs::symlink_file(target, path)
.or_else(|_| win_fs::symlink_dir(target, path))
.is_ok()
{
return Ok(());
}
Ok(())
}
fn io_to_core(e: std::io::Error) -> CoreError {
CoreError::Corrupt {
reason: format!("live_tree: I/O: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::merkle::hash_section;
fn build_blob_with_one_file() -> (MetadataBlob, u64, [u8; 32], Vec<u8>) {
let plaintext = b"hello live tree".to_vec();
let file_inode = make_regular_inline_inode(2, 0o100_644, &plaintext);
let entries = vec![("hello.txt".to_string(), 2u64, 0x01u8)];
let dir_node_bytes = make_dir_node_bytes(&entries);
let dir_hash = hash_section(&dir_node_bytes);
let root_inode = make_directory_inode(1, dir_hash);
let inode_count: u32 = 2;
let dir_node_count: u32 = 1;
let mut bytes = Vec::new();
bytes.extend_from_slice(&inode_count.to_le_bytes());
bytes.extend_from_slice(&root_inode);
bytes.extend_from_slice(&file_inode);
bytes.extend_from_slice(&dir_node_count.to_le_bytes());
bytes.extend_from_slice(&dir_node_bytes);
let mut cursor = crate::cursor::ManifestCursor::new(&bytes);
let blob = crate::metadata::parse_metadata_blob(&mut cursor).expect("parse");
(blob, 1, [0u8; 32], plaintext)
}
fn make_regular_inline_inode(number: u64, mode: u32, data: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&number.to_le_bytes());
bytes.extend_from_slice(&mode.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u64.to_le_bytes());
bytes.extend_from_slice(&0u64.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.push(crate::inode::INODE_FLAG_INLINE_DATA);
let len = u32::try_from(data.len()).unwrap();
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(data);
bytes
}
fn make_directory_inode(number: u64, hash: [u8; 32]) -> Vec<u8> {
let mode = crate::inode::S_IFDIR | 0o755;
let mut bytes = Vec::new();
bytes.extend_from_slice(&number.to_le_bytes());
bytes.extend_from_slice(&mode.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u64.to_le_bytes());
bytes.extend_from_slice(&0u64.to_le_bytes());
bytes.extend_from_slice(&2u32.to_le_bytes());
bytes.push(0);
bytes.extend_from_slice(&hash);
bytes
}
fn make_dir_node_bytes(entries: &[(String, u64, u8)]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.push(1u8); let count = u32::try_from(entries.len()).unwrap();
bytes.extend_from_slice(&count.to_le_bytes());
for (name, ino, etype) in entries {
let name_bytes = name.as_bytes();
let name_len = u32::try_from(name_bytes.len()).unwrap();
bytes.extend_from_slice(&name_len.to_le_bytes());
bytes.extend_from_slice(name_bytes);
bytes.extend_from_slice(&ino.to_le_bytes());
bytes.push(*etype);
}
bytes
}
#[test]
fn walk_visits_root_and_children() {
let (blob, root, _drop_id, plaintext) = build_blob_with_one_file();
let temp = std::env::temp_dir().join(format!(
"limnifs-live-tree-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0),
));
let mut sink = FilesystemSink::new(&temp, None);
walk_live_tree(&blob, root, &mut sink).expect("walk");
assert!(temp.is_dir());
let hello_path = temp.join("hello.txt");
assert!(hello_path.is_file());
let recovered = std::fs::read(&hello_path).expect("read");
assert_eq!(recovered, plaintext);
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn drop_id_collector_gathers_slice_refs() {
let (blob, root, _drop_id, _plaintext) = build_blob_with_one_file();
let mut sink = DropIdCollectorSink::default();
walk_live_tree(&blob, root, &mut sink).expect("walk");
assert!(
sink.drop_ids.is_empty(),
"inline-only tree references no drops"
);
}
fn build_blob_with_named_entry(name: &str) -> MetadataBlob {
let plaintext = b"hostile".to_vec();
let file_inode = make_regular_inline_inode(2, 0o100_644, &plaintext);
let entries = vec![(name.to_string(), 2u64, 0x01u8)];
let dir_node_bytes = make_dir_node_bytes(&entries);
let dir_hash = hash_section(&dir_node_bytes);
let root_inode = make_directory_inode(1, dir_hash);
let mut bytes = Vec::new();
bytes.extend_from_slice(&2u32.to_le_bytes());
bytes.extend_from_slice(&root_inode);
bytes.extend_from_slice(&file_inode);
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&dir_node_bytes);
let mut cursor = crate::cursor::ManifestCursor::new(&bytes);
crate::metadata::parse_metadata_blob(&mut cursor).expect("parse")
}
#[test]
fn walk_refuses_unsafe_entry_names() {
for name in ["..", "."] {
let blob = build_blob_with_named_entry(name);
let mut sink = DropIdCollectorSink::default();
let err = walk_live_tree(&blob, 1, &mut sink)
.expect_err("unsafe entry name must refuse the walk");
assert!(
matches!(err, CoreError::Corrupt { ref reason } if reason.contains("entry name")),
"name {name:?}: wrong error {err:?}"
);
}
}
#[cfg(windows)]
#[test]
fn windows_extract_stamps_mtime_and_keeps_link_names() {
let dir = std::env::temp_dir().join(format!(
"limnifs-win-fidelity-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0),
));
let src = dir.join("src");
let out = dir.join("out");
std::fs::create_dir_all(&src).expect("mkdir src");
std::fs::create_dir_all(&out).expect("mkdir out");
const MTIME_NS: u64 = 1_700_000_123_000_000_000;
let config: &'static limnifs_write::WriteConfig =
Box::leak(Box::new(limnifs_write::WriteConfig::default_v0_1()));
let mut w = limnifs_write::stream::StreamWriter::new(config).expect("writer");
w.add_file(
"f.txt",
limnifs_write::stream::EntryMeta::new(MTIME_NS, 0o644),
&[],
&mut b"body\n".as_slice(),
)
.expect("file");
w.add_symlink(
"lnk",
"f.txt",
limnifs_write::stream::EntryMeta::new(1, 0o777),
)
.expect("symlink");
let artifact = w.finish().expect("finish");
let mut cursor = crate::cursor::ManifestCursor::new(&artifact.bytes);
crate::parse_manifest_header(&mut cursor).expect("header");
crate::parse_feature_flags_section(&mut cursor).expect("flags");
let meta_ref = crate::parse_metadata_reference(&mut cursor).expect("meta");
let inline = meta_ref.inline_metadata.as_ref().expect("inline");
let mut blob_cursor = crate::cursor::ManifestCursor::new(inline);
let blob = crate::metadata::parse_metadata_blob(&mut blob_cursor).expect("blob");
let root_ino = blob.root_inode_number().expect("root");
let mut sink = FilesystemSink::new(&out, None);
walk_live_tree(&blob, root_ino, &mut sink).expect("walk");
let got = std::fs::metadata(out.join("f.txt"))
.expect("file extracted")
.modified()
.expect("mtime");
let got_ns = got
.duration_since(std::time::UNIX_EPOCH)
.expect("post-epoch")
.as_nanos() as i128;
let want_ns = i128::from(MTIME_NS);
assert!(
(got_ns - want_ns).abs() < 2_000_000_000,
"mtime drifted {got_ns} vs {want_ns}"
);
let link = std::fs::symlink_metadata(out.join("lnk")).expect("link name preserved");
if link.file_type().is_symlink() {
assert_eq!(
std::fs::read_link(out.join("lnk"))
.expect("read link")
.to_str()
.unwrap_or_default(),
"f.txt"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn validate_tree_name_accepts_plain_components() {
assert!(validate_tree_name("hello.txt").is_ok());
assert!(validate_tree_name("a b").is_ok());
assert!(validate_tree_name("..hidden").is_ok());
assert!(validate_tree_name("...").is_ok());
assert!(validate_tree_name(r"a\b").is_ok() || cfg!(windows));
}
#[test]
fn validate_tree_name_refuses_encoding_and_traversal() {
for name in ["", ".", "..", "/etc/passwd", "a/b", "nul\0"] {
assert!(validate_tree_name(name).is_err(), "name {name:?}");
}
if cfg!(windows) {
assert!(validate_tree_name(r"\\evil\share").is_err());
}
}
}