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