max / makenotwork
6 files changed,
+392 insertions,
-127 deletions
| @@ -108,31 +108,17 @@ impl SyncKitClient { | |||
| 108 | 108 | Err(last_err.expect("loop ran at least once")) | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | - | /// Encrypt the data field of a change entry for the wire. | |
| 111 | + | /// Encrypt a change entry for the wire. Every change now seals an HLC | |
| 112 | + | /// envelope (Deletes included), so the master key is always required. | |
| 112 | 113 | #[cfg(test)] | |
| 113 | 114 | pub(super) fn encrypt_change(&self, entry: ChangeEntry) -> Result<WireChangeEntry> { | |
| 114 | - | if entry.data.is_some() { | |
| 115 | - | let master_key = self.require_master_key()?; | |
| 116 | - | Self::encrypt_change_with_key(entry, &master_key) | |
| 117 | - | } else { | |
| 118 | - | Self::encrypt_change_no_data(entry) | |
| 119 | - | } | |
| 115 | + | let master_key = self.require_master_key()?; | |
| 116 | + | Self::encrypt_change_with_key(entry, &master_key) | |
| 120 | 117 | } | |
| 121 | 118 | ||
| 122 | - | /// Build a wire entry for a change with no data (e.g. Delete). No key needed. | |
| 123 | - | #[cfg(test)] | |
| 124 | - | pub(super) fn encrypt_change_no_data(entry: ChangeEntry) -> Result<WireChangeEntry> { | |
| 125 | - | debug_assert!(entry.data.is_none()); | |
| 126 | - | Ok(WireChangeEntry { | |
| 127 | - | table: entry.table, | |
| 128 | - | op: entry.op, | |
| 129 | - | row_id: entry.row_id, | |
| 130 | - | timestamp: entry.timestamp, | |
| 131 | - | data: None, | |
| 132 | - | }) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | /// Decrypt a pulled entry that has no data. No key needed. | |
| 119 | + | /// Decrypt a pulled legacy entry that has no encrypted payload (a pre-HLC | |
| 120 | + | /// Delete). The HLC is synthesized from the entry's `client_timestamp`. No key | |
| 121 | + | /// needed. | |
| 136 | 122 | #[cfg(test)] | |
| 137 | 123 | pub(super) fn decrypt_change_no_data(entry: PullChangeEntry) -> Result<ChangeEntry> { | |
| 138 | 124 | debug_assert!(entry.data.is_none()); | |
| @@ -140,17 +126,47 @@ impl SyncKitClient { | |||
| 140 | 126 | table: entry.table, | |
| 141 | 127 | op: entry.op, | |
| 142 | 128 | row_id: entry.row_id, | |
| 129 | + | hlc: Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id), | |
| 143 | 130 | timestamp: entry.timestamp, | |
| 144 | 131 | data: None, | |
| 145 | 132 | }) | |
| 146 | 133 | } | |
| 147 | 134 | ||
| 148 | - | /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock acquisition. | |
| 135 | + | /// Wrap a change's HLC and payload into one JSON envelope. Encrypting the | |
| 136 | + | /// envelope (rather than the bare row payload) is what carries the HLC inside | |
| 137 | + | /// the E2E ciphertext — including for Deletes, which have no row payload. The | |
| 138 | + | /// server stores the ciphertext opaquely and never sees the clock. | |
| 139 | + | fn hlc_envelope(hlc: &Hlc, data: &Option<serde_json::Value>) -> serde_json::Value { | |
| 140 | + | serde_json::json!({ "__skhlc": hlc, "data": data }) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Split a decrypted payload back into `(hlc, data)`. Handles both the HLC | |
| 144 | + | /// envelope and legacy bare-row payloads (entries pushed before HLC support), | |
| 145 | + | /// for which the HLC is synthesized from `node` + `timestamp_ms`. | |
| 146 | + | fn split_hlc_envelope( | |
| 147 | + | decrypted: serde_json::Value, | |
| 148 | + | node: uuid::Uuid, | |
| 149 | + | timestamp_ms: i64, | |
| 150 | + | ) -> (Hlc, Option<serde_json::Value>) { | |
| 151 | + | if let Some(obj) = decrypted.as_object() | |
| 152 | + | && obj.contains_key("data") | |
| 153 | + | && let Some(hlc) = obj | |
| 154 | + | .get("__skhlc") | |
| 155 | + | .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok()) | |
| 156 | + | { | |
| 157 | + | let data = obj.get("data").cloned().filter(|v| !v.is_null()); | |
| 158 | + | return (hlc, data); | |
| 159 | + | } | |
| 160 | + | // Legacy bare-row payload: the decrypted value is the row data itself. | |
| 161 | + | (Hlc::from_legacy(timestamp_ms, node), Some(decrypted)) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock | |
| 165 | + | /// acquisition. Every change (including Deletes) is encrypted, because the HLC | |
| 166 | + | /// envelope always needs sealing. | |
| 149 | 167 | pub(super) fn encrypt_change_with_key(entry: ChangeEntry, master_key: &[u8; 32]) -> Result<WireChangeEntry> { | |
| 150 | - | let encrypted_data = match entry.data { | |
| 151 | - | Some(ref value) => Some(crypto::encrypt_json(value, master_key)?), | |
| 152 | - | None => None, | |
| 153 | - | }; | |
| 168 | + | let envelope = Self::hlc_envelope(&entry.hlc, &entry.data); | |
| 169 | + | let encrypted_data = Some(crypto::encrypt_json(&envelope, master_key)?); | |
| 154 | 170 | ||
| 155 | 171 | Ok(WireChangeEntry { | |
| 156 | 172 | table: entry.table, | |
| @@ -214,33 +230,47 @@ impl SyncKitClient { | |||
| 214 | 230 | /// Inner decryption that borrows the entry (for fallback logic). | |
| 215 | 231 | #[allow(dead_code)] | |
| 216 | 232 | fn decrypt_change_with_key_inner(entry: &PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> { | |
| 217 | - | let decrypted_data = match entry.data { | |
| 218 | - | Some(ref value) => Some(crypto::decrypt_json(value, master_key)?), | |
| 219 | - | None => None, | |
| 233 | + | let (hlc, data) = match entry.data { | |
| 234 | + | Some(ref value) => { | |
| 235 | + | let decrypted = crypto::decrypt_json(value, master_key)?; | |
| 236 | + | Self::split_hlc_envelope(decrypted, entry.device_id, entry.timestamp.timestamp_millis()) | |
| 237 | + | } | |
| 238 | + | None => ( | |
| 239 | + | Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id), | |
| 240 | + | None, | |
| 241 | + | ), | |
| 220 | 242 | }; | |
| 221 | 243 | ||
| 222 | 244 | Ok(ChangeEntry { | |
| 223 | 245 | table: entry.table.clone(), | |
| 224 | 246 | op: entry.op, | |
| 225 | 247 | row_id: entry.row_id.clone(), | |
| 248 | + | hlc, | |
| 226 | 249 | timestamp: entry.timestamp, | |
| 227 | - | data: decrypted_data, | |
| 250 | + | data, | |
| 228 | 251 | }) | |
| 229 | 252 | } | |
| 230 | 253 | ||
| 231 | 254 | /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition. | |
| 232 | 255 | pub(super) fn decrypt_change_with_key(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> { | |
| 233 | - | let decrypted_data = match entry.data { | |
| 234 | - | Some(ref value) => Some(crypto::decrypt_json(value, master_key)?), | |
| 235 | - | None => None, | |
| 256 | + | let (hlc, data) = match entry.data { | |
| 257 | + | Some(ref value) => { | |
| 258 | + | let decrypted = crypto::decrypt_json(value, master_key)?; | |
| 259 | + | Self::split_hlc_envelope(decrypted, entry.device_id, entry.timestamp.timestamp_millis()) | |
| 260 | + | } | |
| 261 | + | None => ( | |
| 262 | + | Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id), | |
| 263 | + | None, | |
| 264 | + | ), | |
| 236 | 265 | }; | |
| 237 | 266 | ||
| 238 | 267 | Ok(ChangeEntry { | |
| 239 | 268 | table: entry.table, | |
| 240 | 269 | op: entry.op, | |
| 241 | 270 | row_id: entry.row_id, | |
| 271 | + | hlc, | |
| 242 | 272 | timestamp: entry.timestamp, | |
| 243 | - | data: decrypted_data, | |
| 273 | + | data, | |
| 244 | 274 | }) | |
| 245 | 275 | } | |
| 246 | 276 | } | |
| @@ -373,21 +403,44 @@ mod tests { | |||
| 373 | 403 | // ── encrypt_change / decrypt_change ── | |
| 374 | 404 | ||
| 375 | 405 | #[test] | |
| 376 | - | fn encrypt_change_with_no_data() { | |
| 406 | + | fn delete_seals_hlc_envelope_and_roundtrips() { | |
| 407 | + | // A Delete carries no row payload, but its HLC must still travel — so it is | |
| 408 | + | // now encrypted into an envelope (data = Some), and decrypt restores the | |
| 409 | + | // op, a None payload, and the exact HLC. | |
| 377 | 410 | let client = SyncKitClient::new(test_config()); | |
| 411 | + | let key = crypto::generate_master_key(); | |
| 412 | + | *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key)); | |
| 413 | + | ||
| 414 | + | let device = uuid::Uuid::new_v4(); | |
| 415 | + | let hlc = Hlc { wall_ms: 12_345, counter: 7, node: device }; | |
| 378 | 416 | let entry = ChangeEntry { | |
| 379 | 417 | table: "tasks".to_string(), | |
| 380 | 418 | op: ChangeOp::Delete, | |
| 381 | 419 | row_id: "row-1".to_string(), | |
| 382 | 420 | timestamp: Utc::now(), | |
| 421 | + | hlc, | |
| 383 | 422 | data: None, | |
| 384 | 423 | }; | |
| 385 | 424 | ||
| 386 | - | let wire = client.encrypt_change(entry.clone()).unwrap(); | |
| 387 | - | assert_eq!(wire.table, "tasks"); | |
| 425 | + | let wire = client.encrypt_change(entry).unwrap(); | |
| 388 | 426 | assert_eq!(wire.op, ChangeOp::Delete); | |
| 389 | - | assert_eq!(wire.row_id, "row-1"); | |
| 390 | - | assert!(wire.data.is_none()); | |
| 427 | + | assert!(wire.data.is_some(), "delete now seals an encrypted HLC envelope"); | |
| 428 | + | ||
| 429 | + | let pull_entry = PullChangeEntry { | |
| 430 | + | seq: 1, | |
| 431 | + | device_id: device, | |
| 432 | + | table: wire.table, | |
| 433 | + | op: wire.op, | |
| 434 | + | row_id: wire.row_id, | |
| 435 | + | timestamp: wire.timestamp, | |
| 436 | + | data: wire.data, | |
| 437 | + | key_id: None, | |
| 438 | + | }; | |
| 439 | + | let decrypted = client.decrypt_change(pull_entry).unwrap(); | |
| 440 | + | assert_eq!(decrypted.op, ChangeOp::Delete); | |
| 441 | + | assert_eq!(decrypted.row_id, "row-1"); | |
| 442 | + | assert!(decrypted.data.is_none(), "payload is still None after the envelope unwraps"); | |
| 443 | + | assert_eq!(decrypted.hlc, hlc, "HLC survives the round trip"); | |
| 391 | 444 | } | |
| 392 | 445 | ||
| 393 | 446 | #[test] | |
| @@ -398,6 +451,7 @@ mod tests { | |||
| 398 | 451 | op: ChangeOp::Insert, | |
| 399 | 452 | row_id: "row-1".to_string(), | |
| 400 | 453 | timestamp: Utc::now(), | |
| 454 | + | hlc: Default::default(), | |
| 401 | 455 | data: Some(serde_json::json!({"title": "test"})), | |
| 402 | 456 | }; | |
| 403 | 457 | ||
| @@ -417,6 +471,7 @@ mod tests { | |||
| 417 | 471 | op: ChangeOp::Insert, | |
| 418 | 472 | row_id: "row-1".to_string(), | |
| 419 | 473 | timestamp: Utc::now(), | |
| 474 | + | hlc: Default::default(), | |
| 420 | 475 | data: Some(original_data.clone()), | |
| 421 | 476 | }; | |
| 422 | 477 | ||
| @@ -444,6 +499,7 @@ mod tests { | |||
| 444 | 499 | op: ChangeOp::Update, | |
| 445 | 500 | row_id: "row-abc".to_string(), | |
| 446 | 501 | timestamp: ts, | |
| 502 | + | hlc: Default::default(), | |
| 447 | 503 | data: Some(original_data.clone()), | |
| 448 | 504 | }; | |
| 449 | 505 | ||
| @@ -687,6 +743,7 @@ mod tests { | |||
| 687 | 743 | op: ChangeOp::Update, | |
| 688 | 744 | row_id: "unique-row-id".to_string(), | |
| 689 | 745 | timestamp: ts, | |
| 746 | + | hlc: Default::default(), | |
| 690 | 747 | data: Some(serde_json::json!({"name": "Alice"})), | |
| 691 | 748 | }; | |
| 692 | 749 | ||
| @@ -711,6 +768,7 @@ mod tests { | |||
| 711 | 768 | op: ChangeOp::Insert, | |
| 712 | 769 | row_id: "r1".to_string(), | |
| 713 | 770 | timestamp: Utc::now(), | |
| 771 | + | hlc: Default::default(), | |
| 714 | 772 | data: Some(serde_json::json!({"title": "Task 1"})), | |
| 715 | 773 | }, | |
| 716 | 774 | ChangeEntry { | |
| @@ -718,6 +776,7 @@ mod tests { | |||
| 718 | 776 | op: ChangeOp::Update, | |
| 719 | 777 | row_id: "r2".to_string(), | |
| 720 | 778 | timestamp: Utc::now(), | |
| 779 | + | hlc: Default::default(), | |
| 721 | 780 | data: Some(serde_json::json!({"title": "Task 2", "done": true})), | |
| 722 | 781 | }, | |
| 723 | 782 | ChangeEntry { | |
| @@ -725,6 +784,7 @@ mod tests { | |||
| 725 | 784 | op: ChangeOp::Delete, | |
| 726 | 785 | row_id: "r3".to_string(), | |
| 727 | 786 | timestamp: Utc::now(), | |
| 787 | + | hlc: Default::default(), | |
| 728 | 788 | data: None, | |
| 729 | 789 | }, | |
| 730 | 790 | ]; | |
| @@ -738,7 +798,8 @@ mod tests { | |||
| 738 | 798 | assert_eq!(wire_entries.len(), 3); | |
| 739 | 799 | assert!(wire_entries[0].data.is_some()); | |
| 740 | 800 | assert!(wire_entries[1].data.is_some()); | |
| 741 | - | assert!(wire_entries[2].data.is_none()); | |
| 801 | + | // The Delete now also seals an encrypted HLC envelope (was None before HLC). | |
| 802 | + | assert!(wire_entries[2].data.is_some()); | |
| 742 | 803 | ||
| 743 | 804 | for (i, wire) in wire_entries.into_iter().enumerate() { | |
| 744 | 805 | let pull = PullChangeEntry { | |
| @@ -771,6 +832,7 @@ mod tests { | |||
| 771 | 832 | op: ChangeOp::Insert, | |
| 772 | 833 | row_id: "row-1".into(), | |
| 773 | 834 | timestamp: Utc::now(), | |
| 835 | + | hlc: Default::default(), | |
| 774 | 836 | data: Some(serde_json::json!({"name": "\u{30C6}\u{30B9}\u{30C8}"})), | |
| 775 | 837 | }; | |
| 776 | 838 | ||
| @@ -803,6 +865,7 @@ mod tests { | |||
| 803 | 865 | op: ChangeOp::Insert, | |
| 804 | 866 | row_id: "".into(), | |
| 805 | 867 | timestamp: Utc::now(), | |
| 868 | + | hlc: Default::default(), | |
| 806 | 869 | data: Some(serde_json::json!(42)), | |
| 807 | 870 | }; | |
| 808 | 871 |
| @@ -70,12 +70,12 @@ impl SyncKitClient { | |||
| 70 | 70 | ) -> Result<i64> { | |
| 71 | 71 | let token = self.require_token()?; | |
| 72 | 72 | ||
| 73 | - | // Extract key once for the entire batch (only needed if any entry has data) | |
| 74 | - | let has_data = changes.iter().any(|c| c.data.is_some()); | |
| 75 | - | let key_holder = if has_data { | |
| 76 | - | self.require_master_key()? | |
| 77 | - | } else { | |
| 73 | + | // Every change now seals an HLC envelope (Deletes included), so the master | |
| 74 | + | // key is needed whenever there is anything to push. | |
| 75 | + | let key_holder = if changes.is_empty() { | |
| 78 | 76 | crypto::ZeroizeOnDrop([0u8; 32]) | |
| 77 | + | } else { | |
| 78 | + | self.require_master_key()? | |
| 79 | 79 | }; | |
| 80 | 80 | let master_key: &[u8; 32] = &key_holder; | |
| 81 | 81 | let wire_changes = changes | |
| @@ -270,6 +270,7 @@ mod tests { | |||
| 270 | 270 | op: ChangeOp::Insert, | |
| 271 | 271 | row_id: Uuid::new_v4().to_string(), | |
| 272 | 272 | timestamp: Utc::now(), | |
| 273 | + | hlc: Default::default(), | |
| 273 | 274 | data: Some(serde_json::json!({"title": "Test task", "done": false})), | |
| 274 | 275 | }; | |
| 275 | 276 | ||
| @@ -289,6 +290,7 @@ mod tests { | |||
| 289 | 290 | op: ChangeOp::Delete, | |
| 290 | 291 | row_id: "abc-123".to_string(), | |
| 291 | 292 | timestamp: Utc::now(), | |
| 293 | + | hlc: Default::default(), | |
| 292 | 294 | data: None, | |
| 293 | 295 | }; | |
| 294 | 296 |
| @@ -16,7 +16,7 @@ use std::collections::HashMap; | |||
| 16 | 16 | use chrono::{DateTime, Utc}; | |
| 17 | 17 | use uuid::Uuid; | |
| 18 | 18 | ||
| 19 | - | use crate::types::{ChangeEntry, ChangeOp, PulledChange}; | |
| 19 | + | use crate::types::{ChangeEntry, PulledChange}; | |
| 20 | 20 | ||
| 21 | 21 | /// A remote change that conflicts with a local pending change. | |
| 22 | 22 | #[derive(Debug, Clone)] | |
| @@ -106,22 +106,22 @@ pub fn detect_conflicts( | |||
| 106 | 106 | (clean, conflicts) | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | - | /// Last-write-wins resolution by `client_timestamp`. | |
| 109 | + | /// Last-write-wins resolution by hybrid logical clock. | |
| 110 | 110 | /// | |
| 111 | - | /// - DELETE vs non-DELETE: delete wins regardless of timestamp. | |
| 112 | - | /// - Both INSERT/UPDATE: newer timestamp wins. Ties go to local. | |
| 111 | + | /// The higher [`Hlc`](crate::types::Hlc) wins, **regardless of operation**. Because | |
| 112 | + | /// the HLC carries the originating device in its `node` component, every value is | |
| 113 | + | /// globally unique and the comparison is convergent: device A resolving (local=a, | |
| 114 | + | /// remote=b) and device B resolving (local=b, remote=a) compare the same two HLCs | |
| 115 | + | /// and keep the same physical change. | |
| 116 | + | /// | |
| 117 | + | /// This replaces the prior wall-clock + "delete always wins" rule. Delete-wins | |
| 118 | + | /// caused permanent loss when a delete on one device beat a strictly-newer edit on | |
| 119 | + | /// another (ultra-fuzz Run #28); under HLC ordering a newer edit beats an older | |
| 120 | + | /// delete and vice-versa, by clock value alone. `>=` only ties to local for an | |
| 121 | + | /// identical HLC, which (distinct `node`) means a same-device echo, not a real | |
| 122 | + | /// cross-device conflict. | |
| 113 | 123 | pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution { | |
| 114 | - | // DELETE wins over non-DELETE | |
| 115 | - | if local.op == ChangeOp::Delete || remote.entry.op == ChangeOp::Delete { | |
| 116 | - | return if local.op == ChangeOp::Delete { | |
| 117 | - | Resolution::KeepLocal | |
| 118 | - | } else { | |
| 119 | - | Resolution::KeepRemote | |
| 120 | - | }; | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | // Both are INSERT/UPDATE: newer timestamp wins, ties go to local | |
| 124 | - | if local.timestamp >= remote.entry.timestamp { | |
| 124 | + | if local.hlc >= remote.entry.hlc { | |
| 125 | 125 | Resolution::KeepLocal | |
| 126 | 126 | } else { | |
| 127 | 127 | Resolution::KeepRemote | |
| @@ -234,14 +234,24 @@ pub fn resolve_field_merge( | |||
| 234 | 234 | #[cfg(test)] | |
| 235 | 235 | mod tests { | |
| 236 | 236 | use super::*; | |
| 237 | + | use crate::types::{ChangeOp, Hlc}; | |
| 237 | 238 | use serde_json::json; | |
| 238 | 239 | ||
| 240 | + | /// Fixed node for locally-minted test entries, distinct from any random | |
| 241 | + | /// `other_device`, so HLC tiebreaks are deterministic. | |
| 242 | + | fn local_node() -> Uuid { | |
| 243 | + | Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111) | |
| 244 | + | } | |
| 245 | + | ||
| 239 | 246 | fn make_entry(table: &str, row_id: &str, op: ChangeOp, ts: DateTime<Utc>) -> ChangeEntry { | |
| 247 | + | // Derive the HLC wall component from the timestamp so the time-ordered | |
| 248 | + | // tests below still express the intended ordering. | |
| 240 | 249 | ChangeEntry { | |
| 241 | 250 | table: table.to_string(), | |
| 242 | 251 | op, | |
| 243 | 252 | row_id: row_id.to_string(), | |
| 244 | 253 | timestamp: ts, | |
| 254 | + | hlc: Hlc::from_legacy(ts.timestamp_millis(), local_node()), | |
| 245 | 255 | data: Some(json!({"value": "test"})), | |
| 246 | 256 | } | |
| 247 | 257 | } | |
| @@ -254,11 +264,17 @@ mod tests { | |||
| 254 | 264 | device_id: Uuid, | |
| 255 | 265 | seq: i64, | |
| 256 | 266 | ) -> PulledChange { | |
| 257 | - | PulledChange { | |
| 258 | - | entry: make_entry(table, row_id, op, ts), | |
| 259 | - | device_id, | |
| 260 | - | seq, | |
| 261 | - | } | |
| 267 | + | let mut entry = make_entry(table, row_id, op, ts); | |
| 268 | + | entry.hlc = Hlc::from_legacy(ts.timestamp_millis(), device_id); | |
| 269 | + | PulledChange { entry, device_id, seq } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// Build a pulled change with an explicit HLC, for resolution tests that need | |
| 273 | + | /// to control the clock independently of the wall timestamp. | |
| 274 | + | fn pulled_with_hlc(row_id: &str, op: ChangeOp, hlc: Hlc, device_id: Uuid) -> PulledChange { | |
| 275 | + | let mut p = make_pulled("tasks", row_id, op, Utc::now(), device_id, 1); | |
| 276 | + | p.entry.hlc = hlc; | |
| 277 | + | p | |
| 262 | 278 | } | |
| 263 | 279 | ||
| 264 | 280 | // ── detect_conflicts ── | |
| @@ -390,7 +406,7 @@ mod tests { | |||
| 390 | 406 | assert!(conflicts.is_empty()); | |
| 391 | 407 | } | |
| 392 | 408 | ||
| 393 | - | // ── resolve_lww ── | |
| 409 | + | // ── resolve_lww (HLC ordering) ── | |
| 394 | 410 | ||
| 395 | 411 | #[test] | |
| 396 | 412 | fn lww_picks_newer_timestamp() { | |
| @@ -417,47 +433,58 @@ mod tests { | |||
| 417 | 433 | } | |
| 418 | 434 | ||
| 419 | 435 | #[test] | |
| 420 | - | fn lww_equal_timestamps_local_wins() { | |
| 421 | - | let other_device = Uuid::new_v4(); | |
| 422 | - | let now = Utc::now(); | |
| 423 | - | ||
| 424 | - | let local = make_entry("tasks", "r1", ChangeOp::Update, now); | |
| 425 | - | let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1); | |
| 426 | - | ||
| 427 | - | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 436 | + | fn lww_counter_breaks_same_wall_tie() { | |
| 437 | + | // Same wall_ms, higher counter wins regardless of node. | |
| 438 | + | let other = Uuid::new_v4(); | |
| 439 | + | let mut local = make_entry("tasks", "r1", ChangeOp::Update, Utc::now()); | |
| 440 | + | local.hlc = Hlc { wall_ms: 1000, counter: 2, node: local_node() }; | |
| 441 | + | let remote = pulled_with_hlc("r1", ChangeOp::Update, Hlc { wall_ms: 1000, counter: 5, node: other }, other); | |
| 442 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); | |
| 428 | 443 | } | |
| 429 | 444 | ||
| 430 | 445 | #[test] | |
| 431 | - | fn lww_local_delete_wins_over_update() { | |
| 432 | - | let other_device = Uuid::new_v4(); | |
| 433 | - | let now = Utc::now(); | |
| 446 | + | fn lww_node_breaks_exact_tie_convergently() { | |
| 447 | + | // Identical (wall, counter): the node decides, and BOTH devices must pick | |
| 448 | + | // the same physical change. Use nodes with a known order (a < b). | |
| 449 | + | let a = Uuid::from_u128(1); | |
| 450 | + | let b = Uuid::from_u128(2); | |
| 451 | + | let hlc_a = Hlc { wall_ms: 1000, counter: 0, node: a }; | |
| 452 | + | let hlc_b = Hlc { wall_ms: 1000, counter: 0, node: b }; | |
| 434 | 453 | ||
| 435 | - | let local = make_entry("tasks", "r1", ChangeOp::Delete, now); | |
| 436 | - | let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1); | |
| 454 | + | // Device A: local is a's change, remote is b's change. | |
| 455 | + | let mut local_a = make_entry("tasks", "r1", ChangeOp::Update, Utc::now()); | |
| 456 | + | local_a.hlc = hlc_a; | |
| 457 | + | let remote_b = pulled_with_hlc("r1", ChangeOp::Update, hlc_b, b); | |
| 458 | + | // b > a, so A drops its local and keeps remote (b's change). | |
| 459 | + | assert!(matches!(resolve_lww(&local_a, &remote_b), Resolution::KeepRemote)); | |
| 437 | 460 | ||
| 438 | - | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 461 | + | // Device B: local is b's change, remote is a's change. | |
| 462 | + | let mut local_b = make_entry("tasks", "r1", ChangeOp::Update, Utc::now()); | |
| 463 | + | local_b.hlc = hlc_b; | |
| 464 | + | let remote_a = pulled_with_hlc("r1", ChangeOp::Update, hlc_a, a); | |
| 465 | + | // b > a, so B keeps its local (b's change). Both devices converge on b. | |
| 466 | + | assert!(matches!(resolve_lww(&local_b, &remote_a), Resolution::KeepLocal)); | |
| 439 | 467 | } | |
| 440 | 468 | ||
| 441 | 469 | #[test] | |
| 442 | - | fn lww_remote_delete_wins_over_update() { | |
| 443 | - | let other_device = Uuid::new_v4(); | |
| 444 | - | let now = Utc::now(); | |
| 445 | - | ||
| 446 | - | let local = make_entry("tasks", "r1", ChangeOp::Update, now); | |
| 447 | - | let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1); | |
| 448 | - | ||
| 449 | - | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); | |
| 470 | + | fn lww_newer_update_beats_older_delete() { | |
| 471 | + | // The data-loss fix: a strictly-newer UPDATE must beat an older DELETE. | |
| 472 | + | // Under the old "delete always wins" rule this edit was silently lost. | |
| 473 | + | let other = Uuid::new_v4(); | |
| 474 | + | let mut local = make_entry("tasks", "r1", ChangeOp::Update, Utc::now()); | |
| 475 | + | local.hlc = Hlc { wall_ms: 2000, counter: 0, node: local_node() }; | |
| 476 | + | let remote = pulled_with_hlc("r1", ChangeOp::Delete, Hlc { wall_ms: 1000, counter: 0, node: other }, other); | |
| 477 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 450 | 478 | } | |
| 451 | 479 | ||
| 452 | 480 | #[test] | |
| 453 | - | fn lww_both_delete_local_wins() { | |
| 454 | - | let other_device = Uuid::new_v4(); | |
| 455 | - | let now = Utc::now(); | |
| 456 | - | ||
| 457 | - | let local = make_entry("tasks", "r1", ChangeOp::Delete, now); | |
| 458 | - | let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1); | |
| 459 | - | ||
| 460 | - | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 481 | + | fn lww_newer_delete_beats_older_update() { | |
| 482 | + | // Symmetric: a strictly-newer DELETE beats an older UPDATE. | |
| 483 | + | let other = Uuid::new_v4(); | |
| 484 | + | let mut local = make_entry("tasks", "r1", ChangeOp::Update, Utc::now()); | |
| 485 | + | local.hlc = Hlc { wall_ms: 1000, counter: 0, node: local_node() }; | |
| 486 | + | let remote = pulled_with_hlc("r1", ChangeOp::Delete, Hlc { wall_ms: 2000, counter: 0, node: other }, other); | |
| 487 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); | |
| 461 | 488 | } | |
| 462 | 489 | ||
| 463 | 490 | // ── resolve_field_merge ── | |
| @@ -669,12 +696,11 @@ mod tests { | |||
| 669 | 696 | assert_eq!(conflicts[0].local.timestamp, t2); | |
| 670 | 697 | } | |
| 671 | 698 | ||
| 672 | - | // Attack vector 2: DELETE vs DELETE in LWW. | |
| 673 | - | // Both are Delete, so it hits `local.op == ChangeOp::Delete` and returns | |
| 674 | - | // KeepLocal — even if remote Delete has a newer timestamp. This is the | |
| 675 | - | // current behavior. Verify it explicitly. | |
| 699 | + | // DELETE vs DELETE under HLC: the higher clock wins like any other pair. | |
| 700 | + | // (Previously local always won and the timestamp was ignored — now a strictly | |
| 701 | + | // newer remote delete wins.) | |
| 676 | 702 | #[test] | |
| 677 | - | fn lww_both_delete_local_wins_even_when_remote_newer() { | |
| 703 | + | fn lww_both_delete_newer_wins() { | |
| 678 | 704 | let other_device = Uuid::new_v4(); | |
| 679 | 705 | let old = Utc::now() - chrono::Duration::seconds(60); | |
| 680 | 706 | let new = Utc::now(); | |
| @@ -682,11 +708,7 @@ mod tests { | |||
| 682 | 708 | let local = make_entry("tasks", "r1", ChangeOp::Delete, old); | |
| 683 | 709 | let remote = make_pulled("tasks", "r1", ChangeOp::Delete, new, other_device, 1); | |
| 684 | 710 | ||
| 685 | - | // BUG CANDIDATE: remote Delete is newer but local wins because the | |
| 686 | - | // code checks `local.op == Delete` first, returning KeepLocal always. | |
| 687 | - | // For Delete-vs-Delete this is semantically fine (both delete the row), | |
| 688 | - | // but the timestamp is ignored entirely. | |
| 689 | - | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 711 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); | |
| 690 | 712 | } | |
| 691 | 713 | ||
| 692 | 714 | // Attack vector 3: field_merge overlapping fields with equal timestamps. | |
| @@ -840,31 +862,29 @@ mod tests { | |||
| 840 | 862 | } | |
| 841 | 863 | } | |
| 842 | 864 | ||
| 843 | - | // Bonus: INSERT vs DELETE in LWW. Insert is not Delete, so the Delete | |
| 844 | - | // branch fires. If local is Insert and remote is Delete, remote wins. | |
| 845 | - | // This means a remote delete can kill a row that was just created locally. | |
| 865 | + | // INSERT vs DELETE under HLC: operation is irrelevant, the higher clock wins. | |
| 866 | + | // A strictly-newer remote delete beats an older local insert. | |
| 846 | 867 | #[test] | |
| 847 | - | fn lww_remote_delete_beats_local_insert() { | |
| 868 | + | fn lww_newer_remote_delete_beats_older_local_insert() { | |
| 848 | 869 | let other_device = Uuid::new_v4(); | |
| 849 | - | let now = Utc::now(); | |
| 870 | + | let old = Utc::now() - chrono::Duration::seconds(60); | |
| 871 | + | let new = Utc::now(); | |
| 850 | 872 | ||
| 851 | - | let local = make_entry("tasks", "r1", ChangeOp::Insert, now); | |
| 852 | - | let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1); | |
| 873 | + | let local = make_entry("tasks", "r1", ChangeOp::Insert, old); | |
| 874 | + | let remote = make_pulled("tasks", "r1", ChangeOp::Delete, new, other_device, 1); | |
| 853 | 875 | ||
| 854 | - | // Remote Delete wins over local Insert — the local insert is discarded. | |
| 855 | - | // This could be surprising: user creates a row, but a concurrent | |
| 856 | - | // delete from another device kills it even though the insert is "newer". | |
| 857 | 876 | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); | |
| 858 | 877 | } | |
| 859 | 878 | ||
| 860 | - | // Bonus: local delete wins over remote insert. Same logic in reverse. | |
| 879 | + | // Reverse: a strictly-newer local delete beats an older remote insert. | |
| 861 | 880 | #[test] | |
| 862 | - | fn lww_local_delete_beats_remote_insert() { | |
| 881 | + | fn lww_newer_local_delete_beats_older_remote_insert() { | |
| 863 | 882 | let other_device = Uuid::new_v4(); | |
| 864 | - | let now = Utc::now(); | |
| 883 | + | let old = Utc::now() - chrono::Duration::seconds(60); | |
| 884 | + | let new = Utc::now(); | |
| 865 | 885 | ||
| 866 | - | let local = make_entry("tasks", "r1", ChangeOp::Delete, now); | |
| 867 | - | let remote = make_pulled("tasks", "r1", ChangeOp::Insert, now, other_device, 1); | |
| 886 | + | let local = make_entry("tasks", "r1", ChangeOp::Delete, new); | |
| 887 | + | let remote = make_pulled("tasks", "r1", ChangeOp::Insert, old, other_device, 1); | |
| 868 | 888 | ||
| 869 | 889 | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 870 | 890 | } |
| @@ -32,6 +32,7 @@ | |||
| 32 | 32 | //! op: ChangeOp::Insert, | |
| 33 | 33 | //! row_id: uuid::Uuid::new_v4().to_string(), | |
| 34 | 34 | //! timestamp: Utc::now(), | |
| 35 | + | //! hlc: Default::default(), | |
| 35 | 36 | //! data: Some(serde_json::json!({"title": "Buy milk"})), | |
| 36 | 37 | //! }, | |
| 37 | 38 | //! ]).await?; | |
| @@ -61,4 +62,4 @@ pub use conflict::{ | |||
| 61 | 62 | detect_conflicts, resolve_field_merge, resolve_lww, ConflictPair, ConflictResolver, Resolution, | |
| 62 | 63 | }; | |
| 63 | 64 | pub use error::{Result, SyncKitError}; | |
| 64 | - | pub use types::{ChangeEntry, ChangeOp, Device, PullFilter, PulledChange, SyncStatus}; | |
| 65 | + | pub use types::{ChangeEntry, ChangeOp, Device, Hlc, PullFilter, PulledChange, SyncStatus}; |
| @@ -45,6 +45,85 @@ impl ChangeOp { | |||
| 45 | 45 | } | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | + | // ── Hybrid logical clock ── | |
| 49 | + | ||
| 50 | + | /// A hybrid logical clock (HLC) timestamp: a wall-clock millisecond reading kept | |
| 51 | + | /// monotonic by a tiebreak counter, with the originating device as a final | |
| 52 | + | /// tiebreak. HLCs are the ordering used for conflict resolution. | |
| 53 | + | /// | |
| 54 | + | /// Why not a bare wall-clock timestamp: a device with a fast-running clock would | |
| 55 | + | /// otherwise win every last-write-wins conflict, and two edits in the same | |
| 56 | + | /// millisecond would be unordered. The HLC keeps causality (it never goes | |
| 57 | + | /// backwards and advances past anything it has seen) and breaks exact ties | |
| 58 | + | /// deterministically by `node`, so every device resolves a conflict identically. | |
| 59 | + | /// | |
| 60 | + | /// Ordering is lexicographic: `(wall_ms, counter, node)`. The `node` field makes | |
| 61 | + | /// the value globally unique across devices, so resolution is convergent without | |
| 62 | + | /// any side-channel — comparing two complete HLCs yields the same winner on every | |
| 63 | + | /// device. | |
| 64 | + | /// | |
| 65 | + | /// The value travels inside the E2E-encrypted change payload (the SyncKit server | |
| 66 | + | /// never sees or orders by it), so adding it required no server-side change. | |
| 67 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] | |
| 68 | + | pub struct Hlc { | |
| 69 | + | /// Wall-clock component, milliseconds since the Unix epoch. | |
| 70 | + | pub wall_ms: i64, | |
| 71 | + | /// Monotonic tiebreak within a single `wall_ms`, reset to 0 when `wall_ms` advances. | |
| 72 | + | pub counter: u32, | |
| 73 | + | /// The device that minted this HLC. Final, deterministic tiebreak. | |
| 74 | + | pub node: Uuid, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | impl Hlc { | |
| 78 | + | /// The zero HLC for `node`. Used as the initial clock state and as the floor | |
| 79 | + | /// for legacy entries that predate HLC support. | |
| 80 | + | pub fn zero(node: Uuid) -> Self { | |
| 81 | + | Hlc { wall_ms: 0, counter: 0, node } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Synthesize an HLC for a legacy change that carried no embedded clock, | |
| 85 | + | /// deriving the wall component from its `client_timestamp`. This keeps | |
| 86 | + | /// pre-HLC entries orderable against new ones during the transition. | |
| 87 | + | pub fn from_legacy(timestamp_ms: i64, node: Uuid) -> Self { | |
| 88 | + | Hlc { wall_ms: timestamp_ms, counter: 0, node } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Advance the clock for a **local** event happening at `now_ms` on `node`. | |
| 92 | + | /// | |
| 93 | + | /// Standard HLC send rule: if physical time has moved past the last reading, | |
| 94 | + | /// adopt it and reset the counter; otherwise hold the wall component and bump | |
| 95 | + | /// the counter so the new event still sorts strictly after the previous one. | |
| 96 | + | pub fn tick(prev: Hlc, now_ms: i64, node: Uuid) -> Hlc { | |
| 97 | + | if now_ms > prev.wall_ms { | |
| 98 | + | Hlc { wall_ms: now_ms, counter: 0, node } | |
| 99 | + | } else { | |
| 100 | + | Hlc { wall_ms: prev.wall_ms, counter: prev.counter + 1, node } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Advance the clock on **receiving** `remote`, at local physical time | |
| 105 | + | /// `now_ms` on `node`. | |
| 106 | + | /// | |
| 107 | + | /// Standard HLC receive rule: take the max wall component across the local | |
| 108 | + | /// clock, the incoming HLC, and physical time, then derive a counter that is | |
| 109 | + | /// strictly greater than any counter seen at that wall component. This keeps | |
| 110 | + | /// the local clock ahead of everything observed, so subsequent local writes | |
| 111 | + | /// causally follow the remote change. | |
| 112 | + | pub fn observe(prev: Hlc, remote: Hlc, now_ms: i64, node: Uuid) -> Hlc { | |
| 113 | + | let wall = prev.wall_ms.max(remote.wall_ms).max(now_ms); | |
| 114 | + | let counter = if wall == prev.wall_ms && wall == remote.wall_ms { | |
| 115 | + | prev.counter.max(remote.counter) + 1 | |
| 116 | + | } else if wall == prev.wall_ms { | |
| 117 | + | prev.counter + 1 | |
| 118 | + | } else if wall == remote.wall_ms { | |
| 119 | + | remote.counter + 1 | |
| 120 | + | } else { | |
| 121 | + | 0 | |
| 122 | + | }; | |
| 123 | + | Hlc { wall_ms: wall, counter, node } | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 48 | 127 | // ── Auth ── | |
| 49 | 128 | ||
| 50 | 129 | #[derive(Serialize)] | |
| @@ -103,8 +182,16 @@ pub struct ChangeEntry { | |||
| 103 | 182 | pub op: ChangeOp, | |
| 104 | 183 | /// App-assigned row identifier (typically a UUID string). | |
| 105 | 184 | pub row_id: String, | |
| 106 | - | /// When this change was made on the originating device. | |
| 185 | + | /// When this change was made on the originating device. Retained for the | |
| 186 | + | /// server's incremental-pull cursor and for display; conflict ordering uses | |
| 187 | + | /// [`Self::hlc`], not this field. | |
| 107 | 188 | pub timestamp: DateTime<Utc>, | |
| 189 | + | /// Hybrid logical clock for this change — the value conflict resolution | |
| 190 | + | /// orders by. The app mints it from its persistent clock at the time the | |
| 191 | + | /// local change is recorded (see [`Hlc::tick`]). It travels inside the | |
| 192 | + | /// E2E-encrypted payload, so the server never sees it. | |
| 193 | + | #[serde(default)] | |
| 194 | + | pub hlc: Hlc, | |
| 108 | 195 | /// The row payload as JSON. `None` for Delete operations. | |
| 109 | 196 | /// Serialized as absent (not null) when `None`. | |
| 110 | 197 | #[serde(skip_serializing_if = "Option::is_none")] | |
| @@ -440,6 +527,7 @@ mod tests { | |||
| 440 | 527 | op: ChangeOp::Delete, | |
| 441 | 528 | row_id: "r".into(), | |
| 442 | 529 | timestamp: chrono::Utc::now(), | |
| 530 | + | hlc: Default::default(), | |
| 443 | 531 | data: None, | |
| 444 | 532 | }; | |
| 445 | 533 | let json = serde_json::to_string(&entry).unwrap(); | |
| @@ -453,6 +541,7 @@ mod tests { | |||
| 453 | 541 | op: ChangeOp::Insert, | |
| 454 | 542 | row_id: "r".into(), | |
| 455 | 543 | timestamp: chrono::Utc::now(), | |
| 544 | + | hlc: Default::default(), | |
| 456 | 545 | data: Some(json!({"k": "v"})), | |
| 457 | 546 | }; | |
| 458 | 547 | let json = serde_json::to_string(&entry).unwrap(); | |
| @@ -568,4 +657,79 @@ mod tests { | |||
| 568 | 657 | assert_eq!(parsed["tables"].as_array().unwrap().len(), 1); | |
| 569 | 658 | assert!(parsed["since"].is_string()); | |
| 570 | 659 | } | |
| 660 | + | ||
| 661 | + | // ── Hybrid logical clock ── | |
| 662 | + | ||
| 663 | + | #[test] | |
| 664 | + | fn hlc_orders_by_wall_then_counter_then_node() { | |
| 665 | + | let a = Uuid::from_u128(1); | |
| 666 | + | let b = Uuid::from_u128(2); | |
| 667 | + | // wall dominates | |
| 668 | + | assert!(Hlc { wall_ms: 1, counter: 9, node: b } < Hlc { wall_ms: 2, counter: 0, node: a }); | |
| 669 | + | // then counter | |
| 670 | + | assert!(Hlc { wall_ms: 5, counter: 0, node: b } < Hlc { wall_ms: 5, counter: 1, node: a }); | |
| 671 | + | // then node | |
| 672 | + | assert!(Hlc { wall_ms: 5, counter: 1, node: a } < Hlc { wall_ms: 5, counter: 1, node: b }); | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | #[test] | |
| 676 | + | fn hlc_tick_adopts_physical_time_and_resets_counter() { | |
| 677 | + | let node = Uuid::from_u128(1); | |
| 678 | + | let prev = Hlc { wall_ms: 1000, counter: 4, node }; | |
| 679 | + | let next = Hlc::tick(prev, 2000, node); | |
| 680 | + | assert_eq!(next, Hlc { wall_ms: 2000, counter: 0, node }); | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | #[test] | |
| 684 | + | fn hlc_tick_bumps_counter_when_clock_has_not_advanced() { | |
| 685 | + | let node = Uuid::from_u128(1); | |
| 686 | + | // Physical time equal to or behind the last reading: hold wall, bump counter, | |
| 687 | + | // so the new local event still sorts strictly after the previous one. | |
| 688 | + | let prev = Hlc { wall_ms: 1000, counter: 4, node }; | |
| 689 | + | assert_eq!(Hlc::tick(prev, 1000, node), Hlc { wall_ms: 1000, counter: 5, node }); | |
| 690 | + | assert_eq!(Hlc::tick(prev, 500, node), Hlc { wall_ms: 1000, counter: 5, node }); | |
| 691 | + | // Strictly increasing under a stuck clock. | |
| 692 | + | let mut h = Hlc::zero(node); | |
| 693 | + | let mut last = h; | |
| 694 | + | for _ in 0..100 { | |
| 695 | + | h = Hlc::tick(h, 0, node); | |
| 696 | + | assert!(h > last); | |
| 697 | + | last = h; | |
| 698 | + | } | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | #[test] | |
| 702 | + | fn hlc_observe_stays_ahead_of_a_skewed_fast_remote() { | |
| 703 | + | let me = Uuid::from_u128(1); | |
| 704 | + | let them = Uuid::from_u128(2); | |
| 705 | + | // Our physical clock is at 1000; a remote with a fast clock sends 9000. | |
| 706 | + | let local = Hlc { wall_ms: 1000, counter: 0, node: me }; | |
| 707 | + | let remote = Hlc { wall_ms: 9000, counter: 3, node: them }; | |
| 708 | + | let merged = Hlc::observe(local, remote, 1000, me); | |
| 709 | + | // We adopt the remote wall and a strictly-greater counter, so our next | |
| 710 | + | // local write causally follows the remote change despite the skew. | |
| 711 | + | assert_eq!(merged.wall_ms, 9000); | |
| 712 | + | assert!(merged.counter > remote.counter); | |
| 713 | + | assert_eq!(merged.node, me); | |
| 714 | + | let next_local = Hlc::tick(merged, 1001, me); | |
| 715 | + | assert!(next_local > remote, "a later local write must outrank the skewed remote"); | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | #[test] | |
| 719 | + | fn hlc_observe_advances_to_physical_time_when_it_leads() { | |
| 720 | + | let me = Uuid::from_u128(1); | |
| 721 | + | let them = Uuid::from_u128(2); | |
| 722 | + | let local = Hlc { wall_ms: 1000, counter: 2, node: me }; | |
| 723 | + | let remote = Hlc { wall_ms: 1500, counter: 0, node: them }; | |
| 724 | + | // Physical time (3000) leads both: adopt it, counter resets. | |
| 725 | + | let merged = Hlc::observe(local, remote, 3000, me); | |
| 726 | + | assert_eq!(merged, Hlc { wall_ms: 3000, counter: 0, node: me }); | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | #[test] | |
| 730 | + | fn hlc_serde_roundtrip() { | |
| 731 | + | let h = Hlc { wall_ms: 123, counter: 4, node: Uuid::from_u128(7) }; | |
| 732 | + | let j = serde_json::to_value(h).unwrap(); | |
| 733 | + | assert_eq!(serde_json::from_value::<Hlc>(j).unwrap(), h); | |
| 734 | + | } | |
| 571 | 735 | } |
| @@ -256,6 +256,7 @@ async fn push_encrypts_data() { | |||
| 256 | 256 | op: ChangeOp::Insert, | |
| 257 | 257 | row_id: "row-1".into(), | |
| 258 | 258 | timestamp: Utc::now(), | |
| 259 | + | hlc: Default::default(), | |
| 259 | 260 | data: Some(json!({"title": "Secret task"})), | |
| 260 | 261 | }], | |
| 261 | 262 | ) | |
| @@ -1131,6 +1132,7 @@ async fn concurrent_push_operations_no_data_corruption() { | |||
| 1131 | 1132 | op: ChangeOp::Insert, | |
| 1132 | 1133 | row_id: format!("row_{i}"), | |
| 1133 | 1134 | timestamp: Utc::now(), | |
| 1135 | + | hlc: Default::default(), | |
| 1134 | 1136 | data: Some(json!({"index": i})), | |
| 1135 | 1137 | }; | |
| 1136 | 1138 | c.push(device_id, vec![entry]).await | |
| @@ -1211,6 +1213,7 @@ async fn push_many_changes_succeeds() { | |||
| 1211 | 1213 | op: ChangeOp::Insert, | |
| 1212 | 1214 | row_id: format!("row-{i}"), | |
| 1213 | 1215 | timestamp: Utc::now(), | |
| 1216 | + | hlc: Default::default(), | |
| 1214 | 1217 | data: Some(json!({"index": i, "value": format!("data-{i}")})), | |
| 1215 | 1218 | }) | |
| 1216 | 1219 | .collect(); | |
| @@ -1377,6 +1380,7 @@ async fn push_with_data_fails_without_master_key() { | |||
| 1377 | 1380 | op: ChangeOp::Insert, | |
| 1378 | 1381 | row_id: "r1".into(), | |
| 1379 | 1382 | timestamp: Utc::now(), | |
| 1383 | + | hlc: Default::default(), | |
| 1380 | 1384 | data: Some(json!({"title": "test"})), | |
| 1381 | 1385 | }]; | |
| 1382 | 1386 | ||
| @@ -1388,7 +1392,10 @@ async fn push_with_data_fails_without_master_key() { | |||
| 1388 | 1392 | } | |
| 1389 | 1393 | ||
| 1390 | 1394 | #[tokio::test] | |
| 1391 | - | async fn push_delete_without_master_key_succeeds() { | |
| 1395 | + | async fn push_delete_requires_master_key() { | |
| 1396 | + | // Deletes used to push without a key (no payload to encrypt). With HLC, a | |
| 1397 | + | // Delete now seals its clock into an encrypted envelope, so the master key is | |
| 1398 | + | // required for every push, Deletes included. | |
| 1392 | 1399 | let server = MockServer::start().await; | |
| 1393 | 1400 | ||
| 1394 | 1401 | Mock::given(method("POST")) | |
| @@ -1398,18 +1405,22 @@ async fn push_delete_without_master_key_succeeds() { | |||
| 1398 | 1405 | .await; | |
| 1399 | 1406 | ||
| 1400 | 1407 | let client = authed_client(&server); | |
| 1401 | - | // No master key -- but Delete entries have no data to encrypt | |
| 1408 | + | // No master key loaded. | |
| 1402 | 1409 | ||
| 1403 | 1410 | let changes = vec![ChangeEntry { | |
| 1404 | 1411 | table: "tasks".into(), | |
| 1405 | 1412 | op: ChangeOp::Delete, | |
| 1406 | 1413 | row_id: "r1".into(), | |
| 1407 | 1414 | timestamp: Utc::now(), | |
| 1415 | + | hlc: Default::default(), | |
| 1408 | 1416 | data: None, | |
| 1409 | 1417 | }]; | |
| 1410 | 1418 | ||
| 1411 | - | let cursor = client.push(Uuid::new_v4(), changes).await.unwrap(); | |
| 1412 | - | assert_eq!(cursor, 1); | |
| 1419 | + | let err = client.push(Uuid::new_v4(), changes).await.unwrap_err(); | |
| 1420 | + | assert!( | |
| 1421 | + | matches!(err, SyncKitError::NoMasterKey), | |
| 1422 | + | "Delete now seals an HLC envelope and needs the key: {err:?}" | |
| 1423 | + | ); | |
| 1413 | 1424 | } | |
| 1414 | 1425 | ||
| 1415 | 1426 | // ── Blob operations require auth ── | |
| @@ -1474,6 +1485,7 @@ async fn double_push_same_data_both_succeed() { | |||
| 1474 | 1485 | op: ChangeOp::Insert, | |
| 1475 | 1486 | row_id: "same-row".into(), | |
| 1476 | 1487 | timestamp: Utc::now(), | |
| 1488 | + | hlc: Default::default(), | |
| 1477 | 1489 | data: Some(json!({"title": "duplicate push test"})), | |
| 1478 | 1490 | }; | |
| 1479 | 1491 | ||
| @@ -1713,6 +1725,7 @@ async fn encryption_setup_cross_device_roundtrip() { | |||
| 1713 | 1725 | op: ChangeOp::Insert, | |
| 1714 | 1726 | row_id: "cross-r1".into(), | |
| 1715 | 1727 | timestamp: Utc::now(), | |
| 1728 | + | hlc: Default::default(), | |
| 1716 | 1729 | data: Some(original_data.clone()), | |
| 1717 | 1730 | }], | |
| 1718 | 1731 | ) | |
| @@ -1973,6 +1986,7 @@ async fn end_to_end_push_pull_encryption_roundtrip() { | |||
| 1973 | 1986 | op: ChangeOp::Insert, | |
| 1974 | 1987 | row_id: "e2e-row".into(), | |
| 1975 | 1988 | timestamp: Utc::now(), | |
| 1989 | + | hlc: Default::default(), | |
| 1976 | 1990 | data: Some(original_data.clone()), | |
| 1977 | 1991 | }], | |
| 1978 | 1992 | ) | |
| @@ -2649,6 +2663,7 @@ async fn concurrent_push_100_entries_each() { | |||
| 2649 | 2663 | op: ChangeOp::Insert, | |
| 2650 | 2664 | row_id: format!("row_{i}"), | |
| 2651 | 2665 | timestamp: Utc::now(), | |
| 2666 | + | hlc: Default::default(), | |
| 2652 | 2667 | data: Some(json!({"index": i})), | |
| 2653 | 2668 | }) | |
| 2654 | 2669 | .collect(); |