Skip to main content

nils_common/
fs.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{self, Read, Write};
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7use thiserror::Error;
8
9pub const SECRET_FILE_MODE: u32 = 0o600;
10const MAX_TEMP_PATH_ATTEMPTS: u32 = 10;
11
12#[derive(Debug, Error)]
13pub enum AtomicWriteError {
14    #[error("failed to create parent directory {path}: {source}")]
15    CreateParentDir {
16        path: PathBuf,
17        #[source]
18        source: io::Error,
19    },
20    #[error("failed to create temporary file {path}: {source}")]
21    CreateTempFile {
22        path: PathBuf,
23        #[source]
24        source: io::Error,
25    },
26    #[error("failed to create unique temporary file for {target} after {attempts} attempts")]
27    TempPathExhausted { target: PathBuf, attempts: u32 },
28    #[error("failed to write temporary file {path}: {source}")]
29    WriteTempFile {
30        path: PathBuf,
31        #[source]
32        source: io::Error,
33    },
34    #[error("failed to set permissions on {path}: {source}")]
35    SetPermissions {
36        path: PathBuf,
37        #[source]
38        source: io::Error,
39    },
40    #[error("failed to replace {to} from {from}: {source}")]
41    ReplaceFile {
42        from: PathBuf,
43        to: PathBuf,
44        #[source]
45        source: io::Error,
46    },
47}
48
49#[derive(Debug, Error)]
50pub enum TimestampError {
51    #[error("failed to create parent directory {path}: {source}")]
52    CreateParentDir {
53        path: PathBuf,
54        #[source]
55        source: io::Error,
56    },
57    #[error("failed to write timestamp file {path}: {source}")]
58    WriteFile {
59        path: PathBuf,
60        #[source]
61        source: io::Error,
62    },
63    #[error("failed to remove timestamp file {path}: {source}")]
64    RemoveFile {
65        path: PathBuf,
66        #[source]
67        source: io::Error,
68    },
69}
70
71#[derive(Debug, Error)]
72pub enum FileHashError {
73    #[error("failed to open file for hashing {path}: {source}")]
74    OpenFile {
75        path: PathBuf,
76        #[source]
77        source: io::Error,
78    },
79    #[error("failed to read file for hashing {path}: {source}")]
80    ReadFile {
81        path: PathBuf,
82        #[source]
83        source: io::Error,
84    },
85}
86
87/// Compute a lowercase SHA-256 digest for a file.
88pub fn sha256_file(path: &Path) -> Result<String, FileHashError> {
89    let mut file = File::open(path).map_err(|source| FileHashError::OpenFile {
90        path: path.to_path_buf(),
91        source,
92    })?;
93    let mut hasher = Sha256::new();
94    let mut buf = [0u8; 8192];
95
96    loop {
97        let read = file
98            .read(&mut buf)
99            .map_err(|source| FileHashError::ReadFile {
100                path: path.to_path_buf(),
101                source,
102            })?;
103        if read == 0 {
104            break;
105        }
106        hasher.update(&buf[..read]);
107    }
108
109    Ok(hex_encode(&hasher.finalize()))
110}
111
112/// Write bytes to `path` using a temp file + replace.
113///
114/// The helper creates parent directories when needed and applies `mode` on Unix.
115pub fn write_atomic(path: &Path, contents: &[u8], mode: u32) -> Result<(), AtomicWriteError> {
116    if let Some(parent) = path.parent() {
117        fs::create_dir_all(parent).map_err(|source| AtomicWriteError::CreateParentDir {
118            path: parent.to_path_buf(),
119            source,
120        })?;
121    }
122
123    let mut attempt = 0u32;
124    loop {
125        let tmp_path = temp_path(path, attempt);
126        match OpenOptions::new()
127            .write(true)
128            .create_new(true)
129            .open(&tmp_path)
130        {
131            Ok(mut file) => {
132                file.write_all(contents)
133                    .map_err(|source| AtomicWriteError::WriteTempFile {
134                        path: tmp_path.clone(),
135                        source,
136                    })?;
137                let _ = file.flush();
138                set_permissions(&tmp_path, mode).map_err(|source| {
139                    AtomicWriteError::SetPermissions {
140                        path: tmp_path.clone(),
141                        source,
142                    }
143                })?;
144                drop(file);
145
146                replace_file(&tmp_path, path).map_err(|source| AtomicWriteError::ReplaceFile {
147                    from: tmp_path.clone(),
148                    to: path.to_path_buf(),
149                    source,
150                })?;
151                set_permissions(path, mode).map_err(|source| AtomicWriteError::SetPermissions {
152                    path: path.to_path_buf(),
153                    source,
154                })?;
155                return Ok(());
156            }
157            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
158                attempt += 1;
159                if attempt > MAX_TEMP_PATH_ATTEMPTS {
160                    return Err(AtomicWriteError::TempPathExhausted {
161                        target: path.to_path_buf(),
162                        attempts: attempt,
163                    });
164                }
165            }
166            Err(source) => {
167                return Err(AtomicWriteError::CreateTempFile {
168                    path: tmp_path,
169                    source,
170                });
171            }
172        }
173    }
174}
175
176/// Persist a timestamp line.
177///
178/// Behavior:
179/// - `Some(value)`: trims at first newline and writes if non-empty.
180/// - `None` or empty value: removes the file, ignoring `NotFound`.
181pub fn write_timestamp(path: &Path, iso: Option<&str>) -> Result<(), TimestampError> {
182    if let Some(parent) = path.parent() {
183        fs::create_dir_all(parent).map_err(|source| TimestampError::CreateParentDir {
184            path: parent.to_path_buf(),
185            source,
186        })?;
187    }
188
189    if let Some(raw) = iso {
190        let trimmed = raw.split(&['\n', '\r'][..]).next().unwrap_or("");
191        if !trimmed.is_empty() {
192            fs::write(path, trimmed).map_err(|source| TimestampError::WriteFile {
193                path: path.to_path_buf(),
194                source,
195            })?;
196            return Ok(());
197        }
198    }
199
200    match fs::remove_file(path) {
201        Ok(()) => Ok(()),
202        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
203        Err(source) => Err(TimestampError::RemoveFile {
204            path: path.to_path_buf(),
205            source,
206        }),
207    }
208}
209
210/// Replace `to` by renaming `from` to `to`.
211///
212/// Notes:
213/// - On Unix, `rename` overwrites atomically when `from` and `to` are on the same filesystem.
214/// - On Windows, `rename` fails when `to` exists. We fall back to remove + rename, which is not
215///   atomic but matches the expected overwrite behavior for temp-file workflows.
216pub fn replace_file(from: &Path, to: &Path) -> io::Result<()> {
217    replace_file_impl(from, to)
218}
219
220/// Alias for `replace_file` (kept for readability at call sites).
221pub fn rename_overwrite(from: &Path, to: &Path) -> io::Result<()> {
222    replace_file(from, to)
223}
224
225#[cfg(unix)]
226fn replace_file_impl(from: &Path, to: &Path) -> io::Result<()> {
227    fs::rename(from, to)
228}
229
230#[cfg(windows)]
231fn replace_file_impl(from: &Path, to: &Path) -> io::Result<()> {
232    match fs::rename(from, to) {
233        Ok(()) => Ok(()),
234        Err(err) => {
235            // Be conservative: do not delete `to` unless we can confirm `from` exists.
236            if !from.exists() {
237                return Err(err);
238            }
239
240            if !to.exists() {
241                return Err(err);
242            }
243
244            match fs::remove_file(to) {
245                Ok(()) => {}
246                Err(remove_err) if remove_err.kind() == io::ErrorKind::NotFound => {}
247                Err(remove_err) => {
248                    return Err(io::Error::new(
249                        io::ErrorKind::Other,
250                        format!("rename failed: {err} (remove failed: {remove_err})"),
251                    ));
252                }
253            }
254
255            fs::rename(from, to).map_err(|err2| {
256                io::Error::new(
257                    io::ErrorKind::Other,
258                    format!("rename failed: {err} ({err2})"),
259                )
260            })
261        }
262    }
263}
264
265#[cfg(not(any(unix, windows)))]
266fn replace_file_impl(from: &Path, to: &Path) -> io::Result<()> {
267    fs::rename(from, to)
268}
269
270#[cfg(unix)]
271fn set_permissions(path: &Path, mode: u32) -> io::Result<()> {
272    let perm = fs::Permissions::from_mode(mode);
273    fs::set_permissions(path, perm)
274}
275
276#[cfg(not(unix))]
277fn set_permissions(_path: &Path, _mode: u32) -> io::Result<()> {
278    Ok(())
279}
280
281fn temp_path(path: &Path, attempt: u32) -> PathBuf {
282    let filename = path
283        .file_name()
284        .and_then(|name| name.to_str())
285        .unwrap_or("tmp");
286    let pid = std::process::id();
287    let nanos = SystemTime::now()
288        .duration_since(UNIX_EPOCH)
289        .map(|duration| duration.as_nanos())
290        .unwrap_or(0);
291    let tmp_name = format!(".{filename}.tmp-{pid}-{nanos}-{attempt}");
292    path.with_file_name(tmp_name)
293}
294
295fn hex_encode(bytes: &[u8]) -> String {
296    const HEX: &[u8; 16] = b"0123456789abcdef";
297
298    let mut out = String::with_capacity(bytes.len() * 2);
299    for byte in bytes {
300        out.push(HEX[(byte >> 4) as usize] as char);
301        out.push(HEX[(byte & 0x0f) as usize] as char);
302    }
303    out
304}
305
306struct Sha256 {
307    state: [u32; 8],
308    buffer: [u8; 64],
309    buffer_len: usize,
310    total_len: u64,
311}
312
313impl Sha256 {
314    fn new() -> Self {
315        Self {
316            state: [
317                0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
318                0x5be0cd19,
319            ],
320            buffer: [0u8; 64],
321            buffer_len: 0,
322            total_len: 0,
323        }
324    }
325
326    fn update(&mut self, mut data: &[u8]) {
327        self.total_len = self.total_len.wrapping_add(data.len() as u64);
328
329        if self.buffer_len > 0 {
330            let need = 64 - self.buffer_len;
331            let take = need.min(data.len());
332            self.buffer[self.buffer_len..self.buffer_len + take].copy_from_slice(&data[..take]);
333            self.buffer_len += take;
334            data = &data[take..];
335
336            if self.buffer_len == 64 {
337                let block = self.buffer;
338                self.compress(&block);
339                self.buffer_len = 0;
340            }
341        }
342
343        while data.len() >= 64 {
344            let block: [u8; 64] = data[..64].try_into().expect("64-byte block");
345            self.compress(&block);
346            data = &data[64..];
347        }
348
349        if !data.is_empty() {
350            self.buffer[..data.len()].copy_from_slice(data);
351            self.buffer_len = data.len();
352        }
353    }
354
355    fn finalize(mut self) -> [u8; 32] {
356        let bit_len = self.total_len.wrapping_mul(8);
357
358        self.buffer[self.buffer_len] = 0x80;
359        self.buffer_len += 1;
360
361        if self.buffer_len > 56 {
362            self.buffer[self.buffer_len..].fill(0);
363            let block = self.buffer;
364            self.compress(&block);
365            self.buffer = [0u8; 64];
366            self.buffer_len = 0;
367        }
368
369        self.buffer[self.buffer_len..56].fill(0);
370        self.buffer[56..64].copy_from_slice(&bit_len.to_be_bytes());
371        let block = self.buffer;
372        self.compress(&block);
373
374        let mut out = [0u8; 32];
375        for (index, chunk) in out.chunks_exact_mut(4).enumerate() {
376            chunk.copy_from_slice(&self.state[index].to_be_bytes());
377        }
378        out
379    }
380
381    fn compress(&mut self, block: &[u8; 64]) {
382        let mut schedule = [0u32; 64];
383        for (index, word) in schedule.iter_mut().take(16).enumerate() {
384            let offset = index * 4;
385            *word = u32::from_be_bytes([
386                block[offset],
387                block[offset + 1],
388                block[offset + 2],
389                block[offset + 3],
390            ]);
391        }
392
393        for index in 16..64 {
394            let s0 = schedule[index - 15].rotate_right(7)
395                ^ schedule[index - 15].rotate_right(18)
396                ^ (schedule[index - 15] >> 3);
397            let s1 = schedule[index - 2].rotate_right(17)
398                ^ schedule[index - 2].rotate_right(19)
399                ^ (schedule[index - 2] >> 10);
400            schedule[index] = schedule[index - 16]
401                .wrapping_add(s0)
402                .wrapping_add(schedule[index - 7])
403                .wrapping_add(s1);
404        }
405
406        let mut a = self.state[0];
407        let mut b = self.state[1];
408        let mut c = self.state[2];
409        let mut d = self.state[3];
410        let mut e = self.state[4];
411        let mut f = self.state[5];
412        let mut g = self.state[6];
413        let mut h = self.state[7];
414
415        for index in 0..64 {
416            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
417            let choice = (e & f) ^ ((!e) & g);
418            let t1 = h
419                .wrapping_add(s1)
420                .wrapping_add(choice)
421                .wrapping_add(ROUND_CONSTANTS[index])
422                .wrapping_add(schedule[index]);
423            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
424            let majority = (a & b) ^ (a & c) ^ (b & c);
425            let t2 = s0.wrapping_add(majority);
426
427            h = g;
428            g = f;
429            f = e;
430            e = d.wrapping_add(t1);
431            d = c;
432            c = b;
433            b = a;
434            a = t1.wrapping_add(t2);
435        }
436
437        self.state[0] = self.state[0].wrapping_add(a);
438        self.state[1] = self.state[1].wrapping_add(b);
439        self.state[2] = self.state[2].wrapping_add(c);
440        self.state[3] = self.state[3].wrapping_add(d);
441        self.state[4] = self.state[4].wrapping_add(e);
442        self.state[5] = self.state[5].wrapping_add(f);
443        self.state[6] = self.state[6].wrapping_add(g);
444        self.state[7] = self.state[7].wrapping_add(h);
445    }
446}
447
448const ROUND_CONSTANTS: [u32; 64] = [
449    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
450    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
451    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
452    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
453    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
454    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
455    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
456    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
457];
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use tempfile::TempDir;
463
464    #[test]
465    fn fs_replace_file_overwrites_existing_destination() {
466        let dir = TempDir::new().expect("tempdir");
467        let from = dir.path().join("from.tmp");
468        let to = dir.path().join("to.txt");
469
470        fs::write(&from, "new").expect("write from");
471        fs::write(&to, "old").expect("write to");
472
473        replace_file(&from, &to).expect("replace_file");
474
475        assert!(!from.exists(), "from should be moved away");
476        assert_eq!(fs::read_to_string(&to).expect("read to"), "new");
477    }
478
479    #[test]
480    fn fs_sha256_file_matches_known_hash() {
481        let dir = TempDir::new().expect("tempdir");
482        let path = dir.path().join("blob.txt");
483        fs::write(&path, b"hello\n").expect("write file");
484
485        let digest = sha256_file(&path).expect("sha256");
486
487        assert_eq!(
488            digest,
489            "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
490        );
491    }
492
493    #[test]
494    fn fs_sha256_file_returns_structured_open_error() {
495        let dir = TempDir::new().expect("tempdir");
496        let missing = dir.path().join("missing.txt");
497
498        let err = sha256_file(&missing).expect_err("missing file should fail");
499
500        match err {
501            FileHashError::OpenFile { path, .. } => assert_eq!(path, missing),
502            other => panic!("unexpected error variant: {other:?}"),
503        }
504    }
505
506    #[test]
507    fn fs_write_atomic_creates_parent_and_writes_contents() {
508        let dir = TempDir::new().expect("tempdir");
509        let path = dir.path().join("nested").join("secret.json");
510
511        write_atomic(&path, br#"{"ok":true}"#, SECRET_FILE_MODE).expect("write_atomic");
512
513        assert_eq!(
514            fs::read_to_string(&path).expect("read content"),
515            r#"{"ok":true}"#
516        );
517
518        #[cfg(unix)]
519        {
520            use std::os::unix::fs::PermissionsExt;
521            let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
522            assert_eq!(mode, 0o600);
523        }
524    }
525
526    #[test]
527    fn fs_write_atomic_returns_structured_parent_error() {
528        let dir = TempDir::new().expect("tempdir");
529        let parent_file = dir.path().join("not-a-directory");
530        let target = parent_file.join("secret.json");
531        fs::write(&parent_file, "block parent dir creation").expect("seed file");
532
533        let err = write_atomic(&target, b"{}", SECRET_FILE_MODE)
534            .expect_err("parent dir creation should fail");
535
536        match err {
537            AtomicWriteError::CreateParentDir { path, .. } => assert_eq!(path, parent_file),
538            other => panic!("unexpected error variant: {other:?}"),
539        }
540    }
541
542    #[test]
543    fn fs_write_timestamp_trims_newlines_and_writes_value() {
544        let dir = TempDir::new().expect("tempdir");
545        let path = dir.path().join("stamp.txt");
546
547        write_timestamp(&path, Some("2025-01-20T00:00:00Z\n")).expect("write timestamp");
548
549        assert_eq!(
550            fs::read_to_string(&path).expect("read timestamp"),
551            "2025-01-20T00:00:00Z"
552        );
553    }
554
555    #[test]
556    fn fs_write_timestamp_removes_file_when_value_missing_or_empty() {
557        let dir = TempDir::new().expect("tempdir");
558        let path = dir.path().join("stamp.txt");
559        fs::write(&path, "present").expect("seed timestamp");
560
561        write_timestamp(&path, None).expect("timestamp none");
562        assert!(!path.exists(), "expected timestamp file removed");
563
564        fs::write(&path, "present").expect("seed timestamp");
565        write_timestamp(&path, Some("\n")).expect("timestamp empty");
566        assert!(!path.exists(), "expected timestamp file removed");
567    }
568
569    #[test]
570    fn fs_write_timestamp_ignores_missing_remove_target() {
571        let dir = TempDir::new().expect("tempdir");
572        let missing = dir.path().join("missing.timestamp");
573
574        write_timestamp(&missing, None).expect("missing remove should not fail");
575    }
576}