Skip to main content

cli/
persist.rs

1//! Shared atomic-write primitives.
2//!
3//! Several modules (config, app/sys/task manifests, ssh transfer, workspace
4//! env files, self-install) each hand-rolled their own "write to a temp file,
5//! then rename over the destination" sequence. This module is the single
6//! place that logic lives.
7
8use anyhow::{Context, Result};
9use std::path::Path;
10use tokio::io::AsyncWriteExt;
11
12/// Durably writes `contents` to `path`.
13///
14/// Creates the parent directory if missing, writes to a uniquely-named temp
15/// file in the same directory, fsyncs it, then renames it over `path`. If
16/// anything fails after the temp file is created, the temp file is removed.
17pub async fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
18    let parent = path.parent().unwrap_or_else(|| Path::new("."));
19    tokio::fs::create_dir_all(parent)
20        .await
21        .with_context(|| format!("creating {}", parent.display()))?;
22    let temp = parent.join(format!(".shine-write-{}", uuid::Uuid::new_v4()));
23
24    if let Err(error) = write_temp(&temp, contents).await {
25        let _ = tokio::fs::remove_file(&temp).await;
26        return Err(error);
27    }
28
29    finalize_temp(&temp, path).await
30}
31
32/// Durably writes a private file with owner-only permissions on Unix.
33///
34/// The temporary file receives the restrictive mode before any content is
35/// written, so plaintext never has a wider visibility window before rename.
36pub async fn atomic_write_private(path: &Path, contents: &[u8]) -> Result<()> {
37    let parent = path.parent().unwrap_or_else(|| Path::new("."));
38    tokio::fs::create_dir_all(parent)
39        .await
40        .with_context(|| format!("creating {}", parent.display()))?;
41    let temp = parent.join(format!(".shine-write-{}", uuid::Uuid::new_v4()));
42
43    if let Err(error) = write_private_temp(&temp, contents).await {
44        let _ = tokio::fs::remove_file(&temp).await;
45        return Err(error);
46    }
47
48    finalize_temp(&temp, path).await
49}
50
51#[cfg(unix)]
52async fn write_private_temp(temp: &Path, contents: &[u8]) -> Result<()> {
53    let mut file = tokio::fs::OpenOptions::new()
54        .write(true)
55        .create_new(true)
56        .mode(0o600)
57        .open(temp)
58        .await
59        .with_context(|| format!("creating {}", temp.display()))?;
60    file.write_all(contents)
61        .await
62        .with_context(|| format!("writing {}", temp.display()))?;
63    file.sync_all()
64        .await
65        .with_context(|| format!("syncing {}", temp.display()))?;
66    Ok(())
67}
68
69#[cfg(not(unix))]
70async fn write_private_temp(temp: &Path, contents: &[u8]) -> Result<()> {
71    write_temp(temp, contents).await
72}
73
74async fn write_temp(temp: &Path, contents: &[u8]) -> Result<()> {
75    let mut file = tokio::fs::File::create(temp)
76        .await
77        .with_context(|| format!("creating {}", temp.display()))?;
78    file.write_all(contents)
79        .await
80        .with_context(|| format!("writing {}", temp.display()))?;
81    file.sync_all()
82        .await
83        .with_context(|| format!("syncing {}", temp.display()))?;
84    Ok(())
85}
86
87/// Renames `temp` over `dest`, for callers that already wrote/streamed their
88/// own temp file (e.g. large-file copies) and only need the finalize step.
89///
90/// On Windows, an existing `dest` is removed first (`rename` there doesn't
91/// replace an existing file). On any failure, `temp` is removed.
92pub async fn finalize_temp(temp: &Path, dest: &Path) -> Result<()> {
93    #[cfg(windows)]
94    if dest.exists() {
95        tokio::fs::remove_file(dest)
96            .await
97            .with_context(|| format!("removing {}", dest.display()))?;
98    }
99    if let Err(error) = tokio::fs::rename(temp, dest).await {
100        let _ = tokio::fs::remove_file(temp).await;
101        return Err(error).with_context(|| format!("replacing {}", dest.display()));
102    }
103    Ok(())
104}
105
106/// Loads and parses a TOML file at `path`, or returns `T::default()` if it
107/// doesn't exist yet. `what` is a human-readable label used in error
108/// messages (e.g. `"app manifest"`).
109pub async fn load_toml_or_default<T>(path: &Path, what: &str) -> Result<T>
110where
111    T: serde::de::DeserializeOwned + Default,
112{
113    match tokio::fs::read_to_string(path).await {
114        Ok(content) => toml::from_str(&content).with_context(|| format!("failed to parse {what}")),
115        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
116        Err(e) => Err(e).with_context(|| format!("failed to read {what}")),
117    }
118}
119
120/// Serializes `value` as pretty TOML and atomically writes it to `path`.
121/// `what` is a human-readable label used in error messages.
122pub async fn save_toml_atomic<T: serde::Serialize>(
123    value: &T,
124    path: &Path,
125    what: &str,
126) -> Result<()> {
127    let content =
128        toml::to_string_pretty(value).with_context(|| format!("failed to serialize {what}"))?;
129    atomic_write(path, content.as_bytes())
130        .await
131        .with_context(|| format!("failed to write {what}"))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[tokio::test]
139    async fn atomic_write_creates_missing_parent_directories() {
140        let dir = crate::test_support::make_temp_dir("shine-persist").await;
141        let path = dir.join("nested/deep/file.txt");
142
143        atomic_write(&path, b"hello").await.unwrap();
144
145        assert_eq!(tokio::fs::read(&path).await.unwrap(), b"hello");
146        tokio::fs::remove_dir_all(&dir).await.unwrap();
147    }
148
149    #[tokio::test]
150    async fn atomic_write_replaces_existing_file() {
151        let dir = crate::test_support::make_temp_dir("shine-persist").await;
152        let path = dir.join("file.txt");
153        tokio::fs::write(&path, b"old").await.unwrap();
154
155        atomic_write(&path, b"new").await.unwrap();
156
157        assert_eq!(tokio::fs::read(&path).await.unwrap(), b"new");
158        tokio::fs::remove_dir_all(&dir).await.unwrap();
159    }
160
161    #[tokio::test]
162    async fn atomic_write_leaves_no_temp_file_behind_on_success() {
163        let dir = crate::test_support::make_temp_dir("shine-persist").await;
164        let path = dir.join("file.txt");
165
166        atomic_write(&path, b"content").await.unwrap();
167
168        let mut entries = tokio::fs::read_dir(&dir).await.unwrap();
169        let mut names = Vec::new();
170        while let Some(entry) = entries.next_entry().await.unwrap() {
171            names.push(entry.file_name());
172        }
173        assert_eq!(names, vec![std::ffi::OsString::from("file.txt")]);
174        tokio::fs::remove_dir_all(&dir).await.unwrap();
175    }
176
177    #[cfg(unix)]
178    #[tokio::test]
179    async fn atomic_write_private_uses_owner_only_permissions() {
180        use std::os::unix::fs::PermissionsExt;
181
182        let dir = crate::test_support::make_temp_dir("shine-persist-private").await;
183        let path = dir.join("secret.env");
184        tokio::fs::write(&path, b"old\n").await.unwrap();
185        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
186            .await
187            .unwrap();
188
189        atomic_write_private(&path, b"TOKEN=secret\n")
190            .await
191            .unwrap();
192
193        assert_eq!(tokio::fs::read(&path).await.unwrap(), b"TOKEN=secret\n");
194        let mode = tokio::fs::metadata(&path)
195            .await
196            .unwrap()
197            .permissions()
198            .mode();
199        assert_eq!(mode & 0o777, 0o600);
200        tokio::fs::remove_dir_all(&dir).await.unwrap();
201    }
202
203    #[tokio::test]
204    async fn finalize_temp_removes_temp_on_rename_failure() {
205        let dir = crate::test_support::make_temp_dir("shine-persist").await;
206        let temp = dir.join(".shine-write-test");
207        tokio::fs::write(&temp, b"content").await.unwrap();
208        // A destination inside a nonexistent directory makes rename fail.
209        let dest = dir.join("missing-dir").join("dest.txt");
210
211        let result = finalize_temp(&temp, &dest).await;
212
213        assert!(result.is_err());
214        assert!(!temp.exists(), "temp file should be cleaned up on failure");
215        tokio::fs::remove_dir_all(&dir).await.unwrap();
216    }
217
218    #[derive(Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
219    struct SampleToml {
220        #[serde(default)]
221        name: String,
222        #[serde(default)]
223        count: u32,
224    }
225
226    #[tokio::test]
227    async fn load_toml_or_default_returns_default_when_file_missing() {
228        let dir = crate::test_support::make_temp_dir("shine-persist").await;
229        let path = dir.join("sample.toml");
230
231        let value: SampleToml = load_toml_or_default(&path, "sample").await.unwrap();
232
233        assert_eq!(value, SampleToml::default());
234        tokio::fs::remove_dir_all(&dir).await.unwrap();
235    }
236
237    #[tokio::test]
238    async fn save_then_load_toml_round_trips() {
239        let dir = crate::test_support::make_temp_dir("shine-persist").await;
240        let path = dir.join("sample.toml");
241        let value = SampleToml {
242            name: "hi".to_string(),
243            count: 3,
244        };
245
246        save_toml_atomic(&value, &path, "sample").await.unwrap();
247        let loaded: SampleToml = load_toml_or_default(&path, "sample").await.unwrap();
248
249        assert_eq!(loaded, value);
250        tokio::fs::remove_dir_all(&dir).await.unwrap();
251    }
252
253    #[tokio::test]
254    async fn save_toml_atomic_creates_missing_parent_directory() {
255        let dir = crate::test_support::make_temp_dir("shine-persist").await;
256        let path = dir.join("nested/sample.toml");
257        let value = SampleToml::default();
258
259        save_toml_atomic(&value, &path, "sample").await.unwrap();
260
261        assert!(path.exists());
262        tokio::fs::remove_dir_all(&dir).await.unwrap();
263    }
264}