Skip to main content

wechat_profile/
login_storage.rs

1//! Saved-login storage is separate from page keys, active business-session keys
2//! and SyncKey. This module only handles caller-authorized stopped files. It never
3//! authenticates, launches a process, obtains credentials or makes network calls.
4use crate::*;
5use md5::{Digest, Md5};
6use rusqlite::OptionalExtension;
7use std::{fs, path::Path};
8use zeroize::{Zeroize, Zeroizing};
9
10/// One accepted credential generation. No Debug/serde implementation is provided.
11/// The caller binds this generation to bootstrap username/UIN/device first.
12pub struct SavedLoginProjection<'a> {
13    pub auto_auth_key: &'a [u8],
14    pub cookie: &'a [u8],
15    pub key_info: &'a [u8],
16    /// Native stored numeric launch policy. The inspected saved-device fixture uses
17    /// 3; a generic boolean is not interchangeable with this value.
18    pub auto_login_flag: u32,
19    pub pc_account_name: Option<&'a str>,
20    pub local_device_id64: Option<u64>,
21}
22fn scalar(input: &[u8]) -> Result<u64> {
23    if input.len() > 10 {
24        return Err(ProfileError::InvalidMetadata);
25    }
26    let mut n = 0u64;
27    for (i, b) in input.iter().copied().enumerate() {
28        if i == 9 && b > 1 {
29            return Err(ProfileError::InvalidMetadata);
30        }
31        n |= u64::from(b & 127) << (7 * i);
32        if b & 128 == 0 {
33            return if i + 1 == input.len() {
34                Ok(n)
35            } else {
36                Err(ProfileError::InvalidMetadata)
37            };
38        }
39    }
40    Err(ProfileError::InvalidMetadata)
41}
42fn scalar_bytes(mut n: u64) -> Vec<u8> {
43    let mut out = vec![];
44    while n >= 128 {
45        out.push((n as u8 & 127) | 128);
46        n >>= 7
47    }
48    out.push(n as u8);
49    out
50}
51pub(crate) fn validate_auto_auth_key(input: &[u8]) -> Result<()> {
52    let fields = crate::projection::proto_fields(input)?;
53    if fields.len() != 2 {
54        return Err(ProfileError::InvalidMetadata);
55    }
56    for tag in [1, 2] {
57        let values = fields
58            .iter()
59            .filter(|f| f.0 == tag && f.1 == 2)
60            .collect::<Vec<_>>();
61        if values.len() != 1 {
62            return Err(ProfileError::InvalidMetadata);
63        }
64        let nested = crate::projection::proto_fields(values[0].2)?;
65        if nested.len() != 2 {
66            return Err(ProfileError::InvalidMetadata);
67        }
68        let lens = nested
69            .iter()
70            .filter(|f| f.0 == 1 && f.1 == 0)
71            .collect::<Vec<_>>();
72        let bytes = nested
73            .iter()
74            .filter(|f| f.0 == 2 && f.1 == 2)
75            .collect::<Vec<_>>();
76        if lens.len() != 1
77            || bytes.len() != 1
78            || bytes[0].2.is_empty()
79            || scalar(lens[0].2)? != bytes[0].2.len() as u64
80        {
81            return Err(ProfileError::InvalidMetadata);
82        }
83    }
84    Ok(())
85}
86pub(crate) fn saved_login_values(
87    username: &str,
88    input: &SavedLoginProjection<'_>,
89) -> Result<Vec<(String, Option<Vec<u8>>)>> {
90    validate_auto_auth_key(input.auto_auth_key)?;
91    if input.cookie.len() > 255 || input.key_info.is_empty() || input.key_info.len() > 1024 * 1024 {
92        return Err(ProfileError::InvalidMetadata);
93    }
94    let mut out = vec![];
95    for (name, bytes) in [
96        ("mmkv_key_auto_auth_key", input.auto_auth_key),
97        ("mmkv_key_latest_login_username", username.as_bytes()),
98    ] {
99        out.push((name.into(), Some(mmkv_string_value(bytes)?.to_vec())))
100    }
101    if !input.cookie.is_empty() {
102        out.push((
103            "mmkv_key_server_id".into(),
104            Some(mmkv_string_value(input.cookie)?.to_vec()),
105        ))
106    }
107    out.push((
108        "mmkv_key_auto_login_flag".into(),
109        Some(scalar_bytes(input.auto_login_flag as u64)),
110    ));
111    if let Some(name) = input.pc_account_name {
112        if name.len() > 4096 || name.chars().any(char::is_control) {
113            return Err(ProfileError::InvalidMetadata);
114        }
115        out.push((
116            "mmkv_key_pc_account_name".into(),
117            Some(mmkv_string_value(name.as_bytes())?.to_vec()),
118        ))
119    }
120    if let Some(local) = input.local_device_id64 {
121        if local == 0 || local > i64::MAX as u64 {
122            return Err(ProfileError::InvalidMetadata);
123        }
124        out.push(("key_wechat_local_id_64".into(), Some(scalar_bytes(local))));
125    }
126    Ok(out)
127}
128#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
129#[serde(rename_all = "snake_case")]
130pub enum KeyInfoSource {
131    LatestDatabaseRow,
132    LegacyFallback,
133}
134/// Authorized saved material only; it does not imply a currently valid session.
135/// Values are zeroized and intentionally do not implement Debug or Serialize.
136pub struct ProfileSavedLogin {
137    pub identity: ProfileIdentity,
138    pub auto_auth_key: Zeroizing<Vec<u8>>,
139    pub cookie: Zeroizing<Vec<u8>>,
140    pub key_info: Zeroizing<Vec<u8>>,
141    pub key_info_source: KeyInfoSource,
142    pub auto_login_flag: u32,
143    pub logout_locked: Option<bool>,
144    pub pc_account_name: Option<Zeroizing<Vec<u8>>>,
145}
146/// Read identity and the exact native latest-row/legacy selection from a stopped
147/// full HOME. Database/WAL/SHM queries use an isolated working copy; encrypted
148/// profile DB keys and native login credentials are never substituted.
149pub fn read_profile_saved_login(
150    home: impl AsRef<Path>,
151    format_key: &[u8],
152    stopped_or_consistent_snapshot: bool,
153) -> Result<ProfileSavedLogin> {
154    if !stopped_or_consistent_snapshot {
155        return Err(ProfileError::SnapshotRequired);
156    }
157    let home = fs::canonicalize(home)?;
158    let global = "xwechat_files/all_users/config/global_config";
159    let data = crate::media::read_cache(&home, Path::new(global), 4 * 1024 * 1024 + 4)?;
160    let meta = crate::media::read_cache(&home, Path::new(&format!("{global}.crc")), 4096)?;
161    let identity = read_mmkv_identity(&data, &meta, format_key)?;
162    crate::official_account_directory(&identity.username, identity.uin)?;
163    let decoded = decode_mmkv_records(&data, &meta, format_key)?;
164    let token = decoded
165        .string_value("mmkv_key_auto_auth_key")?
166        .ok_or(ProfileError::InvalidMetadata)?;
167    validate_auto_auth_key(&token)?;
168    let cookie = decoded
169        .string_value("mmkv_key_server_id")?
170        .unwrap_or_else(|| Zeroizing::new(vec![]));
171    if cookie.len() > 255 {
172        return Err(ProfileError::InvalidMetadata);
173    }
174    let flag = u32::try_from(scalar(
175        decoded
176            .raw_value("mmkv_key_auto_login_flag")
177            .ok_or(ProfileError::InvalidMetadata)?,
178    )?)
179    .map_err(|_| ProfileError::InvalidMetadata)?;
180    let locked = decoded
181        .raw_value("mmkv_key_logout_locked_flag")
182        .map(scalar)
183        .transpose()?
184        .map(|n| match n {
185            0 => Ok(false),
186            1 => Ok(true),
187            _ => Err(ProfileError::InvalidMetadata),
188        })
189        .transpose()?;
190    let pc_account_name = decoded.string_value("mmkv_key_pc_account_name")?;
191    let (key_info, key_info_source) = read_key_info(&home, &identity.username)?;
192    Ok(ProfileSavedLogin {
193        identity,
194        auto_auth_key: token,
195        cookie,
196        key_info,
197        key_info_source,
198        auto_login_flag: flag,
199        logout_locked: locked,
200        pc_account_name,
201    })
202}
203fn read_key_info(home: &Path, username: &str) -> Result<(Zeroizing<Vec<u8>>, KeyInfoSource)> {
204    let (relative, legacy) = key_info_relative(username)?;
205    let source = home.join(&relative);
206    if source.exists() {
207        // Main/WAL/SHM form one caller-confirmed stopped generation. Opening only the
208        // copied files avoids changes to source SHM reader lock metadata.
209        let temp = wechat_platform::private_tempdir("wechat-key-info-")?;
210        for suffix in ["", "-wal", "-shm"] {
211            let path = format!("{relative}{suffix}");
212            match crate::media::read_cache(home, Path::new(&path), 16 * 1024 * 1024) {
213                Ok(bytes) => fs::write(temp.path().join(format!("key_info.db{suffix}")), bytes)?,
214                Err(ProfileError::NotCached) if !suffix.is_empty() => {}
215                Err(e) => return Err(e),
216            }
217        }
218        let c = rusqlite::Connection::open_with_flags(
219            temp.path().join("key_info.db"),
220            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
221        )?;
222        c.execute_batch("PRAGMA trusted_schema=OFF;PRAGMA temp_store=MEMORY;")?;
223        let expected = ["user_name_md5", "key_md5", "key_info_md5", "key_info_data"];
224        let actual = c
225            .prepare("PRAGMA table_info(LoginKeyInfoTable)")?
226            .query_map([], |r| r.get::<_, String>(1))?
227            .collect::<std::result::Result<Vec<_>, _>>()?;
228        if actual.iter().map(String::as_str).collect::<Vec<_>>() != expected {
229            return Err(ProfileError::UnsupportedSchema("login/key_info.db".into()));
230        }
231        let hash = format!("{:x}", Md5::digest(username.as_bytes()));
232        let row=c.query_row("SELECT key_info_md5,key_info_data FROM LoginKeyInfoTable WHERE user_name_md5=?1 ORDER BY rowid DESC LIMIT 1",[hash],|r|Ok((r.get::<_,Option<String>>(0)?,r.get::<_,Option<Vec<u8>>>(1)?))).optional()?;
233        if let Some((Some(hash), Some(data))) = row {
234            if !data.is_empty()
235                && data.len() <= 1024 * 1024
236                && format!("{:x}", Md5::digest(&data)) == hash
237            {
238                return Ok((Zeroizing::new(data), KeyInfoSource::LatestDatabaseRow));
239            }
240        }
241    }
242    let data = crate::media::read_cache(home, Path::new(&legacy), 1024 * 1024)?;
243    if data.is_empty() {
244        return Err(ProfileError::InvalidMetadata);
245    }
246    Ok((Zeroizing::new(data), KeyInfoSource::LegacyFallback))
247}
248
249/// Restore only the existing account's key-info for a new approval/QR exchange.
250/// This remains useful when saved AutoAuth trust is expired or unavailable; a
251/// fresh approval does not justify dropping the installation's key-info.
252pub fn read_profile_key_info(
253    home: impl AsRef<Path>,
254    username: &str,
255    stopped_or_consistent_snapshot: bool,
256) -> Result<(Zeroizing<Vec<u8>>, KeyInfoSource)> {
257    if !stopped_or_consistent_snapshot {
258        return Err(ProfileError::SnapshotRequired);
259    }
260    crate::official_account_directory(username, 1)?;
261    read_key_info(&fs::canonicalize(home)?, username)
262}
263
264/// Stage accepted saved credentials against an already bound identity store. No
265/// filesystem mutation occurs, so a coordinator can journal the complete sealed
266/// generation before publishing the MMKV pair and legacy key-info file.
267pub fn stage_saved_login_projection(
268    data: &[u8],
269    metadata: &[u8],
270    format_key: &[u8],
271    username: &str,
272    input: &SavedLoginProjection<'_>,
273) -> Result<StagedMmkv> {
274    let identity = read_mmkv_identity(data, metadata, format_key)?;
275    if identity.username != username {
276        return Err(ProfileError::AccountMismatch);
277    }
278    let mut values = saved_login_values(username, input)?;
279    let staged = stage_mmkv_values(data, metadata, format_key, &values);
280    values.zeroize();
281    staged
282}
283/// Atomic legacy fallback publication with an expected old-generation SHA-256.
284/// None means a new file, never permission to overwrite unknown contents. The
285/// coordinator journals/retries this step under its exclusive profile lease.
286pub fn publish_legacy_key_info(
287    path: impl AsRef<Path>,
288    bytes: &[u8],
289    expected_sha256: Option<&str>,
290    options: &WriteOptions,
291) -> Result<bool> {
292    use sha2::Sha256;
293    use std::io::Write;
294    if !options.exclusive_ownership_confirmed || options.durable_intent_id.is_empty() {
295        return Err(ProfileError::SnapshotRequired);
296    }
297    if bytes.is_empty()
298        || bytes.len() > 1024 * 1024
299        || expected_sha256.is_some_and(|s| !crate::media::digest_hex(s, 64))
300    {
301        return Err(ProfileError::InvalidMetadata);
302    }
303    let path = path.as_ref();
304    if path.file_name().is_none_or(|n| n != "key_info.dat") {
305        return Err(ProfileError::InvalidQuery);
306    }
307    let parent = path.parent().ok_or(ProfileError::InvalidQuery)?;
308    let canonical = fs::canonicalize(parent)?;
309    if canonical != parent {
310        return Err(ProfileError::UnsupportedFile);
311    }
312    let old = match crate::media::read_cache(parent, Path::new("key_info.dat"), 1024 * 1024) {
313        Ok(old) => Some(Zeroizing::new(old)),
314        Err(ProfileError::NotCached) => None,
315        Err(e) => return Err(e),
316    };
317    if old.as_deref().is_some_and(|old| old.as_slice() == bytes) {
318        return Ok(false);
319    }
320    let matches = match (&old, expected_sha256) {
321        (None, None) => true,
322        (Some(old), Some(expected)) => {
323            hex::encode(Sha256::digest(old.as_slice())) == expected.to_ascii_lowercase()
324        }
325        _ => false,
326    };
327    if !matches {
328        return Err(ProfileError::SourceChanged);
329    }
330    let mut staged = wechat_platform::private_tempfile_in(parent, ".wx-login-")?;
331    staged.write_all(bytes)?;
332    staged.as_file().sync_all()?;
333    if old.is_none() {
334        staged
335            .persist_noclobber(path)
336            .map_err(|e| ProfileError::Io(e.error))?;
337    } else {
338        staged
339            .persist(path)
340            .map_err(|e| ProfileError::Io(e.error))?;
341    }
342    wechat_platform::sync_dir(parent)?;
343    Ok(true)
344}
345
346/// Hash-only expected generations, suitable for embedding in a coordinator's
347/// encrypted journal. No credential bytes are returned by this plan.
348#[derive(Clone, serde::Serialize, serde::Deserialize)]
349#[serde(rename_all = "camelCase", deny_unknown_fields)]
350pub struct SavedKeyInfoPlan {
351    pub username_sha256: String,
352    pub database_present: bool,
353    pub expected_selection_sha256: Option<String>,
354    pub expected_legacy_sha256: Option<String>,
355}
356#[derive(Clone, Copy, serde::Serialize)]
357#[serde(rename_all = "camelCase")]
358pub struct SavedKeyInfoPublication {
359    pub database_changed: bool,
360    pub legacy_changed: bool,
361}
362fn key_info_relative(username: &str) -> Result<(String, String)> {
363    // Apply the same conservative path-component validation as HOME bootstrap.
364    crate::official_account_directory(username, 1)?;
365    if !crate::media::valid_component(username) {
366        return Err(ProfileError::InvalidMetadata);
367    }
368    Ok((
369        format!("xwechat_files/all_users/login/{username}/key_info.db"),
370        format!(".xwechat/login/{username}/key_info.dat"),
371    ))
372}
373fn login_schema(c: &rusqlite::Connection) -> Result<()> {
374    let columns = c
375        .prepare("PRAGMA table_info(LoginKeyInfoTable)")?
376        .query_map([], |r| {
377            Ok((
378                r.get::<_, String>(1)?,
379                r.get::<_, String>(2)?,
380                r.get::<_, bool>(3)?,
381                r.get::<_, Option<String>>(4)?,
382                r.get::<_, i64>(5)?,
383            ))
384        })?
385        .collect::<std::result::Result<Vec<_>, _>>()?;
386    let expected = [
387        ("user_name_md5", "TEXT"),
388        ("key_md5", "TEXT"),
389        ("key_info_md5", "TEXT"),
390        ("key_info_data", "BLOB"),
391    ]
392    .into_iter()
393    .map(|(n, t)| (n.to_string(), t.to_string(), false, None, 0))
394    .collect::<Vec<_>>();
395    let indices = c
396        .prepare("PRAGMA index_list(LoginKeyInfoTable)")?
397        .query_map([], |r| {
398            Ok((
399                r.get::<_, String>(1)?,
400                r.get::<_, bool>(2)?,
401                r.get::<_, bool>(4)?,
402            ))
403        })?
404        .collect::<std::result::Result<Vec<_>, _>>()?;
405    let triggers: i64 = c.query_row(
406        "SELECT count(*) FROM sqlite_master WHERE type='trigger' AND tbl_name='LoginKeyInfoTable'",
407        [],
408        |r| r.get(0),
409    )?;
410    if columns != expected
411        || indices != vec![("LoginKeyInfoTable_USER_KEYINFO".into(), true, false)]
412        || triggers != 0
413    {
414        return Err(ProfileError::UnsupportedSchema("login/key_info.db".into()));
415    }
416    let indexed = c
417        .prepare("PRAGMA index_info(LoginKeyInfoTable_USER_KEYINFO)")?
418        .query_map([], |r| r.get::<_, String>(2))?
419        .collect::<std::result::Result<Vec<_>, _>>()?;
420    let ordering = c
421        .prepare("PRAGMA index_xinfo(LoginKeyInfoTable_USER_KEYINFO)")?
422        .query_map([], |r| {
423            Ok((
424                r.get::<_, Option<String>>(2)?,
425                r.get::<_, bool>(3)?,
426                r.get::<_, String>(4)?,
427                r.get::<_, bool>(5)?,
428            ))
429        })?
430        .collect::<std::result::Result<Vec<_>, _>>()?;
431    let keys = ordering.into_iter().filter(|row| row.3).collect::<Vec<_>>();
432    let expected_keys = vec![
433        (Some("user_name_md5".into()), false, "BINARY".into(), true),
434        (Some("key_info_md5".into()), false, "BINARY".into(), true),
435    ];
436    if indexed != ["user_name_md5", "key_info_md5"] || keys != expected_keys {
437        return Err(ProfileError::UnsupportedSchema("login/key_info.db".into()));
438    }
439    Ok(())
440}
441type LoginRow = (
442    i64,
443    Option<String>,
444    Option<String>,
445    Option<String>,
446    Option<Vec<u8>>,
447);
448fn selected_login_row(c: &rusqlite::Connection, username: &str) -> Result<Option<LoginRow>> {
449    Ok(c.query_row("SELECT rowid,user_name_md5,key_md5,key_info_md5,key_info_data FROM LoginKeyInfoTable WHERE user_name_md5=?1 ORDER BY rowid DESC LIMIT 1",[format!("{:x}",Md5::digest(username.as_bytes()))],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?)
450}
451fn login_selection_hash(row: &Option<LoginRow>) -> Result<String> {
452    use sha2::Sha256;
453    let bytes = Zeroizing::new(serde_json::to_vec(row).map_err(|_| ProfileError::InvalidMetadata)?);
454    Ok(hex::encode(Sha256::digest(bytes.as_slice())))
455}
456/// Inspect only caller-confirmed stopped files, copying DB/WAL/SHM before SQLite.
457/// Existing databases must have the exact known unique-index/table shape.
458pub fn plan_saved_key_info(
459    home: impl AsRef<Path>,
460    username: &str,
461    stopped: bool,
462) -> Result<SavedKeyInfoPlan> {
463    use sha2::Sha256;
464    if !stopped {
465        return Err(ProfileError::SnapshotRequired);
466    }
467    let home = fs::canonicalize(home)?;
468    let (relative, legacy) = key_info_relative(username)?;
469    let database = home.join(&relative);
470    let database_present = fs::symlink_metadata(&database).is_ok();
471    let expected_selection_sha256 = if database_present {
472        let temp = wechat_platform::private_tempdir("wechat-key-info-")?;
473        for suffix in ["", "-wal", "-shm"] {
474            match crate::media::read_cache(
475                &home,
476                Path::new(&format!("{relative}{suffix}")),
477                16 * 1024 * 1024,
478            ) {
479                Ok(bytes) => fs::write(temp.path().join(format!("key_info.db{suffix}")), bytes)?,
480                Err(ProfileError::NotCached) if !suffix.is_empty() => {}
481                Err(e) => return Err(e),
482            }
483        }
484        let c = rusqlite::Connection::open_with_flags(
485            temp.path().join("key_info.db"),
486            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
487        )?;
488        c.execute_batch("PRAGMA trusted_schema=OFF;PRAGMA temp_store=MEMORY;")?;
489        login_schema(&c)?;
490        Some(login_selection_hash(&selected_login_row(&c, username)?)?)
491    } else {
492        None
493    };
494    let expected_legacy_sha256 =
495        match crate::media::read_cache(&home, Path::new(&legacy), 1024 * 1024) {
496            Ok(bytes) => Some(hex::encode(Sha256::digest(bytes))),
497            Err(ProfileError::NotCached) => None,
498            Err(e) => return Err(e),
499        };
500    Ok(SavedKeyInfoPlan {
501        username_sha256: hex::encode(Sha256::digest(username.as_bytes())),
502        database_present,
503        expected_selection_sha256,
504        expected_legacy_sha256,
505    })
506}
507fn create_login_parent(home: &Path, relative: &Path) -> Result<()> {
508    let mut path = home.to_path_buf();
509    for part in relative
510        .parent()
511        .ok_or(ProfileError::InvalidQuery)?
512        .components()
513    {
514        path.push(part.as_os_str());
515        match fs::symlink_metadata(&path) {
516            Ok(m) if m.is_dir() && !wechat_platform::is_link(&m) => {}
517            Ok(_) => return Err(ProfileError::UnsupportedFile),
518            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
519                fs::create_dir(&path)?;
520                #[cfg(unix)]
521                {
522                    use std::os::unix::fs::PermissionsExt;
523                    fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
524                }
525                wechat_platform::sync_dir(path.parent().ok_or(ProfileError::InvalidQuery)?)?;
526            }
527            Err(e) => return Err(e.into()),
528        }
529    }
530    Ok(())
531}
532/// Publish native-shaped key-info, retaining unrelated accounts/history. The
533/// inspected writer supplies key_md5='' (not a digest of the new credential).
534/// Its known UNIQUE(user_name_md5,key_info_md5) tuple is replaced only when the
535/// older tuple contains identical bytes and the same native empty key_md5.
536/// If no DB exists, use the native raw legacy fallback without inventing a DB.
537pub fn publish_saved_key_info(
538    home: impl AsRef<Path>,
539    username: &str,
540    plan: &SavedKeyInfoPlan,
541    bytes: &[u8],
542    options: &WriteOptions,
543) -> Result<SavedKeyInfoPublication> {
544    publish_saved_key_info_guarded(home, username, plan, bytes, options, || true)
545}
546/// The coordinator must recheck its authorization fence immediately before each
547/// independently durable publication. A false guard preserves the caller's
548/// encrypted intent for replay; it cannot roll back an earlier committed file.
549pub fn publish_saved_key_info_guarded(
550    home: impl AsRef<Path>,
551    username: &str,
552    plan: &SavedKeyInfoPlan,
553    bytes: &[u8],
554    options: &WriteOptions,
555    mut allowed: impl FnMut() -> bool,
556) -> Result<SavedKeyInfoPublication> {
557    use rusqlite::params;
558    use sha2::Sha256;
559    if !options.exclusive_ownership_confirmed || options.durable_intent_id.is_empty() {
560        return Err(ProfileError::SnapshotRequired);
561    }
562    if bytes.is_empty()
563        || bytes.len() > 1024 * 1024
564        || plan.username_sha256 != hex::encode(Sha256::digest(username.as_bytes()))
565    {
566        return Err(ProfileError::InvalidMetadata);
567    }
568    let home = fs::canonicalize(home)?;
569    let (relative, legacy) = key_info_relative(username)?;
570    let path = home.join(&relative);
571    let present = fs::symlink_metadata(&path).is_ok();
572    if present != plan.database_present {
573        return Err(ProfileError::SourceChanged);
574    }
575    // Preflight the separate legacy file before the database transaction. Replay
576    // accepts exact target bytes; an unrelated generation is never overwritten.
577    match crate::media::read_cache(&home, Path::new(&legacy), 1024 * 1024) {
578        Ok(old) if old == bytes => {}
579        Ok(old)
580            if plan.expected_legacy_sha256.as_deref()
581                == Some(hex::encode(Sha256::digest(&old)).as_str()) => {}
582        Err(ProfileError::NotCached) if plan.expected_legacy_sha256.is_none() => {}
583        Ok(_) | Err(ProfileError::NotCached) => return Err(ProfileError::SourceChanged),
584        Err(e) => return Err(e),
585    }
586    let mut database_changed = false;
587    if present {
588        if fs::canonicalize(&path)? != path || !fs::symlink_metadata(&path)?.is_file() {
589            return Err(ProfileError::UnsupportedFile);
590        }
591        let mut c = rusqlite::Connection::open_with_flags(
592            &path,
593            rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
594        )?;
595        c.execute_batch(
596            "PRAGMA trusted_schema=OFF;PRAGMA synchronous=FULL;PRAGMA temp_store=MEMORY;",
597        )?;
598        login_schema(&c)?;
599        let tx = c.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
600        let selected = selected_login_row(&tx, username)?;
601        let digest = format!("{:x}", Md5::digest(bytes));
602        let already = selected.as_ref().is_some_and(|r| {
603            r.3.as_deref() == Some(digest.as_str()) && r.4.as_deref() == Some(bytes)
604        });
605        if !already {
606            if Some(login_selection_hash(&selected)?) != plan.expected_selection_sha256 {
607                return Err(ProfileError::SourceChanged);
608            }
609            let user_hash = format!("{:x}", Md5::digest(username.as_bytes()));
610            let duplicate:Option<(Option<String>,Option<Vec<u8>>)>=tx.query_row("SELECT key_md5,key_info_data FROM LoginKeyInfoTable WHERE user_name_md5=?1 AND key_info_md5=?2",params![user_hash,digest],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
611            if duplicate.as_ref().is_some_and(|(key, data)| {
612                key.as_deref() != Some("") || data.as_deref() != Some(bytes)
613            }) {
614                return Err(ProfileError::InvalidMetadata);
615            }
616            if !allowed() {
617                return Err(ProfileError::SourceChanged);
618            }
619            tx.execute("INSERT OR REPLACE INTO LoginKeyInfoTable(user_name_md5,key_md5,key_info_md5,key_info_data)VALUES(?1,'',?2,?3)",params![user_hash,digest,bytes])?;
620            database_changed = true;
621        }
622        tx.commit()?;
623    }
624    create_login_parent(&home, Path::new(&legacy))?;
625    if !allowed() {
626        return Err(ProfileError::SourceChanged);
627    }
628    let legacy_changed = publish_legacy_key_info(
629        home.join(legacy),
630        bytes,
631        plan.expected_legacy_sha256.as_deref(),
632        options,
633    )?;
634    Ok(SavedKeyInfoPublication {
635        database_changed,
636        legacy_changed,
637    })
638}
639/// Preserve the existing desktop auto-login policy while reading a bound store.
640pub fn saved_login_policy(data: &[u8], metadata: &[u8], format_key: &[u8]) -> Result<u32> {
641    let records = decode_mmkv_records(data, metadata, format_key)?;
642    u32::try_from(scalar(
643        records
644            .raw_value("mmkv_key_auto_login_flag")
645            .ok_or(ProfileError::InvalidMetadata)?,
646    )?)
647    .map_err(|_| ProfileError::InvalidMetadata)
648}
649/// Accepted-login-only variant. The caller must have installed the matching
650/// authenticated generation and cleared its own logout fence first. Generic
651/// staging retains the logout flag; only this explicit path may clear it.
652pub fn stage_accepted_saved_login_projection(
653    data: &[u8],
654    metadata: &[u8],
655    format_key: &[u8],
656    username: &str,
657    input: &SavedLoginProjection<'_>,
658) -> Result<StagedMmkv> {
659    let identity = read_mmkv_identity(data, metadata, format_key)?;
660    if identity.username != username {
661        return Err(ProfileError::AccountMismatch);
662    }
663    let mut values = saved_login_values(username, input)?;
664    values.push(("mmkv_key_logout_locked_flag".into(), Some(vec![0])));
665    if input.cookie.is_empty() {
666        values.push((
667            "mmkv_key_server_id".into(),
668            Some(mmkv_string_value(&[])?.to_vec()),
669        ));
670    }
671    let staged = stage_mmkv_values(data, metadata, format_key, &values);
672    values.zeroize();
673    staged
674}