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
-17
View File
@@ -4726,22 +4726,6 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-store"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.0" version = "2.11.0"
@@ -5260,7 +5244,6 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-drag", "tauri-plugin-drag",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-store",
"tokio", "tokio",
"tower-http", "tower-http",
"uuid", "uuid",
-1
View File
@@ -13,7 +13,6 @@ path = "src/main.rs"
[dependencies] [dependencies]
tauri = { version = "2", features = ["image-png", "image-ico"] } tauri = { version = "2", features = ["image-png", "image-ico"] }
tauri-plugin-store = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
File diff suppressed because one or more lines are too long
@@ -2491,180 +2491,6 @@
"type": "string", "type": "string",
"const": "opener:deny-reveal-item-in-dir", "const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
"type": "string",
"const": "store:default",
"markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
},
{
"description": "Enables the clear command without any pre-configured scope.",
"type": "string",
"const": "store:allow-clear",
"markdownDescription": "Enables the clear command without any pre-configured scope."
},
{
"description": "Enables the delete command without any pre-configured scope.",
"type": "string",
"const": "store:allow-delete",
"markdownDescription": "Enables the delete command without any pre-configured scope."
},
{
"description": "Enables the entries command without any pre-configured scope.",
"type": "string",
"const": "store:allow-entries",
"markdownDescription": "Enables the entries command without any pre-configured scope."
},
{
"description": "Enables the get command without any pre-configured scope.",
"type": "string",
"const": "store:allow-get",
"markdownDescription": "Enables the get command without any pre-configured scope."
},
{
"description": "Enables the get_store command without any pre-configured scope.",
"type": "string",
"const": "store:allow-get-store",
"markdownDescription": "Enables the get_store command without any pre-configured scope."
},
{
"description": "Enables the has command without any pre-configured scope.",
"type": "string",
"const": "store:allow-has",
"markdownDescription": "Enables the has command without any pre-configured scope."
},
{
"description": "Enables the keys command without any pre-configured scope.",
"type": "string",
"const": "store:allow-keys",
"markdownDescription": "Enables the keys command without any pre-configured scope."
},
{
"description": "Enables the length command without any pre-configured scope.",
"type": "string",
"const": "store:allow-length",
"markdownDescription": "Enables the length command without any pre-configured scope."
},
{
"description": "Enables the load command without any pre-configured scope.",
"type": "string",
"const": "store:allow-load",
"markdownDescription": "Enables the load command without any pre-configured scope."
},
{
"description": "Enables the reload command without any pre-configured scope.",
"type": "string",
"const": "store:allow-reload",
"markdownDescription": "Enables the reload command without any pre-configured scope."
},
{
"description": "Enables the reset command without any pre-configured scope.",
"type": "string",
"const": "store:allow-reset",
"markdownDescription": "Enables the reset command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "store:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Enables the set command without any pre-configured scope.",
"type": "string",
"const": "store:allow-set",
"markdownDescription": "Enables the set command without any pre-configured scope."
},
{
"description": "Enables the values command without any pre-configured scope.",
"type": "string",
"const": "store:allow-values",
"markdownDescription": "Enables the values command without any pre-configured scope."
},
{
"description": "Denies the clear command without any pre-configured scope.",
"type": "string",
"const": "store:deny-clear",
"markdownDescription": "Denies the clear command without any pre-configured scope."
},
{
"description": "Denies the delete command without any pre-configured scope.",
"type": "string",
"const": "store:deny-delete",
"markdownDescription": "Denies the delete command without any pre-configured scope."
},
{
"description": "Denies the entries command without any pre-configured scope.",
"type": "string",
"const": "store:deny-entries",
"markdownDescription": "Denies the entries command without any pre-configured scope."
},
{
"description": "Denies the get command without any pre-configured scope.",
"type": "string",
"const": "store:deny-get",
"markdownDescription": "Denies the get command without any pre-configured scope."
},
{
"description": "Denies the get_store command without any pre-configured scope.",
"type": "string",
"const": "store:deny-get-store",
"markdownDescription": "Denies the get_store command without any pre-configured scope."
},
{
"description": "Denies the has command without any pre-configured scope.",
"type": "string",
"const": "store:deny-has",
"markdownDescription": "Denies the has command without any pre-configured scope."
},
{
"description": "Denies the keys command without any pre-configured scope.",
"type": "string",
"const": "store:deny-keys",
"markdownDescription": "Denies the keys command without any pre-configured scope."
},
{
"description": "Denies the length command without any pre-configured scope.",
"type": "string",
"const": "store:deny-length",
"markdownDescription": "Denies the length command without any pre-configured scope."
},
{
"description": "Denies the load command without any pre-configured scope.",
"type": "string",
"const": "store:deny-load",
"markdownDescription": "Denies the load command without any pre-configured scope."
},
{
"description": "Denies the reload command without any pre-configured scope.",
"type": "string",
"const": "store:deny-reload",
"markdownDescription": "Denies the reload command without any pre-configured scope."
},
{
"description": "Denies the reset command without any pre-configured scope.",
"type": "string",
"const": "store:deny-reset",
"markdownDescription": "Denies the reset command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "store:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Denies the set command without any pre-configured scope.",
"type": "string",
"const": "store:deny-set",
"markdownDescription": "Denies the set command without any pre-configured scope."
},
{
"description": "Denies the values command without any pre-configured scope.",
"type": "string",
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
} }
] ]
}, },
-174
View File
@@ -2491,180 +2491,6 @@
"type": "string", "type": "string",
"const": "opener:deny-reveal-item-in-dir", "const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
"type": "string",
"const": "store:default",
"markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
},
{
"description": "Enables the clear command without any pre-configured scope.",
"type": "string",
"const": "store:allow-clear",
"markdownDescription": "Enables the clear command without any pre-configured scope."
},
{
"description": "Enables the delete command without any pre-configured scope.",
"type": "string",
"const": "store:allow-delete",
"markdownDescription": "Enables the delete command without any pre-configured scope."
},
{
"description": "Enables the entries command without any pre-configured scope.",
"type": "string",
"const": "store:allow-entries",
"markdownDescription": "Enables the entries command without any pre-configured scope."
},
{
"description": "Enables the get command without any pre-configured scope.",
"type": "string",
"const": "store:allow-get",
"markdownDescription": "Enables the get command without any pre-configured scope."
},
{
"description": "Enables the get_store command without any pre-configured scope.",
"type": "string",
"const": "store:allow-get-store",
"markdownDescription": "Enables the get_store command without any pre-configured scope."
},
{
"description": "Enables the has command without any pre-configured scope.",
"type": "string",
"const": "store:allow-has",
"markdownDescription": "Enables the has command without any pre-configured scope."
},
{
"description": "Enables the keys command without any pre-configured scope.",
"type": "string",
"const": "store:allow-keys",
"markdownDescription": "Enables the keys command without any pre-configured scope."
},
{
"description": "Enables the length command without any pre-configured scope.",
"type": "string",
"const": "store:allow-length",
"markdownDescription": "Enables the length command without any pre-configured scope."
},
{
"description": "Enables the load command without any pre-configured scope.",
"type": "string",
"const": "store:allow-load",
"markdownDescription": "Enables the load command without any pre-configured scope."
},
{
"description": "Enables the reload command without any pre-configured scope.",
"type": "string",
"const": "store:allow-reload",
"markdownDescription": "Enables the reload command without any pre-configured scope."
},
{
"description": "Enables the reset command without any pre-configured scope.",
"type": "string",
"const": "store:allow-reset",
"markdownDescription": "Enables the reset command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "store:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Enables the set command without any pre-configured scope.",
"type": "string",
"const": "store:allow-set",
"markdownDescription": "Enables the set command without any pre-configured scope."
},
{
"description": "Enables the values command without any pre-configured scope.",
"type": "string",
"const": "store:allow-values",
"markdownDescription": "Enables the values command without any pre-configured scope."
},
{
"description": "Denies the clear command without any pre-configured scope.",
"type": "string",
"const": "store:deny-clear",
"markdownDescription": "Denies the clear command without any pre-configured scope."
},
{
"description": "Denies the delete command without any pre-configured scope.",
"type": "string",
"const": "store:deny-delete",
"markdownDescription": "Denies the delete command without any pre-configured scope."
},
{
"description": "Denies the entries command without any pre-configured scope.",
"type": "string",
"const": "store:deny-entries",
"markdownDescription": "Denies the entries command without any pre-configured scope."
},
{
"description": "Denies the get command without any pre-configured scope.",
"type": "string",
"const": "store:deny-get",
"markdownDescription": "Denies the get command without any pre-configured scope."
},
{
"description": "Denies the get_store command without any pre-configured scope.",
"type": "string",
"const": "store:deny-get-store",
"markdownDescription": "Denies the get_store command without any pre-configured scope."
},
{
"description": "Denies the has command without any pre-configured scope.",
"type": "string",
"const": "store:deny-has",
"markdownDescription": "Denies the has command without any pre-configured scope."
},
{
"description": "Denies the keys command without any pre-configured scope.",
"type": "string",
"const": "store:deny-keys",
"markdownDescription": "Denies the keys command without any pre-configured scope."
},
{
"description": "Denies the length command without any pre-configured scope.",
"type": "string",
"const": "store:deny-length",
"markdownDescription": "Denies the length command without any pre-configured scope."
},
{
"description": "Denies the load command without any pre-configured scope.",
"type": "string",
"const": "store:deny-load",
"markdownDescription": "Denies the load command without any pre-configured scope."
},
{
"description": "Denies the reload command without any pre-configured scope.",
"type": "string",
"const": "store:deny-reload",
"markdownDescription": "Denies the reload command without any pre-configured scope."
},
{
"description": "Denies the reset command without any pre-configured scope.",
"type": "string",
"const": "store:deny-reset",
"markdownDescription": "Denies the reset command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "store:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Denies the set command without any pre-configured scope.",
"type": "string",
"const": "store:deny-set",
"markdownDescription": "Denies the set command without any pre-configured scope."
},
{
"description": "Denies the values command without any pre-configured scope.",
"type": "string",
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
} }
] ]
}, },
+74 -43
View File
@@ -217,6 +217,10 @@ pub const SECRET_ENV_KEYS: &[&str] = &[
/// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance, /// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance,
/// which is what keeps the sweep away from the user's own images. /// which is what keeps the sweep away from the user's own images.
pub(crate) const LABEL_MANAGED: &str = "triple-c.managed"; 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 /// Marks the image built from `container/Dockerfile` itself, as opposed to a
/// project snapshot committed from a container. Only ever `"true"` on a base /// 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; 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 // Rewrite every tag this image answers to, so an old tag cannot keep
// serving the un-scrubbed config. // serving the un-scrubbed config.
let mut all_tags_rewritten = true; let mut all_tags_rewritten = true;
@@ -2884,6 +2919,15 @@ async fn rewrite_image_without_secrets(
) -> Result<(), String> { ) -> Result<(), String> {
let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()); 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 let created = docker
.create_container( .create_container(
Some(CreateContainerOptions { Some(CreateContainerOptions {
@@ -2892,6 +2936,7 @@ async fn rewrite_image_without_secrets(
}), }),
Config::<String> { Config::<String> {
image: Some(source_image.to_string()), image: Some(source_image.to_string()),
labels: Some(scratch_labels),
// Deliberately nothing else. The container is never started; // Deliberately nothing else. The container is never started;
// its only job is to be a config to commit from, and every // its only job is to be a config to commit from, and every
// field left unset here is inherited from the image and // field left unset here is inherited from the image and
@@ -4240,53 +4285,39 @@ mod tests {
/// against the same wording in `fold_shell_script`. /// against the same wording in `fold_shell_script`.
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn the_scrub_script_survives_being_folded_onto_one_run_line() { fn a_compaction_runs_this_module_s_scrub_script_byte_for_byte() {
use std::io::Write; // The compaction build used to fold the script onto one `RUN` line by
use std::process::{Command, Stdio}; // joining its lines with a space, which turned `for p in …; do` into
// `do` in statement position and made every compaction fail with
let script = snapshot_scrub_script(); // `syntax error: unexpected "do"`. That fold is gone — `disk.rs` now
for line in script.lines() { // emits the JSON exec form, whose string escapes carry newlines — so
// A `#` only starts a comment at the beginning of a word, so the // the assertion worth pinning from this side is no longer "the folded
// quoted `###TRIPLE-C-SCRUBBED` marker is fine; anything else is a // one-liner still parses" but the stronger one: whatever encoding
// comment that eats the rest of the program once folded. // `disk.rs` chooses, the bytes that reach `sh` are *this* script.
assert!( //
!line.trim_start().starts_with('#') && !line.contains(" #"), // This is what stops the two files drifting. `container.rs` owns the
"a `#` comment swallows the rest of the program once folded: {}", // containment rules in `snapshot_scrub_script`; a compaction that ran a
line // 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
let folded = script
.lines() .lines()
.map(str::trim) .find(|l| l.starts_with("RUN "))
.filter(|line| !line.is_empty()) .expect("the compaction Dockerfile should carry a RUN line");
.collect::<Vec<_>>() let actual = crate::docker::disk::script_from_run_line(run_line)
.join(" "); .expect("the compaction RUN line should be the JSON exec form");
assert!(!folded.contains('\n')); assert_eq!(
actual, expected,
for candidate in [script.as_str(), folded.as_str()] { "the compaction runs a different script than snapshot_scrub_script() produces"
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
); );
} }
}
// ── Container log rotation (A2) ──────────────────────────────────────────
#[test] #[test]
fn every_container_is_created_with_a_bounded_log() { fn every_container_is_created_with_a_bounded_log() {
+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-")) .any(|name| name.trim_start_matches('/').starts_with("triple-c-scrub-"))
} }
/// A scrub container this module is allowed to force-remove: ours *and* old /// A scrub container this module is allowed to force-remove: ours, *not*
/// enough not to be a live rewrite. This is the predicate the survey and the /// currently claimed, and old enough not to be a live rewrite. This is the
/// reclaim both use; [`is_scrub_container`] answers only "is this name ours". /// 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 { 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 /// 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)); 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] #[test]
fn a_probe_container_is_matched_on_its_label_not_on_the_daemons_filter() { 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 // The `label=triple-c.probe=migration` filter is an exact match and would
-1
View File
@@ -213,7 +213,6 @@ pub fn run() {
let lifecycle_setup = lifecycle.clone(); let lifecycle_setup = lifecycle.clone();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
// Drag a file from the Files tab onto the host desktop. The gesture is // Drag a file from the Files tab onto the host desktop. The gesture is
+9
View File
@@ -96,6 +96,13 @@ pub enum ProjectOp {
/// write `:latest`, but it must not run while the container is being /// write `:latest`, but it must not run while the container is being
/// removed out from under it. /// removed out from under it.
CacheClear, CacheClear,
/// `container::scrub_secrets_from_snapshots` — the third writer of
/// `triple-c-snapshot-{id}:latest`, reached from `clear_claude_token`. It
/// creates a scratch container from the snapshot and commits back over the
/// same tag, so it is the same read-modify-write shape as a compaction and
/// loses the same race: any `:latest` move landing between its create and
/// its commit is overwritten by an image derived from the pre-read state.
SecretScrub,
} }
impl ProjectOp { impl ProjectOp {
@@ -108,6 +115,7 @@ impl ProjectOp {
ProjectOp::Reset => "This project is being reset", ProjectOp::Reset => "This project is being reset",
ProjectOp::Destroy => "Something of this project's is being deleted", ProjectOp::Destroy => "Something of this project's is being deleted",
ProjectOp::CacheClear => "This project's caches are being cleared", ProjectOp::CacheClear => "This project's caches are being cleared",
ProjectOp::SecretScrub => "A revoked credential is being removed from this project's snapshot",
} }
} }
@@ -120,6 +128,7 @@ impl ProjectOp {
ProjectOp::Reset => "resetting it", ProjectOp::Reset => "resetting it",
ProjectOp::Destroy => "deleting anything of its", ProjectOp::Destroy => "deleting anything of its",
ProjectOp::CacheClear => "clearing its caches", ProjectOp::CacheClear => "clearing its caches",
ProjectOp::SecretScrub => "removing a credential from its snapshot",
} }
} }
} }
Binary file not shown.