Skip to main content

phemios_core/
cache.rs

1//! Disposable public library/artwork cache; never stores credentials or annotations.
2use serde_json::Value;
3use std::fs::{self, OpenOptions};
4use std::hash::{Hash, Hasher};
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9pub(crate) const MAX_CATALOG: u64 = 100 * 1024 * 1024;
10const MAX_IMAGE: u64 = 32 * 1024 * 1024;
11/// Application-owned paths. No path supplied by the webview is accepted.
12#[derive(Clone)]
13pub struct Cache {
14    root: PathBuf,
15    // Catalog migration is a read/write transaction; image IO never takes this lock.
16    catalog_io: Arc<Mutex<()>>,
17    revision: Arc<AtomicU64>,
18}
19impl Cache {
20    /// Construct an isolated cache location without modifying existing files.
21    pub fn new(root: PathBuf) -> Self {
22        Self {
23            root,
24            catalog_io: Arc::default(),
25            revision: Arc::default(),
26        }
27    }
28    /// Retire disk reads captured before an explicit cache removal.
29    pub fn revision(&self) -> u64 {
30        self.revision.load(Ordering::Acquire)
31    }
32    /// Remove only this source's disposable catalogue and artwork, never personal stores.
33    pub fn clear(&self) -> Result<(), String> {
34        let _transaction = self
35            .catalog_io
36            .lock()
37            .map_err(|_| "Cache du catalogue indisponible.")?;
38        self.revision.fetch_add(1, Ordering::AcqRel);
39        for (path, directory) in [
40            (self.root.clone(), true),
41            (self.root.join("artwork"), true),
42            (self.root.join("catalog.json"), false),
43        ] {
44            match fs::symlink_metadata(&path) {
45                Ok(meta)
46                    if meta.file_type().is_symlink()
47                        || (directory && !meta.is_dir())
48                        || (!directory && !meta.is_file()) =>
49                {
50                    return Err("Emplacement du cache invalide.".into());
51                }
52                Ok(_) => {}
53                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
54                Err(_) => return Err("Cache inaccessible.".into()),
55            }
56        }
57        for (path, directory) in [
58            (self.root.join("artwork"), true),
59            (self.root.join("catalog.json"), false),
60        ] {
61            let result = if directory {
62                fs::remove_dir_all(path)
63            } else {
64                fs::remove_file(path)
65            };
66            match result {
67                Ok(()) => {}
68                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
69                Err(_) => return Err("Suppression du cache impossible.".into()),
70            }
71        }
72        Ok(())
73    }
74    /// A missing cache is distinct from an unreadable or incompatible cache.
75    /// Old public caches lose private policy metadata atomically; supervised projections remain process-only.
76    pub fn load(&self) -> Result<Option<Value>, String> {
77        let _transaction = self
78            .catalog_io
79            .lock()
80            .map_err(|_| "Cache du catalogue indisponible.")?;
81        let path = self.root.join("catalog.json");
82        let Some(bytes) = read(&path, MAX_CATALOG)? else {
83            return Ok(None);
84        };
85        let mut snapshot: Value = serde_json::from_slice(&bytes).map_err(|_| {
86            "Le cache du catalogue est illisible. Relancez explicitement une synchronisation."
87                .to_string()
88        })?;
89        validate(&snapshot)?;
90        if snapshot["parentalEnabled"] == true {
91            return Err(
92                "Un catalogue supervisé ne peut pas être lu depuis le cache public.".into(),
93            );
94        }
95        if strip_parental_metadata(&mut snapshot) {
96            let bytes = serde_json::to_vec(&snapshot)
97                .map_err(|_| "Catalogue impossible à enregistrer.".to_string())?;
98            write_atomic(&path, &bytes)?;
99        }
100        Ok(Some(snapshot))
101    }
102    /// Atomically publish a complete library snapshot after explicit synchronization.
103    pub fn save(&self, mut snapshot: Value) -> Result<Value, String> {
104        let _transaction = self
105            .catalog_io
106            .lock()
107            .map_err(|_| "Cache du catalogue indisponible.")?;
108        validate(&snapshot)?;
109        if snapshot["parentalEnabled"] == true {
110            return Err("Un catalogue supervisé ne peut pas remplacer le cache public.".into());
111        }
112        strip_parental_metadata(&mut snapshot);
113        snapshot["cachedAt"] = Value::from(
114            std::time::SystemTime::now()
115                .duration_since(std::time::UNIX_EPOCH)
116                .map_err(|_| "Horloge système invalide.")?
117                .as_millis()
118                .to_string(),
119        );
120        let bytes = serde_json::to_vec(&snapshot)
121            .map_err(|_| "Catalogue impossible à enregistrer.".to_string())?;
122        if bytes.len() as u64 > MAX_CATALOG {
123            return Err("Catalogue trop grand pour ce cache.".into());
124        }
125        write_atomic(&self.root.join("catalog.json"), &bytes)?;
126        Ok(snapshot)
127    }
128    /// Read already synchronized art; missing art may then be requested explicitly.
129    pub fn image(
130        &self,
131        snapshot: &Value,
132        reference: &str,
133        kind: &str,
134    ) -> Result<Option<Vec<u8>>, String> {
135        let shared = self.image_path(snapshot, reference, kind, false);
136        if let Some(bytes) = read(&shared, MAX_IMAGE)? {
137            return Ok(Some(bytes));
138        }
139        // Reuse the current account's old cache without treating its presence as access authority.
140        let legacy = read(&self.image_path(snapshot, reference, kind, true), MAX_IMAGE)?;
141        if let Some(bytes) = &legacy {
142            write_atomic(&shared, bytes)?;
143        }
144        Ok(legacy)
145    }
146    /// Share immutable catalogue art across accounts of the same authority and catalogue version.
147    /// The native caller must separately admit the reference through the active account projection.
148    pub fn save_image(
149        &self,
150        snapshot: &Value,
151        reference: &str,
152        kind: &str,
153        bytes: &[u8],
154    ) -> Result<(), String> {
155        if bytes.len() as u64 > MAX_IMAGE {
156            return Err("Image trop grande pour le cache.".into());
157        }
158        write_atomic(&self.image_path(snapshot, reference, kind, false), bytes)
159    }
160    fn image_path(&self, snapshot: &Value, reference: &str, kind: &str, legacy: bool) -> PathBuf {
161        let mut hash = std::collections::hash_map::DefaultHasher::new();
162        for value in [
163            snapshot["server"].to_string(),
164            if legacy {
165                snapshot["accountId"].to_string()
166            } else {
167                "shared-artwork-v2".into()
168            },
169            snapshot["scannedAt"].to_string(),
170            reference.to_owned(),
171            kind.to_owned(),
172        ] {
173            value.hash(&mut hash);
174        }
175        self.root
176            .join("artwork")
177            .join(format!("{:016x}.image", hash.finish()))
178    }
179}
180fn strip_parental_metadata(snapshot: &mut Value) -> bool {
181    let mut removed = false;
182    if let Some(fields) = snapshot.as_object_mut() {
183        for key in ["parentalPolicy", "parentalEnabled", "parentalRevision"] {
184            removed |= fields.remove(key).is_some();
185        }
186    }
187    removed
188}
189/// Validate a bounded projection before process-only retention or public-cache publication.
190pub fn validate(snapshot: &Value) -> Result<(), String> {
191    if ["tracks", "labels"]
192        .iter()
193        .any(|key| snapshot.get(key).is_some_and(|value| !value.is_array()))
194        || !snapshot["artists"].is_array()
195        || !snapshot["albums"].is_array()
196        || snapshot["server"].as_str().is_none()
197        || snapshot["accountId"].as_str().is_none()
198    {
199        Err("Format de cache incompatible.".into())
200    } else {
201        Ok(())
202    }
203}
204pub(crate) fn read(path: &Path, limit: u64) -> Result<Option<Vec<u8>>, String> {
205    let metadata = match fs::symlink_metadata(path) {
206        Ok(value) => value,
207        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
208        Err(_) => return Err("Cache inaccessible.".into()),
209    };
210    if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > limit {
211        return Err("Cache invalide ou trop grand.".into());
212    }
213    let file = fs::File::open(path).map_err(|_| "Cache inaccessible.".to_string())?;
214    let mut bytes = Vec::new();
215    file.take(limit + 1)
216        .read_to_end(&mut bytes)
217        .map_err(|_| "Lecture du cache interrompue.".to_string())?;
218    if bytes.len() as u64 > limit {
219        return Err("Cache trop grand.".into());
220    }
221    Ok(Some(bytes))
222}
223pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
224    static NEXT: AtomicU64 = AtomicU64::new(1);
225    let folder = path.parent().ok_or("Emplacement du cache invalide.")?;
226    fs::create_dir_all(folder).map_err(|_| "Dossier du cache inaccessible.".to_string())?;
227    #[cfg(unix)]
228    {
229        use std::os::unix::fs::PermissionsExt;
230        fs::set_permissions(folder, fs::Permissions::from_mode(0o700))
231            .map_err(|_| "Permissions du cache inaccessibles.".to_string())?;
232    }
233    let temporary = folder.join(format!(
234        ".cache-{}-{}.tmp",
235        std::process::id(),
236        NEXT.fetch_add(1, Ordering::Relaxed)
237    ));
238    let result = (|| {
239        let mut file = OpenOptions::new()
240            .write(true)
241            .create_new(true)
242            .open(&temporary)?;
243        file.write_all(bytes)?;
244        file.sync_all()?;
245        fs::rename(&temporary, path)
246    })()
247    .map_err(|_: std::io::Error| "Enregistrement du cache impossible.".to_string());
248    if result.is_err() {
249        let _ = fs::remove_file(&temporary);
250    }
251    result
252}
253#[cfg(test)]
254#[path = "cache_tests.rs"]
255mod tests;