fix(files): check the quota BEFORE the destructive blob write

Self-review of this branch caught a data-loss bug I introduced with the
per-app / group files ceilings: the quota check ran AFTER the blob write.

`write_atomic_at` renames the new bytes over the FINAL path, so by the time
the ceiling refused an update the previous bytes were already gone. The
per-app path then unlinked the blob (row survives, file destroyed — every
read 404s); the group path left the new bytes in place under the old row's
checksum (every read fails `Corrupted`). Either way a user permanently lost
a file merely by exceeding a quota — a strictly worse outcome than the
unchecked-update bypass the ceiling was added to close.

The check now precedes the write on create AND update, per-app and group. As
a bonus this stops an over-quota caller driving unbounded write+unlink disk
churn: the ceiling now bounds I/O, not just stored bytes. The blob still goes
down inside the transaction, under the advisory lock, so a rollback unlinks
it and nothing can reference it in between.

The original test passed against the bug: it asserted the refused update did
not change the stored byte TOTAL — true, while the blob was already
destroyed. The regression test now reads the file back through the
checksum-verifying `FsFilesRepo::get`, and was confirmed to fail with
`Corrupted` against the old ordering.

Also in the KV writer (same file): drop the redundant pre-read on the hottest
write path. `set`/`set_if` did a SELECT purely to learn whether the write
would add a row, when the upsert already returns the previous value. Check
after the insert instead (`>` not `>=`) and let the transaction roll back on
refusal — identical outcome, one round-trip fewer, and an update pays nothing
at all. `kv_repo::get_on` and `FsFilesRepo::final_path` are now unused and
deleted; `FilesRepo::delete` delegates to the `delete_meta_on` + `unlink_blob`
helpers rather than re-implementing them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-14 21:11:49 +02:00
parent 735b2daedf
commit fa79b4ee45
4 changed files with 103 additions and 99 deletions

View File

@@ -20,6 +20,7 @@ use picloud_manager_core::atomic_write::{
GroupKvTarget, GroupKvWriter, KvWriter, PostgresFilesWriter, PostgresGroupDocsWriter,
PostgresGroupFilesWriter, PostgresGroupKvWriter, PostgresKvWriter,
};
use picloud_manager_core::files_repo::{FilesConfig, FilesRepo, FsFilesRepo};
use picloud_manager_core::quota::GroupWriteQuota;
use picloud_shared::{
AppId, ExecutionId, FileUpdate, FilesError, GroupDocsError, GroupFilesError, GroupId,
@@ -734,6 +735,29 @@ async fn concurrent_uploads_cannot_push_an_app_past_its_disk_ceiling() {
stored,
"the refused update must not have changed the stored total"
);
// …and, crucially, the file must SURVIVE. `write_atomic_at` renames over the
// final path, so a quota check that ran AFTER the blob write would have
// destroyed the original bytes — the user would permanently lose a file
// merely by exceeding a quota. Read it back through the repo, which verifies
// the stored checksum, so both "bytes gone" and "bytes replaced but metadata
// still has the old checksum" fail here.
let repo = FsFilesRepo::new(
pool.clone(),
FilesConfig {
root: root.clone(),
max_file_size_bytes: 1024 * 1024,
},
);
let survivor = repo
.get(AppId::from(f.app), "assets", created[0])
.await
.expect("the refused update must not corrupt or destroy the existing file")
.expect("the file must still exist");
assert_eq!(
survivor,
vec![b'x'; 100],
"the original bytes must be intact — a refused update must not touch the blob"
);
cleanup(&pool, f.app).await;
let _ = std::fs::remove_dir_all(&root);