greentic_deployer/environment/
atomic_write.rs1use std::fs;
17use std::io::Write;
18use std::path::Path;
19
20use chrono::Utc;
21use serde::Serialize;
22use tempfile::NamedTempFile;
23use thiserror::Error;
24
25#[derive(Debug, Error)]
26pub enum AtomicWriteError {
27 #[error("target path has no parent directory: {0}")]
28 NoParent(std::path::PathBuf),
29 #[error("io error on {path}: {source}")]
30 Io {
31 path: std::path::PathBuf,
32 #[source]
33 source: std::io::Error,
34 },
35 #[error("serde_json error on {path}: {source}")]
36 Json {
37 path: std::path::PathBuf,
38 #[source]
39 source: serde_json::Error,
40 },
41 #[error("could not persist temp file over {target}: {source}")]
42 Persist {
43 target: std::path::PathBuf,
44 #[source]
45 source: tempfile::PersistError,
46 },
47 #[error("could not allocate a unique backup name at {0} after {1} attempts")]
48 BackupCollision(std::path::PathBuf, u32),
49}
50
51pub fn atomic_write_bytes(target: &Path, bytes: &[u8]) -> Result<(), AtomicWriteError> {
53 let parent = target
54 .parent()
55 .ok_or_else(|| AtomicWriteError::NoParent(target.to_path_buf()))?;
56 fs::create_dir_all(parent).map_err(|e| AtomicWriteError::Io {
57 path: parent.to_path_buf(),
58 source: e,
59 })?;
60 let mut tmp = NamedTempFile::new_in(parent).map_err(|e| AtomicWriteError::Io {
61 path: parent.to_path_buf(),
62 source: e,
63 })?;
64 tmp.write_all(bytes).map_err(|e| AtomicWriteError::Io {
65 path: tmp.path().to_path_buf(),
66 source: e,
67 })?;
68 tmp.flush().map_err(|e| AtomicWriteError::Io {
69 path: tmp.path().to_path_buf(),
70 source: e,
71 })?;
72 tmp.as_file().sync_all().map_err(|e| AtomicWriteError::Io {
73 path: tmp.path().to_path_buf(),
74 source: e,
75 })?;
76 tmp.persist(target).map_err(|e| AtomicWriteError::Persist {
77 target: target.to_path_buf(),
78 source: e,
79 })?;
80 fsync_parent(parent)?;
81 Ok(())
82}
83
84pub fn atomic_write_json<T: Serialize>(target: &Path, value: &T) -> Result<(), AtomicWriteError> {
86 let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| AtomicWriteError::Json {
87 path: target.to_path_buf(),
88 source: e,
89 })?;
90 bytes.push(b'\n');
91 atomic_write_bytes(target, &bytes)
92}
93
94pub fn copy_to_backup(
97 target: &Path,
98 backup_dir: &Path,
99) -> Result<Option<std::path::PathBuf>, AtomicWriteError> {
100 if !target.exists() {
101 return Ok(None);
102 }
103 fs::create_dir_all(backup_dir).map_err(|e| AtomicWriteError::Io {
104 path: backup_dir.to_path_buf(),
105 source: e,
106 })?;
107 let filename = target
108 .file_name()
109 .and_then(|s| s.to_str())
110 .ok_or_else(|| AtomicWriteError::NoParent(target.to_path_buf()))?;
111 let stamp = Utc::now().format("%Y%m%dT%H%M%S%.9fZ").to_string();
117 const MAX_ATTEMPTS: u32 = 1024;
118 for attempt in 0..MAX_ATTEMPTS {
119 let candidate = if attempt == 0 {
120 backup_dir.join(format!("{filename}.{stamp}.bak"))
121 } else {
122 backup_dir.join(format!("{filename}.{stamp}.{attempt}.bak"))
123 };
124 match fs::OpenOptions::new()
128 .write(true)
129 .create_new(true)
130 .open(&candidate)
131 {
132 Ok(_handle) => {
133 drop(_handle);
138 fs::copy(target, &candidate).map_err(|e| AtomicWriteError::Io {
139 path: candidate.clone(),
140 source: e,
141 })?;
142 return Ok(Some(candidate));
143 }
144 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
145 continue;
148 }
149 Err(e) => {
150 return Err(AtomicWriteError::Io {
151 path: candidate,
152 source: e,
153 });
154 }
155 }
156 }
157 Err(AtomicWriteError::BackupCollision(
158 backup_dir.to_path_buf(),
159 MAX_ATTEMPTS,
160 ))
161}
162
163#[cfg(unix)]
164fn fsync_parent(parent: &Path) -> Result<(), AtomicWriteError> {
165 let dir = fs::File::open(parent).map_err(|e| AtomicWriteError::Io {
166 path: parent.to_path_buf(),
167 source: e,
168 })?;
169 dir.sync_all().map_err(|e| AtomicWriteError::Io {
170 path: parent.to_path_buf(),
171 source: e,
172 })
173}
174
175#[cfg(not(unix))]
176fn fsync_parent(_parent: &Path) -> Result<(), AtomicWriteError> {
177 Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use tempfile::TempDir;
184
185 #[test]
186 fn write_bytes_round_trip() {
187 let tmp = TempDir::new().unwrap();
188 let target = tmp.path().join("hello.txt");
189 atomic_write_bytes(&target, b"hello world").unwrap();
190 assert_eq!(fs::read(&target).unwrap(), b"hello world");
191 }
192
193 #[test]
194 fn write_json_round_trip() {
195 let tmp = TempDir::new().unwrap();
196 let target = tmp.path().join("doc.json");
197 let value = serde_json::json!({ "k": [1, 2, 3] });
198 atomic_write_json(&target, &value).unwrap();
199 let read: serde_json::Value = serde_json::from_slice(&fs::read(&target).unwrap()).unwrap();
200 assert_eq!(read, value);
201 let raw = fs::read_to_string(&target).unwrap();
202 assert!(raw.ends_with('\n'), "expected trailing newline");
203 }
204
205 #[test]
206 fn write_creates_missing_parent() {
207 let tmp = TempDir::new().unwrap();
208 let target = tmp.path().join("a/b/c/file.json");
209 atomic_write_json(&target, &serde_json::json!({})).unwrap();
210 assert!(target.exists());
211 }
212
213 #[test]
214 fn overwrites_existing_file() {
215 let tmp = TempDir::new().unwrap();
216 let target = tmp.path().join("doc.json");
217 atomic_write_bytes(&target, b"first").unwrap();
218 atomic_write_bytes(&target, b"second").unwrap();
219 assert_eq!(fs::read(&target).unwrap(), b"second");
220 }
221
222 #[test]
223 fn copy_to_backup_no_target() {
224 let tmp = TempDir::new().unwrap();
225 let target = tmp.path().join("never.json");
226 let backup = tmp.path().join("backups");
227 assert!(copy_to_backup(&target, &backup).unwrap().is_none());
228 assert!(!backup.exists());
229 }
230
231 #[test]
232 fn copy_to_backup_creates_timestamped_copy() {
233 let tmp = TempDir::new().unwrap();
234 let target = tmp.path().join("doc.json");
235 atomic_write_bytes(&target, b"v1").unwrap();
236 let backup_dir = tmp.path().join("backups");
237 let backup_path = copy_to_backup(&target, &backup_dir)
238 .unwrap()
239 .expect("backup should be Some when target exists");
240 assert_eq!(fs::read(&backup_path).unwrap(), b"v1");
241 assert!(backup_path.starts_with(&backup_dir));
242 let name = backup_path.file_name().unwrap().to_str().unwrap();
243 assert!(name.starts_with("doc.json."), "got: {name}");
244 assert!(name.ends_with(".bak"), "got: {name}");
245 }
246
247 #[test]
248 fn no_partial_state_if_serialization_succeeds() {
249 let tmp = TempDir::new().unwrap();
250 let target = tmp.path().join("doc.json");
251 atomic_write_bytes(&target, b"first").unwrap();
254 atomic_write_bytes(&target, b"second").unwrap();
255 let entries: Vec<_> = fs::read_dir(tmp.path()).unwrap().collect();
256 assert_eq!(entries.len(), 1);
257 }
258}