use allow_core::{CargoAllowError, CargoAllowResult};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
pub(crate) fn emit_text(output: Option<&Path>, contents: &str) -> CargoAllowResult<()> {
if let Some(path) = output {
write_file(path, contents)?;
} else {
println!("{contents}");
}
Ok(())
}
pub(crate) fn emit_stderr_text(output: Option<&Path>, contents: &str) -> CargoAllowResult<()> {
if let Some(path) = output {
write_file(path, contents)?;
} else {
eprintln!("{contents}");
}
Ok(())
}
pub(crate) fn write_file(path: impl AsRef<Path>, contents: &str) -> CargoAllowResult<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
CargoAllowError::new(format!("failed to create {}: {e}", parent.display()))
})?;
}
let tmp = sibling_tmp_path(path);
let mut file = match OpenOptions::new()
.write(true)
.create_new(true)
.truncate(true)
.open(&tmp)
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let _ = fs::remove_file(&tmp);
OpenOptions::new()
.write(true)
.create_new(true)
.truncate(true)
.open(&tmp)
.map_err(|e| {
CargoAllowError::new(format!("failed to open {}: {e}", tmp.display()))
})?
}
Err(e) => {
return Err(CargoAllowError::new(format!(
"failed to open {}: {e}",
tmp.display()
)));
}
};
if let Err(e) = file.write_all(contents.as_bytes()) {
let _ = fs::remove_file(&tmp);
return Err(CargoAllowError::new(format!(
"failed to write {}: {e}",
tmp.display()
)));
}
let _ = file.sync_all();
fs::rename(&tmp, path).map_err(|e| {
let _ = fs::remove_file(&tmp);
CargoAllowError::new(format!("failed to install {}: {e}", path.display()))
})
}
pub(crate) fn write_file_no_overwrite(
path: impl AsRef<Path>,
contents: &str,
force: bool,
) -> CargoAllowResult<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
CargoAllowError::new(format!("failed to create {}: {e}", parent.display()))
})?;
}
if force {
if path.exists() {
let bak = path.with_extension("toml.bak");
fs::rename(path, &bak).map_err(|e| {
CargoAllowError::new(format!(
"failed to back up {} -> {}: {e}",
path.display(),
bak.display()
))
})?;
}
return write_file(path, contents);
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.truncate(true)
.open(path)
.map_err(|e| {
let _ = e;
CargoAllowError::new(format!(
"{} already exists; use --force to overwrite",
path.display()
))
})?;
if let Err(e) = file.write_all(contents.as_bytes()) {
let _ = fs::remove_file(path);
return Err(CargoAllowError::new(format!(
"failed to write {}: {e}",
path.display()
)));
}
Ok(())
}
fn sibling_tmp_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(|s| s.to_os_string())
.unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[test]
fn emit_text_writes_to_output_path() -> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("emit-text")?;
let output = root.path().join("nested/report.txt");
let result = emit_text(Some(&output), "hello report\n");
assert!(result.is_ok());
assert_eq!(fs::read_to_string(&output)?, "hello report\n");
Ok(())
}
#[test]
fn emit_stderr_text_writes_to_output_path() -> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("emit-stderr-text")?;
let output = root.path().join("nested/summary.txt");
let result = emit_stderr_text(Some(&output), "summary\n");
assert!(result.is_ok());
assert_eq!(fs::read_to_string(&output)?, "summary\n");
Ok(())
}
#[test]
fn write_file_reports_parent_creation_errors() -> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("write-parent-error")?;
let file_parent = root.path().join("not-a-directory");
fs::write(&file_parent, "already a file")?;
let output = file_parent.join("report.txt");
let source_error = fs::create_dir_all(&file_parent)
.expect_err("creating a directory over a file should fail");
let err = write_file(&output, "contents").expect_err("parent creation should fail");
let message = err.to_string();
assert!(message.contains("failed to create"));
assert!(message.contains(&file_parent.display().to_string()));
assert_eq!(
err,
CargoAllowError::new(format!(
"failed to create {}: {}",
file_parent.display(),
source_error
))
);
Ok(())
}
#[test]
fn write_file_reports_file_write_errors() -> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("write-file-error")?;
let output = root.path().join("directory-target");
fs::create_dir_all(&output)?;
let err = write_file(&output, "contents").expect_err("writing to a directory should fail");
let message = err.to_string();
assert!(
message.contains("failed to open")
|| message.contains("failed to write")
|| message.contains("failed to install"),
"error should mention open, write, or install failure: {message}"
);
Ok(())
}
#[test]
fn write_file_no_overwrite_rejects_existing_path_without_force()
-> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("no-overwrite")?;
let output = root.path().join("policy/allow.toml");
write_file(&output, "original")?;
let err = write_file_no_overwrite(&output, "replacement", false)
.expect_err("existing file should require force");
assert!(err.to_string().contains("already exists"));
assert_eq!(fs::read_to_string(&output)?, "original");
Ok(())
}
#[test]
fn write_file_no_overwrite_replaces_existing_path_with_force()
-> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("force-overwrite")?;
let output = root.path().join("policy/allow.toml");
write_file(&output, "original")?;
let result = write_file_no_overwrite(&output, "replacement", true);
assert!(result.is_ok());
assert_eq!(fs::read_to_string(&output)?, "replacement");
Ok(())
}
#[test]
fn atomic_write_leaves_original_intact_on_rename_failure()
-> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("atomic-intact")?;
let target = root.path().join("dir/policy.toml");
write_file(&target, "original content")?;
let blocking_file = root.path().join("blocker");
fs::write(&blocking_file, "I am a file, not a directory")?;
let impossible_target = blocking_file.join("nested/policy.toml");
let result = write_file(&impossible_target, "new content");
assert!(result.is_err(), "write to an impossible path should fail");
assert_eq!(fs::read_to_string(&target)?, "original content");
Ok(())
}
#[test]
fn write_file_cleans_up_temp_after_failure() -> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("atomic-cleanup")?;
let target = root.path().join("policy/allow.toml");
let tmp = sibling_tmp_path(&target);
fs::create_dir_all(&tmp)?;
let result = write_file(&target, "first");
assert!(
result.is_err(),
"write should fail when its temp is a directory"
);
fs::remove_dir_all(&tmp)?;
assert!(
!tmp.exists(),
"no stale temp file should remain after a failed write"
);
write_file(&target, "recovered")?;
assert_eq!(fs::read_to_string(&target)?, "recovered");
assert!(
!tmp.exists(),
"successful write must not leave its temp behind"
);
Ok(())
}
#[test]
fn write_file_recoverable_after_stale_temp_from_prior_crash()
-> Result<(), Box<dyn std::error::Error>> {
let root = TempRoot::new("stale-temp-recover")?;
let target = root.path().join("policy/allow.toml");
let tmp = sibling_tmp_path(&target);
let parent = target
.parent()
.unwrap_or_else(|| std::panic::panic_any("test target must have a parent directory"));
fs::create_dir_all(parent)?;
fs::write(&tmp, "leftover from a crashed write")?;
write_file(&target, "the real content")?;
assert_eq!(fs::read_to_string(&target)?, "the real content");
assert!(!tmp.exists(), "the stale temp must be replaced/removed");
Ok(())
}
#[test]
fn write_file_no_overwrite_with_force_creates_backup() -> Result<(), Box<dyn std::error::Error>>
{
let root = TempRoot::new("force-backup")?;
let output = root.path().join("policy/allow.toml");
write_file(&output, "original")?;
write_file_no_overwrite(&output, "replacement", true)?;
assert_eq!(fs::read_to_string(&output)?, "replacement");
let bak = output.with_extension("toml.bak");
assert_eq!(fs::read_to_string(&bak)?, "original");
Ok(())
}
struct TempRoot {
path: PathBuf,
}
impl TempRoot {
fn new(label: &str) -> std::io::Result<Self> {
let unique = format!(
"cargo-allow-io-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|err| {
std::panic::panic_any(format!("system time before epoch: {err}"))
})
.as_nanos()
);
let path = std::env::temp_dir().join(unique);
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path)?;
Ok(Self { path })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
}