qcs_api_client_common/configuration/
fs.rs1use std::path::{Path, PathBuf};
4
5use async_tempfile::TempFile;
6use tokio::io::AsyncWriteExt as _;
7
8use super::error::{IoErrorWithPath, IoOperation, WriteError};
9
10pub async fn atomic_write(
30 path: impl AsRef<Path> + Send + Sync,
31 bytes: &[u8],
32) -> Result<(), WriteError> {
33 let dest = canonical_destination(path.as_ref()).await?;
34 let dest_dir = dest.parent().unwrap_or_else(|| Path::new("."));
35
36 let mut temp_file = TempFile::new_in(dest_dir).await?;
37 #[cfg(feature = "tracing")]
38 tracing::debug!("staging temporary file at {:?}", temp_file.file_path());
39
40 if let Some(permissions) = permissions_for(&dest).await? {
41 temp_file
42 .set_permissions(permissions)
43 .await
44 .map_err(|error| IoErrorWithPath {
45 error,
46 path: temp_file.file_path().clone(),
47 operation: IoOperation::SetPermissions,
48 })?;
49 }
50
51 temp_file
52 .write_all(bytes)
53 .await
54 .map_err(|error| IoErrorWithPath {
55 error,
56 path: temp_file.file_path().clone(),
57 operation: IoOperation::Write,
58 })?;
59 temp_file.flush().await.map_err(|error| IoErrorWithPath {
60 error,
61 path: temp_file.file_path().clone(),
62 operation: IoOperation::Flush,
63 })?;
64
65 let temp_file_path = temp_file.file_path();
66 #[cfg(feature = "tracing")]
67 tracing::debug!("atomically replacing {dest:?} with {temp_file_path:?}");
68 tokio::fs::rename(temp_file_path, &dest)
69 .await
70 .map_err(|error| IoErrorWithPath {
71 error,
72 path: temp_file_path.clone(),
73 operation: IoOperation::Rename { dest: dest.clone() },
74 })?;
75
76 Ok(())
77}
78
79async fn permissions_for(dest: &Path) -> Result<Option<std::fs::Permissions>, WriteError> {
85 match tokio::fs::metadata(dest).await {
86 Ok(metadata) => Ok(Some(metadata.permissions())),
87 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
88 #[cfg(unix)]
89 {
90 use std::os::unix::fs::PermissionsExt as _;
91 Ok(Some(std::fs::Permissions::from_mode(0o600)))
92 }
93 #[cfg(not(unix))]
94 {
95 Ok(None)
96 }
97 }
98 Err(error) => Err(IoErrorWithPath {
99 error,
100 path: dest.to_path_buf(),
101 operation: IoOperation::GetMetadata,
102 }
103 .into()),
104 }
105}
106
107async fn canonical_destination(path: &Path) -> Result<PathBuf, WriteError> {
116 if let Ok(canonical) = tokio::fs::canonicalize(path).await {
117 return Ok(canonical);
118 }
119
120 let parent = path
121 .parent()
122 .filter(|parent| !parent.as_os_str().is_empty())
123 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
124 tokio::fs::create_dir_all(&parent)
125 .await
126 .map_err(|error| IoErrorWithPath {
127 error,
128 path: parent.clone(),
129 operation: IoOperation::Write,
130 })?;
131 let canonical_parent =
132 tokio::fs::canonicalize(&parent)
133 .await
134 .map_err(|error| IoErrorWithPath {
135 error,
136 path: parent.clone(),
137 operation: IoOperation::GetMetadata,
141 })?;
142
143 let file_name = path.file_name().ok_or_else(|| IoErrorWithPath {
144 error: std::io::Error::new(
145 std::io::ErrorKind::InvalidInput,
146 "destination path has no file name",
147 ),
148 path: path.to_path_buf(),
149 operation: IoOperation::Write,
150 })?;
151 Ok(canonical_parent.join(file_name))
152}
153
154#[cfg(test)]
155mod tests {
156 use std::time::{SystemTime, UNIX_EPOCH};
157
158 use super::{atomic_write, canonical_destination};
159
160 fn unique_test_root(label: &str) -> std::path::PathBuf {
161 std::env::temp_dir().join(format!(
162 "qcs-common-atomic-write-test-{label}-{}-{}",
163 std::process::id(),
164 SystemTime::now()
165 .duration_since(UNIX_EPOCH)
166 .expect("system clock should be after unix epoch")
167 .as_nanos()
168 ))
169 }
170
171 #[tokio::test]
172 async fn destination_resolves_next_to_the_target_creating_missing_dirs() {
173 let root = unique_test_root("missing-dirs");
174 let target_dir = root.join("nested").join("config");
175 let target_file = target_dir.join("secrets.toml");
176
177 let dest = canonical_destination(&target_file)
178 .await
179 .expect("should resolve a destination next to the target file");
180
181 let canonical_target_dir = tokio::fs::canonicalize(&target_dir)
182 .await
183 .expect("parent directory should have been created");
184 assert_eq!(dest.parent(), Some(canonical_target_dir.as_path()));
185 assert_eq!(dest.file_name(), target_file.file_name());
186
187 std::fs::remove_dir_all(root).expect("should remove the test directory");
188 }
189
190 #[cfg(unix)]
194 #[tokio::test]
195 async fn destination_follows_a_symlinked_file_to_its_real_location() {
196 let root = unique_test_root("symlinked-file");
197 let real_dir = root.join("real");
198 let link_dir = root.join("links");
199 tokio::fs::create_dir_all(&real_dir)
200 .await
201 .expect("should create the real directory");
202 tokio::fs::create_dir_all(&link_dir)
203 .await
204 .expect("should create the links directory");
205
206 let real_file = real_dir.join("secrets.toml");
207 tokio::fs::write(&real_file, b"contents")
208 .await
209 .expect("should create the real file");
210
211 let symlink = link_dir.join("secrets.toml");
212 std::os::unix::fs::symlink(&real_file, &symlink).expect("should create the symlink");
213
214 let dest = canonical_destination(&symlink)
215 .await
216 .expect("should resolve the symlink to its target");
217
218 let canonical_real_dir = tokio::fs::canonicalize(&real_dir)
219 .await
220 .expect("real directory should exist");
221 assert_eq!(dest.parent(), Some(canonical_real_dir.as_path()));
222
223 std::fs::remove_dir_all(root).expect("should remove the test directory");
224 }
225
226 #[tokio::test]
227 async fn atomic_write_creates_then_overwrites_the_file() {
228 let root = unique_test_root("write");
229 let target = root.join("nested").join("secrets.toml");
230
231 atomic_write(&target, b"first")
232 .await
233 .expect("should create the file");
234 assert_eq!(
235 tokio::fs::read(&target).await.expect("file should exist"),
236 b"first"
237 );
238
239 atomic_write(&target, b"second")
240 .await
241 .expect("should overwrite the file");
242 assert_eq!(
243 tokio::fs::read(&target).await.expect("file should exist"),
244 b"second"
245 );
246
247 std::fs::remove_dir_all(root).expect("should remove the test directory");
248 }
249
250 #[cfg(unix)]
251 #[tokio::test]
252 async fn atomic_write_creates_new_files_with_owner_only_permissions() {
253 use std::os::unix::fs::PermissionsExt as _;
254
255 let root = unique_test_root("perms");
256 let target = root.join("secrets.toml");
257
258 atomic_write(&target, b"secret")
259 .await
260 .expect("should create the file");
261
262 let mode = tokio::fs::metadata(&target)
263 .await
264 .expect("file should exist")
265 .permissions()
266 .mode();
267 assert_eq!(mode & 0o777, 0o600);
268
269 std::fs::remove_dir_all(root).expect("should remove the test directory");
270 }
271}