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 = "linux")]
9use std::{ffi::CString, os::unix::ffi::OsStrExt as _};
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
23#[cfg(target_os = "linux")]
24use libc::{AT_FDCWD, RENAME_EXCHANGE, renameat2};
25#[cfg(unix)]
26use libc::{O_CLOEXEC, O_NOFOLLOW, O_NONBLOCK, fchown};
27
28use crate::{Err, Error, Result, debug_warn, err};
29
30static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
31
32struct TempGuard {
33 path: PathBuf,
34 armed: bool,
35}
36
37pub(super) fn write_atomic(path: &Path, content: &[u8], force: bool) -> Result {
38 write_atomic_inner(path, content, force, || {})
39}
40
41#[cfg(test)]
42pub(super) fn write_atomic_with_precommit(
43 path: &Path,
44 content: &[u8],
45 force: bool,
46 before_commit: impl FnOnce(),
47) -> Result {
48 write_atomic_inner(path, content, force, before_commit)
49}
50
51fn write_atomic_inner(
52 path: &Path,
53 content: &[u8],
54 force: bool,
55 before_commit: impl FnOnce(),
56) -> Result {
57 let parent = path
58 .parent()
59 .filter(|parent| !parent.as_os_str().is_empty())
60 .unwrap_or_else(|| Path::new("."));
61
62 let metadata = match symlink_metadata(path) {
63 | Err(error) if error.kind() == ErrorKind::NotFound => None,
64 | Ok(metadata) => Some(metadata),
65 | Err(error) => return Err(fs_error(&error, "inspect output", path)),
66 };
67
68 match metadata {
69 | Some(metadata) if is_nonregular(&metadata) => {
70 Err!("Output is not a regular file: {}.", path.display())
71 },
72 | Some(_) if !force => Err!("Output already exists: {}.", path.display()),
73 | Some(_) => write_replacement(parent, path, content, before_commit),
74 | None => write_new(parent, path, content, before_commit),
75 }
76}
77
78fn write_new(parent: &Path, path: &Path, content: &[u8], before_commit: impl FnOnce()) -> Result {
79 let mut output = create_synced_temp(parent, path, content, None)?;
80
81 before_commit();
82 install_noreplace(&output.path, path, "output")?;
83
84 cleanup_after_commit(&mut output);
85 sync_after_commit(parent, path);
86
87 Ok(())
88}
89
90fn write_replacement(
91 parent: &Path,
92 path: &Path,
93 content: &[u8],
94 before_commit: impl FnOnce(),
95) -> Result {
96 let backup = backup_path(path);
97
98 if entry_exists(&backup)? {
99 return Err!("Backup already exists: {}.", backup.display());
100 }
101
102 let (mut source, metadata) = open_target(path)?;
103 let mut output = create_synced_temp(parent, path, content, Some(&metadata))?;
104 let (mut backup_guard, mut backup_file) =
105 copy_backup(parent, &backup, &mut source, &metadata)?;
106
107 install_noreplace(&backup_guard.path, &backup, "backup")?;
108 sync_parent(parent)
109 .map_err(|error| {
110 err!("Failed to synchronize staged backup `{}`: {error}", backup.display())
111 })
112 .map_err(|error| discard_backup(&backup, parent, error))?;
113
114 before_commit();
115 exchange(&output.path, path).map_err(|error| discard_backup(&backup, parent, error))?;
116 validate_displaced(
117 &output.path,
118 &metadata,
119 &mut source,
120 &mut backup_file,
121 &backup_guard.path,
122 )
123 .map_err(|error| {
124 rollback_replacement(&mut output, &mut backup_guard, path, &backup, error)
125 })?;
126
127 cleanup_after_commit(&mut output);
128 cleanup_after_commit(&mut backup_guard);
129 sync_after_commit(parent, path);
130
131 Ok(())
132}
133
134fn create_synced_temp(
135 parent: &Path,
136 target: &Path,
137 content: &[u8],
138 metadata: Option<&Metadata>,
139) -> Result<TempGuard> {
140 let (path, mut file) = create_temp(parent, target)?;
141 let guard = TempGuard { path, armed: true };
142
143 file.write_all(content)
144 .map_err(|error| fs_error(&error, "write temporary output", &guard.path))?;
145
146 set_output_metadata(&file, metadata, &guard.path)?;
147
148 file.sync_all()
149 .map_err(|error| fs_error(&error, "synchronize temporary output", &guard.path))?;
150
151 Ok(guard)
152}
153
154fn copy_backup(
155 parent: &Path,
156 backup: &Path,
157 source: &mut File,
158 metadata: &Metadata,
159) -> Result<(TempGuard, File)> {
160 let (path, mut file) = create_temp(parent, backup)?;
161 let guard = TempGuard { path, armed: true };
162
163 copy(source, &mut file)
164 .map_err(|error| fs_pair_error(&error, "copy output", backup, &guard.path))?;
165
166 set_output_metadata(&file, Some(metadata), &guard.path)?;
167
168 file.sync_all()
169 .map_err(|error| fs_error(&error, "synchronize temporary backup", &guard.path))?;
170
171 Ok((guard, file))
172}
173
174fn open_target(path: &Path) -> Result<(File, Metadata)> {
175 let mut options = OpenOptions::new();
176
177 options.read(true);
178
179 #[cfg(unix)]
180 options.custom_flags(O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
181
182 let file = options
183 .open(path)
184 .map_err(|error| fs_error(&error, "open output", path))?;
185
186 let metadata = file
187 .metadata()
188 .map_err(|error| fs_error(&error, "inspect opened output", path))?;
189
190 if is_nonregular(&metadata) {
191 return Err!("Output is not a regular file: {}.", path.display());
192 }
193
194 Ok((file, metadata))
195}
196
197fn validate_displaced(
198 displaced: &Path,
199 metadata: &Metadata,
200 source: &mut File,
201 backup: &mut File,
202 backup_path: &Path,
203) -> Result {
204 let displaced_metadata = symlink_metadata(displaced)
205 .map_err(|error| fs_error(&error, "inspect displaced output", displaced))?;
206
207 if !same_file(metadata, &displaced_metadata) {
208 return Err!("Output changed while its replacement was being prepared.");
209 }
210
211 if !same_contents(source, backup, displaced, backup_path)? {
212 return Err!("Output contents changed while its replacement was being prepared.");
213 }
214
215 Ok(())
216}
217
218fn same_contents(
219 left: &mut File,
220 right: &mut File,
221 left_path: &Path,
222 right_path: &Path,
223) -> Result<bool> {
224 left.seek(SeekFrom::Start(0))
225 .map_err(|error| fs_error(&error, "rewind displaced output", left_path))?;
226
227 right
228 .seek(SeekFrom::Start(0))
229 .map_err(|error| fs_error(&error, "rewind temporary backup", right_path))?;
230
231 let mut left_buf = [0_u8; 4096];
232 let mut right_buf = [0_u8; 4096];
233
234 loop {
235 let left_len = left
236 .read(&mut left_buf)
237 .map_err(|error| fs_error(&error, "read displaced output", left_path))?;
238
239 let right_len = right
240 .read(&mut right_buf)
241 .map_err(|error| fs_error(&error, "read temporary backup", right_path))?;
242
243 if left_len != right_len {
244 return Ok(false);
245 }
246
247 if left_buf[..left_len] != right_buf[..left_len] {
248 return Ok(false);
249 }
250
251 if left_len == 0 {
252 return Ok(true);
253 }
254 }
255}
256
257fn rollback_replacement(
258 output: &mut TempGuard,
259 backup_guard: &mut TempGuard,
260 path: &Path,
261 backup: &Path,
262 cause: Error,
263) -> Error {
264 if let Err(recovery_error) = exchange(&output.path, path) {
265 output.armed = false;
266 backup_guard.armed = false;
267
268 return err!(
269 "Output `{}` changed after replacement validation failed: {cause} Restoring it also \
270 failed: {recovery_error} Recover from `{}`; the displaced target remains at `{}`.",
271 path.display(),
272 backup.display(),
273 output.path.display(),
274 );
275 }
276
277 let parent = backup
278 .parent()
279 .filter(|parent| !parent.as_os_str().is_empty())
280 .unwrap_or_else(|| Path::new("."));
281
282 discard_backup(backup, parent, cause)
283}
284
285fn discard_backup(backup: &Path, parent: &Path, cause: Error) -> Error {
286 match remove_file(backup) {
287 | Ok(()) => {},
288 | Err(error) if error.kind() == ErrorKind::NotFound => {},
289 | Err(error) => {
290 return err!(
291 "{cause} Output was not changed, but the staged backup remains at `{}` because \
292 it could not be removed: {error}",
293 backup.display(),
294 );
295 },
296 }
297
298 if let Err(error) = sync_parent(parent) {
299 return err!(
300 "{cause} Output was not changed, but backup cleanup could not be synchronized: \
301 {error}",
302 );
303 }
304
305 cause
306}
307
308fn install_noreplace(source: &Path, target: &Path, kind: &str) -> Result {
309 match hard_link(source, target) {
310 | Ok(()) => Ok(()),
311 | Err(error) if error.kind() != ErrorKind::AlreadyExists =>
312 Err(fs_pair_error(&error, format_args!("install {kind}"), source, target)),
313 | Err(_) => {
314 Err!("{} already exists: {}.", title(kind), target.display())
315 },
316 }
317}
318
319fn title(kind: &str) -> &str {
320 match kind {
321 | "output" => "Output",
322 | "backup" => "Backup",
323 | _ => "Destination",
324 }
325}
326
327#[cfg(target_os = "linux")]
328fn exchange(left: &Path, right: &Path) -> Result {
329 let left_name = CString::new(left.as_os_str().as_bytes())
330 .map_err(|error| err!("Invalid output path `{}`: {error}", left.display()))?;
331
332 let right_name = CString::new(right.as_os_str().as_bytes())
333 .map_err(|error| err!("Invalid output path `{}`: {error}", right.display()))?;
334
335 let result = unsafe {
337 renameat2(AT_FDCWD, left_name.as_ptr(), AT_FDCWD, right_name.as_ptr(), RENAME_EXCHANGE)
338 };
339
340 if result == -1 {
341 let error = IoError::last_os_error();
342
343 return Err(fs_pair_error(&error, "exchange", left, right));
344 }
345
346 Ok(())
347}
348
349#[cfg(not(target_os = "linux"))]
350fn exchange(left: &Path, right: &Path) -> Result {
351 Err!(
352 "Safe forced replacement of `{}` with `{}` is unsupported on this platform.",
353 right.display(),
354 left.display(),
355 )
356}
357
358#[cfg(unix)]
359fn same_file(left: &Metadata, right: &Metadata) -> bool {
360 left.dev() == right.dev() && left.ino() == right.ino()
361}
362
363#[cfg(not(unix))]
364fn same_file(_left: &Metadata, _right: &Metadata) -> bool { false }
365
366#[expect(
367 clippy::filetype_is_file,
368 reason = "Every nonregular output target must be rejected, including devices and sockets."
369)]
370fn is_nonregular(metadata: &Metadata) -> bool { !metadata.file_type().is_file() }
371
372fn create_temp(parent: &Path, target: &Path) -> Result<(PathBuf, File)> {
373 let filename = target
374 .file_name()
375 .ok_or_else(|| err!("Output path has no file name: {}.", target.display()))?;
376
377 loop {
378 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
379 let mut name = OsString::from(".");
380
381 name.push(filename);
382 name.push(format!(".{}.{}.tmp", id(), sequence));
383 let path = parent.join(name);
384 let mut options = OpenOptions::new();
385
386 options.read(true).write(true).create_new(true);
387
388 #[cfg(unix)]
389 options.mode(0o600);
390
391 match options.open(&path) {
392 | Ok(file) => return Ok((path, file)),
393 | Err(error) if error.kind() == ErrorKind::AlreadyExists => {},
394 | Err(error) => return Err(fs_error(&error, "create temporary output", &path)),
395 }
396 }
397}
398
399fn backup_path(path: &Path) -> PathBuf {
400 let mut backup = path.as_os_str().to_owned();
401
402 backup.push(".bak");
403
404 backup.into()
405}
406
407#[cfg(unix)]
408fn set_output_metadata(file: &File, metadata: Option<&Metadata>, path: &Path) -> Result {
409 let Some(metadata) = metadata else {
410 file.set_permissions(Permissions::from_mode(0o600))
411 .map_err(|error| fs_error(&error, "set temporary output permissions", path))?;
412
413 return Ok(());
414 };
415
416 if unsafe { fchown(file.as_raw_fd(), metadata.uid(), metadata.gid()) } == -1 {
418 let error = fs_error(
419 &IoError::last_os_error(),
420 "preserve output ownership on temporary file",
421 path,
422 );
423
424 return Err(error);
425 }
426
427 file.set_permissions(Permissions::from_mode(metadata.mode()))
428 .map_err(|error| {
429 fs_error(&error, "preserve output permissions on temporary file", path)
430 })?;
431
432 Ok(())
433}
434
435#[cfg(not(unix))]
436fn set_output_metadata(file: &File, metadata: Option<&Metadata>, path: &Path) -> Result {
437 if let Some(metadata) = metadata {
438 file.set_permissions(metadata.permissions())
439 .map_err(|error| {
440 fs_error(&error, "preserve output permissions on temporary file", path)
441 })?;
442 }
443
444 Ok(())
445}
446
447#[cfg(unix)]
448fn sync_parent(parent: &Path) -> Result {
449 let directory =
450 File::open(parent).map_err(|error| fs_error(&error, "open output directory", parent))?;
451
452 directory
453 .sync_all()
454 .map_err(|error| fs_error(&error, "synchronize output directory", parent))?;
455
456 Ok(())
457}
458
459#[cfg(not(unix))]
460fn sync_parent(_parent: &Path) -> Result { Ok(()) }
461
462fn sync_after_commit(parent: &Path, path: &Path) {
463 sync_parent(parent)
464 .inspect_err(|error| {
465 debug_warn!(
466 ?error,
467 path = %path.display(),
468 "Config output was installed, but its directory could not be synchronized.",
469 );
470 })
471 .ok();
472}
473
474fn cleanup_after_commit(guard: &mut TempGuard) {
475 guard
476 .remove()
477 .inspect_err(|error| {
478 debug_warn!(
479 ?error,
480 path = %guard.path.display(),
481 "Config output was installed, but a temporary file could not be removed.",
482 );
483 })
484 .ok();
485}
486
487fn entry_exists(path: &Path) -> Result<bool> {
488 match symlink_metadata(path) {
489 | Ok(_) => Ok(true),
490 | Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
491 | Err(error) => Err(fs_error(&error, "inspect backup path", path)),
492 }
493}
494
495fn fs_error(error: &IoError, operation: &str, path: &Path) -> Error {
496 err!("Failed to {operation} `{}`: {error}", path.display())
497}
498
499fn fs_pair_error(
500 error: &IoError,
501 operation: impl Display,
502 source: &Path,
503 target: &Path,
504) -> Error {
505 err!(
506 "Failed to {operation} `{}` as `{}`: {error}",
507 source.display(),
508 target.display(),
509 )
510}
511
512impl TempGuard {
513 fn remove(&mut self) -> IoResult<()> {
514 match remove_file(&self.path) {
515 | Ok(()) => self.armed = false,
516 | Err(error) if error.kind() == ErrorKind::NotFound => self.armed = false,
517 | Err(error) => return Err(error),
518 }
519
520 Ok(())
521 }
522}
523
524impl Drop for TempGuard {
525 fn drop(&mut self) {
526 if !self.armed {
527 return;
528 }
529
530 match remove_file(&self.path) {
531 | Ok(()) => {},
532 | Err(error) if error.kind() == ErrorKind::NotFound => {},
533 | Err(error) => {
534 debug_warn!(
535 ?error,
536 path = %self.path.display(),
537 "Failed to remove temporary config output.",
538 );
539 },
540 }
541 }
542}