use anyhow::{bail, Context, Result};
use std::io::Write;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::Path;
pub fn ensure_not_root() -> Result<()> {
if unsafe { libc_geteuid() } == 0 {
bail!("refusing to run as root (uid 0) for credential operations");
}
Ok(())
}
fn refuse_unsafe_path(path: &Path) -> Result<()> {
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
bail!("refusing to operate on a symlink: {}", path.display());
}
if meta.uid() != unsafe { libc_geteuid() } {
bail!(
"refusing: {} is not owned by the current user",
path.display()
);
}
}
Ok(())
}
fn refuse_insecure_parent(dir: &Path) -> Result<()> {
if let Ok(meta) = std::fs::metadata(dir) {
let mode = meta.mode();
if mode & 0o002 != 0 && mode & 0o1000 == 0 {
bail!("refusing: parent dir {} is world-writable", dir.display());
}
}
Ok(())
}
pub fn refuse_symlink_below(root: &Path, dest: &Path) -> Result<()> {
if let Ok(meta) = std::fs::symlink_metadata(root) {
if meta.file_type().is_symlink() {
bail!("refusing: store path {} is a symlink", root.display());
}
}
let rel = dest.strip_prefix(root).map_err(|_| {
anyhow::anyhow!(
"refusing: {} is not under the store {}",
dest.display(),
root.display()
)
})?;
let mut cur = root.to_path_buf();
for comp in rel.components() {
cur.push(comp);
if let Ok(meta) = std::fs::symlink_metadata(&cur) {
if meta.file_type().is_symlink() {
bail!("refusing: {} is a symlink inside the store", cur.display());
}
}
}
Ok(())
}
pub fn read_regular(path: &Path) -> Result<Vec<u8>> {
refuse_unsafe_path(path)?;
std::fs::read(path).with_context(|| format!("read {}", path.display()))
}
pub fn tmp_path_for(dest: &Path) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = dest.parent().unwrap_or(Path::new("."));
let base = dest.file_name().and_then(|n| n.to_str()).unwrap_or("cred");
dir.join(format!(".{base}.swapdex.{}.{n}.tmp", std::process::id()))
}
pub fn write_secret(dest: &Path, bytes: &[u8]) -> Result<()> {
ensure_not_root()?;
refuse_unsafe_path(dest)?;
let dir = dest.parent().context("destination has no parent dir")?;
if !dir.exists() {
std::fs::create_dir_all(dir).ok();
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
refuse_insecure_parent(dir)?;
let tmp = tmp_path_for(dest);
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.with_context(|| format!("create temp {}", tmp.display()))?;
f.write_all(bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp, dest)
.with_context(|| format!("atomic rename {} -> {}", tmp.display(), dest.display()))?;
if let Ok(dirf) = std::fs::File::open(dir) {
let _ = dirf.sync_all();
}
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o600)).ok();
Ok(())
}
extern "C" {
#[link_name = "geteuid"]
fn libc_geteuid() -> u32;
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
#[test]
fn write_secret_is_0600_and_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join(".credentials.json");
write_secret(&dest, b"{\"token\":\"x\"}").unwrap();
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "credential file must be 0600");
assert_eq!(std::fs::read(&dest).unwrap(), b"{\"token\":\"x\"}");
}
#[test]
fn write_secret_refuses_a_symlinked_destination() {
let dir = tempfile::tempdir().unwrap();
let outside = dir.path().join("outside");
std::fs::write(&outside, b"other").unwrap();
let dest = dir.path().join("link");
symlink(&outside, &dest).unwrap();
assert!(write_secret(&dest, b"secret").is_err());
assert_eq!(
std::fs::read(&outside).unwrap(),
b"other",
"symlink target untouched"
);
}
#[test]
fn read_regular_refuses_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("t");
std::fs::write(&target, b"x").unwrap();
let link = dir.path().join("l");
symlink(&target, &link).unwrap();
assert!(read_regular(&link).is_err());
}
}
#[cfg(test)]
mod unique_tmp_tests {
use super::*;
#[test]
fn concurrent_writers_do_not_share_a_temp_path() {
let d = tempfile::tempdir().unwrap();
let dest = d.path().join("thing.json");
let a = tmp_path_for(&dest);
let b = tmp_path_for(&dest);
assert_ne!(a, b, "two writers picked the same temp path");
assert_eq!(a.parent(), dest.parent());
let name = a.file_name().unwrap().to_string_lossy().into_owned();
assert!(name.starts_with('.'), "{name}");
assert!(name.contains("swapdex"), "{name}");
}
}
#[cfg(test)]
mod root_guard_placement_tests {
use super::*;
#[test]
fn every_secret_write_checks_before_it_writes() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("creds.json");
assert!(write_secret(&p, b"{}").is_ok());
}
}