#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
use figment::providers::Format as _;
#[cfg(feature = "json")]
use figment::providers::Json;
#[cfg(feature = "toml")]
use figment::providers::Toml;
#[cfg(feature = "yaml")]
use figment::providers::Yaml;
use figment::value::Dict;
use figment::{Figment, Metadata};
use std::path::{Path, PathBuf};
use super::CACHED_NAME;
#[cfg(feature = "decrypt")]
use crate::error::Origin;
use crate::error::{Error, ErrorKind};
use crate::source::{Format, LoadSpec, Source};
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
struct Named<P> {
inner: P,
name: String,
file: Option<std::path::PathBuf>,
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
impl<P: figment::Provider> figment::Provider for Named<P> {
fn metadata(&self) -> Metadata {
let metadata = Metadata::named(self.name.clone());
match &self.file {
Some(path) => metadata.source(figment::Source::File(path.clone())),
None => metadata,
}
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
self.inner.data()
}
fn profile(&self) -> Option<figment::Profile> {
self.inner.profile()
}
}
pub(super) struct Cached {
pub(super) values: Dict,
pub(super) profile: figment::Profile,
}
impl figment::Provider for Cached {
fn metadata(&self) -> Metadata {
Metadata::named(CACHED_NAME)
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
let mut map = figment::value::Map::new();
map.insert(self.profile.clone(), self.values.clone());
Ok(map)
}
}
pub(super) fn merge(
figment: Figment,
source: &Source<'_>,
layout: Layout<'_>,
) -> Result<Figment, Error> {
#[cfg(feature = "figment")]
if let Some(provider) = source.foreign() {
return Ok(figment.merge(Foreign(provider)));
}
let Some(format) = source.format() else {
return Ok(figment);
};
match source.path() {
Some(path) if source.is_encrypted() => {
merge_encrypted_file(figment, Path::new(path), format, layout)
}
Some(path) => merge_file(figment, Path::new(path), format, layout),
None => merge_text(
figment,
source.inline_text().unwrap_or_default(),
format,
layout,
),
}
}
#[cfg(feature = "figment")]
struct Foreign<'a>(&'a (dyn figment::Provider + Send + Sync));
#[cfg(feature = "figment")]
impl figment::Provider for Foreign<'_> {
fn metadata(&self) -> Metadata {
self.0.metadata()
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
Ok(self
.0
.data()?
.into_iter()
.map(|(profile, dict)| {
if profile == figment::Profile::Default || profile == figment::Profile::Global {
(profile, dict)
} else {
(
figment::Profile::from(super::section_profile(profile.as_str().as_str())),
dict,
)
}
})
.collect())
}
fn profile(&self) -> Option<figment::Profile> {
self.0.profile()
}
}
fn merge_encrypted_file(
figment: Figment,
path: &Path,
format: Format,
layout: Layout<'_>,
) -> Result<Figment, Error> {
#[cfg(not(feature = "decrypt"))]
{
let _ = (&figment, format, layout);
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(figment),
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)?;
merge_named_text(
figment,
plaintext.text(),
format,
&named,
Some(path),
layout,
)
}
}
pub(super) fn merge_profile_variant(
figment: Figment,
path: &Path,
format: Format,
profile: Option<&str>,
layout: Layout<'_>,
) -> Result<Figment, Error> {
let Some(profile) = profile else {
return Ok(figment);
};
let Some(variant) = profile_variant(path, profile) else {
return Ok(figment);
};
if variant
.to_str()
.and_then(crate::source::inner_name)
.is_some()
{
return merge_encrypted_file(figment, &variant, format, layout);
}
merge_file(figment, &variant, format, layout)
}
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 merge_file(
figment: Figment,
path: &Path,
format: Format,
layout: Layout<'_>,
) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, path, layout);
match format {
#[cfg(feature = "json")]
Format::Json => Ok(figment.merge(Sections::new(Json::file(path), layout))),
#[cfg(feature = "toml")]
Format::Toml => Ok(figment.merge(Sections::new(Toml::file(path), layout))),
#[cfg(feature = "yaml")]
Format::Yaml => Ok(figment.merge(Sections::new(Yaml::file(path), layout))),
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
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>,
}
impl<'a> Layout<'a> {
pub(super) fn of(spec: &LoadSpec<'a>) -> Self {
Self {
whole: spec.whole_document.then_some(spec.key),
}
}
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
struct Sections<'a, P> {
inner: P,
layout: Layout<'a>,
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
impl<'a, P: figment::Provider> Sections<'a, P> {
const fn new(inner: P, layout: Layout<'a>) -> Self {
Self { inner, layout }
}
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
impl<P: figment::Provider> figment::Provider for Sections<'_, P> {
fn metadata(&self) -> Metadata {
self.inner.metadata()
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
let mut sections = figment::value::Map::new();
for (_, document) in self.inner.data()? {
if let Some(key) = self.layout.whole {
let mut values = document;
values.remove(SCHEMA_KEY);
sections.insert(figment::Profile::from(super::section_profile(key)), values);
continue;
}
for (key, value) in document {
if key == SCHEMA_KEY {
continue;
}
let figment::value::Value::Dict(_, dict) = value else {
return Err(figment::Error::from(format!(
"top-level key `{key}` 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()`"
)));
};
sections.insert(figment::Profile::from(super::section_profile(&key)), dict);
}
}
Ok(sections)
}
}
fn merge_text(
figment: Figment,
text: &str,
format: Format,
layout: Layout<'_>,
) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, text, layout);
match format {
#[cfg(feature = "json")]
Format::Json => Ok(figment.merge(Sections::new(Json::string(text), layout))),
#[cfg(feature = "toml")]
Format::Toml => Ok(figment.merge(Sections::new(Toml::string(text), layout))),
#[cfg(feature = "yaml")]
Format::Yaml => Ok(figment.merge(Sections::new(Yaml::string(text), layout))),
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
pub(super) fn merge_named_text(
figment: Figment,
text: &str,
format: Format,
name: &str,
file: Option<&Path>,
layout: Layout<'_>,
) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, text, name, file, layout);
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
fn named<P>(inner: P, name: &str, file: Option<&Path>) -> Named<P> {
Named {
inner,
name: name.to_owned(),
file: file.map(Path::to_owned),
}
}
match format {
#[cfg(feature = "json")]
Format::Json => {
Ok(figment.merge(named(Sections::new(Json::string(text), layout), name, file)))
}
#[cfg(feature = "toml")]
Format::Toml => {
Ok(figment.merge(named(Sections::new(Toml::string(text), layout), name, file)))
}
#[cfg(feature = "yaml")]
Format::Yaml => {
Ok(figment.merge(named(Sections::new(Yaml::string(text), layout), name, file)))
}
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
pub(crate) fn parse_document(text: &str, format: Format) -> Result<Dict, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = text;
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
fn document<P: figment::Provider>(provider: P) -> Result<Dict, Error> {
Ok(provider
.data()
.map_err(|error| super::origin::translate(&error))?
.remove(&figment::Profile::Default)
.unwrap_or_default())
}
match format {
#[cfg(feature = "json")]
Format::Json => document(Json::string(text)),
#[cfg(feature = "toml")]
Format::Toml => document(Toml::string(text)),
#[cfg(feature = "yaml")]
Format::Yaml => document(Yaml::string(text)),
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
fn disabled(format: Format) -> Error {
Error::new(
ErrorKind::Backend,
format!(
"cannot read {format:?} because the `{}` feature is not enabled; \
add features = [\"{}\"] to your dynamic-config dependency",
format.feature(),
format.feature(),
),
)
}
#[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()
);
}
}
}