Skip to main content

phemios_core/
external_links.rs

1//! Narrow system-browser handoff for explicit HTTP(S) source links; never a shell or filesystem opener.
2use std::process::{Command, Stdio};
3use std::time::{Duration, Instant};
4/// Accept bounded HTTP(S) URLs without credentials, whitespace, backslashes or nonstandard ports.
5pub fn browser_url(value: &str) -> Result<&str, String> {
6    if value.len() > 4096
7        || value.chars().any(|c| c.is_control() || c.is_whitespace())
8        || value.contains('\\')
9    {
10        return Err("Lien externe invalide.".into());
11    }
12    let address = value.split('#').next().ok_or("Lien externe invalide.")?;
13    let uri: ureq::http::Uri = address.parse().map_err(|_| "Lien externe invalide.")?;
14    let https = uri
15        .scheme_str()
16        .is_some_and(|s| s.eq_ignore_ascii_case("https"));
17    let http = uri
18        .scheme_str()
19        .is_some_and(|s| s.eq_ignore_ascii_case("http"));
20    let parsed = url::Url::parse(value).map_err(|_| "Lien externe invalide.")?;
21    let canonical = parsed
22        .as_str()
23        .split('#')
24        .next()
25        .ok_or("Lien externe invalide.")?;
26    let local_domain = parsed.domain().is_some_and(|host| {
27        let host = host.strip_suffix('.').unwrap_or(host);
28        host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local")
29    });
30    if !(https || http)
31        || uri.authority().is_none_or(|a| a.as_str().contains('@'))
32        || uri.host().is_none_or(str::is_empty)
33        || !parsed.username().is_empty()
34        || parsed.password().is_some()
35        || !matches!(
36            (https, uri.port_u16()),
37            (_, None) | (true, Some(443)) | (false, Some(80))
38        )
39        || (http && (local_domain || crate::podcast::feed_address(canonical).is_err()))
40    {
41        return Err("Seuls les liens HTTP/HTTPS sans identifiants peuvent être ouverts.".into());
42    }
43    Ok(value)
44}
45fn browser_command(url: &str) -> Result<Command, String> {
46    #[cfg(target_os = "macos")]
47    {
48        let mut command = Command::new("/usr/bin/open");
49        command.arg("--").arg(url);
50        Ok(command)
51    }
52    #[cfg(target_os = "linux")]
53    {
54        let mut command = Command::new("/usr/bin/xdg-open");
55        command.arg(url);
56        Ok(command)
57    }
58    #[cfg(target_os = "windows")]
59    {
60        let system = std::env::var_os("SystemRoot").ok_or("Navigateur système indisponible.")?;
61        let mut command =
62            Command::new(std::path::PathBuf::from(system).join("System32/rundll32.exe"));
63        command.arg("url.dll,FileProtocolHandler").arg(url);
64        Ok(command)
65    }
66    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
67    {
68        let _ = url;
69        Err("Navigateur système indisponible.".into())
70    }
71}
72/// Dispatch a user-activated source URL with fixed OS helpers and separate argv, never command interpretation.
73/// A long-running browser handler is reaped independently; success means OS handoff, not verified page load.
74pub fn open_browser(url: &str) -> Result<(), String> {
75    let mut command = browser_command(browser_url(url)?)?;
76    let mut child = command
77        .stdin(Stdio::null())
78        .stdout(Stdio::null())
79        .stderr(Stdio::null())
80        .spawn()
81        .map_err(|_| "Impossible d’ouvrir le navigateur système.")?;
82    let deadline = Instant::now() + Duration::from_millis(500);
83    loop {
84        if let Some(status) = child
85            .try_wait()
86            .map_err(|_| "Impossible d’ouvrir le navigateur système.")?
87        {
88            return if status.success() {
89                Ok(())
90            } else {
91                Err("Impossible d’ouvrir le navigateur système.".into())
92            };
93        }
94        if Instant::now() >= deadline {
95            std::thread::spawn(move || {
96                let _ = child.wait();
97            });
98            return Ok(());
99        }
100        std::thread::sleep(Duration::from_millis(10));
101    }
102}
103#[cfg(test)]
104#[path = "external_links_tests.rs"]
105mod tests;