Skip to main content

phemios_core/
local.rs

1//! Owned Aède child and protected desktop configuration; no arbitrary executable API.
2use 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;
14/// App-owned local engine. Keeping its stdin open leases the server's lifetime.
15pub 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    /// Construct a manager for a fixed bundled executable and isolated app data.
25    /// Unix roots reject shared write access; Windows retains inherited ACLs.
26    /// Metadata admission does not provide race-proof filesystem confinement.
27    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    /// Fixed bundled engine for read-only staging scans and explicit destination copies.
38    pub fn copy_executable(&self) -> PathBuf {
39        self.executable.clone()
40    }
41    /// All configured local library roots, including offline folders and while stopped.
42    /// Never substitute an empty list for unreadable configuration or return credentials.
43    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    // Admit the root itself before private reads/publication. This metadata
68    // check does not provide race-proof confinement of ancestor directories.
69    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        // Bound the actual read too: the file can grow after metadata inspection.
96        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    /// Public configuration never returns credentials or service keys.
138    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    /// Start or reuse only this process's child, authenticating with a retained generated secret.
162    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    /// Disable future startup; catalog, annotations and source files are retained.
269    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        // A damaged or unwritable preference must not retain the live child lease.
276        self.stop_process();
277        saved
278    }
279    /// Register folders picked by the native dialog; scanning remains an explicit action.
280    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    /// Build an explicit scan request, preserving known unmounted roots.
297    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    /// Record accepted scan registration without pretending the task has completed.
308    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    /// Explicit watched-root/exclusion changes reuse the bundled engine's CLI rules.
316    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    /// Lock budget admission before changing a key: an occupied request cannot produce a false failed save.
370    /// This local-only status projection never returns credentials or contacts the provider.
371    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    /// Read the private concert-provider key only inside native Rust operations.
384    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    /// Publish or explicitly remove the concert key without starting an engine or exposing it.
396    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    /// Lock the device-wide provider budget independently of the active Aède account.
417    pub fn concert_budget(&self) -> Result<crate::concerts::Budget, String> {
418        crate::concerts::Budget::open(self.root.clone())
419    }
420    /// Retained service keys are private Rust data, attached only to explicit fetch requests.
421    pub fn fetch_keys(&self) -> Result<Value, String> {
422        Ok(self.config()?["keys"].clone())
423    }
424    /// Save explicitly supplied external keys; empty input preserves, removal is explicit.
425    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;