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