Phase 0: fixes from review (torn files, pending blobs, exact sequences, UTF-8, storage checks)

- detect files changing during reading or between resumable steps and
  re-read them (size/mtime/ctime/inode), spike F overwrites mid-run
- drop pending blob ids when a pack or index upload fails
- dump and restore PostgreSQL sequence state exactly, bound parameters,
  validated catalog names; spike C checks all sequences exactly
- keep invalid UTF-8 byte-exact (tagged base64)
- refuse large tables without a primary key
- StorageBackend: check write/flush/close results, validate base path
- user_migration import/export: root paths, intermediate folders,
  partial writes, read errors
- spikes: never touch an existing account, own fixtures, 512M note
This commit is contained in:
2026-10-01 18:58:51 +02:00
parent 06e55b08ea
commit 7b33347dbc
13 changed files with 274 additions and 70 deletions
+26 -16
View File
@@ -18,30 +18,40 @@ final class LocalBackend implements IBackend {
}
public function put(string $path, $stream): void {
$target = $this->abs($path);
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
throw new BackendException('Cannot create ' . $dir);
}
// Write to a temporary name and rename, so a crash never leaves a partial object.
$tmp = $target . '.part-' . bin2hex(random_bytes(4));
$out = fopen($tmp, 'wb');
if ($out === false) {
throw new BackendException('Cannot write ' . $path);
}
$tmp = null;
$out = false;
$done = false;
try {
$target = $this->abs($path);
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
throw new BackendException('Cannot create ' . $dir);
}
// Write to a temporary name and rename, so a crash never leaves a partial object.
$tmp = $target . '.part-' . bin2hex(random_bytes(4));
$out = fopen($tmp, 'wb');
if ($out === false) {
throw new BackendException('Cannot write ' . $path);
}
if (stream_copy_to_stream($stream, $out) === false || !fflush($out)) {
throw new BackendException('Write failed for ' . $path);
}
} finally {
fclose($out);
$out = false;
if (!rename($tmp, $target)) {
throw new BackendException('Cannot finalise ' . $path);
}
$done = true;
} finally {
if ($out !== false) {
fclose($out);
}
if (is_resource($stream)) {
fclose($stream);
}
}
if (!rename($tmp, $target)) {
@unlink($tmp);
throw new BackendException('Cannot finalise ' . $path);
if (!$done && $tmp !== null) {
@unlink($tmp);
}
}
}
+24 -5
View File
@@ -23,6 +23,7 @@ final class StorageBackend implements IBackend {
private string $base = '',
) {
$this->base = trim($base, '/');
self::validate($this->base, true);
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
if ($this->base !== '') {
$this->mkdirs($this->base);
@@ -37,13 +38,18 @@ final class StorageBackend implements IBackend {
if ($this->storage->instanceOfStorage(IWriteStreamStorage::class)) {
/** @var IWriteStreamStorage $s */
$s = $this->storage;
$s->writeStream($writeTo, $stream);
$written = $s->writeStream($writeTo, $stream);
if ($written <= 0 && $this->storage->filesize($writeTo) !== 0) {
throw new BackendException('Write reported no data for ' . $path);
}
} else {
$out = $this->storage->fopen($writeTo, 'w');
if ($out === false || stream_copy_to_stream($stream, $out) === false) {
if ($out === false || stream_copy_to_stream($stream, $out) === false || !fflush($out)) {
throw new BackendException('Write failed for ' . $path);
}
fclose($out);
if (!fclose($out)) {
throw new BackendException('Close failed for ' . $path);
}
}
if (is_resource($stream)) {
fclose($stream);
@@ -139,10 +145,23 @@ final class StorageBackend implements IBackend {
$this->storage->mkdir($dir);
}
private function abs(string $path): string {
if (str_contains($path, '..')) {
/** Reject empty segments, '.', '..', NUL, backslashes and absolute paths. */
private static function validate(string $path, bool $allowEmpty = false): void {
if ($path === '' && $allowEmpty) {
return;
}
if ($path === '' || str_starts_with($path, '/') || str_contains($path, "\\") || str_contains($path, "\0")) {
throw new BackendException('Invalid path: ' . $path);
}
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
throw new BackendException('Invalid path: ' . $path);
}
}
}
private function abs(string $path): string {
self::validate(ltrim($path, '/'));
return ltrim(($this->base === '' ? '' : $this->base . '/') . ltrim($path, '/'), '/');
}
}