dynamic-config 0.6.0

Hot-reloadable, lock-free application configuration with a one-attribute API, built on figment.
Documentation
//! A directory of single-value files: one file per key, the filename is the
//! key, the contents are the value.
//!
//! How Docker and Kubernetes hand a container its credentials, and the shape
//! pydantic-settings calls `secrets_dir`. It is not another document format —
//! the same bytes in a `.json` would mean nothing — so it reads like the
//! environment layer does: flat names, coercion left to the struct.
//!
//! # Three decisions
//!
//! **Nesting is in the name, not in subdirectories.** `db__password` is
//! `db.password`, through the same [`nest`](crate::LoadSpec::nest) separator
//! the environment layer uses, so one setting governs both. A Kubernetes
//! secret is one flat directory of keys; recursion would buy a prettier
//! spelling nobody's mount produces. Subdirectories can be added later
//! without changing what a flat directory means today.
//!
//! **A missing directory is skipped, an unreadable one is an error.** A
//! container that mounts secrets in production and not in a test must still
//! start; a directory that is there but refuses to be read is a permissions
//! bug, and silence about it would be worse than a failed load.
//!
//! **Provenance is per file.** Each key is its own provider naming its own
//! path, so `explain` and `source_of` answer with `/run/secrets/db__password`
//! rather than with "the secrets directory" — the exact file, which is the
//! useful answer when two mounts disagree.

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;

/// How one mounted secret names itself, before its path.
///
/// Distinct from the `.env` layer's `"the file "` so the two are told apart
/// by name; the origin both resolve to is the same `Origin::File`, which for
/// this layer comes from the metadata's *source* rather than from the name.
const PREFIX: &str = "the secrets file ";

/// Merges every regular file in the configured directory, one provider each.
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)
}

/// One mounted secret, as its own figment provider.
///
/// Per file rather than one provider for the whole directory: figment
/// attaches metadata per *provider*, so a single provider would trace every
/// key back to the directory and no further. Ten files is ten providers, and
/// ten is the order of magnitude a mount actually has.
struct Secret {
    path: PathBuf,
    /// Dotted key path within the section, from the filename.
    key: String,
    value: String,
    section: String,
}

impl Provider for Secret {
    fn metadata(&self) -> Metadata {
        // The source is what `origin_of` reads: a value traced back here
        // reports `Origin::File(the individual file)`, not the directory.
        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)
    }
}

/// Reads one directory level into one [`Secret`] per key file.
fn read(directory: &Path, spec: &LoadSpec<'_>) -> Result<Vec<Secret>, Error> {
    let entries = match std::fs::read_dir(directory) {
        Ok(entries) => entries,
        // Skipped exactly like a missing config file: the same image runs in
        // a test that mounts nothing.
        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());
    }

    // `read_dir` yields in whatever order the filesystem likes. Sorted so a
    // reload merges the same layers in the same order as the load before it —
    // the keys are distinct, so this changes no outcome, only the reading of
    // a diagnostic.
    paths.sort();

    let mut secrets = Vec::new();

    for path in paths {
        let Some(key) = key_of(&path, spec.nest) else {
            continue;
        };

        // `metadata` follows symlinks where `DirEntry::file_type` does not,
        // and following is the whole point: Kubernetes mounts every key as a
        // symlink into a timestamped directory behind `..data`, so a layer
        // that refused to follow one would read an empty directory. Following
        // is not descending — a link that lands on a directory (`..data`
        // itself) is skipped like any other thing that is not a file.
        let metadata = match std::fs::metadata(&path) {
            Ok(metadata) => metadata,
            // A dangling link is the instant Kubernetes swaps a mount; the
            // next reload sees the new one. Anything else — a permission
            // denied on the link's target — is the bug this layer refuses to
            // be quiet about.
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
            Err(error) => return Err(io(&path, &error)),
        };

        if !metadata.is_file() {
            continue;
        }

        // A file whose bytes are not UTF-8 fails here rather than arriving
        // lossily converted: a mangled credential that loads is worse than
        // one that does not.
        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)
}

/// The dotted key path a filename spells, or `None` if it spells nothing.
///
/// The name is the key *verbatim* — no case folding, unlike the environment
/// layer. A variable name is shouted by convention and has to be quietened on
/// the way in; a filename is written in whatever case the field uses, and
/// lowercasing would put a `#[serde(rename = "apiKey")]` field out of reach.
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)
}

/// Removes one trailing newline and no more.
///
/// Every tool that writes a secret to a file writes one, and nobody means it
/// as part of the password. Two of them are a value that ends in a blank
/// line, and that is the caller's business.
fn trim_one_newline(text: &str) -> &str {
    text.strip_suffix('\n').map_or(text, |trimmed| {
        // A CRLF is one newline, not two characters of value.
        trimmed.strip_suffix('\r').unwrap_or(trimmed)
    })
}

/// An I/O failure naming the path — and never the contents, which for this
/// layer are a credential by construction.
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());
    }
}