1use 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#[derive(Clone)]
13pub struct Cache {
14 root: PathBuf,
15 catalog_io: Arc<Mutex<()>>,
17 revision: Arc<AtomicU64>,
18}
19impl Cache {
20 pub fn new(root: PathBuf) -> Self {
22 Self {
23 root,
24 catalog_io: Arc::default(),
25 revision: Arc::default(),
26 }
27 }
28 pub fn revision(&self) -> u64 {
30 self.revision.load(Ordering::Acquire)
31 }
32 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 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 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 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 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 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}
189pub 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;