use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::Path;
use anyhow::{Context, bail};
pub fn write_private(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(dir) = dir {
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let tmp = path.with_extension(format!(
"{}.tmp",
path.extension()
.map(|e| e.to_string_lossy().into_owned())
.unwrap_or_default()
));
let _ = fs::remove_file(&tmp);
{
let mut f =
new_private_file(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
f.write_all(bytes)?;
f.sync_all()?;
}
fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn create_private(path: &Path, bytes: &[u8], replace: bool) -> anyhow::Result<()> {
if path.exists() {
if !replace {
bail!(
"{} already exists (pass --force to replace it)",
path.display()
);
}
fs::remove_file(path).with_context(|| format!("replacing {}", path.display()))?;
}
let mut f = new_private_file(path).with_context(|| format!("creating {}", path.display()))?;
f.write_all(bytes)?;
f.sync_all()?;
Ok(())
}
#[cfg(unix)]
fn new_private_file(path: &Path) -> std::io::Result<fs::File> {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
}
#[cfg(not(unix))]
fn new_private_file(path: &Path) -> std::io::Result<fs::File> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"{} would hold keys, and its access control cannot be set on this platform",
path.display()
),
))
}
#[cfg(unix)]
pub fn require_private(path: &Path) -> anyhow::Result<()> {
let mode = fs::metadata(path)
.with_context(|| format!("reading {}", path.display()))?
.permissions()
.mode()
& 0o777;
if mode != 0o600 && mode != 0o400 {
bail!(
"{} has mode {mode:04o}; it holds keys, so it must be 0600 or 0400 (chmod 600 {})",
path.display(),
path.display()
);
}
Ok(())
}
#[cfg(not(unix))]
pub fn require_private(path: &Path) -> anyhow::Result<()> {
bail!(
"{} holds keys, and its access control cannot be checked on this platform; use the OS keychain",
path.display()
)
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn private_files_are_0600_and_checked() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("sub/creds.toml");
write_private(&p, b"x").unwrap();
assert_eq!(
fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o600
);
require_private(&p).unwrap();
fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap();
let err = require_private(&p).unwrap_err().to_string();
assert!(err.contains("0644") && err.contains("0600"), "{err}");
let out = dir.path().join(".env");
create_private(&out, b"a", false).unwrap();
assert!(create_private(&out, b"b", false).is_err());
fs::set_permissions(&out, fs::Permissions::from_mode(0o644)).unwrap();
create_private(&out, b"b", true).unwrap();
assert_eq!(
fs::metadata(&out).unwrap().permissions().mode() & 0o777,
0o600
);
}
}