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}