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();