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) -> Result<(), std::io::Error> {
77    if !safe_bundle_path(entry)
78        || !files.iter().any(|(path, _)| path == entry)
79        || files.iter().any(|(path, _)| !safe_bundle_path(path))
80    {
81        return Err(std::io::Error::new(
82            std::io::ErrorKind::InvalidInput,
83            "bundle paths must be relative file paths and include the entry",
84        ));
85    }
86    let parent = root.parent().unwrap_or_else(|| Path::new("."));
87    fs::create_dir_all(parent)?;
88    let folder = root
89        .file_name()
90        .and_then(|name| name.to_str())
91        .unwrap_or("export");
92    let mut staging = None;
93    for _ in 0..100 {
94        let serial = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
95        let candidate = parent.join(format!(".{folder}.stage-{}-{serial}", std::process::id()));
96        match fs::create_dir(&candidate) {
97            Ok(()) => {
98                staging = Some(candidate);
99                break;
100            }
101            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
102            Err(error) => return Err(error),
103        }
104    }
105    let Some(staging) = staging else {
106        return Err(std::io::Error::new(
107            std::io::ErrorKind::AlreadyExists,
108            "could not create export staging directory",
109        ));
110    };
111    let stage_result = (|| {
112        use std::io::Write as _;
113        for (index, (relative, bytes)) in files.iter().enumerate() {
114            let path = staging.join(relative);
115            if let Some(parent) = path.parent() {
116                fs::create_dir_all(parent)?;
117            }
118            let mut file = fs::OpenOptions::new()
119                .write(true)
120                .create_new(true)
121                .open(&path)?;
122            file.write_all(bytes)?;
123            file.sync_all()?;
124            after_stage_write(index, &path)?;
125        }
126        Ok::<(), std::io::Error>(())
127    })();
128    if let Err(error) = stage_result {
129        let _ = fs::remove_dir_all(&staging);
130        return Err(error);
131    }
132
133    fs::create_dir_all(root)?;
134    let install = |relative: &Path| -> Result<(), std::io::Error> {
135        let destination = root.join(relative);
136        if let Some(parent) = destination.parent() {
137            fs::create_dir_all(parent)?;
138        }
139        fs::rename(staging.join(relative), destination)
140    };
141    let result = (|| {
142        for (relative, _) in files.iter().filter(|(path, _)| path != entry) {
143            install(relative)?;
144        }
145        install(entry)
146    })();
147    let _ = fs::remove_dir_all(&staging);
148    result
149}