Skip to main content

text_mirror/
mirror.rs

1//! Mirror path mapping and atomic writes.
2//!
3//! The mirror is division-namespaced: the artifact for source path
4//! `p` in division `d` lives at `<mirror root>/<d>/<p>.txt`, and a
5//! manifest record's `text_path` value is mirror-root-relative with
6//! the division segment first: `<d>/<p>.txt`, no `mirror/` prefix.
7//! First path segments are distinct by construction, so two divisions
8//! holding the same source path can never collide, on the prep
9//! machine or in a merged bundle.
10
11use std::ffi::OsString;
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use crate::{Error, Result};
17
18/// Maps a division and a source path onto the on-disk mirror.
19///
20/// The artifact lives under the division segment, and the full source
21/// name keeps its extension and gains `.txt`, so `Q3 Budget.xlsx` in
22/// division `emea` becomes `<root>/emea/Q3 Budget.xlsx.txt`.
23/// Appending preserves source identity and keeps sources that differ
24/// only by extension from colliding on one output path.
25pub 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
33/// The mirror-root-relative `text_path` value recorded in the
34/// manifest for a source path in a division: the division segment
35/// first, then the source path, then the appended extension.
36pub fn recorded_text_path(division: &str, source_path: &str) -> String {
37    format!("{division}/{source_path}.txt")
38}
39
40/// Resolves a recorded mirror-root-relative path to its on-disk
41/// location under the mirror root.
42///
43/// The recorded value must be a bare division-qualified path, at
44/// least two clean segments, staying inside the tree. Anything else
45/// is a malformed record, not a file to go looking for.
46pub fn resolve_recorded_path(mirror_root: &Path, recorded: &str) -> Result<PathBuf> {
47    let segments: Vec<&str> = recorded.split('/').collect();
48    // Backslash and colon are separators on hosts this crate does not
49    // run on, and a recorded path must mean the same file everywhere.
50    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
68/// Writes `text` to `dest` atomically.
69///
70/// The bytes land in a temp file in the destination directory, then a
71/// rename moves the file into place, so a reader never sees a partial
72/// artifact. Parent directories are created as needed.
73pub 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        // A bare file name lacks the division segment.
132        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}