Stop a project id from steering a Docker API DELETE, and stop trusting a store that lost its list

C-2 (critical). `destroy_ownerless_rollback_pin` validated its tag and not its
project id, then interpolated both into `triple-c-snapshot-{id}:{tag}` and handed
the result to bollard. bollard does not percent-encode: `Uri::parse` joins an
absolute path onto the base URL, which replaces the path outright and applies RFC
3986 dot-segment removal. An id of `a/../../v1.47/volumes/<name>?` turns a
"remove image tag" into `DELETE /v1.47/volumes/<name>`. That arm is reached
*because* `find_project` failed, so the id is unconstrained IPC input, and the
typed confirmation is no barrier — it compares the caller's own two strings.

Reproduced against the live daemon, and now a test: with the check removed the
volume is gone and the test fails; with it, the volume survives and a legitimate
ownerless pin still deletes. The reference that reaches `remove_image` is now the
daemon's own repo_tag, matched on the parsed pair, so nothing built from IPC
input addresses the API at all. The same id check now guards the owned arms of
`destroy` and `compact_snapshot`, which build volume names and image references
from a `projects.json` field.

H-1. The ownerless arm decided ownership from the in-memory list alone and then
called `sweep_orphaned_snapshots()`, which deletes the freshly dangling image on
the same pass — so a corrupt `projects.json` could reap a pin whose migration is
still awaiting confirmation, the one thing `pin_is_reapable` orders its
conditions to prevent. It now re-reads the store from disk, runs
`project_store_trust`, refuses an id the store knows, takes the project lock
before reading anything a decision rests on, and checks `has_record`.

H-3. The corrupt-store guard keyed on "empty list + file exists", and
`ProjectsStore::new()` swallows a corrupt file without rewriting it — so the
first `save()`, as little as starting a project, wrote `[{new}]` over it and the
guard passed with every other project's volumes unclaimed. A corrupt load is now
recorded in a sticky `projects.json.corrupt` marker beside the file, and the
existing `.bak` is no longer clobbered by a second corruption. A missing
`projects.json` is refused too: it cannot be told from a moved or partially
restored data directory, and the genuinely fresh case has nothing to find.

Also: the three migration commands surface the lock's real refusal instead of
substituting "a migration is already running"; `note_ownerless_since` re-checks
`has_record` after writing a tombstone, closing the window that could plant one
behind a valid record and reap the pin with zero grace; corrupt migration-record
copies are capped at four; `reconcile_migration` yields to any lock holder, not
only a migration; and a 22-space run in a refusal string is gone.

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 13:11:42 -07:00
co-authored by Claude Opus 5
parent 42ef1865cc
commit dcd2dfe5a3
6 changed files with 1034 additions and 85 deletions
@@ -256,10 +256,25 @@ pub(crate) fn is_migrating(project_id: &str) -> bool {
struct ActiveGuard(#[allow(dead_code)] crate::project_lock::ProjectGuard);
impl ActiveGuard {
/// `None` when a migration — or anything else — already holds this project.
fn acquire(project_id: &str) -> Option<Self> {
/// `Err` with the registry's own refusal when a migration — **or anything
/// else** — already holds this project.
///
/// The error string is the point. This returned `Option`, and all three
/// callers replaced the discarded reason with a sentence about a migration
/// — so a user blocked by a *compaction*, a reset or a cache clear was told
/// to wait for a base update that was not running, with nothing in the UI
/// that could ever name what actually held the project.
/// `project_lock::try_acquire` already composes "what holds it" with "what
/// you were trying to do"; there is nothing to add to it here.
///
/// The tail it composes is `ProjectOp::Migration`'s — "…before starting a
/// base update" — for confirm and rollback as well as for the migration
/// itself. That is the class all three belong to, and splitting it would
/// mean a `ProjectOp` variant per command: the wrong place to encode a
/// verb, for a phrase that is at worst imprecise where the old one was
/// simply wrong.
fn acquire(project_id: &str) -> Result<Self, String> {
crate::project_lock::try_acquire(project_id, crate::project_lock::ProjectOp::Migration)
.ok()
.map(Self)
}
}
@@ -276,10 +291,9 @@ pub async fn migrate_project_to_base(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<MigrationReport, String> {
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
return Ok(MigrationReport::failed_preflight(
"A migration is already running for this project.",
));
let _guard = match ActiveGuard::acquire(&project_id) {
Ok(guard) => guard,
Err(busy) => return Ok(MigrationReport::failed_preflight(&busy)),
};
let existing = migration_store::load(&project_id)?;
@@ -817,12 +831,7 @@ pub async fn confirm_migration(
let _ = &state;
// Confirming drops the only way back. Doing that underneath a running
// migration would delete the pin it is relying on mid-flight.
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
return Err(
"A container base update is running for this project right now. Wait for it to finish."
.to_string(),
);
};
let _guard = ActiveGuard::acquire(&project_id)?;
let Some(mstate) = migration_store::load(&project_id)? else {
return Ok(());
};
@@ -869,12 +878,7 @@ pub async fn rollback_migration(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
return Err(
"A container base update is running for this project right now. Wait for it to finish."
.to_string(),
);
};
let _guard = ActiveGuard::acquire(&project_id)?;
let mut project = state
.projects_store
@@ -987,7 +991,21 @@ pub async fn get_migration_state(
pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandle) {
// A migration running right now is indistinguishable from a crashed one
// from the outside; only this process knows the difference.
if is_migrating(&project.id) {
//
// **Any holder, not just a migration.** This asked `is_migrating`, which is
// `held() == Some(Migration)` — so a compaction, a reset or a destroy
// holding the project made this fall straight through and start rewriting
// the migration record's phase and untagging its rollback image underneath
// whatever was running. `reconcile_project_statuses` is a command, not just
// a startup step, so "nothing else can be running yet" is not available as
// an argument.
if let Some(holder) = crate::project_lock::held(&project.id) {
log::debug!(
"Skipping migration reconcile for '{}' ({}): {}",
project.name,
project.id,
holder.describe()
);
return;
}
let state = match migration_store::load(&project.id) {
@@ -1897,16 +1915,23 @@ mod tests {
{
let g = ActiveGuard::acquire(id).expect("first acquire must succeed");
assert!(is_migrating(id));
let refused = ActiveGuard::acquire(id)
.err()
.expect("a second concurrent migration must be refused");
// The refusal has to say what is holding the project, not what the
// caller happens to be — the three commands used to substitute
// their own sentence for this and lost the distinction.
assert!(
ActiveGuard::acquire(id).is_none(),
"a second concurrent migration must be refused"
refused.contains("base update"),
"the refusal must name the holder: {}",
refused
);
drop(g);
}
assert!(!is_migrating(id), "the guard must release on drop");
// …including when the migration bailed out through an early return.
fn early_return(id: &str) -> Option<()> {
let _g = ActiveGuard::acquire(id)?;
let _g = ActiveGuard::acquire(id).ok()?;
None
}
assert!(early_return(id).is_none());