use std::path::{Path, PathBuf};
use figment::value::{Dict, Value};
use figment::{Figment, Metadata, Profile, Provider};
use crate::error::{Error, ErrorKind, Origin};
use crate::source::LoadSpec;
const PREFIX: &str = "the secrets file ";
pub(super) fn merge_secrets_dir(
mut figment: Figment,
spec: &LoadSpec<'_>,
) -> Result<Figment, Error> {
let Some(directory) = spec.secrets_dir else {
return Ok(figment);
};
for secret in read(Path::new(directory), spec)? {
figment = figment.merge(secret);
}
Ok(figment)
}
struct Secret {
path: PathBuf,
key: String,
value: String,
section: String,
}
impl Provider for Secret {
fn metadata(&self) -> Metadata {
Metadata::named(format!("{PREFIX}{}", self.path.display()))
.source(figment::Source::File(self.path.clone()))
}
fn data(&self) -> figment::Result<figment::value::Map<Profile, Dict>> {
let mut values = Dict::new();
crate::layer::insert_path(&mut values, &self.key, Value::from(self.value.clone()));
let mut map = figment::value::Map::new();
map.insert(Profile::from(super::section_profile(&self.section)), values);
Ok(map)
}
}
fn read(directory: &Path, spec: &LoadSpec<'_>) -> Result<Vec<Secret>, Error> {
let entries = match std::fs::read_dir(directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(io(directory, &error)),
};
let mut paths = Vec::new();
for entry in entries {
paths.push(entry.map_err(|error| io(directory, &error))?.path());
}
paths.sort();
let mut secrets = Vec::new();
for path in paths {
let Some(key) = key_of(&path, spec.nest) else {
continue;
};
let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io(&path, &error)),
};
if !metadata.is_file() {
continue;
}
let text = std::fs::read_to_string(&path).map_err(|error| io(&path, &error))?;
secrets.push(Secret {
path,
key,
value: trim_one_newline(&text).to_owned(),
section: spec.key.to_owned(),
});
}
Ok(secrets)
}
fn key_of(path: &Path, nest: &str) -> Option<String> {
let key = path.file_name()?.to_str()?.replace(nest, ".");
if key.is_empty() || key.split('.').any(str::is_empty) {
return None;
}
Some(key)
}
fn trim_one_newline(text: &str) -> &str {
text.strip_suffix('\n').map_or(text, |trimmed| {
trimmed.strip_suffix('\r').unwrap_or(trimmed)
})
}
fn io(path: &Path, error: &std::io::Error) -> Error {
Error::new(ErrorKind::Io, error.to_string()).with_origin(Origin::File(path.to_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exactly_one_trailing_newline_goes() {
assert_eq!(trim_one_newline("hunter2\n"), "hunter2");
assert_eq!(trim_one_newline("hunter2\r\n"), "hunter2");
assert_eq!(trim_one_newline("hunter2\n\n"), "hunter2\n");
assert_eq!(trim_one_newline("hunter2"), "hunter2");
assert_eq!(trim_one_newline(" spaced "), " spaced ");
}
#[test]
fn the_filename_nests_through_the_separator() {
assert_eq!(
key_of(Path::new("/run/secrets/db__password"), "__").as_deref(),
Some("db.password")
);
assert_eq!(
key_of(Path::new("/run/secrets/apiKey"), "__").as_deref(),
Some("apiKey"),
"a filename is not shouted the way a variable name is, so it is \
not quietened either"
);
assert_eq!(key_of(Path::new("/run/secrets/db__"), "__"), None);
}
#[test]
fn a_missing_directory_contributes_nothing() {
let spec = LoadSpec::new("db", &[]);
assert!(read(Path::new("/no/such/secrets"), &spec)
.unwrap()
.is_empty());
}
}