Skip to main content

tuwunel_core/config/regenerate/
write.rs

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// The raw syscall stands in for the renameat2 wrapper musl never provides,
361// though the libc crate declares it for every Linux target.
362#[cfg(target_os = "linux")]
363fn swap(left: &CStr, right: &CStr) -> IoResult<()> {
364	// SAFETY: Both paths stay borrowed for the call and are NUL-terminated. The
365	// kernel copies them and reports every path condition through errno.
366	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	// SAFETY: Both paths stay borrowed for the call and are NUL-terminated. The
385	// kernel copies them and reports every path condition through errno.
386	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	// A sandbox can deny the chown syscall even where it would change nothing.
488	if staged.uid() == uid && staged.gid() == gid {
489		return Ok(());
490	}
491
492	// SAFETY: The descriptor is live and the numeric IDs were supplied by the OS.
493	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// Applied after commit so a failed replacement never installs a backup whose
526// copied entries could refuse deletion during cleanup.
527#[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	// SAFETY: Both descriptors are live for the call and the null state selects
551	// the default copy behavior.
552	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}