Skip to main content

wacore_appstate/
processor.rs

1//! Pure, synchronous patch and snapshot processing logic for app state.
2//!
3//! This module provides runtime-agnostic processing of app state patches and snapshots.
4//! All functions are synchronous and take callbacks for key lookup, making them
5//! suitable for use in both async and sync contexts.
6
7use crate::AppStateError;
8use crate::decode::{Mutation, decode_record};
9use crate::hash::{HashState, generate_patch_mac};
10use crate::keys::ExpandedAppStateKeys;
11use log::{debug, trace};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15use waproto::whatsapp as wa;
16
17/// Resolve a mutation's operation to the closed Rust enum. Absent defaults
18/// to SET (proto2 enum default); an unknown wire value is a typed error so
19/// standalone callers stay safe even without process_patch's up-front guard.
20fn known_op(
21    op: Option<buffa::EnumValue<wa::syncd_mutation::SyncdOperation>>,
22) -> Result<wa::syncd_mutation::SyncdOperation, AppStateError> {
23    match op {
24        None => Ok(wa::syncd_mutation::SyncdOperation::SET),
25        Some(v) => v
26            .as_known()
27            .ok_or(AppStateError::UnsupportedSyncdOperation(v.to_i32())),
28    }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AppStateMutationMAC {
33    pub index_mac: Vec<u8>,
34    pub value_mac: Vec<u8>,
35}
36
37/// Result of processing a snapshot.
38#[derive(Debug, Clone)]
39pub struct ProcessedSnapshot {
40    /// The updated hash state after processing.
41    pub state: HashState,
42    /// The decoded mutations from the snapshot.
43    pub mutations: Vec<Mutation>,
44    /// The mutation MACs to store (for later patch processing).
45    pub mutation_macs: Vec<AppStateMutationMAC>,
46}
47
48/// Result of processing a single patch.
49#[derive(Debug, Clone)]
50pub struct PatchProcessingResult {
51    /// The updated hash state after processing.
52    pub state: HashState,
53    /// The decoded mutations from the patch.
54    pub mutations: Vec<Mutation>,
55    /// The mutation MACs that were added.
56    pub added_macs: Vec<AppStateMutationMAC>,
57    /// The index MACs that were removed.
58    pub removed_index_macs: Vec<Vec<u8>>,
59}
60
61/// Process a snapshot and decode all its records.
62///
63/// This is a pure, synchronous function that processes a snapshot without
64/// any async operations. Key lookup is done via a callback.
65///
66/// # Arguments
67/// * `snapshot` - The snapshot to process
68/// * `initial_state` - The initial hash state (will be mutated in place)
69/// * `get_keys` - Callback to get expanded keys for a key ID
70/// * `validate_macs` - Whether to validate MACs during processing
71/// * `collection_name` - The collection name (for MAC validation)
72///
73/// # Returns
74/// A `ProcessedSnapshot` containing the new state and decoded mutations.
75pub fn process_snapshot<F>(
76    snapshot: &wa::SyncdSnapshot,
77    initial_state: &mut HashState,
78    mut get_keys: F,
79    validate_macs: bool,
80    collection_name: &str,
81) -> Result<ProcessedSnapshot, AppStateError>
82where
83    F: FnMut(&[u8]) -> Result<Arc<ExpandedAppStateKeys>, AppStateError>,
84{
85    let version = snapshot.version.version.unwrap_or(0);
86    initial_state.version = version;
87
88    // Update hash state directly from records (no cloning needed)
89    initial_state.update_hash_from_records(&snapshot.records);
90
91    debug!(
92        target: "AppState",
93        "Snapshot {} v{}: {} records, ltHash ends with ...{}",
94        collection_name,
95        version,
96        snapshot.records.len(),
97        hex::encode(&initial_state.hash[120..])
98    );
99
100    // Validate snapshot MAC if requested. A snapshot that omits `mac`/`key_id` is
101    // treated as a validation FAILURE, not skipped: WA Web's anti-tampering
102    // compares against the (possibly undefined) mac and fires the recovery path on
103    // mismatch, so a missing mac must not silently accept unverified records.
104    if validate_macs {
105        let (Some(mac_expected), Some(key_id)) = (
106            snapshot.mac.as_ref(),
107            snapshot.key_id.as_option().and_then(|k| k.id.as_deref()),
108        ) else {
109            return Err(AppStateError::SnapshotMACMismatch);
110        };
111        let keys = get_keys(key_id)?;
112        let computed = initial_state.generate_snapshot_mac(collection_name, &keys.snapshot_mac);
113        trace!(
114            target: "AppState",
115            "Snapshot {} v{} MAC validation: computed={}, expected={}",
116            collection_name,
117            version,
118            hex::encode(&computed),
119            hex::encode(mac_expected)
120        );
121        if computed != *mac_expected {
122            return Err(AppStateError::SnapshotMACMismatch);
123        }
124    }
125
126    // Decode all records and collect MACs in a single pass
127    let mut mutations = Vec::with_capacity(snapshot.records.len());
128    let mut mutation_macs = Vec::with_capacity(snapshot.records.len());
129
130    for rec in &snapshot.records {
131        let key_id = rec.key_id.id.as_ref().ok_or(AppStateError::MissingKeyId)?;
132        let keys = get_keys(key_id)?;
133
134        let (mutation, macs) = decode_record(
135            wa::syncd_mutation::SyncdOperation::SET,
136            rec,
137            &keys,
138            key_id,
139            validate_macs,
140        )?;
141
142        mutation_macs.push(AppStateMutationMAC {
143            index_mac: macs.index_mac,
144            value_mac: macs.value_mac,
145        });
146
147        mutations.push(mutation);
148    }
149
150    Ok(ProcessedSnapshot {
151        state: initial_state.clone(),
152        mutations,
153        mutation_macs,
154    })
155}
156
157/// Process a single patch and decode its mutations.
158///
159/// This is a pure, synchronous function that processes a patch without
160/// any async operations. Key and previous value lookup are done via callbacks.
161///
162/// # Arguments
163/// * `patch` - The patch to process
164/// * `state` - The current hash state (will be mutated in place)
165/// * `get_keys` - Callback to get expanded keys for a key ID
166/// * `get_prev_value_mac` - Callback to get previous value MAC for an index MAC
167/// * `validate_macs` - Whether to validate MACs during processing
168/// * `collection_name` - The collection name (for MAC validation)
169///
170/// # Returns
171/// A `PatchProcessingResult` containing the new state and decoded mutations.
172pub fn process_patch<F, G>(
173    patch: &wa::SyncdPatch,
174    state: &mut HashState,
175    mut get_keys: F,
176    mut get_prev_value_mac: G,
177    validate_macs: bool,
178    collection_name: &str,
179) -> Result<PatchProcessingResult, AppStateError>
180where
181    F: FnMut(&[u8]) -> Result<Arc<ExpandedAppStateKeys>, AppStateError>,
182    G: FnMut(&[u8]) -> Result<Option<Vec<u8>>, AppStateError>,
183{
184    // Capture original state before modification - needed for MAC validation logic
185    // If original state was empty (version=0, hash all zeros), we cannot validate
186    // snapshotMac because we don't have the baseline state the patch was built against.
187    // This matches WhatsApp Web behavior which throws a retryable error in this case.
188    let original_version = state.version;
189    let original_hash_is_empty = state.hash == [0u8; 128];
190    let had_no_prior_state = original_version == 0 && original_hash_is_empty;
191
192    let patch_version = patch.version.version.unwrap_or(0);
193
194    // WA Web: validatePatchVersion — strict monotonic version check.
195    // Patch version must be exactly local_version + 1.  If not, WA Web throws
196    // "syncd-version-check-error-local-version-{greater|less}-than-expected".
197    // Skip this check when we have no prior state (version=0, empty hash),
198    // since we don't have a baseline to validate against.
199    let expected_version = original_version.saturating_add(1);
200    if !had_no_prior_state && patch_version != expected_version {
201        return Err(AppStateError::PatchVersionMismatch {
202            expected: expected_version,
203            got: patch_version,
204        });
205    }
206
207    // SyncdOperation is an open enum: reject unknown operations up front,
208    // before any state is mutated — the LTHash math below can only add
209    // (SET) or subtract (REMOVE), so guessing at an unknown op corrupts the
210    // hash in a way that only surfaces later as MismatchingLTHash.
211    for m in &patch.mutations {
212        known_op(m.operation)?;
213    }
214
215    state.version = patch_version;
216
217    // index_mac -> most-recent in-patch value MAC tail, filled as we iterate. Replaces a
218    // reverse scan over patch.mutations[..idx] (O(n^2) total) with an O(1) lookup, mirroring
219    // WA Web's WAWebSyncdAntiTampering Map. Recording the current value only after the lookup
220    // keeps the old strictly-prior semantics: a mutation never matches itself, and a SET that
221    // overwrites the same index earlier in the patch takes precedence over the DB value.
222    let mut in_patch: HashMap<&[u8], &[u8]> = HashMap::with_capacity(patch.mutations.len());
223    let (hash_update_result, result) = state.update_hash(&patch.mutations, |index_mac, idx| {
224        // WA Web resolves every previous value against the store map fetched before
225        // the loop; the in-patch overlay only models SET-overwrite collapse and must
226        // never feed a REMOVE (a REMOVE preceded by a SET on the same index would
227        // otherwise subtract the in-patch value instead of the store's).
228        let is_remove = patch.mutations[idx]
229            .operation
230            .is_some_and(|op| op == wa::syncd_mutation::SyncdOperation::REMOVE);
231        let prev = if !is_remove && let Some(value_mac) = in_patch.get(index_mac) {
232            Some(value_mac.to_vec())
233        } else {
234            get_prev_value_mac(index_mac).map_err(|e| anyhow::anyhow!(e))?
235        };
236        if let Some(rec) = patch.mutations[idx].record.as_option()
237            && let Some(index) = rec.index.as_option().and_then(|i| i.blob.as_deref())
238            && let Some(value) = rec.value.as_option().and_then(|v| v.blob.as_deref())
239            && value.len() >= 32
240        {
241            in_patch.insert(index, &value[value.len() - 32..]);
242        }
243        Ok(prev)
244    });
245    result.map_err(|_| AppStateError::MismatchingLTHash)?;
246
247    debug!(
248        target: "AppState",
249        "Patch {} v{}: {} mutations, ltHash ends with ...{}, hasMissingRemove={}",
250        collection_name,
251        state.version,
252        patch.mutations.len(),
253        hex::encode(&state.hash[120..]),
254        hash_update_result.has_missing_remove
255    );
256
257    // Validate MACs if requested
258    if validate_macs && let Some(key_id) = patch.key_id.id.as_ref() {
259        let keys = get_keys(key_id)?;
260        let verdict = validate_patch_macs(
261            patch,
262            state,
263            &keys,
264            collection_name,
265            had_no_prior_state,
266            hash_update_result.has_missing_remove,
267        )?;
268        if verdict.snapshot_mac_diverged && !state.mac_mismatch_fatal {
269            log::warn!(
270                target: "AppState",
271                "Collection {collection_name} ltHash diverged at v{}: the patch is authentic \
272                 (patchMac valid) but its snapshotMac cannot match again. Applying it and \
273                 skipping the aggregate comparison from here, as WA Web does.",
274                state.version
275            );
276            state.mac_mismatch_fatal = true;
277        }
278    }
279
280    // Anti-tampering parity: a repeated index within the same operation of one patch
281    // is fatal in WA Web (validateNoSameIndexForMultipleMutations -> SyncdFatalError),
282    // and the cryptographic patch/snapshot MACs above don't catch it (a duplicate-index
283    // patch still MACs correctly). Runs only on the validated inbound path.
284    if validate_macs {
285        detect_duplicate_index_in_patch(&patch.mutations)?;
286    }
287
288    // Decode all mutations and collect MACs in a single pass
289    let mut mutations = Vec::with_capacity(patch.mutations.len());
290    let mut added_macs = Vec::with_capacity(patch.mutations.len());
291    let mut removed_index_macs = Vec::with_capacity(patch.mutations.len());
292
293    for m in &patch.mutations {
294        if m.record.is_set() {
295            let op = known_op(m.operation)?;
296
297            let key_id = m
298                .record
299                .key_id
300                .id
301                .as_ref()
302                .ok_or(AppStateError::MissingKeyId)?;
303            let keys = get_keys(key_id)?;
304
305            let (mutation, macs) = decode_record(op, &m.record, &keys, key_id, validate_macs)?;
306
307            match op {
308                wa::syncd_mutation::SyncdOperation::SET => {
309                    added_macs.push(AppStateMutationMAC {
310                        index_mac: macs.index_mac,
311                        value_mac: macs.value_mac,
312                    });
313                }
314                wa::syncd_mutation::SyncdOperation::REMOVE => {
315                    removed_index_macs.push(macs.index_mac);
316                }
317            }
318
319            mutations.push(mutation);
320        }
321    }
322
323    Ok(PatchProcessingResult {
324        state: state.clone(),
325        mutations,
326        added_macs,
327        removed_index_macs,
328    })
329}
330
331/// Reject a patch that repeats an index within the same operation.
332///
333/// Mirrors WA Web `WAWebSyncdValidateMutations.validateNoSameIndexForMultipleMutations`,
334/// which keeps one Set per operation (SET, REMOVE) and throws a fatal
335/// `SAME_INDEX_FOR_MULTIPLE_MUTATIONS_IN_PATCH` when an index reappears in the same one.
336/// WA Web keys on the decrypted index; the raw index_mac blob is a deterministic
337/// function of that index, so keying on it is equivalent for detection.
338fn detect_duplicate_index_in_patch(mutations: &[wa::SyncdMutation]) -> Result<(), AppStateError> {
339    // index_macs are HMAC outputs (uniformly random), so a HashSet only buys
340    // SipHash setup plus an allocation for no distribution benefit. A linear scan
341    // wins at the patch sizes seen in practice — the same trade-off measured for
342    // collect_unique_index_macs (#856). Set and Remove are deduped independently:
343    // a Set and a Remove may legitimately carry the same index within one patch.
344    let mut seen_set: Vec<&[u8]> = Vec::new();
345    let mut seen_remove: Vec<&[u8]> = Vec::new();
346    for m in mutations {
347        let Some(rec) = m.record.as_option() else {
348            continue;
349        };
350        let Some(index_mac) = rec.index.as_option().and_then(|i| i.blob.as_deref()) else {
351            continue;
352        };
353        let op = known_op(m.operation)?;
354        let seen = match op {
355            wa::syncd_mutation::SyncdOperation::SET => &mut seen_set,
356            wa::syncd_mutation::SyncdOperation::REMOVE => &mut seen_remove,
357        };
358        if seen.contains(&index_mac) {
359            return Err(AppStateError::DuplicateIndexInPatch);
360        }
361        seen.push(index_mac);
362    }
363    Ok(())
364}
365
366/// Outcome of validating a patch's two aggregate MACs.
367///
368/// Only `patchMac` failures are errors. A `snapshotMac` failure is reported here
369/// instead, because it is not a statement about the patch — see
370/// [`validate_patch_macs`].
371#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
372pub struct PatchMacVerdict {
373    /// The patch's `snapshotMac` disagreed with the ltHash this client computed,
374    /// so the local aggregate state has diverged from the sender's.
375    pub snapshot_mac_diverged: bool,
376}
377
378/// Validate the patch and snapshot MACs for a patch.
379///
380/// This is a pure function that validates the MACs without any I/O.
381///
382/// The two MACs answer different questions, so they fail differently:
383///
384/// * `patchMac` is an HMAC over the patch's own bytes under the app-state key,
385///   which the server does not hold. It is the only proof of authorship, and a
386///   mismatch is fatal. WA Web checks it first (`WAWebSyncdAntiTampering`, `K`).
387/// * `snapshotMac` is an HMAC over the *sender's* post-patch ltHash. It agrees
388///   only while the receiver's aggregate state is byte-identical to the
389///   sender's, so once a collection diverges it can never match again — for any
390///   patch, from any device, forever. Rejecting on it would freeze the
391///   collection on the base already proven unusable, so WA Web reports it and
392///   keeps going (`z`: "skip fatal after snapshot mac mismatch"), which is what
393///   [`PatchMacVerdict::snapshot_mac_diverged`] carries back to the caller.
394///   Because `patchMac` covers `snapshotMac`, a valid `patchMac` also proves the
395///   `snapshotMac` is the one the legitimate sender wrote — a server cannot
396///   forge a divergence.
397///
398/// # Arguments
399/// * `patch` - The patch to validate
400/// * `state` - The hash state AFTER applying the patch mutations. Its
401///   [`HashState::mac_mismatch_fatal`] flag suppresses the `snapshotMac`
402///   comparison entirely, mirroring WA Web's `if (E && k) return null`.
403/// * `keys` - The expanded app state keys for MAC computation
404/// * `collection_name` - The collection name
405/// * `had_no_prior_state` - True for the genesis patch (version 1) seeding an empty
406///   collection. Its ltHash is the known empty baseline, so the aggregate MACs are
407///   still computable and a genesis patch that *omits* either one is treated as
408///   tampering: that is the curated-baseline attack, where a server serves a
409///   record set with the aggregate MACs stripped. The empty + non-genesis case
410///   (a patch that can't anchor the ltHash) is rejected upstream in
411///   `process_patch_list` as a retryable resync.
412/// * `has_missing_remove` - If true, a REMOVE mutation was missing its previous value.
413///   WhatsApp Web reports this as MAC-failure telemetry, but it does not make
414///   aggregate MAC mismatches acceptable.
415pub fn validate_patch_macs(
416    patch: &wa::SyncdPatch,
417    state: &HashState,
418    keys: &ExpandedAppStateKeys,
419    collection_name: &str,
420    had_no_prior_state: bool,
421    has_missing_remove: bool,
422) -> Result<PatchMacVerdict, AppStateError> {
423    match patch.patch_mac.as_ref() {
424        Some(patch_mac) => {
425            let version = patch.version.version.unwrap_or(0);
426            let computed_patch =
427                generate_patch_mac(patch, collection_name, &keys.patch_mac, version);
428            if computed_patch != *patch_mac {
429                debug!(
430                    target: "AppState",
431                    "Patch {} v{} patchMAC MISMATCH, hasMissingRemove={}",
432                    collection_name,
433                    state.version,
434                    has_missing_remove
435                );
436                return Err(AppStateError::PatchMACMismatch);
437            }
438        }
439        // WA Web treats a missing patchMac as a failed comparison (fatal). A genesis
440        // patch that omits it is exactly the curated-baseline case, so reject rather
441        // than accept an unauthenticated record set. Non-genesis patches keep the
442        // prior lenient behavior (patchMac only enforced when present).
443        None if had_no_prior_state => return Err(AppStateError::PatchMACMismatch),
444        None => {}
445    }
446
447    // Already known to be diverged: WA Web short-circuits before recomputing.
448    if state.mac_mismatch_fatal {
449        return Ok(PatchMacVerdict {
450            snapshot_mac_diverged: false,
451        });
452    }
453
454    if let Some(snap_mac) = patch.snapshot_mac.as_ref() {
455        let computed_snap = state.generate_snapshot_mac(collection_name, &keys.snapshot_mac);
456        trace!(
457            target: "AppState",
458            "Patch {} v{} snapshotMAC: computed={}, expected={}",
459            collection_name,
460            state.version,
461            hex::encode(&computed_snap),
462            hex::encode(snap_mac)
463        );
464        if computed_snap != *snap_mac {
465            debug!(
466                target: "AppState",
467                "Patch {} v{} snapshotMAC MISMATCH! ltHash=...{}, hasMissingRemove={}",
468                collection_name,
469                state.version,
470                hex::encode(&state.hash[120..]),
471                has_missing_remove
472            );
473            return Ok(PatchMacVerdict {
474                snapshot_mac_diverged: true,
475            });
476        }
477    } else if had_no_prior_state {
478        // A genesis patch that supplies patchMac but strips snapshotMac has no
479        // aggregate to anchor at all; that is omission, not divergence.
480        return Err(AppStateError::PatchSnapshotMACMismatch);
481    }
482
483    Ok(PatchMacVerdict::default())
484}
485
486/// Validate a snapshot MAC.
487///
488/// This is a pure function that validates the snapshot MAC without any I/O.
489pub fn validate_snapshot_mac(
490    snapshot: &wa::SyncdSnapshot,
491    state: &HashState,
492    keys: &ExpandedAppStateKeys,
493    collection_name: &str,
494) -> Result<(), AppStateError> {
495    // A missing snapshot mac is a validation failure, not a skip (matches WA Web
496    // and process_snapshot's enforced gate).
497    let Some(mac_expected) = snapshot.mac.as_ref() else {
498        return Err(AppStateError::SnapshotMACMismatch);
499    };
500    let computed = state.generate_snapshot_mac(collection_name, &keys.snapshot_mac);
501    if computed != *mac_expected {
502        return Err(AppStateError::SnapshotMACMismatch);
503    }
504    Ok(())
505}
506
507#[cfg(test)]
508#[allow(clippy::disallowed_methods)]
509mod tests {
510    use super::*;
511    use crate::hash::{generate_content_mac, generate_index_mac};
512    use crate::keys::expand_app_state_keys;
513    use crate::lthash::WAPATCH_INTEGRITY;
514    use buffa::Message;
515    use wacore_libsignal::crypto::aes_256_cbc_encrypt_into;
516
517    /// Sign a genesis (v1) patch's aggregate MACs the way a legitimate server does,
518    /// so it passes the validation `validate_patch_macs` now enforces for genesis.
519    /// A validate-off probe run reproduces the resulting ltHash to MAC over.
520    fn sign_genesis_patch(
521        patch: &mut wa::SyncdPatch,
522        keys: &ExpandedAppStateKeys,
523        collection: &str,
524    ) {
525        let mut probe = HashState::default();
526        let gk = |_: &[u8]| Ok(Arc::new(keys.clone()));
527        let gp = |_: &[u8]| Ok(None);
528        process_patch(patch, &mut probe, gk, gp, false, collection).expect("probe apply");
529        patch.snapshot_mac = Some(probe.generate_snapshot_mac(collection, &keys.snapshot_mac));
530        let version = patch.version.version.unwrap_or(0);
531        patch.patch_mac = Some(generate_patch_mac(
532            patch,
533            collection,
534            &keys.patch_mac,
535            version,
536        ));
537    }
538
539    fn create_encrypted_record(
540        op: wa::syncd_mutation::SyncdOperation,
541        index_mac: &[u8],
542        keys: &ExpandedAppStateKeys,
543        key_id: &[u8],
544        timestamp: i64,
545    ) -> wa::SyncdRecord {
546        // The `index_mac` arg is the index identity bytes; the stored index blob is
547        // their HMAC, so the record stays valid under unconditional index-MAC checks.
548        let action_data = wa::SyncActionData {
549            index: Some(index_mac.to_vec()),
550            value: buffa::MessageField::some(wa::SyncActionValue {
551                timestamp: Some(timestamp),
552                ..Default::default()
553            }),
554            ..Default::default()
555        };
556        let plaintext = action_data.encode_to_vec();
557
558        let iv = vec![0u8; 16];
559        let mut ciphertext = Vec::new();
560        aes_256_cbc_encrypt_into(&plaintext, &keys.value_encryption, &iv, &mut ciphertext)
561            .expect("test data should be valid");
562
563        let mut value_with_iv = iv;
564        value_with_iv.extend_from_slice(&ciphertext);
565        let value_mac = generate_content_mac(op, &value_with_iv, key_id, &keys.value_mac);
566        let mut value_blob = value_with_iv;
567        value_blob.extend_from_slice(&value_mac);
568
569        wa::SyncdRecord {
570            index: buffa::MessageField::some(wa::SyncdIndex {
571                blob: Some(generate_index_mac(index_mac, &keys.index)),
572            }),
573            value: buffa::MessageField::some(wa::SyncdValue {
574                blob: Some(value_blob),
575            }),
576            key_id: buffa::MessageField::some(wa::KeyId {
577                id: Some(key_id.to_vec()),
578            }),
579        }
580    }
581
582    #[test]
583    fn test_process_snapshot_basic() {
584        let master_key = [7u8; 32];
585        let keys = expand_app_state_keys(&master_key);
586        let key_id = b"test_key_id".to_vec();
587        let index_mac = vec![1; 32];
588
589        let record = create_encrypted_record(
590            wa::syncd_mutation::SyncdOperation::SET,
591            &index_mac,
592            &keys,
593            &key_id,
594            1234567890,
595        );
596
597        let snapshot = wa::SyncdSnapshot {
598            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
599            records: vec![record],
600            key_id: buffa::MessageField::some(wa::KeyId {
601                id: Some(key_id.clone()),
602            }),
603            ..Default::default()
604        };
605
606        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
607
608        let mut state = HashState::default();
609        let result = process_snapshot(&snapshot, &mut state, get_keys, false, "regular")
610            .expect("test data should be valid");
611
612        assert_eq!(result.state.version, 1);
613        assert_eq!(result.mutations.len(), 1);
614        assert_eq!(result.mutation_macs.len(), 1);
615        // Exact MAC bytes (not just counts): catches empty/swapped MACs.
616        assert_eq!(
617            result.mutation_macs[0].index_mac,
618            generate_index_mac(&index_mac, &keys.index)
619        );
620        assert!(!result.mutation_macs[0].value_mac.is_empty());
621        assert_ne!(
622            result.mutation_macs[0].index_mac,
623            result.mutation_macs[0].value_mac
624        );
625        assert_eq!(
626            result.mutations[0]
627                .action_value
628                .as_ref()
629                .and_then(|v| v.timestamp),
630            Some(1234567890)
631        );
632    }
633
634    #[test]
635    fn process_snapshot_rejects_missing_mac_when_validating() {
636        let master_key = [7u8; 32];
637        let keys = expand_app_state_keys(&master_key);
638        let key_id = b"test_key_id".to_vec();
639        let record = create_encrypted_record(
640            wa::syncd_mutation::SyncdOperation::SET,
641            &[1u8; 32],
642            &keys,
643            &key_id,
644            1234567890,
645        );
646        // Snapshot WITHOUT a `mac` field — must fail validation, not be accepted.
647        let snapshot = wa::SyncdSnapshot {
648            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
649            records: vec![record],
650            key_id: buffa::MessageField::some(wa::KeyId {
651                id: Some(key_id.clone()),
652            }),
653            ..Default::default()
654        };
655        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
656        let mut state = HashState::default();
657        let err = process_snapshot(&snapshot, &mut state, get_keys, true, "regular")
658            .expect_err("missing snapshot mac must fail when validating");
659        assert!(matches!(err, AppStateError::SnapshotMACMismatch));
660    }
661
662    #[test]
663    fn process_snapshot_rejects_missing_key_id_when_validating() {
664        let master_key = [7u8; 32];
665        let keys = expand_app_state_keys(&master_key);
666        let key_id = b"test_key_id".to_vec();
667        let record = create_encrypted_record(
668            wa::syncd_mutation::SyncdOperation::SET,
669            &[1u8; 32],
670            &keys,
671            &key_id,
672            1234567890,
673        );
674        // mac present but top-level key_id absent — the other branch of the gate.
675        let snapshot = wa::SyncdSnapshot {
676            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
677            records: vec![record],
678            mac: Some(vec![9u8; 32]),
679            key_id: buffa::MessageField::none(),
680        };
681        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
682        let mut state = HashState::default();
683        let err = process_snapshot(&snapshot, &mut state, get_keys, true, "regular")
684            .expect_err("missing snapshot key_id must fail when validating");
685        assert!(matches!(err, AppStateError::SnapshotMACMismatch));
686    }
687
688    /// Deterministic reproduction of the fresh-pairing race that PR #972 works
689    /// around. The critical `critical_unblock_low` snapshot (the account's saved
690    /// contacts + push name) can arrive before the encrypted app-state key-share
691    /// has been processed, when a heavy history sync saturates the stream at
692    /// pairing time. The SAME snapshot fails to decode with `KeyNotFound` while
693    /// the key is still in flight, and decodes cleanly the instant the key lands
694    /// — proving the failure is purely a key-ORDERING race, not a bad snapshot.
695    ///
696    /// Mirrors the field symptom: `critical_unblock_low v3: N records` failing
697    /// with "didn't find app state key" (`AppStateProcessor::get_app_state_key`
698    /// -> `backend.get_sync_key` returning `None` -> this `get_keys` closure
699    /// returning `KeyNotFound`).
700    #[test]
701    fn critical_snapshot_fails_key_not_found_until_key_share_lands() {
702        let master_key = [7u8; 32];
703        let keys = expand_app_state_keys(&master_key);
704        let key_id = b"appstate-sync-key-1".to_vec();
705
706        // A critical_unblock_low-style snapshot carrying a contact record.
707        let record = create_encrypted_record(
708            wa::syncd_mutation::SyncdOperation::SET,
709            &[1u8; 32],
710            &keys,
711            &key_id,
712            1_700_000_000,
713        );
714        let snapshot = wa::SyncdSnapshot {
715            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(3) }),
716            records: vec![record],
717            key_id: buffa::MessageField::some(wa::KeyId {
718                id: Some(key_id.clone()),
719            }),
720            ..Default::default()
721        };
722
723        // Leg 1 — key-share NOT yet processed: the decode fails with KeyNotFound,
724        // exactly the "didn't find app state key" the paired companion hits.
725        let key_missing = |_: &[u8]| -> Result<Arc<ExpandedAppStateKeys>, AppStateError> {
726            Err(AppStateError::KeyNotFound)
727        };
728        let mut state = HashState::default();
729        let err = process_snapshot(
730            &snapshot,
731            &mut state,
732            key_missing,
733            false,
734            "critical_unblock_low",
735        )
736        .expect_err("must fail while the key-share is still in flight");
737        assert!(
738            matches!(err, AppStateError::KeyNotFound),
739            "expected KeyNotFound (the 'didn't find app state key' failure), got {err:?}"
740        );
741
742        // Leg 2 — key-share lands: the SAME snapshot decodes cleanly. The failure
743        // was ordering, not the snapshot — so the fix is about ensuring the key is
744        // present (event-driven), never about the snapshot or a longer fixed wait.
745        let key_present = |_: &[u8]| Ok(Arc::new(keys.clone()));
746        let mut state2 = HashState::default();
747        let result = process_snapshot(
748            &snapshot,
749            &mut state2,
750            key_present,
751            false,
752            "critical_unblock_low",
753        )
754        .expect("the same snapshot must decode once the key is present");
755        assert_eq!(result.state.version, 3);
756        assert_eq!(result.mutations.len(), 1, "the contact record must apply");
757    }
758
759    #[test]
760    fn test_process_patch_basic() {
761        let master_key = [7u8; 32];
762        let keys = expand_app_state_keys(&master_key);
763        let key_id = b"test_key_id".to_vec();
764        let index_mac = vec![1; 32];
765
766        let record = create_encrypted_record(
767            wa::syncd_mutation::SyncdOperation::SET,
768            &index_mac,
769            &keys,
770            &key_id,
771            1234567890,
772        );
773
774        let patch = wa::SyncdPatch {
775            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }),
776            mutations: vec![wa::SyncdMutation {
777                operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
778                record: buffa::MessageField::some(record),
779            }],
780            key_id: buffa::MessageField::some(wa::KeyId {
781                id: Some(key_id.clone()),
782            }),
783            ..Default::default()
784        };
785
786        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
787        let get_prev = |_: &[u8]| Ok(None);
788
789        let mut state = HashState::default();
790        let result = process_patch(&patch, &mut state, get_keys, get_prev, false, "regular")
791            .expect("test data should be valid");
792
793        assert_eq!(result.state.version, 2);
794        assert_eq!(result.mutations.len(), 1);
795        assert_eq!(result.added_macs.len(), 1);
796        // Exact MAC bytes (not just counts): catches empty/swapped MACs.
797        assert_eq!(
798            result.added_macs[0].index_mac,
799            generate_index_mac(&index_mac, &keys.index)
800        );
801        assert!(!result.added_macs[0].value_mac.is_empty());
802        assert_ne!(
803            result.added_macs[0].index_mac,
804            result.added_macs[0].value_mac
805        );
806        assert!(result.removed_index_macs.is_empty());
807    }
808
809    /// SyncdOperation is open: a wire value beyond SET/REMOVE must fail the
810    /// patch with a typed error before any hash math, not be folded into SET
811    /// (the closed-enum behavior) and corrupt the LTHash.
812    #[test]
813    fn test_process_patch_rejects_unknown_operation() {
814        let master_key = [7u8; 32];
815        let keys = expand_app_state_keys(&master_key);
816        let key_id = b"test_key_id".to_vec();
817        let index_mac = vec![1; 32];
818
819        let record = create_encrypted_record(
820            wa::syncd_mutation::SyncdOperation::SET,
821            &index_mac,
822            &keys,
823            &key_id,
824            1234567890,
825        );
826
827        let patch = wa::SyncdPatch {
828            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }),
829            mutations: vec![wa::SyncdMutation {
830                operation: Some(buffa::EnumValue::Unknown(7)),
831                record: buffa::MessageField::some(record),
832            }],
833            key_id: buffa::MessageField::some(wa::KeyId {
834                id: Some(key_id.clone()),
835            }),
836            ..Default::default()
837        };
838
839        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
840        let get_prev = |_: &[u8]| Ok(None);
841
842        let mut state = HashState::default();
843        let before = state.hash;
844        let err = process_patch(&patch, &mut state, get_keys, get_prev, false, "regular")
845            .expect_err("unknown operation must be rejected");
846
847        assert!(matches!(err, AppStateError::UnsupportedSyncdOperation(7)));
848        assert_eq!(state.hash, before, "hash must be untouched on rejection");
849        assert_eq!(state.version, 0, "version must be untouched on rejection");
850    }
851
852    fn state_at(version: u64, hash: u8) -> HashState {
853        HashState {
854            version,
855            hash: [hash; 128],
856            index_value_map: HashMap::new(),
857            mac_mismatch_fatal: false,
858        }
859    }
860
861    /// A snapshotMAC mismatch is divergence, not tampering: it is reported so
862    /// the caller can latch the collection, never raised as an error. Only the
863    /// patchMAC proves authorship, and it is checked first.
864    #[test]
865    fn validate_patch_macs_reports_snapshot_divergence_instead_of_failing() {
866        let keys = expand_app_state_keys(&[7u8; 32]);
867        let mut patch = wa::SyncdPatch {
868            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }),
869            snapshot_mac: Some(vec![0u8; 32]),
870            ..Default::default()
871        };
872        patch.patch_mac = Some(generate_patch_mac(&patch, "regular", &keys.patch_mac, 2));
873        let state = state_at(2, 3);
874
875        let verdict = validate_patch_macs(&patch, &state, &keys, "regular", false, true)
876            .expect("an authentic patch must not fail on the aggregate ltHash");
877
878        assert!(verdict.snapshot_mac_diverged);
879    }
880
881    /// Once the collection is latched, the comparison is skipped entirely —
882    /// WA Web's `if (E && k) return null`.
883    #[test]
884    fn validate_patch_macs_skips_snapshot_comparison_once_latched() {
885        let keys = expand_app_state_keys(&[7u8; 32]);
886        let mut patch = wa::SyncdPatch {
887            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(3) }),
888            snapshot_mac: Some(vec![0u8; 32]),
889            ..Default::default()
890        };
891        patch.patch_mac = Some(generate_patch_mac(&patch, "regular", &keys.patch_mac, 3));
892        let mut state = state_at(3, 3);
893        state.mac_mismatch_fatal = true;
894
895        let verdict = validate_patch_macs(&patch, &state, &keys, "regular", false, false)
896            .expect("a latched collection must not re-raise the mismatch");
897
898        assert!(
899            !verdict.snapshot_mac_diverged,
900            "a latched collection must not re-report divergence it already acted on"
901        );
902    }
903
904    /// Latching never weakens the patchMAC: it is the only proof the server
905    /// cannot forge, so it stays fatal even for a diverged collection.
906    #[test]
907    fn validate_patch_macs_rejects_patch_mismatch_even_when_latched() {
908        let keys = expand_app_state_keys(&[7u8; 32]);
909        let patch = wa::SyncdPatch {
910            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }),
911            patch_mac: Some(vec![0u8; 32]),
912            ..Default::default()
913        };
914        let mut state = state_at(2, 5);
915        state.mac_mismatch_fatal = true;
916
917        let err = validate_patch_macs(&patch, &state, &keys, "regular", false, true)
918            .expect_err("neither latching nor hasMissingRemove is a patchMAC bypass");
919
920        assert!(matches!(err, AppStateError::PatchMACMismatch));
921    }
922
923    // F2: WA Web validates the aggregate MACs on every patch, genesis included.
924    // A genesis patch that OMITS one is the curated-baseline attack and stays
925    // fatal — omission is not divergence.
926
927    #[test]
928    fn validate_patch_macs_rejects_genesis_tampered_patch_mac() {
929        let keys = expand_app_state_keys(&[7u8; 32]);
930        let patch = wa::SyncdPatch {
931            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
932            patch_mac: Some(vec![0u8; 32]),
933            ..Default::default()
934        };
935        let err = validate_patch_macs(&patch, &state_at(1, 5), &keys, "regular", true, false)
936            .expect_err("genesis patchMAC must be validated, not skipped");
937        assert!(matches!(err, AppStateError::PatchMACMismatch));
938    }
939
940    #[test]
941    fn validate_patch_macs_rejects_genesis_missing_patch_mac() {
942        let keys = expand_app_state_keys(&[7u8; 32]);
943        // No snapshot_mac and no patch_mac: a curated baseline with the aggregate
944        // MAC stripped. The server can't forge it (no app-state key), so a genesis
945        // patch that omits it must be rejected rather than silently accepted.
946        let patch = wa::SyncdPatch {
947            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
948            ..Default::default()
949        };
950        let err = validate_patch_macs(&patch, &state_at(1, 5), &keys, "regular", true, false)
951            .expect_err("genesis patch without patchMAC must be rejected");
952        assert!(matches!(err, AppStateError::PatchMACMismatch));
953    }
954
955    #[test]
956    fn validate_patch_macs_rejects_genesis_missing_snapshot_mac() {
957        let keys = expand_app_state_keys(&[7u8; 32]);
958        // Valid patchMac but snapshotMac stripped: there is no aggregate to
959        // anchor the fresh baseline to, so this is rejected rather than latched.
960        let mut patch = wa::SyncdPatch {
961            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
962            ..Default::default()
963        };
964        patch.patch_mac = Some(generate_patch_mac(&patch, "regular", &keys.patch_mac, 1));
965        let err = validate_patch_macs(&patch, &state_at(1, 3), &keys, "regular", true, false)
966            .expect_err("genesis patch without snapshotMAC must be rejected");
967        assert!(matches!(err, AppStateError::PatchSnapshotMACMismatch));
968    }
969
970    #[test]
971    fn validate_patch_macs_accepts_genesis_valid_macs() {
972        // Regression guard: a legitimate genesis patch, whose MACs are computed over
973        // the empty-seeded ltHash exactly as WA Web does, must still be accepted.
974        let keys = expand_app_state_keys(&[7u8; 32]);
975        let state = state_at(1, 3);
976        let mut patch = wa::SyncdPatch {
977            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
978            ..Default::default()
979        };
980        patch.snapshot_mac = Some(state.generate_snapshot_mac("regular", &keys.snapshot_mac));
981        patch.patch_mac = Some(generate_patch_mac(&patch, "regular", &keys.patch_mac, 1));
982        let verdict = validate_patch_macs(&patch, &state, &keys, "regular", true, false)
983            .expect("legitimate genesis patch with correct MACs must be accepted");
984        assert!(!verdict.snapshot_mac_diverged);
985    }
986
987    /// The `process_patch` half of the contract: a diverged-but-authentic patch
988    /// applies, and it latches the state so the next one skips the comparison.
989    #[test]
990    fn process_patch_latches_divergence_and_keeps_applying() {
991        let keys = expand_app_state_keys(&[7u8; 32]);
992        let key_id = b"test_key_id".to_vec();
993        let mut patch = wa::SyncdPatch {
994            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(6) }),
995            mutations: vec![wa::SyncdMutation {
996                operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
997                record: buffa::MessageField::some(create_encrypted_record(
998                    wa::syncd_mutation::SyncdOperation::SET,
999                    &[1u8; 32],
1000                    &keys,
1001                    &key_id,
1002                    1,
1003                )),
1004            }],
1005            key_id: buffa::MessageField::some(wa::KeyId {
1006                id: Some(key_id.clone()),
1007            }),
1008            // Signed over an ltHash this client does not share.
1009            snapshot_mac: Some(
1010                state_at(6, 0xEE).generate_snapshot_mac("regular", &keys.snapshot_mac),
1011            ),
1012            ..Default::default()
1013        };
1014        patch.patch_mac = Some(generate_patch_mac(&patch, "regular", &keys.patch_mac, 6));
1015
1016        let gk = |_: &[u8]| Ok(Arc::new(keys.clone()));
1017        let gp = |_: &[u8]| Ok(None);
1018        let mut state = state_at(5, 0x11);
1019        let result = process_patch(&patch, &mut state, gk, gp, true, "regular")
1020            .expect("an authentic patch must apply over a diverged base");
1021
1022        assert_eq!(result.mutations.len(), 1);
1023        assert_eq!(result.state.version, 6);
1024        assert!(
1025            result.state.mac_mismatch_fatal,
1026            "the divergence must be latched so it is not re-detected every patch"
1027        );
1028    }
1029
1030    #[test]
1031    fn process_patch_rejects_duplicate_set_index() {
1032        let master_key = [7u8; 32];
1033        let keys = expand_app_state_keys(&master_key);
1034        let key_id = b"test_key_id".to_vec();
1035        let index_mac = vec![1u8; 32];
1036
1037        // Two SET mutations colliding on the same index within one patch.
1038        let mk = |ts| wa::SyncdMutation {
1039            operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1040            record: buffa::MessageField::some(create_encrypted_record(
1041                wa::syncd_mutation::SyncdOperation::SET,
1042                &index_mac,
1043                &keys,
1044                &key_id,
1045                ts,
1046            )),
1047        };
1048        let mut patch = wa::SyncdPatch {
1049            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
1050            mutations: vec![mk(1), mk(2)],
1051            key_id: buffa::MessageField::some(wa::KeyId {
1052                id: Some(key_id.clone()),
1053            }),
1054            ..Default::default()
1055        };
1056        sign_genesis_patch(&mut patch, &keys, "regular");
1057
1058        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1059        let get_prev = |_: &[u8]| Ok(None);
1060        let mut state = HashState::default();
1061        let err = process_patch(&patch, &mut state, get_keys, get_prev, true, "regular")
1062            .expect_err("duplicate SET index must be rejected when validating");
1063        assert!(matches!(err, AppStateError::DuplicateIndexInPatch));
1064    }
1065
1066    #[test]
1067    fn process_patch_allows_same_index_across_set_and_remove() {
1068        let master_key = [7u8; 32];
1069        let keys = expand_app_state_keys(&master_key);
1070        let key_id = b"test_key_id".to_vec();
1071        let index_mac = vec![2u8; 32];
1072
1073        // SET and REMOVE share an index legitimately: WA Web tracks the two
1074        // operations in separate sets, so this is not tampering.
1075        let set = wa::SyncdMutation {
1076            operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1077            record: buffa::MessageField::some(create_encrypted_record(
1078                wa::syncd_mutation::SyncdOperation::SET,
1079                &index_mac,
1080                &keys,
1081                &key_id,
1082                1,
1083            )),
1084        };
1085        let remove = wa::SyncdMutation {
1086            operation: Some(wa::syncd_mutation::SyncdOperation::REMOVE.into()),
1087            record: buffa::MessageField::some(create_encrypted_record(
1088                wa::syncd_mutation::SyncdOperation::REMOVE,
1089                &index_mac,
1090                &keys,
1091                &key_id,
1092                2,
1093            )),
1094        };
1095        let mut patch = wa::SyncdPatch {
1096            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
1097            mutations: vec![set, remove],
1098            key_id: buffa::MessageField::some(wa::KeyId {
1099                id: Some(key_id.clone()),
1100            }),
1101            ..Default::default()
1102        };
1103        sign_genesis_patch(&mut patch, &keys, "regular");
1104
1105        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1106        let get_prev = |_: &[u8]| Ok(None);
1107        let mut state = HashState::default();
1108        let result = process_patch(&patch, &mut state, get_keys, get_prev, true, "regular");
1109        assert!(
1110            result.is_ok(),
1111            "SET+REMOVE on the same index must be allowed: {result:?}"
1112        );
1113    }
1114
1115    #[test]
1116    fn process_patch_allows_distinct_indices_when_validating() {
1117        let master_key = [7u8; 32];
1118        let keys = expand_app_state_keys(&master_key);
1119        let key_id = b"test_key_id".to_vec();
1120
1121        let mk = |index: &[u8], ts| wa::SyncdMutation {
1122            operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1123            record: buffa::MessageField::some(create_encrypted_record(
1124                wa::syncd_mutation::SyncdOperation::SET,
1125                index,
1126                &keys,
1127                &key_id,
1128                ts,
1129            )),
1130        };
1131        let mut patch = wa::SyncdPatch {
1132            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
1133            mutations: vec![mk(&[3u8; 32], 1), mk(&[4u8; 32], 2)],
1134            key_id: buffa::MessageField::some(wa::KeyId {
1135                id: Some(key_id.clone()),
1136            }),
1137            ..Default::default()
1138        };
1139        sign_genesis_patch(&mut patch, &keys, "regular");
1140
1141        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1142        let get_prev = |_: &[u8]| Ok(None);
1143        let mut state = HashState::default();
1144        let result = process_patch(&patch, &mut state, get_keys, get_prev, true, "regular");
1145        assert!(result.is_ok(), "distinct indices must pass: {result:?}");
1146    }
1147
1148    #[test]
1149    fn test_process_patch_with_overwrite() {
1150        let master_key = [7u8; 32];
1151        let keys = expand_app_state_keys(&master_key);
1152        let key_id = b"test_key_id".to_vec();
1153        let index_mac = vec![1; 32];
1154
1155        // Create initial record
1156        let initial_record = create_encrypted_record(
1157            wa::syncd_mutation::SyncdOperation::SET,
1158            &index_mac,
1159            &keys,
1160            &key_id,
1161            1000,
1162        );
1163        let initial_value_blob = initial_record
1164            .value
1165            .blob
1166            .as_ref()
1167            .expect("test data should be valid");
1168        let initial_value_mac = initial_value_blob[initial_value_blob.len() - 32..].to_vec();
1169
1170        // Process initial snapshot to get starting state
1171        let snapshot = wa::SyncdSnapshot {
1172            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
1173            records: vec![initial_record],
1174            key_id: buffa::MessageField::some(wa::KeyId {
1175                id: Some(key_id.clone()),
1176            }),
1177            ..Default::default()
1178        };
1179
1180        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1181        let mut snapshot_state = HashState::default();
1182        let snapshot_result =
1183            process_snapshot(&snapshot, &mut snapshot_state, get_keys, false, "regular")
1184                .expect("test data should be valid");
1185
1186        // Create overwrite record
1187        let overwrite_record = create_encrypted_record(
1188            wa::syncd_mutation::SyncdOperation::SET,
1189            &index_mac,
1190            &keys,
1191            &key_id,
1192            2000,
1193        );
1194
1195        let patch = wa::SyncdPatch {
1196            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }),
1197            mutations: vec![wa::SyncdMutation {
1198                operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1199                record: buffa::MessageField::some(overwrite_record.clone()),
1200            }],
1201            key_id: buffa::MessageField::some(wa::KeyId {
1202                id: Some(key_id.clone()),
1203            }),
1204            ..Default::default()
1205        };
1206
1207        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1208        // process_patch looks up by the stored index MAC (HMAC of the index bytes).
1209        let stored_index_mac = generate_index_mac(&index_mac, &keys.index);
1210        let get_prev = |idx: &[u8]| {
1211            if idx == stored_index_mac.as_slice() {
1212                Ok(Some(initial_value_mac.clone()))
1213            } else {
1214                Ok(None)
1215            }
1216        };
1217
1218        let mut patch_state = snapshot_result.state.clone();
1219        let result = process_patch(
1220            &patch,
1221            &mut patch_state,
1222            get_keys,
1223            get_prev,
1224            false,
1225            "regular",
1226        )
1227        .expect("test data should be valid");
1228
1229        assert_eq!(result.state.version, 2);
1230        assert_eq!(result.mutations.len(), 1);
1231        assert_eq!(
1232            result.mutations[0]
1233                .action_value
1234                .as_ref()
1235                .and_then(|v| v.timestamp),
1236            Some(2000)
1237        );
1238
1239        // Verify the hash was updated correctly (old value removed, new added)
1240        let new_value_blob = overwrite_record
1241            .value
1242            .into_option()
1243            .expect("test data should be valid")
1244            .blob
1245            .expect("test data should be valid");
1246        let new_value_mac = new_value_blob[new_value_blob.len() - 32..].to_vec();
1247
1248        let expected_hash = WAPATCH_INTEGRITY.subtract_then_add(
1249            &snapshot_result.state.hash,
1250            &[initial_value_mac],
1251            &[new_value_mac],
1252        );
1253
1254        assert_eq!(result.state.hash.as_slice(), expected_hash.as_slice());
1255    }
1256
1257    /// Two SETs of the SAME index in one patch: the second must use the first SET's value
1258    /// as its "previous value" (in-patch last-write-wins), NOT the DB. Locks the O(1) map
1259    /// against a regression to a global last-write map (which would remove the wrong value
1260    /// at position 0) or to no in-patch lookup at all (which would leave both values in the
1261    /// ltHash). DB returns None here, so a correct run must still cancel the first value.
1262    #[test]
1263    fn test_process_patch_in_patch_overwrite_last_write_wins() {
1264        let master_key = [7u8; 32];
1265        let keys = expand_app_state_keys(&master_key);
1266        let key_id = b"test_key_id".to_vec();
1267        let index_mac = vec![1; 32];
1268
1269        let first = create_encrypted_record(
1270            wa::syncd_mutation::SyncdOperation::SET,
1271            &index_mac,
1272            &keys,
1273            &key_id,
1274            1000,
1275        );
1276        let second = create_encrypted_record(
1277            wa::syncd_mutation::SyncdOperation::SET,
1278            &index_mac,
1279            &keys,
1280            &key_id,
1281            2000,
1282        );
1283
1284        let tail = |rec: &wa::SyncdRecord| {
1285            let blob = rec.value.as_option().unwrap().blob.as_ref().unwrap();
1286            blob[blob.len() - 32..].to_vec()
1287        };
1288        let first_tail = tail(&first);
1289        let second_tail = tail(&second);
1290        assert_ne!(
1291            first_tail, second_tail,
1292            "distinct timestamps must yield distinct value MACs"
1293        );
1294
1295        let patch = wa::SyncdPatch {
1296            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
1297            mutations: vec![
1298                wa::SyncdMutation {
1299                    operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1300                    record: buffa::MessageField::some(first),
1301                },
1302                wa::SyncdMutation {
1303                    operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1304                    record: buffa::MessageField::some(second),
1305                },
1306            ],
1307            key_id: buffa::MessageField::some(wa::KeyId {
1308                id: Some(key_id.clone()),
1309            }),
1310            ..Default::default()
1311        };
1312
1313        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1314        let get_prev = |_: &[u8]| Ok(None);
1315
1316        // Fresh state -> had_no_prior_state skips version/MAC checks.
1317        let mut state = HashState::default();
1318        let result = process_patch(&patch, &mut state, get_keys, get_prev, false, "regular")
1319            .expect("two in-patch SETs should process");
1320
1321        assert_eq!(result.mutations.len(), 2);
1322        assert_eq!(result.added_macs.len(), 2);
1323
1324        // Net: first value added then removed by the overwrite -> only the second remains.
1325        const EMPTY: &[Vec<u8>] = &[];
1326        let expected = WAPATCH_INTEGRITY.subtract_then_add(
1327            &[0u8; 128],
1328            EMPTY,
1329            std::slice::from_ref(&second_tail),
1330        );
1331        assert_eq!(
1332            result.state.hash.as_slice(),
1333            expected.as_slice(),
1334            "in-patch overwrite must leave only the second SET's value in the ltHash"
1335        );
1336
1337        // Guard the exact regression: if both values stayed (no in-patch lookup), this differs.
1338        let both_kept =
1339            WAPATCH_INTEGRITY.subtract_then_add(&[0u8; 128], EMPTY, &[first_tail, second_tail]);
1340        assert_ne!(
1341            result.state.hash.as_slice(),
1342            both_kept.as_slice(),
1343            "both SET values must not remain: in-patch overwrite regressed"
1344        );
1345    }
1346
1347    /// SET+REMOVE on the same index in one patch: WA Web index-mode pre-collects the
1348    /// REMOVEd indices and suppresses the SET's subtraction (the REMOVE owns it, and
1349    /// it subtracts the STORE value, never the in-patch one). Net must be
1350    /// base + set_tail - store_prev, which also agrees with the persisted MAC store
1351    /// (delete removed_index_macs then put added_macs leaves the index present with
1352    /// the SET's value).
1353    #[test]
1354    fn test_process_patch_set_plus_remove_same_index_wa_web_index_mode() {
1355        let master_key = [7u8; 32];
1356        let keys = expand_app_state_keys(&master_key);
1357        let key_id = b"test_key_id".to_vec();
1358        let index_mac = vec![3; 32];
1359        let store_prev = vec![9u8; 32];
1360
1361        let set = create_encrypted_record(
1362            wa::syncd_mutation::SyncdOperation::SET,
1363            &index_mac,
1364            &keys,
1365            &key_id,
1366            2000,
1367        );
1368        let remove = create_encrypted_record(
1369            wa::syncd_mutation::SyncdOperation::REMOVE,
1370            &index_mac,
1371            &keys,
1372            &key_id,
1373            1000,
1374        );
1375
1376        let tail = |rec: &wa::SyncdRecord| {
1377            let blob = rec.value.as_option().unwrap().blob.as_ref().unwrap();
1378            blob[blob.len() - 32..].to_vec()
1379        };
1380        let set_tail = tail(&set);
1381
1382        let build_patch = |mutations: Vec<wa::SyncdMutation>| wa::SyncdPatch {
1383            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }),
1384            mutations,
1385            key_id: buffa::MessageField::some(wa::KeyId {
1386                id: Some(key_id.clone()),
1387            }),
1388            ..Default::default()
1389        };
1390        let set_mutation = wa::SyncdMutation {
1391            operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1392            record: buffa::MessageField::some(set),
1393        };
1394        let remove_mutation = wa::SyncdMutation {
1395            operation: Some(wa::syncd_mutation::SyncdOperation::REMOVE.into()),
1396            record: buffa::MessageField::some(remove),
1397        };
1398
1399        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1400        let get_prev = |_: &[u8]| Ok(Some(store_prev.clone()));
1401
1402        let expected = WAPATCH_INTEGRITY.subtract_then_add(
1403            &[0u8; 128],
1404            std::slice::from_ref(&store_prev),
1405            std::slice::from_ref(&set_tail),
1406        );
1407
1408        // Both orderings must yield the same hash: the math is per-index, not
1409        // per-position (WA Web accumulates adds/subtracts in maps).
1410        for (label, mutations) in [
1411            (
1412                "set-then-remove",
1413                vec![set_mutation.clone(), remove_mutation.clone()],
1414            ),
1415            ("remove-then-set", vec![remove_mutation, set_mutation]),
1416        ] {
1417            let patch = build_patch(mutations);
1418            let mut state = HashState::default();
1419            let result = process_patch(&patch, &mut state, get_keys, get_prev, false, "regular")
1420                .unwrap_or_else(|e| panic!("{label} should process: {e:?}"));
1421
1422            assert_eq!(
1423                result.state.hash.as_slice(),
1424                expected.as_slice(),
1425                "{label}: net must be base + set_tail - store_prev (WA Web index-mode)"
1426            );
1427
1428            // The MAC store ends with the index present (delete-then-put), so the
1429            // hash above is the only self-consistent answer. The wire blob is the
1430            // HMAC of the index identity, recomputed by decode_record.
1431            let wire_index_mac = generate_index_mac(&index_mac, &keys.index);
1432            assert_eq!(result.added_macs.len(), 1, "{label}");
1433            assert_eq!(result.added_macs[0].index_mac, wire_index_mac, "{label}");
1434            assert_eq!(result.added_macs[0].value_mac, set_tail, "{label}");
1435            assert_eq!(
1436                result.removed_index_macs,
1437                vec![wire_index_mac.clone()],
1438                "{label}"
1439            );
1440        }
1441    }
1442
1443    /// WA Web: validatePatchVersion checks `localVersion !== patchVersion - 1`.
1444    /// If the patch version is not exactly local_version + 1, it rejects with
1445    /// "syncd-version-check-error-local-version-{greater|less}-than-expected".
1446    #[test]
1447    fn test_patch_version_rollback_rejected() {
1448        let master_key = [7u8; 32];
1449        let keys = expand_app_state_keys(&master_key);
1450        let key_id = b"test_key_id".to_vec();
1451        let index_mac = vec![99; 32];
1452
1453        let record = create_encrypted_record(
1454            wa::syncd_mutation::SyncdOperation::SET,
1455            &index_mac,
1456            &keys,
1457            &key_id,
1458            5000,
1459        );
1460
1461        // Current state is at version 5
1462        let mut state = HashState {
1463            version: 5,
1464            ..Default::default()
1465        };
1466
1467        // Patch claims version 3 (rollback: 3 < 5 + 1)
1468        let patch = wa::SyncdPatch {
1469            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(3) }),
1470            mutations: vec![wa::SyncdMutation {
1471                operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1472                record: buffa::MessageField::some(record),
1473            }],
1474            key_id: buffa::MessageField::some(wa::KeyId {
1475                id: Some(key_id.clone()),
1476            }),
1477            ..Default::default()
1478        };
1479
1480        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1481        let get_prev = |_: &[u8]| -> Result<Option<Vec<u8>>, AppStateError> { Ok(None) };
1482
1483        let err = process_patch(&patch, &mut state, get_keys, get_prev, false, "regular")
1484            .expect_err("rollback patch should be rejected");
1485
1486        assert!(
1487            matches!(
1488                err,
1489                AppStateError::PatchVersionMismatch {
1490                    expected: 6,
1491                    got: 3
1492                }
1493            ),
1494            "expected PatchVersionMismatch {{ expected: 6, got: 3 }}, got: {err:?}"
1495        );
1496    }
1497
1498    /// WA Web: version gap (e.g., local=5, patch=8) also triggers
1499    /// "syncd-version-check-error-local-version-less-than-expected".
1500    #[test]
1501    fn test_patch_version_gap_rejected() {
1502        let master_key = [7u8; 32];
1503        let keys = expand_app_state_keys(&master_key);
1504        let key_id = b"test_key_id".to_vec();
1505        let index_mac = vec![99; 32];
1506
1507        let record = create_encrypted_record(
1508            wa::syncd_mutation::SyncdOperation::SET,
1509            &index_mac,
1510            &keys,
1511            &key_id,
1512            6000,
1513        );
1514
1515        // Current state is at version 5
1516        let mut state = HashState {
1517            version: 5,
1518            ..Default::default()
1519        };
1520
1521        // Patch claims version 8 (gap: 8 != 5 + 1)
1522        let patch = wa::SyncdPatch {
1523            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(8) }),
1524            mutations: vec![wa::SyncdMutation {
1525                operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1526                record: buffa::MessageField::some(record),
1527            }],
1528            key_id: buffa::MessageField::some(wa::KeyId {
1529                id: Some(key_id.clone()),
1530            }),
1531            ..Default::default()
1532        };
1533
1534        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1535        let get_prev = |_: &[u8]| -> Result<Option<Vec<u8>>, AppStateError> { Ok(None) };
1536
1537        let err = process_patch(&patch, &mut state, get_keys, get_prev, false, "regular")
1538            .expect_err("version gap should be rejected");
1539
1540        assert!(
1541            matches!(
1542                err,
1543                AppStateError::PatchVersionMismatch {
1544                    expected: 6,
1545                    got: 8
1546                }
1547            ),
1548            "expected PatchVersionMismatch {{ expected: 6, got: 8 }}, got: {err:?}"
1549        );
1550    }
1551
1552    /// Consecutive patch (local=5, patch=6) should succeed.
1553    #[test]
1554    fn test_patch_version_consecutive_accepted() {
1555        let master_key = [7u8; 32];
1556        let keys = expand_app_state_keys(&master_key);
1557        let key_id = b"test_key_id".to_vec();
1558        let index_mac = vec![99; 32];
1559
1560        let record = create_encrypted_record(
1561            wa::syncd_mutation::SyncdOperation::SET,
1562            &index_mac,
1563            &keys,
1564            &key_id,
1565            7000,
1566        );
1567
1568        // Current state at version 5
1569        let mut state = HashState {
1570            version: 5,
1571            ..Default::default()
1572        };
1573
1574        // Patch version 6 (exactly local + 1)
1575        let patch = wa::SyncdPatch {
1576            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(6) }),
1577            mutations: vec![wa::SyncdMutation {
1578                operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1579                record: buffa::MessageField::some(record),
1580            }],
1581            key_id: buffa::MessageField::some(wa::KeyId {
1582                id: Some(key_id.clone()),
1583            }),
1584            ..Default::default()
1585        };
1586
1587        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1588        let get_prev = |_: &[u8]| -> Result<Option<Vec<u8>>, AppStateError> { Ok(None) };
1589
1590        let result = process_patch(&patch, &mut state, get_keys, get_prev, false, "regular")
1591            .expect("consecutive version should be accepted");
1592        assert_eq!(result.state.version, 6);
1593    }
1594
1595    /// When local version is 0 (no prior state), any patch version should be
1596    /// accepted — we can't validate version continuity without a baseline.
1597    /// WA Web: "empty lthash" is retryable, but the patch still applies.
1598    #[test]
1599    fn test_patch_version_check_skipped_when_no_prior_state() {
1600        let master_key = [7u8; 32];
1601        let keys = expand_app_state_keys(&master_key);
1602        let key_id = b"test_key_id".to_vec();
1603        let index_mac = vec![99; 32];
1604
1605        let record = create_encrypted_record(
1606            wa::syncd_mutation::SyncdOperation::SET,
1607            &index_mac,
1608            &keys,
1609            &key_id,
1610            8000,
1611        );
1612
1613        // Fresh state — version 0, empty hash
1614        let mut state = HashState::default();
1615
1616        // Patch version 42 — should be accepted since no prior state
1617        let patch = wa::SyncdPatch {
1618            version: buffa::MessageField::some(wa::SyncdVersion { version: Some(42) }),
1619            mutations: vec![wa::SyncdMutation {
1620                operation: Some(wa::syncd_mutation::SyncdOperation::SET.into()),
1621                record: buffa::MessageField::some(record),
1622            }],
1623            key_id: buffa::MessageField::some(wa::KeyId {
1624                id: Some(key_id.clone()),
1625            }),
1626            ..Default::default()
1627        };
1628
1629        let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone()));
1630        let get_prev = |_: &[u8]| -> Result<Option<Vec<u8>>, AppStateError> { Ok(None) };
1631
1632        let result = process_patch(&patch, &mut state, get_keys, get_prev, false, "regular")
1633            .expect("no-prior-state should skip version check");
1634        assert_eq!(result.state.version, 42);
1635    }
1636}