use std::collections::BTreeSet;
use std::fmt;
use std::path::PathBuf;
use crate::extension;
pub mod files;
#[cfg(windows)]
pub mod registry;
#[derive(Debug, Clone)]
pub enum Settings {
Files(files::Files),
#[cfg(windows)]
Registry(registry::Registry),
}
impl Settings {
#[must_use]
pub fn locations(&self) -> Vec<(Origin, String)> {
match self {
Self::Files(f) => f
.locations()
.map(|(o, p)| (o, slpc::display_path(p).clone()))
.collect(),
#[cfg(windows)]
Self::Registry(r) => r.locations(),
}
}
}
impl Source for Settings {
fn layer(&self, origin: Origin) -> Read {
match self {
Self::Files(f) => f.layer(origin),
#[cfg(windows)]
Self::Registry(r) => r.layer(origin),
}
}
}
#[must_use]
pub fn for_this_platform() -> Settings {
#[cfg(windows)]
{
Settings::Registry(registry::Registry::for_this_platform())
}
#[cfg(not(windows))]
{
Settings::Files(files::Files::for_this_platform())
}
}
#[derive(Debug)]
pub enum Error {
Unreadable {
path: PathBuf,
cause: std::io::Error,
},
Malformed {
path: PathBuf,
cause: String,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unreadable { path, cause } => {
write!(f, "{} cannot be read: {cause}", path.display())
}
Self::Malformed { path, cause } => write!(f, "{}: {cause}", path.display()),
}
}
}
impl std::error::Error for Error {}
pub type Read = std::result::Result<Option<Layer>, Error>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Origin {
BuiltIn,
Configuration,
UserPolicy,
MachinePolicy,
}
impl Origin {
#[must_use]
pub fn is_managed(self) -> bool {
matches!(self, Self::UserPolicy | Self::MachinePolicy)
}
}
impl fmt::Display for Origin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(match self {
Self::BuiltIn => "built-in",
Self::Configuration => "configuration",
Self::UserPolicy => "user policy",
Self::MachinePolicy => "machine policy",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Notify {
Everything,
#[default]
Important,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Replace,
Append,
}
#[derive(Debug, Clone, Default)]
pub struct Layer {
pub allowed: Option<Vec<String>>,
pub mode: Option<Mode>,
pub denied: Option<Vec<String>>,
pub user_may_extend: Option<bool>,
pub confirm_each_write_back: Option<bool>,
pub notify: Option<Notify>,
}
impl Layer {
#[must_use]
pub fn says_nothing(&self) -> bool {
self.allowed.is_none()
&& self.mode.is_none()
&& self.denied.is_none()
&& self.user_may_extend.is_none()
&& self.confirm_each_write_back.is_none()
&& self.notify.is_none()
}
}
pub trait Source {
fn layer(&self, origin: Origin) -> Read;
}
pub const BUILT_IN_ALLOWED: &[&str] = &[
"pdf", "rtf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "odg",
"txt", "md", "csv", "tsv", "log", "json", "xml", "yaml", "yml", "jpg", "jpeg", "png", "gif", "tif", "tiff", "webp", "heic", "bmp",
];
#[derive(Debug, Clone)]
pub struct Effective {
allowed: BTreeSet<String>,
denied: BTreeSet<String>,
pub managed: bool,
pub configuration_suppressed: bool,
pub confirm_each_write_back: bool,
pub notify: Notify,
pub uncomparable_entries: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Open { key: String },
Denied { key: String },
NotPermitted { key: String },
NoUsableExtension,
}
pub fn decide(source: &dyn Source, content_name: &str) -> std::result::Result<Decision, Error> {
let Some(key) = extension::policy_key(content_name) else {
return Ok(Decision::NoUsableExtension);
};
let effective = resolve(source)?;
Ok(if effective.denied.contains(&key) {
Decision::Denied { key }
} else if effective.allowed.contains(&key) {
Decision::Open { key }
} else {
Decision::NotPermitted { key }
})
}
pub fn resolve(source: &dyn Source) -> std::result::Result<Effective, Error> {
let mut uncomparable = Vec::new();
let machine = source.layer(Origin::MachinePolicy)?;
let user_policy = source.layer(Origin::UserPolicy)?;
let may_extend = machine
.as_ref()
.and_then(|l| l.user_may_extend)
.or_else(|| user_policy.as_ref().and_then(|l| l.user_may_extend))
.unwrap_or(true);
let configuration = if may_extend {
source.layer(Origin::Configuration)?
} else {
None
};
let built_in = source.layer(Origin::BuiltIn)?.unwrap_or(Layer {
allowed: Some(BUILT_IN_ALLOWED.iter().map(|s| (*s).to_string()).collect()),
..Layer::default()
});
let stack = [
(Origin::MachinePolicy, machine),
(Origin::UserPolicy, user_policy),
(Origin::Configuration, configuration),
(Origin::BuiltIn, Some(built_in)),
];
let mut denied = BTreeSet::new();
for layer in stack.iter().filter_map(|(_, l)| l.as_ref()) {
fold_into(&mut denied, layer.denied.as_deref(), &mut uncomparable);
}
let mut allowed = BTreeSet::new();
for layer in stack.iter().rev().filter_map(|(_, l)| l.as_ref()) {
let Some(list) = layer.allowed.as_deref() else {
continue;
};
if layer.mode.unwrap_or_default() == Mode::Replace {
allowed.clear();
}
fold_into(&mut allowed, Some(list), &mut uncomparable);
}
let managed = stack
.iter()
.any(|(o, l)| o.is_managed() && l.as_ref().is_some_and(|l| !l.says_nothing()));
let confirm = stack
.iter()
.find_map(|(_, l)| l.as_ref().and_then(|l| l.confirm_each_write_back))
.unwrap_or(false);
let notify = stack
.iter()
.find_map(|(_, l)| l.as_ref().and_then(|l| l.notify))
.unwrap_or_default();
uncomparable.sort_unstable();
uncomparable.dedup();
Ok(Effective {
allowed,
denied,
managed,
configuration_suppressed: !may_extend,
confirm_each_write_back: confirm,
notify,
uncomparable_entries: uncomparable,
})
}
impl Effective {
pub fn allowed(&self) -> impl Iterator<Item = &str> {
self.allowed.iter().map(String::as_str)
}
pub fn denied(&self) -> impl Iterator<Item = &str> {
self.denied.iter().map(String::as_str)
}
}
fn fold_into(into: &mut BTreeSet<String>, list: Option<&[String]>, uncomparable: &mut Vec<String>) {
for entry in list.unwrap_or_default() {
let bare = entry.strip_prefix('.').unwrap_or(entry);
if !bare.is_empty() && bare.chars().all(|c| c.is_ascii_alphanumeric()) {
into.insert(bare.to_ascii_lowercase());
} else {
uncomparable.push(entry.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::{decide, resolve, Decision, Layer, Mode, Origin, Read, Source, BUILT_IN_ALLOWED};
#[derive(Default)]
struct Stack {
machine: Option<Layer>,
user_policy: Option<Layer>,
configuration: Option<Layer>,
}
impl Source for Stack {
fn layer(&self, origin: Origin) -> Read {
Ok(match origin {
Origin::MachinePolicy => self.machine.clone(),
Origin::UserPolicy => self.user_policy.clone(),
Origin::Configuration => self.configuration.clone(),
Origin::BuiltIn => None,
})
}
}
#[allow(clippy::unnecessary_wraps)]
fn list(of: &[&str]) -> Option<Vec<String>> {
Some(of.iter().map(|s| (*s).to_string()).collect())
}
#[test]
fn with_nothing_configured_the_shipped_set_is_what_answers() {
let s = Stack::default();
assert_eq!(
decide(&s, "report.pdf").unwrap(),
Decision::Open {
key: "pdf".to_string()
}
);
assert_eq!(
decide(&s, "setup.exe").unwrap(),
Decision::NotPermitted {
key: "exe".to_string()
}
);
}
#[test]
fn a_nested_container_is_not_permitted_by_default() {
assert!(!BUILT_IN_ALLOWED.contains(&"slpc"));
assert_eq!(
decide(&Stack::default(), "inner.slpc").unwrap(),
Decision::NotPermitted {
key: "slpc".to_string()
}
);
}
#[test]
fn a_policy_list_replaces_rather_than_appends_when_it_does_not_say() {
let s = Stack {
machine: Some(Layer {
allowed: list(&["txt"]),
..Layer::default()
}),
..Stack::default()
};
assert_eq!(
decide(&s, "notes.txt").unwrap(),
Decision::Open {
key: "txt".to_string()
}
);
assert_eq!(
decide(&s, "report.pdf").unwrap(),
Decision::NotPermitted {
key: "pdf".to_string()
}
);
}
#[test]
fn appending_is_available_and_has_to_be_asked_for() {
let s = Stack {
configuration: Some(Layer {
allowed: list(&["slpc"]),
mode: Some(Mode::Append),
..Layer::default()
}),
..Stack::default()
};
assert!(matches!(
decide(&s, "inner.slpc").unwrap(),
Decision::Open { .. }
));
assert!(matches!(
decide(&s, "report.pdf").unwrap(),
Decision::Open { .. }
));
}
#[test]
fn a_deny_wins_over_an_allow_in_the_same_layer() {
let s = Stack {
machine: Some(Layer {
allowed: list(&["pdf", "txt"]),
denied: list(&["pdf"]),
..Layer::default()
}),
..Stack::default()
};
assert_eq!(
decide(&s, "report.pdf").unwrap(),
Decision::Denied {
key: "pdf".to_string()
}
);
}
#[test]
fn a_deny_beneath_wins_over_an_allow_above_it() {
let s = Stack {
machine: Some(Layer {
allowed: list(&["pdf"]),
..Layer::default()
}),
configuration: Some(Layer {
denied: list(&["pdf"]),
..Layer::default()
}),
..Stack::default()
};
assert!(matches!(
decide(&s, "report.pdf").unwrap(),
Decision::Denied { .. }
));
}
#[test]
fn policy_can_suppress_the_users_own_configuration() {
let s = Stack {
machine: Some(Layer {
allowed: list(&["pdf"]),
user_may_extend: Some(false),
..Layer::default()
}),
configuration: Some(Layer {
allowed: list(&["exe"]),
mode: Some(Mode::Append),
..Layer::default()
}),
..Stack::default()
};
assert!(matches!(
decide(&s, "setup.exe").unwrap(),
Decision::NotPermitted { .. }
));
assert!(resolve(&s).unwrap().configuration_suppressed);
}
#[test]
fn suppressing_the_configuration_does_not_suppress_the_other_policy_layer() {
let s = Stack {
machine: Some(Layer {
user_may_extend: Some(false),
..Layer::default()
}),
user_policy: Some(Layer {
allowed: list(&["dwg"]),
..Layer::default()
}),
..Stack::default()
};
assert!(matches!(
decide(&s, "plan.dwg").unwrap(),
Decision::Open { .. }
));
}
#[test]
fn machine_policy_outranks_user_policy_on_the_allowed_set() {
let s = Stack {
machine: Some(Layer {
allowed: list(&["txt"]),
..Layer::default()
}),
user_policy: Some(Layer {
allowed: list(&["dwg"]),
..Layer::default()
}),
..Stack::default()
};
assert!(matches!(
decide(&s, "notes.txt").unwrap(),
Decision::Open { .. }
));
assert!(matches!(
decide(&s, "plan.dwg").unwrap(),
Decision::NotPermitted { .. }
));
}
#[test]
fn a_content_file_with_no_usable_extension_is_refused_whatever_the_lists_say() {
let s = Stack {
machine: Some(Layer {
allowed: list(&["pdf"]),
mode: Some(Mode::Append),
..Layer::default()
}),
..Stack::default()
};
assert_eq!(decide(&s, "README").unwrap(), Decision::NoUsableExtension);
assert_eq!(decide(&s, ".bashrc").unwrap(), Decision::NoUsableExtension);
assert_eq!(
decide(&s, "notes.tëxt").unwrap(),
Decision::NoUsableExtension
);
}
#[test]
fn list_entries_are_folded_the_way_a_content_name_is() {
let s = Stack {
machine: Some(Layer {
allowed: list(&["PDF", ".Txt"]),
..Layer::default()
}),
..Stack::default()
};
assert!(matches!(
decide(&s, "REPORT.PDF").unwrap(),
Decision::Open { .. }
));
assert!(matches!(
decide(&s, "notes.txt").unwrap(),
Decision::Open { .. }
));
}
#[test]
fn the_decision_carries_the_key_it_was_made_against() {
assert_eq!(
decide(&Stack::default(), "SETUP.EXE").unwrap(),
Decision::NotPermitted {
key: "exe".to_string()
}
);
}
#[test]
fn an_entry_nothing_can_compare_is_surfaced_rather_than_dropped() {
let s = Stack {
machine: Some(Layer {
denied: list(&["exe", "*.exe", "ex\u{212a}"]),
..Layer::default()
}),
..Stack::default()
};
let e = resolve(&s).unwrap();
assert_eq!(e.uncomparable_entries, vec!["*.exe", "ex\u{212a}"]);
assert!(e.denied().any(|d| d == "exe"));
}
#[test]
fn managed_says_whether_a_policy_layer_contributed() {
assert!(!resolve(&Stack::default()).unwrap().managed);
let empty = Stack {
user_policy: Some(Layer::default()),
..Stack::default()
};
assert!(!resolve(&empty).unwrap().managed);
let refusing_everything = Stack {
user_policy: Some(Layer {
allowed: Some(Vec::new()),
..Layer::default()
}),
..Stack::default()
};
assert!(resolve(&refusing_everything).unwrap().managed);
}
#[test]
fn confirming_each_write_back_is_off_until_a_layer_asks() {
assert!(!resolve(&Stack::default()).unwrap().confirm_each_write_back);
let s = Stack {
configuration: Some(Layer {
confirm_each_write_back: Some(true),
..Layer::default()
}),
..Stack::default()
};
assert!(resolve(&s).unwrap().confirm_each_write_back);
}
}