Skip to main content

osdk_core/container/
redact.rs

1//! Secret-safe evidence for container diagnostics.
2//!
3//! Raw URLs, header values, command arguments, environment values, and command
4//! output never implement `Serialize`. The public evidence types below retain
5//! only deliberately bounded metadata or values sanitized at construction.
6
7use std::fmt;
8
9use serde::Serialize;
10
11use crate::process::CommandSpec;
12
13pub const REDACTED: &str = "[redacted]";
14
15/// A URL whose credentials, path, query, and fragment cannot reach output.
16#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
17#[serde(transparent)]
18pub struct RedactedUrl(String);
19
20impl RedactedUrl {
21    /// Parse and sanitize an endpoint URL.
22    ///
23    /// The scheme, host, and explicit port are retained. User information is
24    /// removed, a non-root path is replaced, query contents are replaced, and
25    /// the fragment is discarded. Only schemes used by native runtime and
26    /// registry endpoints are accepted.
27    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/// An endpoint or mirror origin with only scheme, host, and explicit port.
71/// Unlike [`RedactedUrl`], it never exposes whether a path or query existed.
72#[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/// Header names useful in diagnostics. Unknown names are collapsed rather
136/// than copied from untrusted input.
137#[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/// A marker whose serialized representation is always the redaction sentinel.
172#[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/// Header evidence that can record presence but never the raw value.
185#[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    /// Build evidence from a raw header while deliberately discarding its
200    /// value. The slice is accepted to make accidental retention unnecessary.
201    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/// A fixed set of executable identities used by native-container adapters.
211#[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/// Typed purpose of a native command. No raw argument is needed to explain the
226/// operation in a report.
227#[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/// Command evidence that records a typed executable and purpose plus only the
241/// number of discarded raw arguments.
242#[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}