aede_core/copy/
output.rs

1//! Filesystem checks and isolated temporary output shared by copying and encoding.
2
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
7
8/// Refuses missing or special sources without opening a potentially blocking
9/// FIFO/device. Source symlinks may resolve to an ordinary readable file.
10pub fn validate_source(source: &Path) -> Result<(), String> {
11    let metadata =
12        std::fs::metadata(source).map_err(|error| format!("{}: {error}", source.display()))?;
13    if !metadata.is_file() {
14        return Err(format!(
15            "{}: source is not an ordinary file",
16            source.display()
17        ));
18    }
19    Ok(())
20}
21
22/// Tests hard-link publication inside an isolated directory, before transfer.
23///
24/// Call only for a real transfer with new outputs and without explicit
25/// replacement. No-write previews and existing-only resumes do not need it.
26pub fn validate_new_publication(base: &Path) -> Result<(), String> {
27    let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
28    let temporary = TemporaryOutput::new(
29        &base.join(format!(".aede-link-check-{}-{serial}", std::process::id())),
30    )?;
31    std::fs::write(temporary.path(), b"").map_err(|error| error.to_string())?;
32    std::fs::hard_link(temporary.path(), temporary.directory.join("published"))
33        .map_err(|error| new_publication_error(base, error))
34}
35
36fn new_publication_error(destination: &Path, error: std::io::Error) -> String {
37    match error.kind() {
38        std::io::ErrorKind::AlreadyExists => format!(
39            "{}: output appeared before publication; use --replace to refresh it",
40            destination.display()
41        ),
42        _ => format!(
43            "{}: cannot safely publish a new output (filesystem hard links required; --replace explicitly allows rename publication): {error}",
44            destination.display()
45        ),
46    }
47}
48
49/// Checks every existing component below a canonical destination base.
50///
51/// Symbolic links are refused even when they currently resolve inside the base:
52/// following them would make a later replacement depend on a different tree.
53/// Missing components are allowed; only ordinary directories may be created.
54/// This is a local filesystem safety check, not protection against a process
55/// concurrently replacing directories while the command runs.
56pub fn validate_destination(base: &Path, relative: &Path) -> Result<(), String> {
57    if relative.as_os_str().is_empty()
58        || relative
59            .components()
60            .any(|part| !matches!(part, Component::Normal(_)))
61    {
62        return Err(format!(
63            "{}: output must be a relative file path",
64            relative.display()
65        ));
66    }
67    let mut path = base.to_path_buf();
68    let count = relative.components().count();
69    for (index, part) in relative.components().enumerate() {
70        path.push(part.as_os_str());
71        match std::fs::symlink_metadata(&path) {
72            Ok(meta) if meta.file_type().is_symlink() => {
73                return Err(format!(
74                    "{}: symbolic links are refused in a copy destination",
75                    path.display()
76                ));
77            }
78            Ok(meta) if index + 1 < count && !meta.is_dir() => {
79                return Err(format!("{}: output parent is not a folder", path.display()));
80            }
81            Ok(meta) if index + 1 == count && !meta.is_file() => {
82                return Err(format!(
83                    "{}: output is not an ordinary file",
84                    path.display()
85                ));
86            }
87            Ok(_) => {}
88            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
89            Err(error) => return Err(format!("{}: {error}", path.display())),
90        }
91    }
92    Ok(())
93}
94
95/// Checks planned names against the destination's actual filesystem rules.
96///
97/// The namespace is mirrored inside one isolated temporary directory, then
98/// removed. Exclusive file and directory creation detects case/Unicode aliases
99/// missed by the portable planner before any real output is changed. Callers
100/// must omit this write-based preflight for a no-write dry run.
101pub fn validate_filesystem_names(base: &Path, plan: &super::Plan) -> Result<(), String> {
102    use std::collections::BTreeSet;
103    let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
104    let temporary = TemporaryOutput::new(
105        &base.join(format!(".aede-names-check-{}-{serial}", std::process::id())),
106    )?;
107    let tree = temporary.directory.join("tree");
108    std::fs::create_dir(&tree).map_err(|error| error.to_string())?;
109    let mut directories = BTreeSet::new();
110    for item in &plan.items {
111        validate_destination(base, &item.relative)?;
112        let parts: Vec<_> = item.relative.components().collect();
113        let mut relative = PathBuf::new();
114        for component in &parts[..parts.len() - 1] {
115            relative.push(component.as_os_str());
116            if directories.insert(relative.clone()) {
117                std::fs::create_dir(tree.join(&relative)).map_err(|error| format!(
118                    "{}: destination aliases a different planned folder or refuses its name: {error}", relative.display()
119                ))?;
120            }
121        }
122        std::fs::OpenOptions::new()
123            .write(true)
124            .create_new(true)
125            .open(tree.join(&item.relative))
126            .map_err(|error| {
127                format!(
128                    "{}: destination aliases another planned output or refuses its name: {error}",
129                    item.relative.display()
130                )
131            })?;
132    }
133    Ok(())
134}
135
136pub(super) fn regular_destination(destination: &Path) -> Result<Option<std::fs::Metadata>, String> {
137    match std::fs::symlink_metadata(destination) {
138        Ok(meta) if !meta.is_file() || meta.file_type().is_symlink() => Err(format!(
139            "{}: refusing to replace or follow a non-regular destination",
140            destination.display()
141        )),
142        Ok(meta) => Ok(Some(meta)),
143        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
144        Err(error) => Err(format!("{}: {error}", destination.display())),
145    }
146}
147
148/// One exclusively created temporary directory beside a destination file.
149///
150/// The output lives inside a private directory, so an old `.aede-partial` file
151/// cannot redirect either a copy or ffmpeg. The final extension is retained for
152/// encoder muxer selection. Dropping the object removes unfinished output.
153pub struct TemporaryOutput {
154    directory: PathBuf,
155    path: PathBuf,
156    destination: PathBuf,
157}
158
159impl TemporaryOutput {
160    /// Prepares an isolated output after refusing a non-regular destination.
161    ///
162    /// Call [`validate_destination`] with the chosen base before this method
163    /// when writing a path taken from a copy plan.
164    pub fn new(destination: &Path) -> Result<Self, String> {
165        regular_destination(destination)?;
166        let parent = destination
167            .parent()
168            .filter(|path| !path.as_os_str().is_empty())
169            .unwrap_or_else(|| Path::new("."));
170        std::fs::create_dir_all(parent)
171            .map_err(|error| format!("{}: {error}", parent.display()))?;
172        let parent = parent
173            .canonicalize()
174            .map_err(|error| format!("{}: {error}", parent.display()))?;
175        let name = destination
176            .file_name()
177            .ok_or_else(|| "output has no file name".to_string())?;
178        let destination = parent.join(name);
179        let clock = std::time::SystemTime::now()
180            .duration_since(std::time::UNIX_EPOCH)
181            .unwrap_or_default()
182            .as_nanos();
183        for _ in 0..100 {
184            let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
185            let directory = parent.join(format!(
186                ".aede-partial-{}-{clock}-{serial}",
187                std::process::id()
188            ));
189            let mut builder = std::fs::DirBuilder::new();
190            #[cfg(unix)]
191            {
192                use std::os::unix::fs::DirBuilderExt;
193                builder.mode(0o700);
194            }
195            match builder.create(&directory) {
196                Ok(()) => {
197                    let mut path = directory.join("output");
198                    if let Some(extension) = destination.extension() {
199                        path.set_extension(extension);
200                    }
201                    return Ok(Self {
202                        directory,
203                        path,
204                        destination,
205                    });
206                }
207                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
208                Err(error) => return Err(format!("{}: {error}", directory.display())),
209            }
210        }
211        Err("could not reserve a unique temporary output".into())
212    }
213
214    /// The isolated output path, carrying the final extension.
215    pub fn path(&self) -> &Path {
216        &self.path
217    }
218
219    /// Publishes a complete file, replacing an ordinary existing target.
220    ///
221    /// Use [`Self::publish_new`] unless replacement was explicitly requested.
222    pub fn publish(self) -> Result<(), String> {
223        regular_destination(&self.destination)?;
224        std::fs::rename(&self.path, &self.destination)
225            .map_err(|error| format!("{}: {error}", self.destination.display()))
226    }
227
228    /// Publishes a complete new file without replacing any existing target.
229    ///
230    /// Creating the hard link is atomic even if a target appears after the
231    /// initial check. Filesystems without hard links are refused; an explicit
232    /// replacement can instead use [`Self::publish`]. No partial final file
233    /// is exposed and no check-then-rename fallback can destroy another file.
234    pub fn publish_new(self) -> Result<(), String> {
235        std::fs::hard_link(&self.path, &self.destination)
236            .map_err(|error| new_publication_error(&self.destination, error))?;
237        Ok(())
238    }
239}
240
241impl Drop for TemporaryOutput {
242    fn drop(&mut self) {
243        // Windows can copy a source's read-only bit, which otherwise prevents
244        // temporary-link deletion. Restore it through the retained file
245        // handle: successful no-replace publication shares that inode, and
246        // a pathname can meanwhile be replaced by another writer.
247        #[cfg(windows)]
248        let restore = std::fs::File::open(&self.path).ok().and_then(|file| {
249            let permissions = file.metadata().ok()?.permissions();
250            if !permissions.readonly() {
251                return None;
252            }
253            let mut writable = permissions.clone();
254            writable.set_readonly(false);
255            file.set_permissions(writable).ok()?;
256            Some((file, permissions))
257        });
258        let _ = std::fs::remove_dir_all(&self.directory);
259        #[cfg(windows)]
260        if let Some((file, permissions)) = restore {
261            let _ = file.set_permissions(permissions);
262        }
263    }
264}
265
266/// Compares file contents using fixed-size buffers, independent of file size.
267pub fn files_match(left: &Path, right: &Path) -> Result<bool, String> {
268    use std::io::Read;
269    let mut left = std::fs::File::open(left).map_err(|error| error.to_string())?;
270    let mut right = std::fs::File::open(right).map_err(|error| error.to_string())?;
271    if left.metadata().map_err(|error| error.to_string())?.len()
272        != right.metadata().map_err(|error| error.to_string())?.len()
273    {
274        return Ok(false);
275    }
276    let mut a = vec![0; 1 << 20];
277    let mut b = vec![0; 1 << 20];
278    loop {
279        let n = left.read(&mut a).map_err(|error| error.to_string())?;
280        if n == 0 {
281            return Ok(right.read(&mut b).map_err(|error| error.to_string())? == 0);
282        }
283        right
284            .read_exact(&mut b[..n])
285            .map_err(|error| error.to_string())?;
286        if a[..n] != b[..n] {
287            return Ok(false);
288        }
289    }
290}
291
292#[cfg(test)]
293#[path = "output_tests.rs"]
294mod tests;