Skip to main content

shep_core/config/
dogs.rs

1//! `$SHEP_HOME/dogs.toml`, a dog's own settings.
2//!
3//! One table per dog, keyed by the name the dog was registered under, with
4//! no prefix: `[metrics]` here is what `[dog.metrics]` was in `shep.toml`
5//! before the move. The daemon serves a section verbatim over the socket as
6//! `Response::DogSection` and never interprets it, so this type parses
7//! exactly far enough to find the right table and no further.
8//!
9//! Hand-editable on purpose, and deliberately not a locked shep-owned store
10//! like `overrides.json`. A dog's config is authored intent rather than
11//! derived state, and an operator on a box with only a shell has to be able
12//! to set one without a dashboard.
13
14use core::fmt;
15use std::collections::BTreeMap;
16
17/// Every `[<dog>]` table in `dogs.toml`
18///
19/// `Debug` is redacted (IR-41): a dog section routinely carries a webhook
20/// URL with a bearer token in it, and this type exists to be logged near
21/// the boot path.
22#[derive(Clone, Default, PartialEq)]
23pub struct DogsConfig {
24    /// Raw `[<name>]` tables keyed by dog name
25    pub dog: BTreeMap<String, toml::Table>,
26}
27
28impl fmt::Debug for DogsConfig {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.debug_struct("DogsConfig")
31            .field("dog", &format_args!("<{} tables>", self.dog.len()))
32            .finish()
33    }
34}
35
36impl DogsConfig {
37    /// Parses `dogs.toml`, or answers empty when there is no file
38    ///
39    /// # Errors
40    ///
41    /// - [`DogsConfigError::Toml`] when `source` is not valid TOML.
42    pub fn load(source: Option<&str>) -> Result<Self, DogsConfigError> {
43        let Some(source) = source else {
44            return Ok(Self::default());
45        };
46        let dog = toml::from_str(source).map_err(DogsConfigError::Toml)?;
47        Ok(Self { dog })
48    }
49}
50
51/// Why `dogs.toml` could not be read
52// One variant today. `#[non_exhaustive]` so a second reading failure (a
53// permissions error, once this type learns to open the file itself) is
54// additive rather than breaking.
55#[non_exhaustive]
56pub enum DogsConfigError {
57    /// The file is not valid TOML
58    Toml(toml::de::Error),
59}
60
61/// Manual, not derived (IR-41): `toml::de::Error`'s own `Debug` forwards to
62/// `toml_edit::TomlError`, which keeps the ENTIRE source document in a `raw`
63/// field so `Display` can quote a line of context. A derived `Debug` here
64/// prints all of it, and this is the one type in the workspace whose source
65/// document is `dogs.toml` -- the file `docs/dogs.md` tells an operator to
66/// paste a Discord or Slack webhook URL into. Measured against `toml`
67/// 0.8.23: a derived `Debug` emitted the whole file, webhook and all, five
68/// lines below the redacted `Debug` [`DogsConfig`] carries for the same
69/// secret.
70///
71/// The redaction is the parser's short `message()`, never the line it
72/// quotes -- the same posture, for the same reason, that
73/// `ShepTomlError::Parse` takes in shep-cli. `Display` below still shows the
74/// full line-and-column rendering: that is the deliberate surface, meant for
75/// the operator who broke their own file to read. `Debug` is not that
76/// surface, it is what a log captures.
77impl fmt::Debug for DogsConfigError {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::Toml(err) => f.debug_tuple("Toml").field(&err.message()).finish(),
81        }
82    }
83}
84
85impl fmt::Display for DogsConfigError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::Toml(err) => write!(f, "invalid TOML in dogs.toml: {err}"),
89        }
90    }
91}
92
93impl core::error::Error for DogsConfigError {
94    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
95        match self {
96            Self::Toml(err) => Some(err),
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn a_missing_file_loads_as_an_empty_map() {
107        let config = DogsConfig::load(None).expect("None is not an error");
108        assert!(config.dog.is_empty());
109    }
110
111    #[test]
112    fn sections_are_keyed_by_name_with_no_prefix() {
113        let source = "[metrics]\nbind = \"127.0.0.1:9615\"\n\n[bark.sinks]\noncall = { kind = \"discord\" }\n";
114        let config = DogsConfig::load(Some(source)).expect("valid TOML");
115        assert_eq!(
116            config.dog.keys().collect::<Vec<_>>(),
117            vec!["bark", "metrics"]
118        );
119        assert_eq!(
120            config.dog["metrics"]["bind"].as_str(),
121            Some("127.0.0.1:9615")
122        );
123    }
124
125    #[test]
126    fn invalid_toml_is_a_named_error() {
127        let err = DogsConfig::load(Some("[metrics")).expect_err("unterminated table header");
128        assert!(matches!(err, DogsConfigError::Toml(_)));
129    }
130
131    // IR-41: this map routinely holds webhook URLs with a bearer token in the
132    // path. `Debug` is the one thing between such a token and any future
133    // `tracing::debug!("{config:?}")`, so the exact string is pinned rather
134    // than the shape.
135    // IR-41 again, and this is the leak the redaction above defeated by
136    // being five lines away from it: `DogsConfig`'s own `Debug` says
137    // `<1 tables>`, and the error returned when the same file will not
138    // parse used to print every byte of it. The exact string is pinned,
139    // not the shape, because a shape assertion passes on a `Debug` that
140    // appended the raw source after the message.
141    #[test]
142    fn debug_redacts_the_source_a_parse_error_carries() {
143        let source =
144            "[bark.sinks]\noncall = { url = \"https://discord.com/api/webhooks/SECRET\" }\n[oops\n";
145        let err = DogsConfig::load(Some(source)).expect_err("unterminated table header");
146        assert_eq!(
147            format!("{err:?}"),
148            "Toml(\"invalid table header\\nexpected `.`, `]`\")"
149        );
150        // The operator's own surface is untouched: `Display` still quotes
151        // the line that failed, which is the one line of the file this type
152        // is meant to show.
153        assert!(
154            err.to_string().contains("line 3, column 6"),
155            "Display keeps its line-and-column context: {err}"
156        );
157        assert!(
158            !err.to_string().contains("SECRET"),
159            "and it quotes only the line that failed: {err}"
160        );
161    }
162
163    #[test]
164    fn debug_redacts_every_dog_section() {
165        let source =
166            "[bark.sinks]\noncall = { url = \"https://discord.com/api/webhooks/SECRET\" }\n";
167        let config = DogsConfig::load(Some(source)).expect("valid TOML");
168        assert_eq!(format!("{config:?}"), "DogsConfig { dog: <1 tables> }");
169    }
170}