use std::ffi::OsStr;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, anyhow};
use cap_std::fs::{Dir, OpenOptions};
use rand::distr::SampleString as _;
const NAME_TRIES: usize = 16;
#[derive(Debug)]
pub(super) struct Landing {
root: PathBuf,
dir: Dir,
}
impl Landing {
pub(super) fn open(out_root: &Path) -> anyhow::Result<Self> {
let out_root = if out_root.as_os_str().is_empty() {
Path::new(".")
} else {
out_root
};
std::fs::create_dir_all(out_root)
.with_context(|| format!("create the output directory {}", out_root.display()))?;
let dir = Dir::open_ambient_dir(out_root, cap_std::ambient_authority())
.with_context(|| format!("open the output directory {}", out_root.display()))?;
Ok(Self {
root: out_root.to_path_buf(),
dir,
})
}
pub(super) fn root(&self) -> &Path {
&self.root
}
pub(super) fn check(&self, dest: &Path) -> anyhow::Result<()> {
let rel = self.relative(dest)?;
let Some(parent) = rel.parent().filter(|p| !p.as_os_str().is_empty()) else {
return Ok(());
};
match self.dir.metadata(parent) {
Ok(meta) if meta.is_dir() => Ok(()),
Ok(_) => Err(anyhow!(
"{} cannot land: {} is not a directory",
rel.display(),
parent.display()
)),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(self.beneath(rel, "read the directory of", e)),
}
}
pub(super) fn materialize(&self, staging: &Path, dest: &Path) -> anyhow::Result<u64> {
let rel = self.relative(dest)?;
let (parent, name) = self.parent_of(rel)?;
copy_into(staging, &parent, name, dest)
}
pub(super) fn move_into_place(&self, staging: &Path, dest: &Path) -> anyhow::Result<u64> {
let len = file_len(staging)?;
let rel = self.relative(dest)?;
let (parent, name) = self.parent_of(rel)?;
let moved = self.relative(staging).and_then(|from| {
self.dir
.rename(from, &parent, name)
.map_err(anyhow::Error::from)
});
match moved {
Ok(()) => Ok(len),
Err(refused) => {
tracing::debug!(
staging = %staging.display(),
dest = %dest.display(),
error = %refused,
"move refused; copying the blob into place"
);
self.materialize(staging, dest)
.with_context(|| format!("copy after the move failed ({refused:#})"))
}
}
}
pub(super) fn link_or_copy_atomic(&self, src: &Path, dest: &Path) -> anyhow::Result<()> {
let rel = self.relative(dest)?;
let (parent, name) = self.parent_of(rel)?;
let src_rel = self.relative(src)?;
let linked = fresh_name(".decdn-link-", |candidate| {
self.dir.hard_link(src_rel, &parent, candidate)
});
let candidate = match linked {
Ok((candidate, ())) => candidate,
Err(refused) => {
tracing::debug!(
src = %src.display(),
dest = %dest.display(),
error = %refused,
"hard link refused; copying the blob into place"
);
return copy_into(src, &parent, name, dest).map(|_| ());
}
};
parent
.rename(&candidate, &parent, name)
.inspect_err(|_| remove_staged(&parent, &candidate))
.with_context(|| format!("write {}", dest.display()))
}
pub(super) fn first_write(
&self,
staging: &Path,
dest: &Path,
is_donor: bool,
) -> anyhow::Result<u64> {
if !is_donor {
return self.move_into_place(staging, dest);
}
let len = file_len(staging)?;
self.link_or_copy_atomic(staging, dest).map(|()| len)
}
fn relative<'p>(&self, path: &'p Path) -> anyhow::Result<&'p Path> {
let rel = path.strip_prefix(&self.root).map_err(|_| {
anyhow!(
"{} is not under the output directory {}",
path.display(),
self.root.display()
)
})?;
anyhow::ensure!(
!rel.as_os_str().is_empty(),
"{} is the output directory itself",
path.display()
);
Ok(rel)
}
fn parent_of<'p>(&self, rel: &'p Path) -> anyhow::Result<(Dir, &'p OsStr)> {
let name = rel
.file_name()
.ok_or_else(|| anyhow!("{} has no file name", rel.display()))?;
let parent = match rel.parent().filter(|p| !p.as_os_str().is_empty()) {
Some(p) => {
self.dir.create_dir_all(p).map_err(|e| {
let cause = match self.dir.metadata(p) {
Err(probe) if is_escape(&probe) => probe,
_ => e,
};
self.beneath(rel, "create the directory of", cause)
})?;
self.dir
.open_dir(p)
.map_err(|e| self.beneath(rel, "open the directory of", e))?
}
None => self
.dir
.try_clone()
.with_context(|| format!("reopen {}", self.root.display()))?,
};
Ok((parent, name))
}
fn beneath(&self, rel: &Path, what: &str, e: std::io::Error) -> anyhow::Error {
if is_escape(&e) {
self.escape(rel)
} else {
anyhow::Error::new(e).context(format!(
"{what} {} beneath {}",
rel.display(),
self.root.display()
))
}
}
fn escape(&self, rel: &Path) -> anyhow::Error {
anyhow!(
"{} resolves outside the output directory {}: a symlink in its path leads out of it",
rel.display(),
self.root.display()
)
}
}
fn copy_into(src: &Path, parent: &Dir, name: &OsStr, dest: &Path) -> anyhow::Result<u64> {
let mut from = std::fs::File::open(src).with_context(|| format!("open {}", src.display()))?;
let (staged, mut file) = fresh_name(".decdn-tmp-", |candidate| {
parent.open_with(candidate, OpenOptions::new().write(true).create_new(true))
})
.with_context(|| format!("stage {}", dest.display()))?;
std::io::copy(&mut from, &mut file)
.with_context(|| format!("copy {} -> {}", src.display(), dest.display()))
.and_then(|written| {
file.sync_all()
.with_context(|| format!("sync staged copy for {}", dest.display()))?;
parent
.rename(&staged, parent, name)
.with_context(|| format!("write {}", dest.display()))?;
Ok(written)
})
.inspect_err(|_| remove_staged(parent, &staged))
}
fn file_len(path: &Path) -> anyhow::Result<u64> {
let meta = std::fs::metadata(path).with_context(|| format!("stat {}", path.display()))?;
Ok(meta.len())
}
fn remove_staged(parent: &Dir, name: &str) {
if let Err(e) = parent.remove_file(name) {
tracing::warn!(name, error = %e, "failed to remove a staged file after its landing failed");
}
}
fn fresh_name<T, F>(prefix: &str, mut create: F) -> std::io::Result<(String, T)>
where
F: FnMut(&str) -> std::io::Result<T>,
{
let mut last = None;
for _ in 0..NAME_TRIES {
let candidate = format!(
"{prefix}{}",
rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 8)
);
match create(&candidate) {
Ok(created) => return Ok((candidate, created)),
Err(e) if e.kind() == ErrorKind::AlreadyExists => last = Some(e),
Err(e) => return Err(e),
}
}
Err(last.unwrap_or_else(|| std::io::Error::new(ErrorKind::AlreadyExists, "no free name")))
}
fn is_escape(e: &std::io::Error) -> bool {
e.kind() == ErrorKind::PermissionDenied && e.to_string().contains("led outside")
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests;