use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::{Error, Layer, Mode, Notify, Origin, Read, Source};
#[derive(Debug, Default, Clone)]
pub struct Files {
paths: BTreeMap<Origin, PathBuf>,
}
impl Files {
#[must_use]
pub fn none() -> Self {
Self::default()
}
#[must_use]
pub fn at(mut self, origin: Origin, path: impl Into<PathBuf>) -> Self {
self.paths.insert(origin, path.into());
self
}
#[must_use]
pub fn locations(&self) -> impl DoubleEndedIterator<Item = (Origin, &Path)> {
self.paths.iter().rev().map(|(o, p)| (*o, p.as_path()))
}
#[must_use]
pub fn for_this_platform() -> Self {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let mut files = Self::none().at(Origin::MachinePolicy, "/etc/slipcase/open.toml");
if let Some(dir) = config_home() {
files = files.at(Origin::Configuration, dir.join("slipcase-open/policy.toml"));
}
files
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Self::none()
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn config_home() -> Option<PathBuf> {
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(x));
}
Some(PathBuf::from(std::env::var_os("HOME")?).join(".config"))
}
impl Source for Files {
fn layer(&self, origin: Origin) -> Read {
let Some(path) = self.paths.get(&origin) else {
return Ok(None);
};
match std::fs::read_to_string(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(cause) => Err(Error::Unreadable {
path: path.clone(),
cause,
}),
Ok(text) => parse(path, &text).map(Some),
}
}
}
fn parse(path: &Path, text: &str) -> std::result::Result<Layer, Error> {
let bad = |cause: String| Error::Malformed {
path: path.to_owned(),
cause,
};
let doc: toml_edit::DocumentMut = text.parse().map_err(|e| bad(format!("{e}")))?;
let list = |key: &str| -> std::result::Result<Option<Vec<String>>, Error> {
let Some(item) = doc.get(key) else {
return Ok(None);
};
let array = item
.as_array()
.ok_or_else(|| bad(format!("`{key}` must be an array of strings")))?;
array
.iter()
.map(|v| {
v.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| bad(format!("`{key}` must be an array of strings")))
})
.collect::<std::result::Result<Vec<_>, _>>()
.map(Some)
};
let flag = |key: &str| -> std::result::Result<Option<bool>, Error> {
doc.get(key)
.map(|v| {
v.as_bool()
.ok_or_else(|| bad(format!("`{key}` must be true or false")))
})
.transpose()
};
let mode = match doc.get("mode").map(|v| v.as_str()) {
None => None,
Some(Some("replace")) => Some(Mode::Replace),
Some(Some("append")) => Some(Mode::Append),
Some(other) => {
return Err(bad(format!(
"`mode` must be \"replace\" or \"append\", not {}",
other.map_or_else(|| "that".to_string(), |s| format!("\"{s}\"")),
)))
}
};
let notify = match doc.get("notify").map(|v| v.as_str()) {
None => None,
Some(Some("everything")) => Some(Notify::Everything),
Some(Some("important")) => Some(Notify::Important),
Some(other) => {
return Err(bad(format!(
"`notify` must be \"everything\" or \"important\", not {}",
other.map_or_else(|| "that".to_string(), |s| format!("\"{s}\"")),
)))
}
};
Ok(Layer {
allowed: list("allowed")?,
mode,
denied: list("denied")?,
user_may_extend: flag("user_may_extend")?,
confirm_each_write_back: flag("confirm_each_write_back")?,
notify,
})
}
#[cfg(test)]
mod tests {
use super::Files;
use crate::policy::{decide, resolve, Decision, Error, Origin, Source};
use std::fs;
fn write(dir: &std::path::Path, name: &str, text: &str) -> std::path::PathBuf {
let p = dir.join(name);
fs::write(&p, text).unwrap();
p
}
#[test]
fn a_layer_that_is_not_there_says_nothing() {
let files = Files::none().at(Origin::MachinePolicy, "/nonexistent/policy.toml");
assert!(files.layer(Origin::MachinePolicy).unwrap().is_none());
assert!(matches!(
decide(&files, "report.pdf").unwrap(),
Decision::Open { .. }
));
}
#[test]
fn a_layer_reads_every_key_it_carries() {
let tmp = tempfile::tempdir().unwrap();
let p = write(
tmp.path(),
"policy.toml",
"allowed = [\"pdf\", \"txt\"]\nmode = \"append\"\ndenied = [\"exe\"]\n\
user_may_extend = false\nconfirm_each_write_back = true\n",
);
let files = Files::none().at(Origin::MachinePolicy, p);
let layer = files.layer(Origin::MachinePolicy).unwrap().unwrap();
assert_eq!(
layer.allowed.as_deref(),
Some(&["pdf".into(), "txt".into()][..])
);
assert_eq!(layer.mode, Some(crate::policy::Mode::Append));
assert_eq!(layer.denied.as_deref(), Some(&["exe".into()][..]));
assert_eq!(layer.user_may_extend, Some(false));
assert_eq!(layer.confirm_each_write_back, Some(true));
}
#[test]
fn an_omitted_key_says_nothing_rather_than_no() {
let tmp = tempfile::tempdir().unwrap();
let p = write(tmp.path(), "policy.toml", "denied = [\"exe\"]\n");
let layer = Files::none()
.at(Origin::MachinePolicy, p)
.layer(Origin::MachinePolicy)
.unwrap()
.unwrap();
assert!(layer.allowed.is_none());
assert!(layer.user_may_extend.is_none());
}
#[test]
fn a_policy_file_that_will_not_parse_stops_the_decision() {
let tmp = tempfile::tempdir().unwrap();
let p = write(tmp.path(), "policy.toml", "denied = [\"exe\"\n");
let files = Files::none().at(Origin::MachinePolicy, &p);
match decide(&files, "report.pdf") {
Err(Error::Malformed { path, .. }) => assert_eq!(path, p),
other => panic!("{other:?}"),
}
}
#[test]
fn a_key_of_the_wrong_type_is_named_rather_than_ignored() {
let tmp = tempfile::tempdir().unwrap();
for (text, want) in [
(
"allowed = \"pdf\"\n",
"`allowed` must be an array of strings",
),
(
"allowed = [1, 2]\n",
"`allowed` must be an array of strings",
),
(
"user_may_extend = \"no\"\n",
"`user_may_extend` must be true or false",
),
(
"mode = \"merge\"\n",
"`mode` must be \"replace\" or \"append\", not \"merge\"",
),
(
"mode = 3\n",
"`mode` must be \"replace\" or \"append\", not that",
),
] {
let p = write(tmp.path(), "policy.toml", text);
match Files::none()
.at(Origin::MachinePolicy, &p)
.layer(Origin::MachinePolicy)
{
Err(Error::Malformed { cause, .. }) => assert_eq!(cause, want, "{text}"),
other => panic!("{text}: {other:?}"),
}
}
}
#[test]
fn a_machine_list_discards_what_the_user_added_beneath_it() {
let tmp = tempfile::tempdir().unwrap();
let machine = write(tmp.path(), "machine.toml", "allowed = [\"txt\"]\n");
let config = write(
tmp.path(),
"config.toml",
"allowed = [\"dwg\"]\nmode = \"append\"\n",
);
let files = Files::none()
.at(Origin::MachinePolicy, machine)
.at(Origin::Configuration, config);
assert!(matches!(
decide(&files, "notes.txt").unwrap(),
Decision::Open { .. }
));
assert!(matches!(
decide(&files, "plan.dwg").unwrap(),
Decision::NotPermitted { .. }
));
assert!(matches!(
decide(&files, "report.pdf").unwrap(),
Decision::NotPermitted { .. }
));
assert!(resolve(&files).unwrap().managed);
}
#[test]
fn a_machine_layer_that_only_denies_leaves_the_user_free_to_add() {
let tmp = tempfile::tempdir().unwrap();
let machine = write(tmp.path(), "machine.toml", "denied = [\"exe\"]\n");
let config = write(
tmp.path(),
"config.toml",
"allowed = [\"dwg\"]\nmode = \"append\"\n",
);
let files = Files::none()
.at(Origin::MachinePolicy, machine)
.at(Origin::Configuration, config);
assert!(matches!(
decide(&files, "plan.dwg").unwrap(),
Decision::Open { .. }
));
assert!(matches!(
decide(&files, "report.pdf").unwrap(),
Decision::Open { .. }
));
assert!(matches!(
decide(&files, "setup.exe").unwrap(),
Decision::Denied { .. }
));
}
#[test]
fn a_suppressed_configuration_is_not_read_at_all() {
let tmp = tempfile::tempdir().unwrap();
let machine = write(
tmp.path(),
"machine.toml",
"allowed = [\"txt\"]\nuser_may_extend = false\n",
);
let config = write(tmp.path(), "config.toml", "this is not toml at all [[[\n");
let files = Files::none()
.at(Origin::MachinePolicy, machine)
.at(Origin::Configuration, config);
assert!(matches!(
decide(&files, "notes.txt").unwrap(),
Decision::Open { .. }
));
assert!(resolve(&files).unwrap().configuration_suppressed);
}
#[test]
fn a_deny_in_the_users_own_file_still_wins() {
let tmp = tempfile::tempdir().unwrap();
let machine = write(tmp.path(), "machine.toml", "allowed = [\"pdf\"]\n");
let config = write(tmp.path(), "config.toml", "denied = [\"pdf\"]\n");
let files = Files::none()
.at(Origin::MachinePolicy, machine)
.at(Origin::Configuration, config);
assert!(matches!(
decide(&files, "report.pdf").unwrap(),
Decision::Denied { .. }
));
}
#[test]
fn the_policy_file_the_package_ships_says_nothing() {
let shipped =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("packaging/linux/open.toml");
assert!(shipped.exists(), "{} is not there", shipped.display());
let files = Files::none().at(Origin::MachinePolicy, &shipped);
let effective = resolve(&files).unwrap();
assert!(
!effective.managed,
"the shipped file must not read as policy"
);
assert!(!effective.confirm_each_write_back);
assert!(effective.uncomparable_entries.is_empty());
for name in ["report.pdf", "notes.txt", "sheet.xlsx"] {
assert!(
matches!(decide(&files, name).unwrap(), Decision::Open { .. }),
"{name}"
);
}
assert!(matches!(
decide(&files, "inner.zip").unwrap(),
Decision::NotPermitted { .. }
));
}
#[test]
fn notify_is_read_and_a_third_word_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let quiet = write(tmp.path(), "quiet.toml", "notify = \"important\"\n");
let loud = write(tmp.path(), "loud.toml", "notify = \"everything\"\n");
let wrong = write(tmp.path(), "wrong.toml", "notify = \"off\"\n");
let at = |p| Files::none().at(Origin::Configuration, p);
assert_eq!(
resolve(&at(quiet)).unwrap().notify,
crate::policy::Notify::Important
);
assert_eq!(
resolve(&at(loud)).unwrap().notify,
crate::policy::Notify::Everything
);
let refused = resolve(&at(wrong)).unwrap_err().to_string();
assert!(refused.contains("everything"), "{refused}");
assert!(refused.contains("\"off\""), "{refused}");
}
#[test]
fn a_machine_can_hold_the_volume_down_over_the_user() {
let tmp = tempfile::tempdir().unwrap();
let machine = write(tmp.path(), "machine.toml", "notify = \"important\"\n");
let user = write(tmp.path(), "user.toml", "notify = \"everything\"\n");
let files = Files::none()
.at(Origin::MachinePolicy, machine)
.at(Origin::Configuration, user);
assert_eq!(
resolve(&files).unwrap().notify,
crate::policy::Notify::Important
);
}
}