Skip to main content

osdk_core/container/
report.rs

1//! Stable, deterministic, secret-safe diagnostic output contracts.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::Serialize;
6
7use super::redact::{RedactedCommand, RedactedHeader, RedactedUrl};
8use super::{BuildkitDiagnosticDetails, ContainerdDiagnosticDetails, DockerDiagnosticDetails};
9
10pub const DIAGNOSTIC_SCHEMA_VERSION: u32 = 2;
11
12/// The native control plane being inspected.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum RuntimeKind {
16    Docker,
17    Containerd,
18    Buildkit,
19    Podman,
20}
21
22/// Overall state of one native runtime or builder.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum DiagnosticStatus {
26    Healthy,
27    Degraded,
28    NotInstalled,
29    ClientOnly,
30    Unreachable,
31    PermissionDenied,
32    UnsupportedVersion,
33}
34
35/// Native operations whose availability can be established by discovery.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum Capability {
39    Client,
40    Daemon,
41    RuntimeInfo,
42    Pull,
43    CacheStatus,
44    CachePrune,
45    RegistryMirrors,
46    RegistryHostMapping,
47    BuilderInspection,
48    PlatformSelection,
49}
50
51/// State of an individually discovered capability.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
53#[serde(rename_all = "kebab-case")]
54pub enum CapabilityStatus {
55    Supported,
56    Unsupported,
57    Unavailable,
58    Unknown,
59}
60
61/// Privilege boundary for an inspected or proposed native operation.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum Privilege {
65    None,
66    CurrentUser,
67    Root,
68    Administrator,
69    RemoteAdministrator,
70    Unknown,
71}
72
73/// How the client reaches a daemon or builder.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
75#[serde(rename_all = "kebab-case")]
76pub enum EndpointTransport {
77    LocalSocket,
78    NamedPipe,
79    Tcp,
80    Http,
81    Https,
82    Ssh,
83    DockerDesktop,
84    Cloud,
85    Unknown,
86}
87
88/// Whether native configuration for an endpoint is local to this process.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
90#[serde(rename_all = "kebab-case")]
91pub enum EndpointScope {
92    Local,
93    Remote,
94    ManagedDesktop,
95    ManagedCloud,
96    Unknown,
97}
98
99/// A daemon or builder endpoint with its address sanitized at construction.
100#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
101pub struct Endpoint {
102    pub transport: EndpointTransport,
103    pub scope: EndpointScope,
104    pub address: RedactedUrl,
105}
106
107impl Endpoint {
108    pub const fn new(
109        transport: EndpointTransport,
110        scope: EndpointScope,
111        address: RedactedUrl,
112    ) -> Self {
113        Self {
114            transport,
115            scope,
116            address,
117        }
118    }
119}
120
121/// Typed, redacted proof supporting a diagnostic conclusion.
122#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
123#[serde(tag = "kind", content = "value", rename_all = "kebab-case")]
124pub enum DiagnosticEvidence {
125    Endpoint(Endpoint),
126    Header(RedactedHeader),
127    Command(RedactedCommand),
128}
129
130/// Closed, runtime-specific facts collected during the same discovery pass as
131/// the generic status and capabilities. Each variant contains only typed or
132/// redacted fields and represents unavailable facts with `None`.
133#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
134#[serde(tag = "kind", rename_all = "kebab-case")]
135pub enum DiagnosticDetails {
136    Docker(DockerDiagnosticDetails),
137    Containerd(ContainerdDiagnosticDetails),
138    Buildkit(BuildkitDiagnosticDetails),
139}
140
141/// Stable diagnostic output. It contains no free-form serializable strings.
142/// Callers must choose typed facts and redacted evidence, keeping report JSON
143/// secret-safe by construction.
144#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
145pub struct DiagnosticReport {
146    pub schema_version: u32,
147    pub runtime: RuntimeKind,
148    pub status: DiagnosticStatus,
149    pub details: Option<DiagnosticDetails>,
150    pub privilege: Privilege,
151    pub capabilities: BTreeMap<Capability, CapabilityStatus>,
152    pub endpoints: BTreeSet<Endpoint>,
153    pub evidence: BTreeSet<DiagnosticEvidence>,
154}
155
156impl DiagnosticReport {
157    pub fn new(runtime: RuntimeKind, status: DiagnosticStatus) -> Self {
158        Self {
159            schema_version: DIAGNOSTIC_SCHEMA_VERSION,
160            runtime,
161            status,
162            details: match runtime {
163                RuntimeKind::Docker => {
164                    Some(DiagnosticDetails::Docker(DockerDiagnosticDetails::default()))
165                }
166                RuntimeKind::Containerd => Some(DiagnosticDetails::Containerd(
167                    ContainerdDiagnosticDetails::default(),
168                )),
169                RuntimeKind::Buildkit => Some(DiagnosticDetails::Buildkit(
170                    BuildkitDiagnosticDetails::default(),
171                )),
172                RuntimeKind::Podman => None,
173            },
174            privilege: Privilege::Unknown,
175            capabilities: BTreeMap::new(),
176            endpoints: BTreeSet::new(),
177            evidence: BTreeSet::new(),
178        }
179    }
180
181    pub fn with_privilege(mut self, privilege: Privilege) -> Self {
182        self.privilege = privilege;
183        self
184    }
185
186    pub fn set_details(&mut self, details: DiagnosticDetails) {
187        self.details = Some(details);
188    }
189
190    pub fn set_capability(&mut self, capability: Capability, status: CapabilityStatus) {
191        self.capabilities.insert(capability, status);
192    }
193
194    pub fn add_endpoint(&mut self, endpoint: Endpoint) {
195        self.endpoints.insert(endpoint);
196    }
197
198    pub fn add_evidence(&mut self, evidence: DiagnosticEvidence) {
199        self.evidence.insert(evidence);
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use crate::process::CommandSpec;
206
207    use super::*;
208    use crate::container::redact::{CommandPurpose, NativeProgram};
209
210    fn report(reverse: bool) -> DiagnosticReport {
211        let endpoint_a = Endpoint::new(
212            EndpointTransport::Https,
213            EndpointScope::Remote,
214            RedactedUrl::parse("https://alice:password@z.example/private?token=secret").unwrap(),
215        );
216        let endpoint_b = Endpoint::new(
217            EndpointTransport::LocalSocket,
218            EndpointScope::Local,
219            RedactedUrl::parse("unix:///var/run/docker.sock").unwrap(),
220        );
221        let command = CommandSpec::new("docker").args(["info", "--format", "secret"]);
222        let command = DiagnosticEvidence::Command(RedactedCommand::from_spec(
223            NativeProgram::Docker,
224            CommandPurpose::RuntimeInfo,
225            &command,
226        ));
227
228        let mut report = DiagnosticReport::new(RuntimeKind::Docker, DiagnosticStatus::Healthy)
229            .with_privilege(Privilege::CurrentUser);
230        let capabilities = [
231            (Capability::Pull, CapabilityStatus::Supported),
232            (Capability::Daemon, CapabilityStatus::Supported),
233        ];
234        let endpoints = [endpoint_a, endpoint_b];
235        if reverse {
236            for (capability, status) in capabilities.into_iter().rev() {
237                report.set_capability(capability, status);
238            }
239            for endpoint in endpoints.into_iter().rev() {
240                report.add_endpoint(endpoint);
241            }
242        } else {
243            for (capability, status) in capabilities {
244                report.set_capability(capability, status);
245            }
246            for endpoint in endpoints {
247                report.add_endpoint(endpoint);
248            }
249        }
250        report.add_evidence(command);
251        report
252    }
253
254    #[test]
255    fn diagnostic_serialization_is_schema_versioned_and_deterministic() {
256        let left = serde_json::to_string(&report(false)).unwrap();
257        let right = serde_json::to_string(&report(true)).unwrap();
258
259        assert_eq!(left, right);
260        assert!(left.starts_with("{\"schema_version\":2,"));
261        assert!(left.contains("\"details\":{\"kind\":\"docker\""));
262        assert!(left.contains("\"daemon\":\"supported\""));
263        assert!(left.contains("\"pull\":\"supported\""));
264        for secret in ["alice", "password", "private", "token", "secret"] {
265            assert!(!left.contains(secret), "leaked {secret}: {left}");
266        }
267    }
268}