Skip to main content

hypermail/
file_utils.rs

1use crate::config::Config;
2use crate::message::EmailInfo;
3use chrono::{Local, TimeZone, Utc};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Apply configured permissions to a path (Unix only).
8#[cfg(unix)]
9pub fn apply_permissions(path: &Path, mode: i32) {
10    use std::os::unix::fs::PermissionsExt;
11    let perms = std::fs::Permissions::from_mode(mode as u32);
12    if let Err(e) = std::fs::set_permissions(path, perms) {
13        log::debug!("Failed to set permissions on {:?}: {}", path, e);
14    }
15}
16
17/// Apply configured permissions to a path (no-op on non-Unix platforms).
18#[cfg(not(unix))]
19pub fn apply_permissions(_path: &Path, _mode: i32) {
20    // Permissions are Unix-only; no-op on other platforms
21}
22
23/// Generates a unique name for an email message.
24///
25/// Uses either sequential numbering (default) or content-based hashing
26/// when `config.nonsequential` is enabled.
27///
28/// # Arguments
29///
30/// * `email` - The email to generate a name for
31/// * `config` - Configuration determining naming scheme
32///
33/// # Returns
34///
35/// - Sequential: `"0001"`, `"0042"`, etc. (4 digits, zero-padded)
36/// - Hashed: `"a3f2c891be4f3210"` (16 hex digits from FNV32 hash)
37///
38/// # Security
39///
40/// The hashed mode uses FNV-1a to prevent predictable filenames, which
41/// could be used to guess Message-IDs. The hash includes both message ID
42/// and timestamp for uniqueness.
43pub fn message_name(email: &EmailInfo, config: &Config) -> String {
44    if config.nonsequential {
45        if let Some(ref msgid) = email.msgid {
46            let hash = fnv32(msgid.as_bytes(), email.from_date);
47            return format!("{:08x}{:08x}", hash, email.from_date as u32);
48        }
49    }
50    format!("{:04}", email.msgnum)
51}
52
53/// FNV-1a 32-bit hash function.
54///
55/// Computes a hash using the FNV-1a algorithm with an optional seed.
56/// This is used for generating content-based filenames that don't reveal
57/// the original Message-ID.
58///
59/// # Security Note
60///
61/// Uses `wrapping_mul` intentionally as required by the FNV algorithm.
62/// Integer overflow is part of the hash specification and produces
63/// correct hash distribution.
64///
65/// # Arguments
66///
67/// * `buf` - Input bytes to hash
68/// * `seed` - Optional seed value (typically timestamp)
69///
70/// # Returns
71///
72/// 32-bit hash value as u32
73fn fnv32(buf: &[u8], seed: i64) -> u32 {
74    const FNV1_32_INIT: u32 = 0x811c9dc5;
75    const FNV_32_PRIME: u32 = 0x01000193;
76    let mut hash = FNV1_32_INIT;
77    for &b in buf {
78        hash ^= b as u32;
79        hash = hash.wrapping_mul(FNV_32_PRIME);
80    }
81    if seed != 0 {
82        for b in seed.to_le_bytes() {
83            hash ^= b as u32;
84            hash = hash.wrapping_mul(FNV_32_PRIME);
85        }
86    }
87    hash
88}
89
90/// Returns the full filename for a message (name + HTML suffix).
91pub fn message_filename(email: &EmailInfo, config: &Config) -> String {
92    format!("{}.{}", message_name(email, config), config.htmlsuffix)
93}
94
95/// Returns the full filesystem path for a message's HTML file.
96pub fn message_path(email: &EmailInfo, config: &Config) -> PathBuf {
97    let dir = config.dir.as_deref().unwrap_or(".");
98    let sub = msg_subdir(email, config);
99    let base = match sub {
100        Some(ref s) => PathBuf::from(dir).join(&s.subdir),
101        None => PathBuf::from(dir),
102    };
103    base.join(message_filename(email, config))
104}
105
106/// Returns the relative URL string for a message, including subdirectory if applicable.
107pub fn message_url_str(email: &EmailInfo, config: &Config) -> String {
108    let sub = msg_subdir(email, config);
109    let filename = message_filename(email, config);
110    match sub {
111        Some(ref s) => {
112            let subdir = s.subdir.trim_end_matches('/');
113            if subdir.is_empty() {
114                filename
115            } else {
116                format!("{}/{}", subdir, filename)
117            }
118        },
119        None => filename,
120    }
121}
122
123/// Returns the path to the message index file.
124pub fn messageindex_name(config: &Config) -> PathBuf {
125    PathBuf::from(config.dir.as_deref().unwrap_or(".")).join("msgindex")
126}
127
128/// Expands date format placeholders (`%y`, `%m`, `%d`, etc.) in a path template.
129pub fn dirpath(frmptr: &str) -> String {
130    let now = chrono::Local::now();
131    let mut result = String::new();
132    let mut chars = frmptr.chars();
133    while let Some(c) = chars.next() {
134        if c == '%' {
135            match chars.next() {
136                Some('d') => result.push_str(&now.format("%d").to_string()),
137                Some('D') => result.push_str(&now.format("%a").to_string()),
138                Some('j') => result.push_str(&now.format("%j").to_string()),
139                Some('m') => result.push_str(&now.format("%m").to_string()),
140                Some('M') => result.push_str(&now.format("%b").to_string()),
141                Some('y') => result.push_str(&now.format("%Y").to_string()),
142                Some('%') => result.push('%'),
143                Some(c) => {
144                    result.push('%');
145                    result.push(c);
146                },
147                None => result.push('%'),
148            }
149        } else {
150            result.push(c);
151        }
152    }
153    result
154}
155
156/// Information about the subdirectory for a message when using folder-based layouts.
157pub struct EmailSubdirInfo {
158    pub subdir: String,
159    pub full_path: String,
160    pub rel_path_to_top: String,
161    pub description: Option<String>,
162}
163
164/// Determines the subdirectory for a message based on folder configuration.
165pub fn msg_subdir(email: &EmailInfo, config: &Config) -> Option<EmailSubdirInfo> {
166    if config.msgsperfolder > 0 {
167        let subdir_no = email.msgnum / config.msgsperfolder;
168        let sub = format!("{}/", subdir_no);
169        let base = config.dir.as_deref().unwrap_or(".");
170        let full = PathBuf::from(base).join(&sub).to_string_lossy().to_string();
171        let rel = if subdir_no == 0 {
172            "./".to_string()
173        } else {
174            let mut r = String::new();
175            let depth = sub.matches('/').count();
176            for _ in 0..depth {
177                r.push_str("../");
178            }
179            r
180        };
181        return Some(EmailSubdirInfo {
182            subdir: sub,
183            full_path: full,
184            rel_path_to_top: rel,
185            description: None,
186        });
187    }
188    if let Some(ref fbd) = config.folder_by_date {
189        if email.date > 0 {
190            let ts = Utc.timestamp_opt(email.date, 0).single().unwrap_or_default();
191            let sub = if config.gmtime {
192                ts.format(fbd).to_string()
193            } else {
194                ts.with_timezone(&Local).format(fbd).to_string()
195            };
196            // Security: reject path traversal attempts via format string
197            if sub.contains("..") || is_rooted_path(&sub) {
198                log::warn!("folder_by_date produced suspicious path '{}', using flat layout", sub);
199                return None;
200            }
201            let sub = if !sub.ends_with('/') {
202                format!("{}/", sub)
203            } else {
204                sub
205            };
206            let base = config.dir.as_deref().unwrap_or(".");
207            let full = PathBuf::from(base).join(&sub).to_string_lossy().to_string();
208            let depth = sub.matches('/').count();
209            let rel = if depth == 0 {
210                "./".to_string()
211            } else {
212                let mut r = String::new();
213                for _ in 0..depth {
214                    r.push_str("../");
215                }
216                r
217            };
218            return Some(EmailSubdirInfo {
219                subdir: sub,
220                full_path: full,
221                rel_path_to_top: rel,
222                description: None,
223            });
224        }
225    }
226    None
227}
228
229/// Returns true if `s` looks like an absolute/rooted path on *any* platform.
230///
231/// `Path::is_absolute()` only recognizes Windows drive/UNC prefixes on Windows,
232/// so a Unix-style `/etc/passwd` config value is (wrongly) "relative" there and
233/// slips past a naive absolute-path guard. Config values are always written with
234/// forward slashes regardless of host OS, so reject leading `/` or `\` and
235/// Windows drive prefixes (`C:`) uniformly.
236fn is_rooted_path(s: &str) -> bool {
237    s.starts_with('/')
238        || s.starts_with('\\')
239        || s.get(1..2) == Some(":")
240        || std::path::Path::new(s).is_absolute()
241}
242
243/// Creates a symlink pointing to the latest folder (if configured).
244///
245/// Target is the subdirectory of the newest message by date (folder_by_date or
246/// msgsperfolder layout). Relative link target matches classic Hypermail.
247pub fn symlink_latest(store: &crate::structs::EmailStore, config: &Config) -> std::io::Result<()> {
248    if let Some(ref latest) = config.latest_folder {
249        // Security: reject path traversal / absolute paths in the (operator-configured)
250        // latest_folder value, same guard as folder_by_date above — a misconfigured or
251        // malicious config could otherwise place a symlink outside the archive dir.
252        if latest.contains("..") || is_rooted_path(latest) {
253            log::warn!(
254                "latest_folder '{}' looks unsafe (absolute or contains ..); skipping symlink",
255                latest
256            );
257            return Ok(());
258        }
259        let dir = config.dir.as_deref().unwrap_or(".");
260        let link_path = PathBuf::from(dir).join(latest);
261        let target = latest_folder_target(store, config);
262        let _ = fs::remove_file(&link_path);
263        // Also remove if it is a directory symlink/junction
264        let _ = fs::remove_dir(&link_path);
265        #[cfg(unix)]
266        std::os::unix::fs::symlink(&target, &link_path)?;
267        #[cfg(windows)]
268        std::os::windows::fs::symlink_dir(&target, &link_path)?;
269    }
270    Ok(())
271}
272
273/// Relative path of the folder containing the newest message, or `"."` if flat layout.
274fn latest_folder_target(store: &crate::structs::EmailStore, config: &Config) -> String {
275    let mut best: Option<(i64, String)> = None;
276    for email in &store.emails {
277        if let Some(sub) = msg_subdir(email, config) {
278            let subdir = sub.subdir.trim_end_matches('/').to_string();
279            if subdir.is_empty() {
280                continue;
281            }
282            match best {
283                Some((d, _)) if email.date < d => {},
284                _ => best = Some((email.date, subdir)),
285            }
286        }
287    }
288    best.map(|(_, s)| s).unwrap_or_else(|| ".".to_string())
289}
290
291/// Creates a directory and all parent directories if they don't exist.
292pub fn checkdir(path: &str) -> std::io::Result<()> {
293    let p = Path::new(path);
294    if !p.exists() {
295        fs::create_dir_all(p)?;
296    }
297    Ok(())
298}
299
300/// Returns true if the archive directory contains no non-hidden files.
301pub fn is_empty_archive(config: &Config) -> bool {
302    let dir = config.dir.as_deref().unwrap_or(".");
303    if let Ok(entries) = fs::read_dir(dir) {
304        for entry in entries.flatten() {
305            if let Some(name) = entry.file_name().to_str() {
306                if !name.starts_with('.') {
307                    return false;
308                }
309            }
310        }
311    }
312    true
313}
314
315/// Writes the message index file mapping message numbers to filenames.
316pub fn write_messageindex(
317    store: &crate::structs::EmailStore,
318    config: &Config,
319) -> std::io::Result<()> {
320    let path = messageindex_name(config);
321    let mut content = String::new();
322    content.push_str(&format!("{:04} {:04}\n", 0, store.max_msgnum.max(0)));
323    for email in &store.emails {
324        let name = message_name(email, config);
325        content.push_str(&format!("{:04} {}\n", email.msgnum, name));
326    }
327    fs::write(&path, &content)
328}
329
330/// Reads the message index file, returning a table of message number to filename mappings.
331pub fn read_messageindex(config: &Config) -> std::io::Result<Vec<Option<String>>> {
332    let path = messageindex_name(config);
333    let content = fs::read_to_string(&path)?;
334    let mut lines = content.lines();
335    let mut table: Vec<Option<String>> = Vec::new();
336    if let Some(first) = lines.next() {
337        let parts: Vec<&str> = first.split_whitespace().collect();
338        let max_num: i32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
339        // Reject negative or implausibly large values to prevent DoS via a corrupted
340        // messageindex file: `(max_num + 1) as usize` would otherwise overflow or
341        // attempt to allocate gigabytes of `Vec` slots.
342        const MAX_MESSAGES: i32 = 100_000_000;
343        if !(0..=MAX_MESSAGES).contains(&max_num) {
344            return Err(std::io::Error::new(
345                std::io::ErrorKind::InvalidData,
346                format!("invalid message count in messageindex header: {}", max_num),
347            ));
348        }
349        let size = (max_num as usize).saturating_add(1);
350        table.resize(size, None);
351        for line in lines {
352            let parts: Vec<&str> = line.split_whitespace().collect();
353            if parts.len() >= 2 {
354                if let Ok(num) = parts[0].parse::<i32>() {
355                    if (num as usize) < table.len() {
356                        table[num as usize] = Some(parts[1].to_string());
357                    }
358                }
359            }
360        }
361    }
362    Ok(table)
363}
364
365/// Loads email metadata from existing HTML archive files for incremental updates.
366pub fn load_old_headers_from_html(store: &mut crate::structs::EmailStore, config: &Config) -> i32 {
367    let dir = config.dir.as_deref().unwrap_or(".");
368    let suffix = &config.htmlsuffix;
369    let mut count = 0;
370
371    let mut files = Vec::new();
372    collect_html_files(Path::new(dir), suffix, &mut files);
373    // Also scan subdirectories if using folders
374    if config.msgsperfolder > 0 || config.folder_by_date.is_some() {
375        if let Ok(entries) = fs::read_dir(dir) {
376            for entry in entries.flatten() {
377                let path = entry.path();
378                if path.is_dir() {
379                    collect_html_files(&path, suffix, &mut files);
380                }
381            }
382        }
383    }
384
385    for path in files {
386        let msgnum = if config.nonsequential {
387            if let Ok(content) = fs::read_to_string(&path) {
388                extract_msgnum_from_html(&content, &config.fragment_prefix).unwrap_or(0)
389            } else {
390                0
391            }
392        } else {
393            path.file_stem()
394                .and_then(|s| s.to_str())
395                .and_then(|s| s.parse::<i32>().ok())
396                .unwrap_or(0)
397        };
398        if msgnum == 0 {
399            continue;
400        }
401        if let Ok(content) = fs::read_to_string(&path) {
402            if let Some(email) = parse_old_html_comments(&content, msgnum) {
403                let idx = store.add_email(email);
404                store.insert_into_date_list(idx);
405                store.insert_into_subject_list(idx);
406                store.insert_into_author_list(idx);
407                count += 1;
408            }
409        }
410    }
411    count
412}
413
414fn collect_html_files(dir: &Path, suffix: &str, files: &mut Vec<PathBuf>) {
415    if let Ok(entries) = fs::read_dir(dir) {
416        for entry in entries.flatten() {
417            let path = entry.path();
418            if path.is_file() {
419                if let Some(ext) = path.extension() {
420                    if ext.to_string_lossy() == suffix {
421                        files.push(path);
422                    }
423                }
424            }
425        }
426    }
427}
428
429fn extract_msgnum_from_html(html: &str, fragment_prefix: &str) -> Option<i32> {
430    // New format: <article id="PREFIXmsgnum">
431    let id_needle_str = format!("<article id=\"{}\"", fragment_prefix);
432    let id_needle = id_needle_str.trim_end_matches('"');
433    if let Some(start) = html.find(id_needle) {
434        let after_prefix = start + id_needle.len();
435        if let Some(end) = html[after_prefix..].find('"') {
436            let msgnum_str = &html[after_prefix..after_prefix + end];
437            if let Ok(n) = msgnum_str.parse::<i32>() {
438                return Some(n);
439            }
440        }
441    }
442    // Legacy format: <a name="PREFIXmsgnum"> (for backward compatibility with old archives)
443    let needle_str = format!("<a name=\"{}\"", fragment_prefix);
444    let needle = needle_str.trim_end_matches('"');
445    if let Some(start) = html.find(needle) {
446        let after_prefix = start + needle.len();
447        if let Some(end) = html[after_prefix..].find('"') {
448            let msgnum_str = &html[after_prefix..after_prefix + end];
449            return msgnum_str.parse::<i32>().ok();
450        }
451    }
452    None
453}
454
455fn parse_old_html_comments(html: &str, msgnum: i32) -> Option<EmailInfo> {
456    let mut name = None;
457    let mut email_addr = None;
458    let mut subject = None;
459    let mut msgid = None;
460    let mut inreplyto = None;
461    let mut date_str = None;
462    let mut from_date_str = None;
463    let mut charset = None;
464    let mut is_deleted = 0;
465    let mut found_body = false;
466
467    for line in html.lines() {
468        let line = line.trim();
469        if let Some(val) = extract_comment(line, "received") {
470            from_date_str = Some(val.to_string());
471        } else if let Some(val) = extract_comment(line, "sent") {
472            date_str = Some(val.to_string());
473        } else if let Some(val) = extract_comment(line, "name") {
474            name = Some(val.to_string());
475        } else if let Some(val) = extract_comment(line, "email") {
476            email_addr = Some(val.to_string());
477        } else if let Some(val) = extract_comment(line, "subject") {
478            subject = Some(val.to_string());
479        } else if let Some(val) = extract_comment(line, "id") {
480            msgid = Some(val.to_string());
481        } else if let Some(val) = extract_comment(line, "charset") {
482            charset = Some(val.to_string());
483        } else if let Some(val) = extract_comment(line, "inreplyto") {
484            inreplyto = Some(val.to_string());
485        } else if let Some(val) = extract_comment(line, "isdeleted") {
486            is_deleted = val.parse().unwrap_or(0);
487        } else if let Some(val) = extract_comment(line, "body") {
488            if val == "start" {
489                found_body = true;
490            }
491        }
492    }
493
494    if !found_body && msgid.is_none() {
495        return None;
496    }
497
498    // Restore timestamps for incremental updates / date-based folders
499    let date = date_str
500        .as_deref()
501        .and_then(parse_comment_timestamp)
502        .or_else(|| from_date_str.as_deref().and_then(parse_comment_timestamp))
503        .unwrap_or(0);
504    let from_date = from_date_str.as_deref().and_then(parse_comment_timestamp).unwrap_or(date);
505
506    Some(EmailInfo {
507        msgnum,
508        name,
509        email_addr,
510        from_date_str,
511        from_date,
512        date_str,
513        date,
514        datenum: date,
515        subject,
516        msgid,
517        inreplyto,
518        charset,
519        is_deleted,
520        ..Default::default()
521    })
522}
523
524/// Parse a timestamp from an HTML comment value (ISO or RFC 2822).
525fn parse_comment_timestamp(s: &str) -> Option<i64> {
526    crate::date::iso_to_secs(s)
527        .ok()
528        .or_else(|| crate::date::parse_rfc2822_date(s).ok())
529        .filter(|&t| t > 0)
530}
531
532fn extract_comment<'a>(line: &'a str, key: &str) -> Option<&'a str> {
533    let pattern = format!("<!-- {}=\"", key);
534    if let Some(start) = line.find(&pattern) {
535        let val_start = start + pattern.len();
536        if let Some(end) = line[val_start..].find('"') {
537            return Some(&line[val_start..val_start + end]);
538        }
539    }
540    None
541}
542
543/// Returns true if two Message-IDs match (trimmed comparison).
544pub fn matches_existing(msgid: &str, existing_msgid: &str) -> bool {
545    msgid.trim() == existing_msgid.trim()
546}
547
548/// Returns the path to the lock file for this archive.
549pub fn lock_file_name(config: &Config) -> PathBuf {
550    PathBuf::from(config.dir.as_deref().unwrap_or(".")).join(".hm_lock")
551}
552
553/// Acquires an exclusive non-blocking lock on the archive directory.
554#[allow(unsafe_code)]
555pub fn try_lock(config: &Config) -> std::io::Result<fs::File> {
556    let path = lock_file_name(config);
557    let file = fs::OpenOptions::new().write(true).create(true).truncate(true).open(&path)?;
558
559    #[cfg(unix)]
560    {
561        use std::os::unix::io::AsRawFd;
562
563        let fd = file.as_raw_fd();
564
565        // SAFETY: This is a safe FFI call to the POSIX flock() system call.
566        // - `fd` is a valid file descriptor obtained via AsRawFd()
567        // - flock() is a standard POSIX function that cannot cause UB with valid fd
568        // - LOCK_EX | LOCK_NB requests exclusive non-blocking lock (safe flags)
569        // - Return value is checked for errors
570        // - The file is owned by this function and fd remains valid
571        let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
572        if ret != 0 {
573            return Err(std::io::Error::last_os_error());
574        }
575    }
576
577    // Write PID on all platforms (best-effort advisory on non-Unix)
578    use std::io::Write;
579    let _ = write!(&file, "{}", std::process::id());
580
581    Ok(file)
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::config::Config;
588    use crate::message::EmailInfo;
589
590    fn make_email(msgnum: i32, msgid: &str) -> EmailInfo {
591        EmailInfo { msgnum, msgid: Some(msgid.to_string()), date: 1000000, ..Default::default() }
592    }
593
594    #[test]
595    fn test_message_name_sequential() {
596        let config = Config::default();
597        let email = make_email(42, "<a@b>");
598        assert_eq!(message_name(&email, &config), "0042");
599    }
600
601    #[test]
602    fn test_message_name_nonsequential() {
603        let mut config = Config::default();
604        config.nonsequential = true;
605        let email = make_email(42, "<hello@world.com>");
606        let name = message_name(&email, &config);
607        assert_eq!(name.len(), 16);
608        assert!(name.chars().all(|c| c.is_ascii_hexdigit()));
609    }
610
611    #[test]
612    fn test_message_filename() {
613        let config = Config::default();
614        let email = make_email(7, "<a@b>");
615        assert_eq!(message_filename(&email, &config), "0007.html");
616    }
617
618    #[test]
619    fn test_fnv32_consistency() {
620        let h1 = fnv32(b"test", 0);
621        let h2 = fnv32(b"test", 0);
622        assert_eq!(h1, h2);
623    }
624
625    #[test]
626    fn test_fnv32_different() {
627        let h1 = fnv32(b"abc", 0);
628        let h2 = fnv32(b"xyz", 0);
629        assert_ne!(h1, h2);
630    }
631
632    #[test]
633    fn test_extract_comment() {
634        let line = "<!-- name=\"Alice\" -->";
635        assert_eq!(extract_comment(line, "name"), Some("Alice"));
636        assert_eq!(extract_comment(line, "subject"), None);
637    }
638
639    #[test]
640    fn test_matches_existing() {
641        assert!(matches_existing("<a@b>", "<a@b>"));
642        assert!(!matches_existing("<a@b>", "<c@d>"));
643    }
644
645    #[test]
646    fn test_dirpath() {
647        let result = dirpath("/archives/%y/%m");
648        assert!(result.starts_with("/archives/20"));
649    }
650
651    #[test]
652    fn test_message_url_str_sequential() {
653        let config = Config::default();
654        let email = make_email(42, "<a@b>");
655        assert_eq!(message_url_str(&email, &config), "0042.html");
656    }
657
658    #[test]
659    fn test_message_url_str_nonsequential() {
660        let mut config = Config::default();
661        config.nonsequential = true;
662        let email = make_email(42, "<hello@world.com>");
663        let url = message_url_str(&email, &config);
664        assert_eq!(url.len(), 21); // 16 hex + ".html"
665        assert!(url.ends_with(".html"));
666    }
667
668    #[test]
669    fn test_message_url_str_with_subdir() {
670        let mut config = Config::default();
671        config.msgsperfolder = 100;
672        let email = make_email(142, "<a@b>");
673        let url = message_url_str(&email, &config);
674        assert_eq!(url, "1/0142.html");
675    }
676
677    #[test]
678    fn test_extract_msgnum_from_html_basic() {
679        let html = r#"<html><body><article id="msg42"></article></body></html>"#;
680        let msgnum = extract_msgnum_from_html(html, "msg");
681        assert_eq!(msgnum, Some(42));
682    }
683
684    #[test]
685    fn test_extract_msgnum_from_html_no_match() {
686        let html = r#"<html><body>no anchor here</body></html>"#;
687        let msgnum = extract_msgnum_from_html(html, "msg");
688        assert_eq!(msgnum, None);
689    }
690
691    #[test]
692    fn test_extract_msgnum_from_html_invalid_prefix() {
693        let html = r#"<html><body><article id="HM_42"></article></body></html>"#;
694        let msgnum = extract_msgnum_from_html(html, "XYZ");
695        assert_eq!(msgnum, None);
696    }
697
698    #[test]
699    fn test_extract_msgnum_from_html_large_msgnum() {
700        let html = r#"<html><body><article id="msg9999999"></article></body></html>"#;
701        let msgnum = extract_msgnum_from_html(html, "msg");
702        assert_eq!(msgnum, Some(9999999));
703    }
704
705    #[test]
706    fn test_extract_msgnum_from_html_legacy_a_name() {
707        // Backward compatibility: old archives use <a name="msg42">
708        let html = r#"<html><body><a name="msg42"></a></body></html>"#;
709        let msgnum = extract_msgnum_from_html(html, "msg");
710        assert_eq!(msgnum, Some(42));
711    }
712
713    #[test]
714    fn test_checkdir_creates_directory() {
715        let tmp = tempfile::tempdir().unwrap();
716        let new_dir = tmp.path().join("new_subdir").to_string_lossy().to_string();
717        assert!(!std::path::Path::new(&new_dir).exists());
718        checkdir(&new_dir).unwrap();
719        assert!(std::path::Path::new(&new_dir).exists());
720    }
721
722    #[test]
723    fn test_checkdir_existing_dir_is_ok() {
724        let tmp = tempfile::tempdir().unwrap();
725        let result = checkdir(tmp.path().to_str().unwrap());
726        assert!(result.is_ok());
727    }
728
729    #[test]
730    fn test_is_empty_archive_empty_dir() {
731        let tmp = tempfile::tempdir().unwrap();
732        let mut config = Config::default();
733        config.dir = Some(tmp.path().to_str().unwrap().to_string());
734        assert!(is_empty_archive(&config));
735    }
736
737    #[test]
738    fn test_is_empty_archive_with_file() {
739        let tmp = tempfile::tempdir().unwrap();
740        std::fs::write(tmp.path().join("index.html"), "content").unwrap();
741        let mut config = Config::default();
742        config.dir = Some(tmp.path().to_str().unwrap().to_string());
743        assert!(!is_empty_archive(&config));
744    }
745
746    #[test]
747    fn test_is_empty_archive_hidden_file_ignored() {
748        let tmp = tempfile::tempdir().unwrap();
749        std::fs::write(tmp.path().join(".hidden"), "content").unwrap();
750        let mut config = Config::default();
751        config.dir = Some(tmp.path().to_str().unwrap().to_string());
752        assert!(is_empty_archive(&config));
753    }
754
755    #[test]
756    fn test_write_and_read_messageindex_roundtrip() {
757        let tmp = tempfile::tempdir().unwrap();
758        let mut config = Config::default();
759        config.dir = Some(tmp.path().to_str().unwrap().to_string());
760
761        let mut store = crate::structs::EmailStore::new();
762        store.add_email(make_email(0, "<a@b>"));
763        store.add_email(make_email(1, "<b@b>"));
764        store.add_email(make_email(2, "<c@b>"));
765
766        write_messageindex(&store, &config).unwrap();
767        let table = read_messageindex(&config).unwrap();
768
769        assert_eq!(table[0].as_deref(), Some("0000"));
770        assert_eq!(table[1].as_deref(), Some("0001"));
771        assert_eq!(table[2].as_deref(), Some("0002"));
772    }
773
774    #[test]
775    fn test_msg_subdir_msgsperfolder() {
776        let mut config = Config::default();
777        config.dir = Some("/tmp".to_string());
778        config.msgsperfolder = 100;
779
780        let email = make_email(250, "<a@b>");
781        let sub = msg_subdir(&email, &config);
782        assert!(sub.is_some());
783        let info = sub.unwrap();
784        assert_eq!(info.subdir, "2/");
785    }
786
787    #[test]
788    fn test_msg_subdir_none_by_default() {
789        let config = Config::default();
790        let email = make_email(42, "<a@b>");
791        assert!(msg_subdir(&email, &config).is_none());
792    }
793
794    #[test]
795    fn test_message_path_sequential() {
796        let mut config = Config::default();
797        config.dir = Some("/tmp".to_string());
798        let email = make_email(7, "<a@b>");
799        let path = message_path(&email, &config);
800        assert!(path.to_string_lossy().ends_with("0007.html"));
801    }
802
803    #[test]
804    fn test_latest_folder_target_msgsperfolder() {
805        let mut config = Config::default();
806        config.dir = Some("/tmp".to_string());
807        config.msgsperfolder = 100;
808        let mut store = crate::structs::EmailStore::new();
809        let mut e1 = make_email(50, "<a@b>");
810        e1.date = 1000;
811        let mut e2 = make_email(250, "<c@d>");
812        e2.date = 2000;
813        store.add_email(e1);
814        store.add_email(e2);
815        let target = latest_folder_target(&store, &config);
816        assert_eq!(target, "2"); // msgnum 250 / 100
817    }
818
819    #[test]
820    fn test_latest_folder_target_flat() {
821        let config = Config::default();
822        let mut store = crate::structs::EmailStore::new();
823        store.add_email(make_email(1, "<a@b>"));
824        assert_eq!(latest_folder_target(&store, &config), ".");
825    }
826
827    #[test]
828    fn test_symlink_latest_rejects_path_traversal() {
829        let tmpdir = std::env::temp_dir().join(format!("hm_test_symlink_{}", std::process::id()));
830        let _ = fs::create_dir_all(&tmpdir);
831        let mut config = Config::default();
832        config.dir = Some(tmpdir.to_string_lossy().to_string());
833        config.latest_folder = Some("../../evil".to_string());
834        let store = crate::structs::EmailStore::new();
835        // Must not error (guard returns Ok early) and must not create anything outside tmpdir.
836        assert!(symlink_latest(&store, &config).is_ok());
837        let escaped = tmpdir.parent().unwrap().parent().unwrap().join("evil");
838        assert!(!escaped.exists());
839        let _ = fs::remove_dir_all(&tmpdir);
840    }
841
842    #[test]
843    fn test_symlink_latest_rejects_absolute_path() {
844        let tmpdir =
845            std::env::temp_dir().join(format!("hm_test_symlink_abs_{}", std::process::id()));
846        let _ = fs::create_dir_all(&tmpdir);
847        let mut config = Config::default();
848        config.dir = Some(tmpdir.to_string_lossy().to_string());
849        config.latest_folder = Some("/tmp/hm_evil_absolute_link".to_string());
850        let store = crate::structs::EmailStore::new();
851        assert!(symlink_latest(&store, &config).is_ok());
852        assert!(!PathBuf::from("/tmp/hm_evil_absolute_link").exists());
853        let _ = fs::remove_dir_all(&tmpdir);
854    }
855
856    #[test]
857    fn test_parse_comment_timestamp_rfc2822() {
858        let t = parse_comment_timestamp("Mon, 15 Mar 2021 12:00:00 +0000");
859        assert!(t.is_some());
860        assert!(t.unwrap() > 0);
861    }
862}