1use 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}
73pub 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 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 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
333pub 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#[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}
511pub 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 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 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#[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
825pub 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
897pub 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}
928pub 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 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
1115pub 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#[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 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 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
1584pub 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#[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 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#[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}