Skip to main content

qcs_api_client_common/configuration/
fs.rs

1//! Utilities for safely writing configuration files to disk.
2
3use std::path::{Path, PathBuf};
4
5use async_tempfile::TempFile;
6use tokio::io::AsyncWriteExt as _;
7
8use super::error::{IoErrorWithPath, IoOperation, WriteError};
9
10/// Atomically overwrite the file at `path` with `bytes`.
11///
12/// The new contents are written to a temporary file which is then renamed over
13/// `path`. To keep that rename atomic, the temporary file is staged in the same
14/// directory as the *canonical* destination: if `path` (or one of its parent
15/// directories) is a symlink crossing a filesystem boundary, staging the
16/// temporary file beside the symlink would put it on a different mount point and
17/// the rename would fail with a `cross-device link error (OS 18)`. Resolving to
18/// the real location first avoids that.
19///
20/// If the destination already exists its permissions are preserved. Otherwise,
21/// on Unix the file is created with `0600` permissions (configuration files may
22/// contain secrets and should not be world-readable); on other platforms the
23/// default permissions are used. Any missing parent directories are created.
24///
25/// # Errors
26///
27/// [`WriteError`] if the destination cannot be resolved, the temporary file
28/// cannot be written, or the final rename fails.
29pub 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
79/// Determine the permissions to apply to the file being written.
80///
81/// An existing file keeps its current permissions. A file that does not yet
82/// exist is created with `0600` on Unix and with default permissions elsewhere
83/// (returned as `None`, meaning "leave as-is").
84async 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
107/// Resolve `path` to its canonical location on the real filesystem, creating the
108/// parent directory if it does not already exist.
109///
110/// If the destination already exists (including as a symlink) it is resolved to
111/// its target, so callers stage temporary files next to — and rename onto — the
112/// real file rather than a symlink that may live on a different mount point.
113/// Otherwise the parent directory is created and canonicalized, so a symlinked
114/// config directory is followed to where its contents actually live.
115async 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                // `canonicalize` resolves the path via metadata syscalls; reuse the
138                // existing operation rather than adding a variant (which would be a
139                // breaking change to the public `IoOperation` enum).
140                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    // A symlinked file crossing a filesystem boundary is the case that motivated
191    // canonicalizing: the destination must resolve to where the real file lives,
192    // not the directory holding the symlink.
193    #[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}