#[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<'_>) -> 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)
}
Some(path) => merge_file(figment, Path::new(path), format),
None => merge_text(figment, source.inline_text().unwrap_or_default(), format),
}
}
#[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) -> Result<Figment, Error> {
#[cfg(not(feature = "decrypt"))]
{
let _ = (&figment, format);
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))
}
}
pub(super) fn merge_profile_variant(
figment: Figment,
path: &Path,
format: Format,
profile: Option<&str>,
) -> 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);
}
merge_file(figment, &variant, format)
}
pub(super) fn validated_profile(spec: &LoadSpec<'_>) -> Result<Option<String>, Error> {
let Some(profile) = spec.profile() else {
return Ok(None);
};
let clean = !profile.contains('/')
&& !profile.contains('\\')
&& !profile.contains("..")
&& !profile.contains('\0');
if clean {
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"),
),
))
}
fn profile_variant(path: &Path, profile: &str) -> Option<PathBuf> {
if let Some((inner, _)) = path
.to_str()
.and_then(|path| crate::source::inner_name(path))
{
let variant = profile_variant(Path::new(inner), profile)?;
return Some(PathBuf::from(format!(
"{}.{}",
variant.display(),
crate::source::ENCRYPTED_SUFFIX
)));
}
let stem = path.file_stem()?.to_str()?;
let extension = path.extension()?.to_str()?;
Some(path.with_file_name(format!("{stem}.{profile}.{extension}")))
}
pub(super) fn merge_file(figment: Figment, path: &Path, format: Format) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, path);
match format {
#[cfg(feature = "json")]
Format::Json => Ok(figment.merge(Sections::from(Json::file(path)))),
#[cfg(feature = "toml")]
Format::Toml => Ok(figment.merge(Sections::from(Toml::file(path)))),
#[cfg(feature = "yaml")]
Format::Yaml => Ok(figment.merge(Sections::from(Yaml::file(path)))),
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
const SCHEMA_KEY: &str = "$schema";
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
struct Sections<P> {
inner: P,
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
impl<P: figment::Provider> From<P> for Sections<P> {
fn from(inner: P) -> Self {
Self { inner }
}
}
#[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()? {
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)"
)));
};
sections.insert(figment::Profile::from(super::section_profile(&key)), dict);
}
}
Ok(sections)
}
}
fn merge_text(figment: Figment, text: &str, format: Format) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, text);
match format {
#[cfg(feature = "json")]
Format::Json => Ok(figment.merge(Sections::from(Json::string(text)))),
#[cfg(feature = "toml")]
Format::Toml => Ok(figment.merge(Sections::from(Toml::string(text)))),
#[cfg(feature = "yaml")]
Format::Yaml => Ok(figment.merge(Sections::from(Yaml::string(text)))),
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
pub(super) fn merge_named_text(
figment: Figment,
text: &str,
format: Format,
name: &str,
file: Option<&Path>,
) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, text, name, file);
#[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::from(Json::string(text)), name, file))),
#[cfg(feature = "toml")]
Format::Toml => Ok(figment.merge(named(Sections::from(Toml::string(text)), name, file))),
#[cfg(feature = "yaml")]
Format::Yaml => Ok(figment.merge(named(Sections::from(Yaml::string(text)), name, file))),
#[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);
}
}