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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user