Tombstones carried no ciphertext, so the server could forge a delete (hiding
an item), and a device that pulled one accepted the next live version without
a seq comparison (delete-then-replay rollback).
* A delete is sealed like an item (same AAD and padding) with plaintext
{id, seq, deleted: true}; seq = working/base seq + 1. DELETE carries it as
the ItemPut body; the test server stores and serves it.
* replay: Base is None | LegacyTombstone | Sealed(plaintext). check_pulled
refuses a record without ciphertext, a deleted flag that disagrees with the
plaintext, or a tombstone naming another item, then requires seq to grow
for tombstones too. The `restores` exception is gone: edit beats delete
re-seals with max(edit, tombstone) + 1. next_version returns just seq.
* Legacy: a pulled tombstone without ciphertext is an integrity alarm (cannot
be told apart from a forgery). A pending legacy delete is sealed on push; a
legacy tombstone already held as base is accepted as unknown seq.
* engine: open_sealed, seal_tombstone; next_local_version -> u64. Merkle leaf
hashes a sealed tombstone's envelope like a live record's.
* kv-model Item: `restores` removed. kv-ffi delete_item seals the tombstone;
FFI surface unchanged.
* Tests: forged (unsealed and relabelled) tombstones raise an integrity alarm
(kv-sync and kv-ffi), replay after a tombstone, replays of the tombstone and
of the restore, legacy pending delete, shared item_replay.json table; the
live test now deletes an item through the real server.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
50 lines
1.6 KiB
Rust
50 lines
1.6 KiB
Rust
//! Checks `kv_sync::replay` against the shared decision table (kestrove-spec/vectors/item_replay.json).
|
|
|
|
use kv_sync::replay::{check_pulled, next_version, Base};
|
|
use serde_json::Value;
|
|
use uuid::Uuid;
|
|
|
|
fn vectors() -> Value {
|
|
serde_json::from_str(include_str!("vectors/item_replay.json")).unwrap()
|
|
}
|
|
|
|
fn base(v: &Value) -> Base<'_> {
|
|
if v.is_null() {
|
|
Base::None
|
|
} else if v["legacy_tombstone"] == Value::Bool(true) {
|
|
Base::LegacyTombstone
|
|
} else {
|
|
Base::Sealed(&v["plaintext"])
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn check_table() {
|
|
let v = vectors();
|
|
let id = Uuid::parse_str(v["item_id"].as_str().unwrap()).unwrap();
|
|
for case in v["check"].as_array().unwrap() {
|
|
let record = &case["record"];
|
|
let plaintext = Some(&record["plaintext"]).filter(|p| !p.is_null());
|
|
let got = check_pulled(base(&case["base"]), id, record["deleted"].as_bool().unwrap(), plaintext);
|
|
let want = case["result"].as_str().unwrap();
|
|
assert_eq!(if got.is_ok() { "accept" } else { "alarm" }, want, "{}: {got:?}", case["name"]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn next_version_table() {
|
|
for case in vectors()["next_version"].as_array().unwrap() {
|
|
let local = Some(&case["local"]).filter(|v| !v.is_null());
|
|
let seq = next_version(local, base(&case["base"]));
|
|
assert_eq!(Some(seq), case["seq"].as_u64(), "{}", case["name"]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn spec_copy_is_identical() {
|
|
let spec = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../kestrove-spec/vectors/item_replay.json");
|
|
if let Ok(s) = std::fs::read_to_string(spec) {
|
|
assert_eq!(s, include_str!("vectors/item_replay.json"), "kestrove-spec copy differs");
|
|
}
|
|
}
|