Fix trash on SFTP and the disaster recovery test after the review fixes
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled

forget() and untrash() uploaded a remote read stream directly, which the
new size check could not judge on SFTP, so cleanup failed there. They now
copy through a local buffer, and the size check only trusts local streams
(php://temp, php://memory, plain files).

The disaster recovery test calls backup:restore:full with --no-interaction:
docker exec without a terminal still counts as interactive, so the new
confirmation question read an empty answer and cancelled.
This commit is contained in:
2026-10-06 23:59:17 +02:00
parent 45069994e9
commit 16e09f4fde
3 changed files with 28 additions and 5 deletions
+6 -2
View File
@@ -155,12 +155,16 @@ final class StorageBackend implements IBackend {
closedir($dh);
}
/** Bytes left to read in a seekable $stream, or null when it cannot tell. @param resource $stream */
/**
* Bytes left to read in a local $stream (php://temp, php://memory or a plain file), or null:
* stream wrappers of remote storages do not report a reliable size.
*/
public static function remaining(mixed $stream): ?int {
if (!is_resource($stream)) {
return null;
}
if (!(stream_get_meta_data($stream)['seekable'] ?? false)) {
$meta = stream_get_meta_data($stream);
if (!($meta['seekable'] ?? false) || !in_array($meta['wrapper_type'] ?? '', ['PHP', 'plainfile'], true)) {
return null;
}
$stat = fstat($stream);
+21 -2
View File
@@ -385,7 +385,7 @@ final class Repository {
// either the old state or the new one plus unreferenced leftovers, never a catalog entry
// pointing at a missing object. The snapshot object is bound to its id (not its path),
// so it can be copied as raw bytes.
$this->backend->put('trash/snapshots/' . $snapshotId, $this->backend->get('snapshots/' . $snapshotId));
$this->copyRaw('snapshots/' . $snapshotId, 'trash/snapshots/' . $snapshotId);
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
unset($this->catalog->snapshots[$snapshotId]);
@@ -405,7 +405,7 @@ final class Repository {
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
}
// Same order as forget(): copy, switch the catalog, then delete the trash copies.
$this->backend->put('snapshots/' . $snapshotId, $this->backend->get('trash/snapshots/' . $snapshotId));
$this->copyRaw('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId);
$this->catalog->snapshots[$snapshotId] = true;
unset($this->catalog->trash[$snapshotId]);
$this->catalog->write();
@@ -416,6 +416,25 @@ final class Repository {
$this->backend->delete('trash/info/' . $snapshotId);
}
/** Copy an object as raw bytes, buffered locally (remote read streams cannot be uploaded directly everywhere). */
private function copyRaw(string $from, string $to): void {
$in = $this->backend->get($from);
$buffer = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
if ($buffer === false) {
fclose($in);
throw new RepositoryException("Cannot buffer $from");
}
try {
if (stream_copy_to_stream($in, $buffer) === false) {
throw new RepositoryException("Cannot read $from");
}
} finally {
fclose($in);
}
rewind($buffer);
$this->backend->put($to, $buffer);
}
/** Record a user_migration export's manifest in the catalog (rollback-protected like a snapshot). */
public function recordUserExport(string $manifestPath): void {
$this->catalog->userExports[$manifestPath] = true;
+1 -1
View File
@@ -68,7 +68,7 @@ run_on ngb-frt-restore backup:target:add offsite local -a null::null -o datadir=
run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location"
ok "recovery kit imported, existing repository adopted, snapshot visible"
run_on ngb-frt-restore backup:restore:full offsite "$SNAP" >/dev/null || fail "restore:full"
run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction >/dev/null || fail "restore:full"
ok "backup:restore:full completed"
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since