Skip to main content

lean_ctx/core/
secure_fs.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Owner-only secret files.
3
4/// Writes a secret file atomically (tmp + rename) with owner-only permissions
5/// (0o600 on Unix), so secrets are never world-readable — not even
6/// transiently between create and chmod.
7pub(crate) fn write_secret_file(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
8    use std::io::Write;
9
10    let parent = path
11        .parent()
12        .ok_or_else(|| std::io::Error::other("secret path has no parent directory"))?;
13    let name = path
14        .file_name()
15        .ok_or_else(|| std::io::Error::other("secret path has no file name"))?
16        .to_string_lossy();
17    // A random part keeps two threads of one process writing the same path
18    // from sharing a temporary file.
19    let mut nonce = [0u8; 8];
20    getrandom::fill(&mut nonce).map_err(std::io::Error::other)?;
21    let tmp = parent.join(format!(
22        ".{name}.tmp.{}.{}",
23        std::process::id(),
24        hex::encode(nonce)
25    ));
26
27    let mut opts = std::fs::OpenOptions::new();
28    opts.write(true).create_new(true);
29    #[cfg(unix)]
30    {
31        use std::os::unix::fs::OpenOptionsExt;
32        opts.mode(0o600);
33    }
34
35    let result = (|| {
36        let mut f = opts.open(&tmp)?;
37        f.write_all(bytes)?;
38        f.sync_all()?;
39        drop(f);
40        // One atomic replace on every platform. Removing the target first on
41        // Windows raced with concurrent writers: a rename onto a target whose
42        // delete is still pending fails with ACCESS_DENIED.
43        crate::core::atomic_fs::replace_file(&tmp, path)
44    })();
45
46    if result.is_err() {
47        let _ = std::fs::remove_file(&tmp);
48    }
49    result
50}
51
52/// Creates `dir` (and its parents) and restricts it to its owner (0700 on Unix). Errors name
53/// the step and the path.
54pub fn private_dir(dir: &std::path::Path) -> std::io::Result<()> {
55    let step = |what: &str, e: std::io::Error| {
56        std::io::Error::new(e.kind(), format!("{what} {}: {e}", dir.display()))
57    };
58    std::fs::create_dir_all(dir).map_err(|e| step("create", e))?;
59    restrict_to_owner(dir).map_err(|e| step("restrict", e))
60}
61
62/// Sets `path` to mode 0700 on Unix, but only when its mode differs (GH #2227).
63///
64/// A `chmod` updates the inode's ctime and raises an inotify `IN_ATTRIB` event
65/// even when the mode stays the same, so an unconditional chmod on a path that
66/// runs per call floods watchers, backup and sync tools. Like `chmod`, this
67/// follows a symlink at `path`. A no-op on other platforms.
68pub fn restrict_to_owner(path: &std::path::Path) -> std::io::Result<()> {
69    #[cfg(unix)]
70    {
71        use std::os::unix::fs::PermissionsExt as _;
72        if std::fs::metadata(path)?.permissions().mode() & 0o7777 != 0o700 {
73            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
74        }
75    }
76    #[cfg(not(unix))]
77    let _ = path;
78    Ok(())
79}
80
81/// Writes `dir/name` so that it is never readable by others and never seen half-written:
82/// [`private_dir`] and `write_secret_file`.
83pub fn write_private(dir: &std::path::Path, name: &str, bytes: &[u8]) -> std::io::Result<()> {
84    private_dir(dir)?;
85    write_secret_file(&dir.join(name), bytes).map_err(|e| {
86        std::io::Error::new(e.kind(), format!("write {name} in {}: {e}", dir.display()))
87    })
88}
89
90#[cfg(test)]
91mod tests {
92    use super::write_secret_file;
93
94    #[test]
95    fn concurrent_writers_of_one_path_all_succeed_and_leave_one_whole_file() {
96        let dir = tempfile::tempdir().unwrap();
97        let path = dir.path().join("secret.json");
98        let writers: Vec<_> = (0..8u8)
99            .map(|i| {
100                let path = path.clone();
101                std::thread::spawn(move || write_secret_file(&path, &[i; 64]))
102            })
103            .collect();
104        for writer in writers {
105            writer.join().unwrap().unwrap();
106        }
107        let written = std::fs::read(&path).unwrap();
108        assert_eq!(written.len(), 64);
109        assert!(written.iter().all(|byte| *byte == written[0]));
110        let names: Vec<_> = std::fs::read_dir(dir.path())
111            .unwrap()
112            .map(|entry| entry.unwrap().file_name())
113            .collect();
114        assert_eq!(names, ["secret.json"], "no temporary file is left");
115        #[cfg(unix)]
116        {
117            use std::os::unix::fs::PermissionsExt;
118            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
119            assert_eq!(mode & 0o777, 0o600);
120        }
121    }
122
123    /// GH #2227: a directory that is already 0700 is not chmodded again, so
124    /// its ctime stays put and watchers see no `IN_ATTRIB` event.
125    #[cfg(unix)]
126    #[test]
127    fn restrict_to_owner_leaves_a_private_dir_untouched() {
128        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
129        let tmp = tempfile::tempdir().unwrap();
130        let dir = tmp.path().join("data");
131        std::fs::create_dir(&dir).unwrap();
132        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
133        let ctime = |p: &std::path::Path| {
134            let m = std::fs::metadata(p).unwrap();
135            (m.ctime(), m.ctime_nsec())
136        };
137        let before = ctime(&dir);
138        // Coarse ctime clocks: wait long enough for a chmod to be visible.
139        std::thread::sleep(std::time::Duration::from_millis(1100));
140        super::restrict_to_owner(&dir).unwrap();
141        super::private_dir(&dir).unwrap();
142        crate::core::data_dir::ensure_dir_permissions(&dir);
143        assert_eq!(
144            ctime(&dir),
145            before,
146            "an already private dir is not chmodded"
147        );
148    }
149
150    /// GH #2227: the check does not weaken the guarantee — a 0755 directory
151    /// is still tightened to 0700.
152    #[cfg(unix)]
153    #[test]
154    fn restrict_to_owner_still_tightens_an_open_dir() {
155        use std::os::unix::fs::PermissionsExt as _;
156        let tmp = tempfile::tempdir().unwrap();
157        let mode =
158            |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o7777;
159        for (i, restrict) in [
160            (|p: &std::path::Path| super::restrict_to_owner(p).unwrap()) as fn(&std::path::Path),
161            |p| super::private_dir(p).unwrap(),
162            |p| crate::core::data_dir::ensure_dir_permissions(p),
163        ]
164        .into_iter()
165        .enumerate()
166        {
167            let dir = tmp.path().join(format!("open-{i}"));
168            std::fs::create_dir(&dir).unwrap();
169            std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
170            restrict(&dir);
171            assert_eq!(mode(&dir), 0o700);
172        }
173    }
174}