max / makenotwork
9 files changed,
+406 insertions,
-136 deletions
| @@ -187,6 +187,7 @@ impl SyncKitClient { | |||
| 187 | 187 | hlc: Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()), | |
| 188 | 188 | timestamp: entry.timestamp, | |
| 189 | 189 | data: None, | |
| 190 | + | extra: Default::default(), | |
| 190 | 191 | }) | |
| 191 | 192 | } | |
| 192 | 193 | ||
| @@ -341,6 +342,7 @@ impl SyncKitClient { | |||
| 341 | 342 | hlc, | |
| 342 | 343 | timestamp: entry.timestamp, | |
| 343 | 344 | data, | |
| 345 | + | extra: Default::default(), | |
| 344 | 346 | }) | |
| 345 | 347 | } | |
| 346 | 348 | ||
| @@ -365,6 +367,7 @@ impl SyncKitClient { | |||
| 365 | 367 | hlc, | |
| 366 | 368 | timestamp: entry.timestamp, | |
| 367 | 369 | data, | |
| 370 | + | extra: Default::default(), | |
| 368 | 371 | }) | |
| 369 | 372 | } | |
| 370 | 373 | } | |
| @@ -569,6 +572,7 @@ mod tests { | |||
| 569 | 572 | timestamp: Utc::now(), | |
| 570 | 573 | hlc, | |
| 571 | 574 | data: None, | |
| 575 | + | extra: Default::default(), | |
| 572 | 576 | }; | |
| 573 | 577 | ||
| 574 | 578 | let wire = client.encrypt_change(entry).unwrap(); | |
| @@ -602,6 +606,7 @@ mod tests { | |||
| 602 | 606 | timestamp: Utc::now(), | |
| 603 | 607 | hlc: Default::default(), | |
| 604 | 608 | data: Some(serde_json::json!({"title": "test"})), | |
| 609 | + | extra: Default::default(), | |
| 605 | 610 | }; | |
| 606 | 611 | ||
| 607 | 612 | let err = client.encrypt_change(entry).unwrap_err(); | |
| @@ -622,6 +627,7 @@ mod tests { | |||
| 622 | 627 | timestamp: Utc::now(), | |
| 623 | 628 | hlc: Default::default(), | |
| 624 | 629 | data: Some(original_data.clone()), | |
| 630 | + | extra: Default::default(), | |
| 625 | 631 | }; | |
| 626 | 632 | ||
| 627 | 633 | let wire = client.encrypt_change(entry).unwrap(); | |
| @@ -650,6 +656,7 @@ mod tests { | |||
| 650 | 656 | timestamp: ts, | |
| 651 | 657 | hlc: Default::default(), | |
| 652 | 658 | data: Some(original_data.clone()), | |
| 659 | + | extra: Default::default(), | |
| 653 | 660 | }; | |
| 654 | 661 | ||
| 655 | 662 | let wire = client.encrypt_change(entry).unwrap(); | |
| @@ -894,6 +901,7 @@ mod tests { | |||
| 894 | 901 | timestamp: ts, | |
| 895 | 902 | hlc: Default::default(), | |
| 896 | 903 | data: Some(serde_json::json!({"name": "Alice"})), | |
| 904 | + | extra: Default::default(), | |
| 897 | 905 | }; | |
| 898 | 906 | ||
| 899 | 907 | let wire = client.encrypt_change(entry).unwrap(); | |
| @@ -919,6 +927,7 @@ mod tests { | |||
| 919 | 927 | timestamp: Utc::now(), | |
| 920 | 928 | hlc: Default::default(), | |
| 921 | 929 | data: Some(serde_json::json!({"title": "Task 1"})), | |
| 930 | + | extra: Default::default(), | |
| 922 | 931 | }, | |
| 923 | 932 | ChangeEntry { | |
| 924 | 933 | table: "tasks".to_string(), | |
| @@ -927,6 +936,7 @@ mod tests { | |||
| 927 | 936 | timestamp: Utc::now(), | |
| 928 | 937 | hlc: Default::default(), | |
| 929 | 938 | data: Some(serde_json::json!({"title": "Task 2", "done": true})), | |
| 939 | + | extra: Default::default(), | |
| 930 | 940 | }, | |
| 931 | 941 | ChangeEntry { | |
| 932 | 942 | table: "events".to_string(), | |
| @@ -935,6 +945,7 @@ mod tests { | |||
| 935 | 945 | timestamp: Utc::now(), | |
| 936 | 946 | hlc: Default::default(), | |
| 937 | 947 | data: None, | |
| 948 | + | extra: Default::default(), | |
| 938 | 949 | }, | |
| 939 | 950 | ]; | |
| 940 | 951 | ||
| @@ -983,6 +994,7 @@ mod tests { | |||
| 983 | 994 | timestamp: Utc::now(), | |
| 984 | 995 | hlc: Default::default(), | |
| 985 | 996 | data: Some(serde_json::json!({"name": "\u{30C6}\u{30B9}\u{30C8}"})), | |
| 997 | + | extra: Default::default(), | |
| 986 | 998 | }; | |
| 987 | 999 | ||
| 988 | 1000 | let wire = client.encrypt_change(entry).unwrap(); | |
| @@ -1016,6 +1028,7 @@ mod tests { | |||
| 1016 | 1028 | timestamp: Utc::now(), | |
| 1017 | 1029 | hlc: Default::default(), | |
| 1018 | 1030 | data: Some(serde_json::json!(42)), | |
| 1031 | + | extra: Default::default(), | |
| 1019 | 1032 | }; | |
| 1020 | 1033 | ||
| 1021 | 1034 | let wire = client.encrypt_change(entry).unwrap(); |
| @@ -48,6 +48,10 @@ impl SyncKitClient { | |||
| 48 | 48 | // promote on completion, so re-encryption uses the same key it commits. | |
| 49 | 49 | let (new_master_key, new_envelope) = | |
| 50 | 50 | Self::rotation_key_material(key_state.pending_key, password)?; | |
| 51 | + | // Wrap immediately so the new key is zeroized on every exit path — an | |
| 52 | + | // error in the re-encrypt or complete loops below would otherwise drop | |
| 53 | + | // the bare array without scrubbing it. | |
| 54 | + | let new_master_key = crypto::ZeroizeOnDrop(new_master_key); | |
| 51 | 55 | ||
| 52 | 56 | // 3. Begin (or resume) rotation. `begin_key_rotation` is idempotent for the | |
| 53 | 57 | // same device: it returns the existing rotation, ignoring `new_envelope` | |
| @@ -101,7 +105,7 @@ impl SyncKitClient { | |||
| 101 | 105 | // 6. Update local state | |
| 102 | 106 | let (app_id, user_id) = self.require_session_ids()?; | |
| 103 | 107 | crate::keystore::store_key(app_id, user_id, &new_master_key)?; | |
| 104 | - | *self.master_key.write() = Some(crypto::ZeroizeOnDrop(new_master_key)); | |
| 108 | + | *self.master_key.write() = Some(new_master_key); | |
| 105 | 109 | *self.master_key_id.write() = new_key_id; | |
| 106 | 110 | *self.pending_key.write() = None; | |
| 107 | 111 |
| @@ -281,6 +281,7 @@ mod tests { | |||
| 281 | 281 | timestamp: Utc::now(), | |
| 282 | 282 | hlc: Default::default(), | |
| 283 | 283 | data: Some(serde_json::json!({"title": "Test task", "done": false})), | |
| 284 | + | extra: Default::default(), | |
| 284 | 285 | }; | |
| 285 | 286 | ||
| 286 | 287 | let json = serde_json::to_string(&entry).unwrap(); | |
| @@ -301,6 +302,7 @@ mod tests { | |||
| 301 | 302 | timestamp: Utc::now(), | |
| 302 | 303 | hlc: Default::default(), | |
| 303 | 304 | data: None, | |
| 305 | + | extra: Default::default(), | |
| 304 | 306 | }; | |
| 305 | 307 | ||
| 306 | 308 | let json = serde_json::to_string(&entry).unwrap(); |
| @@ -52,9 +52,21 @@ impl CleanChanges { | |||
| 52 | 52 | /// when its HLC is older than or equal to the committed clock; everything | |
| 53 | 53 | /// else is returned in pull order, ready to apply. | |
| 54 | 54 | pub fn gated(self, committed_hlc: impl Fn(&str, &str) -> Option<Hlc>) -> Vec<ChangeEntry> { | |
| 55 | + | let now = Utc::now(); | |
| 55 | 56 | self.0 | |
| 56 | 57 | .into_iter() | |
| 57 | 58 | .filter(|p| { | |
| 59 | + | // A clean change is applied without passing through `resolve_lww`, | |
| 60 | + | // so the clock-poisoning guard must also live here: never apply an | |
| 61 | + | // entry whose wall clock is implausibly far in the future. | |
| 62 | + | if is_clock_poisoned(&p.entry.hlc, now) { | |
| 63 | + | tracing::warn!( | |
| 64 | + | table = %p.entry.table, | |
| 65 | + | wall_ms = p.entry.hlc.wall_ms, | |
| 66 | + | "dropping clean change with implausibly-future HLC" | |
| 67 | + | ); | |
| 68 | + | return false; | |
| 69 | + | } | |
| 58 | 70 | committed_hlc(&p.entry.table, &p.entry.row_id) | |
| 59 | 71 | .is_none_or(|committed| p.entry.hlc > committed) | |
| 60 | 72 | }) | |
| @@ -190,6 +202,59 @@ pub fn detect_conflicts( | |||
| 190 | 202 | /// bytes — a deterministic order both devices compute identically — rather than | |
| 191 | 203 | /// unconditionally keeping local (which would let A keep `a` while B keeps `b`). | |
| 192 | 204 | pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution { | |
| 205 | + | resolve_lww_at(local, remote, Utc::now()) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// Maximum forward wall-clock drift tolerated before an HLC is judged | |
| 209 | + | /// clock-poisoned. An entry whose wall component is beyond `now + MAX_HLC_DRIFT_MS` | |
| 210 | + | /// carries an implausible future timestamp — from a misconfigured clock or a | |
| 211 | + | /// hostile peer holding the shared key — and must not be allowed to win LWW | |
| 212 | + | /// against an honest entry. Set generously (5 min) so honest inter-device skew is | |
| 213 | + | /// never flagged. | |
| 214 | + | pub const MAX_HLC_DRIFT_MS: i64 = 5 * 60 * 1000; | |
| 215 | + | ||
| 216 | + | /// Whether `hlc`'s wall clock is implausibly far in the future relative to `now` | |
| 217 | + | /// — the clock-poisoning signal. A bare wall clock, and even an unclamped HLC, | |
| 218 | + | /// lets such an entry win *every* conflict until real time catches up (years, | |
| 219 | + | /// for a hostile timestamp); this predicate is how [`resolve_lww_at`] refuses to | |
| 220 | + | /// let it. Exposed so an app driving its own apply loop can quarantine the same | |
| 221 | + | /// entries at ingest. | |
| 222 | + | pub fn is_clock_poisoned(hlc: &Hlc, now: DateTime<Utc>) -> bool { | |
| 223 | + | hlc.wall_ms > now.timestamp_millis().saturating_add(MAX_HLC_DRIFT_MS) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// [`resolve_lww`] with an explicit `now`, so the poisoning guard is | |
| 227 | + | /// deterministic and testable. | |
| 228 | + | /// | |
| 229 | + | /// A poisoned entry (see [`is_clock_poisoned`]) *loses* to a non-poisoned one | |
| 230 | + | /// regardless of raw HLC order — this is what stops a far-future clock from | |
| 231 | + | /// winning forever. Merely capping the future value would not: capped at | |
| 232 | + | /// `now + drift` it would perpetually track "now" and keep winning by the drift | |
| 233 | + | /// margin. When both sides are poisoned, or neither is, the normal HLC order | |
| 234 | + | /// applies. Two honest devices agree except in the narrow window where an entry | |
| 235 | + | /// straddles the drift threshold by less than their clock skew — vastly better | |
| 236 | + | /// than the unbounded, permanent domination an unguarded compare allows. | |
| 237 | + | pub fn resolve_lww_at(local: &ChangeEntry, remote: &PulledChange, now: DateTime<Utc>) -> Resolution { | |
| 238 | + | match ( | |
| 239 | + | is_clock_poisoned(&local.hlc, now), | |
| 240 | + | is_clock_poisoned(&remote.entry.hlc, now), | |
| 241 | + | ) { | |
| 242 | + | (false, true) => { | |
| 243 | + | tracing::warn!( | |
| 244 | + | remote_wall_ms = remote.entry.hlc.wall_ms, | |
| 245 | + | "remote HLC wall-clock is implausibly far in the future; rejecting it in LWW" | |
| 246 | + | ); | |
| 247 | + | return Resolution::KeepLocal; | |
| 248 | + | } | |
| 249 | + | (true, false) => { | |
| 250 | + | tracing::warn!( | |
| 251 | + | local_wall_ms = local.hlc.wall_ms, | |
| 252 | + | "local HLC wall-clock is implausibly far in the future; letting remote win LWW" | |
| 253 | + | ); | |
| 254 | + | return Resolution::KeepRemote; | |
| 255 | + | } | |
| 256 | + | _ => {} | |
| 257 | + | } | |
| 193 | 258 | match local.hlc.cmp(&remote.entry.hlc) { | |
| 194 | 259 | std::cmp::Ordering::Greater => Resolution::KeepLocal, | |
| 195 | 260 | std::cmp::Ordering::Less => Resolution::KeepRemote, | |
| @@ -206,6 +271,13 @@ pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution { | |||
| 206 | 271 | /// Deterministic byte encoding of a row payload for the exact-HLC tiebreak. | |
| 207 | 272 | /// `serde_json`'s default `Map` is sorted, so equal values always serialize to | |
| 208 | 273 | /// equal bytes on every device — the property the tiebreak relies on. | |
| 274 | + | /// | |
| 275 | + | /// INVARIANT: this convergence holds only while `serde_json` keeps insertion | |
| 276 | + | /// order OFF (i.e. the `preserve_order` feature is NOT enabled anywhere in the | |
| 277 | + | /// dependency tree). If it is ever turned on, map-key order becomes | |
| 278 | + | /// insertion-dependent and two devices can compute different tiebreak bytes for | |
| 279 | + | /// equal values — divergence. The `canonical_payload_sorts_map_keys` test pins | |
| 280 | + | /// this (it fails the moment insertion order leaks in). | |
| 209 | 281 | fn canonical_payload(data: &Option<serde_json::Value>) -> Vec<u8> { | |
| 210 | 282 | match data { | |
| 211 | 283 | Some(v) => serde_json::to_vec(v).unwrap_or_default(), | |
| @@ -219,9 +291,11 @@ fn canonical_payload(data: &Option<serde_json::Value>) -> Vec<u8> { | |||
| 219 | 291 | /// each side changed, then merges non-overlapping changes. For overlapping | |
| 220 | 292 | /// fields, the newer timestamp wins. | |
| 221 | 293 | /// | |
| 222 | - | /// Returns `Resolution::KeepRemote` if any input is not a JSON object | |
| 223 | - | /// (including `Value::Null` for a missing base). Callers should fall back | |
| 224 | - | /// to [`resolve_lww`] when the base snapshot is unavailable. | |
| 294 | + | /// If any input is not a JSON object (including `Value::Null` for a missing base | |
| 295 | + | /// snapshot), a field-level merge is impossible, so this falls back to | |
| 296 | + | /// last-writer-wins on the timestamps rather than unconditionally keeping remote | |
| 297 | + | /// — which would silently discard a strictly-newer local edit whenever no base | |
| 298 | + | /// is available (e.g. a first-ever edit). | |
| 225 | 299 | pub fn resolve_field_merge( | |
| 226 | 300 | local: &serde_json::Value, | |
| 227 | 301 | remote: &serde_json::Value, | |
| @@ -234,7 +308,13 @@ pub fn resolve_field_merge( | |||
| 234 | 308 | remote.as_object(), | |
| 235 | 309 | base.as_object(), | |
| 236 | 310 | ) else { | |
| 237 | - | return Resolution::KeepRemote; | |
| 311 | + | // No usable base: last-writer-wins on timestamp, keeping local when it is | |
| 312 | + | // strictly newer instead of dropping it. | |
| 313 | + | return if local_ts > remote_ts { | |
| 314 | + | Resolution::KeepLocal | |
| 315 | + | } else { | |
| 316 | + | Resolution::KeepRemote | |
| 317 | + | }; | |
| 238 | 318 | }; | |
| 239 | 319 | ||
| 240 | 320 | // Compute diffs: keys where local/remote differ from base | |
| @@ -338,6 +418,7 @@ mod tests { | |||
| 338 | 418 | timestamp: ts, | |
| 339 | 419 | hlc: Hlc::from_legacy(ts.timestamp_millis(), local_node()), | |
| 340 | 420 | data: Some(json!({"value": "test"})), | |
| 421 | + | extra: Default::default(), | |
| 341 | 422 | } | |
| 342 | 423 | } | |
| 343 | 424 | ||
| @@ -1099,4 +1180,60 @@ mod tests { | |||
| 1099 | 1180 | _ => panic!("Expected Merged"), | |
| 1100 | 1181 | } | |
| 1101 | 1182 | } | |
| 1183 | + | ||
| 1184 | + | // 50 years in milliseconds, far beyond MAX_HLC_DRIFT_MS. | |
| 1185 | + | const FIFTY_YEARS_MS: i64 = 50i64 * 365 * 24 * 3600 * 1000; | |
| 1186 | + | ||
| 1187 | + | #[test] | |
| 1188 | + | fn lww_rejects_poisoned_remote_for_honest_local() { | |
| 1189 | + | let now = Utc::now(); | |
| 1190 | + | let now_ms = now.timestamp_millis(); | |
| 1191 | + | let mut local = make_entry("tasks", "r1", ChangeOp::Update, now); | |
| 1192 | + | local.hlc = Hlc { wall_ms: now_ms, counter: 0, node: local_node() }; | |
| 1193 | + | let mut pulled = make_pulled("tasks", "r1", ChangeOp::Update, now, Uuid::new_v4(), 1); | |
| 1194 | + | // Unguarded, a wall clock 50 years ahead would win every conflict for decades. | |
| 1195 | + | pulled.entry.hlc = Hlc { wall_ms: now_ms + FIFTY_YEARS_MS, counter: 0, node: Uuid::new_v4() }; | |
| 1196 | + | assert!(is_clock_poisoned(&pulled.entry.hlc, now)); | |
| 1197 | + | assert!(matches!(resolve_lww_at(&local, &pulled, now), Resolution::KeepLocal)); | |
| 1198 | + | } | |
| 1199 | + | ||
| 1200 | + | #[test] | |
| 1201 | + | fn lww_rejects_poisoned_local_for_honest_remote() { | |
| 1202 | + | let now = Utc::now(); | |
| 1203 | + | let now_ms = now.timestamp_millis(); | |
| 1204 | + | let mut local = make_entry("tasks", "r1", ChangeOp::Update, now); | |
| 1205 | + | local.hlc = Hlc { wall_ms: now_ms + FIFTY_YEARS_MS, counter: 0, node: local_node() }; | |
| 1206 | + | let mut pulled = make_pulled("tasks", "r1", ChangeOp::Update, now, Uuid::new_v4(), 1); | |
| 1207 | + | pulled.entry.hlc = Hlc { wall_ms: now_ms, counter: 0, node: Uuid::new_v4() }; | |
| 1208 | + | assert!(matches!(resolve_lww_at(&local, &pulled, now), Resolution::KeepRemote)); | |
| 1209 | + | } | |
| 1210 | + | ||
| 1211 | + | #[test] | |
| 1212 | + | fn field_merge_null_base_falls_back_to_lww_not_keep_remote() { | |
| 1213 | + | let local = json!({"a": 1}); | |
| 1214 | + | let remote = json!({"a": 2}); | |
| 1215 | + | let base = serde_json::Value::Null; | |
| 1216 | + | let local_ts = Utc::now(); | |
| 1217 | + | // Local strictly newer: must be kept, not silently discarded (the old bug). | |
| 1218 | + | let remote_older = local_ts - chrono::Duration::seconds(10); | |
| 1219 | + | assert!(matches!( | |
| 1220 | + | resolve_field_merge(&local, &remote, &base, local_ts, remote_older), | |
| 1221 | + | Resolution::KeepLocal | |
| 1222 | + | )); | |
| 1223 | + | // Remote newer: remote wins. | |
| 1224 | + | let remote_newer = local_ts + chrono::Duration::seconds(10); | |
| 1225 | + | assert!(matches!( | |
| 1226 | + | resolve_field_merge(&local, &remote, &base, local_ts, remote_newer), | |
| 1227 | + | Resolution::KeepRemote | |
| 1228 | + | )); | |
| 1229 | + | } | |
| 1230 | + | ||
| 1231 | + | #[test] | |
| 1232 | + | fn canonical_payload_sorts_map_keys() { | |
| 1233 | + | // Pins the exact-HLC tiebreak's convergence invariant: serde_json must | |
| 1234 | + | // serialize object keys in sorted order. Fails loudly if `preserve_order` | |
| 1235 | + | // is ever enabled anywhere in the dependency tree. | |
| 1236 | + | let bytes = canonical_payload(&Some(json!({"b": 1, "a": 2}))); | |
| 1237 | + | assert_eq!(bytes, br#"{"a":2,"b":1}"#.to_vec()); | |
| 1238 | + | } | |
| 1102 | 1239 | } |
| @@ -41,6 +41,15 @@ const ARGON2_MEM_COST_KB: u32 = 65_536; // 64 MB | |||
| 41 | 41 | const ARGON2_TIME_COST: u32 = 3; | |
| 42 | 42 | const ARGON2_PARALLELISM: u32 = 1; | |
| 43 | 43 | ||
| 44 | + | // Accepted range for Argon2 parameters read from an (untrusted, server-supplied) | |
| 45 | + | // envelope. The floor rejects a maliciously weakened envelope (KDF downgrade); the | |
| 46 | + | // ceiling rejects an inflated one whose allocation would OOM every syncing device. | |
| 47 | + | // The pinned wrap constants above sit inside this range, so real envelopes unwrap. | |
| 48 | + | const ARGON2_MEM_MIN_KB: u32 = 8 * 1024; // 8 MiB | |
| 49 | + | const ARGON2_MEM_MAX_KB: u32 = 1024 * 1024; // 1 GiB | |
| 50 | + | const ARGON2_TIME_MAX: u32 = 16; | |
| 51 | + | const ARGON2_PARALLELISM_MAX: u32 = 16; | |
| 52 | + | ||
| 44 | 53 | fn default_argon_mem() -> u32 { ARGON2_MEM_COST_KB } | |
| 45 | 54 | fn default_argon_time() -> u32 { ARGON2_TIME_COST } | |
| 46 | 55 | fn default_argon_par() -> u32 { ARGON2_PARALLELISM } | |
| @@ -129,6 +138,18 @@ fn derive_wrapping_key_with_params( | |||
| 129 | 138 | time_cost: u32, | |
| 130 | 139 | parallelism: u32, | |
| 131 | 140 | ) -> Result<ZeroizeOnDrop> { | |
| 141 | + | // Reject out-of-range parameters before the (expensive) allocation. An | |
| 142 | + | // envelope's params are attacker-reachable; a clamp is pointless (wrong | |
| 143 | + | // params derive a wrong key that fails the tag anyway), so fail closed. | |
| 144 | + | if !(ARGON2_MEM_MIN_KB..=ARGON2_MEM_MAX_KB).contains(&mem_kb) | |
| 145 | + | || !(1..=ARGON2_TIME_MAX).contains(&time_cost) | |
| 146 | + | || !(1..=ARGON2_PARALLELISM_MAX).contains(¶llelism) | |
| 147 | + | { | |
| 148 | + | return Err(SyncKitError::InvalidEnvelope( | |
| 149 | + | "Argon2 parameters outside the accepted range".into(), | |
| 150 | + | )); | |
| 151 | + | } | |
| 152 | + | ||
| 132 | 153 | let normalized = normalize_password(password)?; | |
| 133 | 154 | ||
| 134 | 155 | let params = Params::new(mem_kb, time_cost, parallelism, Some(KEY_SIZE)) | |
| @@ -301,10 +322,10 @@ impl<'a> AeadContext<'a> { | |||
| 301 | 322 | /// The associated-data bytes for this context. A server that relocates a | |
| 302 | 323 | /// ciphertext changes its context, hence its AAD, so decryption fails | |
| 303 | 324 | /// closed — the AEAD tag no longer authenticates the moved bytes. | |
| 304 | - | fn aad(&self) -> Vec<u8> { | |
| 325 | + | fn aad(&self) -> Result<Vec<u8>> { | |
| 305 | 326 | match self { | |
| 306 | 327 | AeadContext::Entry { table, row_id } => aad_for_entry(table, row_id), | |
| 307 | - | AeadContext::Blob { hash } => hash.as_bytes().to_vec(), | |
| 328 | + | AeadContext::Blob { hash } => Ok(hash.as_bytes().to_vec()), | |
| 308 | 329 | } | |
| 309 | 330 | } | |
| 310 | 331 | } | |
| @@ -312,16 +333,23 @@ impl<'a> AeadContext<'a> { | |||
| 312 | 333 | /// Canonical associated-data encoding binding an entry's ciphertext to its | |
| 313 | 334 | /// `(table, row_id)` address. | |
| 314 | 335 | /// | |
| 315 | - | /// The unit separator (`0x1f`) cannot appear in the encoded form of either | |
| 316 | - | /// component's length boundary ambiguously, so `("a", "bc")` and `("ab", "c")` | |
| 317 | - | /// produce distinct AAD. Private: callers reach it only through | |
| 318 | - | /// [`AeadContext::Entry`], which is the point of the seal. | |
| 319 | - | fn aad_for_entry(table: &str, row_id: &str) -> Vec<u8> { | |
| 336 | + | /// The encoding is `table || 0x1f || row_id`. This is injective as long as | |
| 337 | + | /// neither field contains the `0x1f` unit separator, so we reject any field that | |
| 338 | + | /// does — making `("a", "bc")` and `("ab", "c")` provably distinct rather than | |
| 339 | + | /// distinct-by-assumption. Rejecting (vs length-prefixing) keeps the wire format | |
| 340 | + | /// unchanged, so existing ciphertext still decrypts. Private: callers reach it | |
| 341 | + | /// only through [`AeadContext::Entry`], which is the point of the seal. | |
| 342 | + | fn aad_for_entry(table: &str, row_id: &str) -> Result<Vec<u8>> { | |
| 343 | + | if table.as_bytes().contains(&0x1f) || row_id.as_bytes().contains(&0x1f) { | |
| 344 | + | return Err(SyncKitError::Crypto( | |
| 345 | + | "table or row_id contains the 0x1f AAD separator".into(), | |
| 346 | + | )); | |
| 347 | + | } | |
| 320 | 348 | let mut aad = Vec::with_capacity(table.len() + row_id.len() + 1); | |
| 321 | 349 | aad.extend_from_slice(table.as_bytes()); | |
| 322 | 350 | aad.push(0x1f); | |
| 323 | 351 | aad.extend_from_slice(row_id.as_bytes()); | |
| 324 | - | aad | |
| 352 | + | Ok(aad) | |
| 325 | 353 | } | |
| 326 | 354 | ||
| 327 | 355 | /// AEAD seal: returns `nonce[24] || ciphertext || poly1305_tag[16]`. | |
| @@ -375,7 +403,7 @@ pub fn encrypt_data_aad( | |||
| 375 | 403 | master_key: &[u8; KEY_SIZE], | |
| 376 | 404 | ctx: &AeadContext, | |
| 377 | 405 | ) -> Result<String> { | |
| 378 | - | Ok(format!("{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, &ctx.aad())?))) | |
| 406 | + | Ok(format!("{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, &ctx.aad()?)?))) | |
| 379 | 407 | } | |
| 380 | 408 | ||
| 381 | 409 | /// Decrypt a data entry, auto-detecting the wire version. A `sk2:`-tagged | |
| @@ -387,7 +415,7 @@ pub fn decrypt_data_aad( | |||
| 387 | 415 | ctx: &AeadContext, | |
| 388 | 416 | ) -> Result<Vec<u8>> { | |
| 389 | 417 | match encoded.strip_prefix(WIRE_V2_TAG) { | |
| 390 | - | Some(rest) => open(&B64.decode(rest)?, master_key, &ctx.aad()), | |
| 418 | + | Some(rest) => open(&B64.decode(rest)?, master_key, &ctx.aad()?), | |
| 391 | 419 | None => open(&B64.decode(encoded)?, master_key, &[]), | |
| 392 | 420 | } | |
| 393 | 421 | } | |
| @@ -410,7 +438,7 @@ pub fn encrypt_bytes_aad( | |||
| 410 | 438 | master_key: &[u8; KEY_SIZE], | |
| 411 | 439 | ctx: &AeadContext, | |
| 412 | 440 | ) -> Result<Vec<u8>> { | |
| 413 | - | let sealed = seal(plaintext, master_key, &ctx.aad())?; | |
| 441 | + | let sealed = seal(plaintext, master_key, &ctx.aad()?)?; | |
| 414 | 442 | let mut out = Vec::with_capacity(WIRE_V2_TAG_BYTES.len() + sealed.len()); | |
| 415 | 443 | out.extend_from_slice(WIRE_V2_TAG_BYTES); | |
| 416 | 444 | out.extend_from_slice(&sealed); | |
| @@ -425,7 +453,7 @@ pub fn decrypt_bytes_aad( | |||
| 425 | 453 | ctx: &AeadContext, | |
| 426 | 454 | ) -> Result<Vec<u8>> { | |
| 427 | 455 | match encrypted.strip_prefix(WIRE_V2_TAG_BYTES) { | |
| 428 | - | Some(rest) => open(rest, master_key, &ctx.aad()), | |
| 456 | + | Some(rest) => open(rest, master_key, &ctx.aad()?), | |
| 429 | 457 | None => open(encrypted, master_key, &[]), | |
| 430 | 458 | } | |
| 431 | 459 | } | |
| @@ -583,6 +611,15 @@ pub fn decrypt_blob_chunked( | |||
| 583 | 611 | hash: &str, | |
| 584 | 612 | ) -> Result<Vec<u8>> { | |
| 585 | 613 | let (header, consumed) = parse_blob_header(encrypted)?; | |
| 614 | + | // `total_len` is an attacker-controlled u64 from the wire header. Plaintext can | |
| 615 | + | // never exceed its own ciphertext, so reject any claim larger than the input | |
| 616 | + | // before allocating — otherwise a header of `u64::MAX` aborts on the capacity | |
| 617 | + | // request (a trivial DoS). | |
| 618 | + | if header.total_len > encrypted.len() { | |
| 619 | + | return Err(SyncKitError::Crypto( | |
| 620 | + | "v3 blob total_len exceeds encrypted input".into(), | |
| 621 | + | )); | |
| 622 | + | } | |
| 586 | 623 | let mut rest = &encrypted[consumed..]; | |
| 587 | 624 | let mut out = Vec::with_capacity(header.total_len); | |
| 588 | 625 | for i in 0..header.chunk_count { | |
| @@ -1371,8 +1408,59 @@ mod tests { | |||
| 1371 | 1408 | fn aad_for_entry_is_injective_across_boundary() { | |
| 1372 | 1409 | // ("a","bc") and ("ab","c") must not collide, or a server could swap | |
| 1373 | 1410 | // table/row_id halves and keep the AAD constant. | |
| 1374 | - | assert_ne!(aad_for_entry("a", "bc"), aad_for_entry("ab", "c")); | |
| 1375 | - | assert_eq!(aad_for_entry("tasks", "r1"), aad_for_entry("tasks", "r1")); | |
| 1411 | + | assert_ne!( | |
| 1412 | + | aad_for_entry("a", "bc").unwrap(), | |
| 1413 | + | aad_for_entry("ab", "c").unwrap() | |
| 1414 | + | ); | |
| 1415 | + | assert_eq!( | |
| 1416 | + | aad_for_entry("tasks", "r1").unwrap(), | |
| 1417 | + | aad_for_entry("tasks", "r1").unwrap() | |
| 1418 | + | ); | |
| 1419 | + | } | |
| 1420 | + | ||
| 1421 | + | #[test] | |
| 1422 | + | fn aad_for_entry_rejects_separator_byte() { | |
| 1423 | + | // A field carrying the 0x1f separator would break injectivity, so it is | |
| 1424 | + | // rejected rather than silently encoded. | |
| 1425 | + | assert!(aad_for_entry("ta\u{1f}sks", "r1").is_err()); | |
| 1426 | + | assert!(aad_for_entry("tasks", "r\u{1f}1").is_err()); | |
| 1427 | + | assert!(aad_for_entry("tasks", "r1").is_ok()); | |
| 1428 | + | } | |
| 1429 | + | ||
| 1430 | + | #[test] | |
| 1431 | + | fn argon2_params_out_of_range_are_rejected() { | |
| 1432 | + | let salt = [7u8; 32]; | |
| 1433 | + | // Inflated memory (OOM DoS from a hostile envelope) rejected before allocation. | |
| 1434 | + | assert!(derive_wrapping_key_with_params("pw", &salt, 4_000_000, 3, 1).is_err()); | |
| 1435 | + | // Weakened memory (KDF downgrade) rejected. | |
| 1436 | + | assert!(derive_wrapping_key_with_params("pw", &salt, 8, 3, 1).is_err()); | |
| 1437 | + | // Zero time / parallelism rejected. | |
| 1438 | + | assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 0, 1).is_err()); | |
| 1439 | + | assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 3, 0).is_err()); | |
| 1440 | + | // The pinned production parameters are inside the accepted range. | |
| 1441 | + | assert!( | |
| 1442 | + | derive_wrapping_key_with_params( | |
| 1443 | + | "pw", | |
| 1444 | + | &salt, | |
| 1445 | + | ARGON2_MEM_COST_KB, | |
| 1446 | + | ARGON2_TIME_COST, | |
| 1447 | + | ARGON2_PARALLELISM | |
| 1448 | + | ) | |
| 1449 | + | .is_ok() | |
| 1450 | + | ); | |
| 1451 | + | } | |
| 1452 | + | ||
| 1453 | + | #[test] | |
| 1454 | + | fn blob_total_len_exceeding_input_is_rejected_not_allocated() { | |
| 1455 | + | let key = generate_master_key(); | |
| 1456 | + | // A v3 header claiming u64::MAX plaintext with no chunk bytes must be | |
| 1457 | + | // rejected on the length bound, not attempt an astronomical allocation. | |
| 1458 | + | let mut buf = Vec::new(); | |
| 1459 | + | buf.extend_from_slice(WIRE_V3_TAG_BYTES); | |
| 1460 | + | buf.push(3); | |
| 1461 | + | buf.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes()); | |
| 1462 | + | buf.extend_from_slice(&u64::MAX.to_le_bytes()); | |
| 1463 | + | assert!(decrypt_blob_chunked(&buf, &key, "hash").is_err()); | |
| 1376 | 1464 | } | |
| 1377 | 1465 | ||
| 1378 | 1466 | #[test] |
| @@ -66,9 +66,13 @@ pub fn load_key(app_id: AppId, user_id: UserId) -> Result<Option<[u8; 32]>> { | |||
| 66 | 66 | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; | |
| 67 | 67 | ||
| 68 | 68 | match entry.get_password() { | |
| 69 | - | Ok(encoded) => { | |
| 69 | + | Ok(mut encoded) => { | |
| 70 | 70 | use zeroize::Zeroize; | |
| 71 | - | let mut bytes = B64.decode(&encoded)?; | |
| 71 | + | // `encoded` is base64 of the raw master key — zeroize it too, not just | |
| 72 | + | // the decoded bytes, so no plaintext key encoding lingers on the heap. | |
| 73 | + | let decoded = B64.decode(&encoded); | |
| 74 | + | encoded.zeroize(); | |
| 75 | + | let mut bytes = decoded?; | |
| 72 | 76 | if bytes.len() != 32 { | |
| 73 | 77 | bytes.zeroize(); | |
| 74 | 78 | return Err(SyncKitError::Keychain( |
| @@ -34,6 +34,7 @@ | |||
| 34 | 34 | //! timestamp: Utc::now(), | |
| 35 | 35 | //! hlc: Default::default(), | |
| 36 | 36 | //! data: Some(serde_json::json!({"title": "Buy milk"})), | |
| 37 | + | //! extra: Default::default(), | |
| 37 | 38 | //! }, | |
| 38 | 39 | //! ]).await?; | |
| 39 | 40 | //! |
| @@ -52,11 +52,15 @@ impl ChangeOp { | |||
| 52 | 52 | /// monotonic by a tiebreak counter, with the originating device as a final | |
| 53 | 53 | /// tiebreak. HLCs are the ordering used for conflict resolution. | |
| 54 | 54 | /// | |
| 55 | - | /// Why not a bare wall-clock timestamp: a device with a fast-running clock would | |
| 56 | - | /// otherwise win every last-write-wins conflict, and two edits in the same | |
| 57 | - | /// millisecond would be unordered. The HLC keeps causality (it never goes | |
| 58 | - | /// backwards and advances past anything it has seen) and breaks exact ties | |
| 59 | - | /// deterministically by `node`, so every device resolves a conflict identically. | |
| 55 | + | /// Why not a bare wall-clock timestamp: two edits in the same millisecond would | |
| 56 | + | /// be unordered. The HLC keeps causality (it never goes backwards and advances | |
| 57 | + | /// past anything it has seen) and breaks exact ties deterministically by `node`, | |
| 58 | + | /// so every device resolves a conflict identically. | |
| 59 | + | /// | |
| 60 | + | /// The HLC does NOT, on its own, stop a device with a fast/hostile clock from | |
| 61 | + | /// winning every LWW conflict — the highest `wall_ms` still wins. That is bounded | |
| 62 | + | /// separately, at comparison time, by the forward-drift cap in | |
| 63 | + | /// [`resolve_lww_at`](crate::conflict::resolve_lww_at) (see `MAX_HLC_DRIFT_MS`). | |
| 60 | 64 | /// | |
| 61 | 65 | /// Ordering is lexicographic: `(wall_ms, counter, node)`. The `node` field makes | |
| 62 | 66 | /// the value globally unique across devices, so resolution is convergent without | |
| @@ -210,6 +214,14 @@ pub struct ChangeEntry { | |||
| 210 | 214 | /// Serialized as absent (not null) when `None`. | |
| 211 | 215 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 212 | 216 | pub data: Option<serde_json::Value>, | |
| 217 | + | /// Forward-compat capture of any fields a newer client added to this | |
| 218 | + | /// (decrypted, E2E) payload that this build does not know about. Preserved | |
| 219 | + | /// verbatim through deserialize -> re-serialize so an older client cannot | |
| 220 | + | /// silently drop a resolution-relevant field (e.g. a future tombstone marker) | |
| 221 | + | /// and thereby change the conflict winner. Empty on current-format entries, | |
| 222 | + | /// so it serializes to nothing and the wire format is unchanged. | |
| 223 | + | #[serde(flatten)] | |
| 224 | + | pub extra: serde_json::Map<String, serde_json::Value>, | |
| 213 | 225 | } | |
| 214 | 226 | ||
| 215 | 227 | /// Wire format sent to server (data is already encrypted). | |
| @@ -551,6 +563,7 @@ mod tests { | |||
| 551 | 563 | timestamp: chrono::Utc::now(), | |
| 552 | 564 | hlc: Default::default(), | |
| 553 | 565 | data: None, | |
| 566 | + | extra: Default::default(), | |
| 554 | 567 | }; | |
| 555 | 568 | let json = serde_json::to_string(&entry).unwrap(); | |
| 556 | 569 | assert!(!json.contains("\"data\""), "None data should be omitted: {json}"); | |
| @@ -565,26 +578,35 @@ mod tests { | |||
| 565 | 578 | timestamp: chrono::Utc::now(), | |
| 566 | 579 | hlc: Default::default(), | |
| 567 | 580 | data: Some(json!({"k": "v"})), | |
| 581 | + | extra: Default::default(), | |
| 568 | 582 | }; | |
| 569 | 583 | let json = serde_json::to_string(&entry).unwrap(); | |
| 570 | 584 | assert!(json.contains("\"data\""), "Some data should be present: {json}"); | |
| 571 | 585 | } | |
| 572 | 586 | ||
| 573 | 587 | #[test] | |
| 574 | - | fn change_entry_deserialization_ignores_extra_fields() { | |
| 588 | + | fn change_entry_preserves_unknown_fields_across_roundtrip() { | |
| 575 | 589 | let json = r#"{ | |
| 576 | 590 | "table": "tasks", | |
| 577 | 591 | "op": "INSERT", | |
| 578 | 592 | "row_id": "r1", | |
| 579 | 593 | "timestamp": "2025-01-15T10:00:00Z", | |
| 580 | 594 | "data": {"title": "test"}, | |
| 581 | - | "extra_field": "should be ignored", | |
| 595 | + | "future_marker": "should survive", | |
| 582 | 596 | "another_unknown": 42 | |
| 583 | 597 | }"#; | |
| 584 | 598 | let entry: ChangeEntry = serde_json::from_str(json).unwrap(); | |
| 585 | 599 | assert_eq!(entry.table, "tasks"); | |
| 586 | 600 | assert_eq!(entry.op, ChangeOp::Insert); | |
| 587 | - | assert_eq!(entry.data.unwrap()["title"], "test"); | |
| 601 | + | assert_eq!(entry.data.as_ref().unwrap()["title"], "test"); | |
| 602 | + | // A newer client's fields are captured, not dropped... | |
| 603 | + | assert_eq!(entry.extra["future_marker"], "should survive"); | |
| 604 | + | assert_eq!(entry.extra["another_unknown"], 42); | |
| 605 | + | // ...and round-trip back out, so an older client re-serializing this | |
| 606 | + | // entry cannot silently discard a resolution-relevant field. | |
| 607 | + | let reserialized = serde_json::to_value(&entry).unwrap(); | |
| 608 | + | assert_eq!(reserialized["future_marker"], "should survive"); | |
| 609 | + | assert_eq!(reserialized["another_unknown"], 42); | |
| 588 | 610 | } | |
| 589 | 611 | ||
| 590 | 612 | #[test] |
| @@ -29,8 +29,8 @@ fn fake_jwt(exp: i64) -> String { | |||
| 29 | 29 | "exp": exp, | |
| 30 | 30 | "iat": exp - 3600, | |
| 31 | 31 | }); | |
| 32 | - | let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD | |
| 33 | - | .encode(payload.to_string().as_bytes()); | |
| 32 | + | let payload_b64 = | |
| 33 | + | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); | |
| 34 | 34 | let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature"); | |
| 35 | 35 | format!("{header}.{payload_b64}.{sig}") | |
| 36 | 36 | } | |
| @@ -56,8 +56,7 @@ fn client_for(server: &MockServer) -> SyncKitClient { | |||
| 56 | 56 | fn authed_client(server: &MockServer) -> SyncKitClient { | |
| 57 | 57 | let client = client_for(server); | |
| 58 | 58 | let (user_id, app_id) = test_ids(); | |
| 59 | - | client | |
| 60 | - | .restore_session(&fresh_token(), user_id, app_id); | |
| 59 | + | client.restore_session(&fresh_token(), user_id, app_id); | |
| 61 | 60 | client | |
| 62 | 61 | } | |
| 63 | 62 | ||
| @@ -147,7 +146,9 @@ async fn authenticate_retries_on_503() { | |||
| 147 | 146 | .await; | |
| 148 | 147 | ||
| 149 | 148 | let client = client_for(&server); | |
| 150 | - | let result = client.authenticate("user@test.com", "password", "test-key").await; | |
| 149 | + | let result = client | |
| 150 | + | .authenticate("user@test.com", "password", "test-key") | |
| 151 | + | .await; | |
| 151 | 152 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 152 | 153 | } | |
| 153 | 154 | ||
| @@ -261,6 +262,7 @@ async fn push_encrypts_data() { | |||
| 261 | 262 | timestamp: Utc::now(), | |
| 262 | 263 | hlc: Default::default(), | |
| 263 | 264 | data: Some(json!({"title": "Secret task"})), | |
| 265 | + | extra: Default::default(), | |
| 264 | 266 | }], | |
| 265 | 267 | ) | |
| 266 | 268 | .await | |
| @@ -341,7 +343,10 @@ async fn push_retries_on_503() { | |||
| 341 | 343 | let key = synckit_client::crypto::generate_master_key(); | |
| 342 | 344 | client.set_master_key_raw(key); | |
| 343 | 345 | ||
| 344 | - | let cursor = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap(); | |
| 346 | + | let cursor = client | |
| 347 | + | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 348 | + | .await | |
| 349 | + | .unwrap(); | |
| 345 | 350 | assert_eq!(cursor, 5); | |
| 346 | 351 | } | |
| 347 | 352 | ||
| @@ -360,7 +365,10 @@ async fn push_fails_immediately_on_401() { | |||
| 360 | 365 | let key = synckit_client::crypto::generate_master_key(); | |
| 361 | 366 | client.set_master_key_raw(key); | |
| 362 | 367 | ||
| 363 | - | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 368 | + | let err = client | |
| 369 | + | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 370 | + | .await | |
| 371 | + | .unwrap_err(); | |
| 364 | 372 | assert!(matches!(err, SyncKitError::Server { status: 401, .. })); | |
| 365 | 373 | } | |
| 366 | 374 | ||
| @@ -516,7 +524,9 @@ async fn blob_upload_retries_on_503() { | |||
| 516 | 524 | client.set_master_key_raw(key); | |
| 517 | 525 | ||
| 518 | 526 | let presigned = format!("{}{}", server.uri(), upload_path); | |
| 519 | - | let result = client.blob_upload("sha256-x", &presigned, b"data".to_vec()).await; | |
| 527 | + | let result = client | |
| 528 | + | .blob_upload("sha256-x", &presigned, b"data".to_vec()) | |
| 529 | + | .await; | |
| 520 | 530 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 521 | 531 | } | |
| 522 | 532 | ||
| @@ -546,8 +556,7 @@ async fn near_expiry_jwt_returns_token_expired() { | |||
| 546 | 556 | ||
| 547 | 557 | // Token expires in 10 seconds (within 30-second buffer) | |
| 548 | 558 | let near_expiry = fake_jwt(Utc::now().timestamp() + 10); | |
| 549 | - | client | |
| 550 | - | .restore_session(&near_expiry, user_id, app_id); | |
| 559 | + | client.restore_session(&near_expiry, user_id, app_id); | |
| 551 | 560 | ||
| 552 | 561 | let err = client.status().await.unwrap_err(); | |
| 553 | 562 | assert!( | |
| @@ -599,7 +608,10 @@ async fn push_without_auth_returns_not_authenticated() { | |||
| 599 | 608 | let server = MockServer::start().await; | |
| 600 | 609 | let client = client_for(&server); | |
| 601 | 610 | ||
| 602 | - | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 611 | + | let err = client | |
| 612 | + | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 613 | + | .await | |
| 614 | + | .unwrap_err(); | |
| 603 | 615 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 604 | 616 | } | |
| 605 | 617 | ||
| @@ -612,8 +624,7 @@ async fn has_server_key_true_on_200() { | |||
| 612 | 624 | Mock::given(method("GET")) | |
| 613 | 625 | .and(path("/api/v1/sync/keys")) | |
| 614 | 626 | .respond_with( | |
| 615 | - | ResponseTemplate::new(200) | |
| 616 | - | .set_body_json(json!({"encrypted_key": "envelope-data"})), | |
| 627 | + | ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": "envelope-data"})), | |
| 617 | 628 | ) | |
| 618 | 629 | .mount(&server) | |
| 619 | 630 | .await; | |
| @@ -650,8 +661,7 @@ async fn has_server_key_retries_on_500() { | |||
| 650 | 661 | Mock::given(method("GET")) | |
| 651 | 662 | .and(path("/api/v1/sync/keys")) | |
| 652 | 663 | .respond_with( | |
| 653 | - | ResponseTemplate::new(200) | |
| 654 | - | .set_body_json(json!({"encrypted_key": "envelope"})), | |
| 664 | + | ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": "envelope"})), | |
| 655 | 665 | ) | |
| 656 | 666 | .mount(&server) | |
| 657 | 667 | .await; | |
| @@ -741,10 +751,7 @@ async fn error_400_not_retried() { | |||
| 741 | 751 | .await; | |
| 742 | 752 | ||
| 743 | 753 | let client = authed_client(&server); | |
| 744 | - | let err = client | |
| 745 | - | .register_device("Device", "test") | |
| 746 | - | .await | |
| 747 | - | .unwrap_err(); | |
| 754 | + | let err = client.register_device("Device", "test").await.unwrap_err(); | |
| 748 | 755 | assert!(matches!(err, SyncKitError::Server { status: 400, .. })); | |
| 749 | 756 | } | |
| 750 | 757 | ||
| @@ -856,18 +863,13 @@ async fn change_password_wrong_old_password_with_cached_key_fails() { | |||
| 856 | 863 | // Server returns the envelope on GET | |
| 857 | 864 | Mock::given(method("GET")) | |
| 858 | 865 | .and(path("/api/v1/sync/keys")) | |
| 859 | - | .respond_with( | |
| 860 | - | ResponseTemplate::new(200) | |
| 861 | - | .set_body_json(json!({"encrypted_key": envelope})), | |
| 862 | - | ) | |
| 866 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 863 | 867 | .mount(&server) | |
| 864 | 868 | .await; | |
| 865 | 869 | ||
| 866 | 870 | // Attempt to change password with wrong old password. | |
| 867 | 871 | // The key IS cached, but the old password must still be validated. | |
| 868 | - | let result = client | |
| 869 | - | .change_password("wrong-old-pass", "new-pass") | |
| 870 | - | .await; | |
| 872 | + | let result = client.change_password("wrong-old-pass", "new-pass").await; | |
| 871 | 873 | ||
| 872 | 874 | assert!( | |
| 873 | 875 | result.is_err(), | |
| @@ -888,10 +890,7 @@ async fn change_password_correct_old_password_with_cached_key_succeeds() { | |||
| 888 | 890 | // Server returns the envelope on GET | |
| 889 | 891 | Mock::given(method("GET")) | |
| 890 | 892 | .and(path("/api/v1/sync/keys")) | |
| 891 | - | .respond_with( | |
| 892 | - | ResponseTemplate::new(200) | |
| 893 | - | .set_body_json(json!({"encrypted_key": envelope})), | |
| 894 | - | ) | |
| 893 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 895 | 894 | .mount(&server) | |
| 896 | 895 | .await; | |
| 897 | 896 | ||
| @@ -902,10 +901,11 @@ async fn change_password_correct_old_password_with_cached_key_succeeds() { | |||
| 902 | 901 | .mount(&server) | |
| 903 | 902 | .await; | |
| 904 | 903 | ||
| 905 | - | let result = client | |
| 906 | - | .change_password("correct-old-pass", "new-pass") | |
| 907 | - | .await; | |
| 908 | - | assert!(result.is_ok(), "change_password should succeed with correct old password"); | |
| 904 | + | let result = client.change_password("correct-old-pass", "new-pass").await; | |
| 905 | + | assert!( | |
| 906 | + | result.is_ok(), | |
| 907 | + | "change_password should succeed with correct old password" | |
| 908 | + | ); | |
| 909 | 909 | ||
| 910 | 910 | // Verify the PUT request was made (new envelope was uploaded) | |
| 911 | 911 | let requests = server.received_requests().await.unwrap(); | |
| @@ -916,11 +916,13 @@ async fn change_password_correct_old_password_with_cached_key_succeeds() { | |||
| 916 | 916 | assert_eq!(put_requests.len(), 1, "Should have sent exactly one PUT"); | |
| 917 | 917 | ||
| 918 | 918 | // Verify the new envelope can be unwrapped with the new password | |
| 919 | - | let put_body: serde_json::Value = | |
| 920 | - | serde_json::from_slice(&put_requests[0].body).unwrap(); | |
| 919 | + | let put_body: serde_json::Value = serde_json::from_slice(&put_requests[0].body).unwrap(); | |
| 921 | 920 | let new_envelope = put_body["encrypted_key"].as_str().unwrap(); | |
| 922 | 921 | let recovered = synckit_client::crypto::unwrap_master_key(new_envelope, "new-pass").unwrap(); | |
| 923 | - | assert_eq!(recovered, master_key, "New envelope should unwrap to the same master key"); | |
| 922 | + | assert_eq!( | |
| 923 | + | recovered, master_key, | |
| 924 | + | "New envelope should unwrap to the same master key" | |
| 925 | + | ); | |
| 924 | 926 | } | |
| 925 | 927 | ||
| 926 | 928 | #[tokio::test] | |
| @@ -935,16 +937,11 @@ async fn change_password_wrong_old_password_without_cached_key_fails() { | |||
| 935 | 937 | ||
| 936 | 938 | Mock::given(method("GET")) | |
| 937 | 939 | .and(path("/api/v1/sync/keys")) | |
| 938 | - | .respond_with( | |
| 939 | - | ResponseTemplate::new(200) | |
| 940 | - | .set_body_json(json!({"encrypted_key": envelope})), | |
| 941 | - | ) | |
| 940 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 942 | 941 | .mount(&server) | |
| 943 | 942 | .await; | |
| 944 | 943 | ||
| 945 | - | let result = client | |
| 946 | - | .change_password("wrong-old-pass", "new-pass") | |
| 947 | - | .await; | |
| 944 | + | let result = client.change_password("wrong-old-pass", "new-pass").await; | |
| 948 | 945 | ||
| 949 | 946 | assert!( | |
| 950 | 947 | result.is_err(), | |
| @@ -959,15 +956,11 @@ async fn change_password_wrong_old_password_without_cached_key_fails() { | |||
| 959 | 956 | #[tokio::test] | |
| 960 | 957 | async fn change_password_old_envelope_invalid_with_new_password() { | |
| 961 | 958 | let server = MockServer::start().await; | |
| 962 | - | let (client, _master_key, envelope) = | |
| 963 | - | setup_change_password_test(&server, "old-pass").await; | |
| 959 | + | let (client, _master_key, envelope) = setup_change_password_test(&server, "old-pass").await; | |
| 964 | 960 | ||
| 965 | 961 | Mock::given(method("GET")) | |
| 966 | 962 | .and(path("/api/v1/sync/keys")) | |
| 967 | - | .respond_with( | |
| 968 | - | ResponseTemplate::new(200) | |
| 969 | - | .set_body_json(json!({"encrypted_key": envelope})), | |
| 970 | - | ) | |
| 963 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 971 | 964 | .mount(&server) | |
| 972 | 965 | .await; | |
| 973 | 966 | ||
| @@ -1014,7 +1007,10 @@ async fn push_empty_changes_succeeds() { | |||
| 1014 | 1007 | let key = synckit_client::crypto::generate_master_key(); | |
| 1015 | 1008 | client.set_master_key_raw(key); | |
| 1016 | 1009 | ||
| 1017 | - | let cursor = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap(); | |
| 1010 | + | let cursor = client | |
| 1011 | + | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 1012 | + | .await | |
| 1013 | + | .unwrap(); | |
| 1018 | 1014 | assert_eq!(cursor, 0); | |
| 1019 | 1015 | } | |
| 1020 | 1016 | ||
| @@ -1026,9 +1022,7 @@ async fn push_malformed_json_response_handled() { | |||
| 1026 | 1022 | ||
| 1027 | 1023 | Mock::given(method("POST")) | |
| 1028 | 1024 | .and(path("/api/v1/sync/push")) | |
| 1029 | - | .respond_with( | |
| 1030 | - | ResponseTemplate::new(200).set_body_string("not valid json at all"), | |
| 1031 | - | ) | |
| 1025 | + | .respond_with(ResponseTemplate::new(200).set_body_string("not valid json at all")) | |
| 1032 | 1026 | .mount(&server) | |
| 1033 | 1027 | .await; | |
| 1034 | 1028 | ||
| @@ -1052,9 +1046,7 @@ async fn pull_malformed_json_response_handled() { | |||
| 1052 | 1046 | ||
| 1053 | 1047 | Mock::given(method("POST")) | |
| 1054 | 1048 | .and(path("/api/v1/sync/pull")) | |
| 1055 | - | .respond_with( | |
| 1056 | - | ResponseTemplate::new(200).set_body_string("{invalid json}"), | |
| 1057 | - | ) | |
| 1049 | + | .respond_with(ResponseTemplate::new(200).set_body_string("{invalid json}")) | |
| 1058 | 1050 | .mount(&server) | |
| 1059 | 1051 | .await; | |
| 1060 | 1052 | ||
| @@ -1072,9 +1064,7 @@ async fn status_malformed_json_response_handled() { | |||
| 1072 | 1064 | ||
| 1073 | 1065 | Mock::given(method("GET")) | |
| 1074 | 1066 | .and(path("/api/v1/sync/status")) | |
| 1075 | - | .respond_with( | |
| 1076 | - | ResponseTemplate::new(200).set_body_string("this is not json"), | |
| 1077 | - | ) | |
| 1067 | + | .respond_with(ResponseTemplate::new(200).set_body_string("this is not json")) | |
| 1078 | 1068 | .mount(&server) | |
| 1079 | 1069 | .await; | |
| 1080 | 1070 | ||
| @@ -1100,7 +1090,9 @@ async fn server_error_message_preserved() { | |||
| 1100 | 1090 | let client = authed_client(&server); | |
| 1101 | 1091 | let err = client.status().await.unwrap_err(); | |
| 1102 | 1092 | match err { | |
| 1103 | - | SyncKitError::Server { status, message, .. } => { | |
| 1093 | + | SyncKitError::Server { | |
| 1094 | + | status, message, .. | |
| 1095 | + | } => { | |
| 1104 | 1096 | assert_eq!(status, 422); | |
| 1105 | 1097 | assert!( | |
| 1106 | 1098 | message.contains("Validation failed"), | |
| @@ -1139,6 +1131,7 @@ async fn concurrent_push_operations_no_data_corruption() { | |||
| 1139 | 1131 | timestamp: Utc::now(), | |
| 1140 | 1132 | hlc: Default::default(), | |
| 1141 | 1133 | data: Some(json!({"index": i})), | |
| 1134 | + | extra: Default::default(), | |
| 1142 | 1135 | }; | |
| 1143 | 1136 | c.push(device_id, vec![entry]).await | |
| 1144 | 1137 | })); | |
| @@ -1191,7 +1184,10 @@ async fn concurrent_push_and_pull_interleaved() { | |||
| 1191 | 1184 | ||
| 1192 | 1185 | for h in handles { | |
| 1193 | 1186 | let result = h.await.unwrap(); | |
| 1194 | - | assert!(result.is_ok(), "Interleaved push/pull should succeed: {result:?}"); | |
| 1187 | + | assert!( | |
| 1188 | + | result.is_ok(), | |
| 1189 | + | "Interleaved push/pull should succeed: {result:?}" | |
| 1190 | + | ); | |
| 1195 | 1191 | } | |
| 1196 | 1192 | } | |
| 1197 | 1193 | ||
| @@ -1220,10 +1216,14 @@ async fn push_many_changes_succeeds() { | |||
| 1220 | 1216 | timestamp: Utc::now(), | |
| 1221 | 1217 | hlc: Default::default(), | |
| 1222 | 1218 | data: Some(json!({"index": i, "value": format!("data-{i}")})), | |
| 1219 | + | extra: Default::default(), | |
| 1223 | 1220 | }) | |
| 1224 | 1221 | .collect(); | |
| 1225 | 1222 | ||
| 1226 | - | let cursor = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap(); | |
| 1223 | + | let cursor = client | |
| 1224 | + | .push(DeviceId::new(Uuid::new_v4()), changes) | |
| 1225 | + | .await | |
| 1226 | + | .unwrap(); | |
| 1227 | 1227 | assert_eq!(cursor, 1000); | |
| 1228 | 1228 | } | |
| 1229 | 1229 | ||
| @@ -1237,10 +1237,12 @@ async fn expired_token_detected_before_push() { | |||
| 1237 | 1237 | ||
| 1238 | 1238 | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 1239 | 1239 | client.restore_session(&expired, user_id, app_id); | |
| 1240 | - | client | |
| 1241 | - | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 1240 | + | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 1242 | 1241 | ||
| 1243 | - | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 1242 | + | let err = client | |
| 1243 | + | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 1244 | + | .await | |
| 1245 | + | .unwrap_err(); | |
| 1244 | 1246 | assert!( | |
| 1245 | 1247 | matches!(err, SyncKitError::TokenExpired), | |
| 1246 | 1248 | "Expired token should be detected pre-flight, got: {err:?}" | |
| @@ -1255,10 +1257,12 @@ async fn expired_token_detected_before_pull() { | |||
| 1255 | 1257 | ||
| 1256 | 1258 | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 1257 | 1259 | client.restore_session(&expired, user_id, app_id); | |
| 1258 | - | client | |
| 1259 | - | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 1260 | + | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 1260 | 1261 | ||
| 1261 | - | let err = client.pull(DeviceId::new(Uuid::new_v4()), 0).await.unwrap_err(); | |
| 1262 | + | let err = client | |
| 1263 | + | .pull(DeviceId::new(Uuid::new_v4()), 0) | |
| 1264 | + | .await | |
| 1265 | + | .unwrap_err(); | |
| 1262 | 1266 | assert!(matches!(err, SyncKitError::TokenExpired)); | |
| 1263 | 1267 | } | |
| 1264 | 1268 | ||
| @@ -1389,9 +1393,13 @@ async fn push_with_data_fails_without_master_key() { | |||
| 1389 | 1393 | timestamp: Utc::now(), | |
| 1390 | 1394 | hlc: Default::default(), | |
| 1391 | 1395 | data: Some(json!({"title": "test"})), | |
| 1396 | + | extra: Default::default(), | |
| 1392 | 1397 | }]; | |
| 1393 | 1398 | ||
| 1394 | - | let err = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap_err(); | |
| 1399 | + | let err = client | |
| 1400 | + | .push(DeviceId::new(Uuid::new_v4()), changes) | |
| 1401 | + | .await | |
| 1402 | + | .unwrap_err(); | |
| 1395 | 1403 | assert!( | |
| 1396 | 1404 | matches!(err, SyncKitError::NoMasterKey), | |
| 1397 | 1405 | "Push with data should fail without master key: {err:?}" | |
| @@ -1421,9 +1429,13 @@ async fn push_delete_requires_master_key() { | |||
| 1421 | 1429 | timestamp: Utc::now(), | |
| 1422 | 1430 | hlc: Default::default(), | |
| 1423 | 1431 | data: None, | |
| 1432 | + | extra: Default::default(), | |
| 1424 | 1433 | }]; | |
| 1425 | 1434 | ||
| 1426 | - | let err = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap_err(); | |
| 1435 | + | let err = client | |
| 1436 | + | .push(DeviceId::new(Uuid::new_v4()), changes) | |
| 1437 | + | .await | |
| 1438 | + | .unwrap_err(); | |
| 1427 | 1439 | assert!( | |
| 1428 | 1440 | matches!(err, SyncKitError::NoMasterKey), | |
| 1429 | 1441 | "Delete now seals an HLC envelope and needs the key: {err:?}" | |
| @@ -1494,13 +1506,17 @@ async fn double_push_same_data_both_succeed() { | |||
| 1494 | 1506 | timestamp: Utc::now(), | |
| 1495 | 1507 | hlc: Default::default(), | |
| 1496 | 1508 | data: Some(json!({"title": "duplicate push test"})), | |
| 1509 | + | extra: Default::default(), | |
| 1497 | 1510 | }; | |
| 1498 | 1511 | ||
| 1499 | 1512 | let cursor1 = client | |
| 1500 | 1513 | .push(DeviceId::new(Uuid::new_v4()), vec![entry.clone()]) | |
| 1501 | 1514 | .await | |
| 1502 | 1515 | .unwrap(); | |
| 1503 | - | let cursor2 = client.push(DeviceId::new(Uuid::new_v4()), vec![entry]).await.unwrap(); | |
| 1516 | + | let cursor2 = client | |
| 1517 | + | .push(DeviceId::new(Uuid::new_v4()), vec![entry]) | |
| 1518 | + | .await | |
| 1519 | + | .unwrap(); | |
| 1504 | 1520 | ||
| 1505 | 1521 | assert_eq!(cursor1, 1); | |
| 1506 | 1522 | assert_eq!(cursor2, 2); | |
| @@ -1545,10 +1561,7 @@ async fn setup_encryption_new_without_auth_fails() { | |||
| 1545 | 1561 | let server = MockServer::start().await; | |
| 1546 | 1562 | let client = client_for(&server); | |
| 1547 | 1563 | ||
| 1548 | - | let err = client | |
| 1549 | - | .setup_encryption_new("password") | |
| 1550 | - | .await | |
| 1551 | - | .unwrap_err(); | |
| 1564 | + | let err = client.setup_encryption_new("password").await.unwrap_err(); | |
| 1552 | 1565 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 1553 | 1566 | } | |
| 1554 | 1567 | ||
| @@ -1580,15 +1593,11 @@ async fn setup_encryption_existing_recovers_key() { | |||
| 1580 | 1593 | let server = MockServer::start().await; | |
| 1581 | 1594 | ||
| 1582 | 1595 | let master_key = synckit_client::crypto::generate_master_key(); | |
| 1583 | - | let envelope = | |
| 1584 | - | synckit_client::crypto::wrap_master_key(&master_key, "my-password").unwrap(); | |
| 1596 | + | let envelope = synckit_client::crypto::wrap_master_key(&master_key, "my-password").unwrap(); | |
| 1585 | 1597 | ||
| 1586 | 1598 | Mock::given(method("GET")) | |
| 1587 | 1599 | .and(path("/api/v1/sync/keys")) | |
| 1588 | - | .respond_with( | |
| 1589 | - | ResponseTemplate::new(200) | |
| 1590 | - | .set_body_json(json!({"encrypted_key": envelope})), | |
| 1591 | - | ) | |
| 1600 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 1592 | 1601 | .mount(&server) | |
| 1593 | 1602 | .await; | |
| 1594 | 1603 | ||
| @@ -1613,10 +1622,7 @@ async fn setup_encryption_existing_wrong_password_fails() { | |||
| 1613 | 1622 | ||
| 1614 | 1623 | Mock::given(method("GET")) | |
| 1615 | 1624 | .and(path("/api/v1/sync/keys")) | |
| 1616 | - | .respond_with( | |
| 1617 | - | ResponseTemplate::new(200) | |
| 1618 | - | .set_body_json(json!({"encrypted_key": envelope})), | |
| 1619 | - | ) | |
| 1625 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 1620 | 1626 | .mount(&server) | |
| 1621 | 1627 | .await; | |
| 1622 | 1628 | ||
| @@ -1649,8 +1655,7 @@ async fn setup_encryption_existing_retries_on_server_error() { | |||
| 1649 | 1655 | let server = MockServer::start().await; | |
| 1650 | 1656 | ||
| 1651 | 1657 | let master_key = synckit_client::crypto::generate_master_key(); | |
| 1652 | - | let envelope = | |
| 1653 | - | synckit_client::crypto::wrap_master_key(&master_key, "password").unwrap(); | |
| 1658 | + | let envelope = synckit_client::crypto::wrap_master_key(&master_key, "password").unwrap(); | |
| 1654 | 1659 | ||
| 1655 | 1660 | Mock::given(method("GET")) | |
| 1656 | 1661 | .and(path("/api/v1/sync/keys")) | |
| @@ -1661,10 +1666,7 @@ async fn setup_encryption_existing_retries_on_server_error() { | |||
| 1661 | 1666 | ||
| 1662 | 1667 | Mock::given(method("GET")) | |
| 1663 | 1668 | .and(path("/api/v1/sync/keys")) | |
| 1664 | - | .respond_with( | |
| 1665 | - | ResponseTemplate::new(200) | |
| 1666 | - | .set_body_json(json!({"encrypted_key": envelope})), | |
| 1667 | - | ) | |
| 1669 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 1668 | 1670 | .mount(&server) | |
| 1669 | 1671 | .await; | |
| 1670 | 1672 | ||
| @@ -1734,6 +1736,7 @@ async fn encryption_setup_cross_device_roundtrip() { | |||
| 1734 | 1736 | timestamp: Utc::now(), | |
| 1735 | 1737 | hlc: Default::default(), | |
| 1736 | 1738 | data: Some(original_data.clone()), | |
| 1739 | + | extra: Default::default(), | |
| 1737 | 1740 | }], | |
| 1738 | 1741 | ) | |
| 1739 | 1742 | .await | |
| @@ -1760,10 +1763,7 @@ async fn encryption_setup_cross_device_roundtrip() { | |||
| 1760 | 1763 | ||
| 1761 | 1764 | Mock::given(method("GET")) | |
| 1762 | 1765 | .and(path("/api/v1/sync/keys")) | |
| 1763 | - | .respond_with( | |
| 1764 | - | ResponseTemplate::new(200) | |
| 1765 | - | .set_body_json(json!({"encrypted_key": envelope})), | |
| 1766 | - | ) | |
| 1766 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 1767 | 1767 | .mount(&server) | |
| 1768 | 1768 | .await; | |
| 1769 | 1769 | ||
| @@ -1833,8 +1833,7 @@ async fn all_4xx_error_codes_mapped() { | |||
| 1833 | 1833 | Mock::given(method("GET")) | |
| 1834 | 1834 | .and(path("/api/v1/sync/status")) | |
| 1835 | 1835 | .respond_with( | |
| 1836 | - | ResponseTemplate::new(status_code) | |
| 1837 | - | .set_body_string(format!("Error {status_code}")), | |
| 1836 | + | ResponseTemplate::new(status_code).set_body_string(format!("Error {status_code}")), | |
| 1838 | 1837 | ) | |
| 1839 | 1838 | .mount(&server) | |
| 1840 | 1839 | .await; | |
| @@ -1843,13 +1842,13 @@ async fn all_4xx_error_codes_mapped() { | |||
| 1843 | 1842 | let err = client.status().await.unwrap_err(); | |
| 1844 | 1843 | ||
| 1845 | 1844 | match err { | |
| 1846 | - | SyncKitError::Server { status, message, .. } => { | |
| 1845 | + | SyncKitError::Server { | |
| 1846 | + | status, message, .. | |
| 1847 | + | } => { | |
| 1847 | 1848 | assert_eq!(status, status_code); | |
| 1848 | 1849 | assert!(message.contains(&format!("Error {status_code}"))); | |
| 1849 | 1850 | } | |
| 1850 | - | other => panic!( | |
| 1851 | - | "Status {status_code} should map to Server error, got: {other:?}" | |
| 1852 | - | ), | |
| 1851 | + | other => panic!("Status {status_code} should map to Server error, got: {other:?}"), | |
| 1853 | 1852 | } | |
| 1854 | 1853 | } | |
| 1855 | 1854 | } | |
| @@ -1862,10 +1861,7 @@ async fn all_5xx_error_codes_retried() { | |||
| 1862 | 1861 | // First request fails with 5xx | |
| 1863 | 1862 | Mock::given(method("GET")) | |
| 1864 | 1863 | .and(path("/api/v1/sync/status")) | |
| 1865 | - | .respond_with( | |
| 1866 | - | ResponseTemplate::new(status_code) | |
| 1867 | - | .set_body_string("Server Error"), | |
| 1868 | - | ) | |
| 1864 | + | .respond_with(ResponseTemplate::new(status_code).set_body_string("Server Error")) | |
| 1869 | 1865 | .up_to_n_times(1) | |
| 1870 | 1866 | .mount(&server) | |
| 1871 | 1867 | .await; | |
| @@ -1934,7 +1930,10 @@ async fn pull_without_auth_returns_not_authenticated() { | |||
| 1934 | 1930 | let server = MockServer::start().await; | |
| 1935 | 1931 | let client = client_for(&server); | |
| 1936 | 1932 | ||
| 1937 | - | let err = client.pull(DeviceId::new(Uuid::new_v4()), 0).await.unwrap_err(); | |
| 1933 | + | let err = client | |
| 1934 | + | .pull(DeviceId::new(Uuid::new_v4()), 0) | |
| 1935 | + | .await | |
| 1936 | + | .unwrap_err(); | |
| 1938 | 1937 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 1939 | 1938 | } | |
| 1940 | 1939 | ||
| @@ -1995,6 +1994,7 @@ async fn end_to_end_push_pull_encryption_roundtrip() { | |||
| 1995 | 1994 | timestamp: Utc::now(), |
Lines truncated