Skip to main content

shep_core/protocol/request/
redacted.rs

1//! Newtypes that exist so a derived `Debug` cannot print a secret (IR-41).
2
3use core::fmt;
4
5use serde::{Deserialize, Serialize};
6
7// Named by intra-doc links and by nothing rustc compiles, so the
8// import is behind `cfg(doc)` rather than flagged unused.
9#[cfg(doc)]
10use super::{Request, Response};
11#[cfg(doc)]
12use crate::config::AppConfig;
13
14/// A dog's `[dog.<name>]` config section, carried as TOML text.
15///
16/// Travels over the socket rather than the child's environment: a dog's
17/// section routinely holds webhook credentials, and the socket keeps them
18/// out of the process table and out of crash dumps. The manual `Debug`
19/// below prints only a length, since [`Response`] derives `Debug`.
20///
21/// [`Self::as_str`] is the only way out: a `Deref<Target = str>` would hand
22/// the type `ToString` and defeat that `Debug`.
23///
24/// `#[serde(transparent)]`: the wire representation is a bare `String`.
25#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct DogSectionToml(String);
28
29impl DogSectionToml {
30    /// The TOML text, empty when the file has no such section.
31    #[must_use]
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35}
36
37impl From<String> for DogSectionToml {
38    fn from(toml: String) -> Self {
39        Self(toml)
40    }
41}
42
43/// Prints a length, never the section body. Pinned as an exact string by
44/// `dog_section_toml_debug_does_not_leak`.
45impl fmt::Debug for DogSectionToml {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(f, "DogSectionToml(<{} bytes>)", self.0.len())
48    }
49}
50
51/// One environment variable's value, on its way to a sheep.
52///
53/// A newtype for one reason, the same one [`DogSectionToml`] exists for: an
54/// env value is the single most secret-dense thing a client can send this
55/// daemon (a database URL, an API token, a signing key), and a derived
56/// `Debug` on [`Request`] would print it in the clear the moment anything
57/// logs a request. Every other secret-bearing field on this wire is already
58/// protected by its inner type ([`AppConfig`]'s own manual `Debug` prints
59/// `env: <N vars>`), and a bare `String` here would have been the first
60/// field in the enum without that protection.
61///
62/// One direction only. Nothing ever sends one back: [`Request::SheepConfig`]
63/// answers with the env keys and no values at all.
64///
65/// [`Self::as_str`] is the only way out, for the reason
66/// [`DogSectionToml`] gives: a `Deref<Target = str>` would hand the type
67/// `ToString` too, and `.to_string()` would return the value in the clear,
68/// defeating the redacted `Debug` below.
69///
70/// `#[serde(transparent)]` makes the wire representation identical to a
71/// bare `String`, so this newtype changes nothing about
72/// [`crate::protocol::PROTOCOL_VERSION`] or the pinned snapshot fixtures.
73#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(transparent)]
75pub struct EnvValue(String);
76
77impl EnvValue {
78    /// The value.
79    #[must_use]
80    pub fn as_str(&self) -> &str {
81        &self.0
82    }
83
84    /// The value, consuming: the caller already owns the entry, so
85    /// borrowing through [`Self::as_str`] and copying would double the
86    /// allocation for a `String` already in hand.
87    #[must_use]
88    pub fn into_string(self) -> String {
89        self.0
90    }
91}
92
93impl From<String> for EnvValue {
94    fn from(value: String) -> Self {
95        Self(value)
96    }
97}
98
99/// Debug prints a length and never the value (IR-41); see the type doc for
100/// why. Exact-string-tested below (`env_value_debug_does_not_leak`) so a
101/// future `#[derive(Debug)]` fails that test instead of silently reopening
102/// the leak.
103impl fmt::Debug for EnvValue {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "EnvValue(<{} bytes>)", self.0.len())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::super::{Request, Response};
112    use super::*;
113    use std::collections::BTreeMap;
114
115    /// Also pins that the newtype protecting this field costs the wire
116    /// nothing: a bare string either way, so no fixture and no protocol
117    /// version moves for it.
118    #[test]
119    fn env_value_debug_does_not_leak() {
120        let request = Request::SetSheepEnv {
121            name: "web".to_string(),
122            key: "DATABASE_URL".to_string(),
123            value: Some("postgres://user:hunter2@localhost/app".to_string().into()),
124        };
125        let debug = format!("{request:?}");
126        assert!(!debug.contains("hunter2"), "{debug}");
127        assert!(debug.contains("EnvValue(<37 bytes>)"), "{debug}");
128
129        let json = serde_json::to_string(&request).unwrap();
130        assert!(
131            json.contains(r#""value":"postgres://user:hunter2@localhost/app""#),
132            "{json}"
133        );
134        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
135    }
136
137    /// The exact `Debug` string, not the absence of one value: `entries` is
138    /// a map of [`EnvValue`], so what is actually under test is that the
139    /// nested redaction renders, and a `contains` check would pass just as
140    /// well against a map that printed nothing at all.
141    #[test]
142    fn put_secrets_round_trips_and_hides_its_values() {
143        let request = Request::PutSecrets {
144            namespace: "vercel".into(),
145            environment: "production".into(),
146            entries: BTreeMap::from([(
147                "API_KEY".to_string(),
148                EnvValue::from("sk_live".to_string()),
149            )]),
150        };
151        let encoded = serde_json::to_string(&request).unwrap();
152        assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
153        assert_eq!(
154            format!("{request:?}"),
155            "PutSecrets { namespace: \"vercel\", environment: \"production\", \
156             entries: {\"API_KEY\": EnvValue(<7 bytes>)} }"
157        );
158    }
159
160    /// IR-41. `EnvValue` is what keeps the derive on `Request` safe, and this
161    /// pins that the batch variant actually uses it.
162    #[test]
163    fn set_sheep_env_batch_debug_does_not_leak() {
164        let request = Request::SetSheepEnvBatch {
165            name: "web".to_string(),
166            entries: BTreeMap::from([(
167                "DB_PASSWORD".to_string(),
168                EnvValue::from("hunter2".to_string()),
169            )]),
170            force: false,
171            dry_run: true,
172        };
173        assert_eq!(
174            format!("{request:?}"),
175            "SetSheepEnvBatch { name: \"web\", entries: {\"DB_PASSWORD\": EnvValue(<7 bytes>)}, \
176             force: false, dry_run: true }"
177        );
178    }
179
180    #[test]
181    fn dog_section_toml_debug_does_not_leak() {
182        // A dog's section routinely holds webhook credentials. Pinned as an
183        // exact string, so a `#[derive(Debug)]` on `DogSectionToml` fails
184        // here.
185        let toml: DogSectionToml =
186            "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
187                .to_string()
188                .into();
189        assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
190
191        let response = Response::DogSection { toml };
192        assert_eq!(
193            format!("{response:?}"),
194            "DogSection { toml: DogSectionToml(<70 bytes>) }"
195        );
196    }
197}