Skip to main content

rmut_core/
scratch.rs

1//! Private temporary files: a part for a mailcap `%s` command, a
2//! signature for gpg, a draft for the editor. They live in the shared
3//! temp dir, so each is created fresh (O_EXCL: never through a link or
4//! over a file someone else put there) and readable by us alone.
5
6use std::fs::OpenOptions;
7use std::io::{ErrorKind, Write as _};
8use std::os::unix::fs::OpenOptionsExt as _;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicUsize, Ordering};
11
12use anyhow::{Context, Result, bail};
13
14/// `bytes` in a new file `rmut-<what>-<unique><suffix>` under the temp
15/// dir, mode 0600. The caller removes it.
16pub fn write(what: &str, suffix: &str, bytes: &[u8]) -> Result<PathBuf> {
17    static COUNTER: AtomicUsize = AtomicUsize::new(0);
18    for _ in 0..100 {
19        // The clock makes the name hard to guess ahead of time; a taken
20        // name is skipped rather than reused either way.
21        let nanos = std::time::SystemTime::now()
22            .duration_since(std::time::UNIX_EPOCH)
23            .map_or(0, |d| d.subsec_nanos());
24        let path = std::env::temp_dir().join(format!(
25            "rmut-{what}-{}-{}-{nanos:08x}{suffix}",
26            std::process::id(),
27            COUNTER.fetch_add(1, Ordering::Relaxed),
28        ));
29        let mut file = match OpenOptions::new()
30            .write(true)
31            .create_new(true)
32            .mode(0o600)
33            .open(&path)
34        {
35            Ok(file) => file,
36            Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
37            Err(e) => return Err(e).with_context(|| format!("creating {}", path.display())),
38        };
39        if let Err(e) = file.write_all(bytes) {
40            let _ = std::fs::remove_file(&path);
41            return Err(e).with_context(|| format!("writing {}", path.display()));
42        }
43        return Ok(path);
44    }
45    bail!(
46        "no free temporary file name in {}",
47        std::env::temp_dir().display()
48    )
49}
50
51/// Replace `path` with `bytes` whole, readable by us alone: written
52/// beside it (mode 0600, synced) and renamed over it, so a crash
53/// leaves the old file or the new one. For what rmut keeps of the
54/// user's own, such as the prompt history.
55pub fn save_private(path: &Path, bytes: &[u8]) -> Result<()> {
56    static COUNTER: AtomicUsize = AtomicUsize::new(0);
57    let dir = path.parent().filter(|d| !d.as_os_str().is_empty());
58    if let Some(dir) = dir {
59        std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
60    }
61    let name = path
62        .file_name()
63        .with_context(|| format!("{} names no file", path.display()))?;
64    let tmp = path.with_file_name(format!(
65        ".{}.rmut-new-{}-{}",
66        name.to_string_lossy(),
67        std::process::id(),
68        COUNTER.fetch_add(1, Ordering::Relaxed)
69    ));
70    let written = (|| -> std::io::Result<()> {
71        let mut file = OpenOptions::new()
72            .write(true)
73            .create_new(true)
74            .mode(0o600)
75            .open(&tmp)?;
76        file.write_all(bytes)?;
77        file.sync_all()?;
78        std::fs::rename(&tmp, path)
79    })();
80    if let Err(err) = written {
81        let _ = std::fs::remove_file(&tmp);
82        return Err(err).with_context(|| format!("writing {}", path.display()));
83    }
84    Ok(())
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::os::unix::fs::PermissionsExt as _;
91
92    #[test]
93    fn private_and_fresh_each_time() {
94        let a = write("scratch-test", ".txt", b"one").unwrap();
95        let b = write("scratch-test", ".txt", b"two").unwrap();
96        assert_ne!(a, b);
97        assert!(a.to_str().unwrap().ends_with(".txt"));
98        assert_eq!(std::fs::read(&a).unwrap(), b"one");
99        let mode = std::fs::metadata(&a).unwrap().permissions().mode();
100        assert_eq!(mode & 0o777, 0o600);
101        let _ = std::fs::remove_file(a);
102        let _ = std::fs::remove_file(b);
103    }
104
105    #[test]
106    fn save_private_replaces_whole_and_private() {
107        let dir = tempfile::tempdir().unwrap();
108        let path = dir.path().join("sub/history");
109        save_private(&path, b"one").unwrap();
110        save_private(&path, b"two").unwrap();
111        assert_eq!(std::fs::read(&path).unwrap(), b"two");
112        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
113        assert_eq!(mode & 0o777, 0o600);
114        let left: Vec<_> = std::fs::read_dir(dir.path().join("sub")).unwrap().collect();
115        assert_eq!(left.len(), 1, "no temporary left behind");
116    }
117
118    #[test]
119    fn never_writes_through_a_planted_link() {
120        // create_new refuses a name that exists, a dangling symlink
121        // included, so a link at the path cannot redirect the write.
122        let dir = tempfile::tempdir().unwrap();
123        let target = dir.path().join("victim");
124        let link = dir.path().join("link");
125        std::os::unix::fs::symlink(&target, &link).unwrap();
126        let err = OpenOptions::new()
127            .write(true)
128            .create_new(true)
129            .mode(0o600)
130            .open(&link)
131            .unwrap_err();
132        assert_eq!(err.kind(), ErrorKind::AlreadyExists);
133        assert!(!target.exists());
134    }
135}