181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
98 lines
4.8 KiB
Rust
98 lines
4.8 KiB
Rust
//! Quota and garbage collection, over real HTTP with injectable clocks and limits.
|
|
|
|
mod common;
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use common::{ADMIN, Server, temp_dir};
|
|
use notescraft_sync_server::{GcReport, Store, TOMBSTONE_GRACE_MS};
|
|
|
|
/// One megabyte of quota, small enough that a test can spend it by hand.
|
|
const QUOTA_BYTES: u64 = 1024 * 1024;
|
|
|
|
fn server_with_quota(name: &str, quota_bytes: Option<u64>) -> (Server, PathBuf) {
|
|
let data = temp_dir(name);
|
|
let state = notescraft_sync_server::AppState::with_quota(Store::open(&data.join("sync.db")).expect("db"), data.join("blobs"), ADMIN, quota_bytes);
|
|
(common::start_with_state(state), data)
|
|
}
|
|
|
|
fn blob_files(data: &std::path::Path, vault: &str) -> Vec<PathBuf> {
|
|
let mut files: Vec<PathBuf> = std::fs::read_dir(data.join("blobs").join(vault))
|
|
.expect("blob dir")
|
|
.filter_map(|entry| entry.expect("entry").file_name().into_string().ok())
|
|
.map(|name| data.join("blobs").join(vault).join(name))
|
|
.collect();
|
|
files.sort();
|
|
files
|
|
}
|
|
|
|
#[test]
|
|
fn quota_rejects_uploads_over_the_live_bytes_limit_before_storing() {
|
|
let (server, data) = server_with_quota("quota", Some(QUOTA_BYTES));
|
|
let (vault, token) = server.paired("pc");
|
|
let near = "x".repeat(usize::try_from(QUOTA_BYTES - 50).expect("small quota"));
|
|
let (status, record) = server.put(&vault, &token, "a.md", &near, 0);
|
|
assert_eq!((status, record["seq"].as_u64()), (201, Some(1)));
|
|
|
|
// A sibling file would exceed the quota: 413, audited, and no blob stored.
|
|
let before = blob_files(&data, &vault);
|
|
let (status, reply) = server.put(&vault, &token, "b.md", &"y".repeat(60), 0);
|
|
assert_eq!(status, 413);
|
|
assert_eq!(reply["error"], "vault storage quota exceeded");
|
|
assert_eq!(blob_files(&data, &vault), before);
|
|
assert!(server.audit().iter().any(|row| row["kind"] == "quota_rejected"));
|
|
server.assert_no_secrets(&serde_json::to_string(&server.audit()).expect("rows"));
|
|
}
|
|
|
|
#[test]
|
|
fn quota_replacement_counts_only_the_size_difference() {
|
|
let (server, _) = server_with_quota("quota-replace", Some(QUOTA_BYTES));
|
|
let (vault, token) = server.paired("pc");
|
|
let (status, record) = server.put(&vault, &token, "a.md", &"x".repeat(usize::try_from(QUOTA_BYTES - 50).expect("small quota")), 0);
|
|
assert_eq!(status, 201);
|
|
let seq = record["seq"].as_u64().expect("seq");
|
|
|
|
// As a new file, +60 bytes overflows; as a replacement, +10 bytes fits the slack.
|
|
assert_eq!(server.put(&vault, &token, "b.md", &"y".repeat(60), 0).0, 413);
|
|
let (status, replaced) = server.put(&vault, &token, "a.md", &"z".repeat(usize::try_from(QUOTA_BYTES - 40).expect("small quota")), seq);
|
|
assert_eq!(replaced["seq"].as_u64(), Some(seq + 1));
|
|
assert_eq!(status, 201);
|
|
// The next new file is still over.
|
|
assert_eq!(server.put(&vault, &token, "b.md", &"y".repeat(59), 0).0, 413);
|
|
}
|
|
|
|
#[test]
|
|
fn gc_removes_unreferenced_blobs_and_keeps_referenced_and_in_grace_ones() {
|
|
let (server, data) = server_with_quota("gc", None);
|
|
let (vault, token) = server.paired("pc");
|
|
let (other, other_token) = server.paired("other");
|
|
let (_, record_a) = server.put(&vault, &token, "a.md", "gone", 0);
|
|
let (_, record_b) = server.put(&vault, &token, "b.md", "stays", 0);
|
|
let (_, record_c) = server.put(&other, &other_token, "c.md", "untouched", 0);
|
|
let (hash_a, hash_b, hash_c): (&str, &str, &str) =
|
|
(record_a["hash"].as_str().expect("hash"), record_b["hash"].as_str().expect("hash"), record_c["hash"].as_str().expect("hash"));
|
|
|
|
// Tombstone a.md; its blob is unreferenced by live records but inside the grace.
|
|
let (status, _) = server.json("DELETE", &format!("/v1/vaults/{vault}/files/a.md"), Some(&token), &[("X-Base-Seq", "1")], None);
|
|
assert_eq!(status, 201);
|
|
let dir = data.join("blobs").join(&vault);
|
|
assert!(dir.join(hash_a).exists() && dir.join(hash_b).exists());
|
|
|
|
// Default 14-day grace keeps the fresh tombstone's blob.
|
|
let report = server.state.gc(&vault, TOMBSTONE_GRACE_MS, notescraft_sync_server::now_ms()).expect("gc");
|
|
assert_eq!(report, GcReport { removed: 0, bytes: 0 });
|
|
|
|
// Aged past the grace (injected clock), the tombstone's blob goes, the live one stays.
|
|
let aged = notescraft_sync_server::now_ms() + TOMBSTONE_GRACE_MS + 1;
|
|
let report = server.state.gc(&vault, TOMBSTONE_GRACE_MS, aged).expect("gc");
|
|
assert_eq!(report, GcReport { removed: 1, bytes: u64::try_from("gone".len()).expect("small") });
|
|
assert!(!dir.join(hash_a).exists());
|
|
assert!(dir.join(hash_b).exists());
|
|
// A zero grace takes even a live tombstone only through its own vault's files.
|
|
assert_eq!(server.state.gc(&vault, 0, aged).expect("gc").removed, 0);
|
|
assert!(data.join("blobs").join(&other).join(hash_c).exists());
|
|
|
|
// The run itself is audited.
|
|
assert!(server.audit().iter().any(|row| row["kind"] == "gc_run"));
|
|
}
|