Skip to main content

secs_graph/
files.rs

1//! File helpers every export writer shares: the FIR WAV bytes, the upstream
2//! filename sanitiser and the staged bundle install (#92 TD-22, TD-45).
3//!
4//! Moved from `secs-camilla` so the filter writers no longer borrow them
5//! from the CamillaDSP writer (#92 C11).
6
7use 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
14/// Serialize an IEEE-float, mono, 32-bit WAV exactly like upstream `write_wav`.
15///
16/// Fails when a tap is not finite once narrowed to `f32` (`1e100` is a finite
17/// `f64` but an infinite `f32`), so every writer that shares it rejects the
18/// coefficients before its first file.
19pub 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
43/// Replace filename-unsafe or non-ASCII characters as upstream does.
44pub 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/// Stage a complete bundle beside its destination, then install its entry file last.
70#[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    // Every destination now has its new bytes. Cleanup cannot undo the commit.
245    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}