aede_core/audio_features/
cache.rs

1use super::*;
2use md5::{Digest, Md5};
3use std::fs;
4use std::io::Read;
5use std::path::PathBuf;
6/// Exact scanned source stamp; a cached descriptor is not fresh merely because its name matches.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct InputIdentity {
9    /// Stable canonical catalogued file path.
10    pub path: String,
11    /// Original source byte length.
12    pub size: u64,
13    /// Original source modification time, whole seconds.
14    pub modified: u64,
15    /// Original source modification-time subsecond nanoseconds.
16    pub nanos: u32,
17}
18/// Complete descriptor for one input. Personal labels never enter this shared acoustic artifact.
19#[derive(Clone, Debug, PartialEq)]
20pub struct Record {
21    /// Exact original file identity.
22    pub input: InputIdentity,
23    /// Validated measurement profile.
24    pub features: Features,
25}
26fn location(root: &Path, input: &InputIdentity) -> PathBuf {
27    let token = format!(
28        "{}\0{}\0{}\0{}",
29        input.path, input.size, input.modified, input.nanos
30    );
31    let hash = Md5::digest(token.as_bytes());
32    root.join(format!("acoustic-v{}", aede_moods::PROFILE))
33        .join(format!("{hash:x}.json"))
34}
35fn safe_root(root: &Path) -> Result<(), String> {
36    let metadata = fs::symlink_metadata(root).map_err(|_| "acoustic cache root unavailable")?;
37    if !metadata.is_dir() || metadata.file_type().is_symlink() {
38        return Err("unsafe acoustic cache root".into());
39    }
40    Ok(())
41}
42fn valid_input(input: &InputIdentity) -> bool {
43    Path::new(&input.path).is_absolute()
44        && input.path.len() <= 4096
45        && !input.path.contains('\0')
46        && input.nanos < 1_000_000_000
47}
48/// Read at most eight KiB, refusing symlinks, malformed records and a mismatched source/profile.
49pub fn load(root: &Path, input: &InputIdentity) -> Result<Option<Record>, String> {
50    if !valid_input(input) {
51        return Err("invalid acoustic input identity".into());
52    }
53    let path = location(root, input);
54    let parent = path.parent().ok_or("invalid acoustic cache path")?;
55    if !parent.exists() {
56        return Ok(None);
57    }
58    safe_root(parent)?;
59    let metadata = match fs::symlink_metadata(&path) {
60        Ok(v) => v,
61        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
62        Err(_) => return Err("acoustic cache unavailable".into()),
63    };
64    if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 8192 {
65        return Err("invalid acoustic cache file".into());
66    }
67    let mut bytes = Vec::new();
68    fs::File::open(&path)
69        .map_err(|_| "acoustic cache unavailable")?
70        .take(8193)
71        .read_to_end(&mut bytes)
72        .map_err(|_| "acoustic cache read failed")?;
73    if bytes.len() > 8192 {
74        return Err("acoustic cache too large".into());
75    }
76    let value: serde_json::Value =
77        serde_json::from_slice(&bytes).map_err(|_| "acoustic cache unreadable")?;
78    if value["path"].as_str() != Some(&input.path)
79        || value["size"].as_u64() != Some(input.size)
80        || value["modified"].as_u64() != Some(input.modified)
81        || value["nanos"].as_u64() != Some(u64::from(input.nanos))
82        || value.as_object().is_none_or(|o| o.len() != 5)
83    {
84        return Err("acoustic cache identity mismatch".into());
85    }
86    let features: Features = serde_json::from_value(value["features"].clone())
87        .map_err(|_| "acoustic cache features invalid")?;
88    if !features.valid() {
89        return Err("acoustic cache profile invalid".into());
90    }
91    Ok(Some(Record {
92        input: input.clone(),
93        features,
94    }))
95}
96/// Publish each completed source atomically. Interrupted jobs retain earlier source records.
97pub fn save(root: &Path, record: &Record) -> Result<(), String> {
98    if !valid_input(&record.input) || !record.features.valid() {
99        return Err("invalid acoustic record".into());
100    }
101    safe_root(root)?;
102    let path = location(root, &record.input);
103    let parent = path.parent().ok_or("invalid acoustic cache path")?;
104    match fs::create_dir(parent) {
105        Ok(()) => {}
106        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
107        Err(_) => return Err("acoustic cache directory unavailable".into()),
108    };
109    safe_root(parent)?;
110    let value = serde_json::json!({"path":record.input.path,"size":record.input.size,"modified":record.input.modified,"nanos":record.input.nanos,"features":record.features});
111    let bytes = serde_json::to_vec(&value).map_err(|_| "acoustic cache serialization failed")?;
112    if bytes.len() > 8192 {
113        return Err("acoustic cache too large".into());
114    }
115    crate::atomic_file::write(&path, &bytes).map_err(|_| "acoustic cache publication failed".into())
116}
117#[cfg(test)]
118#[path = "cache_tests.rs"]
119mod tests;