1use anyhow::{Context, Result};
59use serde::Serialize;
60use std::fs;
61use std::io::Write;
62use std::path::{Path, PathBuf};
63use std::sync::atomic::{AtomicU64, Ordering};
64
65static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
67
68#[derive(Clone, Copy)]
70enum ModePolicy {
71 Private,
73 PreserveTarget,
75}
76
77fn temp_path_for(path: &Path) -> PathBuf {
82 let file_name = path
83 .file_name()
84 .map(|n| n.to_string_lossy().into_owned())
85 .unwrap_or_else(|| "par-term-save".to_string());
86 let unique = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
87 path.with_file_name(format!("{file_name}.tmp.{}.{unique}", std::process::id()))
88}
89
90#[cfg(unix)]
94fn final_mode(target: &Path, policy: ModePolicy) -> Option<u32> {
95 use std::os::unix::fs::PermissionsExt;
96
97 match policy {
98 ModePolicy::Private => None,
99 ModePolicy::PreserveTarget => fs::symlink_metadata(target)
103 .ok()
104 .map(|m| m.permissions().mode() & 0o7777)
105 .filter(|mode| *mode != 0o600),
106 }
107}
108
109#[cfg(not(unix))]
112fn final_mode(_target: &Path, policy: ModePolicy) -> Option<u32> {
113 match policy {
114 ModePolicy::Private | ModePolicy::PreserveTarget => None,
115 }
116}
117
118#[cfg_attr(not(unix), allow(unused_variables))]
124fn write_and_sync(temp_path: &Path, bytes: &[u8], final_mode: Option<u32>) -> Result<()> {
125 let mut options = fs::OpenOptions::new();
126 options.write(true).create(true).truncate(true);
127
128 #[cfg(unix)]
129 {
130 use std::os::unix::fs::OpenOptionsExt;
131 options.mode(0o600);
132 }
133
134 let mut file = options
135 .open(temp_path)
136 .with_context(|| format!("Failed to create temporary file {temp_path:?}"))?;
137
138 #[cfg(unix)]
143 {
144 use std::os::unix::fs::PermissionsExt;
145 file.set_permissions(fs::Permissions::from_mode(0o600))
146 .with_context(|| format!("Failed to restrict permissions on {temp_path:?}"))?;
147 }
148
149 file.write_all(bytes)
150 .with_context(|| format!("Failed to write temporary file {temp_path:?}"))?;
151
152 #[cfg(unix)]
153 if let Some(mode) = final_mode {
154 use std::os::unix::fs::PermissionsExt;
155 file.set_permissions(fs::Permissions::from_mode(mode))
156 .with_context(|| format!("Failed to restore permissions on {temp_path:?}"))?;
157 }
158
159 file.sync_all()
160 .with_context(|| format!("Failed to flush temporary file {temp_path:?} to disk"))?;
161 Ok(())
162}
163
164#[cfg(not(windows))]
165fn rename_into_place(from: &Path, to: &Path) -> Result<()> {
166 fs::rename(from, to).with_context(|| format!("Failed to rename {from:?} to {to:?}"))
167}
168
169#[cfg(windows)]
170fn rename_into_place(from: &Path, to: &Path) -> Result<()> {
171 const ATTEMPTS: u32 = 5;
172
173 let mut last_err = None;
174 for attempt in 0..ATTEMPTS {
175 match fs::rename(from, to) {
176 Ok(()) => return Ok(()),
177 Err(e) => {
178 last_err = Some(e);
179 if attempt + 1 < ATTEMPTS {
180 std::thread::sleep(std::time::Duration::from_millis(
181 50 * u64::from(attempt + 1),
182 ));
183 }
184 }
185 }
186 }
187
188 Err(last_err.expect("loop body runs at least once")).with_context(|| {
189 format!(
190 "Failed to rename {from:?} to {to:?} after {ATTEMPTS} attempts; \
191 the target may be held open by another process"
192 )
193 })
194}
195
196#[cfg(unix)]
198fn sync_parent_dir(path: &Path) {
199 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty())
200 && let Ok(dir) = fs::File::open(parent)
201 {
202 let _ = dir.sync_all();
203 }
204}
205
206#[cfg(not(unix))]
207fn sync_parent_dir(_path: &Path) {}
208
209fn save_bytes_with_policy(path: &Path, bytes: &[u8], policy: ModePolicy) -> Result<()> {
210 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
211 fs::create_dir_all(parent)
212 .with_context(|| format!("Failed to create directory {parent:?}"))?;
213 }
214
215 let mode = final_mode(path, policy);
216 let temp_path = temp_path_for(path);
217
218 if let Err(e) = write_and_sync(&temp_path, bytes, mode) {
219 let _ = fs::remove_file(&temp_path);
220 return Err(e.context(format!(
221 "Failed to stage write for {path:?}; the previous file was left unchanged"
222 )));
223 }
224
225 if let Err(e) = rename_into_place(&temp_path, path) {
226 let _ = fs::remove_file(&temp_path);
227 return Err(e.context(format!(
228 "Failed to replace {path:?}; the previous file was left unchanged"
229 )));
230 }
231
232 sync_parent_dir(path);
233 Ok(())
234}
235
236pub fn save_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
252 save_bytes_with_policy(path, bytes, ModePolicy::Private)
253}
254
255pub fn save_string_atomic(path: &Path, contents: &str) -> Result<()> {
263 save_bytes_atomic(path, contents.as_bytes())
264}
265
266pub fn save_yaml_atomic<T>(path: &Path, value: &T) -> Result<()>
277where
278 T: Serialize + ?Sized,
279{
280 let yaml = serde_yaml_ng::to_string(value)
281 .with_context(|| format!("Failed to serialize data for {path:?}"))?;
282 save_string_atomic(path, &yaml)
283}
284
285pub fn save_bytes_atomic_preserving_mode(path: &Path, bytes: &[u8]) -> Result<()> {
302 save_bytes_with_policy(path, bytes, ModePolicy::PreserveTarget)
303}
304
305pub fn save_string_atomic_preserving_mode(path: &Path, contents: &str) -> Result<()> {
313 save_bytes_atomic_preserving_mode(path, contents.as_bytes())
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use tempfile::tempdir;
320
321 struct AlwaysFailsToSerialize;
324
325 impl Serialize for AlwaysFailsToSerialize {
326 fn serialize<S: serde::Serializer>(&self, _s: S) -> Result<S::Ok, S::Error> {
327 Err(serde::ser::Error::custom(
328 "deliberate serialization failure",
329 ))
330 }
331 }
332
333 fn dir_entries(dir: &Path) -> Vec<String> {
334 let mut names: Vec<String> = fs::read_dir(dir)
335 .expect("read_dir")
336 .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
337 .collect();
338 names.sort();
339 names
340 }
341
342 #[cfg(unix)]
343 fn mode_of(path: &Path) -> u32 {
344 use std::os::unix::fs::PermissionsExt;
345 fs::metadata(path).expect("metadata").permissions().mode() & 0o777
346 }
347
348 #[test]
349 fn temp_path_is_a_sibling_of_the_target() {
350 let target = Path::new("/some/dir/profiles.yaml");
351 let temp = temp_path_for(target);
352
353 assert_eq!(temp.parent(), target.parent());
355 assert_ne!(temp, target);
356 assert!(
357 temp.file_name()
358 .expect("temp has a file name")
359 .to_string_lossy()
360 .starts_with("profiles.yaml.tmp.")
361 );
362 }
363
364 #[test]
365 fn temp_paths_are_unique_within_a_process() {
366 let target = Path::new("/some/dir/profiles.yaml");
367 assert_ne!(temp_path_for(target), temp_path_for(target));
368 }
369
370 #[test]
371 fn save_creates_parent_directory() {
372 let temp = tempdir().expect("tempdir");
373 let path = temp.path().join("nested").join("dir").join("data.yaml");
374
375 save_string_atomic(&path, "hello").expect("save");
376 assert_eq!(fs::read_to_string(&path).expect("read"), "hello");
377 }
378
379 #[test]
380 fn save_replaces_existing_content_and_leaves_no_temp_file() {
381 let temp = tempdir().expect("tempdir");
382 let path = temp.path().join("data.yaml");
383
384 save_string_atomic(&path, "first").expect("first save");
385 save_string_atomic(&path, "second").expect("second save");
386
387 assert_eq!(fs::read_to_string(&path).expect("read"), "second");
388 assert_eq!(dir_entries(temp.path()), vec!["data.yaml".to_string()]);
389 }
390
391 #[test]
392 fn failed_save_leaves_the_previous_file_intact() {
393 let temp = tempdir().expect("tempdir");
394 let path = temp.path().join("data.yaml");
395
396 save_yaml_atomic(&path, &"good data".to_string()).expect("initial save");
397 let before = fs::read_to_string(&path).expect("read before");
398
399 let err =
400 save_yaml_atomic(&path, &AlwaysFailsToSerialize).expect_err("serialization must fail");
401 assert!(
402 err.to_string().contains("Failed to serialize"),
403 "unexpected error: {err:#}"
404 );
405
406 assert_eq!(fs::read_to_string(&path).expect("read after"), before);
409 assert_eq!(dir_entries(temp.path()), vec!["data.yaml".to_string()]);
410 }
411
412 #[test]
413 fn stale_temp_file_does_not_break_a_later_save() {
414 let temp = tempdir().expect("tempdir");
415 let path = temp.path().join("data.yaml");
416
417 fs::write(temp.path().join("data.yaml.tmp.999999.0"), "garbage").expect("stale temp");
419
420 save_string_atomic(&path, "fresh").expect("save over a stale temp");
421 assert_eq!(fs::read_to_string(&path).expect("read"), "fresh");
422 }
423
424 #[test]
425 fn yaml_roundtrip() {
426 let temp = tempdir().expect("tempdir");
427 let path = temp.path().join("data.yaml");
428
429 let value = vec!["a".to_string(), "b".to_string()];
430 save_yaml_atomic(&path, &value).expect("save");
431
432 let loaded: Vec<String> =
433 serde_yaml_ng::from_str(&fs::read_to_string(&path).expect("read")).expect("parse");
434 assert_eq!(loaded, value);
435 }
436
437 #[cfg(unix)]
438 #[test]
439 fn completed_save_has_mode_0600() {
440 let temp = tempdir().expect("tempdir");
441 let path = temp.path().join("data.yaml");
442
443 save_string_atomic(&path, "secret").expect("save");
444
445 assert_eq!(mode_of(&path), 0o600);
446 }
447
448 #[cfg(unix)]
449 #[test]
450 fn save_tightens_a_world_readable_existing_file() {
451 use std::os::unix::fs::PermissionsExt;
452
453 let temp = tempdir().expect("tempdir");
454 let path = temp.path().join("data.yaml");
455
456 fs::write(&path, "old").expect("seed");
457 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod");
458
459 save_string_atomic(&path, "new").expect("save");
460
461 assert_eq!(mode_of(&path), 0o600);
462 }
463
464 #[cfg(unix)]
465 #[test]
466 fn staging_file_is_never_world_readable() {
467 let temp = tempdir().expect("tempdir");
468 let staging = temp.path().join("staged");
469
470 write_and_sync(&staging, b"secret", None).expect("stage");
471
472 assert_eq!(mode_of(&staging), 0o600);
473 }
474
475 #[cfg(unix)]
476 #[test]
477 fn staging_reuses_a_stale_temp_file_but_re_restricts_it() {
478 use std::os::unix::fs::PermissionsExt;
479
480 let temp = tempdir().expect("tempdir");
481 let staging = temp.path().join("staged");
482
483 fs::write(&staging, "stale").expect("seed");
485 fs::set_permissions(&staging, fs::Permissions::from_mode(0o666)).expect("chmod");
486
487 write_and_sync(&staging, b"secret", None).expect("stage");
488
489 assert_eq!(mode_of(&staging), 0o600);
490 assert_eq!(fs::read_to_string(&staging).expect("read"), "secret");
491 }
492
493 #[cfg(unix)]
496 #[test]
497 fn preserving_save_keeps_a_group_readable_rc_file_group_readable() {
498 use std::os::unix::fs::PermissionsExt;
499
500 let temp = tempdir().expect("tempdir");
501 let path = temp.path().join(".zshrc");
502
503 fs::write(&path, "export PATH=/bin\n").expect("seed");
504 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod");
505
506 save_string_atomic_preserving_mode(&path, "export PATH=/bin\nnew line\n").expect("save");
507
508 assert_eq!(mode_of(&path), 0o644, "the user's own mode must survive");
509 assert_eq!(
510 fs::read_to_string(&path).expect("read"),
511 "export PATH=/bin\nnew line\n"
512 );
513 }
514
515 #[cfg(unix)]
516 #[test]
517 fn preserving_save_keeps_an_executable_bit() {
518 use std::os::unix::fs::PermissionsExt;
519
520 let temp = tempdir().expect("tempdir");
521 let path = temp.path().join("script.sh");
522
523 fs::write(&path, "#!/bin/sh\n").expect("seed");
524 fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
525
526 save_string_atomic_preserving_mode(&path, "#!/bin/sh\necho hi\n").expect("save");
527
528 assert_eq!(mode_of(&path), 0o755);
529 }
530
531 #[cfg(unix)]
532 #[test]
533 fn preserving_save_creates_a_new_file_private() {
534 let temp = tempdir().expect("tempdir");
535 let path = temp.path().join("fresh.glsl");
536
537 save_string_atomic_preserving_mode(&path, "void main() {}").expect("save");
538
539 assert_eq!(
540 mode_of(&path),
541 0o600,
542 "with no target to preserve, 0600 is the safe default"
543 );
544 }
545
546 #[test]
547 fn preserving_save_is_still_atomic_and_leaves_no_temp_file() {
548 let temp = tempdir().expect("tempdir");
549 let path = temp.path().join(".bashrc");
550
551 save_string_atomic_preserving_mode(&path, "first").expect("first save");
552 save_string_atomic_preserving_mode(&path, "second").expect("second save");
553
554 assert_eq!(fs::read_to_string(&path).expect("read"), "second");
555 assert_eq!(dir_entries(temp.path()), vec![".bashrc".to_string()]);
556 }
557
558 #[test]
559 fn preserving_save_failure_leaves_the_previous_rc_file_intact() {
560 let temp = tempdir().expect("tempdir");
561 let path = temp.path().join("blocked");
564 fs::create_dir(&path).expect("blocking directory");
565
566 let err = save_string_atomic_preserving_mode(&path, "payload")
567 .expect_err("renaming over a directory must fail");
568 assert!(
569 format!("{err:#}").contains("left unchanged"),
570 "unexpected error: {err:#}"
571 );
572
573 let leftovers: Vec<String> = dir_entries(temp.path())
574 .into_iter()
575 .filter(|n| n.contains(".tmp."))
576 .collect();
577 assert!(leftovers.is_empty(), "staging files left: {leftovers:?}");
578 }
579}