use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overwrite {
Forbid,
Replace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refused {
Appeared,
ReadOnly,
Directory,
NotAFile,
}
impl Refused {
pub fn of(error: &io::Error) -> Option<Self> {
error.get_ref()?.downcast_ref::<Self>().copied()
}
fn kind(self) -> io::ErrorKind {
match self {
Self::Appeared => io::ErrorKind::AlreadyExists,
Self::ReadOnly => io::ErrorKind::PermissionDenied,
Self::Directory => io::ErrorKind::IsADirectory,
Self::NotAFile => io::ErrorKind::InvalidInput,
}
}
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Appeared => "a file appeared there during the export and was left as it was",
Self::ReadOnly => "the file is read-only",
Self::Directory => "it is a directory",
Self::NotAFile => "it is not a regular file",
})
}
}
impl std::error::Error for Refused {}
impl From<Refused> for io::Error {
fn from(refused: Refused) -> Self {
io::Error::new(refused.kind(), refused)
}
}
#[derive(Debug)]
pub struct OutputFile {
temp: tempfile::NamedTempFile,
target: PathBuf,
overwrite: Overwrite,
}
impl OutputFile {
pub fn create(path: &Path, overwrite: Overwrite) -> io::Result<Self> {
let target = resolve_link(path)?;
replaceable(&target, overwrite)?;
let dir = match target.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => Path::new("."),
};
let temp = tempfile::Builder::new()
.prefix(".datui-")
.suffix(&temp_suffix(&target))
.tempfile_in(dir)?;
Ok(Self {
temp,
target,
overwrite,
})
}
pub fn file(&mut self) -> &mut File {
self.temp.as_file_mut()
}
pub fn path(&self) -> &Path {
self.temp.path()
}
pub fn commit(self) -> io::Result<()> {
let Self {
temp,
target,
overwrite,
} = self;
temp.as_file().sync_all()?;
match overwrite {
Overwrite::Replace => {
let existing = replaceable(&target, Overwrite::Replace)?;
set_final_permissions(&temp, existing.as_ref())?;
temp.persist(&target).map_err(|e| e.error)?;
Ok(())
}
Overwrite::Forbid => {
set_final_permissions(&temp, None)?;
Self::persist_new(temp, &target)
}
}
}
fn persist_new(temp: tempfile::NamedTempFile, target: &Path) -> io::Result<()> {
match temp.persist_noclobber(target) {
Ok(_) => Ok(()),
Err(e) if e.error.kind() == io::ErrorKind::AlreadyExists => {
Err(Refused::Appeared.into())
}
Err(e) => {
if fs::symlink_metadata(target).is_ok() {
return Err(Refused::Appeared.into());
}
e.file.persist(target).map(drop).map_err(|e| e.error)
}
}
}
}
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
let mut path = path.to_path_buf();
for _ in 0..40 {
match fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_symlink() => {
let link = fs::read_link(&path)?;
path = match path.parent() {
Some(dir) => dir.join(link),
None => link,
};
}
_ => return Ok(path),
}
}
Err(io::Error::other(format!(
"{} is a loop of symbolic links",
path.display()
)))
}
fn replaceable(target: &Path, overwrite: Overwrite) -> io::Result<Option<fs::Permissions>> {
let meta = match fs::metadata(target) {
Ok(meta) => meta,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if overwrite == Overwrite::Forbid {
return Err(Refused::Appeared.into());
}
if meta.is_dir() {
return Err(Refused::Directory.into());
}
if !meta.is_file() {
return Err(Refused::NotAFile.into());
}
if meta.permissions().readonly() {
return Err(Refused::ReadOnly.into());
}
fs::OpenOptions::new().write(true).open(target)?;
Ok(Some(meta.permissions()))
}
fn temp_suffix(target: &Path) -> String {
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if name.len() <= 200 {
return format!("-{name}");
}
match target.extension() {
Some(ext) if ext.len() <= 32 => format!(".{}", ext.to_string_lossy()),
_ => String::new(),
}
}
#[cfg(unix)]
fn set_final_permissions(
temp: &tempfile::NamedTempFile,
existing: Option<&fs::Permissions>,
) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let permissions = match existing {
Some(permissions) => permissions.clone(),
None => fs::Permissions::from_mode(fresh_mode(temp.path())?),
};
temp.as_file().set_permissions(permissions)
}
#[cfg(not(unix))]
fn set_final_permissions(
_temp: &tempfile::NamedTempFile,
_existing: Option<&fs::Permissions>,
) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn fresh_mode(temp: &Path) -> io::Result<u32> {
use std::os::unix::fs::PermissionsExt;
let dir = match temp.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => Path::new("."),
};
let probe = tempfile::Builder::new()
.prefix(".datui-mode-")
.permissions(fs::Permissions::from_mode(0o666))
.tempfile_in(dir)?;
Ok(probe.as_file().metadata()?.permissions().mode() & 0o777)
}
#[cfg(test)]
mod tests;