Skip to main content

wechat_profile/
media.rs

1//! Checked cache storage for exact local messages. Filesystem caches and ordinary
2//! resource tables have independent transactions; callers retain a durable intent
3//! and retry the same payload rather than treating them as one atomic commit.
4use crate::{
5    projection::{check_known_table, identity, map_id, ownership},
6    writer::{find_database, open_write},
7    *,
8};
9use md5::{Digest, Md5};
10use rusqlite::{params, OptionalExtension};
11use serde::{Deserialize, Serialize};
12use sha2::Sha256;
13use std::{
14    fs,
15    io::Read,
16    path::{Component, Path},
17};
18
19pub const MAX_CACHED_MEDIA_BYTES: usize = 128 * 1024 * 1024;
20pub const MAX_CACHED_FILE_BYTES: usize = wechat_media::MAX_FILE_BYTES;
21fn hardlink_limit(kind: HardlinkKind) -> usize {
22    if kind == HardlinkKind::File {
23        MAX_CACHED_FILE_BYTES
24    } else {
25        MAX_CACHED_MEDIA_BYTES
26    }
27}
28fn cache_path_limit(relative: &str) -> usize {
29    if relative.starts_with("msg/file/") {
30        MAX_CACHED_FILE_BYTES
31    } else {
32        MAX_CACHED_MEDIA_BYTES
33    }
34}
35#[derive(Clone, Debug, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct AppliedVoiceCache {
38    pub database: String,
39    pub changed: bool,
40}
41struct MessageBinding {
42    local: i64,
43    server: i64,
44    time: i64,
45    kind: i64,
46    origin: i64,
47    sender: String,
48}
49fn message_binding(
50    location: &ProfileLocation,
51    keys: &KeyFile,
52    id: &SourceMessageId,
53    chat: &str,
54    options: &WriteOptions,
55) -> Result<MessageBinding> {
56    identity(chat)?;
57    if !crate::schema::message_table(&id.table)
58        || id.table != format!("Msg_{:x}", Md5::digest(chat.as_bytes()))
59    {
60        return Err(ProfileError::InvalidQuery);
61    }
62    let local = id
63        .local_id
64        .parse::<i64>()
65        .map_err(|_| ProfileError::InvalidQuery)?;
66    if local <= 0 {
67        return Err(ProfileError::InvalidQuery);
68    }
69    let c = open_write(location, &id.database, keys, options.allow_plaintext)?;
70    crate::writer::exact_columns(&c, &id.database, &id.table, crate::writer::MESSAGE_COLUMNS)?;
71    c.query_row(&format!("SELECT m.server_id,m.create_time,m.local_type,m.origin_source,n.user_name FROM {} m JOIN Name2Id n ON n.rowid=m.real_sender_id WHERE m.local_id=?1",crate::schema::quote_identifier(&id.table)),[local],|r|Ok(MessageBinding{local,server:r.get(0)?,time:r.get(1)?,kind:r.get(2)?,origin:r.get(3)?,sender:r.get(4)?})).optional()?.ok_or(ProfileError::NotCached)
72}
73/// Preserve the original encoded voice, independently of any WAV derivative.
74/// The supported media shard is selected only when no other media ordinal exists.
75/// The message row must already exist and be type 34 in its own table namespace.
76pub fn cache_voice_projection(
77    location: &ProfileLocation,
78    keys: &KeyFile,
79    source: &SourceMessageId,
80    chat: &str,
81    encoded: &[u8],
82    options: &WriteOptions,
83) -> Result<AppliedVoiceCache> {
84    ownership(location, keys, options)?;
85    if encoded.is_empty() || encoded.len() > 16 * 1024 * 1024 {
86        return Err(ProfileError::ResourceLimit);
87    }
88    let binding = message_binding(location, keys, source, chat, options)?;
89    if binding.kind & 0x7fffffff != 34 {
90        return Err(ProfileError::InvalidQuery);
91    }
92    let db = find_database(location, "media_0.db")?;
93    // The inspected adapter has no media-shard rollover algorithm.
94    let parent = location
95        .storage
96        .join(&db)
97        .parent()
98        .ok_or(ProfileError::InvalidProfile)?
99        .to_path_buf();
100    let count = fs::read_dir(parent)?
101        .collect::<std::result::Result<Vec<_>, _>>()?
102        .into_iter()
103        .filter(|e| {
104            e.file_name().to_str().is_some_and(|n| {
105                n.strip_prefix("media_")
106                    .and_then(|n| n.strip_suffix(".db"))
107                    .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
108            })
109        })
110        .count();
111    if count != 1 {
112        return Err(ProfileError::WritesUnsupported);
113    }
114    let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
115    check_known_table(&c, "VoiceInfo")?;
116    let tx = c.transaction()?;
117    let chat_id = map_id(&tx, "Name2Id", "user_name", chat)?;
118    let existing=tx.query_row("SELECT svr_id,voice_data FROM VoiceInfo WHERE chat_name_id=?1 AND create_time=?2 AND local_id=?3 AND data_index='0'",params![chat_id,binding.time,binding.local],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,Vec<u8>>(1)?))).optional()?;
119    let changed = if let Some((sid, bytes)) = existing {
120        if sid != binding.server || bytes != encoded {
121            return Err(ProfileError::InvalidMetadata);
122        }
123        false
124    } else {
125        tx.execute("INSERT INTO VoiceInfo(chat_name_id,create_time,local_id,svr_id,voice_data,data_index)VALUES(?1,?2,?3,?4,?5,'0')",params![chat_id,binding.time,binding.local,binding.server,encoded])?;
126        true
127    };
128    tx.commit()?;
129    Ok(AppliedVoiceCache {
130        database: db,
131        changed,
132    })
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum HardlinkKind {
138    Image,
139    File,
140    Video,
141}
142impl HardlinkKind {
143    pub(crate) fn table(self) -> &'static str {
144        match self {
145            Self::Image => "image_hardlink_info_v4",
146            Self::File => "file_hardlink_info_v4",
147            Self::Video => "video_hardlink_info_v4",
148        }
149    }
150    fn directory(self) -> Result<&'static str> {
151        match self {
152            Self::File => Ok("msg/file"),
153            Self::Video => Ok("msg/video"),
154            Self::Image => Err(ProfileError::InvalidQuery),
155        }
156    }
157}
158pub(crate) fn valid_component(value: &str) -> bool {
159    !value.is_empty()
160        && value.len() <= 255
161        && !value.contains(['\\', '\0'])
162        && Path::new(value).components().count() == 1
163        && Path::new(value)
164            .components()
165            .all(|p| matches!(p, Component::Normal(_)))
166}
167pub(crate) fn digest_hex(value: &str, n: usize) -> bool {
168    value.len() == n && value.bytes().all(|b| b.is_ascii_hexdigit())
169}
170fn open_cache(
171    root: &Path,
172    relative: &Path,
173    max: usize,
174) -> Result<(fs::File, fs::Metadata, std::path::PathBuf)> {
175    if !crate::keys::valid_relative_path(relative.to_str().ok_or(ProfileError::InvalidMetadata)?) {
176        return Err(ProfileError::InvalidMetadata);
177    }
178    let mut here = root.to_path_buf();
179    let components = relative.components().collect::<Vec<_>>();
180    for (i, part) in components.iter().enumerate() {
181        here.push(part.as_os_str());
182        let m = fs::symlink_metadata(&here).map_err(|e| {
183            if e.kind() == std::io::ErrorKind::NotFound {
184                ProfileError::NotCached
185            } else {
186                e.into()
187            }
188        })?;
189        if wechat_platform::is_link(&m)
190            || if i + 1 == components.len() {
191                !m.is_file()
192            } else {
193                !m.is_dir()
194            }
195        {
196            return Err(ProfileError::UnsupportedFile);
197        }
198    }
199    let mut o = wechat_platform::OpenOptions::new();
200    o.read(true);
201    #[cfg(unix)]
202    {
203        o.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
204    }
205    let f = o.open(&here)?;
206    let before = f.metadata()?;
207    if before.len() > max as u64 {
208        return Err(ProfileError::ResourceLimit);
209    }
210    Ok((f, before, here))
211}
212fn cache_stable(file: &fs::File, before: &fs::Metadata, path: &Path) -> Result<()> {
213    let after = file.metadata()?;
214    if before.len() != after.len()
215        || before.modified()? != after.modified()?
216        || !wechat_platform::same_path_file(path, file)?
217    {
218        return Err(ProfileError::SourceChanged);
219    }
220    Ok(())
221}
222pub(crate) fn read_cache(root: &Path, relative: &Path, max: usize) -> Result<Vec<u8>> {
223    let (mut file, before, path) = open_cache(root, relative, max)?;
224    let mut data = Vec::with_capacity(before.len() as usize);
225    (&mut file).take(max as u64 + 1).read_to_end(&mut data)?;
226    cache_stable(&file, &before, &path)?;
227    if data.len() > max {
228        return Err(ProfileError::ResourceLimit);
229    }
230    Ok(data)
231}
232fn cache_matches(root: &Path, relative: &Path, bytes: &[u8], max: usize) -> Result<bool> {
233    let (mut file, before, path) = open_cache(root, relative, max)?;
234    if before.len() != bytes.len() as u64 {
235        return Ok(false);
236    }
237    let mut buffer = [0; 64 * 1024];
238    for expected in bytes.chunks(buffer.len()) {
239        file.read_exact(&mut buffer[..expected.len()])?;
240        if &buffer[..expected.len()] != expected {
241            return Ok(false);
242        }
243    }
244    cache_stable(&file, &before, &path)?;
245    Ok(true)
246}
247
248impl ProfileReader {
249    /// File/video hardlinks use one month directory and the observed zero second
250    /// directory sentinel. Every candidate is content-MD5 verified; ambiguous bytes
251    /// or unknown path layouts are refused rather than guessed.
252    pub fn cached_hardlink_file(&self, kind: HardlinkKind, md5: &str) -> Result<CachedBytes> {
253        let directory = kind.directory()?;
254        if !digest_hex(md5, 32) {
255            return Err(ProfileError::InvalidMetadata);
256        }
257        let db = self
258            .snapshot
259            .inventory
260            .iter()
261            .filter(|d| d.relative_path.rsplit('/').next() == Some("hardlink.db"))
262            .collect::<Vec<_>>();
263        if db.len() != 1 {
264            return Err(ProfileError::NotCached);
265        }
266        let db = &db[0].relative_path;
267        let c = self.snapshot.open(db)?;
268        let mut q=c.prepare(&format!("SELECT d.username,h.file_name,h.file_size,h.dir2 FROM {} h JOIN dir2id d ON d.rowid=h.dir1 WHERE lower(h.md5)=?1 LIMIT 201",kind.table()))?;
269        let rows = q
270            .query_map([md5.to_ascii_lowercase()], |r| {
271                Ok((
272                    r.get::<_, String>(0)?,
273                    r.get::<_, String>(1)?,
274                    r.get::<_, i64>(2)?,
275                    r.get::<_, i64>(3)?,
276                ))
277            })?
278            .collect::<std::result::Result<Vec<_>, _>>()?;
279        if rows.len() > 200 {
280            return Err(ProfileError::ResourceLimit);
281        }
282        let mut found = None;
283        for (month, name, size, dir2) in rows {
284            if dir2 != 0
285                || !valid_component(&month)
286                || !valid_component(&name)
287                || month.len() != 7
288                || month.as_bytes()[4] != b'-'
289            {
290                return Err(ProfileError::InvalidMetadata);
291            }
292            let bytes = match read_cache(
293                &self.snapshot.location.root,
294                &Path::new(directory).join(month).join(name),
295                hardlink_limit(kind),
296            ) {
297                Ok(b) => b,
298                Err(ProfileError::NotCached) => continue,
299                Err(e) => return Err(e),
300            };
301            if size < 0
302                || bytes.len() as u64 != size as u64
303                || format!("{:x}", Md5::digest(&bytes)) != md5.to_ascii_lowercase()
304            {
305                return Err(ProfileError::InvalidMetadata);
306            }
307            if found.as_ref().is_some_and(|old: &Vec<u8>| old != &bytes) {
308                return Err(ProfileError::InvalidMetadata);
309            }
310            found = Some(bytes);
311        }
312        let bytes = found.ok_or(ProfileError::NotCached)?;
313        Ok(CachedBytes {
314            media_type: crate::images::validate_image(&bytes)
315                .map(|v| v.0)
316                .unwrap_or_else(|_| {
317                    if kind == HardlinkKind::Video {
318                        wechat_media::inspect_video_container(&bytes)
319                            .unwrap_or("application/octet-stream")
320                            .into()
321                    } else {
322                        "application/octet-stream".into()
323                    }
324                }),
325            sha256: hex::encode(Sha256::digest(&bytes)),
326            bytes,
327            source_database: db.clone(),
328            source_table: kind.table().into(),
329        })
330    }
331}
332
333/// Avatar hashes identify the native source URL, not the image-content digest.
334/// A caller supplies the observed URL hash explicitly; it is never inferred from
335/// the bytes. No contact/group field or remote avatar is modified.
336pub struct AvatarProjection<'a> {
337    pub username: &'a str,
338    pub source_url_md5: &'a str,
339    pub bytes: &'a [u8],
340    pub update_time: i64,
341}
342pub fn apply_avatar_projection(
343    location: &ProfileLocation,
344    keys: &KeyFile,
345    input: &AvatarProjection<'_>,
346    options: &WriteOptions,
347) -> Result<bool> {
348    ownership(location, keys, options)?;
349    identity(input.username)?;
350    if !digest_hex(input.source_url_md5, 32)
351        || input.bytes.is_empty()
352        || input.bytes.len() > 16 * 1024 * 1024
353    {
354        return Err(ProfileError::InvalidMetadata);
355    }
356    crate::images::validate_image(input.bytes)?;
357    let db = find_database(location, "head_image.db")?;
358    let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
359    check_known_table(&c, "head_image")?;
360    let tx = c.transaction()?;
361    let old = tx
362        .query_row(
363            "SELECT md5,image_buffer,update_time FROM head_image WHERE username=?1",
364            [input.username],
365            |r| {
366                Ok((
367                    r.get::<_, String>(0)?,
368                    r.get::<_, Vec<u8>>(1)?,
369                    r.get::<_, i64>(2)?,
370                ))
371            },
372        )
373        .optional()?;
374    if old.as_ref().is_some_and(|(hash, bytes, time)| {
375        hash == input.source_url_md5 && bytes == input.bytes && *time == input.update_time
376    }) {
377        return Ok(false);
378    }
379    tx.execute("INSERT INTO head_image(username,md5,image_buffer,update_time)VALUES(?1,?2,?3,?4)ON CONFLICT(username)DO UPDATE SET md5=excluded.md5,image_buffer=excluded.image_buffer,update_time=excluded.update_time",params![input.username,input.source_url_md5,input.bytes,input.update_time])?;
380    tx.commit()?;
381    Ok(true)
382}
383
384/// Exact native resource metadata. The destination allocates all local IDs and
385/// translates names. Detail type/status/data_index and opaque protobuf bytes are
386/// explicit build-adapter inputs rather than guessed media-wide defaults.
387#[derive(Clone, Debug, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase", deny_unknown_fields)]
389pub struct MediaResourceProjection {
390    pub source: SourceMessageId,
391    pub chat: String,
392    pub packed_info: Vec<u8>,
393    pub details: Vec<RawFields>,
394}
395#[derive(Clone, Debug, Serialize)]
396#[serde(rename_all = "camelCase")]
397pub struct AppliedMediaResource {
398    pub message_id: i64,
399    pub detail_ids: Vec<i64>,
400    pub changed: bool,
401}
402fn insert_values(c: &rusqlite::Connection, table: &str, fields: &RawFields) -> Result<i64> {
403    let mut values = vec![];
404    let mut expressions = vec![];
405    for value in fields.values() {
406        let (v, text) = crate::projection::bound(value)?;
407        values.push(v);
408        let p = format!("?{}", values.len());
409        expressions.push(if text {
410            format!("CAST({p} AS TEXT)")
411        } else {
412            p
413        });
414    }
415    c.execute(
416        &format!(
417            "INSERT INTO {}({})VALUES({})",
418            crate::schema::quote_identifier(table),
419            fields
420                .keys()
421                .map(|k| crate::schema::quote_identifier(k))
422                .collect::<Vec<_>>()
423                .join(","),
424            expressions.join(",")
425        ),
426        rusqlite::params_from_iter(values),
427    )?;
428    Ok(c.last_insert_rowid())
429}
430fn complete_fields(
431    c: &rusqlite::Connection,
432    table: &str,
433    excluded: &[&str],
434    fields: &RawFields,
435) -> Result<()> {
436    check_known_table(c, table)?;
437    let report = crate::schema::inspect(c, "media")?;
438    let t = report
439        .tables
440        .iter()
441        .find(|t| t.name == table)
442        .ok_or(ProfileError::InvalidMetadata)?;
443    let expected = t
444        .columns
445        .iter()
446        .map(|col| col.name.as_str())
447        .filter(|col| !excluded.contains(col))
448        .collect::<std::collections::BTreeSet<_>>();
449    if fields
450        .keys()
451        .map(String::as_str)
452        .collect::<std::collections::BTreeSet<_>>()
453        != expected
454    {
455        return Err(ProfileError::InvalidMetadata);
456    }
457    for v in fields.values() {
458        crate::projection::bound(v)?;
459    }
460    Ok(())
461}
462fn raw_row(
463    c: &rusqlite::Connection,
464    table: &str,
465    key_column: &str,
466    id: i64,
467    excluded: &[&str],
468) -> Result<RawFields> {
469    let mut values = c.query_row(
470        &format!(
471            "SELECT * FROM {} WHERE {}=?1",
472            crate::schema::quote_identifier(table),
473            crate::schema::quote_identifier(key_column)
474        ),
475        [id],
476        crate::reader::raw,
477    )?;
478    for col in excluded {
479        values.remove(*col);
480    }
481    Ok(values)
482}
483fn resource_detail_rows(c: &rusqlite::Connection, message: i64) -> Result<Vec<(i64, RawFields)>> {
484    let ids=c.prepare("SELECT resource_id FROM MessageResourceDetail WHERE message_id=?1 ORDER BY type,data_index,resource_id LIMIT 33")?.query_map([message],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
485    if ids.len() > 32 {
486        return Err(ProfileError::ResourceLimit);
487    }
488    ids.into_iter()
489        .map(|id| {
490            Ok((
491                id,
492                raw_row(
493                    c,
494                    "MessageResourceDetail",
495                    "resource_id",
496                    id,
497                    &["resource_id", "message_id"],
498                )?,
499            ))
500        })
501        .collect()
502}
503pub fn apply_media_resource_projection(
504    location: &ProfileLocation,
505    keys: &KeyFile,
506    input: &MediaResourceProjection,
507    options: &WriteOptions,
508) -> Result<AppliedMediaResource> {
509    apply_media_resource_set(location, keys, input, options, false)
510}
511/// Add one or more verified resource variants without erasing other qualities.
512/// Only a matching pending (status0,size0) detail can transition to downloaded.
513pub fn apply_media_resource_variants(
514    location: &ProfileLocation,
515    keys: &KeyFile,
516    input: &MediaResourceProjection,
517    options: &WriteOptions,
518) -> Result<AppliedMediaResource> {
519    apply_media_resource_set(location, keys, input, options, true)
520}
521#[allow(clippy::collapsible_else_if)]
522fn apply_media_resource_set(
523    location: &ProfileLocation,
524    keys: &KeyFile,
525    input: &MediaResourceProjection,
526    options: &WriteOptions,
527    partial: bool,
528) -> Result<AppliedMediaResource> {
529    ownership(location, keys, options)?;
530    if input.packed_info.len() > 1024 * 1024 || input.details.len() > 32 {
531        return Err(ProfileError::ResourceLimit);
532    }
533    crate::projection::proto_fields(&input.packed_info)?;
534    let binding = message_binding(location, keys, &input.source, &input.chat, options)?;
535    let db = find_database(location, "message_resource.db")?;
536    let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
537    check_known_table(&c, "MessageResourceInfo")?;
538    check_known_table(&c, "MessageResourceDetail")?;
539    check_known_table(&c, "ChatName2Id")?;
540    check_known_table(&c, "SenderName2Id")?;
541    for detail in &input.details {
542        complete_fields(
543            &c,
544            "MessageResourceDetail",
545            &["resource_id", "message_id"],
546            detail,
547        )?;
548    }
549    let tx = c.transaction()?;
550    let chat = map_id(&tx, "ChatName2Id", "user_name", &input.chat)?;
551    let sender = map_id(&tx, "SenderName2Id", "user_name", &binding.sender)?;
552    let mut fields = RawFields::new();
553    for (k, n) in [
554        ("chat_id", chat),
555        ("sender_id", sender),
556        ("message_local_type", binding.kind),
557        ("message_create_time", binding.time),
558        ("message_local_id", binding.local),
559        ("message_svr_id", binding.server),
560        ("message_origin_source", binding.origin),
561    ] {
562        fields.insert(k.into(), SqlValue::Integer(n.to_string()));
563    }
564    fields.insert(
565        "packed_info".into(),
566        SqlValue::Blob(base64::Engine::encode(
567            &base64::engine::general_purpose::STANDARD,
568            &input.packed_info,
569        )),
570    );
571    complete_fields(&tx, "MessageResourceInfo", &["message_id"], &fields)?;
572    let ids=tx.prepare("SELECT message_id FROM MessageResourceInfo WHERE chat_id=?1 AND message_create_time=?2 AND message_local_id=?3 LIMIT 3")?.query_map(params![chat,binding.time,binding.local],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
573    if ids.len() > 2 {
574        return Err(ProfileError::InvalidMetadata);
575    }
576    if ids.len() == 2 {
577        // Stock profiles can retain two exact resource aliases for one message.
578        // Accept only a fully satisfied replay across every alias. Never merge,
579        // delete, or partially update duplicate/pending/divergent resources.
580        let mut first: Option<Vec<(i64, RawFields)>> = None;
581        let mut first_ids = Vec::new();
582        for id in &ids {
583            if raw_row(
584                &tx,
585                "MessageResourceInfo",
586                "message_id",
587                *id,
588                &["message_id"],
589            )? != fields
590            {
591                return Err(ProfileError::InvalidMetadata);
592            }
593            let rows = resource_detail_rows(&tx, *id)?;
594            if let Some(first) = &first {
595                if first.iter().map(|(_, r)| r).collect::<Vec<_>>()
596                    != rows.iter().map(|(_, r)| r).collect::<Vec<_>>()
597                {
598                    return Err(ProfileError::InvalidMetadata);
599                }
600            }
601            if !partial && rows.len() != input.details.len() {
602                return Err(ProfileError::InvalidMetadata);
603            }
604            let mut matched = Vec::new();
605            for detail in &input.details {
606                let matches = rows
607                    .iter()
608                    .filter(|(_, row)| row == detail)
609                    .collect::<Vec<_>>();
610                if matches.len() != 1 {
611                    return Err(ProfileError::InvalidMetadata);
612                }
613                matched.push(matches[0].0);
614            }
615            if first.is_none() {
616                first_ids = matched;
617                first = Some(rows);
618            }
619        }
620        tx.commit()?;
621        return Ok(AppliedMediaResource {
622            message_id: ids[0],
623            detail_ids: first_ids,
624            changed: false,
625        });
626    }
627    let mut changed = false;
628    let message_id = if let Some(id) = ids.first() {
629        if raw_row(
630            &tx,
631            "MessageResourceInfo",
632            "message_id",
633            *id,
634            &["message_id"],
635        )? != fields
636        {
637            return Err(ProfileError::InvalidMetadata);
638        }
639        *id
640    } else {
641        changed = true;
642        insert_values(&tx, "MessageResourceInfo", &fields)?
643    };
644    let existing=tx.prepare("SELECT resource_id FROM MessageResourceDetail WHERE message_id=?1 ORDER BY resource_id")?.query_map([message_id],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
645    let mut detail_ids = vec![];
646    // This projects a complete exact set. A later partial transfer must supply a
647    // separate proved transition; it cannot silently replace existing cache rows.
648    if partial {
649        for supplied in &input.details {
650            let kind = supplied.get("type").ok_or(ProfileError::InvalidMetadata)?;
651            let index = supplied
652                .get("data_index")
653                .ok_or(ProfileError::InvalidMetadata)?;
654            let mut matched = None;
655            for id in &existing {
656                let old = raw_row(
657                    &tx,
658                    "MessageResourceDetail",
659                    "resource_id",
660                    *id,
661                    &["resource_id", "message_id"],
662                )?;
663                if old.get("type") == Some(kind) && old.get("data_index") == Some(index) {
664                    if matched.is_some() {
665                        return Err(ProfileError::InvalidMetadata);
666                    }
667                    matched = Some((*id, old));
668                }
669            }
670            if let Some((id, old)) = matched {
671                if old != *supplied {
672                    let zero = SqlValue::Integer("0".into());
673                    let packed_changed = old.get("packed_info") != supplied.get("packed_info");
674                    let file_completion = binding.kind as u64 == (6u64 << 32) | 49
675                        && crate::reader::num(&old, "type").is_some_and(|n| n & 0xffff == 3)
676                        && (!packed_changed || file_name_completion(&old, supplied)?);
677                    if old.get("status") != Some(&zero)
678                        || (old.get("size") != Some(&zero)
679                            && !(file_completion && old.get("size") == supplied.get("size")))
680                        || supplied.get("status") != Some(&SqlValue::Integer("1".into()))
681                        || (packed_changed && !file_completion)
682                        || old.iter().any(|(k, v)| {
683                            !["status", "size", "access_time", "packed_info"].contains(&k.as_str())
684                                && supplied.get(k) != Some(v)
685                        })
686                    {
687                        return Err(ProfileError::InvalidMetadata);
688                    }
689                    let (size, _) = crate::projection::bound(&supplied["size"])?;
690                    let (access, _) = crate::projection::bound(&supplied["access_time"])?;
691                    let (packed, _) = crate::projection::bound(&supplied["packed_info"])?;
692                    tx.execute("UPDATE MessageResourceDetail SET status=1,size=?1,access_time=?2,packed_info=?3 WHERE resource_id=?4",params![size,access,packed,id])?;
693                    changed = true;
694                }
695                detail_ids.push(id);
696            } else {
697                let mut fields = supplied.clone();
698                fields.insert(
699                    "message_id".into(),
700                    SqlValue::Integer(message_id.to_string()),
701                );
702                detail_ids.push(insert_values(&tx, "MessageResourceDetail", &fields)?);
703                changed = true;
704            }
705        }
706    } else {
707        if !existing.is_empty() {
708            if existing.len() != input.details.len() {
709                return Err(ProfileError::InvalidMetadata);
710            }
711            for (id, fields) in existing.into_iter().zip(&input.details) {
712                if raw_row(
713                    &tx,
714                    "MessageResourceDetail",
715                    "resource_id",
716                    id,
717                    &["resource_id", "message_id"],
718                )? != *fields
719                {
720                    return Err(ProfileError::InvalidMetadata);
721                }
722                detail_ids.push(id);
723            }
724        } else {
725            for detail in &input.details {
726                let mut fields = detail.clone();
727                fields.insert(
728                    "message_id".into(),
729                    SqlValue::Integer(message_id.to_string()),
730                );
731                detail_ids.push(insert_values(&tx, "MessageResourceDetail", &fields)?);
732                changed = true;
733            }
734        }
735    }
736    tx.commit()?;
737    Ok(AppliedMediaResource {
738        message_id,
739        detail_ids,
740        changed,
741    })
742}
743
744/// Native hardlink fields, excluding _rowid_/dir1/dir2. Directory numbers are
745/// always allocated in the destination. `hardlink_md5_hash` computes the native
746/// string hash; type/extra_buffer remain explicit in this low-level interface.
747#[derive(Clone, Debug, Serialize, Deserialize)]
748#[serde(rename_all = "camelCase", deny_unknown_fields)]
749pub struct HardlinkProjection {
750    pub kind: HardlinkKind,
751    pub first_directory: String,
752    pub second_directory: Option<String>,
753    pub fields: RawFields,
754}
755#[derive(Clone, Debug, Serialize)]
756#[serde(rename_all = "camelCase")]
757pub struct AppliedHardlink {
758    pub row_id: i64,
759    pub changed: bool,
760}
761pub fn apply_hardlink_projection(
762    location: &ProfileLocation,
763    keys: &KeyFile,
764    input: &HardlinkProjection,
765    options: &WriteOptions,
766) -> Result<AppliedHardlink> {
767    ownership(location, keys, options)?;
768    if !valid_component(&input.first_directory)
769        || input
770            .second_directory
771            .as_ref()
772            .is_some_and(|s| !valid_component(s))
773    {
774        return Err(ProfileError::InvalidMetadata);
775    }
776    let filename = match input.fields.get("file_name") {
777        Some(SqlValue::Text(s)) if valid_component(s) => s,
778        _ => return Err(ProfileError::InvalidMetadata),
779    };
780    let md5 = match input.fields.get("md5") {
781        Some(SqlValue::Text(s)) if digest_hex(s, 32) => s,
782        _ => return Err(ProfileError::InvalidMetadata),
783    };
784    let db = find_database(location, "hardlink.db")?;
785    let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
786    check_known_table(&c, "dir2id")?;
787    complete_fields(
788        &c,
789        input.kind.table(),
790        &["_rowid_", "dir1", "dir2"],
791        &input.fields,
792    )?;
793    let tx = c.transaction()?;
794    let dir1 = map_id(&tx, "dir2id", "username", &input.first_directory)?;
795    let dir2 = if let Some(s) = &input.second_directory {
796        map_id(&tx, "dir2id", "username", s)?
797    } else {
798        0
799    };
800    let mut fields = input.fields.clone();
801    fields.insert("dir1".into(), SqlValue::Integer(dir1.to_string()));
802    fields.insert("dir2".into(), SqlValue::Integer(dir2.to_string()));
803    let ids = tx
804        .prepare(&format!(
805            "SELECT _rowid_ FROM {} WHERE md5=?1 AND file_name=?2 AND dir1=?3 AND dir2=?4",
806            input.kind.table()
807        ))?
808        .query_map(params![md5, filename, dir1, dir2], |r| r.get::<_, i64>(0))?
809        .collect::<std::result::Result<Vec<_>, _>>()?;
810    if ids.len() > 1 {
811        return Err(ProfileError::InvalidMetadata);
812    }
813    let (row_id, changed) = if let Some(id) = ids.first() {
814        if raw_row(&tx, input.kind.table(), "_rowid_", *id, &["_rowid_"])? != fields {
815            return Err(ProfileError::InvalidMetadata);
816        }
817        (*id, false)
818    } else {
819        (insert_values(&tx, input.kind.table(), &fields)?, true)
820    };
821    tx.commit()?;
822    Ok(AppliedHardlink { row_id, changed })
823}
824
825/// Create one content-verified cache file without replacing any existing path.
826/// The adapter supplies a native relative path, encoded bytes and expected SHA.
827/// Atomic publication is idempotent; SQL resource registration is a later step.
828pub fn publish_cache_file(
829    location: &ProfileLocation,
830    relative: &str,
831    bytes: &[u8],
832    expected_sha256: &str,
833    options: &WriteOptions,
834) -> Result<bool> {
835    if !options.exclusive_ownership_confirmed || options.durable_intent_id.is_empty() {
836        return Err(ProfileError::SnapshotRequired);
837    }
838    if !crate::keys::valid_relative_path(relative)
839        || !relative.starts_with("msg/")
840        || !digest_hex(expected_sha256, 64)
841        || bytes.is_empty()
842        || bytes.len() > cache_path_limit(relative)
843    {
844        return Err(ProfileError::InvalidMetadata);
845    }
846    if hex::encode(Sha256::digest(bytes)) != expected_sha256.to_ascii_lowercase() {
847        return Err(ProfileError::InvalidMetadata);
848    }
849    let target = location.root.join(relative);
850    let parent = target.parent().ok_or(ProfileError::InvalidMetadata)?;
851    let mut here = location.root.clone();
852    for p in parent
853        .strip_prefix(&location.root)
854        .map_err(|_| ProfileError::InvalidMetadata)?
855        .components()
856    {
857        here.push(p.as_os_str());
858        match fs::symlink_metadata(&here) {
859            Ok(m) => {
860                if !m.is_dir() || wechat_platform::is_link(&m) {
861                    return Err(ProfileError::UnsupportedFile);
862                }
863            }
864            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
865                fs::create_dir(&here)?;
866                #[cfg(unix)]
867                {
868                    use std::os::unix::fs::PermissionsExt;
869                    fs::set_permissions(&here, fs::Permissions::from_mode(0o700))?;
870                }
871                wechat_platform::sync_dir(here.parent().ok_or(ProfileError::InvalidMetadata)?)?;
872            }
873            Err(e) => return Err(e.into()),
874        }
875    }
876    if fs::symlink_metadata(&target).is_ok() {
877        if cache_matches(
878            &location.root,
879            Path::new(relative),
880            bytes,
881            cache_path_limit(relative),
882        )? {
883            return Ok(false);
884        }
885        return Err(ProfileError::SourceChanged);
886    }
887    use std::io::Write;
888    let mut f = wechat_platform::private_tempfile_in(parent, ".wx-media-")?;
889    f.write_all(bytes)?;
890    f.as_file().sync_all()?;
891    f.persist_noclobber(&target)
892        .map_err(|e| ProfileError::Io(e.error))?;
893    wechat_platform::sync_dir(parent)?;
894    Ok(true)
895}
896
897/// Native 32-bit SuperFastHash of the 32 ASCII hex characters, not decoded MD5.
898/// SQLite stores its unsigned result as a nonnegative 64-bit INTEGER.
899pub fn hardlink_md5_hash(md5: &str) -> Result<u32> {
900    if !digest_hex(md5, 32) {
901        return Err(ProfileError::InvalidMetadata);
902    }
903    let mut hash = md5.len() as u32;
904    for block in md5.as_bytes().chunks_exact(4) {
905        let a = u16::from_le_bytes([block[0], block[1]]) as u32;
906        let b = u16::from_le_bytes([block[2], block[3]]) as u32;
907        hash = hash.wrapping_add(a);
908        let tmp = (b << 11) ^ hash;
909        hash = (hash << 16) ^ tmp;
910        hash = hash.wrapping_add(hash >> 11);
911    }
912    hash ^= hash << 3;
913    hash = hash.wrapping_add(hash >> 5);
914    hash ^= hash << 4;
915    hash = hash.wrapping_add(hash >> 17);
916    hash ^= hash << 25;
917    hash = hash.wrapping_add(hash >> 6);
918    Ok(hash)
919}
920#[derive(Clone, Debug, Serialize)]
921#[serde(rename_all = "camelCase")]
922pub struct AppliedImageCache {
923    pub relative_path: String,
924    pub changed: bool,
925    pub plaintext_bytes: usize,
926    pub encoded_bytes: usize,
927}
928/// Cache one actual JPEG/PNG quality under a deterministic local basename. The
929/// observed DAT/resource/hardlink representation is retained; basename allocation
930/// and UTC month are local policies, not a claim about the native generator.
931/// No other variant is removed. Original bytes must match the message XML MD5.
932pub fn cache_image_projection(
933    location: &ProfileLocation,
934    keys: &KeyFile,
935    source: &SourceMessageId,
936    chat: &str,
937    quality: ImageQuality,
938    plaintext: &[u8],
939    options: &WriteOptions,
940) -> Result<AppliedImageCache> {
941    ownership(location, keys, options)?;
942    let binding = message_binding(location, keys, source, chat, options)?;
943    if binding.kind & 0x7fffffff != 3 {
944        return Err(ProfileError::InvalidQuery);
945    }
946    crate::images::validate_image(plaintext)?;
947    let (suffix, kind) = match quality {
948        ImageQuality::Original => ("_h", 65537),
949        ImageQuality::Standard => ("", 131073),
950        ImageQuality::Thumbnail => ("_t", 262145),
951        _ => return Err(ProfileError::InvalidQuery),
952    };
953    let key = keys
954        .image_dat
955        .as_ref()
956        .ok_or(ProfileError::ImageKeyUnavailable)?;
957    let encoded = crate::encode_dat_v2(plaintext, key, None)?;
958    let hash = format!("{:x}", Md5::digest(plaintext));
959    if quality == ImageQuality::Original {
960        let c = open_write(location, &source.database, keys, options.allow_plaintext)?;
961        let row = c.query_row(
962            &format!(
963                "SELECT * FROM {} WHERE local_id=?1",
964                crate::schema::quote_identifier(&source.table)
965            ),
966            [binding.local],
967            crate::reader::raw,
968        )?;
969        let decoded = crate::reader::decode(
970            &row,
971            "message_content",
972            "WCDB_CT_message_content",
973            4 * 1024 * 1024,
974        )?;
975        if crate::images::image_md5(
976            decoded
977                .text
978                .as_deref()
979                .ok_or(ProfileError::InvalidMetadata)?,
980        )?
981        .as_deref()
982            != Some(hash.as_str())
983        {
984            return Err(ProfileError::InvalidMetadata);
985        }
986    }
987    let db = find_database(location, "message_resource.db")?;
988    let c = open_write(location, &db, keys, options.allow_plaintext)?;
989    let mut rows=c.prepare("SELECT i.packed_info FROM MessageResourceInfo i JOIN ChatName2Id n ON n.rowid=i.chat_id WHERE n.user_name=?1 AND i.message_local_id=?2 AND i.message_create_time=?3 AND i.message_svr_id=?4 LIMIT 3")?.query_map(params![chat,binding.local,binding.time,binding.server],|r|r.get::<_,Vec<u8>>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
990    if rows.len() > 1 {
991        return Err(ProfileError::InvalidMetadata);
992    }
993    let (base, packed) = if let Some(packed) = rows.pop() {
994        let mut bases = vec![];
995        for (f, w, b) in crate::projection::proto_fields(&packed)? {
996            if f == 2 && w == 2 {
997                for (f, w, b) in crate::projection::proto_fields(b)? {
998                    if f == 1 && w == 2 {
999                        let text = String::from_utf8(b.to_vec())
1000                            .map_err(|_| ProfileError::InvalidMetadata)?;
1001                        if !digest_hex(&text, 32) {
1002                            return Err(ProfileError::InvalidMetadata);
1003                        }
1004                        bases.push(text);
1005                    }
1006                }
1007            }
1008        }
1009        if bases.len() != 1 {
1010            return Err(ProfileError::InvalidMetadata);
1011        }
1012        (bases.remove(0), packed)
1013    } else {
1014        let base = format!(
1015            "{:x}",
1016            Md5::digest(
1017                format!(
1018                    "profile-image-v1\0{}\0{}\0{}",
1019                    source.database, source.table, source.local_id
1020                )
1021                .as_bytes()
1022            )
1023        );
1024        let mut packed = vec![18, 34, 10, 32];
1025        packed.extend(base.as_bytes());
1026        (base, packed)
1027    };
1028    drop(c);
1029    let month = chrono::DateTime::from_timestamp(binding.time, 0)
1030        .ok_or(ProfileError::InvalidMetadata)?
1031        .format("%Y-%m")
1032        .to_string();
1033    let directory = format!("{:x}", Md5::digest(chat.as_bytes()));
1034    let filename = format!("{base}{suffix}.dat");
1035    let relative_path = format!("msg/attach/{directory}/{month}/Img/{filename}");
1036    let mut changed = publish_cache_file(
1037        location,
1038        &relative_path,
1039        &encoded,
1040        &hex::encode(Sha256::digest(&encoded)),
1041        options,
1042    )?;
1043    // Native image extra_buffer field2 records encoded DAT size; file_size is plain.
1044    let mut extra = vec![16];
1045    write_varint(encoded.len() as u64, &mut extra);
1046    let mut fields = RawFields::new();
1047    for (k, n) in [
1048        ("md5_hash", hardlink_md5_hash(&hash)? as i64),
1049        ("type", 2),
1050        ("file_size", plaintext.len() as i64),
1051        ("modify_time", binding.time),
1052    ] {
1053        fields.insert(k.into(), SqlValue::Integer(n.to_string()));
1054    }
1055    fields.insert("md5".into(), SqlValue::Text(hash));
1056    fields.insert("file_name".into(), SqlValue::Text(filename));
1057    fields.insert(
1058        "extra_buffer".into(),
1059        SqlValue::Blob(base64::Engine::encode(
1060            &base64::engine::general_purpose::STANDARD,
1061            extra,
1062        )),
1063    );
1064    changed |= apply_hardlink_projection(
1065        location,
1066        keys,
1067        &HardlinkProjection {
1068            kind: HardlinkKind::Image,
1069            first_directory: directory,
1070            second_directory: Some(month),
1071            fields,
1072        },
1073        options,
1074    )?
1075    .changed;
1076    let mut detail = RawFields::new();
1077    for (k, n) in [
1078        ("type", kind),
1079        ("size", encoded.len() as i64),
1080        ("create_time", binding.time),
1081        ("access_time", 0),
1082        ("status", 1),
1083    ] {
1084        detail.insert(k.into(), SqlValue::Integer(n.to_string()));
1085    }
1086    detail.insert("data_index".into(), SqlValue::Text("0".into()));
1087    detail.insert("packed_info".into(), SqlValue::Blob(String::new()));
1088    changed |= apply_media_resource_variants(
1089        location,
1090        keys,
1091        &MediaResourceProjection {
1092            source: source.clone(),
1093            chat: chat.into(),
1094            packed_info: packed,
1095            details: vec![detail],
1096        },
1097        options,
1098    )?
1099    .changed;
1100    Ok(AppliedImageCache {
1101        relative_path,
1102        changed,
1103        plaintext_bytes: plaintext.len(),
1104        encoded_bytes: encoded.len(),
1105    })
1106}
1107fn write_varint(mut n: u64, out: &mut Vec<u8>) {
1108    while n >= 128 {
1109        out.push((n as u8 & 127) | 128);
1110        n >>= 7;
1111    }
1112    out.push(n as u8);
1113}
1114
1115/// Resource categories from the supported Linux build's extension registry. An
1116/// unknown extension uses its observed generic category; these are file resource
1117/// types, independently of the file hardlink table's constant type 1.
1118/// Supported Linux 4.1.13.26 AMD64: registry 0x7554e70, classifier 0x7558600,
1119/// resource constructors 0x755c1ca/0x755d66f (category << 16 | 3).
1120pub fn file_resource_type(extension: &str) -> u32 {
1121    let category = match extension.to_ascii_lowercase().as_str() {
1122        "zip" | "7z" | "rar" | "gz" | "tar" | "z" => 1,
1123        "mp3" | "flac" | "ape" | "wav" | "wma" | "aac" | "amr" | "mmf" | "ram" | "au" | "aif"
1124        | "ogg" | "m4a" => 11,
1125        "dmg" => 21,
1126        "pkg" => 22,
1127        "exe" | "msi" | "com" => 23,
1128        "bat" => 24,
1129        "xls" | "xlsx" | "xlsm" | "csv" => 31,
1130        "numbers" => 32,
1131        "ods" => 33,
1132        "ppt" | "pptx" | "pps" | "ppsm" | "ppsx" | "pptm" => 41,
1133        "key" => 42,
1134        "odp" => 43,
1135        "pdf" => 51,
1136        "doc" | "docx" | "docm" => 52,
1137        "pages" => 53,
1138        "wps" => 54,
1139        "odt" => 55,
1140        "rtf" => 56,
1141        "txt" => 57,
1142        "log" | "md" | "m" | "mm" | "c" | "cc" | "cpp" | "h" | "hpp" => 58,
1143        _ => 0,
1144    };
1145    category << 16 | 3
1146}
1147
1148#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1149#[serde(rename_all = "snake_case")]
1150pub enum VideoQuality {
1151    Normal,
1152    Original,
1153    Thumbnail,
1154}
1155impl VideoQuality {
1156    fn resource_type(self) -> i64 {
1157        match self {
1158            Self::Normal => 131074,
1159            Self::Original => 65538,
1160            Self::Thumbnail => 196610,
1161        }
1162    }
1163    fn suffix(self) -> &'static str {
1164        match self {
1165            Self::Normal => ".mp4",
1166            Self::Original => "_raw.mp4",
1167            Self::Thumbnail => ".jpg",
1168        }
1169    }
1170}
1171
1172// Only recognized leaf paths/attributes are interpreted. All signed URLs and
1173// unrelated message metadata stay in the original protected message row.
1174#[derive(Default)]
1175struct AttachmentXml {
1176    leaves: std::collections::BTreeMap<String, String>,
1177    video: Option<std::collections::BTreeMap<String, String>>,
1178}
1179fn attachment_xml(
1180    location: &ProfileLocation,
1181    keys: &KeyFile,
1182    source: &SourceMessageId,
1183    binding: &MessageBinding,
1184    options: &WriteOptions,
1185) -> Result<AttachmentXml> {
1186    use quick_xml::{events::Event, Reader};
1187    let c = open_write(location, &source.database, keys, options.allow_plaintext)?;
1188    let row = c.query_row(
1189        &format!(
1190            "SELECT * FROM {} WHERE local_id=?1",
1191            crate::schema::quote_identifier(&source.table)
1192        ),
1193        [binding.local],
1194        crate::reader::raw,
1195    )?;
1196    let decoded = crate::reader::decode(
1197        &row,
1198        "message_content",
1199        "WCDB_CT_message_content",
1200        1024 * 1024,
1201    )?;
1202    let xml = decoded
1203        .text
1204        .as_deref()
1205        .ok_or(ProfileError::InvalidMetadata)?;
1206    let xml = xml.trim_start();
1207    let xml = if xml.starts_with('<') {
1208        xml
1209    } else {
1210        let (sender, rest) = xml.split_once(":\n").ok_or(ProfileError::InvalidMetadata)?;
1211        identity(sender)?;
1212        rest
1213    };
1214    let mut r = Reader::from_str(xml);
1215    let mut stack = Vec::new();
1216    let mut out = AttachmentXml::default();
1217    let mut count = 0;
1218    let mut roots = 0;
1219    loop {
1220        match r.read_event() {
1221            Ok(Event::DocType(_)) => return Err(ProfileError::InvalidMetadata),
1222            Ok(event @ (Event::Start(_) | Event::Empty(_))) => {
1223                let empty = matches!(event, Event::Empty(_));
1224                let e = match event {
1225                    Event::Start(e) | Event::Empty(e) => e,
1226                    _ => unreachable!(),
1227                };
1228                count += 1;
1229                if count > 4096 || stack.len() >= 64 {
1230                    return Err(ProfileError::ResourceLimit);
1231                }
1232                let name = std::str::from_utf8(e.name().as_ref())
1233                    .map_err(|_| ProfileError::InvalidMetadata)?
1234                    .to_string();
1235                if stack.is_empty() {
1236                    roots += 1;
1237                    if roots != 1 || name != "msg" {
1238                        return Err(ProfileError::InvalidMetadata);
1239                    }
1240                }
1241                let path = stack
1242                    .iter()
1243                    .map(String::as_str)
1244                    .chain(std::iter::once(name.as_str()))
1245                    .collect::<Vec<_>>()
1246                    .join("/");
1247                if path == "msg/videomsg" {
1248                    if out.video.is_some() {
1249                        return Err(ProfileError::InvalidMetadata);
1250                    }
1251                    let mut attrs = std::collections::BTreeMap::new();
1252                    for a in e.attributes() {
1253                        let a = a.map_err(|_| ProfileError::InvalidMetadata)?;
1254                        let name = std::str::from_utf8(a.key.as_ref())
1255                            .map_err(|_| ProfileError::InvalidMetadata)?;
1256                        if [
1257                            "length",
1258                            "md5",
1259                            "rawlength",
1260                            "rawmd5",
1261                            "cdnthumblength",
1262                            "cdnthumbmd5",
1263                        ]
1264                        .contains(&name)
1265                        {
1266                            let value = a
1267                                .decode_and_unescape_value(r.decoder())
1268                                .map_err(|_| ProfileError::InvalidMetadata)?
1269                                .into_owned();
1270                            if attrs.insert(name.to_string(), value).is_some() {
1271                                return Err(ProfileError::InvalidMetadata);
1272                            }
1273                        }
1274                    }
1275                    out.video = Some(attrs);
1276                }
1277                if matches!(
1278                    path.as_str(),
1279                    "msg/appmsg/title"
1280                        | "msg/appmsg/md5"
1281                        | "msg/appmsg/type"
1282                        | "msg/appmsg/appattach/totallen"
1283                        | "msg/appmsg/appattach/fileext"
1284                ) && out.leaves.insert(path, String::new()).is_some()
1285                {
1286                    return Err(ProfileError::InvalidMetadata);
1287                }
1288                // Empty elements do not have a following End event.
1289                if !empty {
1290                    stack.push(name);
1291                }
1292            }
1293            Ok(Event::Text(e)) => {
1294                let path = stack.join("/");
1295                if let Some(value) = out.leaves.get_mut(&path) {
1296                    let text = e.xml_content().map_err(|_| ProfileError::InvalidMetadata)?;
1297                    value.push_str(
1298                        &quick_xml::escape::unescape(&text)
1299                            .map_err(|_| ProfileError::InvalidMetadata)?,
1300                    );
1301                }
1302            }
1303            Ok(Event::CData(e)) => {
1304                if let Some(value) = out.leaves.get_mut(&stack.join("/")) {
1305                    value.push_str(&e.decode().map_err(|_| ProfileError::InvalidMetadata)?);
1306                }
1307            }
1308            Ok(Event::GeneralRef(e)) => {
1309                let name = e.decode().map_err(|_| ProfileError::InvalidMetadata)?;
1310                let escaped = format!("&{name};");
1311                let text = quick_xml::escape::unescape(&escaped)
1312                    .map_err(|_| ProfileError::InvalidMetadata)?;
1313                if let Some(value) = out.leaves.get_mut(&stack.join("/")) {
1314                    value.push_str(&text);
1315                }
1316            }
1317            Ok(Event::End(_)) => {
1318                stack.pop().ok_or(ProfileError::InvalidMetadata)?;
1319            }
1320            Ok(Event::Eof) => break,
1321            Ok(_) => {}
1322            Err(_) => return Err(ProfileError::InvalidMetadata),
1323        }
1324    }
1325    if roots != 1 || !stack.is_empty() {
1326        return Err(ProfileError::InvalidMetadata);
1327    }
1328    Ok(out)
1329}
1330fn checked_decimal(s: &str) -> Result<u64> {
1331    if s.is_empty() || s.len() > 20 || !s.bytes().all(|b| b.is_ascii_digit()) {
1332        return Err(ProfileError::InvalidMetadata);
1333    }
1334    s.parse().map_err(|_| ProfileError::InvalidMetadata)
1335}
1336fn bytes_match_metadata(
1337    bytes: &[u8],
1338    size: &str,
1339    md5: Option<&str>,
1340    require_md5: bool,
1341    max: usize,
1342) -> Result<String> {
1343    if bytes.is_empty() || bytes.len() > max || checked_decimal(size)? != bytes.len() as u64 {
1344        return Err(ProfileError::InvalidMetadata);
1345    }
1346    let digest = format!("{:x}", Md5::digest(bytes));
1347    if let Some(expected) = md5.filter(|s| !s.is_empty()) {
1348        if !digest_hex(expected, 32) || !expected.eq_ignore_ascii_case(&digest) {
1349            return Err(ProfileError::InvalidMetadata);
1350        }
1351    } else if require_md5 {
1352        return Err(ProfileError::InvalidMetadata);
1353    }
1354    Ok(digest)
1355}
1356fn protobuf_bytes(field: u32, bytes: &[u8]) -> Vec<u8> {
1357    let mut out = Vec::new();
1358    write_varint((field as u64) << 3 | 2, &mut out);
1359    write_varint(bytes.len() as u64, &mut out);
1360    out.extend(bytes);
1361    out
1362}
1363fn file_detail_names(packed: &[u8]) -> Result<(String, Option<String>)> {
1364    let root = crate::projection::proto_fields(packed)?;
1365    if root.len() != 1 || root[0].0 != 1 || root[0].1 != 2 {
1366        return Err(ProfileError::InvalidMetadata);
1367    }
1368    let fields = crate::projection::proto_fields(root[0].2)?;
1369    if !(1..=2).contains(&fields.len())
1370        || fields[0].0 != 1
1371        || fields[0].1 != 2
1372        || fields.get(1).is_some_and(|f| f.0 != 2 || f.1 != 2)
1373    {
1374        return Err(ProfileError::InvalidMetadata);
1375    }
1376    let name = std::str::from_utf8(fields[0].2)
1377        .map_err(|_| ProfileError::InvalidMetadata)?
1378        .to_string();
1379    let local = fields
1380        .get(1)
1381        .map(|f| {
1382            std::str::from_utf8(f.2)
1383                .map(str::to_string)
1384                .map_err(|_| ProfileError::InvalidMetadata)
1385        })
1386        .transpose()?;
1387    if !valid_component(&name) || local.as_ref().is_some_and(|s| !valid_component(s)) {
1388        return Err(ProfileError::InvalidMetadata);
1389    }
1390    Ok((name, local))
1391}
1392fn resource_snapshot(
1393    location: &ProfileLocation,
1394    keys: &KeyFile,
1395    binding: &MessageBinding,
1396    chat: &str,
1397    options: &WriteOptions,
1398) -> Result<Option<(Vec<u8>, Vec<RawFields>)>> {
1399    let db = find_database(location, "message_resource.db")?;
1400    let c = open_write(location, &db, keys, options.allow_plaintext)?;
1401    check_known_table(&c, "MessageResourceInfo")?;
1402    check_known_table(&c, "MessageResourceDetail")?;
1403    let rows = c.prepare("SELECT i.message_id,i.packed_info FROM MessageResourceInfo i JOIN ChatName2Id n ON n.rowid=i.chat_id WHERE n.user_name=?1 AND i.message_local_id=?2 AND i.message_create_time=?3 AND i.message_svr_id=?4 LIMIT 3")?.query_map(params![chat,binding.local,binding.time,binding.server], |r| Ok((r.get::<_,i64>(0)?,r.get::<_,Vec<u8>>(1)?)))?.collect::<std::result::Result<Vec<_>,_>>()?;
1404    if rows.len() > 2 {
1405        return Err(ProfileError::InvalidMetadata);
1406    }
1407    if let Some((id, packed)) = rows.first() {
1408        let info = raw_row(
1409            &c,
1410            "MessageResourceInfo",
1411            "message_id",
1412            *id,
1413            &["message_id"],
1414        )?;
1415        let details = resource_detail_rows(&c, *id)?
1416            .into_iter()
1417            .map(|(_, r)| r)
1418            .collect::<Vec<_>>();
1419        if rows.len() == 2
1420            && details.iter().any(|row| {
1421                crate::reader::num(row, "status") != Some(1)
1422                    || crate::reader::num(row, "size").is_none_or(|n| n <= 0)
1423            })
1424        {
1425            return Err(ProfileError::InvalidMetadata);
1426        }
1427        for (alias, _) in rows.iter().skip(1) {
1428            if raw_row(
1429                &c,
1430                "MessageResourceInfo",
1431                "message_id",
1432                *alias,
1433                &["message_id"],
1434            )? != info
1435                || resource_detail_rows(&c, *alias)?
1436                    .into_iter()
1437                    .map(|(_, r)| r)
1438                    .collect::<Vec<_>>()
1439                    != details
1440            {
1441                return Err(ProfileError::InvalidMetadata);
1442            }
1443        }
1444        Ok(Some((packed.clone(), details)))
1445    } else {
1446        Ok(None)
1447    }
1448}
1449
1450fn resource_blob(fields: &RawFields, key: &str) -> Result<Option<Vec<u8>>> {
1451    match fields.get(key) {
1452        Some(SqlValue::Blob(value)) => {
1453            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value)
1454                .map(Some)
1455                .map_err(|_| ProfileError::InvalidMetadata)
1456        }
1457        Some(SqlValue::Null) | None => Ok(None),
1458        _ => Err(ProfileError::InvalidMetadata),
1459    }
1460}
1461fn file_name_completion(old: &RawFields, new: &RawFields) -> Result<bool> {
1462    let before = resource_blob(old, "packed_info")?.ok_or(ProfileError::InvalidMetadata)?;
1463    let after = resource_blob(new, "packed_info")?.ok_or(ProfileError::InvalidMetadata)?;
1464    let (original, local) = file_detail_names(&before)?;
1465    let (next_original, next_local) = file_detail_names(&after)?;
1466    Ok(original == next_original && local.is_none() && next_local.is_some())
1467}
1468fn retain_access(
1469    detail: &mut RawFields,
1470    existing: Option<&(Vec<u8>, Vec<RawFields>)>,
1471) -> Result<()> {
1472    if let Some((_, rows)) = existing {
1473        let mut matched = rows.iter().filter(|row| {
1474            row.get("type") == detail.get("type")
1475                && row.get("data_index") == detail.get("data_index")
1476        });
1477        if let Some(row) = matched.next() {
1478            if matched.next().is_some() {
1479                return Err(ProfileError::InvalidMetadata);
1480            }
1481            detail.insert(
1482                "access_time".into(),
1483                row.get("access_time")
1484                    .ok_or(ProfileError::InvalidMetadata)?
1485                    .clone(),
1486            );
1487        }
1488    }
1489    Ok(())
1490}
1491fn downloaded_detail(
1492    kind: i64,
1493    binding: &MessageBinding,
1494    length: usize,
1495    packed: &[u8],
1496) -> RawFields {
1497    let mut detail = RawFields::new();
1498    for (key, n) in [
1499        ("type", kind),
1500        ("size", length as i64),
1501        ("create_time", binding.time),
1502        ("access_time", 0),
1503        ("status", 1),
1504    ] {
1505        detail.insert(key.into(), SqlValue::Integer(n.to_string()));
1506    }
1507    detail.insert("data_index".into(), SqlValue::Text("0".into()));
1508    detail.insert(
1509        "packed_info".into(),
1510        SqlValue::Blob(base64::Engine::encode(
1511            &base64::engine::general_purpose::STANDARD,
1512            packed,
1513        )),
1514    );
1515    detail
1516}
1517#[allow(clippy::too_many_arguments)]
1518fn publish_plain_hardlink(
1519    location: &ProfileLocation,
1520    keys: &KeyFile,
1521    binding: &MessageBinding,
1522    kind: HardlinkKind,
1523    filename: &str,
1524    plaintext: &[u8],
1525    hash: &str,
1526    options: &WriteOptions,
1527) -> Result<AppliedHardlinkCache> {
1528    let month = chrono::DateTime::from_timestamp(binding.time, 0)
1529        .ok_or(ProfileError::InvalidMetadata)?
1530        .format("%Y-%m")
1531        .to_string();
1532    let relative_path = format!("{}/{month}/{filename}", kind.directory()?);
1533    let mut changed = publish_cache_file(
1534        location,
1535        &relative_path,
1536        plaintext,
1537        &hex::encode(Sha256::digest(plaintext)),
1538        options,
1539    )?;
1540    let mut fields = RawFields::new();
1541    for (k, n) in [
1542        ("md5_hash", hardlink_md5_hash(hash)? as i64),
1543        ("type", if kind == HardlinkKind::File { 1 } else { 3 }),
1544        ("file_size", plaintext.len() as i64),
1545        ("modify_time", binding.time),
1546    ] {
1547        fields.insert(k.into(), SqlValue::Integer(n.to_string()));
1548    }
1549    fields.insert("md5".into(), SqlValue::Text(hash.into()));
1550    fields.insert("file_name".into(), SqlValue::Text(filename.into()));
1551    fields.insert("extra_buffer".into(), SqlValue::Blob(String::new()));
1552    // Existing native downloads record an independent filesystem modification
1553    // time. Preserve that value after validating this exact path and digest.
1554    let db = find_database(location, "hardlink.db")?;
1555    let c = open_write(location, &db, keys, options.allow_plaintext)?;
1556    check_known_table(&c, kind.table())?;
1557    let existing=c.prepare(&format!("SELECT h.modify_time FROM {} h JOIN dir2id d ON d.rowid=h.dir1 WHERE h.md5=?1 AND h.file_name=?2 AND d.username=?3 AND h.dir2=0 LIMIT 2",kind.table()))?.query_map(params![hash,filename,month],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
1558    if existing.len() > 1 {
1559        return Err(ProfileError::InvalidMetadata);
1560    }
1561    if let Some(time) = existing.first() {
1562        fields.insert("modify_time".into(), SqlValue::Integer(time.to_string()));
1563    }
1564    drop(c);
1565    changed |= apply_hardlink_projection(
1566        location,
1567        keys,
1568        &HardlinkProjection {
1569            kind,
1570            first_directory: month,
1571            second_directory: None,
1572            fields,
1573        },
1574        options,
1575    )?
1576    .changed;
1577    Ok(AppliedHardlinkCache {
1578        relative_path,
1579        changed,
1580        plaintext_bytes: plaintext.len(),
1581    })
1582}
1583
1584/// Register an ordinary subtype6 attachment's verified bytes, resource row,
1585/// filename pair and native hardlink. A new collision-bounded local name is an
1586/// explicit allocation policy; an existing resource filename is retained.
1587pub fn cache_file_projection(
1588    location: &ProfileLocation,
1589    keys: &KeyFile,
1590    source: &SourceMessageId,
1591    chat: &str,
1592    plaintext: &[u8],
1593    options: &WriteOptions,
1594) -> Result<AppliedHardlinkCache> {
1595    ownership(location, keys, options)?;
1596    let binding = message_binding(location, keys, source, chat, options)?;
1597    if binding.kind as u64 != (6u64 << 32) | 49 {
1598        return Err(ProfileError::InvalidQuery);
1599    }
1600    let xml = attachment_xml(location, keys, source, &binding, options)?;
1601    let leaf = |path: &str| {
1602        xml.leaves
1603            .get(path)
1604            .map(String::as_str)
1605            .ok_or(ProfileError::InvalidMetadata)
1606    };
1607    if leaf("msg/appmsg/type")? != "6" {
1608        return Err(ProfileError::InvalidMetadata);
1609    }
1610    let title = leaf("msg/appmsg/title")?;
1611    if !valid_component(title) || title.len() > 255 || title.chars().any(char::is_control) {
1612        return Err(ProfileError::InvalidMetadata);
1613    }
1614    let hash = bytes_match_metadata(
1615        plaintext,
1616        leaf("msg/appmsg/appattach/totallen")?,
1617        Some(leaf("msg/appmsg/md5")?),
1618        true,
1619        MAX_CACHED_FILE_BYTES,
1620    )?;
1621    let extension = leaf("msg/appmsg/appattach/fileext")?;
1622    let kind = file_resource_type(extension) as i64;
1623    let mut local = format!("{hash}_{title}");
1624    if local.len() > 255 {
1625        let extension = title
1626            .rsplit_once('.')
1627            .map(|(_, extension)| extension)
1628            .filter(|extension| {
1629                !extension.is_empty()
1630                    && extension.len() <= 32
1631                    && extension.bytes().all(|b| b.is_ascii_alphanumeric())
1632            })
1633            .unwrap_or("bin");
1634        local = format!("{hash}.{extension}");
1635    }
1636    let existing = resource_snapshot(location, keys, &binding, chat, options)?;
1637    if let Some((packed, details)) = &existing {
1638        if !packed.is_empty() || details.len() > 1 {
1639            return Err(ProfileError::InvalidMetadata);
1640        }
1641        for detail in details {
1642            if crate::reader::num(detail, "type") != Some(kind)
1643                || detail.get("data_index") != Some(&SqlValue::Text("0".into()))
1644            {
1645                return Err(ProfileError::InvalidMetadata);
1646            }
1647            {
1648                let packed =
1649                    resource_blob(detail, "packed_info")?.ok_or(ProfileError::InvalidMetadata)?;
1650                let (original, name) = file_detail_names(&packed)?;
1651                if original != title {
1652                    return Err(ProfileError::InvalidMetadata);
1653                }
1654                if let Some(name) = name {
1655                    local = name;
1656                }
1657            }
1658        }
1659    }
1660    if !valid_component(&local) || local.len() > 255 {
1661        return Err(ProfileError::InvalidMetadata);
1662    }
1663    let mut nested = protobuf_bytes(1, title.as_bytes());
1664    nested.extend(protobuf_bytes(2, local.as_bytes()));
1665    let packed = protobuf_bytes(1, &nested);
1666    let mut detail = downloaded_detail(kind, &binding, plaintext.len(), &packed);
1667    retain_access(&mut detail, existing.as_ref())?;
1668    let mut result = publish_plain_hardlink(
1669        location,
1670        keys,
1671        &binding,
1672        HardlinkKind::File,
1673        &local,
1674        plaintext,
1675        &hash,
1676        options,
1677    )?;
1678    result.changed |= apply_media_resource_variants(
1679        location,
1680        keys,
1681        &MediaResourceProjection {
1682            source: source.clone(),
1683            chat: chat.into(),
1684            packed_info: vec![],
1685            details: vec![detail],
1686        },
1687        options,
1688    )?
1689    .changed;
1690    Ok(result)
1691}
1692
1693/// Cache one video representation using the resource basename plus its exact
1694/// native suffix. Movie and JPEG thumbnail bytes are never interchanged, and
1695/// adding one quality preserves every other resource detail and basename.
1696#[allow(clippy::too_many_arguments)]
1697pub fn cache_video_projection(
1698    location: &ProfileLocation,
1699    keys: &KeyFile,
1700    source: &SourceMessageId,
1701    chat: &str,
1702    quality: VideoQuality,
1703    plaintext: &[u8],
1704    options: &WriteOptions,
1705) -> Result<AppliedHardlinkCache> {
1706    ownership(location, keys, options)?;
1707    let binding = message_binding(location, keys, source, chat, options)?;
1708    if binding.kind & 0x7fffffff != 43 {
1709        return Err(ProfileError::InvalidQuery);
1710    }
1711    let xml = attachment_xml(location, keys, source, &binding, options)?;
1712    let attrs = xml.video.as_ref().ok_or(ProfileError::InvalidMetadata)?;
1713    let (size, digest) = match quality {
1714        VideoQuality::Normal => ("length", "md5"),
1715        VideoQuality::Original => ("rawlength", "rawmd5"),
1716        VideoQuality::Thumbnail => ("cdnthumblength", "cdnthumbmd5"),
1717    };
1718    let hash = bytes_match_metadata(
1719        plaintext,
1720        attrs.get(size).ok_or(ProfileError::InvalidMetadata)?,
1721        attrs.get(digest).map(String::as_str),
1722        quality != VideoQuality::Thumbnail,
1723        MAX_CACHED_MEDIA_BYTES,
1724    )?;
1725    if quality == VideoQuality::Thumbnail {
1726        if crate::images::validate_image(plaintext)?.0 != "image/jpeg" {
1727            return Err(ProfileError::InvalidMetadata);
1728        }
1729    } else if wechat_media::inspect_video_container(plaintext).is_err() {
1730        return Err(ProfileError::InvalidMetadata);
1731    }
1732    let existing = resource_snapshot(location, keys, &binding, chat, options)?;
1733    let (base, packed) = if let Some((packed, _)) = existing.as_ref() {
1734        let fields = crate::projection::proto_fields(packed)?;
1735        if fields.len() != 1 || fields[0].0 != 2 || fields[0].1 != 2 {
1736            return Err(ProfileError::InvalidMetadata);
1737        }
1738        let nested = crate::projection::proto_fields(fields[0].2)?;
1739        if nested.len() != 1 || nested[0].0 != 1 || nested[0].1 != 2 {
1740            return Err(ProfileError::InvalidMetadata);
1741        }
1742        let base = std::str::from_utf8(nested[0].2)
1743            .map_err(|_| ProfileError::InvalidMetadata)?
1744            .to_string();
1745        if !digest_hex(&base, 32) {
1746            return Err(ProfileError::InvalidMetadata);
1747        }
1748        (base, packed.clone())
1749    } else {
1750        // Basename generation is our allocation policy; its representation and
1751        // joins are the native format, independently checked against stock rows.
1752        let base = format!(
1753            "{:x}",
1754            Md5::digest(
1755                format!(
1756                    "profile-video-v1\0{}\0{}\0{}",
1757                    source.database, source.table, source.local_id
1758                )
1759                .as_bytes()
1760            )
1761        );
1762        let packed = protobuf_bytes(2, &protobuf_bytes(1, base.as_bytes()));
1763        (base, packed)
1764    };
1765    let filename = format!("{base}{}", quality.suffix());
1766    let mut detail = downloaded_detail(quality.resource_type(), &binding, plaintext.len(), &[]);
1767    retain_access(&mut detail, existing.as_ref())?;
1768    let mut result = publish_plain_hardlink(
1769        location,
1770        keys,
1771        &binding,
1772        HardlinkKind::Video,
1773        &filename,
1774        plaintext,
1775        &hash,
1776        options,
1777    )?;
1778    result.changed |= apply_media_resource_variants(
1779        location,
1780        keys,
1781        &MediaResourceProjection {
1782            source: source.clone(),
1783            chat: chat.into(),
1784            packed_info: packed,
1785            details: vec![detail],
1786        },
1787        options,
1788    )?
1789    .changed;
1790    Ok(result)
1791}
1792#[derive(Clone, Debug, Serialize)]
1793#[serde(rename_all = "camelCase")]
1794pub struct AppliedHardlinkCache {
1795    pub relative_path: String,
1796    pub changed: bool,
1797    pub plaintext_bytes: usize,
1798}
1799/// Register verified ordinary file/video bytes in the observed month/hardlink
1800/// namespace. This does not invent a MessageResourceDetail subtype or packed
1801/// attachment record; adapters with that metadata can register it separately.
1802/// A digest prefix makes the local filename deterministic and collision bounded.
1803#[allow(clippy::too_many_arguments)]
1804pub fn cache_hardlink_media_projection(
1805    location: &ProfileLocation,
1806    keys: &KeyFile,
1807    source: &SourceMessageId,
1808    chat: &str,
1809    kind: HardlinkKind,
1810    filename: &str,
1811    plaintext: &[u8],
1812    options: &WriteOptions,
1813) -> Result<AppliedHardlinkCache> {
1814    ownership(location, keys, options)?;
1815    if plaintext.is_empty()
1816        || plaintext.len() > hardlink_limit(kind)
1817        || !valid_component(filename)
1818        || filename.len() > 180
1819    {
1820        return Err(ProfileError::InvalidMetadata);
1821    }
1822    let binding = message_binding(location, keys, source, chat, options)?;
1823    let expected = match kind {
1824        HardlinkKind::File => 49,
1825        HardlinkKind::Video => 43,
1826        _ => return Err(ProfileError::InvalidQuery),
1827    };
1828    if binding.kind & 0x7fffffff != expected
1829        || (kind == HardlinkKind::File && (binding.kind as u64 >> 32) != 6)
1830    {
1831        return Err(ProfileError::InvalidQuery);
1832    }
1833    if kind == HardlinkKind::Video && wechat_media::inspect_video_container(plaintext).is_err() {
1834        return Err(ProfileError::InvalidMetadata);
1835    }
1836    let hash = format!("{:x}", Md5::digest(plaintext));
1837    let month = chrono::DateTime::from_timestamp(binding.time, 0)
1838        .ok_or(ProfileError::InvalidMetadata)?
1839        .format("%Y-%m")
1840        .to_string();
1841    let local_name = format!("{hash}_{filename}");
1842    let relative_path = format!("{}/{month}/{local_name}", kind.directory()?);
1843    let mut changed = publish_cache_file(
1844        location,
1845        &relative_path,
1846        plaintext,
1847        &hex::encode(Sha256::digest(plaintext)),
1848        options,
1849    )?;
1850    let mut fields = RawFields::new();
1851    for (k, n) in [
1852        ("md5_hash", hardlink_md5_hash(&hash)? as i64),
1853        ("type", if kind == HardlinkKind::File { 1 } else { 3 }),
1854        ("file_size", plaintext.len() as i64),
1855        ("modify_time", binding.time),
1856    ] {
1857        fields.insert(k.into(), SqlValue::Integer(n.to_string()));
1858    }
1859    fields.insert("md5".into(), SqlValue::Text(hash));
1860    fields.insert("file_name".into(), SqlValue::Text(local_name));
1861    fields.insert("extra_buffer".into(), SqlValue::Blob(String::new()));
1862    changed |= apply_hardlink_projection(
1863        location,
1864        keys,
1865        &HardlinkProjection {
1866            kind,
1867            first_directory: month,
1868            second_directory: None,
1869            fields,
1870        },
1871        options,
1872    )?
1873    .changed;
1874    Ok(AppliedHardlinkCache {
1875        relative_path,
1876        changed,
1877        plaintext_bytes: plaintext.len(),
1878    })
1879}
1880
1881#[cfg(test)]
1882mod cache_limit_tests {
1883    use super::*;
1884    #[test]
1885    fn only_ordinary_file_namespaces_receive_the_larger_limit() {
1886        assert_eq!(hardlink_limit(HardlinkKind::File), 536_870_912);
1887        assert_eq!(cache_path_limit("msg/file/2026-10/a.bin"), 536_870_912);
1888        for kind in [HardlinkKind::Video, HardlinkKind::Image] {
1889            assert_eq!(hardlink_limit(kind), MAX_CACHED_MEDIA_BYTES);
1890        }
1891        for path in ["msg/video/a", "msg/attach/a", "msg/file2/a"] {
1892            assert_eq!(cache_path_limit(path), MAX_CACHED_MEDIA_BYTES);
1893        }
1894    }
1895    #[test]
1896    fn cache_comparison_checks_all_chunks_without_a_second_file_buffer() {
1897        let dir = tempfile::tempdir().unwrap();
1898        let path = dir.path().join("fixture");
1899        let mut bytes = vec![3; 128 * 1024 + 7];
1900        fs::write(&path, &bytes).unwrap();
1901        assert!(cache_matches(dir.path(), Path::new("fixture"), &bytes, bytes.len()).unwrap());
1902        let last = bytes.len() - 1;
1903        bytes[last] ^= 1;
1904        assert!(!cache_matches(dir.path(), Path::new("fixture"), &bytes, bytes.len()).unwrap());
1905        assert!(matches!(
1906            cache_matches(dir.path(), Path::new("fixture"), &bytes, bytes.len() - 1),
1907            Err(ProfileError::ResourceLimit)
1908        ));
1909    }
1910}