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:
Generated
-17
@@ -4726,22 +4726,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.0"
|
||||
@@ -5260,7 +5244,6 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-drag",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
|
||||
@@ -13,7 +13,6 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2491,180 +2491,6 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2491,180 +2491,6 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -213,7 +213,6 @@ pub fn run() {
|
||||
let lifecycle_setup = lifecycle.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
// Drag a file from the Files tab onto the host desktop. The gesture is
|
||||
|
||||
@@ -96,6 +96,13 @@ pub enum ProjectOp {
|
||||
/// write `:latest`, but it must not run while the container is being
|
||||
/// removed out from under it.
|
||||
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 {
|
||||
@@ -108,6 +115,7 @@ impl ProjectOp {
|
||||
ProjectOp::Reset => "This project is being reset",
|
||||
ProjectOp::Destroy => "Something of this project's is being deleted",
|
||||
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::Destroy => "deleting anything of its",
|
||||
ProjectOp::CacheClear => "clearing its caches",
|
||||
ProjectOp::SecretScrub => "removing a credential from its snapshot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user