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
85impl From<String> for EnvValue {
86    fn from(value: String) -> Self {
87        Self(value)
88    }
89}
90
91/// Debug prints a length and never the value (IR-41); see the type doc for
92/// why. Exact-string-tested below (`env_value_debug_does_not_leak`) so a
93/// future `#[derive(Debug)]` fails that test instead of silently reopening
94/// the leak.
95impl fmt::Debug for EnvValue {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(f, "EnvValue(<{} bytes>)", self.0.len())
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::super::{Request, Response};
104    use super::*;
105    use std::collections::BTreeMap;
106
107    /// Also pins that the newtype protecting this field costs the wire
108    /// nothing: a bare string either way, so no fixture and no protocol
109    /// version moves for it.
110    #[test]
111    fn env_value_debug_does_not_leak() {
112        let request = Request::SetSheepEnv {
113            name: "web".to_string(),
114            key: "DATABASE_URL".to_string(),
115            value: Some("postgres://user:hunter2@localhost/app".to_string().into()),
116        };
117        let debug = format!("{request:?}");
118        assert!(!debug.contains("hunter2"), "{debug}");
119        assert!(debug.contains("EnvValue(<37 bytes>)"), "{debug}");
120
121        let json = serde_json::to_string(&request).unwrap();
122        assert!(
123            json.contains(r#""value":"postgres://user:hunter2@localhost/app""#),
124            "{json}"
125        );
126        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
127    }
128
129    /// The exact `Debug` string, not the absence of one value: `entries` is
130    /// a map of [`EnvValue`], so what is actually under test is that the
131    /// nested redaction renders, and a `contains` check would pass just as
132    /// well against a map that printed nothing at all.
133    #[test]
134    fn put_secrets_round_trips_and_hides_its_values() {
135        let request = Request::PutSecrets {
136            namespace: "vercel".into(),
137            environment: "production".into(),
138            entries: BTreeMap::from([(
139                "API_KEY".to_string(),
140                EnvValue::from("sk_live".to_string()),
141            )]),
142        };
143        let encoded = serde_json::to_string(&request).unwrap();
144        assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
145        assert_eq!(
146            format!("{request:?}"),
147            "PutSecrets { namespace: \"vercel\", environment: \"production\", \
148             entries: {\"API_KEY\": EnvValue(<7 bytes>)} }"
149        );
150    }
151
152    /// IR-41. `EnvValue` is what keeps the derive on `Request` safe, and this
153    /// pins that the batch variant actually uses it.
154    #[test]
155    fn set_sheep_env_batch_debug_does_not_leak() {
156        let request = Request::SetSheepEnvBatch {
157            name: "web".to_string(),
158            entries: BTreeMap::from([(
159                "DB_PASSWORD".to_string(),
160                EnvValue::from("hunter2".to_string()),
161            )]),
162            force: false,
163            dry_run: true,
164        };
165        assert_eq!(
166            format!("{request:?}"),
167            "SetSheepEnvBatch { name: \"web\", entries: {\"DB_PASSWORD\": EnvValue(<7 bytes>)}, \
168             force: false, dry_run: true }"
169        );
170    }
171
172    #[test]
173    fn dog_section_toml_debug_does_not_leak() {
174        // A dog's section routinely holds webhook credentials. Pinned as an
175        // exact string, so a `#[derive(Debug)]` on `DogSectionToml` fails
176        // here.
177        let toml: DogSectionToml =
178            "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
179                .to_string()
180                .into();
181        assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
182
183        let response = Response::DogSection { toml };
184        assert_eq!(
185            format!("{response:?}"),
186            "DogSection { toml: DogSectionToml(<70 bytes>) }"
187        );
188    }
189}