1use std::ffi::OsString;
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use crate::{Error, Result};
17
18pub fn mirror_path(mirror_root: &Path, division: &str, relative_source: &Path) -> PathBuf {
26 let mut mapped = mirror_root.join(division).join(relative_source);
27 let mut name = mapped.file_name().map(OsString::from).unwrap_or_default();
28 name.push(".txt");
29 mapped.set_file_name(name);
30 mapped
31}
32
33pub fn recorded_text_path(division: &str, source_path: &str) -> String {
37 format!("{division}/{source_path}.txt")
38}
39
40pub fn resolve_recorded_path(mirror_root: &Path, recorded: &str) -> Result<PathBuf> {
47 let segments: Vec<&str> = recorded.split('/').collect();
48 let clean = segments.len() >= 2
51 && !recorded.contains(['\\', ':'])
52 && segments
53 .iter()
54 .all(|segment| !segment.is_empty() && *segment != "." && *segment != "..");
55 if !clean {
56 return Err(Error::io(
57 "resolve",
58 Path::new(recorded),
59 std::io::Error::new(
60 std::io::ErrorKind::InvalidInput,
61 "recorded path is not a clean division-qualified mirror path",
62 ),
63 ));
64 }
65 Ok(mirror_root.join(recorded))
66}
67
68pub fn write_atomic(dest: &Path, text: &str) -> Result<()> {
74 let dir = dest.parent().ok_or_else(|| {
75 Error::io(
76 "write",
77 dest,
78 std::io::Error::new(std::io::ErrorKind::InvalidInput, "no parent directory"),
79 )
80 })?;
81 fs::create_dir_all(dir).map_err(|e| Error::io("create_dir", dir, e))?;
82 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| Error::io("write", dest, e))?;
83 tmp.as_file_mut()
84 .write_all(text.as_bytes())
85 .map_err(|e| Error::io("write", dest, e))?;
86 tmp.persist(dest)
87 .map_err(|e| Error::io("persist", dest, e.error))?;
88 Ok(())
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn mirror_path_is_division_namespaced() {
97 let mapped = mirror_path(
98 Path::new("/mirror"),
99 "emea",
100 Path::new("a/b/Q3 Budget.xlsx"),
101 );
102 assert_eq!(mapped, PathBuf::from("/mirror/emea/a/b/Q3 Budget.xlsx.txt"));
103 }
104
105 #[test]
106 fn mirror_path_keeps_extensionless_names_distinct() {
107 let with = mirror_path(Path::new("/m"), "d", Path::new("readme.md"));
108 let without = mirror_path(Path::new("/m"), "d", Path::new("readme"));
109 assert_eq!(with, PathBuf::from("/m/d/readme.md.txt"));
110 assert_eq!(without, PathBuf::from("/m/d/readme.txt"));
111 assert_ne!(with, without);
112 }
113
114 #[test]
115 fn same_source_path_in_two_divisions_cannot_collide() {
116 let emea = recorded_text_path("emea", "reports/q3.docx");
117 let apac = recorded_text_path("apac", "reports/q3.docx");
118 assert_eq!(emea, "emea/reports/q3.docx.txt");
119 assert_eq!(apac, "apac/reports/q3.docx.txt");
120 assert_ne!(emea, apac);
121 assert_ne!(
122 mirror_path(Path::new("/m"), "emea", Path::new("reports/q3.docx")),
123 mirror_path(Path::new("/m"), "apac", Path::new("reports/q3.docx"))
124 );
125 }
126
127 #[test]
128 fn recorded_paths_resolve_and_escapes_are_refused() {
129 let resolved = resolve_recorded_path(Path::new("/m"), "emea/a.txt.txt").unwrap();
130 assert_eq!(resolved, PathBuf::from("/m/emea/a.txt.txt"));
131 assert!(resolve_recorded_path(Path::new("/m"), "a.txt.txt").is_err());
133 assert!(resolve_recorded_path(Path::new("/m"), "emea/../evil").is_err());
134 assert!(resolve_recorded_path(Path::new("/m"), "emea//x").is_err());
135 assert!(resolve_recorded_path(Path::new("/m"), "emea/").is_err());
136 assert!(resolve_recorded_path(Path::new("/m"), "").is_err());
137 }
138
139 #[test]
140 fn write_atomic_creates_directories_and_content() {
141 let dir = tempfile::tempdir().unwrap();
142 let dest = dir.path().join("deep/nested/file.txt");
143 write_atomic(&dest, "content\n").unwrap();
144 assert_eq!(fs::read_to_string(&dest).unwrap(), "content\n");
145 }
146
147 #[test]
148 fn write_atomic_replaces_existing_content() {
149 let dir = tempfile::tempdir().unwrap();
150 let dest = dir.path().join("file.txt");
151 write_atomic(&dest, "old\n").unwrap();
152 write_atomic(&dest, "new\n").unwrap();
153 assert_eq!(fs::read_to_string(&dest).unwrap(), "new\n");
154 }
155}