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 = "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	// SAFETY: Both pointers remain valid for the call and name existing paths.
336	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	// SAFETY: The descriptor is live and the numeric IDs were supplied by the OS.
417	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}