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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user