use core::fmt;
use std::collections::BTreeMap;
use serde::Deserializer;
use crate::config::table::deserialize_table;
pub(super) enum EnvValue {
Str(String),
Bool(bool),
Int(i128),
}
impl fmt::Debug for EnvValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Str(_) => f.write_str("<str>"),
Self::Bool(_) => f.write_str("<bool>"),
Self::Int(_) => f.write_str("<int>"),
}
}
}
impl EnvValue {
#[must_use]
fn into_string(self) -> String {
match self {
Self::Str(s) => s,
Self::Bool(b) => b.to_string(),
Self::Int(n) => n.to_string(),
}
}
}
pub(super) fn deserialize_env<'de, D: Deserializer<'de>>(
de: D,
) -> Result<BTreeMap<String, String>, D::Error> {
Ok(deserialize_table::<D, EnvValue>(de, "env")?
.into_iter()
.map(|(k, v)| (k, v.into_string()))
.collect())
}
impl<'de> serde::de::Deserialize<'de> for EnvValue {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct EnvValueVisitor;
impl serde::de::Visitor<'_> for EnvValueVisitor {
type Value = EnvValue;
fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("a string, boolean, or whole number")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<EnvValue, E> {
Ok(EnvValue::Str(v.to_string()))
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<EnvValue, E> {
Ok(EnvValue::Str(v))
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<EnvValue, E> {
Ok(EnvValue::Bool(v))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<EnvValue, E> {
Ok(EnvValue::Int(i128::from(v)))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<EnvValue, E> {
Ok(EnvValue::Int(i128::from(v)))
}
fn visit_f64<E: serde::de::Error>(self, _v: f64) -> Result<EnvValue, E> {
Err(E::custom(
"a float env value loses its written form, quote it",
))
}
}
de.deserialize_any(EnvValueVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use super::super::testing::*;
#[test]
fn a_wire_env_that_is_not_a_table_is_still_refused() {
for (label, value) in [
("null", "null"),
("a bare string", "\"postgres://hunter2\""),
] {
let src = format!(r#"{{"name":"web","script":"./srv","env":{value}}}"#);
let err = serde_json::from_str::<AppConfig>(&src)
.expect_err("a wire env that is not a table must be refused, as it always was")
.to_string();
assert!(err.contains("`env` must be a table"), "{label}: {err}");
assert!(
!err.contains("hunter2"),
"the refusal leaked the value: {err}"
);
}
let config: AppConfig =
serde_json::from_str(r#"{"name":"web","script":"./srv","env":{"A":"b"}}"#)
.expect("a real env table on the wire still decodes");
assert_eq!(config.env["A"], "b");
}
#[test]
fn env_value_debug_never_prints_the_value() {
let cases = [
(EnvValue::Str("postgres://secret".to_string()), "<str>"),
(EnvValue::Bool(true), "<bool>"),
(EnvValue::Int(9_223_372_036_854_775_807), "<int>"),
];
for (value, expected) in cases {
assert_eq!(format!("{value:?}"), expected);
}
}
#[test]
fn the_clauses_enforced_outside_normalize_are_the_ones_named() {
let claims = refusal_claims();
let outside: Vec<(&str, &str, &str)> = claims
.iter()
.filter_map(|claim| match claim.proof {
Proof::Elsewhere(place) => Some((claim.field, claim.refusal, place)),
Proof::Refused { .. } => None,
})
.collect();
assert_eq!(
outside,
vec![
(
"env",
"a float, since 1.10 would arrive as 1.1",
"EnvValue's Deserialize, before normalize sees the table",
),
(
"dogs",
"a value that is not a table",
"DogTable's Deserialize, before normalize sees the table",
),
(
"group",
"a name with no group entry",
"shep-daemon's privilege::resolve, at spawn",
),
(
"group",
"another group, unless the shepherd runs as root",
"shep-daemon's privilege::resolve, at spawn",
),
(
"user",
"a name with no passwd entry",
"shep-daemon's privilege::resolve, at spawn",
),
(
"user",
"another user, unless the shepherd runs as root",
"shep-daemon's privilege::resolve, at spawn",
),
]
);
}
}