1use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36
37use super::{Error, Layer, Mode, Notify, Origin, Read, Source};
38
39#[derive(Debug, Default, Clone)]
43pub struct Files {
44 paths: BTreeMap<Origin, PathBuf>,
45}
46
47impl Files {
48 #[must_use]
50 pub fn none() -> Self {
51 Self::default()
52 }
53
54 #[must_use]
56 pub fn at(mut self, origin: Origin, path: impl Into<PathBuf>) -> Self {
57 self.paths.insert(origin, path.into());
58 self
59 }
60
61 #[must_use]
73 pub fn locations(&self) -> impl DoubleEndedIterator<Item = (Origin, &Path)> {
74 self.paths.iter().rev().map(|(o, p)| (*o, p.as_path()))
78 }
79
80 #[must_use]
102 pub fn for_this_platform() -> Self {
103 #[cfg(any(target_os = "linux", target_os = "macos"))]
104 {
105 let mut files = Self::none().at(Origin::MachinePolicy, "/etc/slipcase/open.toml");
106 if let Some(dir) = config_home() {
107 files = files.at(Origin::Configuration, dir.join("slipcase-open/policy.toml"));
108 }
109 files
110 }
111 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
112 {
113 Self::none()
114 }
115 }
116}
117
118#[cfg(any(target_os = "linux", target_os = "macos"))]
120fn config_home() -> Option<PathBuf> {
121 if let Some(x) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
122 return Some(PathBuf::from(x));
123 }
124 Some(PathBuf::from(std::env::var_os("HOME")?).join(".config"))
125}
126
127impl Source for Files {
128 fn layer(&self, origin: Origin) -> Read {
129 let Some(path) = self.paths.get(&origin) else {
130 return Ok(None);
131 };
132 match std::fs::read_to_string(path) {
133 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
137 Err(cause) => Err(Error::Unreadable {
138 path: path.clone(),
139 cause,
140 }),
141 Ok(text) => parse(path, &text).map(Some),
142 }
143 }
144}
145
146fn parse(path: &Path, text: &str) -> std::result::Result<Layer, Error> {
147 let bad = |cause: String| Error::Malformed {
148 path: path.to_owned(),
149 cause,
150 };
151 let doc: toml_edit::DocumentMut = text.parse().map_err(|e| bad(format!("{e}")))?;
152
153 let list = |key: &str| -> std::result::Result<Option<Vec<String>>, Error> {
154 let Some(item) = doc.get(key) else {
155 return Ok(None);
156 };
157 let array = item
158 .as_array()
159 .ok_or_else(|| bad(format!("`{key}` must be an array of strings")))?;
160 array
161 .iter()
162 .map(|v| {
163 v.as_str()
164 .map(ToOwned::to_owned)
165 .ok_or_else(|| bad(format!("`{key}` must be an array of strings")))
166 })
167 .collect::<std::result::Result<Vec<_>, _>>()
168 .map(Some)
169 };
170
171 let flag = |key: &str| -> std::result::Result<Option<bool>, Error> {
172 doc.get(key)
173 .map(|v| {
174 v.as_bool()
175 .ok_or_else(|| bad(format!("`{key}` must be true or false")))
176 })
177 .transpose()
178 };
179
180 let mode = match doc.get("mode").map(|v| v.as_str()) {
185 None => None,
186 Some(Some("replace")) => Some(Mode::Replace),
187 Some(Some("append")) => Some(Mode::Append),
188 Some(other) => {
189 return Err(bad(format!(
190 "`mode` must be \"replace\" or \"append\", not {}",
191 other.map_or_else(|| "that".to_string(), |s| format!("\"{s}\"")),
192 )))
193 }
194 };
195
196 let notify = match doc.get("notify").map(|v| v.as_str()) {
199 None => None,
200 Some(Some("everything")) => Some(Notify::Everything),
201 Some(Some("important")) => Some(Notify::Important),
202 Some(other) => {
203 return Err(bad(format!(
204 "`notify` must be \"everything\" or \"important\", not {}",
205 other.map_or_else(|| "that".to_string(), |s| format!("\"{s}\"")),
206 )))
207 }
208 };
209
210 Ok(Layer {
211 allowed: list("allowed")?,
212 mode,
213 denied: list("denied")?,
214 user_may_extend: flag("user_may_extend")?,
215 confirm_each_write_back: flag("confirm_each_write_back")?,
216 notify,
217 })
218}
219
220#[cfg(test)]
221mod tests {
222 use super::Files;
223 use crate::policy::{decide, resolve, Decision, Error, Origin, Source};
224 use std::fs;
225
226 fn write(dir: &std::path::Path, name: &str, text: &str) -> std::path::PathBuf {
227 let p = dir.join(name);
228 fs::write(&p, text).unwrap();
229 p
230 }
231
232 #[test]
233 fn a_layer_that_is_not_there_says_nothing() {
234 let files = Files::none().at(Origin::MachinePolicy, "/nonexistent/policy.toml");
235 assert!(files.layer(Origin::MachinePolicy).unwrap().is_none());
236 assert!(matches!(
238 decide(&files, "report.pdf").unwrap(),
239 Decision::Open { .. }
240 ));
241 }
242
243 #[test]
244 fn a_layer_reads_every_key_it_carries() {
245 let tmp = tempfile::tempdir().unwrap();
246 let p = write(
247 tmp.path(),
248 "policy.toml",
249 "allowed = [\"pdf\", \"txt\"]\nmode = \"append\"\ndenied = [\"exe\"]\n\
250 user_may_extend = false\nconfirm_each_write_back = true\n",
251 );
252 let files = Files::none().at(Origin::MachinePolicy, p);
253 let layer = files.layer(Origin::MachinePolicy).unwrap().unwrap();
254
255 assert_eq!(
256 layer.allowed.as_deref(),
257 Some(&["pdf".into(), "txt".into()][..])
258 );
259 assert_eq!(layer.mode, Some(crate::policy::Mode::Append));
260 assert_eq!(layer.denied.as_deref(), Some(&["exe".into()][..]));
261 assert_eq!(layer.user_may_extend, Some(false));
262 assert_eq!(layer.confirm_each_write_back, Some(true));
263 }
264
265 #[test]
266 fn an_omitted_key_says_nothing_rather_than_no() {
267 let tmp = tempfile::tempdir().unwrap();
268 let p = write(tmp.path(), "policy.toml", "denied = [\"exe\"]\n");
269 let layer = Files::none()
270 .at(Origin::MachinePolicy, p)
271 .layer(Origin::MachinePolicy)
272 .unwrap()
273 .unwrap();
274 assert!(layer.allowed.is_none());
275 assert!(layer.user_may_extend.is_none());
276 }
277
278 #[test]
279 fn a_policy_file_that_will_not_parse_stops_the_decision() {
280 let tmp = tempfile::tempdir().unwrap();
284 let p = write(tmp.path(), "policy.toml", "denied = [\"exe\"\n");
285 let files = Files::none().at(Origin::MachinePolicy, &p);
286
287 match decide(&files, "report.pdf") {
288 Err(Error::Malformed { path, .. }) => assert_eq!(path, p),
289 other => panic!("{other:?}"),
290 }
291 }
292
293 #[test]
294 fn a_key_of_the_wrong_type_is_named_rather_than_ignored() {
295 let tmp = tempfile::tempdir().unwrap();
296 for (text, want) in [
297 (
298 "allowed = \"pdf\"\n",
299 "`allowed` must be an array of strings",
300 ),
301 (
302 "allowed = [1, 2]\n",
303 "`allowed` must be an array of strings",
304 ),
305 (
306 "user_may_extend = \"no\"\n",
307 "`user_may_extend` must be true or false",
308 ),
309 (
310 "mode = \"merge\"\n",
311 "`mode` must be \"replace\" or \"append\", not \"merge\"",
312 ),
313 (
314 "mode = 3\n",
315 "`mode` must be \"replace\" or \"append\", not that",
316 ),
317 ] {
318 let p = write(tmp.path(), "policy.toml", text);
319 match Files::none()
320 .at(Origin::MachinePolicy, &p)
321 .layer(Origin::MachinePolicy)
322 {
323 Err(Error::Malformed { cause, .. }) => assert_eq!(cause, want, "{text}"),
324 other => panic!("{text}: {other:?}"),
325 }
326 }
327 }
328
329 #[test]
330 fn a_machine_list_discards_what_the_user_added_beneath_it() {
331 let tmp = tempfile::tempdir().unwrap();
336 let machine = write(tmp.path(), "machine.toml", "allowed = [\"txt\"]\n");
337 let config = write(
338 tmp.path(),
339 "config.toml",
340 "allowed = [\"dwg\"]\nmode = \"append\"\n",
341 );
342 let files = Files::none()
343 .at(Origin::MachinePolicy, machine)
344 .at(Origin::Configuration, config);
345
346 assert!(matches!(
347 decide(&files, "notes.txt").unwrap(),
348 Decision::Open { .. }
349 ));
350 assert!(matches!(
351 decide(&files, "plan.dwg").unwrap(),
352 Decision::NotPermitted { .. }
353 ));
354 assert!(matches!(
355 decide(&files, "report.pdf").unwrap(),
356 Decision::NotPermitted { .. }
357 ));
358 assert!(resolve(&files).unwrap().managed);
359 }
360
361 #[test]
362 fn a_machine_layer_that_only_denies_leaves_the_user_free_to_add() {
363 let tmp = tempfile::tempdir().unwrap();
367 let machine = write(tmp.path(), "machine.toml", "denied = [\"exe\"]\n");
368 let config = write(
369 tmp.path(),
370 "config.toml",
371 "allowed = [\"dwg\"]\nmode = \"append\"\n",
372 );
373 let files = Files::none()
374 .at(Origin::MachinePolicy, machine)
375 .at(Origin::Configuration, config);
376
377 assert!(matches!(
378 decide(&files, "plan.dwg").unwrap(),
379 Decision::Open { .. }
380 ));
381 assert!(matches!(
382 decide(&files, "report.pdf").unwrap(),
383 Decision::Open { .. }
384 ));
385 assert!(matches!(
386 decide(&files, "setup.exe").unwrap(),
387 Decision::Denied { .. }
388 ));
389 }
390
391 #[test]
392 fn a_suppressed_configuration_is_not_read_at_all() {
393 let tmp = tempfile::tempdir().unwrap();
396 let machine = write(
397 tmp.path(),
398 "machine.toml",
399 "allowed = [\"txt\"]\nuser_may_extend = false\n",
400 );
401 let config = write(tmp.path(), "config.toml", "this is not toml at all [[[\n");
402 let files = Files::none()
403 .at(Origin::MachinePolicy, machine)
404 .at(Origin::Configuration, config);
405
406 assert!(matches!(
407 decide(&files, "notes.txt").unwrap(),
408 Decision::Open { .. }
409 ));
410 assert!(resolve(&files).unwrap().configuration_suppressed);
411 }
412
413 #[test]
414 fn a_deny_in_the_users_own_file_still_wins() {
415 let tmp = tempfile::tempdir().unwrap();
416 let machine = write(tmp.path(), "machine.toml", "allowed = [\"pdf\"]\n");
417 let config = write(tmp.path(), "config.toml", "denied = [\"pdf\"]\n");
418 let files = Files::none()
419 .at(Origin::MachinePolicy, machine)
420 .at(Origin::Configuration, config);
421 assert!(matches!(
422 decide(&files, "report.pdf").unwrap(),
423 Decision::Denied { .. }
424 ));
425 }
426
427 #[test]
428 fn the_policy_file_the_package_ships_says_nothing() {
429 let shipped =
435 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("packaging/linux/open.toml");
436 assert!(shipped.exists(), "{} is not there", shipped.display());
437
438 let files = Files::none().at(Origin::MachinePolicy, &shipped);
439 let effective = resolve(&files).unwrap();
440 assert!(
441 !effective.managed,
442 "the shipped file must not read as policy"
443 );
444 assert!(!effective.confirm_each_write_back);
445 assert!(effective.uncomparable_entries.is_empty());
446
447 for name in ["report.pdf", "notes.txt", "sheet.xlsx"] {
450 assert!(
451 matches!(decide(&files, name).unwrap(), Decision::Open { .. }),
452 "{name}"
453 );
454 }
455 assert!(matches!(
456 decide(&files, "inner.zip").unwrap(),
457 Decision::NotPermitted { .. }
458 ));
459 }
460
461 #[test]
462 fn notify_is_read_and_a_third_word_is_refused() {
463 let tmp = tempfile::tempdir().unwrap();
464 let quiet = write(tmp.path(), "quiet.toml", "notify = \"important\"\n");
465 let loud = write(tmp.path(), "loud.toml", "notify = \"everything\"\n");
466 let wrong = write(tmp.path(), "wrong.toml", "notify = \"off\"\n");
467
468 let at = |p| Files::none().at(Origin::Configuration, p);
469 assert_eq!(
470 resolve(&at(quiet)).unwrap().notify,
471 crate::policy::Notify::Important
472 );
473 assert_eq!(
474 resolve(&at(loud)).unwrap().notify,
475 crate::policy::Notify::Everything
476 );
477 let refused = resolve(&at(wrong)).unwrap_err().to_string();
480 assert!(refused.contains("everything"), "{refused}");
481 assert!(refused.contains("\"off\""), "{refused}");
482 }
483
484 #[test]
485 fn a_machine_can_hold_the_volume_down_over_the_user() {
486 let tmp = tempfile::tempdir().unwrap();
490 let machine = write(tmp.path(), "machine.toml", "notify = \"important\"\n");
491 let user = write(tmp.path(), "user.toml", "notify = \"everything\"\n");
492 let files = Files::none()
493 .at(Origin::MachinePolicy, machine)
494 .at(Origin::Configuration, user);
495 assert_eq!(
496 resolve(&files).unwrap().notify,
497 crate::policy::Notify::Important
498 );
499 }
500}