diff --git a/lib/Backend/StorageBackend.php b/lib/Backend/StorageBackend.php index bed3dca..aff055f 100644 --- a/lib/Backend/StorageBackend.php +++ b/lib/Backend/StorageBackend.php @@ -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); diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index 767964c..b312d30 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -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; diff --git a/tests/integration/full-restore-roundtrip.sh b/tests/integration/full-restore-roundtrip.sh index c0d270c..7900f0c 100755 --- a/tests/integration/full-restore-roundtrip.sh +++ b/tests/integration/full-restore-roundtrip.sh @@ -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