Skip to main content

agentmux_srv/backend/attachments/
store.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The on-disk attachment store: content-addressed originals, derived files,
5//! and the retention sweep. SPEC_AGENT_PANE_IMAGE_ATTACHMENTS_2026_09_26.md §6.3.
6//!
7//! ```text
8//! <root>/blobs/ab/<sha256>.<ext>       original bytes
9//! <root>/derived/ab/<sha256>.<fp>.thumb.<ext>
10//! <root>/derived/ab/<sha256>.<fp>.send.<ext>
11//! <root>/derived/ab/<sha256>.<fp>.json   StoredMeta
12//! <root>/derived/ab/<sha256>.sent        marker: sent to an agent at least once
13//! <root>/incoming/<uuid>.part          in-flight copy / upload
14//! <root>/sessions/<sha256(key)>.json     base64 inlined into one Claude session
15//! <root>/derived/ab/<sha256>.<fp>.text.txt  text version of a document
16//! <root>/named/<sha256>/<file name>     per-delivery copy of the original
17//! ```
18//!
19//! `<fp>` is the transform fingerprint ([`fingerprint`]): derived bytes
20//! depend on the send edge and the pipeline, not only on the original, so
21//! a settings change re-derives instead of reusing stale files.
22
23use 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
35/// Bump when the derive pipeline's output changes, so existing derived
36/// files stop being reused.
37pub const DERIVE_VERSION: u32 = 1;
38
39/// The key derived files are stored under for a given send edge.
40pub fn fingerprint(send_max_edge: u32) -> String {
41    format!("v{DERIVE_VERSION}-e{send_max_edge}")
42}
43
44/// `incoming/` files older than this are leftovers from a crash or a
45/// cancelled copy.
46const INCOMING_MAX_AGE: Duration = Duration::from_secs(60 * 60);
47
48/// Attachments never sent to an agent (drafts, abandoned or lost on restart)
49/// are kept at most this long, instead of the full retention window.
50pub const UNSENT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
51
52/// Hex SHA-256, lowercase. Every public entry point that turns an id into a
53/// path validates it with this first, so an id can never carry a separator.
54pub 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/// What `derived/<id>.json` records about an attachment.
62#[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    // ── Any-file attachments (SPEC_AGENT_PANE_FILE_ATTACHMENTS_2026_09_26.md) ──
80    // Absent in metadata written before files existed: those are images.
81    /// `FileKind::as_str`: image, svg, text, pdf, word, excel, …
82    #[serde(default = "default_kind")]
83    pub kind: String,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub page_count: Option<u32>,
86    /// Extension of the derived text version (`text.<ext>`), if one exists.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub text_ext: Option<String>,
89    #[serde(default)]
90    pub text_bytes: u64,
91    /// Why there is no text version, when one was expected.
92    #[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/// Which stored file to serve.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Kind {
132    Original,
133    /// The tile's preview: the image thumbnail, a text file's first lines,
134    /// or an SVG itself.
135    Thumb,
136    Send,
137    /// The extracted text version of a document.
138    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    /// Create the directory tree, owner-only.
168    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    /// Base64 bytes already inlined into the Claude session `key`. Kept on
183    /// disk so a restart doesn't reset it: Claude Code stores every inline
184    /// image in its own session transcript, and this is what bounds that.
185    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    /// Fold the counter kept under `from` into `to` and remove `from`. A new
194    /// Claude session has no id on its first turn, so that turn is counted
195    /// under a per-block key; once the id is known it belongs to the session.
196    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    /// The stored original for `id` and what kind of file it is.
243    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        // The blob's own name carries the extension it was stored with.
249        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    /// Copy the original of `id` into `dir` as `name`, with the same copy a
255    /// drop makes (`agentmux_common::copy_into_dir`): the name kept valid,
256    /// dotfiles included, and de-conflicted exclusively. For container panes,
257    /// whose agents can't see the store
258    /// (SPEC_AGENT_PANE_FILE_ATTACHMENTS_2026_09_26.md §7).
259    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    /// The stored original and its MIME type, whatever kind it is.
280    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    /// An existing original for `id`, whatever extension it was stored with.
290    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    /// A copy of the original named as the user's file, for delivery
309    /// (agents recognise files by extension, and the real name reads
310    /// better than a hash). A real copy, never a hard link: an agent that
311    /// edits the file it was given must not change the content-addressed
312    /// original. Re-copied on every delivery, so an earlier edit never
313    /// reaches the next message.
314    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            // The old copy may be open in an agent's editor (Windows keeps it
326            // locked); it still holds the right name, so keep serving it.
327            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    /// Stored metadata for fingerprint `fp`, or `None` when the id is
339    /// invalid, not in the store, or not yet derived for `fp`.
340    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        // Every file it names must still exist; the sweep can race a lookup.
347        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    /// The derived files `meta` says exist (not the original).
353    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    /// Path and MIME type of one stored file. Non-images have no separate
368    /// send-copy (the original is sent) and, except text, no thumbnail file
369    /// (an SVG is its own thumbnail).
370    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    /// Mark an attachment as used now, so the retention sweep keeps it.
394    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    /// Record that an attachment went to an agent, which moves it from the
418    /// 7-day unsent window to the full retention window.
419    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    /// Remove the original of an attachment that has no metadata under any
435    /// fingerprint, i.e. one whose processing failed after [`Self::place`]
436    /// stored it. Without this, a file that sniffs as an image but won't
437    /// decode would sit in `blobs/` until the retention sweep.
438    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    /// Copy `src` into `incoming/`, hashing as it goes. `on_progress` is
469    /// called with the bytes copied so far; returning `false` cancels.
470    /// Returns the incoming path and the hex SHA-256.
471    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    /// Move a hashed file from `incoming/` into `blobs/`, or drop it when the
511    /// same bytes are already stored (under whatever extension they were
512    /// stored with). `name` is the user's file name, used only to classify.
513    /// Returns the blob path and what kind of file it is.
514    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    /// Derive whatever `kind` needs and write the metadata. Images go through
550    /// the image pipeline (decode, thumbnail, send-copy); other files get a
551    /// text preview, a text version or PDF facts.
552    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    /// Decode a stored image and write its thumbnail, send-copy and
567    /// metadata under fingerprint `fingerprint(send_max_edge)`.
568    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            // Metadata last: its presence is what makes the attachment visible.
612            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    /// Metadata and derived files for a non-image attachment: a preview for
619    /// text, a text version for Office documents and PDFs, a page count and
620    /// macro flag where they apply. Failures to extract never fail the
621    /// attachment; it is still sent by path, with a note.
622    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    /// [`Self::place`] then derive, reusing derived files when they exist.
710    /// Test convenience: the service calls the two steps separately so it
711    /// can reserve decode memory in between.
712    #[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    /// [`Self::commit`] with the user's file name (tests).
723    #[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    /// Metadata for `id` under the current send edge, re-deriving from the
741    /// stored original when the fingerprint changed since it was processed.
742    /// Test helper: production code goes through `Service::ensure_derived`,
743    /// which applies the CPU and memory limits.
744    #[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    /// Delete attachment files unused for `retention` (or for
757    /// [`UNSENT_MAX_AGE`] when the attachment was never sent), and
758    /// `incoming/` leftovers older than an hour. Returns how many files were
759    /// removed.
760    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                    // Session counters are named by a hash; named links
776                    // exist only for sent attachments.
777                    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                        // Drop the per-id folder once its last link is gone.
804                        if let Some(dir) = path.parent() {
805                            let _ = std::fs::remove_dir(dir);
806                        }
807                    }
808                }
809            }
810        }
811        removed
812    }
813}
814
815/// The extension a new original is stored under. It follows the content
816/// where the content decides it (image formats, PDF, SVG, RTF), and
817/// otherwise the user's extension if it fits the kind (docx vs odt vs doc,
818/// mp3 vs wav, …). Text and unknown files are `bin`: the same bytes can
819/// arrive as notes.txt and script.py, and agents get a correctly named
820/// link per delivery instead (`named_link`).
821fn 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
857/// The extension part of a stored blob's file name.
858fn 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
864/// MIME type of a non-image original.
865fn 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
888/// A user's file name made safe as a single path component: separators,
889/// control characters and Windows-reserved characters replaced, leading
890/// dots and trailing dots/spaces trimmed, length bounded.
891pub 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        // incoming/ is empty again.
1018        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        // A fresh Store over the same root sees it (a restart).
1089        let again = Store::new(s.root().to_path_buf());
1090        assert_eq!(again.session_inline_bytes("sess-1"), 1500);
1091        // A first turn counted before the session had an id carries over.
1092        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        // Kept for the full retention window, not the 7-day unsent one.
1107        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        // The original fingerprint is untouched.
1189        assert_eq!(s.meta(&id, &fingerprint(2000)).unwrap().send_width, 1200);
1190    }
1191}