ryra_core/system/
atomic_write.rs1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::Path;
4
5use crate::error::{Error, Result};
6
7pub fn atomic_write(path: &Path, contents: &[u8], mode: u32) -> Result<()> {
21 let parent = path.parent().ok_or_else(|| Error::FileWrite {
22 path: path.to_path_buf(),
23 source: std::io::Error::new(
24 std::io::ErrorKind::InvalidInput,
25 "path has no parent directory",
26 ),
27 })?;
28
29 if !parent.as_os_str().is_empty() {
31 std::fs::create_dir_all(parent).map_err(|source| Error::DirCreate {
32 path: parent.to_path_buf(),
33 source,
34 })?;
35 }
36
37 let name = path.file_name().ok_or_else(|| Error::FileWrite {
38 path: path.to_path_buf(),
39 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name"),
40 })?;
41
42 if let Ok(meta) = std::fs::symlink_metadata(path)
47 && meta.file_type().is_symlink()
48 {
49 return Err(Error::FileWrite {
50 path: path.to_path_buf(),
51 source: std::io::Error::new(
52 std::io::ErrorKind::InvalidInput,
53 "refusing to overwrite a symlink — resolve the symlink or remove it",
54 ),
55 });
56 }
57
58 let mut tmp_name = std::ffi::OsString::from(".");
61 tmp_name.push(name);
62 tmp_name.push(".tmp.");
63 tmp_name.push(std::process::id().to_string());
64 let tmp_path = parent.join(tmp_name);
65
66 let write_result = (|| -> Result<()> {
67 let mut opts = OpenOptions::new();
68 opts.write(true).create(true).truncate(true);
69 #[cfg(unix)]
70 {
71 use std::os::unix::fs::OpenOptionsExt;
72 opts.mode(mode);
73 }
74 let mut f = opts.open(&tmp_path).map_err(|source| Error::FileWrite {
75 path: tmp_path.clone(),
76 source,
77 })?;
78
79 f.write_all(contents).map_err(|source| Error::FileWrite {
80 path: tmp_path.clone(),
81 source,
82 })?;
83 f.sync_all().map_err(|source| Error::FileWrite {
84 path: tmp_path.clone(),
85 source,
86 })?;
87
88 #[cfg(not(unix))]
92 {
93 let _ = mode;
94 }
95
96 std::fs::rename(&tmp_path, path).map_err(|source| Error::FileWrite {
97 path: path.to_path_buf(),
98 source,
99 })?;
100
101 Ok(())
102 })();
103
104 if write_result.is_err() {
107 let _ = std::fs::remove_file(&tmp_path);
108 }
109
110 write_result
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn writes_file_with_mode() -> std::result::Result<(), Box<dyn std::error::Error>> {
119 let dir = tempfile::tempdir()?;
120 let path = dir.path().join("secret.toml");
121 atomic_write(&path, b"hello=world\n", 0o600)?;
122
123 let contents = std::fs::read_to_string(&path)?;
124 assert_eq!(contents, "hello=world\n");
125
126 #[cfg(unix)]
127 {
128 use std::os::unix::fs::PermissionsExt;
129 let mode = std::fs::metadata(&path)?.permissions().mode() & 0o777;
130 assert_eq!(mode, 0o600);
131 }
132 Ok(())
133 }
134
135 #[test]
136 fn overwrites_existing() -> std::result::Result<(), Box<dyn std::error::Error>> {
137 let dir = tempfile::tempdir()?;
138 let path = dir.path().join("config.toml");
139 atomic_write(&path, b"first\n", 0o644)?;
140 atomic_write(&path, b"second\n", 0o644)?;
141
142 let contents = std::fs::read_to_string(&path)?;
143 assert_eq!(contents, "second\n");
144 Ok(())
145 }
146
147 #[test]
148 #[cfg(unix)]
149 fn refuses_to_clobber_symlink() -> std::result::Result<(), Box<dyn std::error::Error>> {
150 let dir = tempfile::tempdir()?;
151 let target = dir.path().join("real.toml");
152 std::fs::write(&target, b"original")?;
153 let link = dir.path().join("config.toml");
154 std::os::unix::fs::symlink(&target, &link)?;
155
156 let result = atomic_write(&link, b"new", 0o644);
157 assert!(result.is_err(), "expected error, got {result:?}");
158
159 assert_eq!(std::fs::read_to_string(&target)?, "original");
161 Ok(())
162 }
163
164 #[test]
165 fn tightens_permissions_on_overwrite() -> std::result::Result<(), Box<dyn std::error::Error>> {
166 let dir = tempfile::tempdir()?;
167 let path = dir.path().join("preferences.toml");
168 atomic_write(&path, b"v1", 0o644)?;
169 atomic_write(&path, b"v2", 0o600)?;
170
171 #[cfg(unix)]
172 {
173 use std::os::unix::fs::PermissionsExt;
174 let mode = std::fs::metadata(&path)?.permissions().mode() & 0o777;
175 assert_eq!(mode, 0o600, "rename-over should install the new mode");
176 }
177 Ok(())
178 }
179}