use std::io::Write;
use std::path::Path;
pub fn write_secret_file(path: &Path, content: &str) -> anyhow::Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("cannot write a secret to {}", path.display()))?;
let temp = dir.join(format!(".{}.{}.tmp", file_name, std::process::id()));
let _ = std::fs::remove_file(&temp);
let result = (|| -> anyhow::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&temp)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
drop(file);
std::fs::rename(&temp, path)?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&temp);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_file_is_owner_only() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secret");
write_secret_file(&path, "value").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "value");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn rewriting_a_loose_file_tightens_it() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secret");
std::fs::write(&path, "old").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
}
write_secret_file(&path, "new").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[cfg(unix)]
#[test]
fn the_new_secret_never_lives_in_the_old_loose_inode() {
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secret");
std::fs::write(&path, "old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let old_inode = std::fs::metadata(&path).unwrap().ino();
let stale = std::fs::File::open(&path).unwrap();
write_secret_file(&path, "new-secret").unwrap();
let new_inode = std::fs::metadata(&path).unwrap().ino();
assert_ne!(
old_inode, new_inode,
"the secret must land in a fresh 0600 inode, not the existing 0644 one"
);
let mut through_stale = String::new();
{
use std::io::Read;
let mut stale = stale;
stale.read_to_string(&mut through_stale).unwrap();
}
assert_eq!(
through_stale, "old",
"a handle opened before the write must never observe the new secret"
);
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn a_leftover_temp_file_does_not_taint_the_write() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secret");
let stale_temp = tmp
.path()
.join(format!(".secret.{}.tmp", std::process::id()));
std::fs::write(&stale_temp, "junk").unwrap();
std::fs::set_permissions(&stale_temp, std::fs::Permissions::from_mode(0o666)).unwrap();
write_secret_file(&path, "value").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "value");
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}