use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use bytes::{BufMut, BytesMut};
use serde::{Deserialize, Serialize};
use super::blob::{
chunk_payload, BlobAdapter, BlobError, BlobRef, ChunkRef, Encoding, MeshBlobAdapter,
BLOB_CHUNK_SIZE_BYTES,
};
use crate::adapter::net::MeshNode;
pub const DIR_MANIFEST_VERSION: u8 = 1;
pub const DEFAULT_FETCH_CONCURRENCY: usize = 16;
const BLOCKING_FS_THRESHOLD: u64 = 256 * 1024;
pub const DEFAULT_INFLIGHT_BUDGET_BYTES: usize = 8 * 1024 * 1024;
fn in_flight_byte_permits(len: u64, budget: u32) -> u32 {
len.min(BLOB_CHUNK_SIZE_BYTES).min(budget as u64).max(1) as u32
}
#[derive(Debug)]
pub enum DirError {
Io(std::io::Error),
Blob(BlobError),
UnsafePath(String),
Manifest(String),
}
impl std::fmt::Display for DirError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "dir transfer io: {e}"),
Self::Blob(e) => write!(f, "dir transfer blob: {e}"),
Self::UnsafePath(p) => write!(f, "dir transfer: unsafe manifest path {p:?}"),
Self::Manifest(m) => write!(f, "dir transfer: bad manifest: {m}"),
}
}
}
impl std::error::Error for DirError {}
impl From<std::io::Error> for DirError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<BlobError> for DirError {
fn from(e: BlobError) -> Self {
Self::Blob(e)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirEntry {
pub path: String,
pub kind: EntryKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntryKind {
File {
mode: u32,
blob: Vec<u8>,
},
Dir {
mode: u32,
},
Symlink {
target: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirManifest {
pub version: u8,
pub entries: Vec<DirEntry>,
}
impl DirManifest {
pub fn file_count(&self) -> usize {
self.entries
.iter()
.filter(|e| matches!(e.kind, EntryKind::File { .. }))
.count()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DirStats {
pub files: usize,
pub dirs: usize,
pub symlinks: usize,
pub bytes: u64,
}
pub async fn store_dir(adapter: &MeshBlobAdapter, root: &Path) -> Result<BlobRef, DirError> {
let root_buf = root.to_path_buf();
let mut raw = tokio::task::spawn_blocking(
move || -> Result<Vec<(String, std::fs::Metadata, PathBuf)>, DirError> {
let mut raw = Vec::new();
walk(&root_buf, &root_buf, &mut raw)?;
Ok(raw)
},
)
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))??;
raw.sort_by(|a, b| a.0.cmp(&b.0));
use futures::stream::StreamExt;
let store_concurrency = DEFAULT_FETCH_CONCURRENCY;
let store_budget = u32::try_from(DEFAULT_INFLIGHT_BUDGET_BYTES).unwrap_or(u32::MAX);
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(store_concurrency));
let byte_sem = std::sync::Arc::new(tokio::sync::Semaphore::new(store_budget as usize));
let abort = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let file_futures = raw.into_iter().map(|(rel, meta, abs)| {
let sem = sem.clone();
let byte_sem = byte_sem.clone();
let abort = abort.clone();
let mode = mode_of(&meta);
async move {
if abort.load(std::sync::atomic::Ordering::Relaxed) {
return Ok::<Option<DirEntry>, DirError>(None);
}
let file_type = meta.file_type();
if file_type.is_symlink() {
let target = std::fs::read_link(&abs)?;
return Ok::<Option<DirEntry>, DirError>(Some(DirEntry {
path: rel,
kind: EntryKind::Symlink {
target: target.to_string_lossy().into_owned(),
},
}));
}
if file_type.is_dir() {
return Ok(Some(DirEntry {
path: rel,
kind: EntryKind::Dir { mode },
}));
}
if file_type.is_file() {
let _permit = sem.acquire().await.map_err(|_| {
DirError::Blob(BlobError::Backend("dir store: semaphore closed".into()))
})?;
let in_flight = in_flight_byte_permits(meta.len(), store_budget);
let _bytes_permit = byte_sem.acquire_many(in_flight).await.map_err(|_| {
DirError::Blob(BlobError::Backend(
"dir store: byte semaphore closed".into(),
))
})?;
let bytes = if meta.len() > BLOCKING_FS_THRESHOLD {
tokio::task::spawn_blocking(move || std::fs::read(&abs))
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))??
} else {
std::fs::read(&abs)?
};
let chunked = chunk_payload(&bytes)?;
let hash: [u8; 32] = blake3::hash(&bytes).into();
let uri = format!("mesh://{}", hex(&hash));
let blob_ref = chunked.into_blob_ref(uri, Encoding::Replicated)?;
adapter.store(&blob_ref, &bytes).await?;
return Ok(Some(DirEntry {
path: rel,
kind: EntryKind::File {
mode,
blob: blob_ref.encode(),
},
}));
}
Ok(None)
}
});
let mut results: Vec<DirEntry> = Vec::new();
let mut first_err: Option<DirError> = None;
let mut stream = futures::stream::iter(file_futures).buffer_unordered(store_concurrency);
while let Some(res) = stream.next().await {
match res {
Ok(Some(entry)) => results.push(entry),
Ok(None) => {}
Err(e) => {
abort.store(true, std::sync::atomic::Ordering::Relaxed);
if first_err.is_none() {
first_err = Some(e);
}
}
}
}
if let Some(e) = first_err {
return Err(e);
}
results.sort_by(|a, b| a.path.cmp(&b.path));
let entries = results;
let manifest = DirManifest {
version: DIR_MANIFEST_VERSION,
entries,
};
let manifest_bytes =
postcard::to_allocvec(&manifest).map_err(|e| DirError::Manifest(format!("encode: {e}")))?;
let chunked = chunk_payload(&manifest_bytes)?;
let mhash: [u8; 32] = blake3::hash(&manifest_bytes).into();
let manifest_ref =
chunked.into_blob_ref(format!("mesh://{}", hex(&mhash)), Encoding::Replicated)?;
adapter.store(&manifest_ref, &manifest_bytes).await?;
Ok(manifest_ref)
}
fn walk(
root: &Path,
dir: &Path,
out: &mut Vec<(String, std::fs::Metadata, PathBuf)>,
) -> Result<(), DirError> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let abs = entry.path();
let meta = std::fs::symlink_metadata(&abs)?;
let rel = rel_path(root, &abs);
let is_dir_descend = meta.file_type().is_dir() && !meta.file_type().is_symlink();
out.push((rel, meta, abs.clone()));
if is_dir_descend {
walk(root, &abs, out)?;
}
}
Ok(())
}
fn rel_path(root: &Path, abs: &Path) -> String {
let rel = abs.strip_prefix(root).unwrap_or(abs);
let mut parts: Vec<String> = Vec::new();
for comp in rel.components() {
if let Component::Normal(c) = comp {
parts.push(c.to_string_lossy().into_owned());
}
}
parts.join("/")
}
#[cfg(unix)]
fn mode_of(meta: &std::fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode()
}
#[cfg(not(unix))]
fn mode_of(_meta: &std::fs::Metadata) -> u32 {
0
}
pub async fn fetch_dir(
node: &Arc<MeshNode>,
source: u64,
manifest_ref: &BlobRef,
dest: &Path,
concurrency: usize,
) -> Result<DirStats, DirError> {
let manifest_bytes = transfer_fetch_blob(node, source, manifest_ref).await?;
let manifest: DirManifest = postcard::from_bytes(&manifest_bytes)
.map_err(|e| DirError::Manifest(format!("decode: {e}")))?;
if manifest.version != DIR_MANIFEST_VERSION {
return Err(DirError::Manifest(format!(
"unsupported manifest version {}",
manifest.version
)));
}
let dest = dest.to_path_buf();
let work = alloc_temp_dir(&dest).await?;
let stats = match reconstruct_tree(node, source, &manifest, &work, concurrency).await {
Ok(stats) => stats,
Err(e) => {
let work = work.clone();
let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&work)).await;
return Err(e);
}
};
install_tree(work, dest).await?;
Ok(stats)
}
async fn reconstruct_tree(
node: &Arc<MeshNode>,
source: u64,
manifest: &DirManifest,
root: &Path,
concurrency: usize,
) -> Result<DirStats, DirError> {
let root = root.to_path_buf();
let mut stats = DirStats::default();
let mut want_dirs: BTreeSet<PathBuf> = BTreeSet::new();
for entry in &manifest.entries {
let safe = safe_join(&root, &entry.path)?;
match &entry.kind {
EntryKind::Dir { .. } => {
want_dirs.insert(safe);
}
EntryKind::File { .. } | EntryKind::Symlink { .. } => {
if let Some(parent) = safe.parent() {
want_dirs.insert(parent.to_path_buf());
}
}
}
}
let root_for_dirs = root.clone();
stats.dirs = tokio::task::spawn_blocking(move || -> Result<usize, DirError> {
std::fs::create_dir_all(&root_for_dirs)?;
let mut n = 0;
for dir in &want_dirs {
if !dir.exists() {
std::fs::create_dir_all(dir)?;
n += 1;
}
}
Ok(n)
})
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))??;
let concurrency = if concurrency == 0 {
DEFAULT_FETCH_CONCURRENCY
} else {
concurrency
};
let sem = Arc::new(tokio::sync::Semaphore::new(concurrency));
let budget = u32::try_from(DEFAULT_INFLIGHT_BUDGET_BYTES).unwrap_or(u32::MAX);
let byte_sem = Arc::new(tokio::sync::Semaphore::new(budget as usize));
let mut tasks = Vec::new();
for entry in &manifest.entries {
let EntryKind::File { mode, blob } = &entry.kind else {
continue;
};
let safe = safe_join(&root, &entry.path)?;
let blob_ref = BlobRef::decode(blob)
.map_err(DirError::Blob)?
.ok_or_else(|| DirError::Manifest(format!("entry {} has no blob ref", entry.path)))?;
let in_flight = in_flight_byte_permits(blob_ref.size(), budget);
let node = node.clone();
let sem = sem.clone();
let byte_sem = byte_sem.clone();
let mode = *mode;
tasks.push(tokio::spawn(async move {
let _permit = sem.acquire().await.map_err(|_| {
DirError::Blob(BlobError::Backend("dir fetch: semaphore closed".into()))
})?;
let _bytes_permit = byte_sem.acquire_many(in_flight).await.map_err(|_| {
DirError::Blob(BlobError::Backend(
"dir fetch: byte semaphore closed".into(),
))
})?;
match &blob_ref {
BlobRef::Manifest { chunks, .. } => {
fetch_blob_to_file(&node, source, chunks, &safe, mode).await
}
_ => {
let bytes = transfer_fetch_blob(&node, source, &blob_ref).await?;
let len = bytes.len() as u64;
if len > BLOCKING_FS_THRESHOLD {
tokio::task::spawn_blocking(move || write_file(&safe, &bytes, mode))
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))??;
} else {
write_file(&safe, &bytes, mode)?;
}
Ok::<u64, DirError>(len)
}
}
}));
}
for task in tasks {
match task.await {
Ok(Ok(n)) => {
stats.files += 1;
stats.bytes += n;
}
Ok(Err(e)) => return Err(e),
Err(join) => {
return Err(DirError::Blob(BlobError::Backend(format!(
"dir fetch task panicked: {join}"
))))
}
}
}
let mut symlink_paths: BTreeSet<Vec<String>> = BTreeSet::new();
for entry in &manifest.entries {
if let EntryKind::Symlink { .. } = &entry.kind {
if let Some(c) = normal_components(&entry.path) {
symlink_paths.insert(c);
}
}
}
let mut links: Vec<(String, PathBuf)> = Vec::new();
for entry in &manifest.entries {
if let EntryKind::Symlink { target } = &entry.kind {
let safe = safe_join(&root, &entry.path)?;
check_link_target(&entry.path, target, &symlink_paths)?;
links.push((target.clone(), safe));
}
}
stats.symlinks = tokio::task::spawn_blocking(move || {
links
.into_iter()
.filter(|(target, safe)| make_symlink(target, safe).is_ok())
.count()
})
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))?;
Ok(stats)
}
fn unique_suffix() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
seq ^ nanos.rotate_left(17) ^ (std::process::id() as u64).rotate_left(43)
}
async fn alloc_temp_dir(dest: &Path) -> Result<PathBuf, DirError> {
let parent = match dest.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
let base = dest
.file_name()
.ok_or_else(|| DirError::UnsafePath(dest.to_string_lossy().into_owned()))?
.to_string_lossy()
.into_owned();
tokio::task::spawn_blocking(move || -> Result<PathBuf, DirError> {
std::fs::create_dir_all(&parent)?;
for _ in 0..8 {
let work = parent.join(format!(".{base}.fetch_{:016x}", unique_suffix()));
match std::fs::create_dir(&work) {
Ok(()) => return Ok(work),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(DirError::Io(e)),
}
}
Err(DirError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"fetch_dir: could not allocate a unique temp directory",
)))
})
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))?
}
async fn install_tree(work: PathBuf, dest: PathBuf) -> Result<(), DirError> {
tokio::task::spawn_blocking(move || -> Result<(), DirError> {
if !dest.exists() {
return std::fs::rename(&work, &dest).map_err(|e| {
let _ = std::fs::remove_dir_all(&work);
DirError::Io(e)
});
}
let parent = match dest.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
let base = dest
.file_name()
.ok_or_else(|| DirError::UnsafePath(dest.to_string_lossy().into_owned()))?
.to_string_lossy()
.into_owned();
let mut backup = None;
for _ in 0..8 {
let cand = parent.join(format!(".{base}.replaced_{:016x}", unique_suffix()));
if !cand.exists() {
backup = Some(cand);
break;
}
}
let backup = backup.ok_or_else(|| {
DirError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"fetch_dir: could not allocate a backup path",
))
})?;
std::fs::rename(&dest, &backup).map_err(DirError::Io)?;
if let Err(e) = std::fs::rename(&work, &dest) {
let _ = std::fs::rename(&backup, &dest);
let _ = std::fs::remove_dir_all(&work);
return Err(DirError::Io(e));
}
let _ = std::fs::remove_dir_all(&backup);
Ok(())
})
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))?
}
async fn transfer_fetch_blob(
node: &Arc<MeshNode>,
source: u64,
blob_ref: &BlobRef,
) -> Result<bytes::Bytes, DirError> {
match blob_ref {
BlobRef::Small { hash, .. } => Ok(node.transfer_fetch_chunk(source, *hash).await?),
BlobRef::Manifest { chunks, .. } => {
let mut buf = BytesMut::with_capacity(blob_ref.size() as usize);
for chunk in chunks {
let bytes = node.transfer_fetch_chunk(source, chunk.hash).await?;
buf.put_slice(&bytes);
}
Ok(buf.freeze())
}
BlobRef::Tree { .. } => Err(DirError::Blob(BlobError::Backend(
"dir transfer: BlobRef::Tree not supported by the directory wrapper".into(),
))),
}
}
async fn fetch_blob_to_file(
node: &Arc<MeshNode>,
source: u64,
chunks: &[ChunkRef],
path: &Path,
mode: u32,
) -> Result<u64, DirError> {
let create_path = path.to_path_buf();
let mut file = tokio::task::spawn_blocking(move || std::fs::File::create(&create_path))
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))??;
let mut written: u64 = 0;
for chunk in chunks {
let bytes = node.transfer_fetch_chunk(source, chunk.hash).await?;
written += bytes.len() as u64;
file = tokio::task::spawn_blocking(move || -> std::io::Result<std::fs::File> {
use std::io::Write as _;
let mut f = file;
f.write_all(&bytes)?;
Ok(f)
})
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))??;
}
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
use std::io::Write as _;
file.flush()
})
.await
.map_err(|e| DirError::Io(std::io::Error::other(e)))??;
apply_mode(path, mode)?;
Ok(written)
}
fn safe_join(dest: &Path, rel: &str) -> Result<PathBuf, DirError> {
if rel.is_empty() {
return Err(DirError::UnsafePath(rel.to_owned()));
}
let mut out = dest.to_path_buf();
for comp in Path::new(rel).components() {
match comp {
Component::Normal(c) => out.push(c),
_ => return Err(DirError::UnsafePath(rel.to_owned())),
}
}
Ok(out)
}
fn fold_component(c: &str) -> String {
use unicode_normalization::UnicodeNormalization;
c.to_lowercase().nfc().collect()
}
fn normal_components(rel: &str) -> Option<Vec<String>> {
let mut out = Vec::new();
for comp in Path::new(rel).components() {
match comp {
Component::Normal(c) => out.push(fold_component(&c.to_string_lossy())),
Component::CurDir => {}
_ => return None,
}
}
Some(out)
}
fn check_link_target(
link_rel: &str,
target: &str,
symlinks: &BTreeSet<Vec<String>>,
) -> Result<(), DirError> {
let t = Path::new(target);
if t.is_absolute() {
return Err(DirError::UnsafePath(target.to_owned()));
}
let mut stack =
normal_components(link_rel).ok_or_else(|| DirError::UnsafePath(link_rel.to_owned()))?;
stack.pop();
for i in 1..=stack.len() {
if symlinks.contains(&stack[..i].to_vec()) {
return Err(DirError::UnsafePath(link_rel.to_owned()));
}
}
let comps: Vec<Component> = t.components().collect();
for (idx, comp) in comps.iter().enumerate() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if stack.pop().is_none() {
return Err(DirError::UnsafePath(target.to_owned()));
}
}
Component::Normal(c) => {
stack.push(fold_component(&c.to_string_lossy()));
let is_final = idx + 1 == comps.len();
if !is_final && symlinks.contains(&stack) {
return Err(DirError::UnsafePath(target.to_owned()));
}
}
_ => return Err(DirError::UnsafePath(target.to_owned())),
}
}
Ok(())
}
fn write_file(path: &Path, bytes: &[u8], mode: u32) -> Result<(), DirError> {
std::fs::write(path, bytes)?;
apply_mode(path, mode)?;
Ok(())
}
#[cfg(unix)]
fn apply_mode(path: &Path, mode: u32) -> Result<(), DirError> {
if mode != 0 {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
}
Ok(())
}
#[cfg(not(unix))]
fn apply_mode(_path: &Path, _mode: u32) -> Result<(), DirError> {
Ok(())
}
#[cfg(unix)]
fn make_symlink(target: &str, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn make_symlink(target: &str, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(target, link)
}
#[cfg(not(any(unix, windows)))]
fn make_symlink(_target: &str, _link: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symlinks unsupported on this platform",
))
}
fn hex(hash: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for b in hash {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_flight_byte_permits_clamps_before_casting() {
let budget = u32::try_from(DEFAULT_INFLIGHT_BUDGET_BYTES).unwrap();
let chunk = u32::try_from(BLOB_CHUNK_SIZE_BYTES).unwrap();
assert_eq!(in_flight_byte_permits(0, budget), 1);
assert_eq!(in_flight_byte_permits(1024, budget), 1024);
assert_eq!(in_flight_byte_permits(BLOB_CHUNK_SIZE_BYTES, budget), chunk);
assert_eq!(
in_flight_byte_permits(BLOB_CHUNK_SIZE_BYTES + 1, budget),
chunk
);
assert_eq!(
in_flight_byte_permits(u64::from(u32::MAX) + 2, budget),
chunk
);
assert_eq!(in_flight_byte_permits(u64::MAX, budget), chunk);
assert_eq!(in_flight_byte_permits(u64::MAX, 64), 64);
}
#[test]
fn safe_join_accepts_plain_relative_paths() {
let dest = Path::new("/tmp/dest");
let p = safe_join(dest, "a/b/c.txt").unwrap();
assert!(p.ends_with("a/b/c.txt") || p.ends_with("a\\b\\c.txt"));
}
#[test]
fn safe_join_rejects_escapes() {
let dest = Path::new("/tmp/dest");
assert!(safe_join(dest, "../escape").is_err());
assert!(safe_join(dest, "a/../../escape").is_err());
assert!(safe_join(dest, "/abs/path").is_err());
assert!(safe_join(dest, "").is_err());
}
fn no_symlinks() -> BTreeSet<Vec<String>> {
BTreeSet::new()
}
#[test]
fn check_link_target_accepts_in_tree_targets() {
let s = no_symlinks();
assert!(check_link_target("sub/link", "file.txt", &s).is_ok());
assert!(check_link_target("sub/link", "../other/file.txt", &s).is_ok());
assert!(check_link_target("a/b/link", "../../c", &s).is_ok());
assert!(check_link_target("link", "./peer", &s).is_ok());
}
#[test]
fn check_link_target_rejects_escapes() {
let s = no_symlinks();
assert!(check_link_target("link", "/etc/passwd", &s).is_err());
assert!(check_link_target("link", "../etc", &s).is_err());
assert!(check_link_target("a/b/link", "../../../../etc", &s).is_err());
assert!(check_link_target("link", "../../a/b", &s).is_err());
}
#[test]
fn check_link_target_rejects_composed_symlink_escape() {
let mut s = BTreeSet::new();
s.insert(vec!["a".to_string()]);
assert!(check_link_target("a", ".", &s).is_ok());
assert!(check_link_target("b", "a/sub/../../etc", &s).is_err());
assert!(check_link_target("b", "a", &s).is_ok());
}
#[test]
fn check_link_target_rejects_composed_escape_with_different_case() {
let mut s = BTreeSet::new();
s.insert(normal_components("a").unwrap());
assert!(check_link_target("b", "A/sub/../../etc", &s).is_err());
let mut s2 = BTreeSet::new();
s2.insert(normal_components("Dir").unwrap());
assert!(check_link_target("b", "dir/sub/../../../etc", &s2).is_err());
assert!(check_link_target("A/inner", "x", &s).is_err());
}
#[test]
fn check_link_target_rejects_composed_escape_with_different_unicode_form() {
const NFC: &str = "caf\u{00E9}"; const NFD: &str = "cafe\u{0301}"; assert_ne!(NFC, NFD);
assert_eq!(fold_component(NFC), fold_component(NFD));
let mut s = BTreeSet::new();
s.insert(normal_components(NFC).unwrap());
assert!(check_link_target("b", &format!("{NFD}/sub/../../etc"), &s).is_err());
let upper_nfd = format!("CAFE{}", "\u{0301}");
assert!(check_link_target("b", &format!("{upper_nfd}/x/../../../etc"), &s).is_err());
}
#[test]
fn check_link_target_rejects_link_under_a_symlinked_parent() {
let mut s = BTreeSet::new();
s.insert(vec!["d".to_string()]);
assert!(check_link_target("d/inner", "file.txt", &s).is_err());
}
#[test]
fn manifest_round_trips_through_postcard() {
let manifest = DirManifest {
version: DIR_MANIFEST_VERSION,
entries: vec![
DirEntry {
path: "dir".into(),
kind: EntryKind::Dir { mode: 0o755 },
},
DirEntry {
path: "dir/file.txt".into(),
kind: EntryKind::File {
mode: 0o644,
blob: BlobRef::small("mesh://x", [7u8; 32], 3).encode(),
},
},
DirEntry {
path: "link".into(),
kind: EntryKind::Symlink {
target: "dir/file.txt".into(),
},
},
],
};
let bytes = postcard::to_allocvec(&manifest).unwrap();
let decoded: DirManifest = postcard::from_bytes(&bytes).unwrap();
assert_eq!(decoded, manifest);
assert_eq!(decoded.file_count(), 1);
}
}