1use crate::config::Config;
2use crate::message::EmailInfo;
3use chrono::{Local, TimeZone, Utc};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[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#[cfg(not(unix))]
19pub fn apply_permissions(_path: &Path, _mode: i32) {
20 }
22
23pub 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
53fn 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
90pub fn message_filename(email: &EmailInfo, config: &Config) -> String {
92 format!("{}.{}", message_name(email, config), config.htmlsuffix)
93}
94
95pub 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
106pub 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
123pub fn messageindex_name(config: &Config) -> PathBuf {
125 PathBuf::from(config.dir.as_deref().unwrap_or(".")).join("msgindex")
126}
127
128pub 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
156pub 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
164pub 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 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
229fn 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
243pub fn symlink_latest(store: &crate::structs::EmailStore, config: &Config) -> std::io::Result<()> {
248 if let Some(ref latest) = config.latest_folder {
249 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 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
273fn 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
291pub 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
300pub 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
315pub 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
330pub 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 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
365pub 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 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 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 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 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
524fn 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
543pub fn matches_existing(msgid: &str, existing_msgid: &str) -> bool {
545 msgid.trim() == existing_msgid.trim()
546}
547
548pub fn lock_file_name(config: &Config) -> PathBuf {
550 PathBuf::from(config.dir.as_deref().unwrap_or(".")).join(".hm_lock")
551}
552
553#[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 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 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); 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 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"); }
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 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}