Skip to main content

pigeon/core/
data.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6/// Behavior shared by every content-parsing pipeline this CLI runs.
7/// Implemented by `commands::job::email_sync::transform::EmailTransform`
8/// (ADR-0023) -- the trait definition lives here since it's a generic
9/// shape; the one real implementation lives with its concrete kind.
10pub(crate) trait Transform {
11    type Input;
12    type Output;
13    fn transform(&self, input: Self::Input) -> Result<Option<Self::Output>, String>;
14}
15
16/// Behavior shared by every content-hash-based deduplication strategy this
17/// CLI runs. Implemented by `commands::job::email_sync::dedup::EmailDedup`
18/// (ADR-0023), which wraps a `ContentIndex` below -- `ContentIndex` itself
19/// stays a plain, trait-free generic utility (ADR-0020), since nothing
20/// about *how a hash maps to a path* varies per kind; only what to *do*
21/// with a hit/miss (merge a duplicate message vs. reuse a duplicate
22/// attachment) varies, which is exactly what these two methods leave to
23/// the implementor.
24pub(crate) trait Dedup {
25    fn check(&self, hash: &str) -> Option<&str>;
26    fn commit(&mut self, hash: &str, relative_path: &str) -> Result<(), String>;
27}
28
29/// One hash's committed entry: the final relative path, plus (ADR-0099) the
30/// original source key of whichever file was kept under that path --
31/// empty when the entry predates ADR-0099 or was committed by a caller that
32/// only ever uses the plain `Dedup::commit` (original key is never
33/// retroactively backfilled, since a hash is only ever committed once).
34#[derive(Clone)]
35struct ContentIndexEntry {
36    relative_path: String,
37    original_key: String,
38}
39
40/// One dotfile's worth of `<hex-md5>\t<relative-path>\t<original-key>`
41/// entries -- a durable, append-only content-hash index backing
42/// byte-identical-content deduplication (originally ADR-0012, for `pigeon
43/// email`'s message/attachment dedup; genericized by ADR-0020 for reuse by
44/// other transforms; relocated by ADR-0023; gained the tab-separated
45/// `original-key` field in ADR-0099). The relative path stored is
46/// caller-defined -- typically relative to wherever that caller's own
47/// transformed output lives.
48pub(crate) struct ContentIndex {
49    staging_dir: PathBuf,
50    file_name: &'static str,
51    entries: HashMap<String, ContentIndexEntry>,
52}
53
54impl ContentIndex {
55    /// Loads `staging_dir/file_name`. A missing file (first run) is an empty
56    /// index. A line containing a tab is read as the current
57    /// `<hash>\t<relative_path>\t<original_key>` format (the trailing
58    /// `original_key` field is optional, for forward compatibility); a line
59    /// with no tab falls back to the pre-ADR-0099
60    /// `<hash> <relative_path>` format, with `original_key` defaulting to
61    /// `""`. Lines matching neither shape are skipped leniently.
62    pub(crate) fn load(
63        staging_dir: &Path,
64        file_name: &'static str,
65    ) -> Result<ContentIndex, String> {
66        let path = staging_dir.join(file_name);
67        let contents = match fs::read_to_string(&path) {
68            Ok(contents) => contents,
69            Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
70            Err(err) => return Err(format!("failed to read {}: {err}", path.display())),
71        };
72        let entries = contents
73            .lines()
74            .filter_map(|line| {
75                if let Some((hash, rest)) = line.split_once('\t') {
76                    let (relative_path, original_key) = rest.split_once('\t').unwrap_or((rest, ""));
77                    Some((
78                        hash.to_string(),
79                        ContentIndexEntry {
80                            relative_path: relative_path.to_string(),
81                            original_key: original_key.to_string(),
82                        },
83                    ))
84                } else {
85                    line.split_once(' ').map(|(hash, relative_path)| {
86                        (
87                            hash.to_string(),
88                            ContentIndexEntry {
89                                relative_path: relative_path.to_string(),
90                                original_key: String::new(),
91                            },
92                        )
93                    })
94                }
95            })
96            .collect();
97        Ok(ContentIndex {
98            staging_dir: staging_dir.to_path_buf(),
99            file_name,
100            entries,
101        })
102    }
103
104    /// Looks up `hash` against every durably committed entry (entries loaded
105    /// at start, plus entries `commit`-ted so far this run).
106    pub(crate) fn check(&self, hash: &str) -> Option<&str> {
107        self.entries
108            .get(hash)
109            .map(|entry| entry.relative_path.as_str())
110    }
111
112    /// Like `check`, but also returns the original source key of the kept
113    /// file (ADR-0099) -- `""` when the entry predates ADR-0099 or was
114    /// committed via the plain `commit`/`Dedup::commit`.
115    pub(crate) fn check_with_original_key(&self, hash: &str) -> Option<(&str, &str)> {
116        self.entries
117            .get(hash)
118            .map(|entry| (entry.relative_path.as_str(), entry.original_key.as_str()))
119    }
120
121    /// Appends one `<hash> <relative_path>` line to `staging_dir/file_name`
122    /// (the same `staging_dir` given to `load`) and makes it visible to
123    /// every subsequent `check()` this run. Thin wrapper over
124    /// `commit_with_key` with an empty original key, for every `Dedup`
125    /// implementor that doesn't track one.
126    pub(crate) fn commit(&mut self, hash: &str, relative_path: &str) -> Result<(), String> {
127        self.commit_with_key(hash, relative_path, "")
128    }
129
130    /// Like `commit`, but also durably records `original_key` (ADR-0099) --
131    /// the source key of the file being kept under `relative_path` -- as a
132    /// third tab-separated field, so a later duplicate's report row can
133    /// name where the copy it was merged into actually came from.
134    pub(crate) fn commit_with_key(
135        &mut self,
136        hash: &str,
137        relative_path: &str,
138        original_key: &str,
139    ) -> Result<(), String> {
140        let path = self.staging_dir.join(self.file_name);
141        let mut file = fs::OpenOptions::new()
142            .create(true)
143            .append(true)
144            .open(&path)
145            .map_err(|err| format!("failed to open {}: {err}", path.display()))?;
146        writeln!(file, "{hash}\t{relative_path}\t{original_key}")
147            .map_err(|err| format!("failed to write {}: {err}", path.display()))?;
148        self.entries.insert(
149            hash.to_string(),
150            ContentIndexEntry {
151                relative_path: relative_path.to_string(),
152                original_key: original_key.to_string(),
153            },
154        );
155        Ok(())
156    }
157}
158
159/// Amends a canonical file's frontmatter for a newly discovered duplicate
160/// occurrence: ensures `tag` is present in `tags:`, and inserts (or, keyed
161/// by `tag`, updates) an `also-in:` entry recording `occurrence`. A pure
162/// textual edit of the existing `---`-delimited block -- no YAML crate.
163/// Originally ADR-0012 (`pigeon email`'s mailbox+uid-keyed message
164/// merging); genericized by ADR-0020 for reuse by other transforms, where
165/// `tag`/`occurrence` can mean whatever that caller's own duplicate-
166/// tracking scheme needs them to.
167///
168/// Returns `Ok(true)` if the file was rewritten (a genuinely new tag/
169/// occurrence pair), `Ok(false)` if this exact pair was already recorded
170/// (an idempotent resume/crash-recovery replay -- the file is left
171/// byte-for-byte untouched). `Err` if `canonical_path` is missing or its
172/// frontmatter isn't well-formed; callers treat that as a lenient skip.
173pub(crate) fn amend_frontmatter_for_duplicate(
174    canonical_path: &Path,
175    tag: &str,
176    occurrence: u32,
177) -> Result<bool, String> {
178    let contents = fs::read_to_string(canonical_path)
179        .map_err(|err| format!("failed to read {}: {err}", canonical_path.display()))?;
180    let had_trailing_newline = contents.ends_with('\n');
181    let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
182
183    if lines.first().map(String::as_str) != Some("---") {
184        return Err(format!(
185            "{} does not start with a frontmatter delimiter",
186            canonical_path.display()
187        ));
188    }
189    let Some(mut close_idx) = lines
190        .iter()
191        .skip(1)
192        .position(|line| line == "---")
193        .map(|i| i + 1)
194    else {
195        return Err(format!(
196            "{} has no closing frontmatter delimiter",
197            canonical_path.display()
198        ));
199    };
200
201    let Some(tags_idx) = lines[1..close_idx]
202        .iter()
203        .position(|line| line == "tags:")
204        .map(|i| i + 1)
205    else {
206        return Err(format!("{} has no tags: field", canonical_path.display()));
207    };
208    let mut tags_block_end = lines[tags_idx + 1..close_idx]
209        .iter()
210        .take_while(|line| line.starts_with("  - "))
211        .count()
212        + tags_idx
213        + 1;
214
215    let mut changed = false;
216    let tag_line = format!("  - {tag}");
217    let has_tag = lines[tags_idx + 1..tags_block_end]
218        .iter()
219        .any(|line| line == &tag_line);
220    if !has_tag {
221        lines.insert(tags_block_end, tag_line);
222        tags_block_end += 1;
223        close_idx += 1;
224        changed = true;
225    }
226
227    let also_in_header = "also-in:";
228    let existing_also_in_idx = lines[tags_block_end..close_idx]
229        .iter()
230        .position(|line| line == also_in_header)
231        .map(|i| i + tags_block_end);
232
233    let new_entry_prefix = format!("  - {tag}#");
234    let new_entry = format!("  - {tag}#{occurrence}");
235
236    match existing_also_in_idx {
237        Some(also_in_idx) => {
238            let also_in_block_end = lines[also_in_idx + 1..close_idx]
239                .iter()
240                .take_while(|line| line.starts_with("  - "))
241                .count()
242                + also_in_idx
243                + 1;
244            let existing_entry_idx = lines[also_in_idx + 1..also_in_block_end]
245                .iter()
246                .position(|line| line.starts_with(&new_entry_prefix))
247                .map(|i| i + also_in_idx + 1);
248            match existing_entry_idx {
249                Some(idx) => {
250                    if lines[idx] != new_entry {
251                        lines[idx] = new_entry;
252                        changed = true;
253                    }
254                }
255                None => {
256                    lines.insert(also_in_block_end, new_entry);
257                    changed = true;
258                }
259            }
260        }
261        None => {
262            lines.insert(tags_block_end, also_in_header.to_string());
263            lines.insert(tags_block_end + 1, new_entry);
264            changed = true;
265        }
266    }
267
268    if !changed {
269        return Ok(false);
270    }
271
272    let mut rewritten = lines.join("\n");
273    if had_trailing_newline {
274        rewritten.push('\n');
275    }
276    fs::write(canonical_path, rewritten)
277        .map_err(|err| format!("failed to write {}: {err}", canonical_path.display()))?;
278    Ok(true)
279}
280
281/// Rewrites a single `attachments:` frontmatter entry from `old_relpath` to
282/// `new_relpath` -- needed when a message's own `.md` was already written
283/// referencing an attachment's staged location, but that attachment turned
284/// out to be a cross-message content duplicate (or hit a filename
285/// collision) and was placed somewhere else by the post-transform dedup
286/// pass (ADR-0021 §7/§10). Same pure-textual-edit technique as
287/// `amend_frontmatter_for_duplicate`, targeting an `  - <path>` line instead
288/// of the `tags:`/`also-in:` block.
289///
290/// Returns `Ok(true)` if a line matching `old_relpath` was found and
291/// rewritten, `Ok(false)` if it wasn't (already rewritten -- an idempotent
292/// resume/crash-recovery replay leaves the file untouched). `Err` if
293/// `md_path` can't be read.
294pub(crate) fn rewrite_attachment_reference(
295    md_path: &Path,
296    old_relpath: &str,
297    new_relpath: &str,
298) -> Result<bool, String> {
299    let contents = fs::read_to_string(md_path)
300        .map_err(|err| format!("failed to read {}: {err}", md_path.display()))?;
301    let had_trailing_newline = contents.ends_with('\n');
302    let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
303
304    let old_line = format!("  - {old_relpath}");
305    let Some(idx) = lines.iter().position(|line| line == &old_line) else {
306        return Ok(false);
307    };
308    lines[idx] = format!("  - {new_relpath}");
309
310    let mut rewritten = lines.join("\n");
311    if had_trailing_newline {
312        rewritten.push('\n');
313    }
314    fs::write(md_path, rewritten)
315        .map_err(|err| format!("failed to write {}: {err}", md_path.display()))?;
316    Ok(true)
317}
318
319/// Escapes `s` as a double-quoted YAML scalar. Beyond `\`/`"`, also escapes
320/// every C0 control character (including a raw newline or carriage return)
321/// via YAML's own double-quoted escape syntax, rather than stripping them --
322/// an unescaped control character wouldn't violate YAML's own scalar rules,
323/// but this codebase's frontmatter is re-parsed as flat `\n`-split lines by
324/// `amend_frontmatter_for_duplicate`, so a smuggled newline could inject a
325/// fake `tags:`/`---` line and desync its rewriter.
326pub(crate) fn yaml_quote(s: &str) -> String {
327    let mut escaped = String::with_capacity(s.len());
328    for c in s.chars() {
329        match c {
330            '\\' => escaped.push_str("\\\\"),
331            '"' => escaped.push_str("\\\""),
332            '\n' => escaped.push_str("\\n"),
333            '\r' => escaped.push_str("\\r"),
334            '\t' => escaped.push_str("\\t"),
335            c if (c as u32) < 0x20 || c as u32 == 0x7f => {
336                escaped.push_str(&format!("\\x{:02x}", c as u32));
337            }
338            c => escaped.push(c),
339        }
340    }
341    format!("\"{escaped}\"")
342}
343
344/// If `desired` doesn't exist yet, returns it as-is; otherwise appends
345/// `-2`, `-3`, ... before the extension until a free path is found.
346pub(crate) fn unique_path(desired: &Path) -> PathBuf {
347    if !desired.exists() {
348        return desired.to_path_buf();
349    }
350    let stem = desired
351        .file_stem()
352        .and_then(|stem| stem.to_str())
353        .unwrap_or("file");
354    let ext = desired.extension().and_then(|ext| ext.to_str());
355    let parent = desired.parent().unwrap_or_else(|| Path::new(""));
356
357    let mut n = 2;
358    loop {
359        let candidate_name = match ext {
360            Some(ext) => format!("{stem}-{n}.{ext}"),
361            None => format!("{stem}-{n}"),
362        };
363        let candidate = parent.join(candidate_name);
364        if !candidate.exists() {
365            return candidate;
366        }
367        n += 1;
368    }
369}
370
371/// Caps a sanitized filename's length so it can never blow past a
372/// filesystem's per-component name limit (255 bytes on APFS/most Unix
373/// filesystems) once stacked onto whatever prefix a caller appends it to.
374const MAX_FILENAME_LENGTH: usize = 100;
375
376/// Reduces an untrusted, externally-sourced name to a safe filename: keeps
377/// only the final path component (so an embedded `/` can't make
378/// `Path::join` create an implicit, never-created subdirectory, per
379/// ADR-0013), caps its length (an externally-sourced name can be
380/// arbitrarily long), and falls back to `"file"` if nothing usable remains.
381/// Extension is preserved where reasonable, unlike
382/// `email::identity::sanitize_segment`, which would corrupt it. Originally
383/// ADR-0013's MIME-attachment-name fix; genericized by ADR-0020.
384pub(crate) fn sanitize_filename(name: &str) -> String {
385    let base = match Path::new(name).file_name().and_then(|f| f.to_str()) {
386        Some(base) if !base.is_empty() => base,
387        _ => return "file".to_string(),
388    };
389    truncate_preserving_extension(base, MAX_FILENAME_LENGTH)
390}
391
392/// Truncates `name` to at most `max_len` bytes. If it has a short-enough
393/// extension (text after the last `.`), the stem is truncated and the
394/// extension kept intact rather than risking cutting it off mid-string.
395/// Always cuts on a UTF-8 char boundary (a sanitized name, unlike
396/// `identity::sanitize_segment`'s output, isn't restricted to ASCII).
397fn truncate_preserving_extension(name: &str, max_len: usize) -> String {
398    if name.len() <= max_len {
399        return name.to_string();
400    }
401    if let Some((stem, ext)) = name.rsplit_once('.')
402        && !ext.is_empty()
403        && ext.len() + 1 < max_len
404    {
405        return format!(
406            "{}.{ext}",
407            truncate_at_char_boundary(stem, max_len - ext.len() - 1)
408        );
409    }
410    truncate_at_char_boundary(name, max_len)
411}
412
413fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> String {
414    let mut end = max_bytes.min(s.len());
415    while end > 0 && !s.is_char_boundary(end) {
416        end -= 1;
417    }
418    s[..end].to_string()
419}
420
421/// Recursively collects every file under `dir`, sorted for deterministic
422/// order. A missing `dir` is treated as an empty result, not an error --
423/// callers that need "does this path exist at all" semantics (e.g. a single
424/// file vs. directory vs. missing distinction) check that themselves before
425/// calling this.
426pub(crate) fn collect_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
427    let mut files = Vec::new();
428    if dir.exists() {
429        visit_dir(dir, &mut files)?;
430    }
431    files.sort();
432    Ok(files)
433}
434
435fn visit_dir(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> {
436    let entries =
437        fs::read_dir(dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
438    for entry in entries {
439        let entry = entry.map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
440        let path = entry.path();
441        if path.is_dir() {
442            visit_dir(&path, files)?;
443        } else {
444            files.push(path);
445        }
446    }
447    Ok(())
448}
449
450/// The portion of `key` after its last `!` zip-member separator -- zip
451/// expansion (`deduplicate`/`pull_transform` worker.rs) builds a synthetic,
452/// human-readable key for an extracted member by joining the zip's own key
453/// onto the member's in-archive path with `!` (e.g.
454/// `"outer.zip!photos/img.jpg"`). `extension_of` and any filename-deriving
455/// `Path` split must look at this, not the raw key, or a zip member's
456/// synthetic prefix leaks into the derived extension/name (ADR-0099) --
457/// e.g. an extensionless member `outer.zip!README` would otherwise be seen
458/// as having extension `"zip!README"`, the only dot in the whole key.
459/// Returns `key` unchanged when there's no `!`.
460pub(crate) fn after_zip_separator(key: &str) -> &str {
461    key.rsplit('!').next().unwrap_or(key)
462}
463
464/// The lowercased extension of `key`'s final path segment, or `"(none)"`
465/// when there isn't one. Hoisted here (ADR-0096 §0) once `pull_transform`,
466/// `deduplicate`, and `reduce` all needed the identical logic -- this
467/// codebase's usual "duplicate until the third consumer" precedent.
468pub(crate) fn extension_of(key: &str) -> String {
469    Path::new(after_zip_separator(key))
470        .extension()
471        .and_then(|ext| ext.to_str())
472        .map(|ext| ext.to_ascii_lowercase())
473        .unwrap_or_else(|| "(none)".to_string())
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn extension_of_lowercases_and_strips_the_dot() {
482        assert_eq!(extension_of("Photos/IMG_0001.JPG"), "jpg");
483    }
484
485    #[test]
486    fn extension_of_handles_no_extension() {
487        assert_eq!(extension_of("Photos/README"), "(none)");
488    }
489
490    #[test]
491    fn extension_of_handles_dotfiles_without_extension() {
492        assert_eq!(extension_of(".DS_Store"), "(none)");
493    }
494
495    #[test]
496    fn extension_of_strips_a_zip_member_prefix_before_deriving_the_extension() {
497        assert_eq!(extension_of("outer.zip!README"), "(none)");
498        assert_eq!(extension_of("outer.zip!photos/img.JPG"), "jpg");
499    }
500
501    #[test]
502    fn after_zip_separator_returns_the_segment_after_the_last_bang() {
503        assert_eq!(after_zip_separator("outer.zip!README"), "README");
504        assert_eq!(
505            after_zip_separator("a.zip!nested.zip!photos/img.jpg"),
506            "photos/img.jpg"
507        );
508        assert_eq!(after_zip_separator("plain/key.jpg"), "plain/key.jpg");
509    }
510
511    #[test]
512    fn content_index_commit_with_key_round_trips_the_original_key() {
513        let dir = tempfile::tempdir().unwrap();
514        let mut index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
515
516        index
517            .commit_with_key("hash-a", "pdf/report.pdf", "docs/report.pdf")
518            .unwrap();
519
520        assert_eq!(index.check("hash-a"), Some("pdf/report.pdf"));
521        assert_eq!(
522            index.check_with_original_key("hash-a"),
523            Some(("pdf/report.pdf", "docs/report.pdf"))
524        );
525
526        // Reload from disk to confirm the 3-field line persisted correctly.
527        let reloaded = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
528        assert_eq!(
529            reloaded.check_with_original_key("hash-a"),
530            Some(("pdf/report.pdf", "docs/report.pdf"))
531        );
532    }
533
534    #[test]
535    fn content_index_commit_writes_an_empty_original_key() {
536        let dir = tempfile::tempdir().unwrap();
537        let mut index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
538
539        index.commit("hash-a", "pdf/report.pdf").unwrap();
540
541        assert_eq!(
542            index.check_with_original_key("hash-a"),
543            Some(("pdf/report.pdf", ""))
544        );
545    }
546
547    #[test]
548    fn content_index_load_is_backward_compatible_with_the_old_space_separated_format() {
549        let dir = tempfile::tempdir().unwrap();
550        std::fs::write(
551            dir.path().join(".content-hashes"),
552            "hash-a pdf/report.pdf\n",
553        )
554        .unwrap();
555
556        let index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
557
558        assert_eq!(index.check("hash-a"), Some("pdf/report.pdf"));
559        assert_eq!(
560            index.check_with_original_key("hash-a"),
561            Some(("pdf/report.pdf", ""))
562        );
563    }
564
565    const MESSAGE_HASHES: &str = ".message-hashes";
566    const ATTACHMENT_HASHES: &str = ".attachment-hashes";
567
568    const FIXTURE: &str = "---\n\
569        from: \"Jane Doe <jane.doe@example.com>\"\n\
570        to: \"first.last@example.com\"\n\
571        subject: \"Hello, World!\"\n\
572        date: 2024-01-26T09:15:00+00:00\n\
573        tags:\n\
574        \x20\x20- mailbox/inbox\n\
575        \x20\x20- identity/first-last\n\
576        attachments:\n\
577        \x20\x20- attachments/2024-01-26-hello-world-bingo.pdf\n\
578        uid: 482\n\
579        ---\n\
580        \n\
581        Hello there!\n";
582
583    #[test]
584    fn load_missing_file_is_empty() {
585        let dir = tempfile::tempdir().unwrap();
586        let index = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
587        assert!(index.check("abc").is_none());
588    }
589
590    #[test]
591    fn commit_then_check_round_trips() {
592        let dir = tempfile::tempdir().unwrap();
593        let mut index = ContentIndex::load(dir.path(), ATTACHMENT_HASHES).unwrap();
594        index.commit("hash1", "identity/attachments/a.pdf").unwrap();
595        assert_eq!(index.check("hash1"), Some("identity/attachments/a.pdf"));
596        assert!(index.check("hash2").is_none());
597    }
598
599    #[test]
600    fn load_picks_up_entries_committed_by_a_prior_load() {
601        let dir = tempfile::tempdir().unwrap();
602        let mut first = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
603        first.commit("hash1", "identity/a.md").unwrap();
604
605        let second = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
606        assert_eq!(second.check("hash1"), Some("identity/a.md"));
607    }
608
609    #[test]
610    fn load_skips_malformed_lines() {
611        let dir = tempfile::tempdir().unwrap();
612        fs::write(dir.path().join(MESSAGE_HASHES), "no-space-here\n").unwrap();
613        let index = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
614        assert!(index.check("no-space-here").is_none());
615    }
616
617    #[test]
618    fn amend_adds_new_tag_and_also_in_entry() {
619        let dir = tempfile::tempdir().unwrap();
620        let path = dir.path().join("canonical.md");
621        fs::write(&path, FIXTURE).unwrap();
622
623        let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
624        assert!(changed);
625
626        let contents = fs::read_to_string(&path).unwrap();
627        assert!(contents.contains("  - mailbox/archive\n"));
628        assert!(contents.contains("also-in:\n  - mailbox/archive#45\n"));
629        assert!(contents.contains("from: \"Jane Doe <jane.doe@example.com>\""));
630        assert!(
631            contents.contains("attachments:\n  - attachments/2024-01-26-hello-world-bingo.pdf")
632        );
633        assert!(contents.contains("uid: 482"));
634        assert!(contents.ends_with("Hello there!\n"));
635    }
636
637    #[test]
638    fn amend_is_idempotent_for_identical_tag_and_occurrence() {
639        let dir = tempfile::tempdir().unwrap();
640        let path = dir.path().join("canonical.md");
641        fs::write(&path, FIXTURE).unwrap();
642
643        amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
644        let after_first = fs::read_to_string(&path).unwrap();
645
646        let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
647        assert!(!changed);
648        assert_eq!(fs::read_to_string(&path).unwrap(), after_first);
649    }
650
651    #[test]
652    fn amend_updates_occurrence_in_place() {
653        let dir = tempfile::tempdir().unwrap();
654        let path = dir.path().join("canonical.md");
655        fs::write(&path, FIXTURE).unwrap();
656
657        amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
658        let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 99).unwrap();
659        assert!(changed);
660
661        let contents = fs::read_to_string(&path).unwrap();
662        assert!(contents.contains("  - mailbox/archive#99"));
663        assert!(!contents.contains("  - mailbox/archive#45"));
664        assert_eq!(contents.matches("mailbox/archive#").count(), 1);
665    }
666
667    #[test]
668    fn amend_errors_on_missing_canonical_file() {
669        let dir = tempfile::tempdir().unwrap();
670        let path = dir.path().join("does-not-exist.md");
671        assert!(amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).is_err());
672    }
673
674    #[test]
675    fn rewrite_attachment_reference_replaces_matching_line() {
676        let dir = tempfile::tempdir().unwrap();
677        let path = dir.path().join("canonical.md");
678        fs::write(&path, FIXTURE).unwrap();
679
680        let changed = rewrite_attachment_reference(
681            &path,
682            "attachments/2024-01-26-hello-world-bingo.pdf",
683            "attachments/2024-01-26-hello-world-bingo-2.pdf",
684        )
685        .unwrap();
686        assert!(changed);
687
688        let contents = fs::read_to_string(&path).unwrap();
689        assert!(
690            contents.contains("attachments:\n  - attachments/2024-01-26-hello-world-bingo-2.pdf")
691        );
692        assert!(!contents.contains("attachments/2024-01-26-hello-world-bingo.pdf\n"));
693    }
694
695    #[test]
696    fn rewrite_attachment_reference_is_idempotent_when_old_path_already_gone() {
697        let dir = tempfile::tempdir().unwrap();
698        let path = dir.path().join("canonical.md");
699        fs::write(&path, FIXTURE).unwrap();
700
701        rewrite_attachment_reference(
702            &path,
703            "attachments/2024-01-26-hello-world-bingo.pdf",
704            "attachments/canonical.pdf",
705        )
706        .unwrap();
707        let after_first = fs::read_to_string(&path).unwrap();
708
709        let changed = rewrite_attachment_reference(
710            &path,
711            "attachments/2024-01-26-hello-world-bingo.pdf",
712            "attachments/canonical.pdf",
713        )
714        .unwrap();
715        assert!(!changed);
716        assert_eq!(fs::read_to_string(&path).unwrap(), after_first);
717    }
718
719    #[test]
720    fn rewrite_attachment_reference_errors_on_missing_file() {
721        let dir = tempfile::tempdir().unwrap();
722        let path = dir.path().join("does-not-exist.md");
723        assert!(
724            rewrite_attachment_reference(&path, "attachments/a.pdf", "attachments/b.pdf").is_err()
725        );
726    }
727
728    #[test]
729    fn commit_survives_concurrent_access_from_multiple_threads() {
730        use std::sync::{Arc, Mutex};
731        use std::thread;
732
733        let dir = tempfile::tempdir().unwrap();
734        let index = Arc::new(Mutex::new(
735            ContentIndex::load(dir.path(), ATTACHMENT_HASHES).unwrap(),
736        ));
737
738        let handles: Vec<_> = (0..8)
739            .map(|n| {
740                let index = Arc::clone(&index);
741                thread::spawn(move || {
742                    index
743                        .lock()
744                        .unwrap()
745                        .commit(&format!("hash{n}"), &format!("identity/a{n}.pdf"))
746                        .unwrap();
747                })
748            })
749            .collect();
750        for handle in handles {
751            handle.join().unwrap();
752        }
753
754        let guard = index.lock().unwrap();
755        for n in 0..8 {
756            assert_eq!(
757                guard.check(&format!("hash{n}")),
758                Some(format!("identity/a{n}.pdf")).as_deref()
759            );
760        }
761
762        let contents = fs::read_to_string(dir.path().join(ATTACHMENT_HASHES)).unwrap();
763        assert_eq!(contents.lines().count(), 8);
764    }
765
766    #[test]
767    fn yaml_quote_escapes_quotes_and_backslashes() {
768        assert_eq!(yaml_quote("Hello: World"), "\"Hello: World\"");
769        assert_eq!(yaml_quote(r#"She said "hi""#), r#""She said \"hi\"""#);
770    }
771
772    #[test]
773    fn yaml_quote_escapes_embedded_newline_and_carriage_return() {
774        assert_eq!(yaml_quote("line1\nline2"), "\"line1\\nline2\"");
775        assert_eq!(yaml_quote("a\r\nb"), "\"a\\r\\nb\"");
776    }
777
778    #[test]
779    fn yaml_quote_escapes_other_control_characters() {
780        assert_eq!(yaml_quote("a\tb"), "\"a\\tb\"");
781        assert_eq!(yaml_quote("a\x01b"), "\"a\\x01b\"");
782    }
783
784    #[test]
785    fn yaml_quote_leaves_non_ascii_names_unchanged() {
786        assert_eq!(yaml_quote("José García"), "\"José García\"");
787    }
788
789    #[test]
790    fn yaml_quote_prevents_frontmatter_line_injection() {
791        // The concrete attack this fix closes: a sender display name
792        // carrying a raw newline followed by a fake `tags:` line must come
793        // back as a single escaped scalar, not a value that reintroduces a
794        // literal newline once written to the frontmatter.
795        let malicious = "Evil\ntags:\n  - admin";
796        assert!(!yaml_quote(malicious).contains('\n'));
797    }
798
799    #[test]
800    fn unique_path_returns_original_when_free() {
801        let dir = tempfile::tempdir().unwrap();
802        let desired = dir.path().join("2024-01-26-hello.md");
803        assert_eq!(unique_path(&desired), desired);
804    }
805
806    #[test]
807    fn unique_path_suffixes_on_collision() {
808        let dir = tempfile::tempdir().unwrap();
809        let desired = dir.path().join("2024-01-26-hello.md");
810        fs::write(&desired, b"").unwrap();
811
812        let resolved = unique_path(&desired);
813        assert_eq!(resolved, dir.path().join("2024-01-26-hello-2.md"));
814    }
815
816    #[test]
817    fn sanitize_filename_strips_leading_slash() {
818        assert_eq!(sanitize_filename("/img0.png"), "img0.png");
819    }
820
821    #[test]
822    fn sanitize_filename_strips_nested_directories() {
823        assert_eq!(sanitize_filename("a/b/c.pdf"), "c.pdf");
824    }
825
826    #[test]
827    fn sanitize_filename_preserves_normal_name() {
828        assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
829    }
830
831    #[test]
832    fn sanitize_filename_falls_back_for_dot_dot() {
833        assert_eq!(sanitize_filename(".."), "file");
834    }
835
836    #[test]
837    fn sanitize_filename_falls_back_for_bare_slash() {
838        assert_eq!(sanitize_filename("/"), "file");
839    }
840
841    #[test]
842    fn sanitize_filename_truncates_long_name_preserving_extension() {
843        let long_name = format!("{}.pdf", "a".repeat(300));
844        let sanitized = sanitize_filename(&long_name);
845        assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
846        assert!(sanitized.ends_with(".pdf"));
847    }
848
849    #[test]
850    fn sanitize_filename_truncates_long_name_with_no_extension() {
851        let long_name = "a".repeat(300);
852        let sanitized = sanitize_filename(&long_name);
853        assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
854    }
855
856    #[test]
857    fn sanitize_filename_truncates_multibyte_name_at_char_boundary() {
858        // Each "é" is 2 bytes in UTF-8; a naive byte-count truncation could
859        // split one in half and panic.
860        let long_name = format!("{}.png", "é".repeat(200));
861        let sanitized = sanitize_filename(&long_name);
862        assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
863        assert!(sanitized.ends_with(".png"));
864        assert!(sanitized.is_char_boundary(sanitized.len()));
865    }
866
867    #[test]
868    fn collect_files_walks_nested_directories() {
869        let dir = tempfile::tempdir().unwrap();
870        fs::create_dir_all(dir.path().join("attachments")).unwrap();
871        fs::write(dir.path().join("hello.md"), b"hi").unwrap();
872        fs::write(dir.path().join("attachments/a.pdf"), b"pdf").unwrap();
873
874        let mut files = collect_files(dir.path()).unwrap();
875        files.sort();
876
877        let mut expected = vec![
878            dir.path().join("attachments/a.pdf"),
879            dir.path().join("hello.md"),
880        ];
881        expected.sort();
882
883        assert_eq!(files, expected);
884    }
885
886    #[test]
887    fn collect_files_missing_directory_is_empty() {
888        let dir = tempfile::tempdir().unwrap();
889        let missing = dir.path().join("does-not-exist");
890        assert!(collect_files(&missing).unwrap().is_empty());
891    }
892}