use std::fs::File;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum SecretFileError {
Io {
path: PathBuf,
source: std::io::Error,
},
NotRegularFile {
path: PathBuf,
},
ForeignOwner {
path: PathBuf,
owner_uid: u32,
expected_uid: u32,
},
PermissiveMode {
path: PathBuf,
mode: u32,
},
}
impl std::fmt::Display for SecretFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => {
write!(f, "cannot read {}: {source}", path.display())
}
Self::NotRegularFile { path } => write!(
f,
"{} is not a regular file; refusing to read secret material from it \
(kind: not_regular_file)",
path.display()
),
Self::ForeignOwner {
path,
owner_uid,
expected_uid,
} => write!(
f,
"{} is owned by uid {owner_uid}, not by this process (uid \
{expected_uid}); refusing to trust secret material supplied by \
another user (kind: foreign_owner)",
path.display()
),
Self::PermissiveMode { path, mode } => write!(
f,
"{} has mode {mode:#o}; it holds secret material and must not be \
group- or world-accessible. Run `chmod 600 {}` (kind: \
permissive_mode)",
path.display(),
path.display()
),
}
}
}
impl std::error::Error for SecretFileError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
impl SecretFileError {
pub fn kind(&self) -> &'static str {
match self {
Self::Io { .. } => "io",
Self::NotRegularFile { .. } => "not_regular_file",
Self::ForeignOwner { .. } => "foreign_owner",
Self::PermissiveMode { .. } => "permissive_mode",
}
}
}
pub fn open_secret_file(path: &Path, allow_insecure: bool) -> Result<File, SecretFileError> {
let file = File::open(path).map_err(|e| SecretFileError::Io {
path: path.to_path_buf(),
source: e,
})?;
let meta = file.metadata().map_err(|e| SecretFileError::Io {
path: path.to_path_buf(),
source: e,
})?;
if !meta.is_file() {
return Err(SecretFileError::NotRegularFile {
path: path.to_path_buf(),
});
}
if allow_insecure {
return Ok(file);
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let euid = unsafe { libc::geteuid() };
if meta.uid() != euid {
return Err(SecretFileError::ForeignOwner {
path: path.to_path_buf(),
owner_uid: meta.uid(),
expected_uid: euid,
});
}
let mode = meta.mode() & 0o777;
if mode & 0o077 != 0 {
return Err(SecretFileError::PermissiveMode {
path: path.to_path_buf(),
mode,
});
}
}
#[cfg(not(unix))]
{
tracing::warn!(
path = %path.display(),
"secret-file permission gate is a no-op on this platform: NTFS ACLs \
are not validated. This file holds secret material — restrict its \
ACL out-of-band, or it may be readable by other local users.",
);
}
Ok(file)
}
pub fn read_secret_file_to_string(
path: &Path,
allow_insecure: bool,
) -> Result<String, SecretFileError> {
use std::io::Read as _;
let mut file = open_secret_file(path, allow_insecure)?;
let mut buf = String::new();
file.read_to_string(&mut buf)
.map_err(|e| SecretFileError::Io {
path: path.to_path_buf(),
source: e,
})?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("net-sec05-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
dir
}
#[test]
fn an_owner_only_file_is_accepted() {
let dir = scratch("ok");
let path = dir.join("secret.toml");
std::fs::write(&path, "psk_hex = \"aa\"\n").expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
}
let text = read_secret_file_to_string(&path, false).expect("owner-only file is readable");
assert!(text.contains("psk_hex"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_directory_is_not_a_secret_file() {
let dir = scratch("dir");
match open_secret_file(&dir, false) {
Err(e) => assert!(
matches!(e.kind(), "not_regular_file" | "io"),
"expected a type refusal, got {}: {e}",
e.kind()
),
Ok(_) => panic!("a directory was accepted as a secret file"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn allow_insecure_still_refuses_a_non_regular_file() {
let dir = scratch("insecure-dir");
assert!(
open_secret_file(&dir, true).is_err(),
"the permission escape hatch also skipped the type check"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_group_readable_file_is_refused_and_the_override_admits_it() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("mode");
let path = dir.join("config.toml");
std::fs::write(&path, "psk_hex = \"aa\"\n").expect("write");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
match open_secret_file(&path, false) {
Err(e) => assert_eq!(e.kind(), "permissive_mode", "got {e}"),
Ok(_) => panic!("a 0644 secret file was accepted"),
}
assert!(open_secret_file(&path, true).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn refusals_name_the_path_and_the_category() {
let dir = scratch("msg");
let missing = dir.join("nope.toml");
let err = open_secret_file(&missing, false).expect_err("missing file");
let rendered = format!("{err}");
assert!(rendered.contains("nope.toml"), "got: {rendered}");
assert_eq!(err.kind(), "io");
let _ = std::fs::remove_dir_all(&dir);
}
}