0f4a238414
Storage and repository:
- Listing skipped every name containing ".part-", directories too, so the
exports of a user like "john.part-time" were invisible to prune on S3,
SFTP, FTP and SMB and their data was deleted. Only real temporary uploads
("<name>.part-<8 hex>") are skipped now.
- prune refuses to run while a recorded per-user export's manifest is
missing, instead of freeing that export's data.
- forget() and untrash() copy first, switch the catalog, and delete the
originals last, so a crash can no longer leave the catalog pointing at a
missing snapshot or database dump.
- Writes are checked against the expected size, so a short upload is an
error instead of a truncated pack; raw restores check rename() and touch().
- A catalog write refuses to overwrite a generation another process just
wrote, and the catalog lock lasts 300 s, so opening a large repository
inside it no longer lets the lease run out.
Backup runs and scheduling:
- A file that keeps changing is read again at most three times; then the
last read is kept and reported, instead of restarting forever and
blocking cleanup.
- Unreadable files and folders are recorded, logged, shown by occ
backup:run, stored in the snapshot and notified to admins, instead of
being skipped silently.
- After a failed scheduled backup, at most three attempts a day, an hour
apart, instead of a new run and notification every five minutes.
- The job no longer spins on a run whose lease another process holds, and
the weekly cleanup catches up after a Sunday spent backing up.
- Notifications for the per-user export retention events.
113 lines
3.6 KiB
PHP
113 lines
3.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
namespace OCA\NgBackup\Job;
|
|
|
|
/**
|
|
* Walks one or more source roots in a stable depth-first order (entries of a directory sorted
|
|
* by name, children right after their directory), yielding "<root>/<relative path>" for files.
|
|
*
|
|
* The same order is used for comparing paths (compare()), so a walk can resume after a cursor
|
|
* and be merge-joined with the previous snapshot without holding any list in memory.
|
|
*/
|
|
final class TreeWalker {
|
|
/**
|
|
* @param array<string, string> $roots name => absolute directory, walked in this order
|
|
* @param list<string> $exclude path prefixes ("<root>/<relative>") that are skipped entirely;
|
|
* a pattern with "*" matches one path segment (fnmatch, FNM_PATHNAME)
|
|
*/
|
|
public function __construct(
|
|
private array $roots,
|
|
private array $exclude = [],
|
|
/** Called with the logical path of a directory that cannot be listed. */
|
|
private ?\Closure $onUnreadable = null,
|
|
) {
|
|
}
|
|
|
|
/** @return \Generator<string, string> logical path => absolute path, strictly after $after */
|
|
public function walk(?string $after = null): \Generator {
|
|
foreach ($this->roots as $name => $dir) {
|
|
$name = (string)$name;
|
|
if ($name !== '' && $after !== null && self::compare($name, self::firstSegment($after)) < 0) {
|
|
continue; // whole root lies before the cursor
|
|
}
|
|
yield from $this->walkDir(rtrim($dir, '/'), $name, $after);
|
|
}
|
|
}
|
|
|
|
/** Order used everywhere: segment by segment, byte-wise. */
|
|
public static function compare(string $a, string $b): int {
|
|
$sa = explode('/', $a);
|
|
$sb = explode('/', $b);
|
|
$n = min(count($sa), count($sb));
|
|
for ($i = 0; $i < $n; $i++) {
|
|
$c = strcmp($sa[$i], $sb[$i]);
|
|
if ($c !== 0) {
|
|
return $c < 0 ? -1 : 1;
|
|
}
|
|
}
|
|
return count($sa) <=> count($sb);
|
|
}
|
|
|
|
private function walkDir(string $abs, string $logical, ?string $after): \Generator {
|
|
if ($logical !== '' && $this->isExcluded($logical, true)) {
|
|
return;
|
|
}
|
|
$names = @scandir($abs);
|
|
if ($names === false) {
|
|
if ($this->onUnreadable !== null) {
|
|
($this->onUnreadable)($logical === '' ? '/' : $logical);
|
|
}
|
|
return;
|
|
}
|
|
$names = array_values(array_diff($names, ['.', '..']));
|
|
sort($names, SORT_STRING);
|
|
foreach ($names as $name) {
|
|
$childLogical = $logical === '' ? $name : $logical . '/' . $name;
|
|
$childAbs = $abs . '/' . $name;
|
|
if (is_link($childAbs)) {
|
|
continue; // never follow symlinks out of the data directory
|
|
}
|
|
if (is_dir($childAbs)) {
|
|
// Skip a directory entirely if everything in it sorts before the cursor.
|
|
if ($after !== null && self::compare($childLogical, $after) < 0 && !self::isPrefix($childLogical, $after)) {
|
|
continue;
|
|
}
|
|
yield from $this->walkDir($childAbs, $childLogical, $after);
|
|
} elseif (is_file($childAbs)) {
|
|
if ($after !== null && self::compare($childLogical, $after) <= 0) {
|
|
continue;
|
|
}
|
|
if (!$this->isExcluded($childLogical, false)) {
|
|
yield $childLogical => $childAbs;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function isExcluded(string $logical, bool $isDir): bool {
|
|
foreach ($this->exclude as $pattern) {
|
|
if (str_contains($pattern, '*')) {
|
|
if (fnmatch($pattern, $logical, FNM_PATHNAME)) {
|
|
return true;
|
|
}
|
|
} elseif ($logical === $pattern || ($isDir && str_starts_with($logical, $pattern . '/'))) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static function isPrefix(string $dir, string $path): bool {
|
|
return str_starts_with($path, $dir . '/');
|
|
}
|
|
|
|
private static function firstSegment(string $path): string {
|
|
return explode('/', $path, 2)[0];
|
|
}
|
|
}
|