1use crate::GraphError;
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11
12static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
13
14pub fn wav_bytes(data: &[f64], rate: u32) -> Result<Vec<u8>, GraphError> {
20 if let Some(index) = data.iter().position(|value| !(*value as f32).is_finite()) {
21 return Err(GraphError::NonFinite(format!("fir[{index}] as f32")));
22 }
23 let data_len = u32::try_from(data.len() * 4).expect("FIR WAV fits u32");
24 let mut out = Vec::with_capacity(44 + data.len() * 4);
25 out.extend_from_slice(b"RIFF");
26 out.extend_from_slice(&(36 + data_len).to_le_bytes());
27 out.extend_from_slice(b"WAVEfmt ");
28 out.extend_from_slice(&16_u32.to_le_bytes());
29 out.extend_from_slice(&3_u16.to_le_bytes());
30 out.extend_from_slice(&1_u16.to_le_bytes());
31 out.extend_from_slice(&rate.to_le_bytes());
32 out.extend_from_slice(&(rate * 4).to_le_bytes());
33 out.extend_from_slice(&4_u16.to_le_bytes());
34 out.extend_from_slice(&32_u16.to_le_bytes());
35 out.extend_from_slice(b"data");
36 out.extend_from_slice(&data_len.to_le_bytes());
37 for value in data {
38 out.extend_from_slice(&(*value as f32).to_le_bytes());
39 }
40 Ok(out)
41}
42
43pub fn safe_name(name: &str) -> String {
45 let safe: String = name
46 .chars()
47 .map(|ch| {
48 if ch.is_ascii() && (ch.is_ascii_alphanumeric() || "-_.".contains(ch)) {
49 ch
50 } else {
51 '_'
52 }
53 })
54 .collect();
55 if safe.is_empty() {
56 "secs_calibration".into()
57 } else {
58 safe
59 }
60}
61
62fn safe_bundle_path(path: &Path) -> bool {
63 !path.as_os_str().is_empty()
64 && path
65 .components()
66 .all(|component| matches!(component, Component::Normal(_)))
67}
68
69#[doc(hidden)]
71pub fn write_bundle_observed(
72 root: &Path,
73 files: &[(PathBuf, Vec<u8>)],
74 entry: &Path,
75 after_stage_write: &dyn Fn(usize, &Path) -> std::io::Result<()>,
76 before_install: &dyn Fn(usize, &Path) -> std::io::Result<()>,
77) -> Result<(), std::io::Error> {
78 if !safe_bundle_path(entry)
79 || !files.iter().any(|(path, _)| path == entry)
80 || files.iter().any(|(path, _)| !safe_bundle_path(path))
81 {
82 return Err(std::io::Error::new(
83 std::io::ErrorKind::InvalidInput,
84 "bundle paths must be relative file paths and include the entry",
85 ));
86 }
87 let parent = root.parent().unwrap_or_else(|| Path::new("."));
88 fs::create_dir_all(parent)?;
89 let folder = root
90 .file_name()
91 .and_then(|name| name.to_str())
92 .unwrap_or("export");
93 let mut staging = None;
94 for _ in 0..100 {
95 let serial = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
96 let candidate = parent.join(format!(".{folder}.stage-{}-{serial}", std::process::id()));
97 match fs::create_dir(&candidate) {
98 Ok(()) => {
99 staging = Some(candidate);
100 break;
101 }
102 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
103 Err(error) => return Err(error),
104 }
105 }
106 let Some(staging) = staging else {
107 return Err(std::io::Error::new(
108 std::io::ErrorKind::AlreadyExists,
109 "could not create export staging directory",
110 ));
111 };
112 let stage_result = (|| {
113 use std::io::Write as _;
114 for (index, (relative, bytes)) in files.iter().enumerate() {
115 let path = staging.join(relative);
116 if let Some(parent) = path.parent() {
117 fs::create_dir_all(parent)?;
118 }
119 let mut file = fs::OpenOptions::new()
120 .write(true)
121 .create_new(true)
122 .open(&path)?;
123 file.write_all(bytes)?;
124 file.sync_all()?;
125 after_stage_write(index, &path)?;
126 }
127 Ok::<(), std::io::Error>(())
128 })();
129 if let Err(error) = stage_result {
130 let _ = fs::remove_dir_all(&staging);
131 return Err(error);
132 }
133
134 if let Err(error) = fs::create_dir_all(root) {
135 let _ = fs::remove_dir_all(&staging);
136 return Err(error);
137 }
138 for (relative, _) in files {
139 let destination = root.join(relative);
140 match fs::symlink_metadata(&destination) {
141 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
142 let _ = fs::remove_dir_all(&staging);
143 return Err(std::io::Error::new(
144 std::io::ErrorKind::InvalidInput,
145 format!(
146 "bundle file destination is a directory: {}",
147 destination.display()
148 ),
149 ));
150 }
151 Ok(_) => {}
152 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
153 Err(error) => {
154 let _ = fs::remove_dir_all(&staging);
155 return Err(error);
156 }
157 }
158 }
159 let mut backup = None;
160 for _ in 0..100 {
161 let serial = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
162 let candidate = parent.join(format!(".{folder}.backup-{}-{serial}", std::process::id()));
163 match fs::create_dir(&candidate) {
164 Ok(()) => {
165 backup = Some(candidate);
166 break;
167 }
168 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
169 Err(error) => {
170 let _ = fs::remove_dir_all(&staging);
171 return Err(error);
172 }
173 }
174 }
175 let Some(backup) = backup else {
176 let _ = fs::remove_dir_all(&staging);
177 return Err(std::io::Error::new(
178 std::io::ErrorKind::AlreadyExists,
179 "could not create export backup directory",
180 ));
181 };
182
183 let mut moved = Vec::<(PathBuf, bool, bool)>::new();
184 let install_order = files
185 .iter()
186 .filter(|(path, _)| path != entry)
187 .map(|(path, _)| path.as_path())
188 .chain(std::iter::once(entry));
189 let result = (|| {
190 for (index, relative) in install_order.enumerate() {
191 let destination = root.join(relative);
192 if let Some(parent) = destination.parent() {
193 fs::create_dir_all(parent)?;
194 }
195 let backup_path = backup.join(relative);
196 let had_old = fs::symlink_metadata(&destination).is_ok();
197 if had_old {
198 if let Some(parent) = backup_path.parent() {
199 fs::create_dir_all(parent)?;
200 }
201 fs::rename(&destination, &backup_path)?;
202 }
203 moved.push((relative.to_path_buf(), had_old, false));
204 before_install(index, &destination)?;
205 fs::rename(staging.join(relative), &destination)?;
206 moved.last_mut().expect("just pushed").2 = true;
207 }
208 Ok::<(), std::io::Error>(())
209 })();
210
211 if let Err(error) = result {
212 let mut rollback_errors = Vec::new();
213 for (relative, had_old, installed) in moved.iter().rev() {
214 let destination = root.join(relative);
215 let removed_new = if *installed {
216 match fs::remove_file(&destination) {
217 Ok(()) => true,
218 Err(remove) => {
219 rollback_errors.push(format!("remove {}: {remove}", destination.display()));
220 false
221 }
222 }
223 } else {
224 true
225 };
226 if *had_old
227 && removed_new
228 && let Err(restore) = fs::rename(backup.join(relative), &destination)
229 {
230 rollback_errors.push(format!("restore {}: {restore}", destination.display()));
231 }
232 }
233 let _ = fs::remove_dir_all(&staging);
234 if rollback_errors.is_empty() {
235 let _ = fs::remove_dir_all(&backup);
236 return Err(error);
237 }
238 return Err(std::io::Error::other(format!(
239 "install failed ({error}); rollback failed ({})",
240 rollback_errors.join("; ")
241 )));
242 }
243
244 let _ = fs::remove_dir_all(&staging);
246 let _ = fs::remove_dir_all(&backup);
247 Ok(())
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn temp_dir(name: &str) -> PathBuf {
255 let serial = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
256 std::env::temp_dir().join(format!("secs-graph-{name}-{}-{serial}", std::process::id()))
257 }
258
259 fn leftovers(parent: &Path, needle: &str) -> Vec<PathBuf> {
260 fs::read_dir(parent)
261 .unwrap()
262 .map(|entry| entry.unwrap().path())
263 .filter(|path| {
264 path.file_name()
265 .unwrap_or_default()
266 .to_string_lossy()
267 .contains(needle)
268 })
269 .collect()
270 }
271
272 #[test]
273 fn rollback_failure_keeps_backup_and_restores_remaining_entries() {
274 let parent = temp_dir("rollback-failure");
275 let root = parent.join("bundle");
276 fs::create_dir_all(&root).unwrap();
277 fs::write(root.join("a.txt"), b"old a").unwrap();
278 fs::write(root.join("config.txt"), b"old config").unwrap();
279 let files = vec![
280 (PathBuf::from("a.txt"), b"new a".to_vec()),
281 (PathBuf::from("config.txt"), b"new config".to_vec()),
282 ];
283
284 let error = write_bundle_observed(
285 &root,
286 &files,
287 Path::new("config.txt"),
288 &|_, _| Ok(()),
289 &|index, _| {
290 if index == 1 {
291 fs::remove_file(root.join("a.txt"))?;
292 fs::create_dir(root.join("a.txt"))?;
293 fs::write(root.join("a.txt/held"), b"held")?;
294 Err(std::io::Error::other("injected install failure"))
295 } else {
296 Ok(())
297 }
298 },
299 )
300 .unwrap_err();
301
302 assert!(error.to_string().contains("rollback failed"));
303 assert_eq!(fs::read(root.join("config.txt")).unwrap(), b"old config");
304 let backups = leftovers(&parent, ".backup-");
305 assert_eq!(backups.len(), 1);
306 assert_eq!(fs::read(backups[0].join("a.txt")).unwrap(), b"old a");
307 fs::remove_dir_all(parent).unwrap();
308 }
309
310 #[test]
311 fn directory_collision_is_refused_before_any_entry_moves() {
312 let parent = temp_dir("directory-collision");
313 let root = parent.join("bundle");
314 fs::create_dir_all(root.join("a.txt")).unwrap();
315 fs::write(root.join("a.txt/held"), b"held").unwrap();
316 fs::write(root.join("config.txt"), b"old config").unwrap();
317 let files = vec![
318 (PathBuf::from("a.txt"), b"new a".to_vec()),
319 (PathBuf::from("config.txt"), b"new config".to_vec()),
320 ];
321
322 assert!(
323 write_bundle_observed(
324 &root,
325 &files,
326 Path::new("config.txt"),
327 &|_, _| Ok(()),
328 &|_, _| Ok(()),
329 )
330 .is_err()
331 );
332 assert_eq!(fs::read(root.join("a.txt/held")).unwrap(), b"held");
333 assert_eq!(fs::read(root.join("config.txt")).unwrap(), b"old config");
334 fs::remove_dir_all(parent).unwrap();
335 }
336
337 #[test]
338 fn rollback_removes_a_new_only_file() {
339 let parent = temp_dir("new-only");
340 let root = parent.join("bundle");
341 fs::create_dir_all(&root).unwrap();
342 fs::write(root.join("config.txt"), b"old config").unwrap();
343 let files = vec![
344 (PathBuf::from("new.txt"), b"new".to_vec()),
345 (PathBuf::from("config.txt"), b"new config".to_vec()),
346 ];
347 write_bundle_observed(
348 &root,
349 &files,
350 Path::new("config.txt"),
351 &|_, _| Ok(()),
352 &|index, _| {
353 if index == 1 {
354 Err(std::io::Error::other("injected install failure"))
355 } else {
356 Ok(())
357 }
358 },
359 )
360 .unwrap_err();
361 assert!(!root.join("new.txt").exists());
362 assert_eq!(fs::read(root.join("config.txt")).unwrap(), b"old config");
363 fs::remove_dir_all(parent).unwrap();
364 }
365
366 #[test]
367 fn destination_creation_failure_removes_completed_staging() {
368 let parent = temp_dir("destination-create");
369 fs::create_dir_all(&parent).unwrap();
370 let root = parent.join("bundle");
371 fs::write(&root, b"not a directory").unwrap();
372 let files = vec![(PathBuf::from("config.txt"), b"new config".to_vec())];
373 assert!(
374 write_bundle_observed(
375 &root,
376 &files,
377 Path::new("config.txt"),
378 &|_, _| Ok(()),
379 &|_, _| Ok(()),
380 )
381 .is_err()
382 );
383 assert!(leftovers(&parent, ".stage-").is_empty());
384 fs::remove_dir_all(parent).unwrap();
385 }
386
387 #[test]
388 fn failed_second_install_restores_every_old_file() {
389 let parent = temp_dir("install-rollback");
390 let root = parent.join("bundle");
391 fs::create_dir_all(root.join("coeffs")).unwrap();
392 fs::write(root.join("coeffs/L.wav"), b"old coefficient").unwrap();
393 fs::write(root.join("config.txt"), b"old config").unwrap();
394 let files = vec![
395 (PathBuf::from("coeffs/L.wav"), b"new coefficient".to_vec()),
396 (PathBuf::from("config.txt"), b"new config".to_vec()),
397 ];
398
399 let error = write_bundle_observed(
400 &root,
401 &files,
402 Path::new("config.txt"),
403 &|_, _| Ok(()),
404 &|index, _| {
405 if index == 1 {
406 Err(std::io::Error::other("injected second install failure"))
407 } else {
408 Ok(())
409 }
410 },
411 )
412 .unwrap_err();
413
414 assert!(
415 error
416 .to_string()
417 .contains("injected second install failure")
418 );
419 assert_eq!(
420 fs::read(root.join("coeffs/L.wav")).unwrap(),
421 b"old coefficient"
422 );
423 assert_eq!(fs::read(root.join("config.txt")).unwrap(), b"old config");
424 fs::remove_dir_all(parent).unwrap();
425 }
426}