1use std::fmt;
8
9use serde::Serialize;
10
11use crate::process::CommandSpec;
12
13pub const REDACTED: &str = "[redacted]";
14
15#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
17#[serde(transparent)]
18pub struct RedactedUrl(String);
19
20impl RedactedUrl {
21 pub fn parse(raw: &str) -> Result<Self, RedactedUrlError> {
28 let mut url = reqwest::Url::parse(raw).map_err(|_| RedactedUrlError::Invalid)?;
29 if !matches!(
30 url.scheme(),
31 "http" | "https" | "tcp" | "ssh" | "unix" | "npipe"
32 ) {
33 return Err(RedactedUrlError::UnsupportedScheme);
34 }
35
36 if !url.username().is_empty() && url.set_username("").is_err() {
37 return Err(RedactedUrlError::Invalid);
38 }
39 if url.password().is_some() && url.set_password(None).is_err() {
40 return Err(RedactedUrlError::Invalid);
41 }
42 if !matches!(url.path(), "" | "/") {
43 url.set_path(REDACTED);
44 }
45 if url.query().is_some() {
46 url.set_query(Some("redacted"));
47 }
48 url.set_fragment(None);
49
50 Ok(Self(url.into()))
51 }
52
53 pub fn as_str(&self) -> &str {
54 &self.0
55 }
56}
57
58impl fmt::Debug for RedactedUrl {
59 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60 formatter.debug_tuple("RedactedUrl").field(&self.0).finish()
61 }
62}
63
64impl fmt::Display for RedactedUrl {
65 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66 formatter.write_str(&self.0)
67 }
68}
69
70#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
73#[serde(transparent)]
74pub struct RedactedOrigin(String);
75
76impl RedactedOrigin {
77 pub fn parse(raw: &str) -> Result<Self, RedactedUrlError> {
78 let url = reqwest::Url::parse(raw).map_err(|_| RedactedUrlError::Invalid)?;
79 if !matches!(
80 url.scheme(),
81 "http" | "https" | "tcp" | "ssh" | "unix" | "npipe"
82 ) {
83 return Err(RedactedUrlError::UnsupportedScheme);
84 }
85 let value = if let Some(host) = url.host_str() {
86 let host = if host.contains(':') {
87 format!("[{host}]")
88 } else {
89 host.to_owned()
90 };
91 match url.port() {
92 Some(port) => format!("{}://{host}:{port}", url.scheme()),
93 None => format!("{}://{host}", url.scheme()),
94 }
95 } else {
96 format!("{}://[redacted]", url.scheme())
97 };
98 Ok(Self(value))
99 }
100
101 pub fn as_str(&self) -> &str {
102 &self.0
103 }
104}
105
106impl From<RedactedUrl> for RedactedOrigin {
107 fn from(value: RedactedUrl) -> Self {
108 Self::parse(value.as_str()).expect("RedactedUrl always has a supported URL scheme")
109 }
110}
111
112impl fmt::Debug for RedactedOrigin {
113 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114 formatter
115 .debug_tuple("RedactedOrigin")
116 .field(&self.0)
117 .finish()
118 }
119}
120
121impl fmt::Display for RedactedOrigin {
122 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123 formatter.write_str(&self.0)
124 }
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
128pub enum RedactedUrlError {
129 #[error("invalid endpoint URL")]
130 Invalid,
131 #[error("unsupported endpoint URL scheme")]
132 UnsupportedScheme,
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum HeaderName {
140 Authorization,
141 ProxyAuthorization,
142 Cookie,
143 SetCookie,
144 WwwAuthenticate,
145 ContentType,
146 ContentLength,
147 ContentRange,
148 DockerContentDigest,
149 Location,
150 Other,
151}
152
153impl HeaderName {
154 pub fn classify(name: &str) -> Self {
155 match name.trim().to_ascii_lowercase().as_str() {
156 "authorization" => Self::Authorization,
157 "proxy-authorization" => Self::ProxyAuthorization,
158 "cookie" => Self::Cookie,
159 "set-cookie" => Self::SetCookie,
160 "www-authenticate" => Self::WwwAuthenticate,
161 "content-type" => Self::ContentType,
162 "content-length" => Self::ContentLength,
163 "content-range" => Self::ContentRange,
164 "docker-content-digest" => Self::DockerContentDigest,
165 "location" => Self::Location,
166 _ => Self::Other,
167 }
168 }
169}
170
171#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
173pub struct RedactedValue;
174
175impl Serialize for RedactedValue {
176 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
177 where
178 S: serde::Serializer,
179 {
180 serializer.serialize_str(REDACTED)
181 }
182}
183
184#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
186pub struct RedactedHeader {
187 name: HeaderName,
188 value: RedactedValue,
189}
190
191impl RedactedHeader {
192 pub const fn present(name: HeaderName) -> Self {
193 Self {
194 name,
195 value: RedactedValue,
196 }
197 }
198
199 pub fn from_raw(name: &str, _value: &[u8]) -> Self {
202 Self::present(HeaderName::classify(name))
203 }
204
205 pub const fn name(&self) -> HeaderName {
206 self.name
207 }
208}
209
210#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
212#[serde(rename_all = "kebab-case")]
213pub enum NativeProgram {
214 Docker,
215 Containerd,
216 Ctr,
217 Crictl,
218 Nerdctl,
219 Podman,
220 Buildx,
221 Buildctl,
222 Other,
223}
224
225#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
228#[serde(rename_all = "kebab-case")]
229pub enum CommandPurpose {
230 Version,
231 RuntimeInfo,
232 ContextInspect,
233 BuilderInspect,
234 CacheStatus,
235 Pull,
236 Prune,
237 Other,
238}
239
240#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
243pub struct RedactedCommand {
244 program: NativeProgram,
245 purpose: CommandPurpose,
246 argument_count: usize,
247 arguments: RedactedValue,
248}
249
250impl RedactedCommand {
251 pub fn from_spec(
252 program: NativeProgram,
253 purpose: CommandPurpose,
254 command: &CommandSpec,
255 ) -> Self {
256 Self {
257 program,
258 purpose,
259 argument_count: command.arguments().len(),
260 arguments: RedactedValue,
261 }
262 }
263
264 pub const fn program(&self) -> NativeProgram {
265 self.program
266 }
267
268 pub const fn purpose(&self) -> CommandPurpose {
269 self.purpose
270 }
271
272 pub const fn argument_count(&self) -> usize {
273 self.argument_count
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn url_serialization_removes_every_secret_bearing_component() {
283 let raw =
284 "https://alice:password@example.test/private/repository?token=abc123&sig=xyz#secret";
285 let url = RedactedUrl::parse(raw).unwrap();
286 let serialized = serde_json::to_string(&url).unwrap();
287
288 assert_eq!(url.as_str(), "https://example.test/[redacted]?redacted");
289 for secret in [
290 "alice",
291 "password",
292 "private",
293 "repository",
294 "abc123",
295 "xyz",
296 "secret",
297 ] {
298 assert!(
299 !serialized.contains(secret),
300 "leaked {secret}: {serialized}"
301 );
302 }
303 }
304
305 #[test]
306 fn origin_serialization_retains_only_scheme_host_and_port() {
307 let raw = "ssh://alice:password@example.test:2222/private?token=secret#fragment";
308 let origin = RedactedOrigin::parse(raw).unwrap();
309 let serialized = serde_json::to_string(&origin).unwrap();
310
311 assert_eq!(serialized, r#""ssh://example.test:2222""#);
312 for secret in ["alice", "password", "private", "token", "fragment"] {
313 assert!(
314 !serialized.contains(secret),
315 "leaked {secret}: {serialized}"
316 );
317 }
318 }
319
320 #[test]
321 fn header_and_command_evidence_never_retain_raw_values() {
322 let header = RedactedHeader::from_raw("Authorization", b"Bearer top-secret");
323 let command = CommandSpec::new("docker")
324 .args(["login", "--password", "top-secret"])
325 .env("REGISTRY_TOKEN", "top-secret");
326 let evidence =
327 RedactedCommand::from_spec(NativeProgram::Docker, CommandPurpose::Other, &command);
328
329 let serialized = serde_json::to_string(&(&header, &evidence)).unwrap();
330 assert!(!serialized.contains("top-secret"));
331 assert!(!serialized.contains("Bearer"));
332 assert!(!serialized.contains("password"));
333 assert!(serialized.contains(REDACTED));
334 assert_eq!(evidence.argument_count(), 3);
335 }
336
337 #[test]
338 fn unsupported_url_errors_do_not_echo_input() {
339 let secret = "credential://top-secret@example.test/path";
340 let error = RedactedUrl::parse(secret).unwrap_err();
341 assert_eq!(error, RedactedUrlError::UnsupportedScheme);
342 assert!(!error.to_string().contains("top-secret"));
343 }
344}