use std::io::Write;
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use anyhow::Context;
use log::warn;
#[cfg(unix)]
pub fn harden(path: &Path, mode: u32) -> anyhow::Result<()> {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("failed to harden permissions on {}", path.display()))
}
#[cfg(not(unix))]
pub fn harden(_path: &Path, _mode: u32) -> anyhow::Result<()> {
Ok(())
}
#[cfg(unix)]
fn is_loose(mode: u32) -> bool {
mode & 0o077 != 0
}
#[cfg(unix)]
pub fn warn_if_loose(path: &Path, recommended: u32) {
if let Ok(meta) = std::fs::metadata(path) {
let mode = meta.permissions().mode() & 0o777;
if is_loose(mode) {
warn!(
"{} is accessible by other users (mode {:03o}); run `chmod {:o} {}` to secure it",
path.display(), mode, recommended, path.display(),
);
}
}
}
#[cfg(not(unix))]
pub fn warn_if_loose(_path: &Path, _recommended: u32) {}
#[cfg(unix)]
pub fn create_new_owner_only(path: &Path, contents: &[u8]) -> anyhow::Result<()> {
let mut f = std::fs::OpenOptions::new()
.write(true).create_new(true).mode(0o600)
.open(path)
.with_context(|| format!("failed to create {}", path.display()))?;
write_or_unlink(&mut f, path, contents)
}
#[cfg(not(unix))]
pub fn create_new_owner_only(path: &Path, contents: &[u8]) -> anyhow::Result<()> {
let mut f = std::fs::OpenOptions::new()
.write(true).create_new(true)
.open(path)
.with_context(|| format!("failed to create {}", path.display()))?;
write_or_unlink(&mut f, path, contents)
}
fn write_or_unlink(f: &mut std::fs::File, path: &Path, contents: &[u8]) -> anyhow::Result<()> {
f.write_all(contents).map_err(|e| {
let _ = std::fs::remove_file(path);
e
}).with_context(|| format!("failed to write {}", path.display()))
}
#[cfg(unix)]
pub fn write_atomic_owner_only(path: &Path, contents: &[u8]) -> anyhow::Result<()> {
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
let name = path.file_name()
.with_context(|| format!("path has no file name: {}", path.display()))?
.to_string_lossy();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
const MAX_ATTEMPTS: u32 = 16;
for n in 0..MAX_ATTEMPTS {
let file = format!(".{}.temp.{}.{}.tmp", name, nanos, n);
let tmp = match parent {
Some(dir) => dir.join(&file),
None => std::path::PathBuf::from(&file),
};
let mut f = match std::fs::OpenOptions::new()
.write(true).create_new(true).mode(0o600).open(&tmp)
{
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e)
.with_context(|| format!("failed to create {}", tmp.display())),
};
let res = (|| -> anyhow::Result<()> {
f.write_all(contents)
.with_context(|| format!("failed to write {}", tmp.display()))?;
f.sync_all()
.with_context(|| format!("failed to flush {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!(
"failed to replace {} with {}", path.display(), tmp.display(),
))
})();
if res.is_err() {
let _ = std::fs::remove_file(&tmp);
}
return res;
}
anyhow::bail!("failed to create a unique temp file for {}", path.display())
}
#[cfg(not(unix))]
pub fn write_atomic_owner_only(path: &Path, contents: &[u8]) -> anyhow::Result<()> {
std::fs::write(path, contents)
.with_context(|| format!("failed to write {}", path.display()))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn mode_of(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn harden_sets_mode_on_file_and_dir() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("f");
std::fs::write(&f, b"x").unwrap();
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap();
harden(&f, 0o600).unwrap();
assert_eq!(mode_of(&f), 0o600);
harden(dir.path(), 0o700).unwrap();
assert_eq!(mode_of(dir.path()), 0o700);
}
#[test]
fn is_loose_detects_group_and_other_bits() {
assert!(!is_loose(0o600));
assert!(!is_loose(0o700));
assert!(is_loose(0o640));
assert!(is_loose(0o604));
assert!(is_loose(0o644));
assert!(is_loose(0o755));
}
#[test]
fn create_new_is_owner_only_and_refuses_clobber() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("seed");
create_new_owner_only(&f, b"hello").unwrap();
assert_eq!(mode_of(&f), 0o600);
assert_eq!(std::fs::read(&f).unwrap(), b"hello");
assert!(create_new_owner_only(&f, b"again").is_err());
assert_eq!(std::fs::read(&f).unwrap(), b"hello");
}
#[test]
fn write_atomic_is_owner_only_replaces_and_leaves_no_temp() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("token");
write_atomic_owner_only(&f, b"one").unwrap();
assert_eq!(mode_of(&f), 0o600);
assert_eq!(std::fs::read(&f).unwrap(), b"one");
write_atomic_owner_only(&f, b"two").unwrap();
assert_eq!(mode_of(&f), 0o600);
assert_eq!(std::fs::read(&f).unwrap(), b"two");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn write_atomic_keeps_owner_only_even_when_target_preexists_loose() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("config");
std::fs::write(&f, b"old").unwrap();
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap();
write_atomic_owner_only(&f, b"new").unwrap();
assert_eq!(mode_of(&f), 0o600);
assert_eq!(std::fs::read(&f).unwrap(), b"new");
}
#[test]
fn write_atomic_replaces_a_symlinked_target_without_writing_through_it() {
let dir = tempfile::tempdir().unwrap();
let victim = dir.path().join("victim");
std::fs::write(&victim, b"secret").unwrap();
let target = dir.path().join("config");
std::os::unix::fs::symlink(&victim, &target).unwrap();
write_atomic_owner_only(&target, b"new").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"secret");
assert!(std::fs::symlink_metadata(&target).unwrap().file_type().is_file());
assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert_eq!(mode_of(&target), 0o600);
}
}