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 && 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
331pub 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#[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}
509pub 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 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 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#[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
823pub 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
895pub 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}
926pub 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 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
1113pub 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#[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 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 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
1582pub 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#[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 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#[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}