1#[cfg(unix)]
2use std::fs::Permissions;
3#[cfg(unix)]
4use std::os::unix::{
5 fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _},
6 io::AsRawFd as _,
7};
8#[cfg(target_os = "macos")]
9use std::ptr::null_mut;
10use std::{
11 ffi::OsString,
12 fmt::Display,
13 fs::{File, Metadata, OpenOptions, hard_link, remove_file, symlink_metadata},
14 io::{
15 Error as IoError, ErrorKind, Read as _, Result as IoResult, Seek as _, SeekFrom,
16 Write as _, copy,
17 },
18 path::{Path, PathBuf},
19 process::id,
20 sync::atomic::{AtomicU64, Ordering},
21};
22#[cfg(any(target_os = "linux", target_os = "macos"))]
23use std::{
24 ffi::{CStr, CString},
25 os::unix::ffi::OsStrExt as _,
26};
27
28#[cfg(target_os = "linux")]
29use libc::{AT_FDCWD, RENAME_EXCHANGE, SYS_renameat2, syscall};
30#[cfg(target_os = "macos")]
31use libc::{COPYFILE_ACL, COPYFILE_XATTR, RENAME_SWAP, fcopyfile, renamex_np};
32#[cfg(unix)]
33use libc::{O_CLOEXEC, O_NOFOLLOW, O_NONBLOCK, fchown};
34
35#[cfg(target_os = "macos")]
36use crate::warn;
37use crate::{Err, Error, Result, debug_warn, err, utils::BoolExt};
38
39type Origin<'a> = (&'a File, &'a Metadata);
40
41static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
42
43struct TempGuard {
44 path: PathBuf,
45 armed: bool,
46}
47
48pub(super) fn write_atomic(path: &Path, content: &[u8], force: bool) -> Result {
49 write_atomic_inner(path, content, force, || {})
50}
51
52#[cfg(test)]
53pub(super) fn write_atomic_with_precommit(
54 path: &Path,
55 content: &[u8],
56 force: bool,
57 before_commit: impl FnOnce(),
58) -> Result {
59 write_atomic_inner(path, content, force, before_commit)
60}
61
62fn write_atomic_inner(
63 path: &Path,
64 content: &[u8],
65 force: bool,
66 before_commit: impl FnOnce(),
67) -> Result {
68 let parent = path
69 .parent()
70 .filter(|parent| !parent.as_os_str().is_empty())
71 .unwrap_or_else(|| Path::new("."));
72
73 let metadata = match symlink_metadata(path) {
74 | Err(error) if error.kind() == ErrorKind::NotFound => None,
75 | Ok(metadata) => Some(metadata),
76 | Err(error) => return Err(fs_error(&error, "inspect output", path)),
77 };
78
79 match metadata {
80 | Some(metadata) if is_nonregular(&metadata) => {
81 Err!("Output is not a regular file: {}.", path.display())
82 },
83 | Some(_) if !force => Err!("Output already exists: {}.", path.display()),
84 | Some(_) => write_replacement(parent, path, content, before_commit),
85 | None => write_new(parent, path, content, before_commit),
86 }
87}
88
89fn write_new(parent: &Path, path: &Path, content: &[u8], before_commit: impl FnOnce()) -> Result {
90 let mut output = create_synced_temp(parent, path, content, None)?;
91
92 before_commit();
93 install_noreplace(&output.path, path, "output")?;
94
95 cleanup_after_commit(&mut output);
96 sync_after_commit(parent, path);
97
98 Ok(())
99}
100
101fn write_replacement(
102 parent: &Path,
103 path: &Path,
104 content: &[u8],
105 before_commit: impl FnOnce(),
106) -> Result {
107 let backup = backup_path(path);
108
109 if entry_exists(&backup)? {
110 return Err!("Backup already exists: {}.", backup.display());
111 }
112
113 let (mut source, metadata) = open_target(path)?;
114 let mut output = create_synced_temp(parent, path, content, Some((&source, &metadata)))?;
115 let (mut backup_guard, mut backup_file) =
116 copy_backup(parent, &backup, &mut source, &metadata)?;
117
118 install_noreplace(&backup_guard.path, &backup, "backup")?;
119 sync_parent(parent)
120 .map_err(|error| {
121 err!("Failed to synchronize staged backup `{}`: {error}", backup.display())
122 })
123 .map_err(|error| discard_backup(&backup, parent, error))?;
124
125 before_commit();
126 exchange(&output.path, path).map_err(|error| discard_backup(&backup, parent, error))?;
127 validate_displaced(
128 &output.path,
129 &metadata,
130 &mut source,
131 &mut backup_file,
132 &backup_guard.path,
133 )
134 .map_err(|error| {
135 rollback_replacement(&mut output, &mut backup_guard, path, &backup, error)
136 })?;
137
138 preserve_backup_attributes(&source, &backup_file, &backup);
139 cleanup_after_commit(&mut output);
140 cleanup_after_commit(&mut backup_guard);
141 sync_after_commit(parent, path);
142
143 Ok(())
144}
145
146fn create_synced_temp(
147 parent: &Path,
148 target: &Path,
149 content: &[u8],
150 origin: Option<Origin<'_>>,
151) -> Result<TempGuard> {
152 let (path, mut file) = create_temp(parent, target)?;
153 let guard = TempGuard { path, armed: true };
154
155 file.write_all(content)
156 .map_err(|error| fs_error(&error, "write temporary output", &guard.path))?;
157
158 set_output_metadata(&file, origin.map(|(_, metadata)| metadata), &guard.path)?;
159
160 if let Some((source, _)) = origin {
161 copy_security_attributes(source, &file, &guard.path)?;
162 }
163
164 file.sync_all()
165 .map_err(|error| fs_error(&error, "synchronize temporary output", &guard.path))?;
166
167 Ok(guard)
168}
169
170fn copy_backup(
171 parent: &Path,
172 backup: &Path,
173 source: &mut File,
174 metadata: &Metadata,
175) -> Result<(TempGuard, File)> {
176 let (path, mut file) = create_temp(parent, backup)?;
177 let guard = TempGuard { path, armed: true };
178
179 copy(source, &mut file)
180 .map_err(|error| fs_pair_error(&error, "copy output", backup, &guard.path))?;
181
182 set_output_metadata(&file, Some(metadata), &guard.path)?;
183
184 file.sync_all()
185 .map_err(|error| fs_error(&error, "synchronize temporary backup", &guard.path))?;
186
187 Ok((guard, file))
188}
189
190fn open_target(path: &Path) -> Result<(File, Metadata)> {
191 let mut options = OpenOptions::new();
192
193 options.read(true);
194
195 #[cfg(unix)]
196 options.custom_flags(O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
197
198 let file = options
199 .open(path)
200 .map_err(|error| fs_error(&error, "open output", path))?;
201
202 let metadata = file
203 .metadata()
204 .map_err(|error| fs_error(&error, "inspect opened output", path))?;
205
206 if is_nonregular(&metadata) {
207 return Err!("Output is not a regular file: {}.", path.display());
208 }
209
210 Ok((file, metadata))
211}
212
213fn validate_displaced(
214 displaced: &Path,
215 metadata: &Metadata,
216 source: &mut File,
217 backup: &mut File,
218 backup_path: &Path,
219) -> Result {
220 let displaced_metadata = symlink_metadata(displaced)
221 .map_err(|error| fs_error(&error, "inspect displaced output", displaced))?;
222
223 if !same_file(metadata, &displaced_metadata) {
224 return Err!("Output changed while its replacement was being prepared.");
225 }
226
227 if !same_contents(source, backup, displaced, backup_path)? {
228 return Err!("Output contents changed while its replacement was being prepared.");
229 }
230
231 Ok(())
232}
233
234fn same_contents(
235 left: &mut File,
236 right: &mut File,
237 left_path: &Path,
238 right_path: &Path,
239) -> Result<bool> {
240 left.seek(SeekFrom::Start(0))
241 .map_err(|error| fs_error(&error, "rewind displaced output", left_path))?;
242
243 right
244 .seek(SeekFrom::Start(0))
245 .map_err(|error| fs_error(&error, "rewind temporary backup", right_path))?;
246
247 let mut left_buf = [0_u8; 4096];
248 let mut right_buf = [0_u8; 4096];
249
250 loop {
251 let left_len = left
252 .read(&mut left_buf)
253 .map_err(|error| fs_error(&error, "read displaced output", left_path))?;
254
255 let right_len = right
256 .read(&mut right_buf)
257 .map_err(|error| fs_error(&error, "read temporary backup", right_path))?;
258
259 if left_len != right_len {
260 return Ok(false);
261 }
262
263 if left_buf[..left_len] != right_buf[..left_len] {
264 return Ok(false);
265 }
266
267 if left_len == 0 {
268 return Ok(true);
269 }
270 }
271}
272
273fn rollback_replacement(
274 output: &mut TempGuard,
275 backup_guard: &mut TempGuard,
276 path: &Path,
277 backup: &Path,
278 cause: Error,
279) -> Error {
280 if let Err(recovery_error) = exchange(&output.path, path) {
281 output.armed = false;
282 backup_guard.armed = false;
283
284 return err!(
285 "Output `{}` changed after replacement validation failed: {cause} Restoring it also \
286 failed: {recovery_error} Recover from `{}`; the displaced target remains at `{}`.",
287 path.display(),
288 backup.display(),
289 output.path.display(),
290 );
291 }
292
293 let parent = backup
294 .parent()
295 .filter(|parent| !parent.as_os_str().is_empty())
296 .unwrap_or_else(|| Path::new("."));
297
298 discard_backup(backup, parent, cause)
299}
300
301fn discard_backup(backup: &Path, parent: &Path, cause: Error) -> Error {
302 match remove_file(backup) {
303 | Ok(()) => {},
304 | Err(error) if error.kind() == ErrorKind::NotFound => {},
305 | Err(error) => {
306 return err!(
307 "{cause} Output was not changed, but the staged backup remains at `{}` because \
308 it could not be removed: {error}",
309 backup.display(),
310 );
311 },
312 }
313
314 if let Err(error) = sync_parent(parent) {
315 return err!(
316 "{cause} Output was not changed, but backup cleanup could not be synchronized: \
317 {error}",
318 );
319 }
320
321 cause
322}
323
324fn install_noreplace(source: &Path, target: &Path, kind: &str) -> Result {
325 match hard_link(source, target) {
326 | Ok(()) => Ok(()),
327 | Err(error) if error.kind() != ErrorKind::AlreadyExists =>
328 Err(fs_pair_error(&error, format_args!("install {kind}"), source, target)),
329 | Err(_) => {
330 Err!("{} already exists: {}.", title(kind), target.display())
331 },
332 }
333}
334
335fn title(kind: &str) -> &str {
336 match kind {
337 | "output" => "Output",
338 | "backup" => "Backup",
339 | _ => "Destination",
340 }
341}
342
343#[cfg(any(target_os = "linux", target_os = "macos"))]
344fn exchange(left: &Path, right: &Path) -> Result {
345 let left_name = path_cstring(left)?;
346 let right_name = path_cstring(right)?;
347
348 swap(&left_name, &right_name).map_err(|error| fs_pair_error(&error, "exchange", left, right))
349}
350
351#[cfg(not(any(target_os = "linux", target_os = "macos")))]
352fn exchange(left: &Path, right: &Path) -> Result {
353 Err!(
354 "Safe forced replacement of `{}` with `{}` is unsupported on this platform.",
355 right.display(),
356 left.display(),
357 )
358}
359
360#[cfg(target_os = "linux")]
363fn swap(left: &CStr, right: &CStr) -> IoResult<()> {
364 let result = unsafe {
367 syscall(
368 SYS_renameat2,
369 AT_FDCWD,
370 left.as_ptr(),
371 AT_FDCWD,
372 right.as_ptr(),
373 RENAME_EXCHANGE,
374 )
375 };
376
377 (result == 0)
378 .then_some(())
379 .ok_or_else(IoError::last_os_error)
380}
381
382#[cfg(target_os = "macos")]
383fn swap(left: &CStr, right: &CStr) -> IoResult<()> {
384 let result = unsafe { renamex_np(left.as_ptr(), right.as_ptr(), RENAME_SWAP) };
387
388 (result == 0)
389 .then_some(())
390 .ok_or_else(IoError::last_os_error)
391}
392
393#[cfg(any(target_os = "linux", target_os = "macos"))]
394pub(super) fn path_cstring(path: &Path) -> Result<CString> {
395 CString::new(path.as_os_str().as_bytes())
396 .map_err(|error| err!("Invalid output path `{}`: {error}", path.display()))
397}
398
399#[cfg(unix)]
400fn same_file(left: &Metadata, right: &Metadata) -> bool {
401 left.dev() == right.dev() && left.ino() == right.ino()
402}
403
404#[cfg(not(unix))]
405fn same_file(_left: &Metadata, _right: &Metadata) -> bool { false }
406
407#[expect(
408 clippy::filetype_is_file,
409 reason = "Every nonregular output target must be rejected, including devices and sockets."
410)]
411fn is_nonregular(metadata: &Metadata) -> bool { !metadata.file_type().is_file() }
412
413fn create_temp(parent: &Path, target: &Path) -> Result<(PathBuf, File)> {
414 let filename = target
415 .file_name()
416 .ok_or_else(|| err!("Output path has no file name: {}.", target.display()))?;
417
418 loop {
419 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
420 let mut name = OsString::from(".");
421
422 name.push(filename);
423 name.push(format!(".{}.{}.tmp", id(), sequence));
424 let path = parent.join(name);
425 let mut options = OpenOptions::new();
426
427 options.read(true).write(true).create_new(true);
428
429 #[cfg(unix)]
430 options.mode(0o600);
431
432 match options.open(&path) {
433 | Ok(file) => return Ok((path, file)),
434 | Err(error) if error.kind() == ErrorKind::AlreadyExists => {},
435 | Err(error) => return Err(fs_error(&error, "create temporary output", &path)),
436 }
437 }
438}
439
440fn backup_path(path: &Path) -> PathBuf {
441 let mut backup = path.as_os_str().to_owned();
442
443 backup.push(".bak");
444
445 backup.into()
446}
447
448#[cfg(unix)]
449fn set_output_metadata(file: &File, metadata: Option<&Metadata>, path: &Path) -> Result {
450 let Some(metadata) = metadata else {
451 file.set_permissions(Permissions::from_mode(0o600))
452 .map_err(|error| fs_error(&error, "set temporary output permissions", path))?;
453
454 return Ok(());
455 };
456
457 set_output_owner(file, metadata, path)?;
458
459 file.set_permissions(Permissions::from_mode(metadata.mode()))
460 .map_err(|error| {
461 fs_error(&error, "preserve output permissions on temporary file", path)
462 })?;
463
464 Ok(())
465}
466
467#[cfg(not(unix))]
468fn set_output_metadata(file: &File, metadata: Option<&Metadata>, path: &Path) -> Result {
469 if let Some(metadata) = metadata {
470 file.set_permissions(metadata.permissions())
471 .map_err(|error| {
472 fs_error(&error, "preserve output permissions on temporary file", path)
473 })?;
474 }
475
476 Ok(())
477}
478
479#[cfg(unix)]
480fn set_output_owner(file: &File, origin: &Metadata, path: &Path) -> Result {
481 let staged = file
482 .metadata()
483 .map_err(|error| fs_error(&error, "inspect temporary output", path))?;
484
485 let (uid, gid) = (origin.uid(), origin.gid());
486
487 if staged.uid() == uid && staged.gid() == gid {
489 return Ok(());
490 }
491
492 let result = unsafe { fchown(file.as_raw_fd(), uid, gid) };
494
495 (result == 0)
496 .then_some(())
497 .ok_or_else(IoError::last_os_error)
498 .map_err(|error| owner_error(&error, &staged, origin, path))
499}
500
501#[cfg(unix)]
502pub(super) fn owner_error(
503 error: &IoError,
504 staged: &Metadata,
505 origin: &Metadata,
506 path: &Path,
507) -> Error {
508 let hint = (error.kind() == ErrorKind::PermissionDenied).copy_or(
509 "",
510 " Changing ownership requires privilege the service does not have, or a sandbox is \
511 blocking the chown syscall.",
512 );
513
514 err!(
515 "Failed to change ownership of temporary file `{}` from uid {} gid {} to uid {} gid {}: \
516 {error}.{hint}",
517 path.display(),
518 staged.uid(),
519 staged.gid(),
520 origin.uid(),
521 origin.gid(),
522 )
523}
524
525#[cfg(target_os = "macos")]
528fn preserve_backup_attributes(source: &File, backup: &File, path: &Path) {
529 copy_security_attributes(source, backup, path)
530 .and_then(|()| {
531 backup
532 .sync_all()
533 .map_err(|error| fs_error(&error, "synchronize backup attributes", path))
534 })
535 .inspect_err(|error| {
536 warn!(
537 ?error,
538 path = %path.display(),
539 "Backup was installed, but its security attributes could not be preserved.",
540 );
541 })
542 .ok();
543}
544
545#[cfg(not(target_os = "macos"))]
546fn preserve_backup_attributes(_source: &File, _backup: &File, _path: &Path) {}
547
548#[cfg(target_os = "macos")]
549fn copy_security_attributes(source: &File, output: &File, path: &Path) -> Result {
550 let result = unsafe {
553 fcopyfile(
554 source.as_raw_fd(),
555 output.as_raw_fd(),
556 null_mut(),
557 COPYFILE_ACL | COPYFILE_XATTR,
558 )
559 };
560
561 (result == 0)
562 .then_some(())
563 .ok_or_else(IoError::last_os_error)
564 .map_err(|error| fs_error(&error, "preserve security attributes", path))
565}
566
567#[cfg(not(target_os = "macos"))]
568fn copy_security_attributes(_source: &File, _output: &File, _path: &Path) -> Result { Ok(()) }
569
570#[cfg(unix)]
571fn sync_parent(parent: &Path) -> Result {
572 let directory =
573 File::open(parent).map_err(|error| fs_error(&error, "open output directory", parent))?;
574
575 directory
576 .sync_all()
577 .map_err(|error| fs_error(&error, "synchronize output directory", parent))?;
578
579 Ok(())
580}
581
582#[cfg(not(unix))]
583fn sync_parent(_parent: &Path) -> Result { Ok(()) }
584
585fn sync_after_commit(parent: &Path, path: &Path) {
586 sync_parent(parent)
587 .inspect_err(|error| {
588 debug_warn!(
589 ?error,
590 path = %path.display(),
591 "Config output was installed, but its directory could not be synchronized.",
592 );
593 })
594 .ok();
595}
596
597fn cleanup_after_commit(guard: &mut TempGuard) {
598 guard
599 .remove()
600 .inspect_err(|error| {
601 debug_warn!(
602 ?error,
603 path = %guard.path.display(),
604 "Config output was installed, but a temporary file could not be removed.",
605 );
606 })
607 .ok();
608}
609
610fn entry_exists(path: &Path) -> Result<bool> {
611 match symlink_metadata(path) {
612 | Ok(_) => Ok(true),
613 | Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
614 | Err(error) => Err(fs_error(&error, "inspect backup path", path)),
615 }
616}
617
618fn fs_error(error: &IoError, operation: &str, path: &Path) -> Error {
619 err!("Failed to {operation} `{}`: {error}", path.display())
620}
621
622fn fs_pair_error(
623 error: &IoError,
624 operation: impl Display,
625 source: &Path,
626 target: &Path,
627) -> Error {
628 err!(
629 "Failed to {operation} `{}` as `{}`: {error}",
630 source.display(),
631 target.display(),
632 )
633}
634
635impl TempGuard {
636 fn remove(&mut self) -> IoResult<()> {
637 match remove_file(&self.path) {
638 | Ok(()) => self.armed = false,
639 | Err(error) if error.kind() == ErrorKind::NotFound => self.armed = false,
640 | Err(error) => return Err(error),
641 }
642
643 Ok(())
644 }
645}
646
647impl Drop for TempGuard {
648 fn drop(&mut self) {
649 if !self.armed {
650 return;
651 }
652
653 match remove_file(&self.path) {
654 | Ok(()) => {},
655 | Err(error) if error.kind() == ErrorKind::NotFound => {},
656 | Err(error) => {
657 debug_warn!(
658 ?error,
659 path = %self.path.display(),
660 "Failed to remove temporary config output.",
661 );
662 },
663 }
664 }
665}