Skip to main content

ready_set_sdk/
fs.rs

1//! Filesystem helpers used by both the dispatcher and plugins.
2
3use std::fs::{self, File};
4use std::io::{Read, Write};
5use std::path::Path;
6
7use sha2::{Digest, Sha256};
8
9use crate::error::{Error, Result};
10
11/// Atomically write `bytes` to `path`.
12///
13/// Strategy: write to `<path>.<rand>.tmp` in the same directory, fsync the
14/// file, then rename it onto the destination. The rename is atomic on every
15/// supported platform (POSIX rename is atomic; Windows `MoveFileEx` with
16/// `MOVEFILE_REPLACE_EXISTING` behaves likewise).
17///
18/// # Errors
19///
20/// Returns the underlying I/O error if any step fails. The temporary file is
21/// removed before returning the error to avoid leaking droppings.
22pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
23    let parent = path
24        .parent()
25        .ok_or_else(|| Error::Other(format!("path has no parent: {}", path.display())))?;
26    fs::create_dir_all(parent)?;
27
28    let tmp = tmp_path(path)?;
29
30    let result = (|| -> Result<()> {
31        let mut f = File::create(&tmp)?;
32        f.write_all(bytes)?;
33        f.sync_all()?;
34        drop(f);
35        fs::rename(&tmp, path)?;
36        Ok(())
37    })();
38
39    if result.is_err() {
40        drop(fs::remove_file(&tmp));
41    }
42    result
43}
44
45fn tmp_path(path: &Path) -> Result<std::path::PathBuf> {
46    let parent = path
47        .parent()
48        .ok_or_else(|| Error::Other(format!("path has no parent: {}", path.display())))?;
49    let name = path
50        .file_name()
51        .ok_or_else(|| Error::Other(format!("path has no file name: {}", path.display())))?
52        .to_os_string();
53
54    let mut buf = [0_u8; 8];
55    getrandom::fill(&mut buf).map_err(|e| Error::Other(format!("getrandom: {e}")))?;
56    let mut tmp_name = std::ffi::OsString::new();
57    tmp_name.push(&name);
58    tmp_name.push(format!(".{}.tmp", encode_hex_lower(&buf)));
59    Ok(parent.join(tmp_name))
60}
61
62/// Compute the SHA-256 of `path` as a lowercase hex string.
63///
64/// # Errors
65///
66/// Forwards I/O errors from opening or reading the file.
67pub fn sha256_file(path: &Path) -> Result<String> {
68    let mut f = File::open(path)?;
69    let mut hasher = Sha256::new();
70    let mut buf = vec![0_u8; 64 * 1024].into_boxed_slice();
71    loop {
72        let n = f.read(&mut buf)?;
73        if n == 0 {
74            break;
75        }
76        hasher.update(&buf[..n]);
77    }
78    Ok(encode_hex_lower(&hasher.finalize()))
79}
80
81/// Compute the SHA-256 of `bytes` as a lowercase hex string.
82#[must_use]
83pub fn sha256_bytes(bytes: &[u8]) -> String {
84    let mut hasher = Sha256::new();
85    hasher.update(bytes);
86    encode_hex_lower(&hasher.finalize())
87}
88
89pub(crate) fn encode_hex_lower(bytes: &[u8]) -> String {
90    const HEX: &[u8; 16] = b"0123456789abcdef";
91    let mut out = String::with_capacity(bytes.len() * 2);
92    for b in bytes {
93        out.push(HEX[(b >> 4) as usize] as char);
94        out.push(HEX[(b & 0x0f) as usize] as char);
95    }
96    out
97}
98
99/// Restrict `path` to the current user (mode `0600`) on Unix.
100///
101/// # Errors
102///
103/// Forwards I/O errors from `chmod`.
104#[cfg(unix)]
105pub fn restrict_to_user(path: &Path) -> Result<()> {
106    use std::os::unix::fs::PermissionsExt;
107    let mut perms = fs::metadata(path)?.permissions();
108    perms.set_mode(0o600);
109    fs::set_permissions(path, perms)?;
110    Ok(())
111}
112
113/// Restrict `path` to the current user. No-op on Windows in v0.1.0.
114///
115/// # Errors
116///
117/// Always returns `Ok` on Windows; reserved for future ACL support.
118#[cfg(windows)]
119#[allow(clippy::missing_const_for_fn)]
120pub fn restrict_to_user(_path: &Path) -> Result<()> {
121    Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn atomic_write_creates_parent_dirs() {
130        let dir = tempfile::tempdir().unwrap();
131        let target = dir.path().join("nested/dir/output.txt");
132        atomic_write(&target, b"hello").unwrap();
133        assert_eq!(fs::read(&target).unwrap(), b"hello");
134    }
135
136    #[test]
137    fn atomic_write_overwrites_existing() {
138        let dir = tempfile::tempdir().unwrap();
139        let target = dir.path().join("output.txt");
140        atomic_write(&target, b"v1").unwrap();
141        atomic_write(&target, b"v2").unwrap();
142        assert_eq!(fs::read(&target).unwrap(), b"v2");
143    }
144
145    #[test]
146    fn sha256_matches_known_vector() {
147        // sha256("abc") == ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
148        assert_eq!(
149            sha256_bytes(b"abc"),
150            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
151        );
152    }
153}