write_atomic/
lib.rs

1/*!
2# Write Atomic
3
4[![docs.rs](https://img.shields.io/docsrs/write_atomic.svg?style=flat-square&label=docs.rs)](https://docs.rs/write_atomic/)
5[![changelog](https://img.shields.io/crates/v/write_atomic.svg?style=flat-square&label=changelog&color=9b59b6)](https://github.com/Blobfolio/write_atomic/blob/master/CHANGELOG.md)<br>
6[![crates.io](https://img.shields.io/crates/v/write_atomic.svg?style=flat-square&label=crates.io)](https://crates.io/crates/write_atomic)
7[![ci](https://img.shields.io/github/actions/workflow/status/Blobfolio/write_atomic/ci.yaml?style=flat-square&label=ci)](https://github.com/Blobfolio/write_atomic/actions)
8[![deps.rs](https://deps.rs/crate/write_atomic/latest/status.svg?style=flat-square&label=deps.rs)](https://deps.rs/crate/write_atomic/)<br>
9[![license](https://img.shields.io/badge/license-wtfpl-ff1493?style=flat-square)](https://en.wikipedia.org/wiki/WTFPL)
10[![contributions welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square&label=contributions)](https://github.com/Blobfolio/write_atomic/issues)
11
12Write Atomic was originally a stripped-down remake of [`tempfile-fast`](https://crates.io/crates/tempfile-fast), but with the `3.4.0` release of [`tempfile`](https://crates.io/crates/tempfile), it has largely been mooted.
13
14(`tempfile` now supports Linux optimizations like `O_TMPFILE` natively.)
15
16That said, one might still enjoy the ergonomic single-shot nature of Write Atomic's [`write_file`] and [`copy_file`] methods, as well as their permission/ownership-syncing behaviors, and so it lives on!
17
18## Examples
19
20```no_run
21// One line is all it takes:
22write_atomic::write_file("/path/to/my-file.txt", b"Some data!").unwrap();
23```
24*/
25
26#![forbid(unsafe_code)]
27
28#![deny(
29	clippy::allow_attributes_without_reason,
30	clippy::correctness,
31	unreachable_pub,
32)]
33
34#![warn(
35	clippy::complexity,
36	clippy::nursery,
37	clippy::pedantic,
38	clippy::perf,
39	clippy::style,
40
41	clippy::allow_attributes,
42	clippy::clone_on_ref_ptr,
43	clippy::create_dir,
44	clippy::filetype_is_file,
45	clippy::format_push_string,
46	clippy::get_unwrap,
47	clippy::impl_trait_in_params,
48	clippy::lossy_float_literal,
49	clippy::missing_assert_message,
50	clippy::missing_docs_in_private_items,
51	clippy::needless_raw_strings,
52	clippy::panic_in_result_fn,
53	clippy::pub_without_shorthand,
54	clippy::rest_pat_in_fully_bound_structs,
55	clippy::semicolon_inside_block,
56	clippy::str_to_string,
57	clippy::string_to_string,
58	clippy::todo,
59	clippy::undocumented_unsafe_blocks,
60	clippy::unneeded_field_pattern,
61	clippy::unseparated_literal_suffix,
62	clippy::unwrap_in_result,
63
64	macro_use_extern_crate,
65	missing_copy_implementations,
66	missing_docs,
67	non_ascii_idents,
68	trivial_casts,
69	trivial_numeric_casts,
70	unused_crate_dependencies,
71	unused_extern_crates,
72	unused_import_braces,
73)]
74
75
76
77use filetime::FileTime;
78use std::{
79	fs::{
80		File,
81		Metadata,
82	},
83	io::{
84		Error,
85		ErrorKind,
86		Result,
87		Write,
88	},
89	path::{
90		Path,
91		PathBuf,
92	},
93};
94use tempfile::NamedTempFile;
95
96#[cfg(unix)]
97use std::os::unix::fs::MetadataExt;
98
99// Re-export both dependencies.
100pub use filetime;
101pub use tempfile;
102
103
104
105/// # Atomic File Copy!
106///
107/// Copy the contents — and permissions, ownership, and access/modification
108/// times — of one file to another, atomically.
109///
110/// Similar to [`write_file`], this method first copies everything over to a
111/// temporary file before moving it into place.
112///
113/// ## Examples
114///
115/// ```no_run
116/// // It's just one line:
117/// match write_atomic::copy_file("/some/source.jpg", "/some/copy.jpg") {
118///     // The file was copied!
119///     Ok(()) => {},
120///
121///     // There was an std::io::Error.
122///     Err(e) => panic!("{e}"),
123/// };
124/// ```
125///
126/// ## Errors
127///
128/// This will bubble up any filesystem-related errors encountered along the
129/// way.
130pub fn copy_file<P>(src: P, dst: P) -> Result<()>
131where P: AsRef<Path> {
132	let src = src.as_ref();
133	let (dst, parent) = check_path(dst)?;
134
135	let file = tempfile::Builder::new().tempfile_in(parent)?;
136	std::fs::copy(src, &file)?;
137	let meta = std::fs::metadata(src)?;
138	copy_metadata(&meta, file.as_file(), true)?;
139	write_finish(file, &dst)
140}
141
142/// # Atomic File Write!
143///
144/// Write content to a file, atomically.
145///
146/// Under the hood, this method creates a temporary file to hold all the
147/// changes, then moves that file into place once everything is good to go.
148///
149/// If a file already exists at the destination path, this method will (try
150/// to) preserve its permissions and ownership.
151///
152/// If not, it will simply create it.
153///
154/// Unlike [`File::create`](std::fs::File::create), this method will also
155/// attempt to create any missing parent directories.
156///
157/// ## Examples
158///
159/// ```no_run
160/// // It's just one line:
161/// match write_atomic::write_file("/path/to/my/file.txt", b"Some data!") {
162///     // The file was saved!
163///     Ok(()) => {},
164///
165///     // There was an std::io::Error.
166///     Err(e) => panic!("{e}"),
167/// };
168/// ```
169///
170/// ## Errors
171///
172/// This will bubble up any filesystem-related errors encountered along the
173/// way.
174pub fn write_file<P>(dst: P, data: &[u8]) -> Result<()>
175where P: AsRef<Path> {
176	let (dst, parent) = check_path(dst)?;
177
178	let mut file = tempfile::Builder::new().tempfile_in(parent)?;
179	file.write_all(data)?;
180	file.flush()?;
181
182	try_copy_metadata(&dst, file.as_file())?;
183	write_finish(file, &dst)
184}
185
186
187
188/// # Handle Path.
189///
190/// This checks the path and returns it and its parent, assuming it is valid,
191/// or an error if not.
192fn check_path<P>(src: P) -> Result<(PathBuf, PathBuf)>
193where P: AsRef<Path> {
194	// Normalize the formatting.
195	let src = std::path::absolute(src)?;
196
197	// The path cannot be a directory.
198	if src.is_dir() {
199		return Err(Error::new(ErrorKind::InvalidInput, "Path cannot be a directory."));
200	}
201
202	// The path must have a parent.
203	let parent = src.parent()
204		.ok_or_else(|| Error::new(ErrorKind::NotFound, "Path must have a parent directory."))?;
205
206	// Create the parent if it doesn't already exist.
207	std::fs::create_dir_all(parent)?;
208
209	// It has to be owned for return purposes.
210	let parent = parent.to_path_buf();
211
212	// We're good to go!
213	Ok((src, parent))
214}
215
216/// # Copy Metadata.
217///
218/// Make sure we don't lose details like permissions, ownership, etc., when
219/// replacing an existing file.
220fn copy_metadata(src: &Metadata, dst: &File, times: bool) -> Result<()> {
221	// Copy permissions.
222	dst.set_permissions(src.permissions())?;
223
224	#[cfg(unix)]
225	// Copy ownership.
226	std::os::unix::fs::fchown(dst, Some(src.uid()), Some(src.gid()))?;
227
228	// Copy file times too?
229	if times {
230		let atime = FileTime::from_last_access_time(src);
231		let mtime = FileTime::from_last_modification_time(src);
232		let _res = filetime::set_file_handle_times(dst, Some(atime), Some(mtime));
233	}
234
235	Ok(())
236}
237
238/// # Try Copy Metadata.
239///
240/// For `write_file` operations, there isn't necessarily an existing file to
241/// copy permissions from.
242///
243/// This method will (temporarily) create one if missing so that the default
244/// file permissions can at least be synced.
245fn try_copy_metadata(src: &Path, dst: &File) -> Result<()> {
246	match std::fs::metadata(src) {
247		// We have a source! Copy the metadata as normal!
248		Ok(meta) => copy_metadata(&meta, dst, false),
249
250		// The file doesn't exist; let's (briefly) create it and sync the
251		// permissions.
252		Err(ref e) if ErrorKind::NotFound == e.kind() => {
253			let mut res = Ok(());
254
255			// Try to create it.
256			if File::create(src).is_ok() {
257				// Grab the permissions.
258				if let Ok(perms) = std::fs::metadata(src).map(|m| m.permissions()) {
259					res = dst.set_permissions(perms);
260				}
261
262				// Clean up.
263				let _res = std::fs::remove_file(src);
264			}
265
266			res
267		},
268
269		// All other errors bubble.
270		Err(e) => Err(e),
271	}
272}
273
274/// # Finish Write.
275///
276/// Persist the temporary file.
277fn write_finish(file: NamedTempFile, dst: &Path) -> Result<()> {
278	file.persist(dst).map(|_| ()).map_err(|e| e.error)
279}
280
281
282
283#[cfg(test)]
284mod tests {
285	use super::*;
286
287	#[cfg(unix)]
288	/// # Get User/Group IDs.
289	fn user_group(meta: &Metadata) -> (u32, u32) {
290		use std::os::unix::fs::MetadataExt;
291		(meta.uid(), meta.gid())
292	}
293
294	#[test]
295	fn test_file_times() {
296		let mut dst = std::env::temp_dir();
297		if ! dst.is_dir() { return; }
298		dst.push("LICENSE-copy.txt");
299
300		// Pull the source's details.
301		let src = std::fs::canonicalize("./LICENSE")
302			.expect("Missing LICENSE file?");
303		let meta1 = std::fs::metadata(&src)
304			.expect("Unable to read LICENSE metadata.");
305
306		// Copy it and pull the destination's details.
307		assert!(copy_file(&src, &dst).is_ok());
308		let meta2 = std::fs::metadata(&dst)
309			.expect("Unable to read LICENSE-copy.txt metadata.");
310
311		// Check sameness!
312		assert_eq!(
313			meta1.permissions(),
314			meta2.permissions(),
315			"Copied permissions not equal.",
316		);
317
318		#[cfg(unix)]
319		assert_eq!(
320			user_group(&meta1),
321			user_group(&meta2),
322			"Copied ownership not equal.",
323		);
324
325		assert_eq!(
326			FileTime::from_last_modification_time(&meta1),
327			FileTime::from_last_modification_time(&meta2),
328			"Copied mtimes not equal.",
329		);
330
331		// Let's rewrite to the same destination and re-verify the
332		// details. (`write_file` only syncs permissions if overwriting.)
333		write_file(&dst, b"Testing a rewrite!").expect("Write failed.");
334		let meta2 = std::fs::metadata(&dst)
335			.expect("Unable to read LICENSE-copy.txt metadata.");
336
337		// Make sure we're reading something new. Haha.
338		assert_eq!(meta2.len(), 18, "Unexpected file length.");
339
340		// Check sameness!
341		assert_eq!(
342			meta1.permissions(),
343			meta2.permissions(),
344			"Copied permissions not equal.",
345		);
346
347		#[cfg(unix)]
348		assert_eq!(
349			user_group(&meta1),
350			user_group(&meta2),
351			"Copied ownership not equal.",
352		);
353
354		// This time around the times should be different!
355		assert_ne!(
356			FileTime::from_last_modification_time(&meta1),
357			FileTime::from_last_modification_time(&meta2),
358			"Mtimes shouldn't match anymore!",
359		);
360
361		// Remove the copy.
362		let _res = std::fs::remove_file(dst);
363	}
364
365	#[test]
366	fn test_write() {
367		// Hopefully sandboxes running this test can write to their own
368		// temporary directory!
369		let mut path = std::env::temp_dir();
370		if ! path.is_dir() { return; }
371		path.push("write-atomic-test.txt");
372
373		// Now that we have a path, let's try to write to it!
374		assert!(write_file(&path, b"This is the first write!").is_ok());
375
376		// Make sure the content is written correctly.
377		assert_eq!(
378			std::fs::read(&path).expect("Unable to open file."),
379			b"This is the first write!",
380		);
381
382		// One more time with different content.
383		assert!(write_file(&path, b"This is the second write!").is_ok());
384
385		// Make sure the content is written correctly.
386		assert_eq!(
387			std::fs::read(&path).expect("Unable to open file."),
388			b"This is the second write!",
389		);
390
391		// Test copy!
392		let path2 = path.parent()
393			.expect("Missing parent?!")
394			.join("copy-atomic-test.txt");
395		assert!(copy_file(&path, &path2).is_ok());
396		assert_eq!(
397			std::fs::read(&path2).expect("Unable to open file."),
398			b"This is the second write!",
399		);
400
401		// Let's clean up after ourselves.
402		let _res = std::fs::remove_file(path);
403		let _res = std::fs::remove_file(path2);
404	}
405}