Fixes from the phase-1 code review

- one locking protocol per location: backup steps and restores hold a
  shared lock, prune and untrash an exclusive one and refuse while any
  run (backup, restore, export) exists; starting a backup checks and
  creates the run under the exclusive lock, closing the race with prune
- snapshot entries are validated when read (relative path without '.',
  '..', NUL, backslash or control characters); raw restore also checks
  that the resolved directory stays below the target; the restore API
  and service accept canonical paths only
- recovery kit confirmation requires a recent password confirmation and
  the confirmation code printed in the current kit file (new code per
  kit version), proving the kit was actually downloaded
- prune decides on the authenticated index files on the location and
  refreshes the local index cache from them
- restore job marks the run failed on any error; restore runs without
  progress for 6 hours are failed so they cannot block cleanup forever
- tests for safe paths and a malicious snapshot path
This commit is contained in:
2026-10-02 11:21:40 +02:00
parent d3aa07b106
commit ac82b38e37
14 changed files with 226 additions and 40 deletions
+26 -8
View File
@@ -33,6 +33,31 @@ class PruneService {
}
public function untrash(Target $target, string $snapshotId): void {
$this->lockExclusive($target);
try {
$this->doUntrash($target, $snapshotId);
} finally {
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
}
}
/**
* Exclusive lock on the location, then make sure no run of any kind (backup, restore, export)
* is in progress: their data may not be referenced by a snapshot yet, or is being read.
*/
private function lockExclusive(Target $target): void {
try {
$this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) {
throw new \RuntimeException('A backup, restore or cleanup of ' . $target->getName() . ' is in progress; try again later');
}
if ($this->runs->findRunning($target->getId()) !== []) {
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
throw new \RuntimeException('A backup or restore of ' . $target->getName() . ' is running; try again later');
}
}
private function doUntrash(Target $target, string $snapshotId): void {
$repo = $this->targets->repository($target);
if (!array_key_exists($snapshotId, $repo->trash())) {
throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)');
@@ -84,14 +109,7 @@ class PruneService {
if ($target->getAppendOnly()) {
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
}
if ($this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
throw new \RuntimeException('A backup to ' . $target->getName() . ' is running; try again later');
}
try {
$this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) {
throw new \RuntimeException('Another backup or cleanup of ' . $target->getName() . ' is running');
}
$this->lockExclusive($target);
try {
$repo = $this->targets->repository($target);
$times = [];