use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use reqwest::Url;
use crate::core::egress::config::ProxySource;
use crate::core::error::{OlError, ERR_PAC_UNAVAILABLE, ERR_PROXY_CONFIG_INVALID};
use super::{rung, Context, Ladder, RungResult};
const SCHEMA: &str = "org.gnome.system.proxy";
const BIN_ENV: &str = "OPENLATCH_GSETTINGS_BIN";
const BIN_ABSOLUTE: &str = "/usr/bin/gsettings"; const DEADLINE: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GSettingsOutcome {
Output(String),
NotInstalled,
Failed(String),
}
pub trait GSettingsSource {
fn list_recursively(&self, schema: &str) -> GSettingsOutcome;
}
pub fn walk(ladder: &mut Ladder, ctx: Context, src: &dyn GSettingsSource, target: &Url) {
let _ = (ctx, target);
ladder.offer(rung::GSETTINGS, gsettings_rung(src));
}
fn gsettings_rung(src: &dyn GSettingsSource) -> RungResult {
let output = match src.list_recursively(SCHEMA) {
GSettingsOutcome::Output(o) => o,
GSettingsOutcome::NotInstalled => {
return RungResult::empty_with(
ProxySource::Gnome,
"no gsettings on this host. If this is KDE, its proxy settings live in \
kioslaverc and are not read: set https_proxy/http_proxy, or [proxy] url",
)
}
GSettingsOutcome::Failed(why) => {
return RungResult::empty_with(
ProxySource::Gnome,
format!("gsettings did not answer: {why}"),
)
}
};
let settings = parse_list_recursively(&output);
match settings.get(SCHEMA, "mode").unwrap_or("none") {
"manual" => manual_rung(&settings),
"auto" => auto_refusal(&settings),
_ => RungResult::empty(ProxySource::Gnome),
}
}
fn manual_rung(settings: &Settings) -> RungResult {
for (child, scheme) in [
("org.gnome.system.proxy.https", "http"),
("org.gnome.system.proxy.http", "http"),
("org.gnome.system.proxy.socks", "socks5h"),
] {
let Some(host) = settings.get(child, "host").filter(|h| !h.is_empty()) else {
continue;
};
let port = settings
.get(child, "port")
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(0);
if port == 0 {
return RungResult::skipped(
ProxySource::Gnome,
ERR_PROXY_CONFIG_INVALID,
format!("{child} sets host = '{host}' with no port"),
);
}
return match Url::parse(&format!("{scheme}://{host}:{port}")) {
Ok(url) => RungResult::static_route(ProxySource::Gnome, url),
Err(e) => RungResult::skipped(
ProxySource::Gnome,
ERR_PROXY_CONFIG_INVALID,
format!("{child} names an unusable proxy '{host}:{port}': {e}"),
),
};
}
RungResult::empty_with(
ProxySource::Gnome,
"mode = 'manual' but no child schema sets a host",
)
}
fn auto_refusal(settings: &Settings) -> RungResult {
let url = settings
.get(SCHEMA, "autoconfig-url")
.unwrap_or("")
.trim()
.to_string();
if url.is_empty() {
return RungResult::skipped(
ProxySource::Wpad,
ERR_PAC_UNAVAILABLE,
"WPAD requested by GNOME settings; not supported on Linux — set [proxy] url, \
or run `openlatch proxy set <url>`",
);
}
RungResult::skipped(
ProxySource::Pac,
ERR_PAC_UNAVAILABLE,
format!(
"GNOME names a PAC script ({url}) and no PAC evaluator exists on Linux — set \
[proxy] url, or run `openlatch proxy set <url>`"
),
)
}
pub fn pac_refusal(pac_url: &str) -> OlError {
OlError::new(
ERR_PAC_UNAVAILABLE,
format!("[proxy] pac_url = \"{pac_url}\" cannot be used: Linux has no PAC evaluator"),
)
.with_suggestion(
"This client ships no JavaScript engine, deliberately. Set [proxy] url to the proxy \
the PAC would have returned, or run `openlatch proxy set <url>`.",
)
}
#[derive(Debug, Default)]
struct Settings(std::collections::HashMap<(String, String), String>);
impl Settings {
fn get(&self, schema: &str, key: &str) -> Option<&str> {
self.0
.get(&(schema.to_string(), key.to_string()))
.map(String::as_str)
}
}
fn parse_list_recursively(output: &str) -> Settings {
let mut out = Settings::default();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.splitn(3, ' ');
let (Some(schema), Some(key), value) = (parts.next(), parts.next(), parts.next()) else {
continue;
};
let value = value.unwrap_or("").trim();
let value = value
.strip_prefix('\'')
.and_then(|v| v.strip_suffix('\''))
.unwrap_or(value);
out.0
.insert((schema.to_string(), key.to_string()), value.to_string());
}
out
}
pub fn native() -> CommandGSettings {
CommandGSettings::from_env()
}
#[derive(Debug, Clone)]
pub struct CommandGSettings {
binary: String,
}
impl CommandGSettings {
pub fn from_env() -> Self {
Self {
binary: resolve_binary(),
}
}
pub fn with_binary(binary: impl Into<String>) -> Self {
Self {
binary: binary.into(),
}
}
}
impl GSettingsSource for CommandGSettings {
fn list_recursively(&self, schema: &str) -> GSettingsOutcome {
let bin = &self.binary;
let mut child = match Command::new(bin)
.arg("list-recursively")
.arg(schema)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return GSettingsOutcome::NotInstalled
}
Err(e) => return GSettingsOutcome::Failed(format!("{bin} could not start: {e}")),
};
let deadline = Instant::now() + DEADLINE;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return GSettingsOutcome::Failed(format!(
"no answer within {} s",
DEADLINE.as_secs()
));
}
Ok(None) => std::thread::sleep(Duration::from_millis(25)),
Err(e) => return GSettingsOutcome::Failed(format!("{e}")),
}
};
let mut buf = String::new();
if let Some(mut stdout) = child.stdout.take() {
let _ = stdout.read_to_string(&mut buf);
}
if !status.success() {
return GSettingsOutcome::Failed(format!("exit {status}"));
}
GSettingsOutcome::Output(buf)
}
}
fn resolve_binary() -> String {
if let Some(seam) = std::env::var(BIN_ENV).ok().filter(|v| !v.is_empty()) {
return seam;
}
if std::path::Path::new(BIN_ABSOLUTE).is_file() {
return BIN_ABSOLUTE.to_string();
}
"gsettings".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::egress::discovery::tests::ScriptedProbe;
use crate::core::egress::discovery::{CandidateAttempt, CandidateOutcome, Route};
struct Fixture(GSettingsOutcome);
impl GSettingsSource for Fixture {
fn list_recursively(&self, _schema: &str) -> GSettingsOutcome {
self.0.clone()
}
}
fn out(lines: &str) -> Fixture {
Fixture(GSettingsOutcome::Output(lines.to_string()))
}
fn target() -> Url {
Url::parse("https://app.openlatch.ai/api/v1/health").expect("target")
}
fn walk_with(src: &dyn GSettingsSource, probe: &ScriptedProbe) -> Vec<CandidateAttempt> {
let mut ladder = Ladder::new(probe);
walk(&mut ladder, Context::UserSession, src, &target());
ladder.finish().1
}
#[test]
fn manual_mode_yields_a_static_candidate() {
let src = out("org.gnome.system.proxy mode 'manual'\n\
org.gnome.system.proxy.https host 'secure.corp'\n\
org.gnome.system.proxy.https port 8443\n");
let probe = ScriptedProbe::new(vec![Ok(5)]);
let mut ladder = Ladder::new(&probe);
walk(&mut ladder, Context::UserSession, &src, &target());
let won = ladder.finish().0.expect("gnome win");
assert_eq!(won.source, ProxySource::Gnome);
assert_eq!(
won.route,
Route::Static(Url::parse("http://secure.corp:8443").expect("url"))
);
}
#[test]
fn the_enabled_key_is_ignored() {
let src = out("org.gnome.system.proxy mode 'manual'\n\
org.gnome.system.proxy.http enabled false\n\
org.gnome.system.proxy.http host 'proxy.corp'\n\
org.gnome.system.proxy.http port 3128\n");
let probe = ScriptedProbe::new(vec![Ok(2)]);
let mut ladder = Ladder::new(&probe);
walk(&mut ladder, Context::UserSession, &src, &target());
let won = ladder.finish().0.expect("gnome win despite enabled=false");
assert_eq!(
won.route,
Route::Static(Url::parse("http://proxy.corp:3128").expect("url"))
);
}
#[test]
fn a_host_with_port_zero_is_incomplete_and_carries_its_own_warning() {
let src = out("org.gnome.system.proxy mode 'manual'\n\
org.gnome.system.proxy.https host 'secure.corp'\n\
org.gnome.system.proxy.https port 0\n");
let probe = ScriptedProbe::always_fails();
let trace = walk_with(&src, &probe);
assert_eq!(trace.len(), 1);
assert_eq!(
trace[0].probe,
CandidateOutcome::Skipped(ERR_PROXY_CONFIG_INVALID)
);
assert!(trace[0]
.detail
.as_deref()
.is_some_and(|d| d.contains("no port")));
}
#[test]
fn auto_mode_is_the_named_pac_refusal() {
let src = out("org.gnome.system.proxy mode 'auto'\n\
org.gnome.system.proxy autoconfig-url 'http://corp/proxy.pac'\n");
let probe = ScriptedProbe::always_fails();
let trace = walk_with(&src, &probe);
assert_eq!(
trace[0].probe,
CandidateOutcome::Skipped(ERR_PAC_UNAVAILABLE)
);
assert_eq!(trace[0].source, ProxySource::Pac);
let detail = trace[0].detail.as_deref().unwrap_or_default();
assert!(detail.contains("http://corp/proxy.pac"));
assert!(
detail.contains("proxy set") || detail.contains("[proxy] url"),
"a refusal owes an executable remedy: {detail}"
);
}
#[test]
fn auto_mode_with_no_url_is_the_wpad_refusal_and_says_wpad() {
let src = out("org.gnome.system.proxy mode 'auto'\n\
org.gnome.system.proxy autoconfig-url ''\n");
let probe = ScriptedProbe::always_fails();
let trace = walk_with(&src, &probe);
assert_eq!(trace[0].source, ProxySource::Wpad);
let detail = trace[0].detail.as_deref().unwrap_or_default();
assert!(detail.contains("WPAD"), "{detail}");
}
#[test]
fn no_gsettings_names_kde_and_the_environment_remedy() {
let src = Fixture(GSettingsOutcome::NotInstalled);
let probe = ScriptedProbe::always_fails();
let trace = walk_with(&src, &probe);
assert_eq!(trace.len(), 1, "the rung must still leave its one entry");
assert_eq!(trace[0].probe, CandidateOutcome::NotConfigured);
let detail = trace[0].detail.as_deref().unwrap_or_default();
assert!(detail.contains("kioslaverc"), "{detail}");
assert!(detail.contains("https_proxy"), "{detail}");
}
#[test]
fn mode_none_leaves_a_clean_empty_rung() {
let src = out("org.gnome.system.proxy mode 'none'\n");
let probe = ScriptedProbe::always_fails();
let trace = walk_with(&src, &probe);
assert_eq!(trace[0].probe, CandidateOutcome::NotConfigured);
assert!(trace[0].detail.is_none());
}
#[test]
fn the_value_parser_unquotes_scalars_and_leaves_lists_alone() {
let s = parse_list_recursively(
"org.gnome.system.proxy mode 'manual'\n\
org.gnome.system.proxy.http port 8080\n\
org.gnome.system.proxy ignore-hosts ['localhost', '::1']\n\
malformed\n",
);
assert_eq!(s.get("org.gnome.system.proxy", "mode"), Some("manual"));
assert_eq!(s.get("org.gnome.system.proxy.http", "port"), Some("8080"));
assert_eq!(
s.get("org.gnome.system.proxy", "ignore-hosts"),
Some("['localhost', '::1']")
);
assert_eq!(s.get("org.gnome.system.proxy", "missing"), None);
}
#[test]
fn the_explicit_pac_refusal_names_the_url_and_a_remedy() {
let err = pac_refusal("http://corp/proxy.pac");
assert_eq!(err.code, ERR_PAC_UNAVAILABLE);
assert!(err.message.contains("http://corp/proxy.pac"));
assert!(err
.suggestion
.as_deref()
.is_some_and(|s| s.contains("proxy set")));
}
#[test]
fn a_missing_binary_is_not_installed_rather_than_a_failure() {
let src = CommandGSettings::with_binary("openlatch-no-such-gsettings-binary");
assert_eq!(src.list_recursively(SCHEMA), GSettingsOutcome::NotInstalled);
let trace = walk_with(&src, &ScriptedProbe::always_fails());
assert!(trace[0]
.detail
.as_deref()
.is_some_and(|d| d.contains("kioslaverc")));
}
}