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
103pub 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
128pub 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
192pub 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
225pub 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
240pub fn replace_file(from: &Path, to: &Path) -> io::Result<()> {
247 replace_file_impl(from, to)
248}
249
250pub 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 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}