1use anyhow::{Context, Result};
9use std::path::Path;
10use tokio::io::AsyncWriteExt;
11
12pub 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
32pub 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
87pub 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
106pub 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
120pub 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 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}