Skip to main content

osdk_core/container/
cache.rs

1//! Read-only, secret-safe native cache status queries.
2//!
3//! Native cache ownership remains with Docker Engine, the selected BuildKit
4//! builder, or containerd. Queries use only supported native CLI surfaces and
5//! never inspect `/var/lib/docker`, containerd roots, BuildKit state, or any
6//! other implementation-private storage. Raw command output is discarded after
7//! conversion into the closed, serializable contract below.
8
9use std::collections::BTreeSet;
10
11use semver::Version;
12use serde::Serialize;
13use serde_json::Value;
14
15use super::buildkit::BuildxBuilderSelector;
16use super::redact::{CommandPurpose, NativeProgram};
17use super::report::{DiagnosticEvidence, RuntimeKind};
18use super::runtime::ProbeCommand;
19use crate::process::{CaptureLimits, CommandOutcome, CommandRunner, CommandSpec};
20
21/// Version of the stable native-cache status JSON contract.
22pub const NATIVE_CACHE_STATUS_SCHEMA_VERSION: u32 = 1;
23
24// `docker buildx du --format=json` became a documented NDJSON interface in
25// Buildx v0.28.0. Older releases only expose human-oriented output, which this
26// module deliberately refuses to scrape.
27const MINIMUM_BUILDX_JSON_DU_VERSION: Version = Version::new(0, 28, 0);
28
29/// The native component that owns the reported cache.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum NativeCacheOwner {
33    DockerEngine,
34    BuildkitBuilder,
35    Containerd,
36}
37
38/// Outcome of a bounded native cache query.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum CacheQueryStatus {
42    Available,
43    NotInstalled,
44    PermissionDenied,
45    Unreachable,
46    TimedOut,
47    Unsupported,
48    UnsupportedVersion,
49    OutputTruncated,
50    InvalidOutput,
51    CommandFailed,
52}
53
54/// A stable cache category. Unknown native labels are rejected instead of
55/// entering the output contract as free-form strings.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum NativeCacheRecordKind {
59    Images,
60    Containers,
61    LocalVolumes,
62    BuildCache,
63}
64
65/// Aggregate facts for one stable cache category.
66#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
67pub struct NativeCacheRecord {
68    pub kind: NativeCacheRecordKind,
69    pub count: u64,
70    pub active: u64,
71    /// Total bytes reported by the owning native component.
72    pub total: u64,
73    /// Bytes the owning native component identifies as reclaimable.
74    pub reclaimable: u64,
75}
76
77/// Stable, deterministic, secret-safe native cache status.
78///
79/// This type intentionally has no free-form serializable strings. In
80/// particular, builder names, cache record IDs and descriptions, native paths,
81/// stderr, and unparsed stdout cannot enter the contract.
82#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
83pub struct NativeCacheStatus {
84    pub schema_version: u32,
85    pub runtime: RuntimeKind,
86    pub owner: NativeCacheOwner,
87    pub status: CacheQueryStatus,
88    pub records: Vec<NativeCacheRecord>,
89    /// Sum of `records[].total`, in bytes.
90    pub total: u64,
91    /// Sum of `records[].reclaimable`, in bytes.
92    pub reclaimable: u64,
93    pub evidence: BTreeSet<DiagnosticEvidence>,
94}
95
96impl NativeCacheStatus {
97    fn terminal(
98        runtime: RuntimeKind,
99        owner: NativeCacheOwner,
100        status: CacheQueryStatus,
101        evidence: BTreeSet<DiagnosticEvidence>,
102    ) -> Self {
103        Self {
104            schema_version: NATIVE_CACHE_STATUS_SCHEMA_VERSION,
105            runtime,
106            owner,
107            status,
108            records: Vec::new(),
109            total: 0,
110            reclaimable: 0,
111            evidence,
112        }
113    }
114
115    fn available(
116        runtime: RuntimeKind,
117        owner: NativeCacheOwner,
118        mut records: Vec<NativeCacheRecord>,
119        evidence: BTreeSet<DiagnosticEvidence>,
120    ) -> Result<Self, CacheParseError> {
121        records.sort_by_key(|record| record.kind);
122        let mut total = 0_u64;
123        let mut reclaimable = 0_u64;
124        for record in &records {
125            if record.active > record.count || record.reclaimable > record.total {
126                return Err(CacheParseError::Invalid);
127            }
128            total = total
129                .checked_add(record.total)
130                .ok_or(CacheParseError::Overflow)?;
131            reclaimable = reclaimable
132                .checked_add(record.reclaimable)
133                .ok_or(CacheParseError::Overflow)?;
134        }
135        Ok(Self {
136            schema_version: NATIVE_CACHE_STATUS_SCHEMA_VERSION,
137            runtime,
138            owner,
139            status: CacheQueryStatus::Available,
140            records,
141            total,
142            reclaimable,
143            evidence,
144        })
145    }
146}
147
148/// Read-only Docker Engine cache query.
149#[derive(Clone, Copy, Debug, Default)]
150pub struct DockerCacheQuery;
151
152impl DockerCacheQuery {
153    /// Query aggregate Docker Engine usage through the documented formatter.
154    pub fn query(&self, runner: &dyn CommandRunner, limits: CaptureLimits) -> NativeCacheStatus {
155        let probe = ProbeCommand::new(
156            NativeProgram::Docker,
157            CommandPurpose::CacheStatus,
158            CommandSpec::new("docker").args(["system", "df", "--format", "{{json .}}"]),
159        );
160        let mut evidence = BTreeSet::new();
161        evidence.insert(DiagnosticEvidence::Command(probe.evidence().clone()));
162        let outcome = probe.execute(runner, limits);
163        if let Some(status) = failed_outcome_status(&outcome, ProbeFlavor::Docker) {
164            return NativeCacheStatus::terminal(
165                RuntimeKind::Docker,
166                NativeCacheOwner::DockerEngine,
167                status,
168                evidence,
169            );
170        }
171
172        let output = outcome.output().expect("successful outcome has output");
173        match parse_docker_cache_records(&output.stdout).and_then(|records| {
174            NativeCacheStatus::available(
175                RuntimeKind::Docker,
176                NativeCacheOwner::DockerEngine,
177                records,
178                evidence.clone(),
179            )
180        }) {
181            Ok(status) => status,
182            Err(_) => NativeCacheStatus::terminal(
183                RuntimeKind::Docker,
184                NativeCacheOwner::DockerEngine,
185                CacheQueryStatus::InvalidOutput,
186                evidence,
187            ),
188        }
189    }
190}
191
192/// Read-only BuildKit cache query backed by Docker Buildx.
193#[derive(Clone, Default)]
194pub struct BuildxCacheQuery {
195    selector: BuildxBuilderSelector,
196}
197
198impl std::fmt::Debug for BuildxCacheQuery {
199    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        formatter
201            .debug_struct("BuildxCacheQuery")
202            .field(
203                "selector",
204                &self.selector.as_name().map(|_| "named").unwrap_or("auto"),
205            )
206            .finish()
207    }
208}
209
210impl BuildxCacheQuery {
211    pub fn new(selector: BuildxBuilderSelector) -> Self {
212        Self { selector }
213    }
214
215    pub fn selector(&self) -> &BuildxBuilderSelector {
216        &self.selector
217    }
218
219    /// Query the selected builder through documented Buildx NDJSON output.
220    ///
221    /// A local version probe is mandatory: invoking `du --format=json` without
222    /// first proving Buildx v0.28.0 or newer would risk treating a human format
223    /// as a machine contract. No `--bootstrap` flag is used.
224    pub fn query(&self, runner: &dyn CommandRunner, limits: CaptureLimits) -> NativeCacheStatus {
225        let version_probe = ProbeCommand::new(
226            NativeProgram::Buildx,
227            CommandPurpose::Version,
228            CommandSpec::new("docker").args(["buildx", "version"]),
229        );
230        let mut evidence = BTreeSet::new();
231        evidence.insert(DiagnosticEvidence::Command(
232            version_probe.evidence().clone(),
233        ));
234        let version_outcome = version_probe.execute(runner, limits);
235        if let Some(status) = failed_outcome_status(&version_outcome, ProbeFlavor::BuildxVersion) {
236            return NativeCacheStatus::terminal(
237                RuntimeKind::Buildkit,
238                NativeCacheOwner::BuildkitBuilder,
239                status,
240                evidence,
241            );
242        }
243
244        let version_output = version_outcome
245            .output()
246            .expect("successful outcome has output");
247        let version = parse_buildx_version(&version_output.stdout);
248        if version
249            .as_ref()
250            .is_none_or(|version| version < &MINIMUM_BUILDX_JSON_DU_VERSION)
251        {
252            return NativeCacheStatus::terminal(
253                RuntimeKind::Buildkit,
254                NativeCacheOwner::BuildkitBuilder,
255                CacheQueryStatus::UnsupportedVersion,
256                evidence,
257            );
258        }
259
260        let mut command = CommandSpec::new("docker").args(["buildx", "du", "--format=json"]);
261        if let Some(name) = self.selector.as_name() {
262            // `BuildxBuilderSelector::named` has already restricted this to one
263            // bounded ASCII argument; it is never copied into report output.
264            command = command.args(["--builder", name]);
265        }
266        let du_probe =
267            ProbeCommand::new(NativeProgram::Buildx, CommandPurpose::CacheStatus, command);
268        evidence.insert(DiagnosticEvidence::Command(du_probe.evidence().clone()));
269        let du_outcome = du_probe.execute(runner, limits);
270        if let Some(status) = failed_outcome_status(&du_outcome, ProbeFlavor::BuildxDu) {
271            return NativeCacheStatus::terminal(
272                RuntimeKind::Buildkit,
273                NativeCacheOwner::BuildkitBuilder,
274                status,
275                evidence,
276            );
277        }
278
279        let output = du_outcome.output().expect("successful outcome has output");
280        match parse_buildx_cache_records(&output.stdout).and_then(|records| {
281            NativeCacheStatus::available(
282                RuntimeKind::Buildkit,
283                NativeCacheOwner::BuildkitBuilder,
284                records,
285                evidence.clone(),
286            )
287        }) {
288            Ok(status) => status,
289            Err(_) => NativeCacheStatus::terminal(
290                RuntimeKind::Buildkit,
291                NativeCacheOwner::BuildkitBuilder,
292                CacheQueryStatus::InvalidOutput,
293                evidence,
294            ),
295        }
296    }
297}
298
299/// Explicitly unsupported containerd cache query.
300///
301/// containerd has namespaces, snapshots, content, and CRI views but no single
302/// selected, stable aggregate contract equivalent to the two CLI interfaces
303/// above. Returning `unsupported` is safer than walking its private roots.
304#[derive(Clone, Copy, Debug, Default)]
305pub struct ContainerdCacheQuery;
306
307impl ContainerdCacheQuery {
308    pub fn query(&self, _runner: &dyn CommandRunner, _limits: CaptureLimits) -> NativeCacheStatus {
309        NativeCacheStatus::terminal(
310            RuntimeKind::Containerd,
311            NativeCacheOwner::Containerd,
312            CacheQueryStatus::Unsupported,
313            BTreeSet::new(),
314        )
315    }
316}
317
318#[derive(Clone, Copy)]
319enum ProbeFlavor {
320    Docker,
321    BuildxVersion,
322    BuildxDu,
323}
324
325fn failed_outcome_status(
326    outcome: &CommandOutcome,
327    flavor: ProbeFlavor,
328) -> Option<CacheQueryStatus> {
329    match outcome {
330        CommandOutcome::NotInstalled => Some(CacheQueryStatus::NotInstalled),
331        CommandOutcome::PermissionDenied => Some(CacheQueryStatus::PermissionDenied),
332        CommandOutcome::TimedOut { .. } => Some(CacheQueryStatus::TimedOut),
333        CommandOutcome::SpawnFailed { .. } | CommandOutcome::ExecutionFailed { .. } => {
334            Some(classify_failed_output(outcome, flavor))
335        }
336        CommandOutcome::Exited { status, output } => {
337            if output.stdout_truncated || output.stderr_truncated {
338                Some(CacheQueryStatus::OutputTruncated)
339            } else if status.success() {
340                None
341            } else {
342                Some(classify_failed_output(outcome, flavor))
343            }
344        }
345    }
346}
347
348fn classify_failed_output(outcome: &CommandOutcome, flavor: ProbeFlavor) -> CacheQueryStatus {
349    let Some(output) = outcome.output() else {
350        return CacheQueryStatus::CommandFailed;
351    };
352    let mut text = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
353    text.push(' ');
354    text.push_str(&String::from_utf8_lossy(&output.stderr).to_ascii_lowercase());
355
356    if text.contains("permission denied") || text.contains("access is denied") {
357        CacheQueryStatus::PermissionDenied
358    } else if text.contains("cannot connect")
359        || text.contains("connection refused")
360        || text.contains("connection error")
361        || text.contains("deadline exceeded")
362        || text.contains("context deadline")
363        || text.contains("timed out")
364    {
365        CacheQueryStatus::Unreachable
366    } else if matches!(flavor, ProbeFlavor::BuildxVersion | ProbeFlavor::BuildxDu)
367        && (text.contains("is not a docker command")
368            || text.contains("unknown command \"buildx\"")
369            || text.contains("docker-buildx: executable file not found")
370            || text.contains("docker: 'buildx' is not a docker command"))
371    {
372        CacheQueryStatus::NotInstalled
373    } else if matches!(flavor, ProbeFlavor::BuildxDu)
374        && (text.contains("unknown flag: --format")
375            || text.contains("unknown flag: --format=json")
376            || text.contains("unknown shorthand flag"))
377    {
378        CacheQueryStatus::UnsupportedVersion
379    } else {
380        CacheQueryStatus::CommandFailed
381    }
382}
383
384fn parse_buildx_version(bytes: &[u8]) -> Option<Version> {
385    String::from_utf8_lossy(bytes)
386        .split_whitespace()
387        .find_map(super::parse_vendor_version)
388}
389
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391enum CacheParseError {
392    Invalid,
393    Overflow,
394}
395
396fn parse_docker_cache_records(bytes: &[u8]) -> Result<Vec<NativeCacheRecord>, CacheParseError> {
397    let text = std::str::from_utf8(bytes).map_err(|_| CacheParseError::Invalid)?;
398    let mut records = Vec::new();
399    let mut kinds = BTreeSet::new();
400    for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
401        let value: Value = serde_json::from_str(line).map_err(|_| CacheParseError::Invalid)?;
402        let kind = match required_string(&value, "Type")? {
403            "Images" => NativeCacheRecordKind::Images,
404            "Containers" => NativeCacheRecordKind::Containers,
405            "Local Volumes" => NativeCacheRecordKind::LocalVolumes,
406            "Build Cache" => NativeCacheRecordKind::BuildCache,
407            _ => return Err(CacheParseError::Invalid),
408        };
409        if !kinds.insert(kind) {
410            return Err(CacheParseError::Invalid);
411        }
412        let count = required_u64(&value, "TotalCount")?;
413        let active = required_u64(&value, "Active")?;
414        let total = required_human_bytes(&value, "Size")?;
415        let reclaimable = required_reclaimable_bytes(&value, "Reclaimable")?;
416        if active > count || reclaimable > total {
417            return Err(CacheParseError::Invalid);
418        }
419        records.push(NativeCacheRecord {
420            kind,
421            count,
422            active,
423            total,
424            reclaimable,
425        });
426    }
427    if records.is_empty() {
428        return Err(CacheParseError::Invalid);
429    }
430    Ok(records)
431}
432
433fn parse_buildx_cache_records(bytes: &[u8]) -> Result<Vec<NativeCacheRecord>, CacheParseError> {
434    let text = std::str::from_utf8(bytes).map_err(|_| CacheParseError::Invalid)?;
435    let mut count = 0_u64;
436    let mut active = 0_u64;
437    let mut total = 0_u64;
438    let mut reclaimable = 0_u64;
439    for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
440        let value: Value = serde_json::from_str(line).map_err(|_| CacheParseError::Invalid)?;
441        let size = required_u64(&value, "Size")?;
442        let is_reclaimable = value
443            .get("Reclaimable")
444            .and_then(Value::as_bool)
445            .ok_or(CacheParseError::Invalid)?;
446        count = count.checked_add(1).ok_or(CacheParseError::Overflow)?;
447        total = total.checked_add(size).ok_or(CacheParseError::Overflow)?;
448        if is_reclaimable {
449            reclaimable = reclaimable
450                .checked_add(size)
451                .ok_or(CacheParseError::Overflow)?;
452        } else {
453            active = active.checked_add(1).ok_or(CacheParseError::Overflow)?;
454        }
455    }
456    Ok(vec![NativeCacheRecord {
457        kind: NativeCacheRecordKind::BuildCache,
458        count,
459        active,
460        total,
461        reclaimable,
462    }])
463}
464
465fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, CacheParseError> {
466    value
467        .as_object()
468        .and_then(|object| object.get(key))
469        .and_then(Value::as_str)
470        .ok_or(CacheParseError::Invalid)
471}
472
473fn required_u64(value: &Value, key: &str) -> Result<u64, CacheParseError> {
474    let value = value
475        .as_object()
476        .and_then(|object| object.get(key))
477        .ok_or(CacheParseError::Invalid)?;
478    match value {
479        Value::Number(number) => number.as_u64().ok_or(CacheParseError::Invalid),
480        Value::String(number)
481            if !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit()) =>
482        {
483            number.parse().map_err(|_| CacheParseError::Overflow)
484        }
485        _ => Err(CacheParseError::Invalid),
486    }
487}
488
489fn required_human_bytes(value: &Value, key: &str) -> Result<u64, CacheParseError> {
490    let value = value
491        .as_object()
492        .and_then(|object| object.get(key))
493        .ok_or(CacheParseError::Invalid)?;
494    match value {
495        Value::Number(number) => number.as_u64().ok_or(CacheParseError::Invalid),
496        Value::String(size) => parse_human_bytes(size),
497        _ => Err(CacheParseError::Invalid),
498    }
499}
500
501fn required_reclaimable_bytes(value: &Value, key: &str) -> Result<u64, CacheParseError> {
502    let value = value
503        .as_object()
504        .and_then(|object| object.get(key))
505        .ok_or(CacheParseError::Invalid)?;
506    match value {
507        Value::Number(number) => number.as_u64().ok_or(CacheParseError::Invalid),
508        Value::String(size) => {
509            let size = size.split_once('(').map_or(size.as_str(), |(size, _)| size);
510            parse_human_bytes(size.trim())
511        }
512        _ => Err(CacheParseError::Invalid),
513    }
514}
515
516/// Parse Docker CLI decimal SI sizes without floating-point conversion.
517fn parse_human_bytes(value: &str) -> Result<u64, CacheParseError> {
518    let value = value.trim();
519    let unit_start = value
520        .bytes()
521        .position(|byte| byte.is_ascii_alphabetic())
522        .ok_or(CacheParseError::Invalid)?;
523    let (number, unit) = value.split_at(unit_start);
524    if number.is_empty() || unit.is_empty() {
525        return Err(CacheParseError::Invalid);
526    }
527
528    let mut coefficient = 0_u128;
529    let mut scale = 1_u128;
530    let mut saw_digit = false;
531    let mut saw_decimal = false;
532    for byte in number.bytes() {
533        match byte {
534            b'0'..=b'9' => {
535                saw_digit = true;
536                coefficient = coefficient
537                    .checked_mul(10)
538                    .and_then(|value| value.checked_add(u128::from(byte - b'0')))
539                    .ok_or(CacheParseError::Overflow)?;
540                if saw_decimal {
541                    scale = scale.checked_mul(10).ok_or(CacheParseError::Overflow)?;
542                }
543            }
544            b'.' if !saw_decimal => saw_decimal = true,
545            _ => return Err(CacheParseError::Invalid),
546        }
547    }
548    if !saw_digit || number.ends_with('.') {
549        return Err(CacheParseError::Invalid);
550    }
551
552    let multiplier = match unit.to_ascii_lowercase().as_str() {
553        "b" => 1_u128,
554        "kb" => 1_000_u128,
555        "mb" => 1_000_000_u128,
556        "gb" => 1_000_000_000_u128,
557        "tb" => 1_000_000_000_000_u128,
558        "pb" => 1_000_000_000_000_000_u128,
559        "eb" => 1_000_000_000_000_000_000_u128,
560        _ => return Err(CacheParseError::Invalid),
561    };
562    let numerator = coefficient
563        .checked_mul(multiplier)
564        .ok_or(CacheParseError::Overflow)?;
565    // A fractional byte cannot represent a native byte count. Docker's own
566    // formatter never emits one, so rejecting it catches incompatible output.
567    if numerator % scale != 0 {
568        return Err(CacheParseError::Invalid);
569    }
570    u64::try_from(numerator / scale).map_err(|_| CacheParseError::Overflow)
571}
572
573#[cfg(test)]
574mod tests {
575    use std::collections::VecDeque;
576    use std::io;
577    use std::process::ExitStatus;
578    use std::sync::Mutex;
579    use std::time::Duration;
580
581    use super::*;
582    use crate::process::{CapturedOutput, TerminationStatus};
583
584    #[derive(Clone, Debug, PartialEq, Eq)]
585    struct Call {
586        program: String,
587        arguments: Vec<String>,
588        limits: CaptureLimits,
589        environment_count: usize,
590        has_working_directory: bool,
591    }
592
593    struct FakeRunner {
594        outcomes: Mutex<VecDeque<CommandOutcome>>,
595        calls: Mutex<Vec<Call>>,
596    }
597
598    impl FakeRunner {
599        fn new(outcomes: impl IntoIterator<Item = CommandOutcome>) -> Self {
600            Self {
601                outcomes: Mutex::new(outcomes.into_iter().collect()),
602                calls: Mutex::new(Vec::new()),
603            }
604        }
605
606        fn calls(&self) -> Vec<Call> {
607            self.calls.lock().unwrap().clone()
608        }
609    }
610
611    impl CommandRunner for FakeRunner {
612        fn run_captured(&self, command: &CommandSpec, limits: CaptureLimits) -> CommandOutcome {
613            self.calls.lock().unwrap().push(Call {
614                program: command.program().to_string_lossy().into_owned(),
615                arguments: command
616                    .arguments()
617                    .iter()
618                    .map(|argument| argument.to_string_lossy().into_owned())
619                    .collect(),
620                limits,
621                environment_count: command.environment().len(),
622                has_working_directory: command.working_directory().is_some(),
623            });
624            self.outcomes
625                .lock()
626                .unwrap()
627                .pop_front()
628                .expect("unexpected native command")
629        }
630
631        fn run_foreground(&self, _command: &CommandSpec) -> io::Result<ExitStatus> {
632            panic!("cache status must never execute a foreground command")
633        }
634    }
635
636    fn exited(success: bool, stdout: &str, stderr: &str) -> CommandOutcome {
637        CommandOutcome::Exited {
638            status: exit_status(success),
639            output: CapturedOutput {
640                stdout: stdout.as_bytes().to_vec(),
641                stderr: stderr.as_bytes().to_vec(),
642                elapsed: Duration::ZERO,
643                ..CapturedOutput::default()
644            },
645        }
646    }
647
648    fn truncated(stdout: &str, stderr: bool) -> CommandOutcome {
649        CommandOutcome::Exited {
650            status: exit_status(true),
651            output: CapturedOutput {
652                stdout: stdout.as_bytes().to_vec(),
653                stdout_truncated: !stderr,
654                stderr_truncated: stderr,
655                ..CapturedOutput::default()
656            },
657        }
658    }
659
660    #[cfg(unix)]
661    fn exit_status(success: bool) -> ExitStatus {
662        use std::os::unix::process::ExitStatusExt;
663        ExitStatus::from_raw(if success { 0 } else { 1 << 8 })
664    }
665
666    #[cfg(windows)]
667    fn exit_status(success: bool) -> ExitStatus {
668        use std::os::windows::process::ExitStatusExt;
669        ExitStatus::from_raw(if success { 0 } else { 1 })
670    }
671
672    fn docker_fixture() -> &'static str {
673        concat!(
674            r#"{"Type":"Build Cache","TotalCount":"5","Active":"1","Size":"1.5GB","Reclaimable":"500MB (33%)","Secret":"top-secret"}"#,
675            "\n",
676            r#"{"Type":"Images","TotalCount":"2","Active":"1","Size":"2GB","Reclaimable":"1GB (50%)"}"#,
677            "\n",
678            r#"{"Type":"Containers","TotalCount":"3","Active":"2","Size":"12.5kB","Reclaimable":"2.5kB (20%)"}"#,
679            "\n",
680            r#"{"Type":"Local Volumes","TotalCount":"1","Active":"0","Size":"1MB","Reclaimable":"1MB (100%)"}"#,
681            "\n"
682        )
683    }
684
685    #[test]
686    fn docker_parser_converts_documented_json_lines_to_checked_bytes() {
687        let records = parse_docker_cache_records(docker_fixture().as_bytes()).unwrap();
688        assert_eq!(records.len(), 4);
689        let images = records
690            .iter()
691            .find(|record| record.kind == NativeCacheRecordKind::Images)
692            .unwrap();
693        assert_eq!((images.count, images.active), (2, 1));
694        assert_eq!(
695            (images.total, images.reclaimable),
696            (2_000_000_000, 1_000_000_000)
697        );
698        let containers = records
699            .iter()
700            .find(|record| record.kind == NativeCacheRecordKind::Containers)
701            .unwrap();
702        assert_eq!((containers.total, containers.reclaimable), (12_500, 2_500));
703    }
704
705    #[test]
706    fn docker_size_parser_is_decimal_exact_and_bounded() {
707        for (raw, expected) in [
708            ("0B", 0),
709            ("999B", 999),
710            ("1.234kB", 1_234),
711            ("12.5MB", 12_500_000),
712            ("2GB", 2_000_000_000),
713            ("0.001TB", 1_000_000_000),
714        ] {
715            assert_eq!(parse_human_bytes(raw), Ok(expected), "{raw}");
716        }
717        for raw in ["", "12", "-1GB", "NaNGB", "1GiB", "1.2.3GB", "0.1B"] {
718            assert_eq!(
719                parse_human_bytes(raw),
720                Err(CacheParseError::Invalid),
721                "{raw}"
722            );
723        }
724        assert_eq!(parse_human_bytes("19EB"), Err(CacheParseError::Overflow));
725    }
726
727    #[test]
728    fn docker_parser_rejects_partial_unknown_duplicate_and_inconsistent_rows() {
729        for raw in [
730            "not-json\n",
731            r#"{"Type":"Future Cache","TotalCount":"1","Active":"0","Size":"1B","Reclaimable":"1B"}"#,
732            concat!(
733                r#"{"Type":"Images","TotalCount":"1","Active":"0","Size":"1B","Reclaimable":"1B"}"#,
734                "\n",
735                r#"{"Type":"Images","TotalCount":"1","Active":"0","Size":"1B","Reclaimable":"1B"}"#
736            ),
737            r#"{"Type":"Images","TotalCount":"1","Active":"2","Size":"1B","Reclaimable":"0B"}"#,
738            r#"{"Type":"Images","TotalCount":"1","Active":"0","Size":"1B","Reclaimable":"2B"}"#,
739            "",
740        ] {
741            assert_eq!(
742                parse_docker_cache_records(raw.as_bytes()),
743                Err(CacheParseError::Invalid),
744                "{raw}"
745            );
746        }
747    }
748
749    #[test]
750    fn docker_query_is_bounded_read_only_deterministic_and_secret_safe() {
751        let limits = CaptureLimits::new(Duration::from_millis(321), 4_096, 2_048);
752        let runner = FakeRunner::new([exited(
753            true,
754            docker_fixture(),
755            "warning includes bearer super-secret",
756        )]);
757        let status = DockerCacheQuery.query(&runner, limits);
758
759        assert_eq!(status.status, CacheQueryStatus::Available);
760        assert_eq!(status.schema_version, 1);
761        assert_eq!(status.total, 3_501_012_500);
762        assert_eq!(status.reclaimable, 1_501_002_500);
763        assert_eq!(
764            status
765                .records
766                .iter()
767                .map(|record| record.kind)
768                .collect::<Vec<_>>(),
769            [
770                NativeCacheRecordKind::Images,
771                NativeCacheRecordKind::Containers,
772                NativeCacheRecordKind::LocalVolumes,
773                NativeCacheRecordKind::BuildCache,
774            ]
775        );
776
777        let calls = runner.calls();
778        assert_eq!(calls.len(), 1);
779        assert_eq!(calls[0].program, "docker");
780        assert_eq!(
781            calls[0].arguments,
782            ["system", "df", "--format", "{{json .}}"]
783        );
784        assert_eq!(calls[0].limits, limits);
785        assert_eq!(calls[0].environment_count, 0);
786        assert!(!calls[0].has_working_directory);
787        assert!(!calls[0]
788            .arguments
789            .iter()
790            .any(|argument| argument == "prune" || argument == "-c"));
791
792        let serialized = serde_json::to_string(&status).unwrap();
793        assert!(serialized.starts_with("{\"schema_version\":1,\"runtime\":\"docker\""));
794        for secret in ["top-secret", "super-secret", "bearer"] {
795            assert!(!serialized.to_ascii_lowercase().contains(secret));
796        }
797        assert_eq!(serialized, serde_json::to_string(&status).unwrap());
798    }
799
800    #[test]
801    fn docker_overflow_becomes_invalid_without_partial_records() {
802        let output = concat!(
803            r#"{"Type":"Images","TotalCount":"1","Active":"0","Size":"18446744073709551615B","Reclaimable":"0B"}"#,
804            "\n",
805            r#"{"Type":"Containers","TotalCount":"1","Active":"0","Size":"1B","Reclaimable":"0B"}"#
806        );
807        let runner = FakeRunner::new([exited(true, output, "")]);
808        let status = DockerCacheQuery.query(&runner, CaptureLimits::default());
809        assert_eq!(status.status, CacheQueryStatus::InvalidOutput);
810        assert!(status.records.is_empty());
811        assert_eq!((status.total, status.reclaimable), (0, 0));
812    }
813
814    #[test]
815    fn nonzero_docker_exits_are_typed_without_retaining_output() {
816        for (stderr, expected) in [
817            (
818                "permission denied token=secret",
819                CacheQueryStatus::PermissionDenied,
820            ),
821            (
822                "Cannot connect to the Docker daemon",
823                CacheQueryStatus::Unreachable,
824            ),
825            (
826                "daemon returned proprietary failure",
827                CacheQueryStatus::CommandFailed,
828            ),
829        ] {
830            let runner = FakeRunner::new([exited(false, "stale parseable secret", stderr)]);
831            let status = DockerCacheQuery.query(&runner, CaptureLimits::default());
832            assert_eq!(status.status, expected, "{stderr}");
833            assert!(status.records.is_empty());
834            let serialized = serde_json::to_string(&status).unwrap();
835            assert!(!serialized.contains("secret"));
836            assert!(!serialized.contains("proprietary"));
837        }
838    }
839
840    #[test]
841    fn docker_truncation_and_timeout_are_never_parsed() {
842        for outcome in [
843            truncated(docker_fixture(), false),
844            truncated(docker_fixture(), true),
845        ] {
846            let runner = FakeRunner::new([outcome]);
847            let status = DockerCacheQuery.query(&runner, CaptureLimits::default());
848            assert_eq!(status.status, CacheQueryStatus::OutputTruncated);
849            assert!(status.records.is_empty());
850        }
851        let runner = FakeRunner::new([CommandOutcome::TimedOut {
852            output: CapturedOutput {
853                stdout: docker_fixture().as_bytes().to_vec(),
854                ..CapturedOutput::default()
855            },
856            termination: TerminationStatus::Requested,
857        }]);
858        let status = DockerCacheQuery.query(&runner, CaptureLimits::default());
859        assert_eq!(status.status, CacheQueryStatus::TimedOut);
860        assert!(status.records.is_empty());
861    }
862
863    #[test]
864    fn buildx_parser_uses_only_documented_numeric_and_boolean_fields() {
865        let output = concat!(
866            r#"{"ID":"private-id","Description":"token=secret","Size":"829889526","Reclaimable":true,"Shared":false}"#,
867            "\n",
868            r#"{"ID":"other-id","Description":"/private/path","Size":170474,"Reclaimable":false}"#,
869            "\n"
870        );
871        let records = parse_buildx_cache_records(output.as_bytes()).unwrap();
872        assert_eq!(
873            records,
874            [NativeCacheRecord {
875                kind: NativeCacheRecordKind::BuildCache,
876                count: 2,
877                active: 1,
878                total: 830_060_000,
879                reclaimable: 829_889_526,
880            }]
881        );
882        assert_eq!(
883            parse_buildx_cache_records(b""),
884            Ok(vec![NativeCacheRecord {
885                kind: NativeCacheRecordKind::BuildCache,
886                count: 0,
887                active: 0,
888                total: 0,
889                reclaimable: 0,
890            }])
891        );
892    }
893
894    #[test]
895    fn buildx_parser_rejects_malformed_human_or_overflowing_output() {
896        for raw in [
897            "not-json",
898            r#"{"Size":"1.2GB","Reclaimable":true}"#,
899            r#"{"Size":"1","Reclaimable":"true"}"#,
900            r#"{"Size":-1,"Reclaimable":true}"#,
901            concat!(
902                r#"{"Size":"18446744073709551615","Reclaimable":true}"#,
903                "\n",
904                r#"{"Size":"1","Reclaimable":true}"#
905            ),
906        ] {
907            assert!(parse_buildx_cache_records(raw.as_bytes()).is_err(), "{raw}");
908        }
909    }
910
911    #[test]
912    fn buildx_query_is_version_gated_bounded_and_builder_bound() {
913        let limits = CaptureLimits::new(Duration::from_millis(777), 8_192, 1_024);
914        let du = concat!(
915            r#"{"ID":"secret-id","Description":"credential=secret","Size":"1000","Reclaimable":true}"#,
916            "\n",
917            r#"{"ID":"active-id","Size":"2000","Reclaimable":false}"#,
918            "\n"
919        );
920        let runner = FakeRunner::new([
921            exited(true, "github.com/docker/buildx v0.36.1 deadbeef\n", ""),
922            exited(true, du, "warning secret-stderr"),
923        ]);
924        let query =
925            BuildxCacheQuery::new(BuildxBuilderSelector::named("team.private-builder").unwrap());
926        let status = query.query(&runner, limits);
927
928        assert_eq!(status.status, CacheQueryStatus::Available);
929        assert_eq!(status.runtime, RuntimeKind::Buildkit);
930        assert_eq!(status.owner, NativeCacheOwner::BuildkitBuilder);
931        assert_eq!((status.total, status.reclaimable), (3_000, 1_000));
932        assert_eq!((status.records[0].count, status.records[0].active), (2, 1));
933        let calls = runner.calls();
934        assert_eq!(calls.len(), 2);
935        assert_eq!(calls[0].arguments, ["buildx", "version"]);
936        assert_eq!(
937            calls[1].arguments,
938            [
939                "buildx",
940                "du",
941                "--format=json",
942                "--builder",
943                "team.private-builder"
944            ]
945        );
946        assert!(calls.iter().all(|call| call.limits == limits));
947        assert!(!calls
948            .iter()
949            .flat_map(|call| &call.arguments)
950            .any(|argument| argument == "--bootstrap" || argument == "prune"));
951
952        let serialized = serde_json::to_string(&status).unwrap();
953        for secret in [
954            "team.private-builder",
955            "secret-id",
956            "credential",
957            "secret-stderr",
958        ] {
959            assert!(
960                !serialized.contains(secret),
961                "leaked {secret}: {serialized}"
962            );
963        }
964        assert_eq!(status.evidence.len(), 2);
965    }
966
967    #[test]
968    fn old_or_unparseable_buildx_never_runs_disk_usage() {
969        for version in [
970            "github.com/docker/buildx v0.27.9 deadbeef\n",
971            "vendor buildx unknown-version\n",
972        ] {
973            let runner = FakeRunner::new([exited(true, version, "")]);
974            let status = BuildxCacheQuery::default().query(&runner, CaptureLimits::default());
975            assert_eq!(status.status, CacheQueryStatus::UnsupportedVersion);
976            assert_eq!(runner.calls().len(), 1);
977            assert_eq!(status.evidence.len(), 1);
978        }
979    }
980
981    #[test]
982    fn buildx_nonzero_exit_and_format_rejection_are_typed() {
983        let missing = FakeRunner::new([exited(
984            false,
985            "",
986            "docker: 'buildx' is not a docker command",
987        )]);
988        assert_eq!(
989            BuildxCacheQuery::default()
990                .query(&missing, CaptureLimits::default())
991                .status,
992            CacheQueryStatus::NotInstalled
993        );
994
995        let unsupported = FakeRunner::new([
996            exited(true, "github.com/docker/buildx v0.28.0\n", ""),
997            exited(false, "", "unknown flag: --format"),
998        ]);
999        assert_eq!(
1000            BuildxCacheQuery::default()
1001                .query(&unsupported, CaptureLimits::default())
1002                .status,
1003            CacheQueryStatus::UnsupportedVersion
1004        );
1005
1006        let unreachable = FakeRunner::new([
1007            exited(true, "github.com/docker/buildx v0.36.1\n", ""),
1008            exited(false, "", "connection refused"),
1009        ]);
1010        assert_eq!(
1011            BuildxCacheQuery::default()
1012                .query(&unreachable, CaptureLimits::default())
1013                .status,
1014            CacheQueryStatus::Unreachable
1015        );
1016    }
1017
1018    #[test]
1019    fn buildx_invalid_overflow_truncation_and_timeout_have_no_partial_records() {
1020        let cases = [
1021            (
1022                exited(true, r#"{"Size":"human","Reclaimable":true}"#, ""),
1023                CacheQueryStatus::InvalidOutput,
1024            ),
1025            (
1026                exited(
1027                    true,
1028                    concat!(
1029                        r#"{"Size":"18446744073709551615","Reclaimable":true}"#,
1030                        "\n",
1031                        r#"{"Size":"1","Reclaimable":true}"#
1032                    ),
1033                    "",
1034                ),
1035                CacheQueryStatus::InvalidOutput,
1036            ),
1037            (truncated("{}", false), CacheQueryStatus::OutputTruncated),
1038            (
1039                CommandOutcome::TimedOut {
1040                    output: CapturedOutput::default(),
1041                    termination: TerminationStatus::Requested,
1042                },
1043                CacheQueryStatus::TimedOut,
1044            ),
1045        ];
1046        for (du_outcome, expected) in cases {
1047            let runner = FakeRunner::new([
1048                exited(true, "github.com/docker/buildx v0.36.1\n", ""),
1049                du_outcome,
1050            ]);
1051            let status = BuildxCacheQuery::default().query(&runner, CaptureLimits::default());
1052            assert_eq!(status.status, expected);
1053            assert!(status.records.is_empty());
1054            assert_eq!((status.total, status.reclaimable), (0, 0));
1055        }
1056
1057        let version_timeout = FakeRunner::new([CommandOutcome::TimedOut {
1058            output: CapturedOutput::default(),
1059            termination: TerminationStatus::Requested,
1060        }]);
1061        let status = BuildxCacheQuery::default().query(&version_timeout, CaptureLimits::default());
1062        assert_eq!(status.status, CacheQueryStatus::TimedOut);
1063        assert_eq!(version_timeout.calls().len(), 1);
1064    }
1065
1066    #[test]
1067    fn containerd_is_explicitly_unsupported_without_any_probe_or_private_scan() {
1068        let runner = FakeRunner::new([]);
1069        let status = ContainerdCacheQuery.query(&runner, CaptureLimits::default());
1070        assert_eq!(status.schema_version, 1);
1071        assert_eq!(status.runtime, RuntimeKind::Containerd);
1072        assert_eq!(status.owner, NativeCacheOwner::Containerd);
1073        assert_eq!(status.status, CacheQueryStatus::Unsupported);
1074        assert!(status.records.is_empty());
1075        assert!(status.evidence.is_empty());
1076        assert!(runner.calls().is_empty());
1077    }
1078}