mod notify;
use std::ffi::c_int;
use std::io;
use core_foundation::dictionary::CFDictionary;
use core_foundation::propertylist::CFPropertyList;
use system_configuration::dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder};
use crate::config::ProxyConfig;
use crate::error::Error;
use crate::mode::ProxyMode;
use crate::watch::WatchOptions;
use super::cf_dict::to_proxy_dict;
use super::proxy_dict;
pub(crate) use self::notify::Watch;
const STORE_NAME: &str = "proxy-watch";
#[link(name = "SystemConfiguration", kind = "framework")]
unsafe extern "C" {
fn SCError() -> c_int;
}
const SC_STATUS_OK: c_int = 0;
const SC_STATUS_NO_KEY: c_int = 1004;
pub(crate) const STATE_PROXIES_KEY: &str = "State:/Network/Global/Proxies";
pub(crate) const SETUP_PROXIES_KEY: &str = "Setup:/Network/Global/Proxies";
pub(crate) fn read_config(_options: &WatchOptions) -> Result<ProxyConfig, Error> {
let store = create_store()?;
read_both_scopes(|key| read_scope(&store, key))
}
fn read_both_scopes(
read: impl Fn(&str) -> Result<Option<ProxyMode>, Error>,
) -> Result<ProxyConfig, Error> {
let state = read(STATE_PROXIES_KEY)?;
let setup = read(SETUP_PROXIES_KEY);
crate::trace::debug!(
setup_present = setup.as_ref().is_ok_and(Option::is_some),
state_present = state.is_some(),
"read the SCDynamicStore proxies keys"
);
let config = proxy_dict::merge_setup_and_state(setup, state)?;
crate::trace::debug!(
config = %crate::trace::ConfigSummary(&config),
"read the macOS proxy configuration"
);
Ok(config)
}
fn create_store() -> Result<SCDynamicStore, Error> {
let mut attempt = 0;
loop {
if let Some(store) = SCDynamicStoreBuilder::new(STORE_NAME).build() {
return Ok(store);
}
if !notify::should_retry(attempt) {
return Err(Error::io(
"creating an SCDynamicStore session",
io::Error::other("SCDynamicStoreCreateWithOptions returned NULL"),
));
}
crate::trace::warning!(
attempt = attempt + 1,
max_retries = notify::REGISTRATION_MAX_RETRIES,
"SCDynamicStoreCreateWithOptions returned NULL; retrying (configd may still \
be starting)"
);
attempt += 1;
std::thread::sleep(notify::REGISTRATION_RETRY_INTERVAL);
}
}
fn read_scope(store: &SCDynamicStore, key: &str) -> Result<Option<ProxyMode>, Error> {
interpret_copied_value(key, || (store.get(key), unsafe { SCError() }))
}
fn interpret_copied_value(
key: &str,
copy: impl FnOnce() -> (Option<CFPropertyList>, c_int),
) -> Result<Option<ProxyMode>, Error> {
let (value, status) = copy();
let Some(value) = value else {
return match status {
SC_STATUS_NO_KEY => {
crate::trace::debug!(key, "the dynamic store has no such key");
Ok(None)
}
SC_STATUS_OK => {
crate::trace::warning!(
key,
"SCDynamicStoreCopyValue returned NULL but recorded no error; \
treating the key as absent"
);
Ok(None)
}
status => Err(Error::io(
"reading a proxies key from the SCDynamicStore",
io::Error::other(format!(
"SCDynamicStoreCopyValue({key}) failed with SCError() = {status}"
)),
)),
};
};
let Some(dictionary) = CFPropertyList::downcast_into::<CFDictionary>(value) else {
crate::trace::warning!(
key,
"the dynamic store value is not a CFDictionary; treating the key as absent"
);
return Ok(None);
};
proxy_dict::mode_from_dict(&to_proxy_dict(&dictionary)).map(Some)
}
#[cfg(test)]
mod tests {
use core_foundation::data::CFData;
use core_foundation::propertylist::CFPropertyListSubClass;
use crate::config::ProxyConfigSource;
use crate::diagnostic::RejectionKind;
use crate::endpoint::Scheme;
use core_foundation::array::CFArray;
use core_foundation::base::{CFType, TCFType};
use core_foundation::boolean::CFBoolean;
use core_foundation::number::CFNumber;
use core_foundation::string::CFString;
use super::super::cf_dict::cf_string_to_string;
use super::super::proxy_dict::{DictValue, ProxyDict};
use super::*;
fn cf_dict(pairs: &[(&str, CFType)]) -> CFDictionary {
let pairs: Vec<(CFString, CFType)> = pairs
.iter()
.map(|(key, value)| (CFString::new(key), value.clone()))
.collect();
CFDictionary::from_CFType_pairs(&pairs).into_untyped()
}
fn expect(pairs: &[(&str, DictValue)]) -> ProxyDict {
let mut dict = ProxyDict::new();
for (key, value) in pairs {
dict.insert(*key, value.clone());
}
dict
}
#[test]
fn the_modelled_value_types_convert() {
let converted = to_proxy_dict(&cf_dict(&[
("HTTPProxy", CFString::new("proxy.corp").as_CFType()),
("HTTPPort", CFNumber::from(3128i64).as_CFType()),
("HTTPEnable", CFBoolean::true_value().as_CFType()),
("FTPEnable", CFBoolean::false_value().as_CFType()),
(
"ExceptionsList",
CFArray::from_CFTypes(&[CFString::new("*.internal")]).as_CFType(),
),
]));
assert_eq!(
converted,
expect(&[
("HTTPProxy", DictValue::Text("proxy.corp".to_owned())),
("HTTPPort", DictValue::Number(3128)),
("HTTPEnable", DictValue::Number(1)),
("FTPEnable", DictValue::Number(0)),
(
"ExceptionsList",
DictValue::List {
items: vec!["*.internal".to_owned()],
unreadable: 0,
}
),
])
);
}
#[test]
fn a_realistic_dictionary_reaches_the_expected_mode() {
let converted = to_proxy_dict(&cf_dict(&[
("HTTPEnable", CFBoolean::true_value().as_CFType()),
("HTTPProxy", CFString::new("proxy.corp").as_CFType()),
("HTTPPort", CFNumber::from(3128i64).as_CFType()),
("SOCKSEnable", CFBoolean::false_value().as_CFType()),
(
"ExceptionsList",
CFArray::from_CFTypes(&[CFString::new("*.internal"), CFString::new("localhost")])
.as_CFType(),
),
]));
let mode = proxy_dict::mode_from_dict(&converted).expect("a well formed dictionary");
assert_eq!(
mode.endpoint_for(Scheme::Http)
.expect("HTTP is enabled with a host")
.authority(),
"proxy.corp:3128"
);
assert!(
mode.entry_for(Scheme::Socks)
.expect("SOCKSEnable = 0 records an explicit entry")
.is_disabled()
);
}
#[test]
fn an_unknown_key_is_carried_through() {
let converted = to_proxy_dict(&cf_dict(&[(
"ProxyWatchNoSuchKey",
CFString::new("whatever").as_CFType(),
)]));
assert_eq!(
converted,
expect(&[(
"ProxyWatchNoSuchKey",
DictValue::Text("whatever".to_owned())
)])
);
}
#[test]
fn non_string_array_elements_are_skipped_and_counted() {
let converted = to_proxy_dict(&cf_dict(&[(
"ExceptionsList",
CFArray::from_CFTypes(&[
CFString::new("*.internal").as_CFType(),
CFNumber::from(7i64).as_CFType(),
])
.as_CFType(),
)]));
assert_eq!(
converted,
expect(&[(
"ExceptionsList",
DictValue::List {
items: vec!["*.internal".to_owned()],
unreadable: 1,
}
)]),
"the dropped element has to be counted, and when only the count differs the two \
sides above print alike, because `ProxyDict`'s `Debug` deliberately says nothing \
about members it could not read"
);
}
#[test]
fn an_unmodelled_value_type_marks_the_key_unreadable() {
let converted = to_proxy_dict(&cf_dict(&[
("HTTPEnable", CFData::from_buffer(&[1, 2, 3]).as_CFType()),
("HTTPProxy", CFString::new("proxy.corp").as_CFType()),
]));
assert_eq!(
converted,
expect(&[
("HTTPEnable", DictValue::Unreadable),
("HTTPProxy", DictValue::Text("proxy.corp".to_owned())),
]),
"the CFData's contents must not reach ProxyDict, but the key must"
);
}
#[test]
fn an_unreadable_value_under_an_unread_key_is_still_dropped() {
let converted = to_proxy_dict(&cf_dict(&[(
"ProxyWatchNoSuchKey",
CFData::from_buffer(&[1, 2, 3]).as_CFType(),
)]));
assert_eq!(converted, expect(&[]));
}
#[test]
fn a_key_that_is_not_a_string_is_skipped() {
let dictionary = CFDictionary::from_CFType_pairs(&[
(
CFNumber::from(1).as_CFType(),
CFString::new("proxy.corp").as_CFType(),
),
(
CFString::new("HTTPProxy").as_CFType(),
CFString::new("proxy.corp").as_CFType(),
),
])
.into_untyped();
assert_eq!(
to_proxy_dict(&dictionary),
expect(&[("HTTPProxy", DictValue::Text("proxy.corp".to_owned()))])
);
}
#[test]
fn a_string_that_cannot_be_reencoded_as_utf8_is_unreadable_not_absent() {
let chars: [core_foundation::string::UniChar; 1] = [0xD800];
let malformed = unsafe {
let string_ref = core_foundation::string::CFStringCreateWithCharacters(
core_foundation::base::kCFAllocatorDefault,
chars.as_ptr(),
1,
);
CFString::wrap_under_create_rule(string_ref)
};
assert_eq!(cf_string_to_string(&malformed), None);
let converted = to_proxy_dict(&cf_dict(&[
("HTTPEnable", CFData::from_buffer(&[1, 2, 3]).as_CFType()),
("HTTPProxy", malformed.as_CFType()),
]));
assert_eq!(
converted,
expect(&[
("HTTPEnable", DictValue::Unreadable),
("HTTPProxy", DictValue::Unreadable),
]),
"a HTTPProxy value that cannot be reencoded as UTF-8 must not reach ProxyDict"
);
}
#[test]
fn a_lossy_number_is_unreadable_not_absent() {
let converted = to_proxy_dict(&cf_dict(&[
("HTTPEnable", CFBoolean::true_value().as_CFType()),
("HTTPProxy", CFString::new("proxy.corp").as_CFType()),
("HTTPPort", CFNumber::from(3128.5f64).as_CFType()),
]));
assert_eq!(
converted,
expect(&[
("HTTPEnable", DictValue::Number(1)),
("HTTPProxy", DictValue::Text("proxy.corp".to_owned())),
("HTTPPort", DictValue::Unreadable),
])
);
let mode = proxy_dict::mode_from_dict(&converted).expect("a well formed dictionary");
assert!(
mode.endpoint_for(Scheme::Http).is_none(),
"the scheme default may not stand in for a port that was filled in"
);
let rejected = mode.rejected().expect("a manual mode carries the list");
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].kind(), RejectionKind::InvalidProxyEndpoint);
assert_eq!(rejected[0].affected_scheme(), Some(Scheme::Http));
}
#[test]
fn an_absent_key_reports_no_key_and_is_not_an_error() {
let store = create_store().expect("configd answers on the macOS runner");
let key = "Setup:/Network/Global/ProxyWatchNoSuchKey";
let value = store.get(key);
let status = unsafe { SCError() };
assert!(
value.is_none(),
"the key must be absent for this to measure anything"
);
assert_eq!(
status, SC_STATUS_NO_KEY,
"SCError() after a key that does not exist"
);
let result = read_scope(&store, key);
assert!(matches!(result, Ok(None)), "{result:?}");
}
#[test]
fn each_scope_key_reaches_the_argument_named_after_it() {
let configured = ProxyMode::pac(url::Url::parse("http://setup.example/proxy.pac").unwrap());
let config = read_both_scopes(|key| {
Ok(Some(match key {
SETUP_PROXIES_KEY => configured.clone(),
STATE_PROXIES_KEY => ProxyMode::WpadAutoDetect,
other => panic!("read a key neither scope names: {other}"),
}))
})
.expect("both scopes answered");
assert_eq!(
config.sources,
vec![
(
ProxyConfigSource::SystemConfigurationState,
ProxyMode::WpadAutoDetect
),
(ProxyConfigSource::SystemConfigurationSetup, configured),
]
);
assert_eq!(config.effective, ProxyMode::WpadAutoDetect);
}
#[test]
fn only_a_named_status_folds_a_null_copy_into_absence() {
let anomaly = interpret_copied_value(STATE_PROXIES_KEY, || (None, SC_STATUS_OK));
assert!(
matches!(anomaly, Ok(None)),
"a NULL that reports success is an anomaly, but still absence: {anomaly:?}"
);
let failure = interpret_copied_value(STATE_PROXIES_KEY, || (None, 1003));
let failure = failure.expect_err("an unnamed status must not read as no proxy");
let reported = format!("{failure:?}");
assert!(
reported.contains(STATE_PROXIES_KEY) && reported.contains("1003"),
"the error has to name the key and the status it came back with: {reported}"
);
}
#[test]
fn a_copied_value_is_read_through_only_when_it_is_a_dictionary() {
let not_a_dictionary = interpret_copied_value(STATE_PROXIES_KEY, || {
(
Some(CFString::new("Setup:/Network/Global/Proxies").into_CFPropertyList()),
SC_STATUS_OK,
)
});
assert!(matches!(not_a_dictionary, Ok(None)), "{not_a_dictionary:?}");
let read = interpret_copied_value(STATE_PROXIES_KEY, || {
(
Some(
cf_dict(&[
("HTTPEnable", CFBoolean::true_value().as_CFType()),
("HTTPProxy", CFString::new("proxy.corp").as_CFType()),
("HTTPPort", CFNumber::from(3128i64).as_CFType()),
])
.into_CFPropertyList(),
),
SC_STATUS_OK,
)
});
let mode = read.expect("a well formed dictionary").expect("a value");
assert_eq!(
mode.endpoint_for(Scheme::Http)
.expect("HTTP is enabled with a host")
.authority(),
"proxy.corp:3128"
);
}
#[test]
fn the_real_proxies_keys_read_without_error() {
let store = create_store().expect("configd answers on the macOS runner");
for key in [SETUP_PROXIES_KEY, STATE_PROXIES_KEY] {
let result = read_scope(&store, key);
assert!(result.is_ok(), "{key}: {result:?}");
}
}
}