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
87pub 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
112pub 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
176pub 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
210pub fn replace_file(from: &Path, to: &Path) -> io::Result<()> {
217 replace_file_impl(from, to)
218}
219
220pub 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 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}