use std::path::{Path, PathBuf};
use crate::error::Origin;
use crate::error::{Error, ErrorKind};
use crate::source::{Format, LoadSpec, Source};
pub(super) fn validated_profile(spec: &LoadSpec<'_>) -> Result<Option<String>, Error> {
let Some(profile) = spec.profile() else {
return Ok(None);
};
if profile_is_safe(&profile) {
return Ok(Some(profile));
}
Err(Error::new(
ErrorKind::Env,
format!(
"`{}` names the active profile and becomes part of a file name; \
a profile must be a plain word, not a path",
spec.profile_variable().unwrap_or("the profile variable"),
),
))
}
pub(crate) fn profile_is_safe(profile: &str) -> bool {
!profile.contains('/')
&& !profile.contains('\\')
&& !profile.contains("..")
&& !profile.contains('\0')
}
pub(crate) fn profile_variant(path: &Path, profile: &str) -> Option<PathBuf> {
let name = path.file_name()?.to_str()?;
Some(path.with_file_name(name_variant(name, profile)?))
}
fn name_variant(name: &str, profile: &str) -> Option<String> {
if let Some((inner, _)) = crate::source::inner_name(name) {
let inner = name_variant(inner, profile)?;
return Some(format!("{inner}.{}", crate::source::ENCRYPTED_SUFFIX));
}
let name = Path::new(name);
let stem = name.file_stem()?.to_str()?;
let extension = name.extension()?.to_str()?;
Some(format!("{stem}.{profile}.{extension}"))
}
pub(super) fn table_of(document: crate::Value) -> Table {
match document {
crate::Value::Table(table) => table,
_ => Table::new(),
}
}
const SCHEMA_KEY: &str = "$schema";
#[derive(Clone, Copy)]
pub(super) struct Layout<'a> {
#[cfg_attr(
not(any(feature = "json", feature = "toml", feature = "yaml")),
allow(dead_code)
)]
whole: Option<&'a str>,
reader: &'static dyn crate::reader::Reader,
}
impl<'a> Layout<'a> {
pub(super) fn of(spec: &LoadSpec<'a>) -> Self {
Self {
whole: spec.whole_document.then_some(spec.key),
reader: spec.reader(),
}
}
pub(super) fn reader(self) -> &'static dyn crate::reader::Reader {
self.reader
}
}
type Table = std::collections::BTreeMap<String, crate::Value>;
pub(super) fn collect_file(
into: &mut crate::resolve::Collected,
layer: &'static str,
path: &Path,
format: Format,
layout: Layout<'_>,
key: &str,
) -> Result<(), Error> {
let Some(document) = crate::document::read(layout.reader(), path, format)? else {
return Ok(());
};
let (section, siblings) = section_of(table_of(document), layout, key)?;
into.document(layer, &Origin::File(path.to_owned()), section, siblings);
Ok(())
}
pub(super) fn collect_profile_variant(
into: &mut crate::resolve::Collected,
layer: &'static str,
path: &Path,
format: Format,
profile: Option<&str>,
layout: Layout<'_>,
key: &str,
) -> Result<(), Error> {
let Some(profile) = profile else {
return Ok(());
};
let Some(variant) = profile_variant(path, profile) else {
return Ok(());
};
if variant
.to_str()
.and_then(crate::source::inner_name)
.is_some()
{
return collect_encrypted_file(into, layer, &variant, format, layout, key);
}
collect_file(into, layer, &variant, format, layout, key)
}
fn collect_encrypted_file(
into: &mut crate::resolve::Collected,
layer: &'static str,
path: &Path,
format: Format,
layout: Layout<'_>,
key: &str,
) -> Result<(), Error> {
#[cfg(not(feature = "decrypt"))]
{
let _ = (into, layer, format, layout, key);
Err(Error::new(
ErrorKind::Backend,
format!(
"{} is encrypted, and this build has no decryption support; \
add features = [\"age\"] to your dynamic-config dependency",
path.display()
),
))
}
#[cfg(feature = "decrypt")]
{
let ciphertext = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(Error::new(ErrorKind::Io, error.to_string())
.with_origin(Origin::File(path.to_owned())))
}
};
let named = path.display().to_string();
let plaintext = crate::decrypt::decrypt(&ciphertext, &named)?;
let parsed = crate::document::parse_with(layout.reader(), plaintext.text(), format)
.map_err(|error| error.with_origin(Origin::File(path.to_owned())))?;
let (section, siblings) = section_of(table_of(parsed), layout, key)?;
into.document(layer, &Origin::File(path.to_owned()), section, siblings);
Ok(())
}
}
pub(super) fn collect_source(
into: &mut crate::resolve::Collected,
layer: &'static str,
source: &Source<'_>,
layout: Layout<'_>,
key: &str,
) -> Result<(), Error> {
if let Some(foreign) = source.foreign_config() {
let values = crate::backend::config_rs::layer(foreign)?;
if !values.is_empty() {
into.layer(layer, Origin::Inline, values);
}
return Ok(());
}
#[cfg(feature = "figment")]
if let Some(provider) = source.foreign() {
if let Some(values) = crate::backend::figment::section_of(provider, key)? {
into.layer(layer, crate::backend::figment::origin_of(provider), values);
}
return Ok(());
}
let Some(format) = source.format() else {
return Ok(());
};
match source.path() {
Some(path) if source.is_encrypted() => {
collect_encrypted_file(into, layer, Path::new(path), format, layout, key)
}
Some(path) => collect_file(into, layer, Path::new(path), format, layout, key),
None => {
let parsed = crate::document::parse_with(
layout.reader(),
source.inline_text().unwrap_or_default(),
format,
)?;
let (section, siblings) = section_of(table_of(parsed), layout, key)?;
into.document(layer, &Origin::Inline, section, siblings);
Ok(())
}
}
}
pub(super) fn section_of(
document: Table,
layout: Layout<'_>,
key: &str,
) -> Result<(Option<Table>, std::collections::BTreeMap<String, Table>), Error> {
if layout.whole.is_some() {
let mut values = document;
values.remove(SCHEMA_KEY);
return Ok((Some(values), std::collections::BTreeMap::new()));
}
let mut section = None;
let mut siblings = std::collections::BTreeMap::new();
for (name, value) in document {
if name == SCHEMA_KEY {
continue;
}
let crate::Value::Table(table) = value else {
return Err(Error::new(
ErrorKind::Parse,
format!(
"top-level key `{name}` is not a table; every top-level key \
in a config file is a section, so a value there must be a \
table (`{SCHEMA_KEY}` is the one exception). If this file \
is not sectioned — if the whole of it is one \
configuration — read it with `.whole_document()`"
),
));
};
if name == key {
section = Some(table);
} else {
siblings.insert(name, table);
}
}
Ok((section, siblings))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_profile_variant_sits_next_to_its_base() {
assert_eq!(
profile_variant(Path::new("/etc/app/config.toml"), "production"),
Some(PathBuf::from("/etc/app/config.production.toml"))
);
assert_eq!(
profile_variant(Path::new("config.json"), "dev"),
Some(PathBuf::from("config.dev.json"))
);
}
#[test]
fn a_path_without_an_extension_has_no_variant() {
assert_eq!(profile_variant(Path::new("config"), "production"), None);
}
#[test]
fn an_encrypted_file_carries_the_profile_under_its_suffix() {
assert_eq!(
profile_variant(Path::new("/etc/app/secrets.json.age"), "production"),
Some(PathBuf::from("/etc/app/secrets.production.json.age"))
);
}
#[test]
fn a_variant_never_leaves_the_directory_it_was_built_from() {
for base in [
"/etc/my.app/..age",
"/srv/conf.d/..age",
"/etc/my.app/config.toml",
"relative.d/..age",
] {
let base = Path::new(base);
let Some(variant) = profile_variant(base, "production") else {
continue;
};
assert_eq!(
variant.parent(),
base.parent(),
"{} moved to {}",
base.display(),
variant.display()
);
}
}
}