1use serde_json::{Value, json};
3use std::{
4 fs,
5 io::{BufRead, BufReader, Read, Write},
6 path::{Path, PathBuf},
7 process::{Child, ChildStdin, Command, Stdio},
8 time::Duration,
9};
10#[path = "local_mounts.rs"]
11mod mounts;
12#[path = "local_tls.rs"]
13mod tls;
14pub struct Engine {
16 root: PathBuf,
17 executable: PathBuf,
18 child: Option<Child>,
19 lease: Option<ChildStdin>,
20 address: Option<String>,
21 certificate: Option<Vec<u8>>,
22}
23impl Engine {
24 pub fn new(root: PathBuf, executable: PathBuf) -> Self {
28 Self {
29 root,
30 executable,
31 child: None,
32 lease: None,
33 address: None,
34 certificate: None,
35 }
36 }
37 pub fn copy_executable(&self) -> PathBuf {
39 self.executable.clone()
40 }
41 pub fn library_roots(&self) -> Result<Vec<PathBuf>, String> {
44 let value = self.config()?;
45 let rows = value["roots"]
46 .as_array()
47 .ok_or("Dossiers locaux invalides.")?;
48 if rows.len() > 64 {
49 return Err("Trop de dossiers locaux.".into());
50 }
51 rows.iter()
52 .map(|row| {
53 let path = row["path"]
54 .as_str()
55 .filter(|s| s.len() <= 4096 && !s.contains('\0'))
56 .map(PathBuf::from)
57 .filter(|p| p.is_absolute())
58 .ok_or("Dossier local invalide.")?;
59 Ok(path)
60 })
61 .collect::<Result<std::collections::BTreeSet<_>, String>>()
62 .map(|roots| roots.into_iter().collect())
63 }
64 fn file(&self) -> PathBuf {
65 self.root.join("desktop.json")
66 }
67 fn validate_root(&self) -> Result<(), String> {
70 crate::private_storage::validate_root(&self.root)
71 }
72 fn config(&self) -> Result<Value, String> {
73 self.validate_root()?;
74 let path = self.file();
75 let meta = match fs::symlink_metadata(&path) {
76 Ok(meta) => meta,
77 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
78 return Ok(json!({"version":1,"enabled":false,"roots":[],"keys":{}}));
79 }
80 Err(_) => return Err("Configuration locale illisible.".into()),
81 };
82 if !meta.is_file() || meta.len() > 128 * 1024 {
83 return Err("Configuration locale invalide.".into());
84 }
85 #[cfg(unix)]
86 {
87 use std::os::unix::fs::PermissionsExt;
88 if meta.permissions().mode() & 0o077 != 0 {
89 return Err(
90 "Les permissions de la configuration locale ne protègent pas ses secrets."
91 .into(),
92 );
93 }
94 }
95 let mut bytes = Vec::new();
97 fs::File::open(path)
98 .map_err(|_| "Configuration locale illisible.")?
99 .take(128 * 1024 + 1)
100 .read_to_end(&mut bytes)
101 .map_err(|_| "Configuration locale illisible.")?;
102 if bytes.len() > 128 * 1024 {
103 return Err("Configuration locale invalide.".into());
104 }
105 let value: Value =
106 serde_json::from_slice(&bytes).map_err(|_| "Configuration locale corrompue.")?;
107 if value["version"] != 1 || !value["roots"].is_array() || !value["keys"].is_object() {
108 return Err("Configuration locale incompatible.".into());
109 }
110 Ok(value)
111 }
112 fn save(&self, value: &Value) -> Result<(), String> {
113 crate::private_storage::create_root(&self.root)?;
114 let tmp = self.root.join("desktop.pending");
115 let mut options = fs::OpenOptions::new();
116 options.write(true).create_new(true);
117 #[cfg(unix)]
118 {
119 use std::os::unix::fs::OpenOptionsExt;
120 options.mode(0o600);
121 }
122 let mut file = options
123 .open(&tmp)
124 .map_err(|_| "Configuration locale occupée.")?;
125 let work = (|| {
126 file.write_all(value.to_string().as_bytes())
127 .map_err(|_| "Écriture locale impossible.")?;
128 file.sync_all().map_err(|_| "Écriture locale impossible.")?;
129 fs::rename(&tmp, self.file()).map_err(|_| "Publication locale impossible.")?;
130 Ok(())
131 })();
132 if work.is_err() {
133 let _ = fs::remove_file(tmp);
134 }
135 work
136 }
137 pub fn status(&mut self) -> Result<Value, String> {
139 let value = self.config()?;
140 let running = match &mut self.child {
141 Some(child) => child
142 .try_wait()
143 .map_err(|_| "État du moteur indisponible.")?
144 .is_none(),
145 None => false,
146 };
147 let roots = value["roots"]
148 .as_array()
149 .ok_or("Dossiers invalides.")?
150 .iter()
151 .map(|row| {
152 let path = row["path"].as_str().unwrap_or("");
153 let available = mounts::available(row);
154 json!({"path":path,"available":available,"mount":row["mount"]})
155 })
156 .collect::<Vec<_>>();
157 Ok(
158 json!({"enabled":value["enabled"],"running":running,"roots":roots,"keys":{"fanart":value["keys"]["fanart"].as_str().is_some(),"acoustid":value["keys"]["acoustid"].as_str().is_some()}}),
159 )
160 }
161 pub fn start(&mut self) -> Result<crate::network::Client, String> {
163 let mut value = self.config()?;
164 if value["password"].is_null() {
165 let mut bytes = [0; 32];
166 getrandom::fill(&mut bytes).map_err(|_| "Générateur sécurisé indisponible.")?;
167 value["password"] = Value::String(bytes.iter().map(|b| format!("{b:02x}")).collect());
168 }
169 value["enabled"] = Value::Bool(true);
170 self.save(&value)?;
171 let password = value["password"]
172 .as_str()
173 .filter(|s| s.len() == 64)
174 .ok_or("Secret local invalide.")?;
175 if self
176 .child
177 .as_mut()
178 .is_some_and(|child| child.try_wait().ok() == Some(None))
179 {
180 return crate::network::Client::login_pinned(
181 self.address.as_deref().ok_or("Adresse locale absente.")?,
182 "phemios-local",
183 password,
184 self.certificate
185 .as_deref()
186 .ok_or("Identité TLS locale absente.")?,
187 );
188 }
189 self.stop_process();
190 let identity = tls::Identity::generate()?;
191 let mut command = self.command();
192 command
193 .args(["serve", "--port=0"])
194 .env("AEDE_DESKTOP_LEASE", "1")
195 .stdin(Stdio::piped())
196 .stdout(Stdio::piped());
197 command
198 .env_remove("AEDE_FANARTTV_KEY")
199 .env_remove("AEDE_ACOUSTID_KEY");
200 let mut child = command
201 .spawn()
202 .map_err(|_| "Le moteur Aède embarqué ne peut pas démarrer.")?;
203 let mut stdin = child.stdin.take().ok_or("Entrée du moteur indisponible.")?;
204 writeln!(stdin, "{}", identity.bootstrap("phemios-local", password))
205 .map_err(|_| "Initialisation du moteur impossible.")?;
206 let stdout = child
207 .stdout
208 .take()
209 .ok_or("Sortie du moteur indisponible.")?;
210 let (tx, rx) = std::sync::mpsc::sync_channel(1);
211 std::thread::spawn(move || {
212 let mut reader = BufReader::new(stdout);
213 let mut line = String::new();
214 let result = std::io::Read::take(&mut reader, 2049)
215 .read_line(&mut line)
216 .map(|_| line);
217 let _ = tx.send(result);
218 let _ = std::io::copy(&mut reader, &mut std::io::sink());
219 });
220 let ready = rx
221 .recv_timeout(Duration::from_secs(12))
222 .ok()
223 .and_then(Result::ok)
224 .and_then(|line| serde_json::from_str::<Value>(&line).ok());
225 let address = ready.as_ref().and_then(tls::address);
226 let Some(address) = address else {
227 drop(stdin);
228 let _ = child.kill();
229 let _ = child.wait();
230 return Err("Aède n’a pas confirmé son démarrage local.".into());
231 };
232 self.address = Some(address.clone());
233 self.certificate = Some(identity.certificate_der.clone());
234 self.child = Some(child);
235 self.lease = Some(stdin);
236 crate::network::Client::login_pinned(
237 &address,
238 "phemios-local",
239 password,
240 &identity.certificate_der,
241 )
242 }
243 fn command(&self) -> Command {
244 let mut command = Command::new(&self.executable);
245 command
246 .arg(format!("--data={}", self.root.join("engine").display()))
247 .env_remove("AEDE_ADMIN_TOKEN")
248 .env_remove("AEDE_DESKTOP_LEASE")
249 .env_remove("AEDE_FANARTTV_KEY")
250 .env_remove("AEDE_ACOUSTID_KEY")
251 .env_remove("AEDE_DELEGATED_CHILD")
252 .env_remove("AEDE_DELEGATED_DATA_DIR")
253 .env_remove("AEDE_UNAVAILABLE_ROOTS")
254 .env("NO_COLOR", "1")
255 .stderr(Stdio::null());
256 command
257 }
258 fn stop_process(&mut self) {
259 self.lease.take();
260 self.address.take();
261 self.certificate.take();
262 if let Some(mut child) = self.child.take() {
263 std::thread::spawn(move || {
264 let _ = child.wait();
265 });
266 }
267 }
268 pub fn stop(&mut self) -> Result<(), String> {
270 let saved = (|| {
271 let mut value = self.config()?;
272 value["enabled"] = Value::Bool(false);
273 self.save(&value)
274 })();
275 self.stop_process();
277 saved
278 }
279 pub fn add_roots(&self, paths: &[PathBuf]) -> Result<(), String> {
281 let mut value = self.config()?;
282 let roots = value["roots"].as_array_mut().ok_or("Dossiers invalides.")?;
283 for path in paths {
284 let path = path
285 .to_str()
286 .ok_or("Le chemin du dossier doit être UTF-8.")?;
287 if !Path::new(path).is_absolute() || path.len() > 4096 || roots.len() >= 64 {
288 return Err("Dossier local invalide ou limite de 64 dossiers atteinte.".into());
289 }
290 if !roots.iter().any(|row| row["path"] == path) {
291 roots.push(json!({"path":path,"mount":mounts::mount_for(Path::new(path))}));
292 }
293 }
294 self.save(&value)
295 }
296 pub fn scan_request(&self, full: bool, threads: u64) -> Result<Value, String> {
298 if threads > 64 {
299 return Err("Nombre de threads invalide.".into());
300 }
301 let value = self.config()?;
302 let roots = value["roots"].as_array().ok_or("Dossiers invalides.")?;
303 Ok(
304 json!({"folders":roots.iter().map(|v|v["path"].clone()).collect::<Vec<_>>(),"unavailable_folders":roots.iter().filter(|v|!mounts::available(v)).map(|v|v["path"].clone()).collect::<Vec<_>>(),"full":full,"threads":threads}),
305 )
306 }
307 pub fn accepted_scan(&self) -> Result<(), String> {
309 let mut value = self.config()?;
310 for row in value["roots"].as_array_mut().ok_or("Dossiers invalides.")? {
311 row["registered"] = Value::Bool(true);
312 }
313 self.save(&value)
314 }
315 pub fn root_action(&self, path: &str, action: &str) -> Result<(), String> {
317 if !Path::new(path).is_absolute() || path.contains('\0') || path.len() > 4096 {
318 return Err("Dossier invalide.".into());
319 }
320 if action == "remove" {
321 let mut config = self.config()?;
322 let registered = config["roots"]
323 .as_array()
324 .ok_or("Dossiers invalides.")?
325 .iter()
326 .any(|row| row["path"] == path && row["registered"] == true);
327 if !registered {
328 config["roots"]
329 .as_array_mut()
330 .ok_or("Dossiers invalides.")?
331 .retain(|row| row["path"] != path);
332 return self.save(&config);
333 }
334 }
335 let mut command = self.command();
336 command.args(["roots", "--no-scan"]);
337 match action {
338 "remove" => {
339 command.args(["--remove", path]);
340 }
341 "exclude" => {
342 command.args(["--exclude", path]);
343 }
344 "include" => {
345 command.args(["--exclude", path, "--remove"]);
346 }
347 _ => return Err("Action dossier invalide.".into()),
348 };
349 let result = command
350 .stdout(Stdio::null())
351 .status()
352 .map_err(|_| "Modification des dossiers impossible.")?;
353 if !result.success() {
354 return Err(
355 "Aède a refusé la modification des dossiers (données occupées ou chemin invalide)."
356 .into(),
357 );
358 }
359 if action == "remove" {
360 let mut value = self.config()?;
361 value["roots"]
362 .as_array_mut()
363 .ok_or("Dossiers invalides.")?
364 .retain(|row| row["path"] != path);
365 self.save(&value)?;
366 }
367 Ok(())
368 }
369 pub fn configure_concerts(&self, key: Option<&str>, remove: bool) -> Result<Value, String> {
372 let budget = self.concert_budget()?;
373 if let Some(key) = key {
374 self.set_concert_key(key, remove)?;
375 } else if remove {
376 self.set_concert_key("", true)?;
377 }
378 let ready = self.concert_key()?.is_some();
379 let mut status = budget.status();
380 status["ready"] = ready.into();
381 Ok(status)
382 }
383 pub fn concert_key(&self) -> Result<Option<String>, String> {
385 let value = self.config()?;
386 match value.get("jambase") {
387 None | Some(Value::Null) => Ok(None),
388 Some(Value::String(key)) => {
389 crate::concerts::validate_key(key)?;
390 Ok(Some(key.clone()))
391 }
392 _ => Err("Configuration JamBase illisible.".into()),
393 }
394 }
395 fn set_concert_key(&self, key: &str, remove: bool) -> Result<(), String> {
397 if !remove {
398 self.concert_key()?;
399 }
400 let mut value = self.config()?;
401 if remove {
402 value
403 .as_object_mut()
404 .ok_or("Configuration locale invalide.")?
405 .remove("jambase");
406 } else {
407 crate::concerts::validate_key(key.trim())?;
408 value["jambase"] = key.trim().into();
409 }
410 value
411 .as_object_mut()
412 .ok_or("Configuration locale invalide.")?
413 .remove("ticketmaster");
414 self.save(&value)
415 }
416 pub fn concert_budget(&self) -> Result<crate::concerts::Budget, String> {
418 crate::concerts::Budget::open(self.root.clone())
419 }
420 pub fn fetch_keys(&self) -> Result<Value, String> {
422 Ok(self.config()?["keys"].clone())
423 }
424 pub fn keys(
426 &mut self,
427 fanart: &str,
428 acoustid: &str,
429 remove_fanart: bool,
430 remove_acoustid: bool,
431 ) -> Result<(), String> {
432 let mut value = self.config()?;
433 for (key, text, remove) in [
434 ("fanart", fanart, remove_fanart),
435 ("acoustid", acoustid, remove_acoustid),
436 ] {
437 if text.len() > 1024 || text.chars().any(char::is_control) {
438 return Err("Clé API invalide.".into());
439 }
440 if remove {
441 value["keys"]
442 .as_object_mut()
443 .ok_or("Clés invalides.")?
444 .remove(key);
445 } else if !text.trim().is_empty() {
446 value["keys"][key] = text.trim().into();
447 }
448 }
449 self.save(&value)?;
450 Ok(())
451 }
452}
453impl Drop for Engine {
454 fn drop(&mut self) {
455 self.stop_process();
456 }
457}
458#[cfg(test)]
459#[path = "local_tests.rs"]
460mod tests;