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}