1use 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
21pub const NATIVE_CACHE_STATUS_SCHEMA_VERSION: u32 = 1;
23
24const MINIMUM_BUILDX_JSON_DU_VERSION: Version = Version::new(0, 28, 0);
28
29#[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#[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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
67pub struct NativeCacheRecord {
68 pub kind: NativeCacheRecordKind,
69 pub count: u64,
70 pub active: u64,
71 pub total: u64,
73 pub reclaimable: u64,
75}
76
77#[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 pub total: u64,
91 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#[derive(Clone, Copy, Debug, Default)]
150pub struct DockerCacheQuery;
151
152impl DockerCacheQuery {
153 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#[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 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 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#[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
516fn 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 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}