112 lines
4.6 KiB
Rust
112 lines
4.6 KiB
Rust
//! [`write_secret_0600`] and [`write_secret_mode`]: the ways hive-c0re puts a
|
|
//! credential, or a hash of one, on disk.
|
|
|
|
use std::io::Write as _;
|
|
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context as _, Result};
|
|
|
|
/// Write `contents` to `path` as a `0600` file, creating the parent directory.
|
|
///
|
|
/// The bytes go to a sibling temp file opened `0600` and are renamed over
|
|
/// `path`, so the file is never readable by anyone else for an instant, and a
|
|
/// reader (or a crash) never sees half of it. Errors carry paths, never
|
|
/// `contents`.
|
|
pub fn write_secret_0600(path: &Path, contents: &[u8]) -> Result<()> {
|
|
write_secret_mode(path, contents, 0o600)
|
|
}
|
|
|
|
/// [`write_secret_0600`] with the file's mode given, for a file another user
|
|
/// has to read (the group comes from the directory).
|
|
pub fn write_secret_mode(path: &Path, contents: &[u8], mode: u32) -> Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
|
}
|
|
let mut tmp_name = path
|
|
.file_name()
|
|
.with_context(|| format!("{} has no file name", path.display()))?
|
|
.to_owned();
|
|
tmp_name.push(".tmp");
|
|
let tmp = path.with_file_name(tmp_name);
|
|
// A temp file left by a crashed earlier write would fail `create_new`.
|
|
let _ = std::fs::remove_file(&tmp);
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(mode)
|
|
.open(&tmp)
|
|
.with_context(|| format!("create {}", tmp.display()))?;
|
|
// `open` masks the mode with the process umask; the mode is a property of
|
|
// the writer, not of the unit's umask.
|
|
f.set_permissions(std::fs::Permissions::from_mode(mode))
|
|
.with_context(|| format!("chmod {}", tmp.display()))?;
|
|
f.write_all(contents)
|
|
.and_then(|()| f.sync_all())
|
|
.with_context(|| format!("write {}", tmp.display()))?;
|
|
std::fs::rename(&tmp, path).with_context(|| format!("rename onto {}", path.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{write_secret_0600, write_secret_mode};
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
|
|
fn mode(path: &std::path::Path) -> u32 {
|
|
std::fs::metadata(path).expect("stat").permissions().mode() & 0o777
|
|
}
|
|
|
|
#[test]
|
|
fn creates_parent_and_writes_0600() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path().join("sub").join("token");
|
|
write_secret_0600(&path, b"secret\n").expect("writes");
|
|
assert_eq!(std::fs::read(&path).expect("read"), b"secret\n");
|
|
assert_eq!(mode(&path), 0o600);
|
|
assert!(!dir.path().join("sub").join("token.tmp").exists());
|
|
}
|
|
|
|
/// An existing file at a looser mode is replaced, not rewritten in place:
|
|
/// the new file is 0600 and holds only the new bytes.
|
|
#[test]
|
|
fn replaces_an_existing_looser_file() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path().join("token");
|
|
std::fs::write(&path, "a much longer old value\n").expect("seed");
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
|
|
write_secret_0600(&path, b"new\n").expect("writes");
|
|
assert_eq!(std::fs::read(&path).expect("read"), b"new\n");
|
|
assert_eq!(mode(&path), 0o600);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_temp_file_does_not_block_the_write() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path().join("token");
|
|
std::fs::write(dir.path().join("token.tmp"), "stale").expect("seed");
|
|
write_secret_0600(&path, b"new\n").expect("writes");
|
|
assert_eq!(std::fs::read(&path).expect("read"), b"new\n");
|
|
}
|
|
|
|
/// A restrictive umask doesn't change the mode asked for. The umask is
|
|
/// process-wide, so the write happens in a child run of this test.
|
|
#[test]
|
|
fn the_mode_survives_a_restrictive_umask() {
|
|
const DIR: &str = "SECRET_FILE_UMASK_TEST_DIR";
|
|
if let Some(dir) = std::env::var_os(DIR) {
|
|
write_secret_mode(&std::path::Path::new(&dir).join("shared"), b"x\n", 0o640)
|
|
.expect("writes");
|
|
return;
|
|
}
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let status = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(r#"umask 077 && exec "$0" --exact secret_file::tests::the_mode_survives_a_restrictive_umask"#)
|
|
.arg(std::env::current_exe().expect("test binary"))
|
|
.env(DIR, dir.path())
|
|
.status()
|
|
.expect("spawns the child");
|
|
assert!(status.success());
|
|
assert_eq!(mode(&dir.path().join("shared")), 0o640);
|
|
}
|
|
}
|