use std::ffi::OsStr;
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use crate::error::Error;
use super::Env;
use super::desktop::Reading;
use super::kioslaverc::{self, FILE_NAME, KdeConfig, KioslavercSettings};
mod watch;
pub(crate) use watch::{FileWatch, LossFlags, watch};
const XDG_CONFIG_DIRS: &str = "XDG_CONFIG_DIRS";
const DEFAULT_CONFIG_DIRS: &str = "/etc/xdg";
fn search_dirs(
xdg_config_home: Option<&OsStr>,
home: Option<&OsStr>,
xdg_config_dirs: Option<&OsStr>,
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = non_empty(xdg_config_home)
.map(PathBuf::from)
.into_iter()
.chain(non_empty(home).map(|home| Path::new(home).join(".config")))
.find(|dir| dir.is_absolute())
.into_iter()
.collect();
let mut system: Vec<PathBuf> = non_empty(xdg_config_dirs)
.unwrap_or_else(|| OsStr::new(DEFAULT_CONFIG_DIRS))
.as_bytes()
.split(|&b| b == b':')
.filter(|entry| !entry.is_empty())
.map(|entry| PathBuf::from(OsStr::from_bytes(entry)))
.filter(|dir| dir.is_absolute())
.collect();
if system.is_empty() {
system.push(PathBuf::from(DEFAULT_CONFIG_DIRS));
}
for candidate in system {
if !dirs.contains(&candidate) {
dirs.push(candidate);
}
}
dirs
}
fn non_empty(value: Option<&OsStr>) -> Option<&OsStr> {
value.filter(|v| !v.is_empty())
}
fn config_search_dirs(env: &Env) -> Vec<PathBuf> {
search_dirs(
env.var_os("XDG_CONFIG_HOME").as_deref(),
env.var_os("HOME").as_deref(),
env.var_os(XDG_CONFIG_DIRS).as_deref(),
)
}
struct KioslavercRead {
settings: KioslavercSettings,
#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
paths: Vec<PathBuf>,
found_proxy_settings: bool,
}
fn read_kioslaverc(dirs: Vec<PathBuf>) -> Result<Option<KioslavercRead>, Error> {
let mut settings = KioslavercSettings::new();
let mut paths = Vec::new();
let mut found_proxy_settings = false;
for dir in dirs.into_iter().rev() {
let path = dir.join(FILE_NAME);
match std::fs::read_to_string(&path) {
Ok(text) => {
let outcome = apply_proxy_settings(&text, &path, &mut settings)?;
found_proxy_settings |= outcome.found_proxy_settings;
paths.push(path);
if outcome.file_immutable {
break;
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(Error::io(format!("reading {}", path.display()), error)),
}
}
Ok((!paths.is_empty()).then_some(KioslavercRead {
settings,
paths,
found_proxy_settings,
}))
}
pub(crate) fn read_store(env: &Env) -> Result<Reading, Error> {
let Some(read) = read_kioslaverc(config_search_dirs(env))? else {
return Ok(Reading::Absent);
};
if !read.found_proxy_settings {
return Ok(Reading::Unset);
}
Ok(
match kioslaverc::configured_from_kioslaverc(&read.settings, |name: &str| env.var_os(name))?
{
Some(KdeConfig { source, mode }) => Reading::configured(source, mode),
None => {
crate::trace::debug!(
layers = read.paths.len(),
"kioslaverc carries no ProxyType; the KDE store is unconfigured"
);
Reading::Unset
}
},
)
}
fn kioslaverc_ini() -> configparser::ini::Ini {
let mut defaults = configparser::ini::IniDefault::default();
defaults.case_sensitive = true;
defaults.comment_symbols = vec!['#'];
defaults.enable_inline_comments = false;
defaults.delimiters = vec!['='];
configparser::ini::Ini::new_from_defaults(defaults)
}
struct GroupHeader {
name: String,
file_immutable: bool,
group_immutable: bool,
}
fn group_header(line: &str) -> Option<GroupHeader> {
let bytes = line.as_bytes();
let mut header = GroupHeader {
name: String::new(),
file_immutable: false,
group_immutable: false,
};
let mut start = 1;
loop {
let end = start + bytes.get(start..)?.iter().position(|&byte| byte == b']')?;
let part = &line[start..end];
if end + 1 == bytes.len() && part == "$i" {
if header.name.is_empty() {
header.file_immutable = true;
} else {
header.group_immutable = true;
}
} else {
if !header.name.is_empty() {
header.name.push('\x1d');
}
if part.contains('\\') {
header
.name
.push_str(&printable_to_string(part.trim_ascii()));
} else {
header.name.push_str(part);
}
}
start = end + 2;
if start > bytes.len() || bytes[end + 1] != b'[' {
return Some(header);
}
}
}
fn printable_to_string(raw: &str) -> String {
if !raw.contains('\\') {
return raw.to_owned();
}
let chars: Vec<char> = raw.chars().collect();
let len = chars.len();
let mut out: Vec<u8> = Vec::with_capacity(raw.len());
let mut i = 0;
while i < len {
if chars[i] != '\\' {
push_char(&mut out, chars[i]);
i += 1;
continue;
}
if i + 1 >= len {
out.push(b'\\');
break;
}
match chars[i + 1] {
's' => {
out.push(b' ');
i += 2;
}
't' => {
out.push(b'\t');
i += 2;
}
'n' => {
out.push(b'\n');
i += 2;
}
'r' => {
out.push(b'\r');
i += 2;
}
'\\' => {
out.push(b'\\');
i += 2;
}
';' => {
out.extend_from_slice(b"\\;");
i += 2;
}
',' => {
out.extend_from_slice(b"\\,");
i += 2;
}
'x' => {
if i + 3 < len {
match (chars[i + 2].to_digit(16), chars[i + 3].to_digit(16)) {
(Some(hi), Some(lo)) => out.push((hi * 16 + lo) as u8),
_ => {
warn_invalid_hex_escape();
out.push(b'x');
}
}
i += 4;
} else {
out.push(b'x');
i = len;
}
}
unknown => {
warn_unknown_kconfig_escape(unknown);
return raw.to_owned();
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn push_char(out: &mut Vec<u8>, c: char) {
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn warn_unknown_kconfig_escape(escape: char) {
crate::trace::warning!(
escape = %escape,
"kioslaverc: unrecognized KConfig escape sequence in a value; leaving it as written"
);
}
fn warn_invalid_hex_escape() {
crate::trace::warning!(
"kioslaverc: invalid hex digits in a \\x escape; substituting a literal 'x'"
);
}
#[cfg(test)]
fn proxy_settings(text: &str, path: &Path) -> Result<Option<KioslavercSettings>, Error> {
let mut settings = KioslavercSettings::new();
let outcome = apply_proxy_settings(text, path, &mut settings)?;
Ok(outcome.found_proxy_settings.then_some(settings))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ApplyOutcome {
found_proxy_settings: bool,
file_immutable: bool,
}
fn apply_proxy_settings(
text: &str,
path: &Path,
settings: &mut KioslavercSettings,
) -> Result<ApplyOutcome, Error> {
let mut ini = kioslaverc_ini();
ini.read(text.to_owned()).map_err(|reason| {
Error::io(
format!("parsing {}", path.display()),
io::Error::new(io::ErrorKind::InvalidData, reason),
)
})?;
let mut in_proxy_settings = false;
let group_was_immutable = settings.group_is_immutable();
let mut current_group_immutable = false;
let mut pending_group_immutable = false;
let mut file_immutable = false;
let mut found = false;
for line in text.lines() {
let trimmed = line.trim_ascii();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') {
let Some(header) = group_header(trimmed) else {
current_group_immutable = in_proxy_settings && file_immutable;
continue;
};
file_immutable |= header.file_immutable;
in_proxy_settings = header.name == kioslaverc::SECTION;
current_group_immutable =
in_proxy_settings && (file_immutable || header.group_immutable);
pending_group_immutable |= current_group_immutable;
found |= in_proxy_settings;
continue;
}
if !in_proxy_settings || group_was_immutable {
continue;
}
let (raw_key, value) = match trimmed.split_once('=') {
Some((key, value)) => (key, Some(printable_to_string(value.trim_ascii()))),
None => (trimmed, None),
};
if value.is_some() || kioslaverc::has_kconfig_flag(raw_key, 'd') {
settings.apply(raw_key, value, current_group_immutable);
}
}
if pending_group_immutable {
settings.mark_group_immutable();
}
Ok(ApplyOutcome {
found_proxy_settings: found,
file_immutable,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ProxyConfigSource;
use crate::endpoint::Scheme;
use crate::mode::ProxyMode;
#[test]
fn search_dirs_respects_xdg_config_home_dirs_and_defaults() {
type Case<'a> = (
Option<&'a OsStr>,
Option<&'a OsStr>,
Option<&'a OsStr>,
&'a [PathBuf],
);
let cases: &[Case<'_>] = &[
(
Some(OsStr::new("/home/alice/.config")),
Some(OsStr::new("/home/alice")),
Some(OsStr::new("/etc/xdg/kde:/usr/local/etc/xdg")),
&[
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg/kde"),
PathBuf::from("/usr/local/etc/xdg"),
],
),
(
None,
Some(OsStr::new("/home/alice")),
None,
&[
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg"),
],
),
(
Some(OsStr::new("")),
Some(OsStr::new("/home/alice")),
None,
&[
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg"),
],
),
(
Some(OsStr::new("/home/alice/.config")),
None,
None,
&[
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg"),
],
),
(
Some(OsStr::new("/home/alice/.config")),
None,
Some(OsStr::new("")),
&[
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg"),
],
),
(
None,
None,
Some(OsStr::new("/etc/xdg::/opt/xdg:")),
&[PathBuf::from("/etc/xdg"), PathBuf::from("/opt/xdg")],
),
(
Some(OsStr::new("/home/alice/.config")),
None,
Some(OsStr::new("/etc/xdg:/etc/xdg:/opt/xdg")),
&[
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg"),
PathBuf::from("/opt/xdg"),
],
),
(
None,
None,
Some(OsStr::new("/etc/xdg")),
&[PathBuf::from("/etc/xdg")],
),
(None, None, None, &[PathBuf::from(DEFAULT_CONFIG_DIRS)]),
(
Some(OsStr::new("relative")),
Some(OsStr::new("/home/alice")),
None,
&[
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg"),
],
),
(
Some(OsStr::new("relative")),
Some(OsStr::new("also/relative")),
None,
&[PathBuf::from("/etc/xdg")],
),
(
None,
Some(OsStr::new("relative")),
None,
&[PathBuf::from("/etc/xdg")],
),
(
None,
None,
Some(OsStr::new("relative:/opt/xdg:./also")),
&[PathBuf::from("/opt/xdg")],
),
(
None,
None,
Some(OsStr::new("relative:./also")),
&[PathBuf::from("/etc/xdg")],
),
];
for (xdg_config_home, home, xdg_config_dirs, expected) in cases {
let dirs = search_dirs(*xdg_config_home, *home, *xdg_config_dirs);
assert_eq!(
dirs, *expected,
"for {xdg_config_home:?}, {home:?}, {xdg_config_dirs:?}"
);
}
}
pub(super) fn fixture(
name: &str,
candidates: &[&str],
existing: &[&str],
holding: &[&str],
) -> Fixture {
let root =
std::env::temp_dir().join(format!("proxy-watch-kde-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for dir in existing {
std::fs::create_dir_all(root.join(dir)).expect("creating a fixture directory");
}
for dir in holding {
std::fs::write(
root.join(dir).join(FILE_NAME),
"[Proxy Settings]\nProxyType=0\n",
)
.expect("creating a fixture kioslaverc");
}
Fixture {
dirs: candidates.iter().map(|dir| root.join(dir)).collect(),
root,
}
}
pub(super) struct Fixture {
pub(super) root: PathBuf,
pub(super) dirs: Vec<PathBuf>,
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
const SAMPLE: &str = "\
[$Version]
update_info=kioslave.upd:kioslave
[Proxy Settings][$i]
ProxyType=1
httpProxy=http://proxy.corp:8080
httpsProxy=proxy.corp 3128
NoProxyFor=localhost,.corp.example
ReversedException=false
Proxy Config Script[$e]=/home/alice/proxy.pac
";
#[test]
fn the_flagged_section_and_keys_are_found() {
let settings = proxy_settings(SAMPLE, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings][$i] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert_eq!(config.source, ProxyConfigSource::Kioslaverc);
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"proxy.corp:8080"
);
assert_eq!(
config.mode.endpoint_for(Scheme::Https).unwrap().authority(),
"proxy.corp:3128"
);
}
#[test]
fn a_later_immutable_spelling_replaces_an_earlier_value() {
let text = "\
[Proxy Settings]
ProxyType=1
httpProxy=http://unflagged.corp:8080
httpProxy[$i]=http://flagged.corp:9090
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"flagged.corp:9090"
);
}
#[test]
fn an_immutable_entry_refuses_a_later_unflagged_write() {
let text = "\
[Proxy Settings]
ProxyType[$i]=1
ProxyType=0
httpProxy=proxy.corp:8080
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is present");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"proxy.corp:8080"
);
}
#[test]
fn a_group_marker_freezes_its_keys_against_a_later_section_in_the_same_file() {
let text = "\
[Proxy Settings][$i]
ProxyType=1
httpProxy=locked.corp:8080
[Proxy Settings]
httpProxy=later.corp:9090
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is present");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"locked.corp:8080"
);
}
#[test]
fn a_deleted_entry_does_not_survive_as_an_older_proxy() {
let text = "\
[Proxy Settings]
ProxyType=1
httpProxy=old.corp:8080
httpProxy[$d]
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is present");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert!(config.mode.endpoint_for(Scheme::Http).is_none());
}
#[test]
fn a_deleted_entry_is_not_a_blank_one() {
let mode_after = |last_row: &str| {
let text = format!(
"[Proxy Settings]\nProxyType=1\nftpProxy=ftp.corp:2121\nhttpProxy=old.corp:8080\n{last_row}\n"
);
let settings = proxy_settings(&text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is present");
kioslaverc::config_from_kioslaverc(&settings, |_| None)
.unwrap()
.mode
};
assert!(
mode_after("httpProxy[$d]")
.entry_for(Scheme::Http)
.is_none()
);
assert!(matches!(
mode_after("httpProxy=").entry_for(Scheme::Http),
Some(crate::endpoint::ProxyEntry::Disabled)
));
}
#[test]
fn an_immutable_deletion_refuses_a_later_recreation_only_when_i_comes_first() {
let http_after = |flags: &str| {
let text = format!(
"[Proxy Settings]\nProxyType=1\nhttpProxy=old.corp:8080\nhttpProxy[{flags}]\n\
httpProxy=new.corp:9090\n"
);
let settings = proxy_settings(&text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is present");
kioslaverc::config_from_kioslaverc(&settings, |_| None)
.unwrap()
.mode
.endpoint_for(Scheme::Http)
.map(|endpoint| endpoint.authority())
};
assert_eq!(http_after("$id"), None);
assert_eq!(http_after("$di").as_deref(), Some("new.corp:9090"));
assert_eq!(http_after("$d").as_deref(), Some("new.corp:9090"));
}
#[test]
fn a_normal_duplicate_key_uses_the_last_value() {
let text = "\
[Proxy Settings]
ProxyType=1
httpProxy=first.corp:8080
httpProxy=last.corp:9090
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is present");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"last.corp:9090"
);
}
#[test]
fn an_expansion_flag_never_reads_or_expands_the_environment() {
let text = "\
[Proxy Settings]
ProxyType=1
httpProxy[$e]=$PROXY_WATCH_KDE_TEST:8080
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is present");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| {
panic!("ProxyType=1 must not consult the environment")
})
.unwrap();
assert!(config.mode.endpoint_for(Scheme::Http).is_none());
assert_eq!(
config.mode.rejected().unwrap()[0].redacted_input(),
"$PROXY_WATCH_KDE_TEST:8080"
);
}
#[test]
fn repeated_logical_sections_are_merged_in_file_order() {
let text = "\
[Proxy Settings]
ProxyType=1
httpProxy=first.corp:8080
[Other]
value=ignored
[Proxy Settings]
httpsProxy=second.corp:8443
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is present");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"first.corp:8080"
);
assert_eq!(
config.mode.endpoint_for(Scheme::Https).unwrap().authority(),
"second.corp:8443"
);
}
#[test]
fn more_specific_files_override_normal_system_values() {
let f = fixture("cascade", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy=system.corp:8080\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpProxy=user.corp:9090\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
assert_eq!(
read.paths,
vec![f.dirs[1].join(FILE_NAME), f.dirs[0].join(FILE_NAME)]
);
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"user.corp:9090"
);
}
#[test]
fn system_immutable_entry_refuses_a_user_override() {
let f = fixture("immutable-entry", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy[$i]=system.corp:8080\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpProxy=user.corp:9090\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"system.corp:8080"
);
}
#[test]
fn system_immutable_group_refuses_existing_and_new_user_keys() {
let f = fixture("immutable-group", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings][$i]\nProxyType=1\nhttpProxy=system.corp:8080\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpProxy=user.corp:9090\nhttpsProxy=user.corp:9443\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"system.corp:8080"
);
assert!(config.mode.endpoint_for(Scheme::Https).is_none());
}
#[test]
fn a_repeated_group_after_the_marker_does_not_unlock_it() {
let f = fixture(
"immutable-group-again",
&["home", "etc"],
&["home", "etc"],
&[],
);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings][$i]\nProxyType=1\nhttpProxy=system.corp:8080\n\n\
[Proxy Settings]\nhttpProxy=system.corp:8080\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpsProxy=user.corp:9443\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"system.corp:8080"
);
assert!(
config.mode.endpoint_for(Scheme::Https).is_none(),
"a slot the locked group never named is still refused"
);
}
#[test]
fn a_user_layer_without_the_group_does_not_unfind_the_system_layer() {
let f = fixture("group-fold", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy=system.corp:8080\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[General]\nBrowserApplication=firefox\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
assert!(
read.found_proxy_settings,
"the system layer named the group: {:?}",
read.paths
);
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"system.corp:8080"
);
}
#[test]
fn system_file_immutable_marker_ends_the_cascade() {
let f = fixture("immutable-file", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy=system.corp:8080\n\n[$i]\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpProxy=user.corp:9090\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
assert_eq!(read.paths, vec![f.dirs[1].join(FILE_NAME)]);
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"system.corp:8080"
);
}
#[test]
fn a_padded_file_immutable_marker_is_an_ordinary_group() {
let f = fixture(
"immutable-file-padded",
&["home", "etc"],
&["home", "etc"],
&[],
);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy=system.corp:8080\n\n[ $i ]\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpProxy=user.corp:9090\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
assert_eq!(read.paths.len(), 2, "{:?}", read.paths);
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"user.corp:9090"
);
}
#[test]
fn a_group_header_is_read_as_kconfig_reads_it() {
let opens = |header: &str| {
let text = format!("{header}\nProxyType=1\nhttpProxy=p.corp 8080\n");
proxy_settings(&text, Path::new("kioslaverc"))
.unwrap()
.is_some()
};
assert!(opens("[Proxy Settings]"));
assert!(opens("[Proxy Settings] trailing text"));
assert!(opens("[Proxy Settings]# a comment KConfig does not have"));
assert!(opens("[Proxy Settings][$i]"));
assert!(!opens("[ Proxy Settings ]"));
assert!(!opens("[Proxy Settings][$e]"));
assert!(opens("[Proxy Settings] [$i]"));
assert!(
!group_header("[Proxy Settings] [$i]")
.unwrap()
.group_immutable
);
assert!(proxy_settings("[Proxy Settings\nProxyType=1\n", Path::new("kioslaverc")).is_err());
let header = group_header("[Proxy Settings][$i]").unwrap();
assert_eq!(header.name, "Proxy Settings");
assert!(header.group_immutable && !header.file_immutable);
let header = group_header("[$i]").unwrap();
assert!(header.file_immutable && header.name.is_empty());
assert_eq!(group_header("[a][b]x").unwrap().name, "a\x1db");
}
#[test]
fn a_layer_that_exists_but_cannot_be_read_refuses_the_whole_read() {
let f = fixture("unreadable", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy=system.corp:8080\n",
)
.unwrap();
std::fs::create_dir(f.dirs[0].join(FILE_NAME)).expect("a kioslaverc that is not a file");
let Err(error) = read_kioslaverc(f.dirs.clone()) else {
panic!("a layer that is neither missing nor readable is not an answer");
};
let Error::Io { context, .. } = &error else {
panic!("the refusal is an I/O error, not {error:?}");
};
assert!(
context.ends_with(&f.dirs[0].join(FILE_NAME).display().to_string()),
"the refusal names the layer that could not be read: {context}"
);
}
#[test]
fn a_user_deletion_removes_a_system_value() {
let f = fixture("delete", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy=system.corp:8080\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpProxy[$d]\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert!(config.mode.endpoint_for(Scheme::Http).is_none());
}
#[test]
fn an_absent_immutable_deletion_blocks_a_later_layer() {
let f = fixture("absent-delete-id", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy[$id]\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpProxy=user.corp:9090\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert!(config.mode.endpoint_for(Scheme::Http).is_none());
}
#[test]
fn an_absent_immutable_deletion_does_not_block_a_later_layer() {
let f = fixture("absent-delete", &["home", "etc"], &["home", "etc"], &[]);
std::fs::write(
f.dirs[1].join(FILE_NAME),
"[Proxy Settings]\nProxyType=1\nhttpProxy[$di]\n",
)
.unwrap();
std::fs::write(
f.dirs[0].join(FILE_NAME),
"[Proxy Settings]\nhttpProxy=user.corp:9090\n",
)
.unwrap();
let read = read_kioslaverc(f.dirs.clone()).unwrap().unwrap();
let config = kioslaverc::config_from_kioslaverc(&read.settings, |_| None).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"user.corp:9090"
);
}
#[test]
fn a_file_without_the_section_is_no_source() {
let text = "[$Version]\nupdate_info=x\n";
assert!(
proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.is_none()
);
}
#[test]
fn a_non_ascii_space_does_not_open_a_section_kconfig_leaves_shut() {
let text = "\u{3000}[Proxy Settings]\nProxyType=1\nhttpProxy=http://proxy.corp:8080\n";
assert!(
proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.is_none(),
"U+3000 ahead of `[` leaves the line an ordinary one for KConfig"
);
}
#[test]
fn a_non_ascii_space_stays_on_a_key_the_way_kconfig_keeps_it() {
let text = "[Proxy Settings]\nProxyType=1\n\u{a0}httpProxy=http://proxy.corp:8080\n";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert!(
config.mode.endpoint_for(Scheme::Http).is_none(),
"`\u{a0}httpProxy` is not `httpProxy`: {:?}",
config.mode
);
}
#[test]
fn a_hash_in_a_manual_proxy_password_is_not_truncated_by_the_ini_parser() {
let text = "\
[Proxy Settings][$i]
ProxyType=1
httpProxy=http://user:pa#ss@proxy.example:8080
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings][$i] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
assert!(
config.mode.endpoint_for(Scheme::Http).is_none(),
"endpoint.rs's own userinfo/`#` gap still drops this entry"
);
assert_eq!(
config.mode.rejected().unwrap()[0].redacted_input(),
"http://user:***@proxy.example:8080",
"rejected must carry the full, un-truncated (and masked) address, not a \
truncated remnant"
);
}
#[test]
fn a_semicolon_in_a_manual_proxy_password_is_not_truncated_by_the_ini_parser() {
let text = "\
[Proxy Settings][$i]
ProxyType=1
httpProxy=http://user:pa;ss@proxy.example:8080
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings][$i] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
let endpoint = config
.mode
.endpoint_for(Scheme::Http)
.expect("a `;` must not be mistaken for a comment introducer");
assert_eq!(endpoint.authority(), "proxy.example:8080");
let auth = endpoint.auth.as_ref().expect("userinfo was present");
assert_eq!(auth.username(), "user");
assert_eq!(auth.password(), Some("pa;ss"));
}
#[test]
fn a_hash_in_a_proxy_config_script_path_is_not_truncated_by_the_ini_parser() {
let text = "\
[Proxy Settings][$i]
ProxyType=2
Proxy Config Script[$e]=/home/alice/proxy#1.pac
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings][$i] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
match config.mode {
ProxyMode::Pac { url, .. } => {
assert_eq!(url.path(), "/home/alice/proxy%231.pac");
assert_eq!(
url.fragment(),
None,
"the `#` names a byte in the file, so nothing is left over as a fragment"
);
}
other => panic!("expected a PAC mode, got {other:?}"),
}
}
#[test]
fn a_leading_hash_line_is_still_dropped_as_a_comment() {
let text = "\
[Proxy Settings][$i]
# httpProxy=http://ignored.example:1
ProxyType=1
";
let mut ini = kioslaverc_ini();
let map = ini.read(text.to_owned()).unwrap();
let entries = &map["Proxy Settings][$i"];
assert!(
!entries.keys().any(|key| key.contains("httpProxy")),
"a line starting with `#` must not surface as a key at all: {entries:?}"
);
}
#[test]
fn a_leading_semicolon_line_is_not_a_comment() {
let text = "\
[Proxy Settings][$i]
ProxyType=1
;httpProxy=http://proxy.corp:8080
";
let mut ini = kioslaverc_ini();
let map = ini.read(text.to_owned()).unwrap();
let entries = &map["Proxy Settings][$i"];
assert_eq!(
entries.get(";httpProxy"),
Some(&Some("http://proxy.corp:8080".to_owned())),
"a leading `;` must be retained as ordinary text, not treated as a \
comment: {entries:?}"
);
}
#[test]
fn a_file_the_ini_syntax_refuses_is_refused_whole() {
let text = "\
[Proxy Settings]
ProxyType=1
httpProxy=http://proxy.corp:8080
[truncated
";
let Err(error) = proxy_settings(text, Path::new("/etc/xdg/kioslaverc")) else {
panic!("an unclosed section is not a kioslaverc");
};
let Error::Io { context, source } = &error else {
panic!("the refusal is an I/O error, not {error:?}");
};
assert_eq!(context, "parsing /etc/xdg/kioslaverc");
assert_eq!(source.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn leading_and_trailing_space_is_reversed_before_the_url_is_parsed() {
let text = "\
[Proxy Settings][$i]
ProxyType=1
httpProxy=\\shttp://user:secret@proxy.example.test:8080\\s
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings][$i] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
let endpoint = config
.mode
.endpoint_for(Scheme::Http)
.expect("the padding spaces must not stop the URL from parsing");
assert_eq!(endpoint.authority(), "proxy.example.test:8080");
let auth = endpoint.auth.as_ref().expect("userinfo was present");
assert_eq!(auth.username(), "user");
assert_eq!(auth.password(), Some("secret"));
assert!(
config.mode.rejected().unwrap().is_empty(),
"nothing should have been dropped once \\s is reversed"
);
}
#[test]
fn kconfig_escape_sequences_are_reversed_before_parsing() {
enum Expectation<'a> {
Password(&'a str),
Rejected(&'a str),
}
let cases: &[(&str, Expectation<'_>)] = &[
(
"http://user:pa\\\\ss@proxy.example.test:8080",
Expectation::Password("pa\\ss"),
),
(
"bad host\\twith\\ntab\\rand\\rcr",
Expectation::Rejected("bad host\twith\ntab\rand\rcr"),
),
(
"bad\\x2Ehost value",
Expectation::Rejected("bad.host value"),
),
(
"weird\\qvalue with space",
Expectation::Rejected("weird\\qvalue with space"),
),
(
"bad\\shost\\qtail",
Expectation::Rejected("bad\\shost\\qtail"),
),
(
"bad caf\\xc3\\xa9 host",
Expectation::Rejected("bad café host"),
),
(
"bad caf\u{e9}\\shost",
Expectation::Rejected("bad café host"),
),
("bad \\xff byte", Expectation::Rejected("bad \u{fffd} byte")),
("bad host\\", Expectation::Rejected("bad host\\")),
(
"bad host\\;and\\,more",
Expectation::Rejected("bad host\\;and\\,more"),
),
("bad \\xzz byte", Expectation::Rejected("bad x byte")),
("bad host \\xA", Expectation::Rejected("bad host x")),
];
for (http_proxy, expectation) in cases {
let text = format!(
"\
[Proxy Settings][$i]
ProxyType=1
httpProxy={http_proxy}
"
);
let settings = proxy_settings(&text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings][$i] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
match expectation {
Expectation::Password(password) => {
let endpoint = config
.mode
.endpoint_for(Scheme::Http)
.expect("a literal backslash in the password must not break parsing");
let auth = endpoint.auth.as_ref().expect("userinfo was present");
assert_eq!(auth.password(), Some(*password), "for {http_proxy:?}");
}
Expectation::Rejected(rejected) => {
assert!(
config.mode.endpoint_for(Scheme::Http).is_none(),
"for {http_proxy:?}"
);
assert_eq!(
config.mode.rejected().unwrap()[0].redacted_input(),
*rejected,
"for {http_proxy:?}"
);
}
}
}
}
#[test]
fn a_decoded_escape_survives_into_the_script_url() {
for (written, decoded) in [
("/home/a\\\\b/proxy.pac", "/home/a\\b/proxy.pac"),
("/home/a\\tb/proxy.pac", "/home/a\tb/proxy.pac"),
] {
let text = format!(
"\
[Proxy Settings][$i]
ProxyType=2
Proxy Config Script={written}
"
);
let settings = proxy_settings(&text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings][$i] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
match &config.mode {
crate::ProxyMode::Pac { url, .. } => assert_eq!(
crate::util::percent_decode(url.path()),
decoded,
"for {written:?}"
),
other => panic!("expected Pac for {written:?}, got {other:?}"),
}
}
}
#[test]
fn a_padded_script_row_is_trimmed_before_its_escapes_are_read() {
let text = "\
[Proxy Settings]
ProxyType=2
Proxy Config Script = /home/alice/proxy.pac\\s
";
let settings = proxy_settings(text, Path::new("kioslaverc"))
.unwrap()
.expect("[Proxy Settings] is the section");
let config = kioslaverc::config_from_kioslaverc(&settings, |_| None).unwrap();
let crate::ProxyMode::Pac { url, .. } = &config.mode else {
panic!("ProxyType=2 is a PAC mode, got {:?}", config.mode);
};
assert_eq!(
crate::util::percent_decode(url.path()),
"/home/alice/proxy.pac ",
"the administrator's own trailing space is the one that survives"
);
}
}