Files
samiandClaude Opus 5.5 cfab3ab543 kv-sync: authenticated tombstones (sealed {id, seq, deleted: true})
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]>
2026-10-16 11:34:08 +09:00

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");
}
}