Skip to main content

datui_lib/export/
output_file.rs

1//! A file written whole or not at all. [`OutputFile`] writes a private temp file beside
2//! the destination and moves it into place in [`OutputFile::commit`]; until then a
3//! failure leaves the old file (bytes and permissions) untouched and a new export
4//! leaves nothing; dropping uncommitted (even in a panic) removes the temp file. The file
5//! is synced before the rename (some errors surface only then); the rename is not, so a
6//! power cut right after can lose it. Every user-facing file goes through here: exports,
7//! the Data Quality report, chart images.
8
9use std::fs::{self, File};
10use std::io;
11use std::path::{Path, PathBuf};
12
13/// What the commit may do to a file already at the destination.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Overwrite {
16    /// Nothing was there when the export was asked for. A file that appears
17    /// before the commit is left alone and the export fails.
18    Forbid,
19    /// The user agreed to replace the file there.
20    Replace,
21}
22
23/// Why [`OutputFile`] would not write a destination. Carried inside the
24/// `io::Error`, whose kind says the same to code that matches on kinds; its
25/// text is for the user and leaves the path to the caller.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Refused {
28    /// Something is at the path and replacing it was not agreed to: the
29    /// overwrite was not asked about, because nothing was there then.
30    Appeared,
31    ReadOnly,
32    Directory,
33    NotAFile,
34}
35
36impl Refused {
37    /// The refusal inside `error`, if it is one of these rather than the OS's.
38    pub fn of(error: &io::Error) -> Option<Self> {
39        error.get_ref()?.downcast_ref::<Self>().copied()
40    }
41
42    fn kind(self) -> io::ErrorKind {
43        match self {
44            Self::Appeared => io::ErrorKind::AlreadyExists,
45            Self::ReadOnly => io::ErrorKind::PermissionDenied,
46            Self::Directory => io::ErrorKind::IsADirectory,
47            Self::NotAFile => io::ErrorKind::InvalidInput,
48        }
49    }
50}
51
52impl std::fmt::Display for Refused {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.write_str(match self {
55            Self::Appeared => "a file appeared there during the export and was left as it was",
56            Self::ReadOnly => "the file is read-only",
57            Self::Directory => "it is a directory",
58            Self::NotAFile => "it is not a regular file",
59        })
60    }
61}
62
63impl std::error::Error for Refused {}
64
65impl From<Refused> for io::Error {
66    fn from(refused: Refused) -> Self {
67        io::Error::new(refused.kind(), refused)
68    }
69}
70
71/// A file being written to a temporary sibling of its destination.
72#[derive(Debug)]
73pub struct OutputFile {
74    temp: tempfile::NamedTempFile,
75    /// Where the file lands: the destination, or what a symlink there points to,
76    /// so a link is written through as it was before rather than replaced.
77    target: PathBuf,
78    overwrite: Overwrite,
79}
80
81impl OutputFile {
82    /// Start writing `path`. Fails early, before any work, when the destination
83    /// cannot be written under `overwrite`: something is there and replacing was
84    /// not agreed to, or it is read-only, a directory or not a regular file.
85    pub fn create(path: &Path, overwrite: Overwrite) -> io::Result<Self> {
86        let target = resolve_link(path)?;
87        replaceable(&target, overwrite)?;
88        let dir = match target.parent() {
89            Some(dir) if !dir.as_os_str().is_empty() => dir,
90            _ => Path::new("."),
91        };
92        // Hidden, named for datui and the destination, and ending in the
93        // destination's extension, which a writer that picks its encoding from
94        // the path needs. Created 0600 on Unix.
95        let temp = tempfile::Builder::new()
96            .prefix(".datui-")
97            .suffix(&temp_suffix(&target))
98            .tempfile_in(dir)?;
99        Ok(Self {
100            temp,
101            target,
102            overwrite,
103        })
104    }
105
106    /// The temporary file, for a writer that takes a `Write`.
107    pub fn file(&mut self) -> &mut File {
108        self.temp.as_file_mut()
109    }
110
111    /// The temporary file's path, for a writer that opens a path itself. It must
112    /// write to this path in place (truncating is fine), not replace it.
113    pub fn path(&self) -> &Path {
114        self.temp.path()
115    }
116
117    /// Sync the file and move it into place; the caller has finished every encoder and
118    /// flush over [`Self::file`]. A replaced file's permission bits carry over on Unix (a new
119    /// one gets 0666 less umask); ownership, ACLs, xattrs and hard links do not. Under
120    /// [`Overwrite::Forbid`] a file appearing meanwhile fails with `AlreadyExists`,
121    /// atomically where supported (see `Self::persist_new`).
122    pub fn commit(self) -> io::Result<()> {
123        let Self {
124            temp,
125            target,
126            overwrite,
127        } = self;
128        temp.as_file().sync_all()?;
129        match overwrite {
130            Overwrite::Replace => {
131                let existing = replaceable(&target, Overwrite::Replace)?;
132                set_final_permissions(&temp, existing.as_ref())?;
133                temp.persist(&target).map_err(|e| e.error)?;
134                Ok(())
135            }
136            Overwrite::Forbid => {
137                set_final_permissions(&temp, None)?;
138                Self::persist_new(temp, &target)
139            }
140        }
141    }
142
143    /// Persist without replacing: `RENAME_NOREPLACE` on Linux and macOS (falling back to a
144    /// hard link), no `MOVEFILE_REPLACE_EXISTING` on Windows, all atomic. Filesystems with
145    /// neither (FAT, some network mounts) check then rename, racing a file created between.
146    fn persist_new(temp: tempfile::NamedTempFile, target: &Path) -> io::Result<()> {
147        match temp.persist_noclobber(target) {
148            Ok(_) => Ok(()),
149            Err(e) if e.error.kind() == io::ErrorKind::AlreadyExists => {
150                Err(Refused::Appeared.into())
151            }
152            Err(e) => {
153                if fs::symlink_metadata(target).is_ok() {
154                    return Err(Refused::Appeared.into());
155                }
156                e.file.persist(target).map(drop).map_err(|e| e.error)
157            }
158        }
159    }
160}
161
162/// `path`, or the file a symlink at `path` points to, followed to the end. A
163/// dangling link resolves to the missing file it names.
164fn resolve_link(path: &Path) -> io::Result<PathBuf> {
165    let mut path = path.to_path_buf();
166    // A bound rather than cycle detection: the OS refuses loops this long too.
167    for _ in 0..40 {
168        match fs::symlink_metadata(&path) {
169            Ok(meta) if meta.file_type().is_symlink() => {
170                let link = fs::read_link(&path)?;
171                path = match path.parent() {
172                    Some(dir) => dir.join(link),
173                    None => link,
174                };
175            }
176            _ => return Ok(path),
177        }
178    }
179    Err(io::Error::other(format!(
180        "{} is a loop of symbolic links",
181        path.display()
182    )))
183}
184
185/// The permissions of the regular, writable file at `target`, or None when
186/// nothing is there. Errors where `overwrite` forbids replacing what is there,
187/// or where it could not be written to in place before: a file datui may not
188/// write is refused rather than replaced by rename.
189fn replaceable(target: &Path, overwrite: Overwrite) -> io::Result<Option<fs::Permissions>> {
190    let meta = match fs::metadata(target) {
191        Ok(meta) => meta,
192        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
193        Err(e) => return Err(e),
194    };
195    if overwrite == Overwrite::Forbid {
196        return Err(Refused::Appeared.into());
197    }
198    if meta.is_dir() {
199        return Err(Refused::Directory.into());
200    }
201    if !meta.is_file() {
202        return Err(Refused::NotAFile.into());
203    }
204    if meta.permissions().readonly() {
205        return Err(Refused::ReadOnly.into());
206    }
207    // A write bit in the mode is not leave to write: another user's file in a
208    // directory of ours has one, and a rename would replace it where opening
209    // it fails. Opening for write, without truncating, asks the OS.
210    fs::OpenOptions::new().write(true).open(target)?;
211    Ok(Some(meta.permissions()))
212}
213
214/// `.datui-XXXXXX-<name>`, or `.datui-XXXXXX.<ext>` for a name too long to
215/// carry whole within the 255 bytes most filesystems allow.
216fn temp_suffix(target: &Path) -> String {
217    let name = target
218        .file_name()
219        .map(|n| n.to_string_lossy().into_owned())
220        .unwrap_or_default();
221    if name.len() <= 200 {
222        return format!("-{name}");
223    }
224    match target.extension() {
225        Some(ext) if ext.len() <= 32 => format!(".{}", ext.to_string_lossy()),
226        _ => String::new(),
227    }
228}
229
230/// Give the temporary file its final mode before it becomes visible under the
231/// destination's name: the replaced file's, or a fresh file's.
232#[cfg(unix)]
233fn set_final_permissions(
234    temp: &tempfile::NamedTempFile,
235    existing: Option<&fs::Permissions>,
236) -> io::Result<()> {
237    use std::os::unix::fs::PermissionsExt;
238    let permissions = match existing {
239        Some(permissions) => permissions.clone(),
240        None => fs::Permissions::from_mode(fresh_mode(temp.path())?),
241    };
242    temp.as_file().set_permissions(permissions)
243}
244
245/// Windows has one permission bit, read-only, which [`replaceable`] refuses;
246/// the replacement keeps the default attributes and the directory's ACLs.
247#[cfg(not(unix))]
248fn set_final_permissions(
249    _temp: &tempfile::NamedTempFile,
250    _existing: Option<&fs::Permissions>,
251) -> io::Result<()> {
252    Ok(())
253}
254
255/// The mode a plain `File::create` would give a file beside `temp`: 0666 less
256/// the umask. Read from an empty probe rather than by setting the umask, which
257/// is process-wide and would race other threads creating files.
258#[cfg(unix)]
259fn fresh_mode(temp: &Path) -> io::Result<u32> {
260    use std::os::unix::fs::PermissionsExt;
261    let dir = match temp.parent() {
262        Some(dir) if !dir.as_os_str().is_empty() => dir,
263        _ => Path::new("."),
264    };
265    let probe = tempfile::Builder::new()
266        .prefix(".datui-mode-")
267        .permissions(fs::Permissions::from_mode(0o666))
268        .tempfile_in(dir)?;
269    Ok(probe.as_file().metadata()?.permissions().mode() & 0o777)
270}
271
272#[cfg(test)]
273mod tests;