lockExclusive($target, 300); try { $this->doUntrash($target, $snapshotId); } finally { $this->leases->release($lease); } } /** * Run $fn under the location's exclusive lock (to create a run atomically with respect to a * cleanup, which takes the same lock and then refuses while a run exists). */ public function underStartLock(Target $target, callable $fn): mixed { try { return $this->leases->with(self::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $fn()); } catch (LockedException) { throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later'); } } /** * 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, int $ttl): string { try { $lease = $this->leases->acquire(self::lockKey($target), LeaseService::EXCLUSIVE, $ttl); } 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->leases->release($lease); throw new \RuntimeException('A backup or restore of ' . $target->getName() . ' is running; try again later'); } return $lease; } 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)'); } $repo->untrash($snapshotId); // Put it back in the local snapshot list as well. $meta = $repo->snapshotMeta($snapshotId); $s = new \OCA\NgBackup\Db\Snapshot(); $s->setTargetId($target->getId()); $s->setSnapshotId($snapshotId); $s->setKind((string)($meta['kind'] ?? 'full')); $s->setLabel(($meta['label'] ?? '') !== '' ? $meta['label'] : null); $s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now'))); $s->setFiles((int)($meta['stats']['files'] ?? 0)); $s->setBytes((int)($meta['stats']['bytes'] ?? 0)); try { $this->snapshots->insert($s); } catch (\Throwable) { } $this->alerts->securityEvent('snapshot_untrashed', ['target' => $target->getName(), 'snapshot' => $snapshotId]); } public static function lockKey(Target $target): string { return 'ng_backup/target/' . $target->getId(); } /** * A separate, short-lived exclusive lock serializing writes to the catalog itself. The * per-target lock above only keeps readers/writers out while a cleanup runs; it does not * stop two SHARED holders (e.g. two concurrent per-user backups, or one alongside a full * backup) from both reading the same catalog generation and racing to write the next one, * silently dropping whichever's change loses. The full-backup path avoids this today only * because a Run row makes concurrent runs for the same target mutually exclusive; per-user * backup/restore has no such row, so it takes this lock around the catalog write itself. */ public static function catalogLockKey(Target $target): string { return self::lockKey($target) . '/catalog'; } /** How many of each user's own exports (occ backup:user:backup) to keep, newest first. */ public function userExportRetention(): int { return $this->appConfig->getValueInt(Application::APP_ID, 'user_export_retention', 3); } public function setUserExportRetention(int $keep): void { if ($keep < 1) { throw new \InvalidArgumentException('Keep at least the last export'); } $old = $this->userExportRetention(); $this->appConfig->setValueInt(Application::APP_ID, 'user_export_retention', $keep); if ($old !== $keep) { $this->alerts->securityEvent('user_export_retention_changed', ['keep' => (string)$keep]); } } /** * Forgets a user's older exports once more than userExportRetention() exist for them, * newest first; no trash, unlike a full snapshot (occ backup:user:backup is on-demand, not * yet the primary safety net the deletion delay protects). * * @return array{kept: array>, forgotten: list} */ public function applyUserExports(Target $target, bool $dryRun = false): array { if ($target->getAppendOnly()) { throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there'); } $lease = $this->lockExclusive($target, 1800); try { $repo = $this->targets->repository($target); $keep = $this->userExportRetention(); $byUser = []; foreach ($repo->listUserExports() as $path) { $uid = explode('/', $path)[1] ?? ''; $manifest = json_decode($repo->getObject($path), true, 512, JSON_THROW_ON_ERROR); $byUser[$uid][$path] = (int)strtotime((string)($manifest['time'] ?? '@0')); } $kept = []; $forget = []; foreach ($byUser as $uid => $times) { arsort($times); $i = 0; foreach (array_keys($times) as $path) { if ($i++ < $keep) { $kept[$uid][] = $path; } else { $forget[] = $path; } } } if (!$dryRun) { foreach ($forget as $path) { $repo->forgetUserExport($path); } if ($forget !== []) { $this->alerts->securityEvent('user_exports_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], false); } } return ['kept' => $kept, 'forgotten' => $forget]; } finally { $this->leases->release($lease); } } public function policy(): RetentionPolicy { return RetentionPolicy::fromArray(json_decode($this->appConfig->getValueString(Application::APP_ID, 'retention', '{}'), true) ?: []); } public function setPolicy(RetentionPolicy $policy): void { if ($policy->last < 1) { throw new \InvalidArgumentException('Keep at least the last snapshot'); } $old = $this->policy()->toArray(); $this->appConfig->setValueString(Application::APP_ID, 'retention', json_encode($policy->toArray(), JSON_THROW_ON_ERROR)); if ($old !== $policy->toArray()) { $this->alerts->securityEvent('retention_changed', ['policy' => json_encode($policy->toArray())]); } } /** * Snapshots the policy drops go to the trash on the location; trash entries older than the * deletion delay are purged, and only then is their data freed. * * @return array{kept: array>, forgotten: list, purged: list, prune: array} */ public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array { if ($target->getAppendOnly()) { throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there'); } $lease = $this->lockExclusive($target, 1800); try { $repo = $this->targets->repository($target); $times = []; foreach ($repo->listSnapshots() as $id) { $meta = $repo->snapshotMeta($id); $times[$id] = (int)strtotime((string)($meta['time'] ?? '@0')); } $keep = $this->policy()->keep($times); $forget = array_values(array_diff(array_keys($times), array_keys($keep))); $actor = $scheduled ? 'retention policy' : $this->alerts->actor(); $purged = []; if (!$dryRun) { foreach ($forget as $id) { $repo->forget($id, $actor); try { $this->snapshots->delete($this->snapshots->findOne($target->getId(), $id)); } catch (\Throwable) { } } } // Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone. $delay = $this->keys->deleteDelayDays(); if (!$dryRun && $forget !== []) { // Scheduled cleanup follows the policy: audit log only, no notification every week. $this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget), 'until' => date('Y-m-d', time() + $delay * 86400)], !$scheduled); } if (!$dryRun) { $purged = $repo->purgeTrash($delay * 86400); } $prune = $repo->prune($dryRun); return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune]; } finally { $this->leases->release($lease); } } }