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