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}