CI: round trip against real S3 and SFTP backends, not just a local path
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 / 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

Added tests/integration/targets-matrix.sh, which runs the full backup/restore
round trip through files_external's AmazonS3 and SFTP backend classes (S3
via a disposable s3mock container, SFTP via atmoz/sftp), since the existing
matrix only ever exercised the local backend.

This caught a real bug: moving a snapshot to the trash read it from the
location and streamed it straight back into a write on the same connection,
which SFTP can't do reliably (phpseclib fails to interleave a read and a
write on one connection). Backends now expose a native move() and the
id-bound snapshot object is moved with that instead of a read+write copy;
the path-bound database dump still goes through decrypt/re-encrypt, since
its AEAD authentication is tied to its path.
This commit is contained in:
2026-10-05 03:15:34 +02:00
parent 9d97f9c1f1
commit 48ea6bb33d
7 changed files with 113 additions and 11 deletions
+7
View File
@@ -27,6 +27,13 @@ interface IBackend {
public function exists(string $path): bool;
/**
* Move an object within the same location (used for the trash). Backends must use a native
* rename, not a read+write copy: some storages (e.g. SFTP) cannot reliably read and write on
* the same connection at once, since the read side is a lazily-pulled stream.
*/
public function move(string $from, string $to): void;
/** @return list<string> object paths below $prefix (recursive) */
public function list(string $prefix): array;
+11
View File
@@ -106,6 +106,17 @@ final class LocalBackend implements IBackend {
@unlink($this->abs($path));
}
public function move(string $from, string $to): void {
$target = $this->abs($to);
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
throw new BackendException('Cannot create ' . $dir);
}
if (!rename($this->abs($from), $target)) {
throw new BackendException("Cannot move $from to $to");
}
}
private function abs(string $path): string {
if (str_contains($path, '..') || str_starts_with($path, '/')) {
throw new BackendException('Invalid path: ' . $path);
+8
View File
@@ -115,6 +115,14 @@ final class StorageBackend implements IBackend {
$this->storage->unlink($this->abs($path));
}
public function move(string $from, string $to): void {
$target = $this->abs($to);
$this->mkdirs(dirname($target));
if (!$this->storage->rename($this->abs($from), $target)) {
throw new BackendException("Cannot move $from to $to");
}
}
private function listInto(string $dir, array &$result): void {
if (!$this->storage->is_dir($dir)) {
return;
+6 -8
View File
@@ -345,20 +345,19 @@ final class Repository {
$meta = $this->snapshotMeta($snapshotId);
$dbInTrash = null;
if (isset($meta['db']) && is_string($meta['db']) && $this->backend->exists($meta['db'])) {
// Its encryption is bound to its path (object:db/...), which changes on a move, so it
// must be decrypted and re-encrypted under the new path, not moved as raw bytes.
$dbInTrash = 'trash/' . $meta['db'];
$this->putObject($dbInTrash, $this->getObject($meta['db']));
$this->backend->delete($meta['db']);
}
// The snapshot object is bound to its id (not its path), so it can be copied as-is.
$this->backend->put('trash/snapshots/' . $snapshotId, $this->backend->get('snapshots/' . $snapshotId));
// The snapshot object is bound to its id (not its path), so it can be moved as raw bytes.
$this->backend->move('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]);
$this->catalog->trash[$snapshotId] = true;
$this->catalog->write();
if ($dbInTrash !== null) {
$this->backend->delete($meta['db']);
}
$this->backend->delete('snapshots/' . $snapshotId);
}
/** Undo forget(): move a snapshot back from the trash. */
@@ -368,11 +367,10 @@ final class Repository {
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
$this->backend->delete($info['dbInTrash']);
}
$this->backend->put('snapshots/' . $snapshotId, $this->backend->get('trash/snapshots/' . $snapshotId));
$this->backend->move('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId);
$this->catalog->snapshots[$snapshotId] = true;
unset($this->catalog->trash[$snapshotId]);
$this->catalog->write();
$this->backend->delete('trash/snapshots/' . $snapshotId);
$this->backend->delete('trash/info/' . $snapshotId);
}