Skip to main content

datui_lib/
output_file.rs

1//! A file written whole or not at all.
2//!
3//! [`OutputFile`] writes to a private temporary file beside the destination and
4//! moves it into place in [`OutputFile::commit`]. Until then the destination is
5//! untouched: a failure while serializing, finishing an encoder or flushing
6//! leaves the old file's bytes and permissions as they were, and a new export
7//! leaves no partial file. Dropping an uncommitted `OutputFile`, including in a
8//! panic, removes the temporary file.
9//!
10//! The file is synced before the rename, since some write errors (a network
11//! filesystem's quota, a disk's I/O error) are reported only then. The rename
12//! itself is not, so a power cut just after it can still lose the new file.
13//!
14//! Every file datui writes for the user goes through here: data exports, the
15//! Data Quality report and chart images.
16
17use std::fs::{self, File};
18use std::io;
19use std::path::{Path, PathBuf};
20
21/// What the commit may do to a file already at the destination.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Overwrite {
24    /// Nothing was there when the export was asked for. A file that appears
25    /// before the commit is left alone and the export fails.
26    Forbid,
27    /// The user agreed to replace the file there.
28    Replace,
29}
30
31/// Why [`OutputFile`] would not write a destination. Carried inside the
32/// `io::Error`, whose kind says the same to code that matches on kinds; its
33/// text is for the user and leaves the path to the caller.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Refused {
36    /// Something is at the path and replacing it was not agreed to: the
37    /// overwrite was not asked about, because nothing was there then.
38    Appeared,
39    ReadOnly,
40    Directory,
41    NotAFile,
42}
43
44impl Refused {
45    /// The refusal inside `error`, if it is one of these rather than the OS's.
46    pub fn of(error: &io::Error) -> Option<Self> {
47        error.get_ref()?.downcast_ref::<Self>().copied()
48    }
49
50    fn kind(self) -> io::ErrorKind {
51        match self {
52            Self::Appeared => io::ErrorKind::AlreadyExists,
53            Self::ReadOnly => io::ErrorKind::PermissionDenied,
54            Self::Directory => io::ErrorKind::IsADirectory,
55            Self::NotAFile => io::ErrorKind::InvalidInput,
56        }
57    }
58}
59
60impl std::fmt::Display for Refused {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(match self {
63            Self::Appeared => "a file appeared there during the export and was left as it was",
64            Self::ReadOnly => "the file is read-only",
65            Self::Directory => "it is a directory",
66            Self::NotAFile => "it is not a regular file",
67        })
68    }
69}
70
71impl std::error::Error for Refused {}
72
73impl From<Refused> for io::Error {
74    fn from(refused: Refused) -> Self {
75        io::Error::new(refused.kind(), refused)
76    }
77}
78
79/// A file being written to a temporary sibling of its destination.
80#[derive(Debug)]
81pub struct OutputFile {
82    temp: tempfile::NamedTempFile,
83    /// Where the file lands: the destination, or what a symlink there points to,
84    /// so a link is written through as it was before rather than replaced.
85    target: PathBuf,
86    overwrite: Overwrite,
87}
88
89impl OutputFile {
90    /// Start writing `path`. Fails early, before any work, when the destination
91    /// cannot be written under `overwrite`: something is there and replacing was
92    /// not agreed to, or it is read-only, a directory or not a regular file.
93    pub fn create(path: &Path, overwrite: Overwrite) -> io::Result<Self> {
94        let target = resolve_link(path)?;
95        replaceable(&target, overwrite)?;
96        let dir = match target.parent() {
97            Some(dir) if !dir.as_os_str().is_empty() => dir,
98            _ => Path::new("."),
99        };
100        // Hidden, named for datui and the destination, and ending in the
101        // destination's extension, which a writer that picks its encoding from
102        // the path needs. Created 0600 on Unix.
103        let temp = tempfile::Builder::new()
104            .prefix(".datui-")
105            .suffix(&temp_suffix(&target))
106            .tempfile_in(dir)?;
107        Ok(Self {
108            temp,
109            target,
110            overwrite,
111        })
112    }
113
114    /// The temporary file, for a writer that takes a `Write`.
115    pub fn file(&mut self) -> &mut File {
116        self.temp.as_file_mut()
117    }
118
119    /// The temporary file's path, for a writer that opens a path itself. It must
120    /// write to this path in place (truncating is fine), not replace it.
121    pub fn path(&self) -> &Path {
122        self.temp.path()
123    }
124
125    /// Sync the written file and move it into place. The caller has finished
126    /// every encoder and flushed every buffer over [`Self::file`]; an error
127    /// from those must stop it before it gets here.
128    ///
129    /// A replaced file's permission bits carry over on Unix; a new file gets the
130    /// mode a plain create would (0666 less the umask). Ownership, ACLs, extended
131    /// attributes and hard links of a replaced file do not carry over: the
132    /// destination is a new file. Under [`Overwrite::Forbid`] a file that
133    /// appeared meanwhile fails the commit with `AlreadyExists`, atomically where
134    /// the filesystem has a no-replace rename or hard links (see
135    /// [`Self::persist_new`]).
136    pub fn commit(self) -> io::Result<()> {
137        let Self {
138            temp,
139            target,
140            overwrite,
141        } = self;
142        temp.as_file().sync_all()?;
143        match overwrite {
144            Overwrite::Replace => {
145                let existing = replaceable(&target, Overwrite::Replace)?;
146                set_final_permissions(&temp, existing.as_ref())?;
147                temp.persist(&target).map_err(|e| e.error)?;
148                Ok(())
149            }
150            Overwrite::Forbid => {
151                set_final_permissions(&temp, None)?;
152                Self::persist_new(temp, &target)
153            }
154        }
155    }
156
157    /// Persist without replacing anything. Linux and macOS rename with
158    /// `RENAME_NOREPLACE`, falling back to a hard link; Windows moves without
159    /// `MOVEFILE_REPLACE_EXISTING`. All of these are atomic. A filesystem with
160    /// neither (FAT, some network mounts) gets a check and a plain rename: a
161    /// file created in the instant between the two is replaced.
162    fn persist_new(temp: tempfile::NamedTempFile, target: &Path) -> io::Result<()> {
163        match temp.persist_noclobber(target) {
164            Ok(_) => Ok(()),
165            Err(e) if e.error.kind() == io::ErrorKind::AlreadyExists => {
166                Err(Refused::Appeared.into())
167            }
168            Err(e) => {
169                if fs::symlink_metadata(target).is_ok() {
170                    return Err(Refused::Appeared.into());
171                }
172                e.file.persist(target).map(drop).map_err(|e| e.error)
173            }
174        }
175    }
176}
177
178/// `path`, or the file a symlink at `path` points to, followed to the end. A
179/// dangling link resolves to the missing file it names.
180fn resolve_link(path: &Path) -> io::Result<PathBuf> {
181    let mut path = path.to_path_buf();
182    // A bound rather than cycle detection: the OS refuses loops this long too.
183    for _ in 0..40 {
184        match fs::symlink_metadata(&path) {
185            Ok(meta) if meta.file_type().is_symlink() => {
186                let link = fs::read_link(&path)?;
187                path = match path.parent() {
188                    Some(dir) => dir.join(link),
189                    None => link,
190                };
191            }
192            _ => return Ok(path),
193        }
194    }
195    Err(io::Error::other(format!(
196        "{} is a loop of symbolic links",
197        path.display()
198    )))
199}
200
201/// The permissions of the regular, writable file at `target`, or None when
202/// nothing is there. Errors where `overwrite` forbids replacing what is there,
203/// or where it could not be written to in place before: a file datui may not
204/// write is refused rather than replaced by rename.
205fn replaceable(target: &Path, overwrite: Overwrite) -> io::Result<Option<fs::Permissions>> {
206    let meta = match fs::metadata(target) {
207        Ok(meta) => meta,
208        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
209        Err(e) => return Err(e),
210    };
211    if overwrite == Overwrite::Forbid {
212        return Err(Refused::Appeared.into());
213    }
214    if meta.is_dir() {
215        return Err(Refused::Directory.into());
216    }
217    if !meta.is_file() {
218        return Err(Refused::NotAFile.into());
219    }
220    if meta.permissions().readonly() {
221        return Err(Refused::ReadOnly.into());
222    }
223    // A write bit in the mode is not leave to write: another user's file in a
224    // directory of ours has one, and a rename would replace it where opening
225    // it fails. Opening for write, without truncating, asks the OS.
226    fs::OpenOptions::new().write(true).open(target)?;
227    Ok(Some(meta.permissions()))
228}
229
230/// `.datui-XXXXXX-<name>`, or `.datui-XXXXXX.<ext>` for a name too long to
231/// carry whole within the 255 bytes most filesystems allow.
232fn temp_suffix(target: &Path) -> String {
233    let name = target
234        .file_name()
235        .map(|n| n.to_string_lossy().into_owned())
236        .unwrap_or_default();
237    if name.len() <= 200 {
238        return format!("-{name}");
239    }
240    match target.extension() {
241        Some(ext) if ext.len() <= 32 => format!(".{}", ext.to_string_lossy()),
242        _ => String::new(),
243    }
244}
245
246/// Give the temporary file its final mode before it becomes visible under the
247/// destination's name: the replaced file's, or a fresh file's.
248#[cfg(unix)]
249fn set_final_permissions(
250    temp: &tempfile::NamedTempFile,
251    existing: Option<&fs::Permissions>,
252) -> io::Result<()> {
253    use std::os::unix::fs::PermissionsExt;
254    let permissions = match existing {
255        Some(permissions) => permissions.clone(),
256        None => fs::Permissions::from_mode(fresh_mode(temp.path())?),
257    };
258    temp.as_file().set_permissions(permissions)
259}
260
261/// Windows has one permission bit, read-only, which [`replaceable`] refuses;
262/// the replacement keeps the default attributes and the directory's ACLs.
263#[cfg(not(unix))]
264fn set_final_permissions(
265    _temp: &tempfile::NamedTempFile,
266    _existing: Option<&fs::Permissions>,
267) -> io::Result<()> {
268    Ok(())
269}
270
271/// The mode a plain `File::create` would give a file beside `temp`: 0666 less
272/// the umask. Read from an empty probe rather than by setting the umask, which
273/// is process-wide and would race other threads creating files.
274#[cfg(unix)]
275fn fresh_mode(temp: &Path) -> io::Result<u32> {
276    use std::os::unix::fs::PermissionsExt;
277    let dir = match temp.parent() {
278        Some(dir) if !dir.as_os_str().is_empty() => dir,
279        _ => Path::new("."),
280    };
281    let probe = tempfile::Builder::new()
282        .prefix(".datui-mode-")
283        .permissions(fs::Permissions::from_mode(0o666))
284        .tempfile_in(dir)?;
285    Ok(probe.as_file().metadata()?.permissions().mode() & 0o777)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::io::Write;
292
293    /// Names in `dir` other than `keep`: what an export left behind.
294    fn leftovers(dir: &Path, keep: &[&str]) -> Vec<String> {
295        fs::read_dir(dir)
296            .unwrap()
297            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
298            .filter(|name| !keep.contains(&name.as_str()))
299            .collect()
300    }
301
302    #[test]
303    fn a_commit_writes_a_new_file() {
304        let dir = tempfile::tempdir().unwrap();
305        let path = dir.path().join("out.csv");
306        let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
307        out.file().write_all(b"a\n1\n").unwrap();
308        assert!(!path.exists(), "nothing lands before the commit");
309        out.commit().unwrap();
310        assert_eq!(fs::read(&path).unwrap(), b"a\n1\n");
311        assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
312    }
313
314    #[test]
315    fn an_uncommitted_new_file_leaves_nothing() {
316        let dir = tempfile::tempdir().unwrap();
317        let path = dir.path().join("out.csv");
318        let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
319        out.file().write_all(b"partial").unwrap();
320        drop(out);
321        assert!(leftovers(dir.path(), &[]).is_empty());
322    }
323
324    #[test]
325    fn an_uncommitted_replacement_keeps_the_old_bytes() {
326        let dir = tempfile::tempdir().unwrap();
327        let path = dir.path().join("out.csv");
328        fs::write(&path, b"old").unwrap();
329        let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
330        out.file().write_all(b"new and partial").unwrap();
331        drop(out);
332        assert_eq!(fs::read(&path).unwrap(), b"old");
333        assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
334    }
335
336    /// A writer that panics part way, as a worker thread can, unwinds through
337    /// the drop: the old file stays and the temporary file goes.
338    #[test]
339    fn a_panic_while_writing_keeps_the_old_file() {
340        let dir = tempfile::tempdir().unwrap();
341        let path = dir.path().join("out.csv");
342        fs::write(&path, b"old").unwrap();
343        let result = std::panic::catch_unwind(|| {
344            let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
345            out.file().write_all(b"new and partial").unwrap();
346            panic!("the writer failed");
347        });
348        assert!(result.is_err());
349        assert_eq!(fs::read(&path).unwrap(), b"old");
350        assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
351    }
352
353    #[test]
354    fn an_approved_replacement_lands() {
355        let dir = tempfile::tempdir().unwrap();
356        let path = dir.path().join("out.csv");
357        fs::write(&path, b"old").unwrap();
358        let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
359        out.file().write_all(b"new").unwrap();
360        out.commit().unwrap();
361        assert_eq!(fs::read(&path).unwrap(), b"new");
362        assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
363    }
364
365    #[test]
366    fn an_existing_file_is_not_replaced_without_approval() {
367        let dir = tempfile::tempdir().unwrap();
368        let path = dir.path().join("out.csv");
369        fs::write(&path, b"old").unwrap();
370        let err = OutputFile::create(&path, Overwrite::Forbid).unwrap_err();
371        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
372        assert_eq!(fs::read(&path).unwrap(), b"old");
373        assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
374    }
375
376    /// The race the confirmation cannot see: nothing was there when asked, and
377    /// something is by the time the file is done.
378    #[test]
379    fn a_file_that_appears_before_the_commit_is_left_alone() {
380        let dir = tempfile::tempdir().unwrap();
381        let path = dir.path().join("out.csv");
382        let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
383        out.file().write_all(b"ours").unwrap();
384        fs::write(&path, b"theirs").unwrap();
385        let err = out.commit().unwrap_err();
386        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
387        assert_eq!(fs::read(&path).unwrap(), b"theirs");
388        assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
389    }
390
391    /// Approval covers what is there at the commit, including a file that was
392    /// deleted meanwhile: the export is created.
393    #[test]
394    fn an_approved_replacement_of_a_vanished_file_creates_it() {
395        let dir = tempfile::tempdir().unwrap();
396        let path = dir.path().join("out.csv");
397        fs::write(&path, b"old").unwrap();
398        let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
399        out.file().write_all(b"new").unwrap();
400        fs::remove_file(&path).unwrap();
401        out.commit().unwrap();
402        assert_eq!(fs::read(&path).unwrap(), b"new");
403    }
404
405    #[test]
406    fn a_read_only_file_is_refused_and_kept() {
407        let dir = tempfile::tempdir().unwrap();
408        let path = dir.path().join("out.csv");
409        fs::write(&path, b"old").unwrap();
410        let writable = fs::metadata(&path).unwrap().permissions();
411        let mut read_only = writable.clone();
412        read_only.set_readonly(true);
413        fs::set_permissions(&path, read_only).unwrap();
414        let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
415        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
416        assert_eq!(fs::read(&path).unwrap(), b"old");
417        assert!(fs::metadata(&path).unwrap().permissions().readonly());
418        assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
419        // Writable again, or the temp dir cannot be removed on Windows.
420        fs::set_permissions(&path, writable).unwrap();
421    }
422
423    #[test]
424    fn a_directory_is_refused() {
425        let dir = tempfile::tempdir().unwrap();
426        let path = dir.path().join("out.csv");
427        fs::create_dir(&path).unwrap();
428        let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
429        assert_eq!(err.kind(), io::ErrorKind::IsADirectory);
430        assert!(path.is_dir());
431    }
432
433    #[test]
434    fn a_missing_directory_fails_before_any_work() {
435        let dir = tempfile::tempdir().unwrap();
436        let path = dir.path().join("missing").join("out.csv");
437        let err = OutputFile::create(&path, Overwrite::Forbid).unwrap_err();
438        assert_eq!(err.kind(), io::ErrorKind::NotFound);
439    }
440
441    /// A writer that opens the path itself writes in place,
442    /// and the extension it reads its encoding from is the destination's.
443    #[test]
444    fn the_temporary_path_keeps_the_extension() {
445        let dir = tempfile::tempdir().unwrap();
446        let path = dir.path().join("chart.png");
447        let out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
448        assert_eq!(out.path().extension().unwrap(), "png");
449        assert_eq!(out.path().parent(), Some(dir.path()));
450        fs::write(out.path(), b"png bytes").unwrap();
451        out.commit().unwrap();
452        assert_eq!(fs::read(&path).unwrap(), b"png bytes");
453    }
454
455    #[test]
456    fn a_long_name_keeps_its_extension() {
457        let name = format!("{}.parquet", "x".repeat(240));
458        assert_eq!(temp_suffix(Path::new(&name)), ".parquet");
459        assert_eq!(temp_suffix(Path::new("out.csv")), "-out.csv");
460    }
461
462    #[cfg(unix)]
463    mod unix {
464        use super::*;
465        use std::os::unix::fs::PermissionsExt;
466
467        fn mode(path: &Path) -> u32 {
468            fs::metadata(path).unwrap().permissions().mode() & 0o7777
469        }
470
471        #[test]
472        fn a_replacement_keeps_the_old_mode() {
473            let dir = tempfile::tempdir().unwrap();
474            let path = dir.path().join("out.csv");
475            fs::write(&path, b"old").unwrap();
476            fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
477            let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
478            out.file().write_all(b"new").unwrap();
479            out.commit().unwrap();
480            assert_eq!(mode(&path), 0o640);
481        }
482
483        #[test]
484        fn a_failed_replacement_keeps_the_old_mode() {
485            let dir = tempfile::tempdir().unwrap();
486            let path = dir.path().join("out.csv");
487            fs::write(&path, b"old").unwrap();
488            fs::set_permissions(&path, fs::Permissions::from_mode(0o604)).unwrap();
489            let out = OutputFile::create(&path, Overwrite::Replace).unwrap();
490            drop(out);
491            assert_eq!(mode(&path), 0o604);
492            assert_eq!(fs::read(&path).unwrap(), b"old");
493        }
494
495        /// Writable by its group only: not by us, its owner, though the mode
496        /// is not read-only. Writing in place was refused, so replacing by
497        /// rename is too.
498        #[test]
499        fn a_file_we_may_not_write_is_refused() {
500            let dir = tempfile::tempdir().unwrap();
501            let path = dir.path().join("out.csv");
502            fs::write(&path, b"old").unwrap();
503            fs::set_permissions(&path, fs::Permissions::from_mode(0o060)).unwrap();
504            if fs::OpenOptions::new().write(true).open(&path).is_ok() {
505                return; // root writes anything
506            }
507            let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
508            assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
509            assert_eq!(mode(&path), 0o060);
510            assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
511        }
512
513        /// Private while written; a plain create's mode once it lands.
514        #[test]
515        fn a_new_file_is_private_until_it_lands() {
516            let dir = tempfile::tempdir().unwrap();
517            let path = dir.path().join("out.csv");
518            let out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
519            assert_eq!(mode(out.path()), 0o600);
520            let plain = dir.path().join("plain");
521            File::create(&plain).unwrap();
522            out.commit().unwrap();
523            assert_eq!(mode(&path), mode(&plain));
524        }
525
526        /// A link is written through, as `File::create` did, not replaced. The
527        /// temporary file sits beside the file, not the link, so the rename
528        /// stays on the file's filesystem.
529        #[test]
530        fn a_symlink_is_written_through() {
531            let dir = tempfile::tempdir().unwrap();
532            let data = dir.path().join("data");
533            let links = dir.path().join("links");
534            fs::create_dir(&data).unwrap();
535            fs::create_dir(&links).unwrap();
536            let real = data.join("real.csv");
537            let link = links.join("link.csv");
538            fs::write(&real, b"old").unwrap();
539            std::os::unix::fs::symlink("../data/real.csv", &link).unwrap();
540            let mut out = OutputFile::create(&link, Overwrite::Replace).unwrap();
541            assert_eq!(
542                out.path().parent().unwrap().canonicalize().unwrap(),
543                data.canonicalize().unwrap()
544            );
545            out.file().write_all(b"new").unwrap();
546            out.commit().unwrap();
547            assert!(leftovers(&data, &["real.csv"]).is_empty());
548            assert!(leftovers(&links, &["link.csv"]).is_empty());
549            assert!(
550                fs::symlink_metadata(&link)
551                    .unwrap()
552                    .file_type()
553                    .is_symlink()
554            );
555            assert_eq!(fs::read(&real).unwrap(), b"new");
556        }
557    }
558}