use std::path::Path;
pub const OWNER_ONLY_FILE_MODE: u32 = 0o600;
pub fn create_staging_file(
parent: &Path,
prefix: &str,
suffix: &str,
) -> std::io::Result<tempfile::NamedTempFile> {
for (label, part) in [("prefix", prefix), ("suffix", suffix)] {
if part.contains(['/', '\\']) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("staging file {label} must not contain a path separator: {part:?}"),
));
}
}
let mut builder = tempfile::Builder::new();
builder.prefix(prefix).suffix(suffix);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
builder.permissions(std::fs::Permissions::from_mode(OWNER_ONLY_FILE_MODE));
}
builder.tempfile_in(parent)
}
pub fn sync_dir(dir: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
match std::fs::File::open(dir)?.sync_all() {
Err(err) if err.kind() == std::io::ErrorKind::InvalidInput => Ok(()),
other => other,
}
}
#[cfg(not(unix))]
{
let _ = dir;
Ok(())
}
}
pub fn publish(tmp: tempfile::NamedTempFile, path: &Path) -> std::io::Result<()> {
tmp.as_file().sync_all()?;
tmp.persist(path).map_err(|err| err.error)?;
sync_dir(path.parent().unwrap_or_else(|| Path::new(".")))
}
pub fn write_json<T>(path: &Path, prefix: &str, value: &T) -> std::io::Result<()>
where
T: serde::Serialize + ?Sized,
{
use std::io::Write as _;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = create_staging_file(parent, prefix, ".tmp")?;
let json = serde_json::to_string_pretty(value).map_err(std::io::Error::other)?;
tmp.write_all(json.as_bytes())?;
tmp.write_all(b"\n")?;
publish(tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_staging_file_lands_in_the_parent_it_was_given() {
let dir = tempfile::tempdir().unwrap();
let tmp = create_staging_file(dir.path(), "kv", ".tmp").unwrap();
assert_eq!(tmp.path().parent(), Some(dir.path()));
}
#[test]
fn the_name_carries_the_prefix_and_the_suffix() {
let dir = tempfile::tempdir().unwrap();
let tmp = create_staging_file(dir.path(), "barks", ".tmp").unwrap();
let name = tmp.path().file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("barks"), "{name}");
assert!(name.ends_with(".tmp"), "{name}");
assert!(name.len() > "barks.tmp".len(), "no unique middle: {name}");
}
#[test]
fn a_path_separator_is_refused_in_either_argument() {
let dir = tempfile::tempdir().unwrap();
for (prefix, suffix) in [
("../escape", ".tmp"),
("kv", "/etc/passwd"),
("..\\escape", ".tmp"),
("kv", "\\tmp"),
] {
let err = create_staging_file(dir.path(), prefix, suffix)
.expect_err("a separator must not reach tempfile");
assert_eq!(
err.kind(),
std::io::ErrorKind::InvalidInput,
"{prefix:?} {suffix:?}: {err:?}"
);
}
}
#[test]
fn a_refused_rename_takes_the_staging_file_with_it() {
let dir = tempfile::tempdir().unwrap();
let occupied = dir.path().join("a-directory");
std::fs::create_dir(&occupied).unwrap();
let tmp = create_staging_file(dir.path(), "kv", ".tmp").unwrap();
publish(tmp, &occupied).expect_err("a rename over a directory must fail");
assert!(occupied.is_dir(), "the target was replaced anyway");
assert_eq!(entry_names(dir.path()), vec!["a-directory"]);
}
#[test]
fn write_json_writes_pretty_json_under_one_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.json");
let value = std::collections::BTreeMap::from([("one", 1), ("two", 2)]);
write_json(&path, "kv", &value).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{\n \"one\": 1,\n \"two\": 2\n}\n"
);
assert_eq!(entry_names(dir.path()), vec!["store.json"]);
}
#[test]
fn write_json_refuses_a_prefix_holding_a_separator() {
let dir = tempfile::tempdir().unwrap();
let err = write_json(&dir.path().join("store.json"), "../escape", &1)
.expect_err("a separator must not reach tempfile");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "{err:?}");
}
fn entry_names(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[test]
fn sync_dir_accepts_a_real_directory() {
let dir = tempfile::tempdir().unwrap();
sync_dir(dir.path()).unwrap();
}
#[test]
#[cfg(unix)]
fn sync_dir_reports_a_directory_that_is_not_there() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("never-created");
let err = sync_dir(&missing).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "{err:?}");
}
}