1use std::io::{Read, Write};
24use std::path::{Path, PathBuf};
25use std::time::{Duration, SystemTime};
26
27use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29
30use super::extract;
31use super::kind::{self, FileKind};
32use super::process::{self, Derived};
33use crate::backend::rpc_types::AttachmentInfo;
34
35pub const DERIVE_VERSION: u32 = 1;
38
39pub fn fingerprint(send_max_edge: u32) -> String {
41 format!("v{DERIVE_VERSION}-e{send_max_edge}")
42}
43
44const INCOMING_MAX_AGE: Duration = Duration::from_secs(60 * 60);
47
48pub const UNSENT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
51
52pub fn is_valid_id(id: &str) -> bool {
55 id.len() == 64
56 && id
57 .bytes()
58 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63pub struct StoredMeta {
64 pub v: u32,
65 pub mime: String,
66 pub ext: String,
67 pub bytes: u64,
68 pub width: u32,
69 pub height: u32,
70 pub send_mime: String,
71 pub send_ext: String,
72 pub send_bytes: u64,
73 pub send_width: u32,
74 pub send_height: u32,
75 pub thumb_mime: String,
76 pub thumb_ext: String,
77 #[serde(default)]
78 pub first_frame_only: bool,
79 #[serde(default = "default_kind")]
83 pub kind: String,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub page_count: Option<u32>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub text_ext: Option<String>,
89 #[serde(default)]
90 pub text_bytes: u64,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub text_note: Option<String>,
94 #[serde(default)]
95 pub macros: bool,
96}
97
98fn default_kind() -> String {
99 "image".to_string()
100}
101
102impl StoredMeta {
103 pub fn is_image(&self) -> bool {
104 self.kind == "image"
105 }
106
107 pub fn to_info(&self, id: &str, name: &str) -> AttachmentInfo {
108 AttachmentInfo {
109 id: id.to_string(),
110 name: name.to_string(),
111 mime: self.mime.clone(),
112 bytes: self.bytes,
113 width: self.width,
114 height: self.height,
115 send_mime: self.send_mime.clone(),
116 send_bytes: self.send_bytes,
117 send_width: self.send_width,
118 send_height: self.send_height,
119 first_frame_only: self.first_frame_only,
120 kind: self.kind.clone(),
121 page_count: self.page_count,
122 text_bytes: self.text_bytes,
123 text_note: self.text_note.clone(),
124 macros: self.macros,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Kind {
132 Original,
133 Thumb,
136 Send,
137 Text,
139}
140
141impl Kind {
142 pub fn parse(s: &str) -> Option<Self> {
143 match s {
144 "original" => Some(Kind::Original),
145 "thumb" => Some(Kind::Thumb),
146 "send" => Some(Kind::Send),
147 "text" => Some(Kind::Text),
148 _ => None,
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
154pub struct Store {
155 root: PathBuf,
156}
157
158impl Store {
159 pub fn new(root: PathBuf) -> Self {
160 Self { root }
161 }
162
163 pub fn root(&self) -> &Path {
164 &self.root
165 }
166
167 pub fn ensure_dirs(&self) -> std::io::Result<()> {
169 std::fs::create_dir_all(&self.root)?;
170 let _ = agentmux_common::data_paths::ensure_owner_only_dir(&self.root);
171 for sub in ["blobs", "derived", "incoming", "sessions"] {
172 std::fs::create_dir_all(self.root.join(sub))?;
173 }
174 Ok(())
175 }
176
177 fn session_path(&self, key: &str) -> PathBuf {
178 let hashed = hex::encode(Sha256::digest(key.as_bytes()));
179 self.root.join("sessions").join(format!("{hashed}.json"))
180 }
181
182 pub fn session_inline_bytes(&self, key: &str) -> u64 {
186 std::fs::read_to_string(self.session_path(key))
187 .ok()
188 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
189 .and_then(|v| v.get("bytes").and_then(|b| b.as_u64()))
190 .unwrap_or(0)
191 }
192
193 pub fn adopt_session_inline_bytes(&self, from: &str, to: &str) {
197 let carried = self.session_inline_bytes(from);
198 if carried == 0 {
199 return;
200 }
201 self.add_session_inline_bytes(to, carried);
202 let _ = std::fs::remove_file(self.session_path(from));
203 }
204
205 pub fn add_session_inline_bytes(&self, key: &str, bytes: u64) {
206 if bytes == 0 {
207 return;
208 }
209 let total = self.session_inline_bytes(key).saturating_add(bytes);
210 let path = self.session_path(key);
211 if let Some(dir) = path.parent() {
212 let _ = std::fs::create_dir_all(dir);
213 }
214 let _ = write_atomic(
215 &path,
216 serde_json::json!({ "bytes": total }).to_string().as_bytes(),
217 );
218 }
219
220 fn shard(id: &str) -> &str {
221 &id[..2]
222 }
223
224 pub fn blob_path(&self, id: &str, ext: &str) -> PathBuf {
225 self.root
226 .join("blobs")
227 .join(Self::shard(id))
228 .join(format!("{id}.{ext}"))
229 }
230
231 fn derived_path(&self, id: &str, fp: &str, suffix: &str) -> PathBuf {
232 self.root
233 .join("derived")
234 .join(Self::shard(id))
235 .join(format!("{id}.{fp}.{suffix}"))
236 }
237
238 fn meta_path(&self, id: &str, fp: &str) -> PathBuf {
239 self.derived_path(id, fp, "json")
240 }
241
242 pub fn find_blob(&self, id: &str) -> Option<(PathBuf, FileKind)> {
244 if !is_valid_id(id) {
245 return None;
246 }
247 let path = self.existing_blob(id)?;
248 let name = path.file_name()?.to_string_lossy().into_owned();
250 let kind = kind::classify(&path, &name).ok()?;
251 Some((path, kind))
252 }
253
254 pub fn copy_original_to(&self, id: &str, dir: &Path, name: &str) -> std::io::Result<PathBuf> {
260 if !is_valid_id(id) {
261 return Err(std::io::Error::new(
262 std::io::ErrorKind::InvalidInput,
263 "invalid attachment id",
264 ));
265 }
266 let blob = self.existing_blob(id).ok_or_else(|| {
267 std::io::Error::new(std::io::ErrorKind::NotFound, "attachment not found")
268 })?;
269 if !dir.is_dir() {
270 return Err(std::io::Error::new(
271 std::io::ErrorKind::NotFound,
272 "working folder not found",
273 ));
274 }
275 use agentmux_common::copy_into_dir::{copy_into_dir, CopyControl};
276 copy_into_dir(&blob, dir, name, &CopyControl::default())
277 }
278
279 pub fn original(&self, id: &str) -> Option<(PathBuf, String)> {
281 let (path, kind) = self.find_blob(id)?;
282 let mime = match kind {
283 FileKind::Image(f) => process::mime_of(f).to_string(),
284 _ => mime_for(kind, &blob_ext(&path)),
285 };
286 Some((path, mime))
287 }
288
289 fn existing_blob(&self, id: &str) -> Option<PathBuf> {
291 let dir = self.root.join("blobs").join(Self::shard(id));
292 let prefix = format!("{id}.");
293 std::fs::read_dir(dir)
294 .ok()?
295 .filter_map(Result::ok)
296 .map(|e| e.path())
297 .find(|p| {
298 p.file_name()
299 .and_then(|n| n.to_str())
300 .is_some_and(|n| n.starts_with(&prefix))
301 })
302 }
303
304 fn named_path(&self, id: &str, name: &str) -> PathBuf {
305 self.root.join("named").join(id).join(safe_file_name(name))
306 }
307
308 pub fn named_link(&self, id: &str, name: &str) -> Option<PathBuf> {
315 if !is_valid_id(id) {
316 return None;
317 }
318 let target = self.existing_blob(id)?;
319 let link = self.named_path(id, name);
320 std::fs::create_dir_all(link.parent()?).ok()?;
321 let tmp = link.with_extension(format!("part-{}", uuid::Uuid::new_v4()));
322 std::fs::copy(&target, &tmp).ok()?;
323 if std::fs::rename(&tmp, &link).is_err() {
324 let _ = std::fs::remove_file(&tmp);
325 return link.is_file().then_some(link);
328 }
329 Some(link)
330 }
331
332 pub fn new_incoming_path(&self) -> PathBuf {
333 self.root
334 .join("incoming")
335 .join(format!("{}.part", uuid::Uuid::new_v4()))
336 }
337
338 pub fn meta(&self, id: &str, fp: &str) -> Option<StoredMeta> {
341 if !is_valid_id(id) {
342 return None;
343 }
344 let text = std::fs::read_to_string(self.meta_path(id, fp)).ok()?;
345 let meta: StoredMeta = serde_json::from_str(&text).ok()?;
346 let complete = self.blob_path(id, &meta.ext).is_file()
348 && self.meta_files(id, fp, &meta).iter().all(|p| p.is_file());
349 complete.then_some(meta)
350 }
351
352 fn meta_files(&self, id: &str, fp: &str, meta: &StoredMeta) -> Vec<PathBuf> {
354 let mut out = Vec::new();
355 if meta.is_image() {
356 out.push(self.derived_path(id, fp, &format!("send.{}", meta.send_ext)));
357 }
358 if !meta.thumb_ext.is_empty() {
359 out.push(self.derived_path(id, fp, &format!("thumb.{}", meta.thumb_ext)));
360 }
361 if let Some(ext) = &meta.text_ext {
362 out.push(self.derived_path(id, fp, &format!("text.{ext}")));
363 }
364 out
365 }
366
367 pub fn file(&self, id: &str, fp: &str, kind: Kind) -> Option<(PathBuf, String)> {
371 let meta = self.meta(id, fp)?;
372 let original = (self.blob_path(id, &meta.ext), meta.mime.clone());
373 Some(match kind {
374 Kind::Original => original,
375 Kind::Send if !meta.is_image() => original,
376 Kind::Send => (
377 self.derived_path(id, fp, &format!("send.{}", meta.send_ext)),
378 meta.send_mime,
379 ),
380 Kind::Thumb if meta.kind == "svg" => original,
381 Kind::Thumb if meta.thumb_ext.is_empty() => return None,
382 Kind::Thumb => (
383 self.derived_path(id, fp, &format!("thumb.{}", meta.thumb_ext)),
384 meta.thumb_mime,
385 ),
386 Kind::Text => (
387 self.derived_path(id, fp, &format!("text.{}", meta.text_ext.as_deref()?)),
388 "text/plain; charset=utf-8".to_string(),
389 ),
390 })
391 }
392
393 pub fn touch(&self, id: &str, fp: &str) {
395 let Some(meta) = self.meta(id, fp) else {
396 return;
397 };
398 let now = filetime_now();
399 let mut files = vec![
400 self.blob_path(id, &meta.ext),
401 self.meta_path(id, fp),
402 self.sent_marker(id),
403 ];
404 files.extend(self.meta_files(id, fp, &meta));
405 for p in files {
406 let _ = set_mtime(&p, now);
407 }
408 }
409
410 fn sent_marker(&self, id: &str) -> PathBuf {
411 self.root
412 .join("derived")
413 .join(Self::shard(id))
414 .join(format!("{id}.sent"))
415 }
416
417 pub fn mark_sent(&self, id: &str) {
420 if !is_valid_id(id) {
421 return;
422 }
423 let marker = self.sent_marker(id);
424 if let Some(dir) = marker.parent() {
425 let _ = std::fs::create_dir_all(dir);
426 }
427 let _ = std::fs::write(&marker, b"");
428 }
429
430 fn is_sent(&self, id: &str) -> bool {
431 self.sent_marker(id).is_file()
432 }
433
434 pub fn discard_if_unprocessed(&self, id: &str) {
439 if !is_valid_id(id) {
440 return;
441 }
442 let prefix = format!("{id}.");
443 let names = |sub: &str| -> Vec<PathBuf> {
444 std::fs::read_dir(self.root.join(sub).join(Self::shard(id)))
445 .map(|rd| {
446 rd.filter_map(Result::ok)
447 .map(|e| e.path())
448 .filter(|p| {
449 p.file_name()
450 .and_then(|n| n.to_str())
451 .is_some_and(|n| n.starts_with(&prefix))
452 })
453 .collect()
454 })
455 .unwrap_or_default()
456 };
457 let processed = names("derived")
458 .iter()
459 .any(|p| p.extension().is_some_and(|e| e == "json"));
460 if processed {
461 return;
462 }
463 for blob in names("blobs") {
464 let _ = std::fs::remove_file(blob);
465 }
466 }
467
468 pub fn copy_in(
472 &self,
473 src: &Path,
474 mut on_progress: impl FnMut(u64) -> bool,
475 ) -> std::io::Result<(PathBuf, String, u64)> {
476 let mut input = std::fs::File::open(src)?;
477 let tmp = self.new_incoming_path();
478 let result = (|| {
479 let mut out = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
480 let mut hasher = Sha256::new();
481 let mut buf = vec![0u8; 1024 * 1024];
482 let mut done = 0u64;
483 loop {
484 let n = input.read(&mut buf)?;
485 if n == 0 {
486 break;
487 }
488 hasher.update(&buf[..n]);
489 out.write_all(&buf[..n])?;
490 done += n as u64;
491 if !on_progress(done) {
492 return Err(std::io::Error::new(
493 std::io::ErrorKind::Interrupted,
494 "cancelled",
495 ));
496 }
497 }
498 out.flush()?;
499 Ok((hex::encode(hasher.finalize()), done))
500 })();
501 match result {
502 Ok((id, n)) => Ok((tmp, id, n)),
503 Err(e) => {
504 let _ = std::fs::remove_file(&tmp);
505 Err(e)
506 }
507 }
508 }
509
510 pub fn place(
515 &self,
516 incoming: &Path,
517 id: &str,
518 name: &str,
519 ) -> Result<(PathBuf, FileKind), process::ProcessError> {
520 let cleanup = || {
521 let _ = std::fs::remove_file(incoming);
522 };
523 if let Some(existing) = self.existing_blob(id) {
524 cleanup();
525 let _ = set_mtime(&existing, filetime_now());
526 let stored_name = existing
527 .file_name()
528 .map(|n| n.to_string_lossy().into_owned())
529 .unwrap_or_default();
530 let kind = kind::classify(&existing, &stored_name).map_err(io_error)?;
531 return Ok((existing, kind));
532 }
533 let kind = kind::classify(incoming, name).map_err(|e| {
534 cleanup();
535 io_error(e)
536 })?;
537 let blob = self.blob_path(id, &stored_extension(kind, name));
538 let placed = (|| {
539 std::fs::create_dir_all(blob.parent().unwrap())?;
540 std::fs::rename(incoming, &blob)
541 })();
542 placed.map_err(|e| {
543 cleanup();
544 io_error(e)
545 })?;
546 Ok((blob, kind))
547 }
548
549 pub fn derive_and_save(
553 &self,
554 id: &str,
555 blob: &Path,
556 kind: FileKind,
557 name: &str,
558 send_max_edge: u32,
559 ) -> Result<StoredMeta, process::ProcessError> {
560 match kind {
561 FileKind::Image(format) => self.derive_image(id, blob, format, send_max_edge),
562 _ => self.derive_file(id, blob, kind, name, send_max_edge),
563 }
564 }
565
566 fn derive_image(
569 &self,
570 id: &str,
571 blob: &Path,
572 format: image::ImageFormat,
573 send_max_edge: u32,
574 ) -> Result<StoredMeta, process::ProcessError> {
575 let fp = fingerprint(send_max_edge);
576 let derived: Derived = process::derive(blob, format, send_max_edge)?;
577 let bytes = std::fs::metadata(blob).map(|m| m.len()).map_err(io_error)?;
578 let meta = StoredMeta {
579 v: DERIVE_VERSION,
580 mime: process::mime_of(format).to_string(),
581 ext: blob_ext(blob),
582 bytes,
583 width: derived.width,
584 height: derived.height,
585 send_mime: process::mime_of(derived.send.format).to_string(),
586 send_ext: process::ext_of(derived.send.format).to_string(),
587 send_bytes: derived.send.bytes.len() as u64,
588 send_width: derived.send.width,
589 send_height: derived.send.height,
590 thumb_mime: process::mime_of(derived.thumb.format).to_string(),
591 thumb_ext: process::ext_of(derived.thumb.format).to_string(),
592 first_frame_only: derived.first_frame_only,
593 kind: default_kind(),
594 page_count: None,
595 text_ext: None,
596 text_bytes: 0,
597 text_note: None,
598 macros: false,
599 };
600 let write_all = || -> std::io::Result<()> {
601 let meta_path = self.meta_path(id, &fp);
602 std::fs::create_dir_all(meta_path.parent().unwrap())?;
603 write_atomic(
604 &self.derived_path(id, &fp, &format!("send.{}", meta.send_ext)),
605 &derived.send.bytes,
606 )?;
607 write_atomic(
608 &self.derived_path(id, &fp, &format!("thumb.{}", meta.thumb_ext)),
609 &derived.thumb.bytes,
610 )?;
611 write_atomic(&meta_path, serde_json::to_string(&meta).unwrap().as_bytes())
613 };
614 write_all().map_err(io_error)?;
615 Ok(meta)
616 }
617
618 fn derive_file(
623 &self,
624 id: &str,
625 blob: &Path,
626 kind: FileKind,
627 name: &str,
628 send_max_edge: u32,
629 ) -> Result<StoredMeta, process::ProcessError> {
630 let fp = fingerprint(send_max_edge);
631 let bytes = std::fs::metadata(blob).map(|m| m.len()).map_err(io_error)?;
632 let ext = blob_ext(blob);
633 let mime = mime_for(kind, &ext);
634 let mut meta = StoredMeta {
635 v: DERIVE_VERSION,
636 mime: mime.clone(),
637 ext: ext.clone(),
638 bytes,
639 width: 0,
640 height: 0,
641 send_mime: mime,
642 send_ext: ext,
643 send_bytes: bytes,
644 send_width: 0,
645 send_height: 0,
646 thumb_mime: String::new(),
647 thumb_ext: String::new(),
648 first_frame_only: false,
649 kind: kind.as_str().to_string(),
650 page_count: None,
651 text_ext: None,
652 text_bytes: 0,
653 text_note: None,
654 macros: false,
655 };
656 let mut thumb: Option<String> = None;
657 let mut text: Option<String> = None;
658 match kind {
659 FileKind::Text => thumb = extract::text_preview(blob).ok(),
660 FileKind::Pdf => {
661 let facts = extract::pdf_facts_isolated(blob, name);
662 meta.page_count = facts.pages;
663 if facts.text.is_none() {
664 meta.text_note = Some(
665 "no text could be read from this PDF (it may be scanned or protected)"
666 .into(),
667 );
668 }
669 text = facts.text;
670 }
671 FileKind::Word | FileKind::Excel | FileKind::PowerPoint => {
672 meta.macros = extract::has_macros(blob);
673 match extract::document_text_isolated(blob, kind, name) {
674 Ok(Some(t)) => text = Some(t),
675 Ok(None) => {
676 meta.text_note = Some(match meta.ext.as_str() {
677 "doc" => "no text version; save it as .docx".to_string(),
678 "ppt" => "no text version; save it as .pptx".to_string(),
679 _ => "no text version for this format".to_string(),
680 })
681 }
682 Err(e) => meta.text_note = Some(format!("no text version: {e}")),
683 }
684 }
685 _ => {}
686 }
687 let write_all = |meta: &mut StoredMeta| -> std::io::Result<()> {
688 let meta_path = self.meta_path(id, &fp);
689 std::fs::create_dir_all(meta_path.parent().unwrap())?;
690 if let Some(t) = &thumb {
691 meta.thumb_ext = "txt".into();
692 meta.thumb_mime = "text/plain; charset=utf-8".into();
693 write_atomic(&self.derived_path(id, &fp, "thumb.txt"), t.as_bytes())?;
694 }
695 if let Some(t) = &text {
696 meta.text_ext = Some("txt".into());
697 meta.text_bytes = t.len() as u64;
698 write_atomic(&self.derived_path(id, &fp, "text.txt"), t.as_bytes())?;
699 }
700 write_atomic(
701 &meta_path,
702 serde_json::to_string(&*meta).unwrap().as_bytes(),
703 )
704 };
705 write_all(&mut meta).map_err(io_error)?;
706 Ok(meta)
707 }
708
709 #[cfg(test)]
713 pub fn commit(
714 &self,
715 incoming: &Path,
716 id: &str,
717 send_max_edge: u32,
718 ) -> Result<StoredMeta, process::ProcessError> {
719 self.commit_named(incoming, id, "file.png", send_max_edge)
720 }
721
722 #[cfg(test)]
724 pub fn commit_named(
725 &self,
726 incoming: &Path,
727 id: &str,
728 name: &str,
729 send_max_edge: u32,
730 ) -> Result<StoredMeta, process::ProcessError> {
731 let (blob, kind) = self.place(incoming, id, name)?;
732 let fp = fingerprint(send_max_edge);
733 if let Some(meta) = self.meta(id, &fp) {
734 self.touch(id, &fp);
735 return Ok(meta);
736 }
737 self.derive_and_save(id, &blob, kind, name, send_max_edge)
738 }
739
740 #[cfg(test)]
745 pub fn ensure_derived(&self, id: &str, send_max_edge: u32) -> Option<StoredMeta> {
746 let fp = fingerprint(send_max_edge);
747 if let Some(meta) = self.meta(id, &fp) {
748 return Some(meta);
749 }
750 let (blob, kind) = self.find_blob(id)?;
751 let name = blob.file_name()?.to_string_lossy().into_owned();
752 self.derive_and_save(id, &blob, kind, &name, send_max_edge)
753 .ok()
754 }
755
756 pub fn sweep(&self, retention: Duration) -> usize {
761 let now = SystemTime::now();
762 let unsent = UNSENT_MAX_AGE.min(retention);
763 let mut sent_cache: std::collections::HashMap<String, bool> =
764 std::collections::HashMap::new();
765 let mut removed = 0;
766 for sub in ["blobs", "derived", "incoming", "sessions", "named"] {
767 let dir = self.root.join(sub);
768 let Ok(entries) = walk_files(&dir) else {
769 continue;
770 };
771 for path in entries {
772 let max_age = if sub == "incoming" {
773 INCOMING_MAX_AGE
774 } else if sub == "sessions" || sub == "named" {
775 retention
778 } else {
779 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
780 match name.get(..64).filter(|id| is_valid_id(id)) {
781 Some(id) => {
782 let sent = *sent_cache
783 .entry(id.to_string())
784 .or_insert_with(|| self.is_sent(id));
785 if sent {
786 retention
787 } else {
788 unsent
789 }
790 }
791 None => retention,
792 }
793 };
794 let old = std::fs::metadata(&path)
795 .and_then(|m| m.modified())
796 .ok()
797 .and_then(|t| now.duration_since(t).ok())
798 .map(|age| age > max_age)
799 .unwrap_or(false);
800 if old && std::fs::remove_file(&path).is_ok() {
801 removed += 1;
802 if sub == "named" {
803 if let Some(dir) = path.parent() {
805 let _ = std::fs::remove_dir(dir);
806 }
807 }
808 }
809 }
810 }
811 removed
812 }
813}
814
815fn stored_extension(kind: FileKind, name: &str) -> String {
822 let user = kind::extension_of(name);
823 let pick = |allowed: &[&str], default: &str| -> String {
824 user.as_deref()
825 .filter(|e| allowed.contains(e))
826 .unwrap_or(default)
827 .to_string()
828 };
829 match kind {
830 FileKind::Image(f) => process::ext_of(f).to_string(),
831 FileKind::Svg => "svg".into(),
832 FileKind::Pdf => "pdf".into(),
833 FileKind::Word => pick(&["docx", "docm", "dotx", "odt", "doc", "rtf"], "docx"),
834 FileKind::Excel => pick(&["xlsx", "xlsm", "xlsb", "xls", "ods"], "xlsx"),
835 FileKind::PowerPoint => pick(&["pptx", "pptm", "ppsx", "odp", "ppt"], "pptx"),
836 FileKind::Archive => pick(
837 &["zip", "gz", "tgz", "tar", "7z", "rar", "bz2", "xz", "zst"],
838 "bin",
839 ),
840 FileKind::Audio => pick(
841 &[
842 "mp3", "wav", "flac", "m4a", "aac", "ogg", "oga", "opus", "wma", "aiff", "aif",
843 ],
844 "bin",
845 ),
846 FileKind::Video => pick(
847 &[
848 "mp4", "m4v", "mov", "webm", "mkv", "avi", "wmv", "flv", "mpg", "mpeg",
849 ],
850 "bin",
851 ),
852 FileKind::ImageFile => pick(&["heic", "heif", "avif"], "bin"),
853 FileKind::Text | FileKind::Other => "bin".into(),
854 }
855}
856
857fn blob_ext(blob: &Path) -> String {
859 blob.extension()
860 .map(|e| e.to_string_lossy().into_owned())
861 .unwrap_or_else(|| "bin".into())
862}
863
864fn mime_for(kind: FileKind, ext: &str) -> String {
866 match (kind, ext) {
867 (FileKind::Svg, _) => "image/svg+xml",
868 (FileKind::Pdf, _) => "application/pdf",
869 (FileKind::Text, _) => "text/plain; charset=utf-8",
870 (_, "docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
871 (_, "xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
872 (_, "pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
873 (_, "odt") => "application/vnd.oasis.opendocument.text",
874 (_, "ods") => "application/vnd.oasis.opendocument.spreadsheet",
875 (_, "odp") => "application/vnd.oasis.opendocument.presentation",
876 (_, "doc") => "application/msword",
877 (_, "xls") => "application/vnd.ms-excel",
878 (_, "ppt") => "application/vnd.ms-powerpoint",
879 (_, "rtf") => "application/rtf",
880 (_, "zip") => "application/zip",
881 (FileKind::Audio, e) => return format!("audio/{e}"),
882 (FileKind::Video, e) => return format!("video/{e}"),
883 _ => "application/octet-stream",
884 }
885 .to_string()
886}
887
888pub fn safe_file_name(name: &str) -> String {
892 let cleaned: String = name
893 .chars()
894 .map(|c| {
895 if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
896 '_'
897 } else {
898 c
899 }
900 })
901 .collect();
902 let trimmed = cleaned
903 .trim_start_matches('.')
904 .trim_end_matches(['.', ' '])
905 .trim();
906 let short: String = trimmed.chars().take(120).collect();
907 if short.is_empty() {
908 "file".to_string()
909 } else {
910 short
911 }
912}
913
914fn io_error(e: std::io::Error) -> process::ProcessError {
915 process::ProcessError {
916 code: "io",
917 message: e.to_string(),
918 }
919}
920
921fn walk_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
922 let mut out = Vec::new();
923 for entry in std::fs::read_dir(dir)? {
924 let entry = entry?;
925 let ft = entry.file_type()?;
926 if ft.is_dir() {
927 if let Ok(mut inner) = walk_files(&entry.path()) {
928 out.append(&mut inner);
929 }
930 } else if ft.is_file() {
931 out.push(entry.path());
932 }
933 }
934 Ok(out)
935}
936
937fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
938 let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
939 std::fs::write(&tmp, bytes)?;
940 std::fs::rename(&tmp, path).inspect_err(|_| {
941 let _ = std::fs::remove_file(&tmp);
942 })
943}
944
945fn filetime_now() -> SystemTime {
946 SystemTime::now()
947}
948
949fn set_mtime(path: &Path, t: SystemTime) -> std::io::Result<()> {
950 let f = std::fs::OpenOptions::new().write(true).open(path)?;
951 f.set_modified(t)
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957 use image::{DynamicImage, ImageFormat, Rgb, RgbImage};
958
959 fn store() -> (tempfile::TempDir, Store) {
960 let dir = tempfile::tempdir().unwrap();
961 let s = Store::new(dir.path().join("attachments"));
962 s.ensure_dirs().unwrap();
963 (dir, s)
964 }
965
966 fn png(dir: &Path, name: &str, shade: u8) -> PathBuf {
967 let p = dir.join(name);
968 DynamicImage::ImageRgb8(RgbImage::from_pixel(40, 30, Rgb([shade, 0, 0])))
969 .save_with_format(&p, ImageFormat::Png)
970 .unwrap();
971 p
972 }
973
974 fn ingest(s: &Store, src: &Path) -> Result<(String, StoredMeta), process::ProcessError> {
975 let (tmp, id, _) = s.copy_in(src, |_| true).unwrap();
976 s.commit(&tmp, &id, 2000).map(|m| (id, m))
977 }
978
979 #[test]
980 fn copy_to_workdir_keeps_a_dotfile_name_and_deconflicts() {
981 let (dir, s) = store();
982 let src = png(dir.path(), "a.png", 10);
983 let (id, _) = ingest(&s, &src).unwrap();
984 let work = dir.path().join("work");
985 std::fs::create_dir_all(&work).unwrap();
986 let first = s.copy_original_to(&id, &work, ".env").unwrap();
987 let second = s.copy_original_to(&id, &work, ".env").unwrap();
988 assert_eq!(first.file_name().unwrap(), ".env");
989 assert_eq!(second.file_name().unwrap(), ".env_1");
990 assert_eq!(std::fs::read(&second).unwrap(), std::fs::read(&src).unwrap());
991 assert!(s.copy_original_to(&id, &work.join("missing"), "x.png").is_err());
992 assert!(s.copy_original_to("../escape", &work, "x.png").is_err());
993 }
994
995 #[test]
996 fn id_validation_rejects_anything_path_like() {
997 assert!(is_valid_id(&"a".repeat(64)));
998 assert!(!is_valid_id(&"A".repeat(64)));
999 assert!(!is_valid_id("../../etc/passwd"));
1000 assert!(!is_valid_id(&format!("{}/", "a".repeat(63))));
1001 assert!(!is_valid_id(""));
1002 }
1003
1004 #[test]
1005 fn ingest_stores_original_thumb_send_and_meta() {
1006 let (dir, s) = store();
1007 let src = png(dir.path(), "a.png", 10);
1008 let (id, meta) = ingest(&s, &src).unwrap();
1009 let fp = fingerprint(2000);
1010 assert!(is_valid_id(&id));
1011 assert_eq!(meta.mime, "image/png");
1012 assert_eq!((meta.width, meta.height), (40, 30));
1013 assert_eq!(s.meta(&id, &fp), Some(meta));
1014 for kind in [Kind::Original, Kind::Thumb, Kind::Send] {
1015 assert!(s.file(&id, &fp, kind).unwrap().0.is_file());
1016 }
1017 assert_eq!(
1019 std::fs::read_dir(s.root().join("incoming"))
1020 .unwrap()
1021 .count(),
1022 0
1023 );
1024 }
1025
1026 #[test]
1027 fn same_bytes_are_stored_once() {
1028 let (dir, s) = store();
1029 let a = png(dir.path(), "a.png", 10);
1030 let b = dir.path().join("copy-of-a.png");
1031 std::fs::copy(&a, &b).unwrap();
1032 let (id1, _) = ingest(&s, &a).unwrap();
1033 let (id2, _) = ingest(&s, &b).unwrap();
1034 assert_eq!(id1, id2);
1035 assert_eq!(walk_files(&s.root().join("blobs")).unwrap().len(), 1);
1036 }
1037
1038 #[test]
1039 fn text_file_is_stored_with_a_preview() {
1040 let (dir, s) = store();
1041 let src = dir.path().join("notes.txt");
1042 std::fs::write(&src, "hello").unwrap();
1043 let (tmp, id, _) = s.copy_in(&src, |_| true).unwrap();
1044 let meta = s.commit_named(&tmp, &id, "notes.txt", 2000).unwrap();
1045 assert_eq!(meta.kind, "text");
1046 assert!(!meta.is_image());
1047 let (thumb, _) = s.file(&id, &fingerprint(2000), Kind::Thumb).unwrap();
1048 assert_eq!(std::fs::read_to_string(thumb).unwrap(), "hello");
1049 let (send, _) = s.file(&id, &fingerprint(2000), Kind::Send).unwrap();
1050 assert_eq!(std::fs::read_to_string(send).unwrap(), "hello");
1051 }
1052
1053 #[test]
1054 fn cancelled_copy_leaves_nothing_behind() {
1055 let (dir, s) = store();
1056 let src = png(dir.path(), "a.png", 10);
1057 assert!(s.copy_in(&src, |_| false).is_err());
1058 assert!(walk_files(&s.root()).unwrap().is_empty());
1059 }
1060
1061 #[test]
1062 fn a_failed_decode_is_discarded_but_a_processed_one_is_kept() {
1063 let (dir, s) = store();
1064 let bad = png(dir.path(), "bad.png", 1);
1065 let bytes = std::fs::read(&bad).unwrap();
1066 std::fs::write(&bad, &bytes[..bytes.len() / 3]).unwrap();
1067 let (tmp, id, _) = s.copy_in(&bad, |_| true).unwrap();
1068 let (blob, kind) = s.place(&tmp, &id, "bad.png").unwrap();
1069 assert!(s
1070 .derive_and_save(&id, &blob, kind, "bad.png", 2000)
1071 .is_err());
1072 s.discard_if_unprocessed(&id);
1073 assert!(walk_files(&s.root().join("blobs")).unwrap().is_empty());
1074
1075 let (good, _) = ingest(&s, &png(dir.path(), "good.png", 2)).unwrap();
1076 s.discard_if_unprocessed(&good);
1077 assert!(s.meta(&good, &fingerprint(2000)).is_some());
1078 }
1079
1080 #[test]
1081 fn session_inline_bytes_accumulate_and_persist() {
1082 let (_dir, s) = store();
1083 assert_eq!(s.session_inline_bytes("sess-1"), 0);
1084 s.add_session_inline_bytes("sess-1", 1000);
1085 s.add_session_inline_bytes("sess-1", 500);
1086 assert_eq!(s.session_inline_bytes("sess-1"), 1500);
1087 assert_eq!(s.session_inline_bytes("sess-2"), 0);
1088 let again = Store::new(s.root().to_path_buf());
1090 assert_eq!(again.session_inline_bytes("sess-1"), 1500);
1091 s.add_session_inline_bytes("block:b1:new", 700);
1093 s.adopt_session_inline_bytes("block:b1:new", "sess-1");
1094 assert_eq!(s.session_inline_bytes("sess-1"), 2200);
1095 assert_eq!(
1096 s.session_inline_bytes("block:b1:new"),
1097 0,
1098 "moved, not copied"
1099 );
1100 s.adopt_session_inline_bytes("block:b1:new", "sess-1");
1101 assert_eq!(
1102 s.session_inline_bytes("sess-1"),
1103 2200,
1104 "adopting twice adds nothing"
1105 );
1106 let then = SystemTime::now() - Duration::from_secs(8 * 24 * 3600);
1108 for p in walk_files(&s.root().join("sessions")).unwrap() {
1109 set_mtime(&p, then).unwrap();
1110 }
1111 assert_eq!(s.sweep(Duration::from_secs(30 * 24 * 3600)), 0);
1112 assert_eq!(s.session_inline_bytes("sess-1"), 2200);
1113 }
1114
1115 fn age_all(s: &Store, id: &str, days: u64) {
1116 let then = SystemTime::now() - Duration::from_secs(days * 24 * 3600);
1117 for p in walk_files(s.root()).unwrap() {
1118 if p.to_string_lossy().contains(id) {
1119 set_mtime(&p, then).unwrap();
1120 }
1121 }
1122 }
1123
1124 #[test]
1125 fn never_sent_attachments_go_after_seven_days_sent_ones_after_retention() {
1126 let (dir, s) = store();
1127 let (draft, _) = ingest(&s, &png(dir.path(), "draft.png", 1)).unwrap();
1128 let (sent, _) = ingest(&s, &png(dir.path(), "sent.png", 2)).unwrap();
1129 s.mark_sent(&sent);
1130 age_all(&s, &draft, 8);
1131 age_all(&s, &sent, 8);
1132 let thirty = Duration::from_secs(30 * 24 * 3600);
1133 assert_eq!(s.sweep(thirty), 4, "the draft's four files go");
1134 let fp = fingerprint(2000);
1135 assert!(s.meta(&draft, &fp).is_none());
1136 assert!(
1137 s.meta(&sent, &fp).is_some(),
1138 "a sent attachment keeps the full window"
1139 );
1140 age_all(&s, &sent, 31);
1141 assert_eq!(s.sweep(thirty), 5, "four files plus the sent marker");
1142 assert!(s.meta(&sent, &fp).is_none());
1143 }
1144
1145 #[test]
1146 fn sweep_removes_stale_files_and_keeps_recent_ones() {
1147 let (dir, s) = store();
1148 let (old_id, _) = ingest(&s, &png(dir.path(), "old.png", 1)).unwrap();
1149 let (new_id, _) = ingest(&s, &png(dir.path(), "new.png", 2)).unwrap();
1150 let long_ago = SystemTime::now() - Duration::from_secs(40 * 24 * 3600);
1151 for p in walk_files(&s.root()).unwrap() {
1152 if p.to_string_lossy().contains(&old_id) {
1153 set_mtime(&p, long_ago).unwrap();
1154 }
1155 }
1156 let removed = s.sweep(Duration::from_secs(30 * 24 * 3600));
1157 assert_eq!(removed, 4);
1158 let fp = fingerprint(2000);
1159 assert!(s.meta(&old_id, &fp).is_none());
1160 assert!(s.meta(&new_id, &fp).is_some());
1161 }
1162
1163 #[test]
1164 fn touch_rescues_an_attachment_from_the_sweep() {
1165 let (dir, s) = store();
1166 let (id, _) = ingest(&s, &png(dir.path(), "a.png", 1)).unwrap();
1167 let long_ago = SystemTime::now() - Duration::from_secs(40 * 24 * 3600);
1168 for p in walk_files(&s.root()).unwrap() {
1169 set_mtime(&p, long_ago).unwrap();
1170 }
1171 s.touch(&id, &fingerprint(2000));
1172 assert_eq!(s.sweep(Duration::from_secs(30 * 24 * 3600)), 0);
1173 assert!(s.meta(&id, &fingerprint(2000)).is_some());
1174 }
1175
1176 #[test]
1177 fn changing_the_send_edge_rederives_from_the_stored_original() {
1178 let (dir, s) = store();
1179 let p = dir.path().join("wide.png");
1180 DynamicImage::ImageRgb8(RgbImage::from_pixel(1200, 600, Rgb([5, 5, 5])))
1181 .save_with_format(&p, ImageFormat::Png)
1182 .unwrap();
1183 let (id, first) = ingest(&s, &p).unwrap();
1184 assert_eq!(first.send_width, 1200);
1185 assert!(s.meta(&id, &fingerprint(800)).is_none());
1186 let again = s.ensure_derived(&id, 800).unwrap();
1187 assert_eq!((again.send_width, again.send_height), (800, 400));
1188 assert_eq!(s.meta(&id, &fingerprint(2000)).unwrap().send_width, 1200);
1190 }
1191}