use std::ffi::OsStr;
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::SyncSender;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use crate::error::Error;
use super::Env;
use super::desktop::Reading;
use super::kioslaverc::{self, FILE_NAME, KdeConfig, KioslavercSettings};
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,
})
}
pub(crate) struct FileWatch {
_watcher: RecommendedWatcher,
flags: LossFlags,
}
#[derive(Clone)]
pub(crate) struct LossFlags {
lost: Arc<AtomicBool>,
live_directories: Arc<AtomicUsize>,
}
impl LossFlags {
pub(crate) fn any_lost(&self) -> bool {
self.lost.load(Ordering::Acquire)
}
pub(crate) fn is_live(&self) -> bool {
self.live_directories.load(Ordering::Relaxed) > 0
}
}
impl FileWatch {
pub(crate) fn is_live(&self) -> bool {
self.flags.is_live()
}
pub(crate) fn any_lost(&self) -> bool {
self.flags.any_lost()
}
pub(crate) fn loss_flags(&self) -> LossFlags {
self.flags.clone()
}
}
impl std::fmt::Debug for FileWatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileWatch").finish_non_exhaustive()
}
}
#[cfg(test)]
impl LossFlags {
pub(crate) fn lose_one_for_test(&self) {
self.live_directories.fetch_sub(1, Ordering::Relaxed);
self.lost.store(true, Ordering::Release);
}
}
#[cfg(test)]
impl FileWatch {
pub(crate) fn with_flags_for_test(lost: bool, live_directories: usize) -> Self {
FileWatch {
_watcher: notify::recommended_watcher(|_: notify::Result<notify::Event>| {})
.expect("a watcher that watches nothing still needs an inotify handle"),
flags: LossFlags {
lost: Arc::new(AtomicBool::new(lost)),
live_directories: Arc::new(AtomicUsize::new(live_directories)),
},
}
}
}
fn watch_targets(dirs: Vec<PathBuf>) -> (Vec<PathBuf>, bool) {
let leading_dropped = dirs.first().is_some_and(|dir| !dir.is_dir());
let kept: Vec<PathBuf> = dirs.into_iter().filter(|dir| dir.is_dir()).collect();
let leading_dropped = leading_dropped && !kept.is_empty();
(kept, leading_dropped)
}
pub(crate) fn watch(trigger: SyncSender<()>, env: &Env) -> Result<Option<FileWatch>, Error> {
let (directories, leading_dropped) = watch_targets(config_search_dirs(env));
if directories.is_empty() {
return Ok(None);
}
if leading_dropped {
crate::trace::warning!(
file = FILE_NAME,
"the most specific configuration directory does not exist, so it cannot be \
watched; a kioslaverc created there later would win the cascade and be read, \
but no event would announce it — health() reports this route as degraded, and \
WatchOptions::poll_interval is what closes it"
);
}
let flags = LossFlags {
lost: Arc::new(AtomicBool::new(leading_dropped)),
live_directories: Arc::new(AtomicUsize::new(directories.len())),
};
let mut watcher =
notify::recommended_watcher(loss_callback(flags.clone(), directories.clone(), trigger))
.map_err(|source| notify_error("creating the kioslaverc watcher", source))?;
for directory in &directories {
watcher
.watch(directory, RecursiveMode::NonRecursive)
.map_err(|source| {
notify_error(
format!("watching {} for kioslaverc", directory.display()),
source,
)
})?;
crate::trace::debug!(
directory = %directory.display(),
file = FILE_NAME,
"watching the configuration directory with inotify"
);
}
Ok(Some(FileWatch {
_watcher: watcher,
flags,
}))
}
fn loss_callback(
flags: LossFlags,
directories: Vec<PathBuf>,
trigger: SyncSender<()>,
) -> impl FnMut(notify::Result<notify::Event>) {
let mut warned = false;
let mut watched = directories;
move |event: notify::Result<notify::Event>| {
let mut dark = false;
let relevant = match event {
Ok(event) => {
let ended = drop_ended_directories(&event, &mut watched);
if ended > 0 {
let live = flags.live_directories.fetch_sub(ended, Ordering::Relaxed) - ended;
flags.lost.store(true, Ordering::Release);
let first = !std::mem::replace(&mut warned, true);
if live == 0 {
dark = true;
crate::trace::warning!(
file = FILE_NAME,
"every watched configuration directory has been removed or \
renamed; the watches are dropped or now follow the moved \
directories, so no kioslaverc change will be noticed at all \
from here on"
);
} else if first {
crate::trace::warning!(
file = FILE_NAME,
ended = ended,
remaining = live,
"watched configuration directories were removed or renamed; \
those watches are dropped or now follow the moved directories, \
so kioslaverc changes there will no longer be noticed — \
health() now reports this route as degraded, though the \
remaining directories still report"
);
}
true
} else {
is_interesting(&event)
}
}
Err(_) => true,
};
if relevant {
if dark {
super::watcher::wake_delivered(&trigger);
} else {
super::watcher::wake(&trigger);
}
}
}
}
fn drop_ended_directories(event: ¬ify::Event, watched: &mut Vec<PathBuf>) -> usize {
use notify::EventKind;
use notify::event::ModifyKind;
if !matches!(
event.kind,
EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(_))
) {
return 0;
}
let before = watched.len();
watched.retain(|directory| !event.paths.iter().any(|path| path == directory));
before - watched.len()
}
fn is_interesting(event: ¬ify::Event) -> bool {
use notify::EventKind;
if event.need_rescan() {
return true;
}
if !matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any
) {
return false;
}
event
.paths
.iter()
.any(|path| path.file_name().is_some_and(|name| name == FILE_NAME))
}
fn notify_error(context: impl Into<String>, source: notify::Error) -> Error {
let text = source.to_string();
let io_error = match source.kind {
notify::ErrorKind::Io(error) => error,
_ => io::Error::other(text),
};
Error::io(context, io_error)
}
#[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:?}"
);
}
}
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,
}
}
struct Fixture {
root: PathBuf,
dirs: Vec<PathBuf>,
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
#[test]
fn a_lower_priority_holder_still_watches_the_directories_ahead_of_it() {
let f = fixture("lower", &["home", "etc"], &["home", "etc"], &["etc"]);
assert_eq!(watch_targets(f.dirs.clone()), (f.dirs.clone(), false));
}
#[test]
fn the_leading_holder_does_not_hide_lower_layers_from_the_watch() {
let f = fixture("leading", &["home", "etc"], &["home", "etc"], &["home"]);
assert_eq!(watch_targets(f.dirs.clone()), (f.dirs.clone(), false));
}
#[test]
fn candidates_below_a_holder_are_watched() {
let f = fixture(
"below",
&["home", "etc", "opt"],
&["home", "etc", "opt"],
&["etc"],
);
assert_eq!(watch_targets(f.dirs.clone()), (f.dirs.clone(), false));
}
#[test]
fn a_candidate_directory_that_does_not_exist_is_skipped() {
let f = fixture("absent", &["home", "etc"], &["etc"], &["etc"]);
assert_eq!(
watch_targets(f.dirs.clone()),
(vec![f.dirs[1].clone()], true)
);
}
#[test]
fn a_dropped_middle_candidate_is_not_reported() {
let f = fixture(
"middle",
&["home", "session", "etc"],
&["home", "etc"],
&["etc"],
);
assert_eq!(
watch_targets(f.dirs.clone()),
(vec![f.dirs[0].clone(), f.dirs[2].clone()], false)
);
}
#[test]
fn with_no_kioslaverc_anywhere_all_existing_directories_are_watched() {
let f = fixture("none", &["home", "etc"], &["home", "etc"], &[]);
assert_eq!(watch_targets(f.dirs.clone()), (f.dirs.clone(), false));
}
#[test]
fn with_no_candidate_directory_at_all_there_is_nothing_to_watch() {
let f = fixture("empty", &["home", "etc"], &[], &[]);
assert_eq!(watch_targets(f.dirs.clone()), (Vec::new(), false));
}
fn ends_the_watch(event: ¬ify::Event, watched: &[PathBuf]) -> bool {
drop_ended_directories(event, &mut watched.to_vec()) > 0
}
fn directory_event(kind: notify::EventKind, path: &str) -> notify::Event {
notify::Event::new(kind).add_path(PathBuf::from(path))
}
fn watched() -> Vec<PathBuf> {
vec![
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg"),
]
}
#[test]
fn removing_a_watched_directory_ends_the_watch() {
use notify::event::RemoveKind;
let event = directory_event(
notify::EventKind::Remove(RemoveKind::Folder),
"/home/alice/.config",
);
assert!(ends_the_watch(&event, &watched()));
assert!(
!is_interesting(&event),
"the basename is not kioslaverc, which is exactly why is_interesting alone \
let this route go silent"
);
}
#[test]
fn renaming_a_watched_directory_ends_the_watch() {
use notify::event::{ModifyKind, RenameMode};
let event = directory_event(
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"/etc/xdg",
);
assert!(ends_the_watch(&event, &watched()));
}
#[test]
fn removing_the_file_itself_does_not_end_the_watch() {
use notify::event::RemoveKind;
let event = directory_event(
notify::EventKind::Remove(RemoveKind::File),
"/home/alice/.config/kioslaverc",
);
assert!(!ends_the_watch(&event, &watched()));
assert!(is_interesting(&event), "it is still a change to report");
}
#[test]
fn events_elsewhere_do_not_end_the_watch() {
use notify::event::{DataChange, ModifyKind, RemoveKind};
for event in [
directory_event(notify::EventKind::Remove(RemoveKind::Folder), "/tmp/other"),
directory_event(
notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)),
"/home/alice/.config/kdeglobals",
),
] {
assert!(!ends_the_watch(&event, &watched()));
}
}
#[test]
fn a_non_terminal_event_on_the_directory_itself_is_not_terminal() {
use notify::event::{CreateKind, MetadataKind, ModifyKind};
for kind in [
notify::EventKind::Create(CreateKind::Folder),
notify::EventKind::Modify(ModifyKind::Metadata(MetadataKind::Permissions)),
notify::EventKind::Any,
] {
let event = directory_event(kind, "/home/alice/.config");
assert!(!ends_the_watch(&event, &watched()));
}
}
#[test]
fn directories_are_lost_one_at_a_time() {
use notify::event::{ModifyKind, RemoveKind, RenameMode};
let mut live = watched();
assert_eq!(
drop_ended_directories(
&directory_event(
notify::EventKind::Remove(RemoveKind::Folder),
"/home/alice/.config",
),
&mut live,
),
1
);
assert_eq!(live, vec![PathBuf::from("/etc/xdg")]);
assert_eq!(
drop_ended_directories(
&directory_event(
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"/home/alice/.config",
),
&mut live,
),
0
);
assert_eq!(live, vec![PathBuf::from("/etc/xdg")]);
assert_eq!(
drop_ended_directories(
&directory_event(
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"/etc/xdg",
),
&mut live,
),
1
);
assert!(live.is_empty());
}
#[test]
fn one_event_can_end_more_than_one_directory() {
use notify::event::RemoveKind;
let event = notify::Event::new(notify::EventKind::Remove(RemoveKind::Folder))
.add_path(PathBuf::from("/home/alice/.config"))
.add_path(PathBuf::from("/etc/xdg"));
let mut live = watched();
assert_eq!(drop_ended_directories(&event, &mut live), 2);
assert!(live.is_empty());
}
fn callback_over_two_directories() -> (
LossFlags,
std::sync::mpsc::Receiver<()>,
impl FnMut(notify::Result<notify::Event>),
) {
let flags = LossFlags {
lost: Arc::new(AtomicBool::new(false)),
live_directories: Arc::new(AtomicUsize::new(2)),
};
let (trigger, wakes) = std::sync::mpsc::sync_channel(1);
let callback = loss_callback(flags.clone(), watched(), trigger);
(flags, wakes, callback)
}
#[test]
fn a_partial_loss_is_published_before_the_route_goes_dark() {
use notify::event::{DataChange, ModifyKind, RemoveKind, RenameMode};
let (flags, wakes, mut callback) = callback_over_two_directories();
callback(Ok(directory_event(
notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)),
"/etc/xdg/kioslaverc",
)));
assert!(wakes.try_recv().is_ok());
assert!(!flags.any_lost());
assert!(flags.is_live());
callback(Ok(directory_event(
notify::EventKind::Remove(RemoveKind::Folder),
"/home/alice/.config",
)));
assert!(wakes.try_recv().is_ok(), "a lost directory is itself news");
assert!(flags.any_lost());
assert!(flags.is_live(), "/etc/xdg still delivers");
callback(Ok(directory_event(
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"/etc/xdg",
)));
assert!(wakes.try_recv().is_ok());
assert!(flags.any_lost());
assert!(!flags.is_live());
}
#[test]
fn an_event_that_ends_every_directory_at_once_goes_dark_in_one_step() {
use notify::event::RemoveKind;
let (flags, wakes, mut callback) = callback_over_two_directories();
callback(Ok(notify::Event::new(notify::EventKind::Remove(
RemoveKind::Folder,
))
.add_path(PathBuf::from("/home/alice/.config"))
.add_path(PathBuf::from("/etc/xdg"))));
assert!(wakes.try_recv().is_ok());
assert!(flags.any_lost());
assert!(!flags.is_live());
}
#[test]
fn the_wake_that_reports_a_dark_route_is_not_allowed_to_be_refused() {
use notify::event::{DataChange, ModifyKind, RemoveKind};
let (flags, wakes, mut callback) = callback_over_two_directories();
callback(Ok(directory_event(
notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)),
"/etc/xdg/kioslaverc",
)));
let ending = std::thread::spawn(move || {
callback(Ok(notify::Event::new(notify::EventKind::Remove(
RemoveKind::Folder,
))
.add_path(PathBuf::from("/home/alice/.config"))
.add_path(PathBuf::from("/etc/xdg"))));
});
while flags.is_live() {
std::thread::yield_now();
}
std::thread::sleep(std::time::Duration::from_millis(200));
let timeout = std::time::Duration::from_secs(5);
assert_eq!(
wakes.recv_timeout(timeout),
Ok(()),
"the ordinary change is still queued"
);
assert_eq!(
wakes.recv_timeout(timeout),
Ok(()),
"the wake that says the route went dark must survive a full buffer"
);
ending.join().expect("the callback thread must finish");
assert!(flags.any_lost());
assert!(!flags.is_live());
}
#[test]
fn a_notify_failure_keeps_the_io_kind_it_arrived_with() {
let Error::Io { context, source } = notify_error(
"watching /etc/xdg for kioslaverc",
notify::Error::io(io::Error::from_raw_os_error(28)),
) else {
panic!("a notify failure is an I/O error");
};
assert_eq!(context, "watching /etc/xdg for kioslaverc");
assert_eq!(source.raw_os_error(), Some(28));
let Error::Io { source, .. } = notify_error("creating", notify::Error::generic("no fds"))
else {
panic!("a notify failure is an I/O error");
};
assert!(
source.to_string().contains("no fds"),
"the message is all there is: {source}"
);
}
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"
);
}
#[test]
fn only_kioslaverc_events_are_interesting() {
use notify::event::{CreateKind, ModifyKind};
use notify::{Event, EventKind};
let kioslaverc = PathBuf::from("/home/alice/.config/kioslaverc");
let other = PathBuf::from("/home/alice/.config/kdeglobals");
let hit = Event::new(EventKind::Modify(ModifyKind::Any)).add_path(kioslaverc.clone());
assert!(is_interesting(&hit));
let miss = Event::new(EventKind::Modify(ModifyKind::Any)).add_path(other);
assert!(!is_interesting(&miss));
let created = Event::new(EventKind::Create(CreateKind::File)).add_path(kioslaverc.clone());
assert!(is_interesting(&created));
let accessed =
Event::new(EventKind::Access(notify::event::AccessKind::Read)).add_path(kioslaverc);
assert!(
!is_interesting(&accessed),
"reads must not wake the watcher"
);
}
#[test]
fn a_dropped_event_queue_wakes_the_watcher_even_though_it_names_nothing() {
use notify::event::Flag;
use notify::{Event, EventKind};
let overflow = Event::new(EventKind::Any).set_flag(Flag::Rescan);
assert!(
overflow.paths.is_empty(),
"an overflow reports no path, which is the whole difficulty"
);
assert!(is_interesting(&overflow));
}
}