Skip to main content

eggress_system_proxy/backends/
linux.rs

1use crate::apply::Command;
2use crate::command_runner::CommandRunner;
3use crate::inspection::SystemProxySettings;
4
5/// Inspect GNOME proxy settings via `gsettings`.
6pub fn inspect_gnome_proxy(runner: &dyn CommandRunner) -> Result<SystemProxySettings, String> {
7    let mut raw = std::collections::HashMap::new();
8
9    let mode = get_gsettings_value(runner, "org.gnome.system.proxy", "mode")?;
10    raw.insert("mode".to_string(), mode.clone().unwrap_or_default());
11
12    if mode.as_deref() == Some("none") || mode.is_none() {
13        return Ok(SystemProxySettings {
14            source: "linux:gnome:gsettings".to_string(),
15            http_proxy: None,
16            https_proxy: None,
17            socks_proxy: None,
18            no_proxy: None,
19            raw,
20        });
21    }
22
23    let http_host = get_gsettings_value(runner, "org.gnome.system.proxy.http", "host")?;
24    let http_port = get_gsettings_value(runner, "org.gnome.system.proxy.http", "port")?;
25    let https_host = get_gsettings_value(runner, "org.gnome.system.proxy.https", "host")?;
26    let https_port = get_gsettings_value(runner, "org.gnome.system.proxy.https", "port")?;
27    let socks_host = get_gsettings_value(runner, "org.gnome.system.proxy.socks", "host")?;
28    let socks_port = get_gsettings_value(runner, "org.gnome.system.proxy.socks", "port")?;
29    let ignore_hosts = get_gsettings_value(runner, "org.gnome.system.proxy", "ignore-hosts")?;
30
31    let http_proxy = format_proxy_address(&http_host, &http_port);
32    let https_proxy = format_proxy_address(&https_host, &https_port);
33    let socks_proxy = format_proxy_address(&socks_host, &socks_port);
34
35    if let Some(ref v) = http_proxy {
36        raw.insert("http_proxy".to_string(), v.clone());
37    }
38    if let Some(ref v) = https_proxy {
39        raw.insert("https_proxy".to_string(), v.clone());
40    }
41    if let Some(ref v) = socks_proxy {
42        raw.insert("socks_proxy".to_string(), v.clone());
43    }
44
45    let no_proxy = ignore_hosts.map(|h| parse_gsettings_string_array(&h));
46
47    if let Some(ref v) = no_proxy {
48        raw.insert("no_proxy".to_string(), v.clone());
49    }
50
51    Ok(SystemProxySettings {
52        source: "linux:gnome:gsettings".to_string(),
53        http_proxy,
54        https_proxy,
55        socks_proxy,
56        no_proxy,
57        raw,
58    })
59}
60
61/// Generate `gsettings` commands to apply GNOME proxy settings (dry-run).
62pub fn generate_gnome_apply_commands(
63    http_proxy: Option<&str>,
64    https_proxy: Option<&str>,
65    socks_proxy: Option<&str>,
66    no_proxy: Option<&str>,
67) -> Vec<Command> {
68    let mut commands = Vec::new();
69
70    let has_any = http_proxy.is_some() || https_proxy.is_some() || socks_proxy.is_some();
71    if has_any {
72        commands.push(Command::new(
73            "gsettings",
74            vec![
75                "set".into(),
76                "org.gnome.system.proxy".into(),
77                "mode".into(),
78                "manual".into(),
79            ],
80        ));
81    }
82
83    if let Some(http) = http_proxy {
84        if let Some((host, port)) = parse_proxy_address(http) {
85            commands.push(Command::new(
86                "gsettings",
87                vec![
88                    "set".into(),
89                    "org.gnome.system.proxy.http".into(),
90                    "host".into(),
91                    host,
92                ],
93            ));
94            commands.push(Command::new(
95                "gsettings",
96                vec![
97                    "set".into(),
98                    "org.gnome.system.proxy.http".into(),
99                    "port".into(),
100                    port.to_string(),
101                ],
102            ));
103        }
104    }
105
106    if let Some(https) = https_proxy {
107        if let Some((host, port)) = parse_proxy_address(https) {
108            commands.push(Command::new(
109                "gsettings",
110                vec![
111                    "set".into(),
112                    "org.gnome.system.proxy.https".into(),
113                    "host".into(),
114                    host,
115                ],
116            ));
117            commands.push(Command::new(
118                "gsettings",
119                vec![
120                    "set".into(),
121                    "org.gnome.system.proxy.https".into(),
122                    "port".into(),
123                    port.to_string(),
124                ],
125            ));
126        }
127    }
128
129    if let Some(socks) = socks_proxy {
130        if let Some((host, port)) = parse_proxy_address(socks) {
131            commands.push(Command::new(
132                "gsettings",
133                vec![
134                    "set".into(),
135                    "org.gnome.system.proxy.socks".into(),
136                    "host".into(),
137                    host,
138                ],
139            ));
140            commands.push(Command::new(
141                "gsettings",
142                vec![
143                    "set".into(),
144                    "org.gnome.system.proxy.socks".into(),
145                    "port".into(),
146                    port.to_string(),
147                ],
148            ));
149        }
150    }
151
152    if let Some(no_proxy) = no_proxy {
153        let gsettings_list: Vec<String> = no_proxy
154            .split(',')
155            // Escape embedded single quotes as \' per GVariant string syntax.
156            // (argv-based exec: no shell involved, but a raw quote would
157            // produce an invalid GVariant value.)
158            .map(|s| format!("'{}'", s.trim().replace('\'', "\\'")))
159            .collect();
160        let value = format!("[{}]", gsettings_list.join(", "));
161        commands.push(Command::new(
162            "gsettings",
163            vec![
164                "set".into(),
165                "org.gnome.system.proxy".into(),
166                "ignore-hosts".into(),
167                value,
168            ],
169        ));
170    }
171
172    commands
173}
174
175/// Generate `gsettings` commands to disable GNOME proxy (dry-run).
176pub fn generate_gnome_disable_commands() -> Vec<Command> {
177    vec![Command::new(
178        "gsettings",
179        vec![
180            "set".into(),
181            "org.gnome.system.proxy".into(),
182            "mode".into(),
183            "none".into(),
184        ],
185    )]
186}
187
188fn get_gsettings_value(
189    runner: &dyn CommandRunner,
190    schema: &str,
191    key: &str,
192) -> Result<Option<String>, String> {
193    let output = runner
194        .run("gsettings", &["get", schema, key])
195        .map_err(|e| format!("failed to run gsettings: {e}"))?;
196
197    if !output.status.success() {
198        return Ok(None);
199    }
200
201    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
202    if stdout.is_empty() || stdout == "undefined" {
203        return Ok(None);
204    }
205
206    // Strip surrounding quotes from gsettings output
207    let value = if stdout.starts_with('\'') && stdout.ends_with('\'') {
208        stdout[1..stdout.len() - 1].to_string()
209    } else {
210        stdout
211    };
212
213    Ok(Some(value))
214}
215
216fn format_proxy_address(host: &Option<String>, port: &Option<String>) -> Option<String> {
217    match (host, port) {
218        (Some(h), Some(p)) if !h.is_empty() => Some(format!("{h}:{p}")),
219        (Some(h), _) if !h.is_empty() => Some(h.clone()),
220        _ => None,
221    }
222}
223
224fn parse_proxy_address(addr: &str) -> Option<(String, u16)> {
225    let addr = addr
226        .strip_prefix("http://")
227        .or_else(|| addr.strip_prefix("https://"))
228        .or_else(|| addr.strip_prefix("socks://"))
229        .or_else(|| addr.strip_prefix("socks5://"))
230        .unwrap_or(addr);
231
232    if let Some(addr) = addr.strip_prefix('[') {
233        let end = addr.find(']')?;
234        let host = &addr[..end];
235        let port = addr.get(end + 1..)?.strip_prefix(':')?.parse().ok()?;
236        return Some((host.to_string(), port));
237    }
238
239    if let Some(pos) = addr.rfind(':') {
240        let host = addr[..pos].to_string();
241        let port_str = &addr[pos + 1..];
242        if !host.contains(':') {
243            if let Ok(port) = port_str.parse::<u16>() {
244                return Some((host, port));
245            }
246        }
247    }
248    None
249}
250
251fn parse_gsettings_string_array(value: &str) -> String {
252    let value = value.trim();
253    let Some(value) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
254        return value.to_string();
255    };
256
257    let mut entries = Vec::new();
258    let mut current = String::new();
259    let mut quoted = false;
260    let mut escaped = false;
261    for ch in value.chars() {
262        if escaped {
263            current.push(ch);
264            escaped = false;
265        } else if ch == '\\' && quoted {
266            escaped = true;
267        } else if ch == '\'' {
268            quoted = !quoted;
269        } else if ch == ',' && !quoted {
270            let entry = current.trim();
271            if !entry.is_empty() {
272                entries.push(entry.to_string());
273            }
274            current.clear();
275        } else {
276            current.push(ch);
277        }
278    }
279    let entry = current.trim();
280    if !entry.is_empty() {
281        entries.push(entry.to_string());
282    }
283    entries.join(",")
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::command_runner::MockCommandRunner;
290
291    fn success_output(stdout: &str) -> std::process::Output {
292        #[cfg(unix)]
293        {
294            use std::os::unix::process::ExitStatusExt;
295            std::process::Output {
296                status: std::process::ExitStatus::from_raw(0),
297                stdout: stdout.as_bytes().to_vec(),
298                stderr: Vec::new(),
299            }
300        }
301        #[cfg(not(unix))]
302        {
303            std::process::Output {
304                status: std::process::ExitStatus::default(),
305                stdout: stdout.as_bytes().to_vec(),
306                stderr: Vec::new(),
307            }
308        }
309    }
310
311    #[test]
312    fn inspect_gnome_manual_mode() {
313        let runner = MockCommandRunner::new()
314            .add_response(
315                "gsettings",
316                vec![
317                    "get".to_string(),
318                    "org.gnome.system.proxy".to_string(),
319                    "mode".to_string(),
320                ],
321                Ok(success_output("'manual'\n")),
322            )
323            .add_response(
324                "gsettings",
325                vec![
326                    "get".to_string(),
327                    "org.gnome.system.proxy.http".to_string(),
328                    "host".to_string(),
329                ],
330                Ok(success_output("'proxy.example.com'\n")),
331            )
332            .add_response(
333                "gsettings",
334                vec![
335                    "get".to_string(),
336                    "org.gnome.system.proxy.http".to_string(),
337                    "port".to_string(),
338                ],
339                Ok(success_output("8080\n")),
340            )
341            .add_response(
342                "gsettings",
343                vec![
344                    "get".to_string(),
345                    "org.gnome.system.proxy.https".to_string(),
346                    "host".to_string(),
347                ],
348                Ok(success_output("'proxy.example.com'\n")),
349            )
350            .add_response(
351                "gsettings",
352                vec![
353                    "get".to_string(),
354                    "org.gnome.system.proxy.https".to_string(),
355                    "port".to_string(),
356                ],
357                Ok(success_output("8443\n")),
358            )
359            .add_response(
360                "gsettings",
361                vec![
362                    "get".to_string(),
363                    "org.gnome.system.proxy.socks".to_string(),
364                    "host".to_string(),
365                ],
366                Ok(success_output("''\n")),
367            )
368            .add_response(
369                "gsettings",
370                vec![
371                    "get".to_string(),
372                    "org.gnome.system.proxy.socks".to_string(),
373                    "port".to_string(),
374                ],
375                Ok(success_output("0\n")),
376            )
377            .add_response(
378                "gsettings",
379                vec![
380                    "get".to_string(),
381                    "org.gnome.system.proxy".to_string(),
382                    "ignore-hosts".to_string(),
383                ],
384                Ok(success_output("['localhost', '127.0.0.1']\n")),
385            );
386
387        let settings = inspect_gnome_proxy(&runner).unwrap();
388        assert_eq!(
389            settings.http_proxy.as_deref(),
390            Some("proxy.example.com:8080")
391        );
392        assert_eq!(
393            settings.https_proxy.as_deref(),
394            Some("proxy.example.com:8443")
395        );
396        assert_eq!(settings.socks_proxy, None);
397        assert!(settings.no_proxy.unwrap().contains("localhost"));
398    }
399
400    #[test]
401    fn generate_gnome_apply_commands_structure() {
402        let commands = generate_gnome_apply_commands(
403            Some("proxy:8080"),
404            Some("proxy:8443"),
405            None,
406            Some("localhost"),
407        );
408        assert!(commands.iter().any(|c| {
409            let s = c.to_string();
410            s.contains("mode") && s.contains("manual")
411        }));
412        assert!(commands.iter().any(|c| c.to_string().contains("http host")));
413        assert!(commands
414            .iter()
415            .any(|c| c.to_string().contains("https host")));
416        assert!(commands
417            .iter()
418            .any(|c| c.to_string().contains("ignore-hosts")));
419    }
420
421    #[test]
422    fn generate_gnome_disable_commands_works() {
423        let commands = generate_gnome_disable_commands();
424        assert_eq!(commands.len(), 1);
425        assert!(commands[0].to_string().contains("none"));
426    }
427
428    #[test]
429    fn parse_proxy_address_with_scheme() {
430        assert_eq!(
431            parse_proxy_address("http://proxy:8080"),
432            Some(("proxy".to_string(), 8080))
433        );
434    }
435
436    #[test]
437    fn parse_proxy_address_without_scheme() {
438        assert_eq!(
439            parse_proxy_address("proxy:8080"),
440            Some(("proxy".to_string(), 8080))
441        );
442    }
443
444    #[test]
445    fn parse_proxy_address_ipv6() {
446        assert_eq!(
447            parse_proxy_address("http://[::1]:8080"),
448            Some(("::1".to_string(), 8080))
449        );
450    }
451
452    #[test]
453    fn parse_gsettings_single_item_array() {
454        assert_eq!(parse_gsettings_string_array("['localhost']"), "localhost");
455    }
456
457    #[test]
458    fn parse_gsettings_array_preserves_commas_inside_values() {
459        assert_eq!(
460            parse_gsettings_string_array("['localhost', 'foo,bar']"),
461            "localhost,foo,bar"
462        );
463    }
464}