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]>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
//! Checks `kv_sync::replay` against the shared decision table (kestrove-spec/vectors/item_replay.json).
|
||||
|
||||
use kv_sync::replay::{check_successor, next_version, Base};
|
||||
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()
|
||||
@@ -9,22 +10,24 @@ fn vectors() -> Value {
|
||||
|
||||
fn base(v: &Value) -> Base<'_> {
|
||||
if v.is_null() {
|
||||
return Base::None;
|
||||
}
|
||||
let revision = v["revision"].as_u64().unwrap();
|
||||
if v["tombstone"] == Value::Bool(true) {
|
||||
Base::Tombstone { revision }
|
||||
Base::None
|
||||
} else if v["legacy_tombstone"] == Value::Bool(true) {
|
||||
Base::LegacyTombstone
|
||||
} else {
|
||||
Base::Live { revision, item: &v["item"] }
|
||||
Base::Sealed(&v["plaintext"])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_table() {
|
||||
for case in vectors()["check"].as_array().unwrap() {
|
||||
let got = check_successor(base(&case["base"]), case["revision"].as_u64().unwrap(), &case["incoming"]);
|
||||
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, "{}", case["name"]);
|
||||
assert_eq!(if got.is_ok() { "accept" } else { "alarm" }, want, "{}: {got:?}", case["name"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +35,8 @@ fn check_table() {
|
||||
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, restores) = next_version(local, base(&case["base"]));
|
||||
let seq = next_version(local, base(&case["base"]));
|
||||
assert_eq!(Some(seq), case["seq"].as_u64(), "{}", case["name"]);
|
||||
assert_eq!(restores, case["restores"].as_u64(), "{}", case["name"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+115
-16
@@ -10,7 +10,7 @@ use kv_crypto::{
|
||||
use kv_proto::{DeviceEnroll, ItemRecord, Platform};
|
||||
use kv_store::{MemoryStore, Store, SyncState, VaultRow};
|
||||
use kv_sync::{
|
||||
engine::{next_local_version, open_record, seal_record},
|
||||
engine::{next_local_version, open_record, open_sealed, seal_record, seal_tombstone},
|
||||
replay,
|
||||
signing::SoftwareSigner,
|
||||
test_server::{TestServer, SETUP_TOKEN},
|
||||
@@ -103,10 +103,10 @@ impl Device {
|
||||
self.keyset.open_vault_key(vid, &self.store.vault(vid).unwrap().unwrap().sealed_key).unwrap()
|
||||
}
|
||||
|
||||
/// Saves a local edit the way a client does: with the next `seq` (and `restores`) stamped in.
|
||||
/// Saves a local edit the way a client does: with the next `seq` stamped in.
|
||||
fn save(&self, vid: Uuid, id: Uuid, mut item: Value) {
|
||||
let (seq, restores) = next_local_version(&self.store, self.keyset.account_id, vid, &self.vk(vid), id).unwrap();
|
||||
replay::stamp(&mut item, seq, restores);
|
||||
let seq = next_local_version(&self.store, self.keyset.account_id, vid, &self.vk(vid), id).unwrap();
|
||||
replay::stamp(&mut item, seq);
|
||||
self.save_raw(vid, id, item);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,16 @@ impl Device {
|
||||
self.store.put_local(vid, &rec).unwrap();
|
||||
}
|
||||
|
||||
/// Deletes the way a client does: a sealed tombstone with the next `seq`.
|
||||
fn delete(&self, vid: Uuid, id: Uuid) {
|
||||
let (acct, vk) = (self.keyset.account_id, self.vk(vid));
|
||||
let seq = next_local_version(&self.store, acct, vid, &vk, id).unwrap();
|
||||
let tomb = seal_tombstone(acct, vid, &vk, id, seq, self.signer.device_id()).unwrap();
|
||||
self.store.put_local(vid, &tomb).unwrap();
|
||||
}
|
||||
|
||||
/// A pending delete as stored before tombstones were sealed (no ciphertext).
|
||||
fn delete_legacy(&self, vid: Uuid, id: Uuid) {
|
||||
let mut cur = self.store.get(vid, id).unwrap().unwrap().record;
|
||||
cur.deleted = true;
|
||||
cur.wrapped_item_key = None;
|
||||
@@ -124,6 +133,12 @@ impl Device {
|
||||
self.store.put_local(vid, &cur).unwrap();
|
||||
}
|
||||
|
||||
/// The sealed plaintext of the item's base (live or tombstone).
|
||||
fn read_base(&self, vid: Uuid, id: Uuid) -> Option<Value> {
|
||||
let base = self.store.get(vid, id).unwrap()?.base?;
|
||||
open_sealed(self.keyset.account_id, vid, &self.vk(vid), &base).unwrap()
|
||||
}
|
||||
|
||||
fn read(&self, vid: Uuid, id: Uuid) -> Option<Value> {
|
||||
let item = self.store.get(vid, id).unwrap()?;
|
||||
open_record(self.keyset.account_id, vid, &self.vk(vid), &item.record).unwrap()
|
||||
@@ -257,9 +272,14 @@ async fn deleting_and_never_synced_items() {
|
||||
a.save(vid, local_only, item(local_only, "L", "l", 1));
|
||||
a.delete(vid, local_only);
|
||||
a.sync().await.unwrap();
|
||||
assert!(server.state().vaults[&vid].items[&x].deleted);
|
||||
let tomb = server.state().vaults[&vid].items[&x].clone();
|
||||
assert!(tomb.deleted && tomb.payload.is_some(), "the server stores the sealed tombstone");
|
||||
assert!(!server.state().vaults[&vid].items.contains_key(&local_only));
|
||||
assert!(a.store.get(vid, local_only).unwrap().is_none());
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
assert_eq!(b.read_base(vid, x).unwrap(), json!({"id": x, "seq": 2, "deleted": true}));
|
||||
assert!(b.read(vid, x).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -634,7 +654,6 @@ async fn concurrent_equal_seq_edits_are_not_an_alarm() {
|
||||
assert_eq!(b.sync().await.unwrap().merged, 1);
|
||||
let merged = b.read(vid, id).unwrap();
|
||||
assert_eq!(merged["seq"], 3);
|
||||
assert!(merged.get("restores").is_none());
|
||||
for x in [&a, &c, &d] {
|
||||
x.sync().await.unwrap(); // d jumps from seq 1 straight to 3
|
||||
assert_eq!(x.read(vid, id).unwrap(), merged);
|
||||
@@ -649,8 +668,8 @@ async fn concurrent_equal_seq_edits_are_not_an_alarm() {
|
||||
assert!(c.store.pending(vid).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// An edit that beats a delete may carry a lower `seq` than versions other devices saw before the delete.
|
||||
/// `restores` lets a device that never saw the tombstone accept it, and only directly after that delete.
|
||||
/// An edit that beats a delete is written above the tombstone's `seq`, so every device sees `seq` grow,
|
||||
/// including one that never saw the tombstone. Replays of the tombstone or of the restore are refused.
|
||||
#[tokio::test]
|
||||
async fn edit_beats_delete_is_not_an_alarm_but_its_replay_is() {
|
||||
let server = TestServer::start().await;
|
||||
@@ -668,31 +687,111 @@ async fn edit_beats_delete_is_not_an_alarm_but_its_replay_is() {
|
||||
c.sync().await.unwrap(); // c holds seq 4 and will not see the tombstone
|
||||
a.delete(vid, id);
|
||||
a.sync().await.unwrap();
|
||||
let tombstone_rev = server.state().vaults[&vid].items[&id].revision;
|
||||
let tombstone = server.state().vaults[&vid].items[&id].clone();
|
||||
let d = Device::second(&server, &a).await;
|
||||
d.sync().await.unwrap(); // d holds the tombstone
|
||||
d.sync().await.unwrap(); // d holds the tombstone (seq 5)
|
||||
assert_eq!(d.read_base(vid, id).unwrap()["seq"], 5);
|
||||
|
||||
b.save(vid, id, item(id, "X", "x-b", 9));
|
||||
let r = b.sync().await.unwrap();
|
||||
assert_eq!(r.restored, vec![id]);
|
||||
let restored = b.read(vid, id).unwrap();
|
||||
assert_eq!((restored["seq"].as_u64(), restored["restores"].as_u64()), (Some(2), Some(tombstone_rev)));
|
||||
assert_eq!(restored["seq"], 6, "above the tombstone's seq");
|
||||
let restore_envelope = server.state().vaults[&vid].items[&id].clone();
|
||||
|
||||
for x in [&a, &c, &d] {
|
||||
x.sync().await.unwrap();
|
||||
assert_eq!(x.read(vid, id).unwrap()["password"], "x-b");
|
||||
}
|
||||
// The next edit clears `restores` and continues from the restored `seq`.
|
||||
c.save(vid, id, item(id, "X", "x-c", 10));
|
||||
assert_eq!(c.read(vid, id).unwrap()["seq"], 3);
|
||||
assert!(c.read(vid, id).unwrap().get("restores").is_none());
|
||||
assert_eq!(c.read(vid, id).unwrap()["seq"], 7);
|
||||
c.sync().await.unwrap();
|
||||
a.sync().await.unwrap();
|
||||
d.sync().await.unwrap();
|
||||
|
||||
// Re-serving the restore later is a rollback: the receiver's base is past the tombstone now.
|
||||
server.state().write_item(vid, restore_envelope);
|
||||
// Re-serving the tombstone or the restore later is a rollback.
|
||||
server.state().write_item(vid, tombstone);
|
||||
assert_replay_alarm(a.sync().await, vid);
|
||||
server.state().write_item(vid, restore_envelope);
|
||||
assert_replay_alarm(d.sync().await, vid);
|
||||
}
|
||||
|
||||
/// The server forges a delete. A tombstone without ciphertext, or an item envelope relabelled as deleted, is an
|
||||
/// integrity alarm; the item stays visible.
|
||||
#[tokio::test]
|
||||
async fn forged_tombstone_is_an_integrity_alarm() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
let live = server.state().vaults[&vid].items[&id].clone();
|
||||
let manifest_version = server.state().vaults[&vid].manifest.as_ref().unwrap().version;
|
||||
|
||||
// An unauthenticated tombstone, as the server could always write before.
|
||||
let forged = ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..live.clone() };
|
||||
server.state().write_item(vid, forged);
|
||||
let err = b.sync().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SyncError::Integrity { vault_id, ref reason } if vault_id == vid && reason.contains("unauthenticated delete")),
|
||||
"{err}"
|
||||
);
|
||||
assert_eq!(b.read(vid, id).unwrap()["password"], "pw1", "not hidden");
|
||||
assert!(b.store.vault(vid).unwrap().unwrap().integrity_alarm);
|
||||
// A device that never held the item refuses it too.
|
||||
let c = Device::second(&server, &a).await;
|
||||
assert!(matches!(c.sync().await, Err(SyncError::Integrity { .. })));
|
||||
|
||||
// An authentic item envelope re-served with the deleted flag set.
|
||||
let relabelled = ItemRecord { deleted: true, ..live };
|
||||
server.state().write_item(vid, relabelled);
|
||||
let err = a.sync().await.unwrap_err();
|
||||
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("deleted flag")), "{err}");
|
||||
assert_eq!(a.read(vid, id).unwrap()["password"], "pw1");
|
||||
assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, manifest_version);
|
||||
}
|
||||
|
||||
/// Delete-then-replay: after a device pulls a (genuine) tombstone, an old live envelope re-served on top of it
|
||||
/// is refused, because the tombstone's `seq` is authenticated.
|
||||
#[tokio::test]
|
||||
async fn replay_after_a_tombstone_is_an_integrity_alarm() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
||||
a.sync().await.unwrap();
|
||||
let old = server.state().vaults[&vid].items[&id].clone();
|
||||
a.save(vid, id, item(id, "Bank", "pw2", 2));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
a.delete(vid, id);
|
||||
a.sync().await.unwrap();
|
||||
b.sync().await.unwrap();
|
||||
assert!(b.read(vid, id).is_none());
|
||||
|
||||
server.state().write_item(vid, old);
|
||||
assert_replay_alarm(b.sync().await, vid);
|
||||
assert!(b.read(vid, id).is_none(), "the deleted item did not come back");
|
||||
}
|
||||
|
||||
/// A pending delete stored before tombstones were sealed is sealed when it is pushed.
|
||||
#[tokio::test]
|
||||
async fn legacy_pending_delete_is_sealed_on_push() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "X", "x", 1));
|
||||
a.sync().await.unwrap();
|
||||
a.delete_legacy(vid, id);
|
||||
a.sync().await.unwrap();
|
||||
let tomb = server.state().vaults[&vid].items[&id].clone();
|
||||
assert!(tomb.deleted && tomb.payload.is_some());
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
assert_eq!(b.read_base(vid, id).unwrap(), json!({"id": id, "seq": 2, "deleted": true}));
|
||||
}
|
||||
|
||||
/// Items written before `seq` existed read as seq 0; the first edit writes seq 1.
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
{
|
||||
"description": "Per-item replay protection (docs/03 'Per-item replay protection'). Item plaintexts are shown as JSON; only seq, restores and content equality matter. 'base' is the last server-confirmed version this device holds: null (none), {tombstone, revision} or {revision, item}.",
|
||||
"description": "Per-item replay protection (docs/03 'Per-item replay protection'). Plaintexts are shown as JSON; only seq, the deleted marker, the tombstone id and content equality matter. Every case is for item_id. 'base' is the last server-confirmed version this device holds: null (none), {legacy_tombstone: true} (a tombstone without ciphertext from before tombstones were sealed) or {plaintext} (a live item or a sealed tombstone {id, seq, deleted: true}). 'record' is a pulled record: its deleted flag and its authenticated plaintext, null when it carries no ciphertext. 'local' is the device's working copy.",
|
||||
"item_id": "11111111-1111-1111-1111-111111111111",
|
||||
"check": [
|
||||
{ "name": "no base: first sight of the item", "base": null, "revision": 7, "incoming": {"title": "a", "seq": 1}, "result": "accept" },
|
||||
{ "name": "tombstone base: anything may follow a delete", "base": {"tombstone": true, "revision": 5}, "revision": 7, "incoming": {"title": "a", "seq": 1}, "result": "accept" },
|
||||
{ "name": "higher seq", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 4}, "result": "accept" },
|
||||
{ "name": "seq jumps by more than one", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 9}, "result": "accept" },
|
||||
{ "name": "older seq: replay of an old envelope", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "old", "seq": 2}, "result": "alarm" },
|
||||
{ "name": "same seq, different content", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 3}, "result": "alarm" },
|
||||
{ "name": "same seq, identical content", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "a", "seq": 3}, "result": "accept" },
|
||||
{ "name": "legacy items: absent seq is 0", "base": {"revision": 5, "item": {"title": "a"}}, "revision": 7, "incoming": {"title": "b", "seq": 0}, "result": "alarm" },
|
||||
{ "name": "legacy base, first seq-aware edit", "base": {"revision": 5, "item": {"title": "a"}}, "revision": 7, "incoming": {"title": "b", "seq": 1}, "result": "accept" },
|
||||
{ "name": "restore over a tombstone this device did not see", "base": {"revision": 5, "item": {"title": "a", "seq": 6}}, "revision": 9, "incoming": {"title": "r", "seq": 2, "restores": 8}, "result": "accept" },
|
||||
{ "name": "restore claim older than the base", "base": {"revision": 8, "item": {"title": "a", "seq": 6}}, "revision": 9, "incoming": {"title": "r", "seq": 2, "restores": 7}, "result": "alarm" },
|
||||
{ "name": "restore claim equal to the base revision", "base": {"revision": 8, "item": {"title": "a", "seq": 6}}, "revision": 9, "incoming": {"title": "r", "seq": 2, "restores": 8}, "result": "alarm" },
|
||||
{ "name": "restore claim not below the record's own revision", "base": {"revision": 5, "item": {"title": "a", "seq": 6}}, "revision": 9, "incoming": {"title": "r", "seq": 2, "restores": 9}, "result": "alarm" }
|
||||
{"name": "no base: first sight of the item", "base": null, "record": {"deleted": false, "plaintext": {"title": "a", "seq": 1}}, "result": "accept"},
|
||||
{"name": "no base: first sight is a sealed tombstone", "base": null, "record": {"deleted": true, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 2, "deleted": true}}, "result": "accept"},
|
||||
{"name": "higher seq", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 4}}, "result": "accept"},
|
||||
{"name": "seq jumps by more than one", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 9}}, "result": "accept"},
|
||||
{"name": "older seq: replay of an old envelope", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "old", "seq": 2}}, "result": "alarm"},
|
||||
{"name": "same seq, different content", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 3}}, "result": "alarm"},
|
||||
{"name": "same seq, identical content", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "a", "seq": 3}}, "result": "accept"},
|
||||
{"name": "legacy items: absent seq is 0", "base": {"plaintext": {"title": "a"}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 0}}, "result": "alarm"},
|
||||
{"name": "legacy base, first seq-aware edit", "base": {"plaintext": {"title": "a"}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 1}}, "result": "accept"},
|
||||
{"name": "delete after a live version", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": true, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "result": "accept"},
|
||||
{"name": "old tombstone replayed after a newer edit", "base": {"plaintext": {"title": "a", "seq": 6}}, "record": {"deleted": true, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 5, "deleted": true}}, "result": "alarm"},
|
||||
{"name": "restore after a delete (edit beats delete)", "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "record": {"deleted": false, "plaintext": {"title": "r", "seq": 5}}, "result": "accept"},
|
||||
{"name": "delete-then-replay: old live version after a tombstone", "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "record": {"deleted": false, "plaintext": {"title": "old", "seq": 3}}, "result": "alarm"},
|
||||
{"name": "same tombstone again", "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "record": {"deleted": true, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "result": "accept"},
|
||||
{"name": "restore seen by a device that missed the delete", "base": {"plaintext": {"title": "a", "seq": 5}}, "record": {"deleted": false, "plaintext": {"title": "r", "seq": 7}}, "result": "accept"},
|
||||
{"name": "unsealed tombstone after a live version", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": true, "plaintext": null}, "result": "alarm"},
|
||||
{"name": "unsealed tombstone, no base", "base": null, "record": {"deleted": true, "plaintext": null}, "result": "alarm"},
|
||||
{"name": "live record without ciphertext", "base": null, "record": {"deleted": false, "plaintext": null}, "result": "alarm"},
|
||||
{"name": "tombstone envelope served as a live record", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "result": "alarm"},
|
||||
{"name": "live envelope served as a tombstone", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": true, "plaintext": {"title": "b", "seq": 4}}, "result": "alarm"},
|
||||
{"name": "tombstone naming another item", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": true, "plaintext": {"id": "22222222-2222-2222-2222-222222222222", "seq": 4, "deleted": true}}, "result": "alarm"},
|
||||
{"name": "legacy tombstone base: seq unknown", "base": {"legacy_tombstone": true}, "record": {"deleted": false, "plaintext": {"title": "r", "seq": 1}}, "result": "accept"}
|
||||
],
|
||||
"next_version": [
|
||||
{ "name": "new item", "local": null, "base": null, "seq": 1, "restores": null },
|
||||
{ "name": "edit of a clean item", "local": {"seq": 3}, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 4, "restores": null },
|
||||
{ "name": "second edit before syncing", "local": {"seq": 4}, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 5, "restores": null },
|
||||
{ "name": "re-save after a pending local delete", "local": null, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 4, "restores": null },
|
||||
{ "name": "legacy item", "local": {}, "base": {"revision": 5, "item": {}}, "seq": 1, "restores": null },
|
||||
{ "name": "save over a tombstone", "local": null, "base": {"tombstone": true, "revision": 8}, "seq": 1, "restores": 8 },
|
||||
{ "name": "edit after a restore clears restores", "local": {"seq": 2, "restores": 8}, "base": {"revision": 9, "item": {"seq": 2, "restores": 8}}, "seq": 3, "restores": null }
|
||||
{"name": "new item", "local": null, "base": null, "seq": 1},
|
||||
{"name": "edit of a clean item", "local": {"seq": 3}, "base": {"plaintext": {"seq": 3}}, "seq": 4},
|
||||
{"name": "second edit before syncing", "local": {"seq": 4}, "base": {"plaintext": {"seq": 3}}, "seq": 5},
|
||||
{"name": "delete of a clean item (the tombstone's seq)", "local": {"seq": 3}, "base": {"plaintext": {"seq": 3}}, "seq": 4},
|
||||
{"name": "re-save after a pending local delete", "local": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}, "base": {"plaintext": {"seq": 3}}, "seq": 5},
|
||||
{"name": "legacy item", "local": {}, "base": {"plaintext": {}}, "seq": 1},
|
||||
{"name": "save over a pulled tombstone", "local": {"id": "11111111-1111-1111-1111-111111111111", "seq": 8, "deleted": true}, "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 8, "deleted": true}}, "seq": 9},
|
||||
{"name": "pending edit rebased over a pulled tombstone (edit beats delete)", "local": {"seq": 4}, "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 6, "deleted": true}}, "seq": 7},
|
||||
{"name": "legacy tombstone base", "local": null, "base": {"legacy_tombstone": true}, "seq": 1}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user