Files
Triple-C/app/src-tauri/src/docker/disk_tests.rs
T
shadow-testandClaude Opus 5 6abc7f27a4 Fix disk/migration defects and add a real per-project lock
The compaction panel's headline action had never worked, three reclaim
paths could delete data with no confirmation and no grace period, and the
app's only mutual-exclusion primitive was one-way.

**A per-project lock (`project_lock.rs`).** `ACTIVE_MIGRATIONS` was the
app's only exclusion and everything but migration merely *polled* it once
at entry. Compaction, start/stop/recreate, Reset and destroy now
**acquire** a `ProjectGuard` and hold it for the whole operation;
`is_migrating` is a view onto the same registry. Closes the three verified
interleavings where a compaction commits `flat(A)` over a `:latest` that a
migration, a recreate or a Reset had already moved. In-process only — the
two-instance case is documented in the module, not solved, and the
daemon-wide reapers gained age gates to bound it.

**H1: compaction never ran.** `fold_shell_script` joined the scrub script's
lines with a space, so every build died on `syntax error: unexpected "do"`.
Replaced with the JSON exec form, which carries any script verbatim;
`sh -n` and a real end-to-end build now cover it (159.5 MB / 9 layers ->
33.7 MB / 1 layer, setuid and multi-line env preserved).

**H2/H4:** `reclaim_migration_pins` and `survey_rollback_pins` apply
`parse_rollback_tag` and `pin_is_reapable` like every other path, and stop
double-counting an image with two pin tags. The 14-day grace period is
re-anchored from the tag's timestamp (when the migration *started*) to a
tombstone recording when the record went missing, with clock skew handled
in both directions.

**H3:** `migration_store::load` no longer renames a corrupt record aside —
that destroyed the `has_record` signal both pin reapers depend on. `save`
fsyncs the file and the directory, and corruption backups are timestamped.

**H2b/M2:** a crashed compaction's `:compacting` tag and `triple-c-compact-*`
container are reaped at startup; the stale-container sweep moved from the
end of a compaction to the start, where its doc always claimed it was.

**M5/M6:** orphan-volume deletion moved from a `Safety::Safe` tick to the
destructive path with a typed volume name; `project_store_trust` reads the
real `projects.json` so a second instance's project is not offered as an
orphan.

Numbers: `images_total_bytes` uses `df()`'s deduplicated `layers_size`; the
Total column is derived from the same figure the Snapshot column shows;
partial container/staging reclaims report their failure count; `human()`
no longer prints "1000.0 KB"; `docker_cli` has a timeout; blocking `fs`
calls moved to `spawn_blocking`.

Also fixes `ProbeContainerGuard::remove_now`, which disarmed before the
await and so did nothing on the cancellation path it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 11:45:28 -07:00

1496 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tests for the disk view's pure logic.
//!
//! Split into its own file because `disk.rs` is already long and because
//! everything here has to stay runnable without a daemon — which is the point
//! of keeping the classification, the orphan set and the script builders pure.
//!
//! The blast radius of a mistake in this module is a user's credentials,
//! transcripts and toolchains, so the tests below are deliberately about
//! *refusing*, not about succeeding.
use super::*;
// ---------------------------------------------------------------------------
// Safety classification
// ---------------------------------------------------------------------------
/// Every `ReclaimTarget` variant, so the walks below cannot silently skip a new
/// one. A variant added without a line here fails `every_variant_is_covered`.
fn all_reclaim_targets() -> Vec<ReclaimTarget> {
vec![
ReclaimTarget::DanglingSnapshots,
ReclaimTarget::SupersededBaseImages,
ReclaimTarget::BuildCache { all: false },
ReclaimTarget::BuildCache { all: true },
ReclaimTarget::MigrationPins,
ReclaimTarget::MigrationStaging,
ReclaimTarget::ProbeContainers,
ReclaimTarget::ScrubContainers,
ReclaimTarget::CompactSnapshot {
project_id: "p1".to_string(),
},
ReclaimTarget::ClearCaches {
project_id: "p1".to_string(),
include_rustup: false,
},
ReclaimTarget::ClearCaches {
project_id: "p1".to_string(),
include_rustup: true,
},
]
}
#[test]
fn every_variant_is_covered_by_the_safety_walk() {
// `ReclaimTarget` has no way to enumerate itself, so this pins the count by
// hand. Bumping it is the prompt to add the new variant above *and* decide
// its safety deliberately rather than by whatever the match arm falls into.
let discriminants: HashSet<String> = all_reclaim_targets()
.iter()
.map(|t| serde_json::to_value(t).unwrap()["kind"].as_str().unwrap().to_string())
.collect();
assert_eq!(
discriminants.len(),
9,
"a ReclaimTarget variant was added or removed; update all_reclaim_targets() and check its \
safety: {:?}",
discriminants
);
}
#[test]
fn nothing_destructive_can_land_in_the_safe_bucket() {
// The strongest form of this guarantee is structural: `reclaim` takes
// `&[ReclaimTarget]` and `DestructiveTarget` is a different type, so a
// destructive action cannot be passed to a bulk reclaim at all. What this
// test pins is the second half — that no *safe*-classified target names a
// live project's data either.
for target in all_reclaim_targets() {
match &target {
// These act on a project, and both are rewrites or cache flushes.
// Neither may ever be classified Safe: one rebuilds an image and
// the other costs a re-download.
ReclaimTarget::CompactSnapshot { .. } | ReclaimTarget::ClearCaches { .. } => {
assert_eq!(
target.safety(),
Safety::SemiSafe,
"{:?} must ask for confirmation",
target
);
}
// A safe target may name a *volume*, but only ever one that orphan
// detection produced — which by construction belongs to no project
// in the store.
other => assert_eq!(
other.safety(),
Safety::Safe,
"{:?} was expected to need no confirmation",
other
),
}
}
}
#[test]
fn only_the_build_cache_reaches_outside_triple_c() {
// The user's daemon also holds their unrelated postgres, mysql and
// site-builder work. Exactly one action here touches it, and the UI has to
// say so — so if a second one ever does, this fails loudly.
let daemon_wide: Vec<_> = all_reclaim_targets()
.into_iter()
.filter(ReclaimTarget::is_daemon_wide)
.collect();
assert_eq!(daemon_wide.len(), 2, "expected only the two BuildCache variants");
assert!(daemon_wide
.iter()
.all(|t| matches!(t, ReclaimTarget::BuildCache { .. })));
}
#[test]
fn destructive_targets_all_name_a_project() {
// The typed confirmation is "type the project name". A destructive target
// that could not name a project would have nothing to confirm against.
for target in [
DestructiveTarget::HomeVolume {
project_id: "p1".to_string(),
},
DestructiveTarget::ConfigVolume {
project_id: "p1".to_string(),
},
DestructiveTarget::SnapshotImage {
project_id: "p1".to_string(),
},
DestructiveTarget::RollbackPin {
project_id: "p1".to_string(),
tag: "pre-migration-20260101-101500".to_string(),
},
] {
assert_eq!(target.project_id(), "p1");
}
}
#[test]
fn a_dangling_image_is_a_base_only_when_it_says_so() {
let base = HashMap::from([(LABEL_BASE.to_string(), "true".to_string())]);
assert_eq!(classify_dangling(&base), DanglingClass::Base);
// `create_container` writes `triple-c.base` explicitly *empty* precisely so
// an inherited `true` cannot ride a commit onto a snapshot and make it
// claim to be a base image.
let commit = HashMap::from([(LABEL_BASE.to_string(), String::new())]);
assert_eq!(classify_dangling(&commit), DanglingClass::SnapshotCommit);
// Images committed before the label existed carry it not at all.
assert_eq!(
classify_dangling(&HashMap::new()),
DanglingClass::SnapshotCommit
);
// Anything other than the exact string `true` is not a base.
let liar = HashMap::from([(LABEL_BASE.to_string(), "yes".to_string())]);
assert_eq!(classify_dangling(&liar), DanglingClass::SnapshotCommit);
}
// ---------------------------------------------------------------------------
// Orphan detection — the part that can delete a user's transcripts
// ---------------------------------------------------------------------------
fn vol(name: &str, bytes: i64, links: i64) -> VolumeFacts {
VolumeFacts {
name: name.to_string(),
bytes,
links,
created_at: Some("2026-03-14T09:00:00Z".to_string()),
}
}
#[test]
fn orphan_detection_skips_every_project_in_the_store() {
let volumes = vec![
vol("triple-c-home-live", 1_000, 0),
vol("triple-c-claude-config-live", 2_000, 0),
vol("triple-c-home-gone", 3_000, 0),
vol("triple-c-claude-config-gone", 4_000, 0),
];
let known = HashSet::from(["live".to_string()]);
let orphans = orphan_volumes(&volumes, &known, true);
let names: Vec<&str> = orphans.iter().map(|o| o.name.as_str()).collect();
assert_eq!(
names,
vec!["triple-c-claude-config-gone", "triple-c-home-gone"],
"sorted biggest first"
);
assert!(
!names.iter().any(|n| n.contains("live")),
"a live project's volumes were offered for deletion"
);
}
#[test]
fn a_store_that_did_not_load_yields_no_orphans_at_all() {
// This is the case the whole design turns on, and the one that would wipe
// every project's credentials, transcripts and toolchains at once. With the
// store unreadable, *every* project's volumes look unclaimed — so the
// answer has to be "nothing, and here is why", never "everything".
let volumes = vec![
vol("triple-c-home-a", 1_000, 0),
vol("triple-c-claude-config-a", 2_000, 0),
vol("triple-c-home-b", 3_000, 0),
];
assert!(orphan_volumes(&volumes, &HashSet::new(), false).is_empty());
// And with the store loaded but genuinely empty, they *are* orphans — the
// distinction is the flag, not the emptiness of the set.
assert_eq!(orphan_volumes(&volumes, &HashSet::new(), true).len(), 3);
}
#[test]
fn an_idle_live_project_is_never_mistaken_for_a_deleted_one() {
// The exact mistake this guard exists for. An "orphan" heuristic of "no
// container and no snapshot image" was tried against a real project list
// and flagged two live projects — `site-builder` and `cal-dav-mcp` — that
// had simply been idle long enough for their containers to be removed.
// Their volumes held `.credentials.json`, Claude transcripts and shell
// history.
//
// From the daemon's side those look identical to a deleted project's
// leftovers: volumes present, ref count zero, no container, no image. The
// *only* thing that tells them apart is membership in Triple-C's own
// project store, so that is the only thing consulted.
let idle_but_live = vec![
vol("triple-c-home-site-builder", 8_400_000_000, 0),
vol("triple-c-claude-config-site-builder", 427_000_000, 0),
vol("triple-c-home-cal-dav-mcp", 1_200_000_000, 0),
vol("triple-c-claude-config-cal-dav-mcp", 44_000_000, 0),
vol("triple-c-home-really-gone", 900_000, 0),
];
let store = HashSet::from(["site-builder".to_string(), "cal-dav-mcp".to_string()]);
let orphans = orphan_volumes(&idle_but_live, &store, true);
assert_eq!(
orphans.iter().map(|o| o.name.as_str()).collect::<Vec<_>>(),
vec!["triple-c-home-really-gone"],
"an idle live project's volumes were offered for deletion"
);
// And nothing in the signature even *offers* container or image state, so a
// future change cannot quietly start inferring from it.
assert_eq!(
orphans[0].created_at.as_deref(),
Some("2026-03-14T09:00:00Z"),
"the creation date is the evidence a user recognises the project by"
);
}
#[test]
fn a_volume_with_a_container_attached_is_never_an_orphan() {
let volumes = vec![
vol("triple-c-home-gone", 1_000, 1),
// -1 is "the daemon did not compute it", which must fail closed: an
// unknown ref count is not permission.
vol("triple-c-claude-config-gone", 2_000, -1),
vol("triple-c-home-other", 3_000, 0),
];
let orphans = orphan_volumes(&volumes, &HashSet::new(), true);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].name, "triple-c-home-other");
}
#[test]
fn orphan_detection_ignores_volumes_that_are_not_ours() {
let volumes = vec![
vol("nfc-profile-mysql", 183_926_366, 0),
vol("postgres_data", 9_000_000, 0),
vol("triple-c-stt-model-cache", 900_000_000, 0),
vol("triple-c-gateway-config", 1_000, 0),
vol("triple-c-home-gone", 5_000, 0),
];
let orphans = orphan_volumes(&volumes, &HashSet::new(), true);
assert_eq!(orphans.len(), 1, "{:?}", orphans);
assert_eq!(orphans[0].name, "triple-c-home-gone");
// The STT model cache and the gateway config are ours by name but are not
// per-project volumes; they belong to features, not projects, and nothing
// here may reach them.
assert!(parse_project_volume_name("triple-c-stt-model-cache").is_none());
assert!(parse_project_volume_name("triple-c-gateway-config").is_none());
}
#[test]
fn a_volume_name_splits_into_the_right_project_and_role() {
assert_eq!(
parse_project_volume_name("triple-c-home-abc-123"),
Some(("abc-123", "home"))
);
assert_eq!(
parse_project_volume_name("triple-c-claude-config-abc-123"),
Some(("abc-123", "config"))
);
// A bare prefix names no project, so it is not ours to delete.
assert!(parse_project_volume_name("triple-c-home-").is_none());
assert!(parse_project_volume_name("triple-c-claude-config-").is_none());
assert!(parse_project_volume_name("triple-c-").is_none());
assert!(parse_project_volume_name("").is_none());
}
#[test]
fn the_config_role_is_reported_because_it_is_the_one_holding_credentials() {
let orphans = orphan_volumes(
&[vol("triple-c-claude-config-gone", 7, 0)],
&HashSet::new(),
true,
);
assert_eq!(orphans[0].role, "config");
assert_eq!(orphans[0].project_id, "gone");
}
// ---------------------------------------------------------------------------
// Throwaway-container predicates — these gate a `docker rm`
// ---------------------------------------------------------------------------
fn summary(names: &[&str], labels: &[(&str, &str)]) -> ContainerSummary {
ContainerSummary {
names: Some(names.iter().map(|n| (*n).to_string()).collect()),
labels: Some(
labels
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
),
..Default::default()
}
}
#[test]
fn a_scrub_container_is_matched_on_its_whole_name_not_a_substring() {
// Docker's `name` filter is a *substring* match, so the daemon happily
// returns a user's own container whose name merely contains ours. The
// predicate is what decides, and it anchors at the start.
assert!(is_scrub_container(&summary(&["/triple-c-scrub-abc123"], &[])));
assert!(!is_scrub_container(&summary(&["/my-triple-c-scrub-notes"], &[])));
assert!(!is_scrub_container(&summary(&["/triple-c-scrubber"], &[])));
assert!(!is_scrub_container(&summary(&["/triple-c-abc"], &[])));
assert!(!is_scrub_container(&summary(&[], &[])));
}
#[test]
fn a_compaction_container_is_never_matched_by_the_scrub_bucket() {
// These had the same `triple-c-scrub-*` prefix once. The scrub bucket
// removes with `force: true`, so a reclaim fired from a second window while
// a compaction was mid-flight would have destroyed the container the commit
// was about to run against. Separate prefixes, and neither predicate may
// reach the other's containers.
let compaction = summary(&["/triple-c-compact-abc123"], &[]);
let scrub = summary(&["/triple-c-scrub-abc123"], &[]);
assert!(is_compaction_container(&compaction));
assert!(!is_scrub_container(&compaction), "the scrub bucket must not reach it");
assert!(is_scrub_container(&scrub));
assert!(!is_compaction_container(&scrub));
// Same substring-filter hazard applies to the new prefix.
assert!(!is_compaction_container(&summary(&["/my-triple-c-compact-notes"], &[])));
}
#[test]
fn the_daemon_wide_buckets_leave_a_young_container_alone() {
// Both of these buckets are `Safety::Safe` — one tick, no confirmation —
// and both reach *every* matching container on the daemon, because a label
// and a name prefix are daemon-wide and `ReclaimTarget::project_id()` is
// `None` for them. So a second app instance's live migration probe, and a
// live secret rewrite's scratch container, are both in range. Age is the
// only discriminator available from this side of the process boundary, and
// a container the daemon gave no creation time for is treated as young.
let now = chrono::Utc::now().timestamp();
let mut young_probe = summary(&["/nervous_curie"], &[(migration::LABEL_PROBE, "migration")]);
young_probe.created = Some(now - 30);
assert!(is_migration_probe(&young_probe));
assert!(!is_reapable_migration_probe(&young_probe));
let mut old_probe = young_probe.clone();
old_probe.created = Some(now - migration::PROBE_REAP_MIN_AGE_SECS - 1);
assert!(is_reapable_migration_probe(&old_probe));
let mut undated_probe = young_probe.clone();
undated_probe.created = None;
assert!(!is_reapable_migration_probe(&undated_probe));
// `triple-c-scrub-*` is a **live** name: `rewrite_image_without_secrets`
// creates its scratch container under it, and killing that between the
// create and the commit leaves a revoked OAuth token baked into the
// snapshot's Config.Env — the exact thing that function exists to remove.
let mut young_scrub = summary(&["/triple-c-scrub-abc123"], &[]);
young_scrub.created = Some(now - 5);
assert!(is_scrub_container(&young_scrub));
assert!(!is_reapable_scrub_container(&young_scrub));
let mut old_scrub = young_scrub.clone();
old_scrub.created = Some(now - SCRATCH_CONTAINER_MIN_AGE_SECS - 1);
assert!(is_reapable_scrub_container(&old_scrub));
}
#[test]
fn a_probe_container_is_matched_on_its_label_not_on_the_daemons_filter() {
// The `label=triple-c.probe=migration` filter is an exact match and would
// be enough on its own — but a filter is a string assembled elsewhere in
// the file, and "enough" is not the standard for something that runs
// `docker rm`.
assert!(is_migration_probe(&summary(
&["/nervous_curie"],
&[(migration::LABEL_PROBE, migration::PROBE_LABEL_MIGRATION)]
)));
// A different probe kind, a truncated value, and no label at all.
assert!(!is_migration_probe(&summary(
&["/x"],
&[(migration::LABEL_PROBE, "something-else")]
)));
assert!(!is_migration_probe(&summary(&["/x"], &[])));
assert!(!is_migration_probe(&summary(
&["/x"],
&[("triple-c.managed", "true")]
)));
}
// ---------------------------------------------------------------------------
// Store trust
// ---------------------------------------------------------------------------
fn project(id: &str, name: &str) -> Project {
let mut p = Project::new(name.to_string(), Vec::new());
p.id = id.to_string();
p
}
#[test]
fn an_unreadable_projects_json_is_never_trusted() {
let err = project_store_trust(&[project("a", "api")], true, None).unwrap_err();
assert!(err.contains("could not be read"), "{}", err);
}
#[test]
fn an_empty_list_from_an_existing_file_is_treated_as_a_failed_load() {
// `ProjectsStore::new()` swallows a corrupt projects.json: it backs the file
// up and starts empty. That is right for the app and catastrophic here, so
// the combination "empty list + file present" is refused rather than read as
// "the user has no projects".
let err = project_store_trust(&[], true, Some(&[])).unwrap_err();
assert!(err.contains("suppressed"), "{}", err);
// No file at all is a genuine fresh install, and there is nothing on the
// daemon to mis-attribute in that state.
assert!(project_store_trust(&[], false, Some(&[])).unwrap().is_empty());
}
#[test]
fn a_healthy_store_yields_its_ids() {
let ids =
project_store_trust(&[project("a", "api"), project("b", "web")], true, Some(&[])).unwrap();
assert_eq!(ids, HashSet::from(["a".to_string(), "b".to_string()]));
}
#[test]
fn a_project_only_the_file_knows_about_still_counts_as_live() {
// M6: `projects` is this process's in-memory list. A project added by a
// *second* copy of the app is in `projects.json` and not in that list, and
// its live home and config volumes then matched "no project claims this".
// The union is what closes the gap; the file is authoritative for
// everything this instance has not heard about.
let ids = project_store_trust(
&[project("a", "api")],
true,
Some(&["a".to_string(), "b-from-the-other-window".to_string()]),
)
.unwrap();
assert!(
ids.contains("b-from-the-other-window"),
"a project only the file knows about must not look orphaned: {:?}",
ids
);
// And the reverse: a project this instance just added is live whether or
// not the file has caught up.
assert!(ids.contains("a"), "{:?}", ids);
}
// ---------------------------------------------------------------------------
// Layer accounting — the number the whole UI exists to show
// ---------------------------------------------------------------------------
#[test]
fn commit_layers_are_the_history_a_snapshot_has_beyond_its_base() {
// `image_history` returns newest first, and a snapshot's history is its
// base's history with the commits appended — so the commits are the head.
let snapshot = vec![0, 868_000_000, 500_000_000, 4_000_000_000, 0];
let stats = layer_stats(&snapshot, Some(2));
assert_eq!(stats.commit_layers, 3);
assert_eq!(stats.above_base_bytes, Some(1_368_000_000));
}
#[test]
fn a_missing_base_reports_a_count_but_refuses_to_split_the_bytes() {
// A base image that has been swept is common — the project keeps running
// from its own snapshot. The layer count is still useful; the byte split is
// not knowable, and a guess there would be the one number in this UI that
// is not measured.
let stats = layer_stats(&[10, 20, 0, 30], None);
assert_eq!(stats.commit_layers, 3, "zero-byte layers are metadata, not commits");
assert_eq!(stats.above_base_bytes, None);
}
#[test]
fn a_base_longer_than_the_snapshot_means_they_are_not_the_same_lineage() {
let stats = layer_stats(&[10, 20], Some(5));
assert_eq!(stats.above_base_bytes, None);
}
#[test]
fn a_snapshot_that_is_exactly_its_base_has_no_commits() {
let stats = layer_stats(&[10, 20, 30], Some(3));
assert_eq!(stats.commit_layers, 0);
assert_eq!(stats.above_base_bytes, Some(0));
}
#[test]
fn compaction_is_bounded_and_the_floor_is_zero() {
// Verified on Docker 29.7.2: a stack with nothing superseded came out
// *larger* (29.8 MB -> 30.8 MB), because the merged layer recompresses on
// its own. So the floor is zero and never a fraction of the total.
let (floor, ceiling) = compaction_bounds(&[100, 100, 100]);
assert_eq!(floor, 0);
assert_eq!(ceiling, 200, "at most everything but the largest layer");
// One layer can supersede nothing, so there is no upside at all.
assert_eq!(compaction_bounds(&[500]), (0, 0));
assert_eq!(compaction_bounds(&[]), (0, 0));
}
#[test]
fn the_ceiling_shown_in_the_plan_matches_the_bound() {
// With no shared base to re-duplicate, the bound is the superseded-bytes
// one: an even split approximating "everything but the largest layer".
assert_eq!(compaction_ceiling_for(300, 0, 3), 200);
assert_eq!(compaction_ceiling_for(300, 0, 1), 0, "one layer supersedes nothing");
assert_eq!(compaction_ceiling_for(0, 0, 14), 0);
assert_eq!(compaction_ceiling_for(-5, 0, 3), 0, "never negative");
}
#[test]
fn compacting_a_thin_snapshot_over_a_fat_base_is_never_offered() {
// The bug this exists to stop, with the real numbers that exposed it.
//
// `FROM scratch` + `COPY --from` produces an image that shares nothing, so
// the flattened snapshot carries its own private copy of the base — which
// stays on disk regardless, because every other project is still built from
// it. Eight of ten projects on a real daemon had a unique delta of
// 0.101.32 GB over a 4.72 GB shared base: flattening any of them turns a
// sub-gigabyte cost into a ~4.7 GB one.
//
// A ceiling of zero keeps them out of the plan entirely, rather than
// offering a 4 GB loss as a saving.
let shared_base = 4_723_860_394;
for unique in [100_000_000i64, 630_000_000, 1_320_000_000] {
assert_eq!(
compaction_ceiling_for(unique, shared_base, 6),
0,
"a {}-byte delta over a {}-byte base must not be offered",
unique,
shared_base
);
}
// The one project that *was* worth it: 8.44 GB unique across 14 layers over
// a 3.83 GB base. The base penalty still binds — 8.44 - 3.83 = 4.61 GB is
// smaller than the 7.84 GB the even split allows — so that is the figure.
let ceiling = compaction_ceiling_for(8_440_966_715, 3_832_425_659, 14);
assert_eq!(ceiling, 8_440_966_715 - 3_832_425_659);
assert!(ceiling < (8_440_966_715 / 14) * 13, "the base penalty must bind here");
}
#[test]
fn the_superseded_bound_still_binds_when_the_base_is_small() {
// With a tiny base, the limit on what can come back is how much the layers
// superseded, not the duplication cost. Both terms have to be live.
let ceiling = compaction_ceiling_for(300, 10, 3);
assert_eq!(ceiling, 200, "the even split binds, not 300 - 10");
}
// ---------------------------------------------------------------------------
// Build cache
// ---------------------------------------------------------------------------
fn cache(size: i64, in_use: bool, age_hours: i64) -> BuildCacheFacts {
BuildCacheFacts {
size,
in_use,
last_used_at: Some(chrono::Utc::now() - chrono::Duration::hours(age_hours)),
}
}
#[test]
fn the_age_filter_leaves_in_use_and_recent_records_alone() {
let now = chrono::Utc::now();
let entries = vec![
cache(1_000, false, 200), // old and free -> counted
cache(2_000, true, 200), // old but in use -> never
cache(4_000, false, 10), // free but recent -> not by this filter
BuildCacheFacts {
size: 8_000,
in_use: false,
// No timestamp at all: unknown age fails closed, same rule as an
// unknown volume ref count.
last_used_at: None,
},
];
assert_eq!(stale_build_cache_bytes(&entries, 168, now), 1_000);
}
#[test]
fn docker_sizes_parse_in_base_1000_because_that_is_what_docker_prints() {
// `units.HumanSize` is base 1000. Reading "28.0GB" as 1024-based would
// overstate the single biggest win in this panel by about 7%.
assert_eq!(parse_docker_size("0B"), Some(0));
assert_eq!(parse_docker_size("28.0GB"), Some(28_000_000_000));
assert_eq!(parse_docker_size("1.5MB"), Some(1_500_000));
assert_eq!(parse_docker_size(" 46.88GB "), Some(46_880_000_000));
assert_eq!(parse_docker_size("12kB"), Some(12_000));
// Anything unrecognised is None, so the caller falls back to `df()` rather
// than showing a wrong number.
assert_eq!(parse_docker_size("lots"), None);
assert_eq!(parse_docker_size(""), None);
assert_eq!(parse_docker_size("12GiB"), None);
// A space before the unit is fine — `docker builder prune` uses a tab.
assert_eq!(parse_docker_size("1.5 kB"), Some(1_500));
assert_eq!(parse_docker_size("\t20.59MB"), Some(20_590_000));
// A negative would subtract from the running freed total if it got through.
assert_eq!(parse_docker_size("-5GB"), None);
}
#[test]
fn buildx_du_output_parses_into_total_and_reclaimable() {
// Real shape, taken from `docker buildx du` on Docker 29.7.2.
let output = "ID RECLAIMABLE SIZE LAST ACCESSED\n\
abc123 true 29.78MB 36 seconds ago\n\
Reclaimable:\t28.0GB\n\
Total:\t\t33.57MB\n";
assert_eq!(parse_buildx_du(output), Some((33_570_000, 28_000_000_000)));
// An empty cache still reports both lines.
assert_eq!(
parse_buildx_du("Reclaimable:\t0B\nTotal:\t\t0B\n"),
Some((0, 0))
);
// No Total line means the output is not what we expect; fall back rather
// than invent.
assert_eq!(parse_buildx_du("nothing here"), None);
}
#[test]
fn the_reclaimed_figure_comes_from_the_prunes_own_report() {
// `docker system prune` / `image prune` wording.
let output = "deleted: sha256:abc\ndeleted: sha256:def\nTotal reclaimed space: 12.3GB\n";
assert_eq!(parse_reclaimed_space(output), 12_300_000_000);
assert_eq!(parse_reclaimed_space("Total reclaimed space: 0B"), 0);
// `docker builder prune` wording — the one this module actually runs, and
// the one an earlier draft of the parser missed entirely, reporting every
// build-cache prune as having freed nothing. Verbatim from Docker 29.7.2.
let builder = "2zp7lsfz2me0jtqe8rio6s4eq*\ttrue\t\t8.192kB\tLess than a second ago\n\
rmonzx1v6jrrlgxt783dmfb3k\ttrue\t16.79MB\t1 second ago\n\
Total:\t20.59MB\n";
assert_eq!(parse_reclaimed_space(builder), 20_590_000);
// A filtered prune that matched nothing still prints the summary.
assert_eq!(parse_reclaimed_space("Total:\t0B\n"), 0);
// A prune that printed nothing recognisable freed nothing we can claim.
assert_eq!(parse_reclaimed_space("nothing to do"), 0);
}
// ---------------------------------------------------------------------------
// Scripts — shell strings, so pinned by test
// ---------------------------------------------------------------------------
#[test]
fn the_compaction_dockerfile_reuses_the_one_scrub_list() {
let df = compaction_dockerfile(
"triple-c-snapshot-p1:latest",
&container::snapshot_scrub_script(),
);
assert!(df.starts_with("FROM triple-c-snapshot-p1:latest AS src\n"));
assert!(
df.contains("\nFROM scratch\nCOPY --from=src / /\n"),
"the flatten is the whole point: {}",
df
);
// Every path in the reviewed list has to appear, and it has to be *that*
// list rather than a second copy — a forked list is the failure mode a
// hardcoded set of `rm -rf` paths invites.
for path in container::SNAPSHOT_SCRUB_PATHS {
assert!(df.contains(path), "scrub path {} missing from {}", path, df);
}
// The RUN must be one line: a Dockerfile instruction does not continue over
// a bare newline, and a script folded wrongly would silently truncate to
// its first statement.
let run_lines: Vec<&str> = df.lines().filter(|l| l.starts_with("RUN ")).collect();
assert_eq!(run_lines.len(), 1, "{}", df);
assert!(!run_lines[0].contains('\n'));
}
#[test]
fn the_compaction_run_carries_the_scrub_script_byte_for_byte() {
// **This is the assertion the old one should have been.** The previous
// version checked only that the `RUN` was a single line — which the broken
// space-fold satisfied perfectly, while producing
// `… for p in …; do [ -e "$p" ] || continue sz=$(…) …`, i.e. shell that
// `sh` refuses to parse. Every compaction ever attempted failed on the
// build's first stage.
//
// Nothing is folded now: the script goes into the JSON exec form verbatim,
// so the strong statement is available — what reaches `/bin/sh -c` is
// exactly what `snapshot_scrub_script()` returned, newlines included.
let script = container::snapshot_scrub_script();
let df = compaction_dockerfile("triple-c-snapshot-p1:latest", &script);
let run = df
.lines()
.find(|l| l.starts_with("RUN "))
.expect("no RUN line");
let recovered = script_from_run_line(run).expect("the RUN is not a parseable exec form");
assert_eq!(
recovered, script,
"the compaction must run the scrub script unmodified"
);
// The exec form names the shell itself, because `RUN [...]` does not go
// through one.
let argv: Vec<String> = serde_json::from_str(run.trim_start_matches("RUN ")).unwrap();
assert_eq!(argv[0], "/bin/sh");
assert_eq!(argv[1], "-c");
}
#[test]
fn the_compaction_run_is_valid_shell() {
// The test that would have caught H1, and the only kind that can: hand the
// exact program the daemon will execute to a real shell and ask it to
// parse. `sh -n` reads and parses without running anything.
//
// It is run against the live `snapshot_scrub_script()` rather than a fixture
// precisely because that script is not this module's to own — it is free to
// grow a `case`, an `if` or a function, and this must keep holding when it
// does.
let df = compaction_dockerfile("triple-c-snapshot-p1:latest", &container::snapshot_scrub_script());
let run = df.lines().find(|l| l.starts_with("RUN ")).unwrap();
let script = script_from_run_line(run).unwrap();
assert_shell_parses(&script, "the compaction scrub");
}
#[test]
fn a_multi_line_script_with_blocks_survives_the_run_encoding() {
// The property the old fold did not have, stated directly: a script with a
// `for`/`do`, an `if`/`then`, a `case` and a quote-heavy line has to survive
// whatever this module does to it. Joining lines with a space breaks the
// first three; joining with `;` breaks `if x; then; y`. Encoding the string
// breaks none of them.
let awkward = "total=0\n for p in /tmp/a* /tmp/b*; do\n \t[ -e \"$p\" ] || continue\n \tcase \"$p\" in\n \t\t*.keep) continue ;;\n \tesac\n \tif [ -d \"$p\" ]; then\n \t\trm -rf -- \"$p\"\n \tfi\n done\n echo \"done: $total\"\n";
let df = compaction_dockerfile("x:latest", awkward);
let run = df.lines().find(|l| l.starts_with("RUN ")).unwrap();
assert!(!run.contains('\n'), "the RUN must still be one Dockerfile line");
assert_eq!(script_from_run_line(run).unwrap(), awkward);
assert_shell_parses(awkward, "an awkward but legal script");
}
/// Run `sh -n` over a program and fail with the shell's own diagnostic.
///
/// Skipped, loudly, on a host with no `/bin/sh` — which is not a case any
/// developer machine or CI image this repo targets is in, but a silent pass
/// would be worse than a skipped test.
fn assert_shell_parses(script: &str, what: &str) {
let output = match std::process::Command::new("/bin/sh")
.arg("-n")
.arg("-c")
.arg(script)
.output()
{
Ok(output) => output,
Err(e) => {
eprintln!("skipping the shell syntax check for {}: {}", what, e);
return;
}
};
assert!(
output.status.success(),
"{} is not valid shell:\n{}\n--- script ---\n{}",
what,
String::from_utf8_lossy(&output.stderr),
script
);
}
#[test]
fn the_compaction_build_is_labelled_so_the_sweep_can_collect_it() {
// Everything that cleans up after this build — the discard path when the
// result is not smaller, the untag after a successful commit — leans on
// `sweep_orphaned_snapshots`, and that sweep filters on `dangling=true`
// AND `triple-c.managed=true`. Without the label it can never match, and
// the flattened intermediate is stranded.
let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script());
assert!(
df.contains("LABEL triple-c.managed=true"),
"the sweep filters on this label and would never match: {}",
df
);
// It has to be on the *final* stage, not the discarded `src` one.
let after_scratch = df.split("FROM scratch").nth(1).expect("no final stage");
assert!(after_scratch.contains("LABEL triple-c.managed=true"), "{}", df);
}
#[test]
fn the_compaction_dockerfile_never_reaches_a_bind_mount() {
let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script());
// `/workspace/{mount_name}` subtrees are the user's real project
// directories, mounted from the host. Nothing in a scrub may name one, and
// the two read-only host mounts under /tmp are dot-prefixed so no glob
// reaches them either.
//
// **Necessary, not sufficient, and do not read it as coverage.** A path not
// appearing as a literal says nothing about where a glob or a symlink
// resolves to at runtime; the containment property itself is tested in
// `container.rs`, which owns the path list and the script. This assertion
// is kept because a literal `/workspace` appearing here would be an
// unambiguous mistake, and that is all it detects.
assert!(!df.contains("/workspace"), "{}", df);
assert!(!df.contains(".host-ca"), "{}", df);
assert!(!df.contains(".host-aws"), "{}", df);
}
#[test]
fn the_cache_script_only_ever_names_paths_under_home() {
for include_rustup in [false, true] {
let script = cache_clear_script(include_rustup);
for line in script.lines() {
// Every deletion in this script is anchored to $HOME. A path that
// is not would be operating on the system layer, or worse on a
// bind mount.
if line.contains("rm -rf") {
assert!(
line.contains("$HOME") || line.contains("$d"),
"unanchored deletion: {}",
line
);
}
}
assert!(!script.contains("/workspace"), "{}", script);
assert!(!script.contains(" / "), "{}", script);
}
}
#[test]
fn rustup_is_only_cleared_when_it_is_asked_for() {
// Regenerable, but a re-download rather than a rebuild from a local cache —
// which is why it is a separate tick and not part of the set.
assert!(!cache_clear_script(false).contains(".rustup"));
assert!(cache_clear_script(true).contains("$HOME/.rustup/toolchains"));
}
#[test]
fn the_cache_script_keeps_the_newest_playwright_revision() {
// Deleting the current revision turns a working browser-view project into
// one that downloads 400 MB on next use, so only superseded revisions go.
let script = cache_clear_script(false);
assert!(script.contains("keep=$("), "{}", script);
assert!(script.contains("= \"$keep\" ] && continue"), "{}", script);
// ...and it must not simply remove the whole directory.
assert!(!script.contains("rm -rf -- \"$HOME/.cache/ms-playwright\""), "{}", script);
}
#[test]
fn the_cache_script_covers_every_documented_cache() {
let script = cache_clear_script(false);
for path in [
"$HOME/.npm/_cacache",
"$HOME/.npm/_npx",
"$HOME/.cache/go-build",
"$HOME/.cache/pip",
"$HOME/.cache/uv",
"$HOME/.cache/act",
"$HOME/.cache/chrome-devtools-mcp",
"$HOME/go/pkg/mod",
"$HOME/.cache/ms-playwright",
] {
assert!(script.contains(path), "{} missing from the cache script", path);
}
}
#[test]
fn the_cache_script_reports_a_total_that_can_be_read_back() {
let script = cache_clear_script(false);
assert!(script.contains(CACHE_MARKER));
assert_eq!(
parse_cache_total(&format!("noise\n{}6291456\nmore noise\n", CACHE_MARKER)),
Some(6_291_456)
);
// No marker means the script never reached its last line — a killed exec,
// not a run that freed nothing.
assert_eq!(parse_cache_total("permission denied"), None);
assert_eq!(parse_cache_total(&format!("{}0", CACHE_MARKER)), Some(0));
}
// ---------------------------------------------------------------------------
// Confirmation
// ---------------------------------------------------------------------------
#[test]
fn a_typed_confirmation_must_match_the_project_name_exactly() {
assert!(confirmation_matches("whp", "whp"));
// A trailing space from a paste is not a different intent.
assert!(confirmation_matches("whp", " whp "));
// Case is not negotiable: `Api` and `api` are different projects, and this
// is the only thing between a user and their transcripts.
assert!(!confirmation_matches("Api", "api"));
assert!(!confirmation_matches("whp", "wh"));
assert!(!confirmation_matches("whp", ""));
// An empty expected name would otherwise be satisfied by an empty box.
assert!(!confirmation_matches("", ""));
}
#[test]
fn only_a_real_rollback_tag_can_name_an_image_to_delete() {
// `DestructiveTarget::RollbackPin` is the one destructive variant carrying
// a free-form string from the frontend, and `destroy` interpolates it into
// an image reference it then removes. Unguarded, `tag: "latest"` names the
// project's *live snapshot* — deleted under a dialog that says "rollback
// pin". The guard is `parse_rollback_tag`, so this pins what it accepts.
assert!(migration::parse_rollback_tag("pre-migration-20260101-101500").is_some());
for hostile in [
"latest",
"",
"pre-migration-",
"pre-migration-notatimestamp",
"../latest",
"latest\npre-migration-20260101-101500",
] {
assert!(
migration::parse_rollback_tag(hostile).is_none(),
"{:?} must not be accepted as a rollback pin tag",
hostile
);
}
}
#[test]
fn a_destroy_result_never_claims_to_be_reclaim_work() {
// An earlier version returned `OrphanVolume { name }` for a home-volume
// deletion — naming a volume that was never an orphan, and attributing the
// outcome to a plan row the user never ticked. Exactly one of the two
// fields is ever set.
let reclaim_shaped = ReclaimResult {
target: Some(ReclaimTarget::DanglingSnapshots),
destroyed: None,
ok: true,
freed_bytes: 1,
projected_bytes: None,
message: String::new(),
};
let destroy_shaped = ReclaimResult {
target: None,
destroyed: Some(DestructiveTarget::HomeVolume {
project_id: "p1".to_string(),
}),
..reclaim_shaped.clone()
};
assert!(reclaim_shaped.target.is_some() != reclaim_shaped.destroyed.is_some());
assert!(destroy_shaped.target.is_some() != destroy_shaped.destroyed.is_some());
// And both shapes survive the wire.
let json = serde_json::to_string(&destroy_shaped).unwrap();
assert_eq!(serde_json::from_str::<ReclaimResult>(&json).unwrap(), destroy_shaped);
}
#[test]
fn a_snapshot_with_no_known_base_is_not_offered_for_compaction() {
// With `triple-c.base-image-id` absent — the normal case for a project
// created before that label existed — `layer_stats` counts every layer that
// carries bytes, base included. A never-recreated project then reports ~15
// "commit layers" and would sail past a `> 1` check. `base_lineage_known`
// is what stops the plan offering a rewrite sized from a number that does
// not mean what its name says.
let unknown = layer_stats(&[10, 20, 30, 40], None);
assert_eq!(unknown.commit_layers, 4);
assert_eq!(unknown.above_base_bytes, None, "the split must not be guessed");
let known = layer_stats(&[10, 20, 30, 40], Some(3));
assert_eq!(known.commit_layers, 1);
assert_eq!(known.above_base_bytes, Some(10));
}
// ---------------------------------------------------------------------------
// Host detection
// ---------------------------------------------------------------------------
#[test]
fn the_vhdx_caveat_needs_both_windows_and_docker_desktop() {
assert!(vhdx_applies(true, "Docker Desktop"));
assert!(vhdx_applies(true, "Docker Desktop 4.30.0"), "matched loosely");
// macOS Docker Desktop has the same never-shrinks property but a different
// file and a different fix, so this note would be wrong there.
assert!(!vhdx_applies(false, "Docker Desktop"));
// A Windows host talking to a native or remote engine has neither.
assert!(!vhdx_applies(true, "Ubuntu 24.04.1 LTS"));
}
#[test]
fn the_vhdx_note_spells_out_the_fix() {
// Users otherwise report "I pruned and C: did not change" as a bug, so both
// routes have to be on screen, not in a doc.
assert!(WSL2_VHDX_NOTE.contains("never shrinks"));
assert!(WSL2_VHDX_FIX[0].contains("wsl --shutdown"));
assert!(WSL2_VHDX_FIX[1].contains("Optimize-VHD"));
assert!(WSL2_VHDX_FIX[1].contains("docker_data.vhdx"));
assert!(WSL2_VHDX_FIX_GUI.contains("Purge data"));
}
#[test]
fn base_images_are_recognised_by_reference_for_display_only() {
assert!(is_base_image_reference("ghcr.io/shadowdao/triple-c-sandbox:latest"));
assert!(is_base_image_reference("triple-c-sandbox:latest"));
assert!(is_base_image_reference("triple-c:latest"));
// A registry port must not be mistaken for a tag separator.
assert!(is_base_image_reference("localhost:5000/triple-c-sandbox:latest"));
assert!(is_base_image_reference("registry.example.com:8443/triple-c-sandbox"));
// A project's own snapshot is not a base image, and neither is anything of
// the user's.
assert!(!is_base_image_reference("triple-c-snapshot-abc:latest"));
assert!(!is_base_image_reference("localhost:5000/postgres:17"));
assert!(!is_base_image_reference("triple-c-gateway:latest"));
assert!(!is_base_image_reference("postgres:17-alpine"));
}
// ---------------------------------------------------------------------------
// IPC contract
// ---------------------------------------------------------------------------
#[test]
fn reclaim_targets_round_trip_through_the_wire_format() {
// The frontend ticks an item and hands the very same `target` object back,
// so the tagged representation has to survive the trip unchanged in both
// directions.
for target in all_reclaim_targets() {
let json = serde_json::to_string(&target).unwrap();
let back: ReclaimTarget = serde_json::from_str(&json).unwrap();
assert_eq!(target, back, "{}", json);
assert!(json.contains("\"kind\""), "{}", json);
}
}
#[test]
fn destructive_targets_round_trip_too() {
let target = DestructiveTarget::RollbackPin {
project_id: "p1".to_string(),
tag: "pre-migration-20260101-101500".to_string(),
};
let json = serde_json::to_string(&target).unwrap();
assert!(json.contains("\"kind\":\"rollback_pin\""), "{}", json);
assert_eq!(
serde_json::from_str::<DestructiveTarget>(&json).unwrap(),
target
);
}
#[test]
fn the_report_serialises_as_snake_case_like_every_other_ipc_struct() {
let report = DiskUsageReport {
projects: vec![ProjectDiskRow {
project_id: "p1".to_string(),
project_name: "whp".to_string(),
snapshot_commit_layers: 14,
container_writable_bytes: 868_000_000,
..Default::default()
}],
..Default::default()
};
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["projects"][0]["snapshot_commit_layers"], 14);
assert_eq!(json["projects"][0]["base_lineage_known"], false);
assert_eq!(json["projects"][0]["container_writable_bytes"], 868_000_000i64);
assert!(json["orphan_volumes_unavailable"].is_null());
// `Option<i64>` must reach the frontend as null, not be omitted — the TS
// type is `number | null`, matching every other optional in `types.ts`.
assert!(json["projects"][0]["snapshot_above_base_bytes"].is_null());
}
// ---------------------------------------------------------------------------
// Rollback pins — the guards the safe bucket was missing
// ---------------------------------------------------------------------------
fn moment(y: i32, m: u32, d: u32) -> chrono::DateTime<chrono::Utc> {
chrono::NaiveDate::from_ymd_opt(y, m, d)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
.and_utc()
}
#[test]
fn the_safe_pin_bucket_applies_every_guard_the_other_paths_do() {
let now = moment(2026, 8, 23);
let ours = migration::rollback_tag(&moment(2026, 1, 1));
// 1. A tag that merely *starts* `pre-migration-`. `destroy` refuses it with
// a comment explaining that `tag: "latest"` would otherwise name the
// project's live snapshot; the safe bucket used to accept anything.
assert_eq!(
pin_disposition("pre-migration-keepme", false, Some(moment(2020, 1, 1)), &now),
PinDisposition::NotOurs
);
assert_eq!(
pin_disposition("latest", false, Some(moment(2020, 1, 1)), &now),
PinDisposition::NotOurs
);
// 2. A record still claims it. Never reaped at any age, and this is the one
// guard the old code did have.
assert_eq!(
pin_disposition(&ours, true, Some(moment(2020, 1, 1)), &now),
PinDisposition::Claimed
);
// 3. Ownerless but inside the grace period — including "no reaper has seen
// it yet", which is where the clock starts. The old code untagged this
// immediately, and `sweep_orphaned_snapshots` four lines later turned
// the untag into a deletion.
assert_eq!(
pin_disposition(&ours, false, None, &now),
PinDisposition::WithinGrace
);
assert_eq!(
pin_disposition(&ours, false, Some(now - chrono::Duration::days(1)), &now),
PinDisposition::WithinGrace
);
// 4. Ownerless and past its grace period. The only case that is collectable
// without a typed confirmation.
assert_eq!(
pin_disposition(
&ours,
false,
Some(now - chrono::Duration::days(migration::STALE_PIN_MAX_AGE_DAYS)),
&now
),
PinDisposition::Reapable
);
}
#[test]
fn the_safe_bucket_and_the_startup_reaper_cannot_disagree() {
// Both go through `migration::pin_is_reapable`. The bug was that the safe
// bucket did not: `reap_stale_migration_pins` required a parseable tag and
// an age, the reclaim button required neither, and the button is the path
// with no confirmation in front of it.
let now = moment(2026, 8, 23);
let ours = migration::rollback_tag(&moment(2026, 1, 1));
for since in [
None,
Some(now - chrono::Duration::days(1)),
Some(now - chrono::Duration::days(migration::STALE_PIN_MAX_AGE_DAYS)),
] {
for has_record in [true, false] {
assert_eq!(
pin_disposition(&ours, has_record, since, &now) == PinDisposition::Reapable,
migration::pin_is_reapable(&ours, has_record, since, &now),
"disposition and the reaper's own predicate disagreed for {:?}/{}",
since,
has_record
);
}
}
}
#[test]
fn an_orphaned_volume_cannot_be_reached_without_a_typed_confirmation() {
// M5: it was a `ReclaimTarget` at `Safety::Safe` — a tick and the group
// button. `reclaim` cannot be handed a `DestructiveTarget` at all, which is
// the type-level half of the guarantee; this pins the other half, that no
// reclaim variant names a volume any more.
for target in all_reclaim_targets() {
let wire = serde_json::to_value(&target).unwrap();
let kind = wire["kind"].as_str().unwrap();
assert!(
!kind.contains("volume"),
"{} can reach a volume from the safe path",
kind
);
}
// And the confirmation subject is the *volume* name, because an orphan has
// no project in the store whose name could be typed.
let target = DestructiveTarget::OrphanVolume {
name: "triple-c-claude-config-gone".to_string(),
project_id: "gone".to_string(),
};
assert!(confirmation_matches(
"triple-c-claude-config-gone",
" triple-c-claude-config-gone "
));
assert!(!confirmation_matches("triple-c-claude-config-gone", "gone"));
assert_eq!(target.project_id(), "gone");
}
// ---------------------------------------------------------------------------
// The numbers, which the user reads as facts
// ---------------------------------------------------------------------------
#[test]
fn the_snapshot_column_and_the_total_come_from_one_rule() {
// Case 1: the daemon measured the sharing. Its figure wins.
assert_eq!(snapshot_attribution(5_000_000_000, 4_700_000_000, Some(1)), 300_000_000);
// Case 2: no shared size, but the lineage is known. The layer arithmetic is
// the honest answer, and it is what the Snapshot column already showed —
// while `total_bytes` used `size - shared` (i.e. the whole image, base
// included) and `triple_c_total_bytes` then added the base again as its own
// row. That double count is the exact thing the comment beside the
// subtraction says it prevents.
assert_eq!(snapshot_attribution(5_000_000_000, 0, Some(300_000_000)), 300_000_000);
// Case 3: nothing known. The full size, not zero — an image that shares
// nothing measurable really does cost all of it, and a flattened snapshot
// is exactly that shape.
assert_eq!(snapshot_attribution(5_000_000_000, 0, None), 5_000_000_000);
// A daemon that reports `-1` for "not computed" must not be read as a
// 1-byte saving, and a negative result is never returned.
assert_eq!(snapshot_attribution(1_000, -1, None), 1_000);
assert_eq!(snapshot_attribution(1_000, 4_000, Some(0)), 0);
}
#[test]
fn a_row_adds_up() {
// The property the fix exists for, stated arithmetically: whatever the
// Snapshot column shows is what the Total is built from.
for (size, shared, above) in [
(5_000_000_000i64, 4_700_000_000i64, Some(1i64)),
(5_000_000_000, 0, Some(300_000_000)),
(5_000_000_000, 0, None),
(0, 0, None),
] {
let snapshot = snapshot_attribution(size, shared, above);
let (writable, home, config) = (10i64, 20i64, 30i64);
let total = snapshot + writable + home + config;
assert_eq!(
total - (writable + home + config),
snapshot,
"the Total column has to reconcile with the Snapshot column"
);
}
}
#[test]
fn human_never_prints_a_unit_the_ladder_forbids() {
// The 999,999 → "1000.0 KB" bug, which is the one `formatBytes.ts` was
// written to fix on the frontend. This function's output is rendered on the
// same line as `formatBytes`' in a compaction message, so the two
// disagreeing is visible in a single sentence.
assert_eq!(human(999_999), "1.0 MB");
assert_eq!(human(999_999_999), "1.0 GB");
assert_eq!(human(999_999_999_999), "1.0 TB");
// The ordinary cases still read the way `docker system df` prints them,
// base 1000.
assert_eq!(human(0), "0 B");
assert_eq!(human(999), "999 B");
assert_eq!(human(1_000), "1.0 KB");
assert_eq!(human(1_500_000), "1.5 MB");
assert_eq!(human(4_700_000_000), "4.7 GB");
// Rounding that does *not* cross the boundary is untouched.
assert_eq!(human(999_400), "999.4 KB");
}
#[test]
fn no_output_of_human_is_ever_a_four_digit_mantissa() {
// A sweep rather than a handful of cases: every power-of-ten boundary and
// its neighbours, which is where the bug lived.
let mut value = 1i64;
for _ in 0..19 {
for candidate in [value - 1, value, value + 1] {
if candidate < 0 {
continue;
}
let rendered = human(candidate);
let mantissa = rendered.split(' ').next().unwrap();
let numeric: f64 = mantissa.parse().unwrap();
assert!(
numeric < 1000.0 || rendered.ends_with(" PB"),
"{} rendered as {}, which the unit ladder is supposed to make impossible",
candidate,
rendered
);
}
value = match value.checked_mul(10) {
Some(v) => v,
None => break,
};
}
}
// ---------------------------------------------------------------------------
// End-to-end compaction, against a real daemon
// ---------------------------------------------------------------------------
/// The whole compaction mechanism, run for real.
///
/// `#[ignore]` because it needs a Docker daemon, pulls `busybox`, and takes
/// tens of seconds — `cargo test` has to stay daemon-free. Run it with
/// `cargo test -- --ignored compaction_end_to_end`.
///
/// It exists because H1 was invisible to every unit test in this file: the
/// generated Dockerfile looked right, the `RUN` was one line as asserted, and
/// the build failed on `/bin/sh: line 0: syntax error: unexpected "do"` every
/// single time. The only test that could have caught it is one that hands the
/// Dockerfile to a daemon.
///
/// It exercises the production functions — [`compaction_dockerfile`],
/// [`build_from_dockerfile`], [`restore_image_config`] — rather than
/// [`compact_snapshot`] itself, so it never has to create a
/// `triple-c-snapshot-*` tag that the app's own sweeps might reach.
#[tokio::test]
#[ignore]
async fn compaction_end_to_end_against_a_real_image() {
let stem = format!("compaction-e2e-{}", uuid::Uuid::new_v4().simple());
let source = format!("{}:source", stem);
let staging = format!("{}:compacting", stem);
let final_ref = format!("{}:latest", stem);
// A snapshot-shaped source: four stacked layers, each superseding the last,
// plus the scrub's own targets and a setuid bit that `COPY --from` has to
// preserve. The env var carries a newline and a double quote, which is what
// `restore_image_config` exists for.
let source_dockerfile = format!(
"FROM busybox:1.36\n\
RUN mkdir -p /tmp/claude-1000 /var/cache/apt/archives /var/log/apt /var/lib/apt/lists && \
dd if=/dev/urandom of=/big-a bs=1M count=40 2>/dev/null\n\
RUN dd if=/dev/urandom of=/big-b bs=1M count=40 2>/dev/null && rm -f /big-a\n\
RUN dd if=/dev/urandom of=/tmp/claude-1000/scratch bs=1M count=25 2>/dev/null && \
dd if=/dev/urandom of=/var/cache/apt/archives/x.deb bs=1M count=15 2>/dev/null && \
touch /var/log/dpkg.log\n\
RUN dd if=/dev/urandom of=/big-c bs=1M count=30 2>/dev/null && rm -f /big-b && \
touch /keepme && chmod 4755 /keepme\n\
LABEL {LABEL_MANAGED}=true\n\
WORKDIR /keep-this-dir\n"
);
// Force, unlike anything in production: `untag_image` is deliberately
// unforced because it runs against a user's images, and here the leftovers
// are this test's own and must not be left on the developer's daemon
// whatever refuses them.
async fn cleanup(refs: Vec<String>) {
let Ok(docker) = get_docker() else {
return;
};
for r in refs {
if let Err(e) = docker
.remove_image(
&r,
Some(RemoveImageOptions {
force: true,
noprune: false,
}),
None,
)
.await
{
eprintln!("could not clean up {}: {}", r, e);
}
}
}
build_from_dockerfile(&source_dockerfile, &source)
.await
.expect("could not build the throwaway source image");
let docker = get_docker().expect("no docker");
let before = docker.inspect_image(&source).await.expect("inspect source");
let before_size = before.size.unwrap_or(0);
let mut config = before.config.clone().expect("source has no config");
// A genuinely multi-line env var, injected here rather than through the
// Dockerfile because `ENV` cannot express a literal newline. This is the
// case `restore_image_config` exists for: it is why the config is replayed
// through a create-and-commit instead of being rendered back into
// Dockerfile instructions, where a newline and a `"` would not survive.
config
.env
.get_or_insert_with(Vec::new)
.push("E2E_MULTILINE=first\nsecond \"quoted\"".to_string());
// --- the thing under test ------------------------------------------------
let dockerfile = compaction_dockerfile(&source, &container::snapshot_scrub_script());
let built = build_from_dockerfile(&dockerfile, &staging).await;
assert!(
built.is_ok(),
"the compaction build failed, which is exactly the H1 regression: {:?}\n{}",
built,
dockerfile
);
restore_image_config(&staging, &final_ref, config)
.await
.expect("could not replay the image config");
let after = docker
.inspect_image(&final_ref)
.await
.expect("inspect compacted");
let after_size = after.size.unwrap_or(0);
let history = docker.image_history(&final_ref).await.expect("history");
let after_config = after.config.clone().expect("no config after");
// The scrub actually ran — the assertion H1 made impossible. `sh -c` inside
// the compacted image, so the answer comes from the filesystem rather than
// from the build log.
let probe = migration::run_throwaway(
&final_ref,
"ls /keepme >/dev/null 2>&1 && echo KEEP-OK\n\
[ -e /tmp/claude-1000/scratch ] && echo SCRATCH-LEFT\n\
[ -e /var/cache/apt/archives/x.deb ] && echo DEB-LEFT\n\
[ -e /var/log/dpkg.log ] && echo DPKGLOG-LEFT\n\
[ -e /big-a ] && echo BIGA-LEFT\n\
[ -e /big-c ] || echo BIGC-MISSING\n\
ls -l /keepme\n\
echo PROBE-END\n\
exit 0\n",
)
.await
.expect("could not probe the compacted image");
// **Everything is measured before anything is asserted**, so a failing
// assertion below does not leave several hundred megabytes of throwaway
// images on the developer's daemon.
cleanup(vec![final_ref, staging, source]).await;
// 1. It is smaller. The three superseded 3040 MB layers and the ~40 MB of
// scrub targets are the whole point.
assert!(
after_size < before_size,
"compaction did not shrink the image: {} -> {}",
before_size,
after_size
);
// 2. One layer of content.
let content_layers = history.iter().filter(|e| e.size > 0).count();
assert_eq!(content_layers, 1, "expected one content layer: {:?}", history);
// 3. The config round-tripped, newline and quote included.
let env = after_config.env.clone().unwrap_or_default();
assert!(
env.iter()
.any(|e| e == "E2E_MULTILINE=first\nsecond \"quoted\""),
"the multi-line env var did not survive: {:?}",
env
);
assert_eq!(after_config.working_dir.as_deref(), Some("/keep-this-dir"));
// 4. The scrub actually ran.
assert!(probe.stdout.contains("PROBE-END"), "{}", probe.stdout);
assert!(probe.stdout.contains("KEEP-OK"), "{}", probe.stdout);
assert!(
!probe.stdout.contains("SCRATCH-LEFT"),
"the agent scratchpad survived the scrub:\n{}",
probe.stdout
);
assert!(
!probe.stdout.contains("DEB-LEFT"),
"apt archives survived the scrub:\n{}",
probe.stdout
);
assert!(
!probe.stdout.contains("DPKGLOG-LEFT"),
"dpkg.log survived the scrub:\n{}",
probe.stdout
);
assert!(
!probe.stdout.contains("BIGC-MISSING"),
"the live payload was lost, which is worse than not compacting:\n{}",
probe.stdout
);
// `COPY --from` has to keep the setuid bit; a compaction that dropped it
// would break sudo inside every migrated project.
assert!(
probe.stdout.contains("-rwsr-xr-x"),
"the setuid bit did not survive the flatten:\n{}",
probe.stdout
);
}