Skip to main content

wechat_profile/
mmkv.rs

1//! Read-only identity fields from the inspected MMKV v4 layout. The format key
2//! is caller-provided; no artifact-derived key or account material is shipped.
3use crate::{ProfileError, Result};
4use std::collections::BTreeMap;
5use zeroize::Zeroizing;
6
7pub struct ProfileIdentity {
8    pub username: String,
9    pub uin: u32,
10    pub device_id: Vec<u8>,
11}
12impl ProfileIdentity {
13    /// The complete persisted installation ID remains in MMKV. The inspected
14    /// native BaseRequest transmits only its first sixteen bytes.
15    pub fn request_device_id(&self) -> Result<&[u8]> {
16        if !(16..=256).contains(&self.device_id.len()) {
17            return Err(ProfileError::InvalidMetadata);
18        }
19        Ok(&self.device_id[..16])
20    }
21}
22fn var(input: &[u8], at: &mut usize) -> Result<u64> {
23    let mut value = 0;
24    for i in 0..10 {
25        let byte = *input.get(*at).ok_or(ProfileError::InvalidMetadata)?;
26        *at += 1;
27        if i == 9 && byte > 1 {
28            return Err(ProfileError::InvalidMetadata);
29        }
30        value |= ((byte & 127) as u64) << (i * 7);
31        if byte & 128 == 0 {
32            return Ok(value);
33        }
34    }
35    Err(ProfileError::InvalidMetadata)
36}
37fn span<'a>(input: &'a [u8], at: &mut usize) -> Result<&'a [u8]> {
38    let n = usize::try_from(var(input, at)?).map_err(|_| ProfileError::ResourceLimit)?;
39    let end = at.checked_add(n).ok_or(ProfileError::ResourceLimit)?;
40    let value = input.get(*at..end).ok_or(ProfileError::InvalidMetadata)?;
41    *at = end;
42    Ok(value)
43}
44fn string(input: &[u8]) -> Result<Vec<u8>> {
45    let mut at = 0;
46    let value = span(input, &mut at)?.to_vec();
47    if at != input.len() {
48        return Err(ProfileError::InvalidMetadata);
49    }
50    Ok(value)
51}
52/// CRC32 checks the captured ciphertext span; it is not authentication. Identity
53/// values must also match the independently authenticated native account.
54pub fn read_mmkv_identity(
55    data: &[u8],
56    metadata: &[u8],
57    format_key: &[u8],
58) -> Result<ProfileIdentity> {
59    if format_key.len() != 16
60        || data.len() < 4
61        || data.len() > 4 * 1024 * 1024 + 4
62        || metadata.len() < 40
63        || metadata.len() > 4096
64    {
65        return Err(ProfileError::InvalidMetadata);
66    }
67    let number = |offset| {
68        u32::from_le_bytes(
69            metadata[offset..offset + 4]
70                .try_into()
71                .expect("metadata bound"),
72        )
73    };
74    if number(4) != 4 || metadata.len() >= 108 && number(104) != 0 {
75        return Err(ProfileError::InvalidMetadata);
76    }
77    let actual = number(28) as usize;
78    let ciphertext = data
79        .get(4..4 + actual)
80        .ok_or(ProfileError::InvalidMetadata)?;
81    if crc32fast::hash(ciphertext) != number(0) {
82        return Err(ProfileError::InvalidMetadata);
83    }
84    let plain = Zeroizing::new(
85        openssl::symm::decrypt(
86            openssl::symm::Cipher::aes_128_cfb128(),
87            format_key,
88            Some(&metadata[12..28]),
89            ciphertext,
90        )
91        .map_err(|_| ProfileError::InvalidMetadata)?,
92    );
93    let mut at = 0;
94    var(&plain, &mut at)?;
95    let mut entries = BTreeMap::new();
96    let mut count = 0;
97    while at < plain.len() {
98        let key = std::str::from_utf8(span(&plain, &mut at)?)
99            .map_err(|_| ProfileError::InvalidMetadata)?;
100        if key.is_empty() || key.len() > 4096 || key.chars().any(char::is_control) {
101            return Err(ProfileError::InvalidMetadata);
102        }
103        let value = span(&plain, &mut at)?;
104        if value.is_empty() {
105            entries.remove(key);
106        } else {
107            entries.insert(key, value);
108        }
109        count += 1;
110        if count > 65536 {
111            return Err(ProfileError::ResourceLimit);
112        }
113    }
114    let get = |name: &str| {
115        entries
116            .get(name)
117            .copied()
118            .ok_or(ProfileError::InvalidMetadata)
119    };
120    let username = String::from_utf8(string(get("mmkv_key_user_name")?)?)
121        .map_err(|_| ProfileError::InvalidMetadata)?;
122    let mut at = 0;
123    let raw = get("mmkv_key_latest_login_uin")?;
124    let uin = u32::try_from(var(raw, &mut at)?).map_err(|_| ProfileError::InvalidMetadata)?;
125    if at != raw.len()
126        || uin == 0
127        || username.is_empty()
128        || username.len() > 256
129        || username.chars().any(char::is_control)
130    {
131        return Err(ProfileError::InvalidMetadata);
132    }
133    let device_id = string(get("key_wechat_device_id")?)?;
134    if device_id.len() < 16 || device_id.len() > 256 {
135        return Err(ProfileError::InvalidMetadata);
136    }
137    Ok(ProfileIdentity {
138        username,
139        uin,
140        device_id,
141    })
142}
143#[cfg(test)]
144mod tests {
145    use super::*;
146    fn append(value: u64, out: &mut Vec<u8>) {
147        let mut value = value;
148        while value >= 128 {
149            out.push((value as u8 & 127) | 128);
150            value >>= 7;
151        }
152        out.push(value as u8);
153    }
154    fn framed(value: &[u8]) -> Vec<u8> {
155        let mut out = vec![];
156        append(value.len() as u64, &mut out);
157        out.extend(value);
158        out
159    }
160    fn fixture() -> (Vec<u8>, Vec<u8>) {
161        let mut plain = vec![0];
162        for (key, value) in [
163            ("mmkv_key_user_name", framed(b"synthetic_self")),
164            ("mmkv_key_latest_login_uin", vec![7]),
165            ("key_wechat_device_id", framed(b"synthetic-device-16")),
166        ] {
167            plain.extend(framed(key.as_bytes()));
168            plain.extend(framed(&value));
169        }
170        let cipher = openssl::symm::encrypt(
171            openssl::symm::Cipher::aes_128_cfb128(),
172            &[9; 16],
173            Some(&[3; 16]),
174            &plain,
175        )
176        .unwrap();
177        let mut metadata = vec![0; 112];
178        metadata[..4].copy_from_slice(&crc32fast::hash(&cipher).to_le_bytes());
179        metadata[4..8].copy_from_slice(&4u32.to_le_bytes());
180        metadata[12..28].copy_from_slice(&[3; 16]);
181        metadata[28..32].copy_from_slice(&(cipher.len() as u32).to_le_bytes());
182        let mut data = vec![0; 4];
183        data.extend(cipher);
184        (data, metadata)
185    }
186    #[test]
187    fn identity_requires_consistent_version_crc_key_and_complete_scalar() {
188        let (mut data, mut metadata) = fixture();
189        let id = read_mmkv_identity(&data, &metadata, &[9; 16]).unwrap();
190        assert_eq!(id.username, "synthetic_self");
191        assert_eq!(id.uin, 7);
192        assert!(read_mmkv_identity(&data, &metadata, &[8; 16]).is_err());
193        data[4] ^= 1;
194        assert!(read_mmkv_identity(&data, &metadata, &[9; 16]).is_err());
195        data[4] ^= 1;
196        metadata[104] = 1;
197        assert!(read_mmkv_identity(&data, &metadata, &[9; 16]).is_err());
198        metadata[104] = 0;
199        metadata[4] = 5;
200        assert!(read_mmkv_identity(&data, &metadata, &[9; 16]).is_err());
201    }
202}
203
204/// Decoded v4 records are secrets; Debug and serde intentionally unavailable.
205pub struct MmkvRecords {
206    entries: BTreeMap<String, Zeroizing<Vec<u8>>>,
207    plain: Zeroizing<Vec<u8>>,
208}
209impl MmkvRecords {
210    pub fn raw_value(&self, name: &str) -> Option<&[u8]> {
211        self.entries.get(name).map(|v| v.as_slice())
212    }
213    pub fn string_value(&self, name: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
214        self.raw_value(name)
215            .map(string)
216            .transpose()
217            .map(|v| v.map(Zeroizing::new))
218    }
219    pub fn contains(&self, name: &str) -> bool {
220        self.entries.contains_key(name)
221    }
222    pub fn len(&self) -> usize {
223        self.entries.len()
224    }
225    pub fn is_empty(&self) -> bool {
226        self.entries.is_empty()
227    }
228}
229fn append_var(mut value: u64, out: &mut Vec<u8>) {
230    while value >= 128 {
231        out.push((value as u8 & 127) | 128);
232        value >>= 7;
233    }
234    out.push(value as u8)
235}
236fn append_span(value: &[u8], out: &mut Vec<u8>) {
237    append_var(value.len() as u64, out);
238    out.extend(value)
239}
240/// Native string/Data encoding used inside each MMKV value record.
241pub fn mmkv_string_value(value: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
242    if value.len() > 1024 * 1024 {
243        return Err(ProfileError::ResourceLimit);
244    }
245    let mut output = Zeroizing::new(Vec::new());
246    append_span(value, &mut output);
247    Ok(output)
248}
249pub fn decode_mmkv_records(data: &[u8], metadata: &[u8], key: &[u8]) -> Result<MmkvRecords> {
250    if key.len() != 16
251        || data.len() < 4
252        || data.len() > 4 * 1024 * 1024 + 4
253        || metadata.len() < 112
254        || metadata.len() > 4096
255    {
256        return Err(ProfileError::InvalidMetadata);
257    }
258    let n = |o| u32::from_le_bytes(metadata[o..o + 4].try_into().expect("bounded"));
259    if n(4) != 4 || metadata[104..112].iter().any(|b| *b != 0) {
260        return Err(ProfileError::InvalidMetadata);
261    }
262    let length = n(28) as usize;
263    let cipher = data
264        .get(4..4 + length)
265        .ok_or(ProfileError::InvalidMetadata)?;
266    if crc32fast::hash(cipher) != n(0) {
267        return Err(ProfileError::InvalidMetadata);
268    }
269    let plain = Zeroizing::new(
270        openssl::symm::decrypt(
271            openssl::symm::Cipher::aes_128_cfb128(),
272            key,
273            Some(&metadata[12..28]),
274            cipher,
275        )
276        .map_err(|_| ProfileError::InvalidMetadata)?,
277    );
278    let mut at = 0;
279    var(&plain, &mut at)?;
280    let mut entries = BTreeMap::new();
281    let mut records = 0;
282    while at < plain.len() {
283        let name = std::str::from_utf8(span(&plain, &mut at)?)
284            .map_err(|_| ProfileError::InvalidMetadata)?;
285        if name.is_empty() || name.len() > 4096 || name.chars().any(char::is_control) {
286            return Err(ProfileError::InvalidMetadata);
287        }
288        let value = span(&plain, &mut at)?;
289        if value.is_empty() {
290            entries.remove(name);
291        } else {
292            entries.insert(name.to_owned(), Zeroizing::new(value.to_vec()));
293        }
294        records += 1;
295        if records > 65536 {
296            return Err(ProfileError::ResourceLimit);
297        }
298    }
299    Ok(MmkvRecords { entries, plain })
300}
301/// Complete staged pair. Publication across these files requires caller recovery;
302/// this object alone never modifies any filesystem or native sync checkpoint.
303pub struct StagedMmkv {
304    data: Zeroizing<Vec<u8>>,
305    metadata: Vec<u8>,
306    pub changed_keys: Vec<String>,
307    expected_data_sha256: Option<[u8; 32]>,
308    expected_meta_sha256: Option<[u8; 32]>,
309}
310impl StagedMmkv {
311    pub fn data(&self) -> &[u8] {
312        &self.data
313    }
314    pub fn metadata(&self) -> &[u8] {
315        &self.metadata
316    }
317}
318/// Append while preserving IV/sequence/flags and the complete old ciphertext
319/// prefix. A full data file grows to the next 4096-byte page, at most 4 MiB;
320/// records are never compacted or discarded. Identical values are byte-idempotent.
321/// Values are already MMKV-encoded; None is a tombstone. Unknown keys survive.
322pub fn stage_mmkv_values(
323    data: &[u8],
324    metadata: &[u8],
325    key: &[u8],
326    updates: &[(String, Option<Vec<u8>>)],
327) -> Result<StagedMmkv> {
328    let records = decode_mmkv_records(data, metadata, key)?;
329    let mut plain = records.plain.clone();
330    let mut changed = vec![];
331    let mut names = std::collections::BTreeSet::new();
332    for (name, value) in updates {
333        if name.is_empty()
334            || name.len() > 4096
335            || name.chars().any(char::is_control)
336            || !names.insert(name)
337        {
338            return Err(ProfileError::InvalidMetadata);
339        }
340        if value
341            .as_ref()
342            .is_some_and(|v| v.is_empty() || v.len() > 1024 * 1024)
343        {
344            return Err(ProfileError::InvalidMetadata);
345        }
346        if records.raw_value(name) == value.as_deref() {
347            continue;
348        }
349        append_span(name.as_bytes(), &mut plain);
350        append_span(value.as_deref().unwrap_or_default(), &mut plain);
351        if plain.len() + 4 > data.len().max(4 * 1024 * 1024) {
352            return Err(ProfileError::ResourceLimit);
353        }
354        changed.push(name.clone());
355    }
356    let capacity = if plain.len() > data.len() - 4 {
357        let rounded = (plain.len() + 4).div_ceil(4096) * 4096;
358        if rounded > 4 * 1024 * 1024 {
359            return Err(ProfileError::ResourceLimit);
360        }
361        rounded
362    } else {
363        data.len()
364    };
365    let cipher = Zeroizing::new(
366        openssl::symm::encrypt(
367            openssl::symm::Cipher::aes_128_cfb128(),
368            key,
369            Some(&metadata[12..28]),
370            &plain,
371        )
372        .map_err(|_| ProfileError::InvalidMetadata)?,
373    );
374    let old = u32::from_le_bytes(metadata[28..32].try_into().expect("bounded")) as usize;
375    if cipher.get(..old) != data.get(4..4 + old) {
376        return Err(ProfileError::InvalidMetadata);
377    }
378    let mut output = Zeroizing::new(data.to_vec());
379    output.resize(capacity, 0);
380    output[4..4 + cipher.len()].copy_from_slice(&cipher);
381    let mut meta = metadata.to_vec();
382    meta[..4].copy_from_slice(&crc32fast::hash(&cipher).to_le_bytes());
383    meta[28..32].copy_from_slice(&(cipher.len() as u32).to_le_bytes());
384    let checked = decode_mmkv_records(&output, &meta, key)?;
385    for (name, value) in updates {
386        if checked.raw_value(name) != value.as_deref() {
387            return Err(ProfileError::InvalidMetadata);
388        }
389    }
390    Ok(StagedMmkv {
391        data: output,
392        metadata: meta,
393        changed_keys: changed,
394        expected_data_sha256: Some(mmkv_hash(data)),
395        expected_meta_sha256: Some(mmkv_hash(metadata)),
396    })
397}
398/// New nonexpiring v4 pair with explicit caller-generated key and IV. Fixed
399/// capacity is the total page-aligned data-file size, including its 4-byte
400/// reserved header. The metadata occupies one 4096-byte page, matching the
401/// inspected native files. Maximum data capacity is 4 MiB.
402pub fn create_mmkv_v4(key: &[u8], iv: [u8; 16], capacity: usize) -> Result<StagedMmkv> {
403    if key.len() != 16
404        || !(4096..=4 * 1024 * 1024).contains(&capacity)
405        || !capacity.is_multiple_of(4096)
406    {
407        return Err(ProfileError::InvalidMetadata);
408    }
409    let cipher = openssl::symm::encrypt(
410        openssl::symm::Cipher::aes_128_cfb128(),
411        key,
412        Some(&iv),
413        &[0],
414    )
415    .map_err(|_| ProfileError::InvalidMetadata)?;
416    let mut data = Zeroizing::new(vec![0; capacity]);
417    data[4] = cipher[0];
418    let mut meta = vec![0; 4096];
419    meta[..4].copy_from_slice(&crc32fast::hash(&cipher).to_le_bytes());
420    meta[4..8].copy_from_slice(&4u32.to_le_bytes());
421    meta[12..28].copy_from_slice(&iv);
422    meta[28..32].copy_from_slice(&1u32.to_le_bytes());
423    Ok(StagedMmkv {
424        data,
425        metadata: meta,
426        changed_keys: vec![],
427        expected_data_sha256: None,
428        expected_meta_sha256: None,
429    })
430}
431/// Stage the proved native SyncKey string field in an explicitly selected account
432/// store. This does not guess the path, write the legacy file, or advance a cursor.
433pub fn stage_mmkv_sync_key(
434    data: &[u8],
435    metadata: &[u8],
436    key: &[u8],
437    sync_key: &[u8],
438) -> Result<StagedMmkv> {
439    if sync_key.is_empty() || sync_key.len() > 1024 * 1024 {
440        return Err(ProfileError::InvalidMetadata);
441    }
442    let value = mmkv_string_value(sync_key)?;
443    stage_mmkv_values(
444        data,
445        metadata,
446        key,
447        &[("mmkv_key_sync_key".into(), Some(value.to_vec()))],
448    )
449}
450
451fn mmkv_hash(bytes: &[u8]) -> [u8; 32] {
452    use sha2::{Digest, Sha256};
453    Sha256::digest(bytes).into()
454}
455#[derive(Clone, Debug, serde::Serialize)]
456#[serde(rename_all = "camelCase")]
457pub struct MmkvPublication {
458    pub data_replaced: bool,
459    pub metadata_replaced: bool,
460    pub already_applied: bool,
461}
462fn safe_pair_path(path: &std::path::Path) -> Result<std::path::PathBuf> {
463    let parent = path.parent().ok_or(ProfileError::InvalidQuery)?;
464    let canonical = std::fs::canonicalize(parent)?;
465    if canonical != parent {
466        return Err(ProfileError::UnsupportedFile);
467    }
468    let name = path.file_name().ok_or(ProfileError::InvalidQuery)?;
469    let target = canonical.join(name);
470    for p in [
471        &target,
472        &target.with_file_name(format!("{}.crc", name.to_string_lossy())),
473    ] {
474        let m = std::fs::symlink_metadata(p)?;
475        if !m.is_file() || wechat_platform::is_link(&m) {
476            return Err(ProfileError::UnsupportedFile);
477        }
478    }
479    Ok(target)
480}
481fn replace_mmkv_file(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
482    use std::io::Write;
483    let parent = path.parent().ok_or(ProfileError::InvalidQuery)?;
484    let mut staged = wechat_platform::private_tempfile_in(parent, ".wx-mmkv-")?;
485    staged.write_all(bytes)?;
486    staged.as_file().sync_all()?;
487    staged
488        .persist(path)
489        .map_err(|e| ProfileError::Io(e.error))?;
490    wechat_platform::sync_dir(parent)?;
491    Ok(())
492}
493/// Publish a previously staged append while the caller holds exclusive profile
494/// ownership and its durable intent. Data is replaced first: the old metadata
495/// still validates the unchanged ciphertext prefix. Metadata publication exposes
496/// the new span. Replay accepts the intermediate new-data/old-metadata state.
497/// This is not atomic with database, credential or sidecar cursor transactions.
498pub fn publish_mmkv_append(
499    path: impl AsRef<std::path::Path>,
500    staged: &StagedMmkv,
501    options: &crate::WriteOptions,
502) -> Result<MmkvPublication> {
503    publish_mmkv_append_guarded(path, staged, options, || true)
504}
505/// Recheck an external authorization fence before each independent data/metadata
506/// commit. If the fence closes between commits, the caller retains its sealed
507/// intent and the ordinary append replay rules accept the data-first state.
508pub fn publish_mmkv_append_guarded(
509    path: impl AsRef<std::path::Path>,
510    staged: &StagedMmkv,
511    options: &crate::WriteOptions,
512    mut allowed: impl FnMut() -> bool,
513) -> Result<MmkvPublication> {
514    if !options.exclusive_ownership_confirmed {
515        return Err(ProfileError::SnapshotRequired);
516    }
517    if options.durable_intent_id.is_empty() || options.durable_intent_id.len() > 256 {
518        return Err(ProfileError::InvalidQuery);
519    }
520    let old_data = staged
521        .expected_data_sha256
522        .ok_or(ProfileError::InvalidMetadata)?;
523    let old_meta = staged
524        .expected_meta_sha256
525        .ok_or(ProfileError::InvalidMetadata)?;
526    let path = safe_pair_path(path.as_ref())?;
527    let meta = path.with_file_name(format!(
528        "{}.crc",
529        path.file_name()
530            .ok_or(ProfileError::InvalidQuery)?
531            .to_string_lossy()
532    ));
533    let current_data = mmkv_hash(&std::fs::read(&path)?);
534    let current_meta = mmkv_hash(&std::fs::read(&meta)?);
535    let new_data = mmkv_hash(staged.data());
536    let new_meta = mmkv_hash(staged.metadata());
537    if current_data == new_data && current_meta == new_meta {
538        return Ok(MmkvPublication {
539            data_replaced: false,
540            metadata_replaced: false,
541            already_applied: true,
542        });
543    }
544    if current_meta != old_meta || (current_data != old_data && current_data != new_data) {
545        return Err(ProfileError::SourceChanged);
546    }
547    let replace_data = current_data != new_data;
548    if replace_data {
549        if !allowed() {
550            return Err(ProfileError::SourceChanged);
551        }
552        replace_mmkv_file(&path, staged.data())?;
553    }
554    if mmkv_hash(&std::fs::read(&meta)?) != old_meta
555        || mmkv_hash(&std::fs::read(&path)?) != new_data
556    {
557        return Err(ProfileError::SourceChanged);
558    }
559    if !allowed() {
560        return Err(ProfileError::SourceChanged);
561    }
562    replace_mmkv_file(&meta, staged.metadata())?;
563    Ok(MmkvPublication {
564        data_replaced: replace_data,
565        metadata_replaced: true,
566        already_applied: false,
567    })
568}
569
570/// The inspected per-account v2 store derives its CFB key from the account name,
571/// independently of global format keys, database passwords and login credentials.
572/// This is a format derivation, not a means of authenticating an account.
573pub fn account_mmkv_key(username: &str) -> Result<Zeroizing<Vec<u8>>> {
574    use md5::{Digest, Md5};
575    crate::projection::identity(username)?;
576    Ok(Zeroizing::new(Md5::digest(username.as_bytes()).to_vec()))
577}
578/// Select the observed account login store only after exact username/UIN binding.
579pub fn account_sync_store_path(
580    location: &crate::ProfileLocation,
581    identity: &ProfileIdentity,
582) -> Result<std::path::PathBuf> {
583    if location.account_directory()
584        != crate::official_account_directory(&identity.username, identity.uin)?
585    {
586        return Err(ProfileError::AccountMismatch);
587    }
588    Ok(location.root().join("config/login_configv2"))
589}
590/// The inspected contact drain stores its independent SyncKeyBuffer here.
591/// It does not use `mmkv_key_byp_sync_key` from the login store.
592pub fn account_contact_sync_store_path(
593    location: &crate::ProfileLocation,
594    identity: &ProfileIdentity,
595) -> Result<std::path::PathBuf> {
596    account_sync_store_path(location, identity)?;
597    Ok(location.root().join("config/rs_config"))
598}
599pub fn stage_account_contact_sync_key(
600    data: &[u8],
601    metadata: &[u8],
602    username: &str,
603    cursor: &[u8],
604) -> Result<StagedMmkv> {
605    if cursor.len() > 65536 {
606        return Err(ProfileError::ResourceLimit);
607    }
608    let key = account_mmkv_key(username)?;
609    stage_mmkv_values(
610        data,
611        metadata,
612        &key,
613        &[("sync_11".into(), Some(mmkv_string_value(cursor)?.to_vec()))],
614    )
615}
616/// Read the desktop cursor from a caller-confirmed stopped/coherent account.
617/// The binary cursor is independent of saved-login and business-session keys.
618pub fn read_account_sync_key(
619    location: &crate::ProfileLocation,
620    identity: &ProfileIdentity,
621    options: &crate::ReadOptions,
622) -> Result<Zeroizing<Vec<u8>>> {
623    if !options.stopped_or_consistent_snapshot {
624        return Err(ProfileError::SnapshotRequired);
625    }
626    account_sync_store_path(location, identity)?;
627    let data = crate::media::read_cache(
628        location.root(),
629        std::path::Path::new("config/login_configv2"),
630        4 * 1024 * 1024,
631    )?;
632    let metadata = crate::media::read_cache(
633        location.root(),
634        std::path::Path::new("config/login_configv2.crc"),
635        4096,
636    )?;
637    let key = account_mmkv_key(&identity.username)?;
638    let records = decode_mmkv_records(&data, &metadata, &key)?;
639    Ok(records
640        .string_value("mmkv_key_sync_key")?
641        .unwrap_or_default())
642}
643/// Stage the native account store's cursor. A coordinator must first durably apply
644/// every relevant command and journal this sealed generation before publication.
645pub fn stage_account_sync_key(
646    data: &[u8],
647    metadata: &[u8],
648    username: &str,
649    cursor: &[u8],
650) -> Result<StagedMmkv> {
651    let key = account_mmkv_key(username)?;
652    stage_mmkv_sync_key(data, metadata, &key, cursor)
653}
654
655/// Publish a journaled newly created pair without replacing any existing bytes.
656/// Replay accepts only exact staged files, including a data-only crash state.
657/// The caller must authorize the missing store (e.g. its managed bootstrap receipt)
658/// and create/own the canonical parent directory before calling this function.
659pub fn publish_mmkv_create(
660    path: impl AsRef<std::path::Path>,
661    staged: &StagedMmkv,
662    options: &crate::WriteOptions,
663) -> Result<MmkvPublication> {
664    use std::{fs, io::Write, path::Path};
665    if !options.exclusive_ownership_confirmed {
666        return Err(ProfileError::SnapshotRequired);
667    }
668    if options.durable_intent_id.is_empty() || options.durable_intent_id.len() > 256 {
669        return Err(ProfileError::InvalidQuery);
670    }
671    if staged.expected_data_sha256.is_some() || staged.expected_meta_sha256.is_some() {
672        return Err(ProfileError::InvalidMetadata);
673    }
674    let path = path.as_ref();
675    let parent = path.parent().ok_or(ProfileError::InvalidQuery)?;
676    if fs::canonicalize(parent)? != parent {
677        return Err(ProfileError::UnsupportedFile);
678    }
679    let meta = path.with_file_name(format!(
680        "{}.crc",
681        path.file_name()
682            .and_then(|n| n.to_str())
683            .ok_or(ProfileError::InvalidQuery)?
684    ));
685    fn exists_exact(path: &Path, bytes: &[u8]) -> Result<bool> {
686        match fs::symlink_metadata(path) {
687            Ok(m) => {
688                if !m.is_file() || wechat_platform::is_link(&m) {
689                    return Err(ProfileError::UnsupportedFile);
690                }
691                if m.len() != bytes.len() as u64 {
692                    return Err(ProfileError::SourceChanged);
693                }
694                let old = fs::read(path)?;
695                if old != bytes {
696                    return Err(ProfileError::SourceChanged);
697                }
698                Ok(true)
699            }
700            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
701            Err(e) => Err(e.into()),
702        }
703    }
704    let data_exists = exists_exact(path, staged.data())?;
705    let meta_exists = exists_exact(&meta, staged.metadata())?;
706    fn create(path: &Path, bytes: &[u8]) -> Result<()> {
707        let parent = path.parent().ok_or(ProfileError::InvalidQuery)?;
708        let mut temp = wechat_platform::private_tempfile_in(parent, ".wx-mmkv-")?;
709        temp.write_all(bytes)?;
710        temp.as_file().sync_all()?;
711        temp.persist_noclobber(path)
712            .map_err(|e| ProfileError::Io(e.error))?;
713        wechat_platform::sync_dir(parent)?;
714        Ok(())
715    }
716    if !data_exists {
717        create(path, staged.data())?;
718    }
719    if !meta_exists {
720        create(&meta, staged.metadata())?;
721    }
722    Ok(MmkvPublication {
723        data_replaced: !data_exists,
724        metadata_replaced: !meta_exists,
725        already_applied: data_exists && meta_exists,
726    })
727}