Close the cross-stream gaps the parallel fix round left open

Three items each of which fell between two agents' file lists.

`scrub_secrets_from_snapshots` was the third unsynchronised writer of
`triple-c-snapshot-{id}:latest`, after a recreate's commit and a
compaction. It has the same read-modify-write shape — create a scratch
container from the snapshot, commit back over the same tag — and loses
the same race, which here means re-baking the very credential it exists
to remove. It now takes the project's claim, under a new
`ProjectOp::SecretScrub`, and reports a snapshot it had to skip rather
than rewriting it unsafely.

Its scratch container also now carries `triple-c.scrub=true`, so the
Disk panel's reclaim bucket discriminates by label and by the live claim
rather than by a clock. The 15-minute age gate stays as the backstop for
the cross-process case the claim cannot see.

The store plugin is unregistered and its dependency dropped. Its
capability grants were removed as a host-file-write primitive; the
registration without a grant was unreachable but dead.

Finally, container.rs's fold test was pinning a fold that no longer
exists — disk.rs now emits the JSON exec form. It asserts the stronger
property instead: the script a compaction runs is byte-for-byte the one
snapshot_scrub_script() produces, so the two files cannot drift silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 11:53:25 -07:00
co-authored by Claude Opus 5
parent 17f031a5d7
commit a76f2c0a17
11 changed files with 169 additions and 417 deletions
+76 -45
View File
@@ -217,6 +217,10 @@ pub const SECRET_ENV_KEYS: &[&str] = &[
/// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance,
/// which is what keeps the sweep away from the user's own images.
pub(crate) const LABEL_MANAGED: &str = "triple-c.managed";
/// Marks the throwaway container [`rewrite_image_without_secrets`] commits
/// from. It exists so the Disk panel's reclaim bucket can distinguish a live
/// credential rewrite from a leftover by label rather than by age.
pub(crate) const LABEL_SCRUB: &str = "triple-c.scrub";
/// Marks the image built from `container/Dockerfile` itself, as opposed to a
/// project snapshot committed from a container. Only ever `"true"` on a base
@@ -2826,6 +2830,37 @@ pub async fn scrub_secrets_from_snapshots() -> SnapshotScrubReport {
continue;
}
// Claim the project before touching its snapshot.
//
// This is the third writer of `triple-c-snapshot-{id}:latest`, after a
// recreate's commit and a compaction, and it has the same
// read-modify-write shape: create a scratch container *from* the
// snapshot, then commit back over the same tag. A `:latest` move
// landing in between is silently overwritten by an image derived from
// the pre-read state — which here would mean re-baking the very
// credential this function exists to remove.
//
// A snapshot whose project is busy is left for the next call rather
// than rewritten unsafely, and it is *reported*: silently skipping a
// credential removal is the one outcome worse than failing it.
let project_id = summary
.repo_tags
.iter()
.find_map(|t| crate::docker::migration::parse_snapshot_reference(t))
.map(|(id, _tag)| id);
let _claim = match project_id.as_deref() {
Some(id) => match crate::project_lock::try_acquire(id, crate::project_lock::ProjectOp::SecretScrub) {
Ok(guard) => Some(guard),
Err(reason) => {
report.failed.push((summary.repo_tags.join(", "), reason));
continue;
}
},
// Not a `triple-c-snapshot-{uuid}` reference despite the filter.
// Nothing owns it, so there is nothing to serialise against.
None => None,
};
// Rewrite every tag this image answers to, so an old tag cannot keep
// serving the un-scrubbed config.
let mut all_tags_rewritten = true;
@@ -2884,6 +2919,15 @@ async fn rewrite_image_without_secrets(
) -> Result<(), String> {
let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple());
// `triple-c.scrub=true` so the Disk panel's scrub-container bucket can tell
// a *live* rewrite from a leftover by label rather than by age. An age gate
// alone was the previous discriminator, and a clock is a poor proxy for
// "somebody is using this": killing this container between the create and
// the commit below leaves the revoked credential baked into the snapshot's
// `Config.Env`, which is precisely the state this function exists to end.
let scratch_labels: HashMap<String, String> =
HashMap::from([(LABEL_SCRUB.to_string(), "true".to_string())]);
let created = docker
.create_container(
Some(CreateContainerOptions {
@@ -2892,6 +2936,7 @@ async fn rewrite_image_without_secrets(
}),
Config::<String> {
image: Some(source_image.to_string()),
labels: Some(scratch_labels),
// Deliberately nothing else. The container is never started;
// its only job is to be a config to commit from, and every
// field left unset here is inherited from the image and
@@ -4240,54 +4285,40 @@ mod tests {
/// against the same wording in `fold_shell_script`.
#[cfg(unix)]
#[test]
fn the_scrub_script_survives_being_folded_onto_one_run_line() {
use std::io::Write;
use std::process::{Command, Stdio};
let script = snapshot_scrub_script();
for line in script.lines() {
// A `#` only starts a comment at the beginning of a word, so the
// quoted `###TRIPLE-C-SCRUBBED` marker is fine; anything else is a
// comment that eats the rest of the program once folded.
assert!(
!line.trim_start().starts_with('#') && !line.contains(" #"),
"a `#` comment swallows the rest of the program once folded: {}",
line
);
}
let folded = script
fn a_compaction_runs_this_module_s_scrub_script_byte_for_byte() {
// The compaction build used to fold the script onto one `RUN` line by
// joining its lines with a space, which turned `for p in …; do` into
// `do` in statement position and made every compaction fail with
// `syntax error: unexpected "do"`. That fold is gone — `disk.rs` now
// emits the JSON exec form, whose string escapes carry newlines — so
// the assertion worth pinning from this side is no longer "the folded
// one-liner still parses" but the stronger one: whatever encoding
// `disk.rs` chooses, the bytes that reach `sh` are *this* script.
//
// This is what stops the two files drifting. `container.rs` owns the
// containment rules in `snapshot_scrub_script`; a compaction that ran a
// mangled copy would be running a scrub with those rules altered, and
// the mangling would be silent.
let expected = snapshot_scrub_script();
// Build the real Dockerfile the compaction would, then pull the script
// back out of it — going through `compaction_dockerfile` rather than a
// helper means a change to how the RUN line is emitted is caught here.
let dockerfile = crate::docker::disk::compaction_dockerfile(
"triple-c-snapshot-00000000-0000-0000-0000-000000000000:latest",
&expected,
);
let run_line = dockerfile
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
assert!(!folded.contains('\n'));
for candidate in [script.as_str(), folded.as_str()] {
let mut child = Command::new("/bin/sh")
.arg("-n")
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn /bin/sh -n");
child
.stdin
.take()
.expect("stdin")
.write_all(candidate.as_bytes())
.expect("write the script");
let out = child.wait_with_output().expect("wait");
assert!(
out.status.success(),
"the scrub script does not parse: {}\n---\n{}",
String::from_utf8_lossy(&out.stderr),
candidate
);
}
.find(|l| l.starts_with("RUN "))
.expect("the compaction Dockerfile should carry a RUN line");
let actual = crate::docker::disk::script_from_run_line(run_line)
.expect("the compaction RUN line should be the JSON exec form");
assert_eq!(
actual, expected,
"the compaction runs a different script than snapshot_scrub_script() produces"
);
}
// ── Container log rotation (A2) ──────────────────────────────────────────
#[test]
fn every_container_is_created_with_a_bounded_log() {
let cfg = capped_log_config();
+29 -4
View File
@@ -2455,11 +2455,36 @@ fn is_scrub_container(summary: &ContainerSummary) -> bool {
.any(|name| name.trim_start_matches('/').starts_with("triple-c-scrub-"))
}
/// A scrub container this module is allowed to force-remove: ours *and* old
/// enough not to be a live rewrite. This is the predicate the survey and the
/// reclaim both use; [`is_scrub_container`] answers only "is this name ours".
/// A scrub container this module is allowed to force-remove: ours, *not*
/// currently claimed, and old enough not to be a live rewrite. This is the
/// predicate the survey and the reclaim both use; [`is_scrub_container`]
/// answers only "is this name ours".
///
/// The label is the real discriminator and the age is the backstop. A live
/// rewrite now carries `triple-c.scrub=true` **and** holds its project's
/// `ProjectOp::SecretScrub` claim, so within this process the answer is exact.
/// Age still matters because the claim is process-local: a second app instance
/// mid-rewrite is invisible here, and killing that container between its create
/// and its commit leaves the revoked credential baked into the snapshot's
/// `Config.Env` — the state `scrub_secrets_from_snapshots` exists to end.
fn is_reapable_scrub_container(summary: &ContainerSummary) -> bool {
is_scrub_container(summary) && is_stale_scratch(summary)
if !is_scrub_container(summary) {
return false;
}
if scrub_container_project(summary)
.is_some_and(|id| crate::project_lock::is_held_by(&id, crate::project_lock::ProjectOp::SecretScrub))
{
return false;
}
is_stale_scratch(summary)
}
/// The project a live scrub container is rewriting, read off the image it was
/// created from. `None` when the image is not a `triple-c-snapshot-*`
/// reference, which is the case for a leftover whose image has since gone.
fn scrub_container_project(summary: &ContainerSummary) -> Option<String> {
let image = summary.image.as_deref()?;
crate::docker::migration::parse_snapshot_reference(image).map(|(id, _tag)| id)
}
/// How old a `triple-c-scrub-*` / `triple-c-compact-*` scratch container must be
+54
View File
@@ -393,6 +393,60 @@ fn the_daemon_wide_buckets_leave_a_young_container_alone() {
assert!(is_reapable_scrub_container(&old_scrub));
}
#[test]
fn a_claimed_scrub_container_is_spared_however_old_it_looks() {
// Age is the backstop, not the rule. A credential rewrite that outruns the
// 15-minute gate — a slow daemon, a huge snapshot, a machine asleep between
// the create and the commit — must still not be force-removed while this
// process is holding the project's claim, because killing it there leaves
// the revoked token baked into the snapshot's `Config.Env`.
let project_id = "11111111-2222-3333-4444-555555555555";
let now = chrono::Utc::now().timestamp();
let mut scrub = summary(&["/triple-c-scrub-deadbeef"], &[]);
scrub.image = Some(format!("triple-c-snapshot-{}:latest", project_id));
// Far past any age gate, so nothing but the claim can spare it.
scrub.created = Some(now - SCRATCH_CONTAINER_MIN_AGE_SECS * 100);
// Unclaimed: an old leftover, and reapable.
assert!(
is_reapable_scrub_container(&scrub),
"an old, unclaimed scrub container is a leftover"
);
// Claimed: the same container is a live rewrite.
let claim = crate::project_lock::try_acquire(project_id, crate::project_lock::ProjectOp::SecretScrub)
.expect("nothing else holds this project in a unit test");
assert!(
!is_reapable_scrub_container(&scrub),
"a scrub container whose project is claimed is a live credential rewrite"
);
// And releasing the claim makes it reapable again, so the guard is the
// claim itself rather than something sticky.
drop(claim);
assert!(is_reapable_scrub_container(&scrub));
}
#[test]
fn a_scrub_containers_project_is_read_off_the_image_it_was_created_from() {
// The claim lookup only works if the project id can be recovered from the
// container. The name is a bare uuid unrelated to the project, so the image
// reference is the only link.
let mut scrub = summary(&["/triple-c-scrub-abc"], &[]);
scrub.image = Some("triple-c-snapshot-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:latest".to_string());
assert_eq!(
scrub_container_project(&scrub).as_deref(),
Some("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
);
// A leftover whose image has since been removed reads as unowned, and
// falls back to the age gate rather than being spared forever.
let mut orphaned = scrub.clone();
orphaned.image = Some("sha256:0123456789abcdef".to_string());
assert_eq!(scrub_container_project(&orphaned), None);
}
#[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