Skip to main content

slipcase_open/policy/
files.rs

1//! Policy layers read from files.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 10's four layers, as TOML documents. The paths are given rather than
7//! discovered, so the precedence can be tested without three operating systems
8//! and so a test never reads a real machine's policy.
9//!
10//! **This is the portable shape and not the whole of concept 10.** Windows
11//! reads its two policy layers from the `Policies` registry subtree, which is
12//! access-controlled against standard users and cleaned up by Group Policy on
13//! unapply, and that is not a file and does not belong here. Concept 10 gives
14//! macOS a configuration profile read through `CFPreferencesAppValueIsForced`,
15//! and that is not built: PLAN.md Phase 5 takes the file shape there instead,
16//! for as long as the channel is one that runs nothing as root at install.
17//! What is here is the shape Linux and macOS share, and the trait
18//! implementation every test uses.
19//!
20//! ## What a layer looks like
21//!
22//! ```toml
23//! allowed = ["pdf", "docx", "odt"]
24//! mode = "replace"                  # or "append"; replace is the default
25//! denied = ["exe", "dll"]
26//! user_may_extend = false
27//! confirm_each_write_back = true
28//! notify = "important"             # or "everything"
29//! ```
30//!
31//! Every key is optional, and omitting one means this layer says nothing about
32//! it rather than saying no.
33
34use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36
37use super::{Error, Layer, Mode, Notify, Origin, Read, Source};
38
39/// Policy layers, each read from a path.
40///
41/// A layer with no path, or whose path is not there, says nothing.
42#[derive(Debug, Default, Clone)]
43pub struct Files {
44    paths: BTreeMap<Origin, PathBuf>,
45}
46
47impl Files {
48    /// Nothing anywhere. Layers are added with [`at`](Self::at).
49    #[must_use]
50    pub fn none() -> Self {
51        Self::default()
52    }
53
54    /// Read this layer from this path.
55    #[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    /// Where each layer is looked for, highest authority first.
62    ///
63    /// For an interface that reports the file it is actually reading rather
64    /// than the one the documentation names. Every path here comes out of the
65    /// environment — `XDG_CONFIG_HOME` on this platform, and the equivalents
66    /// elsewhere — so where a person's settings live and where they live *by
67    /// default* are two questions, and only the running program can answer the
68    /// first.
69    ///
70    /// A layer being listed says nothing about the file being there. Ask
71    /// [`Source::layer`] for that, which reads it.
72    #[must_use]
73    pub fn locations(&self) -> impl DoubleEndedIterator<Item = (Origin, &Path)> {
74        // Ascending by authority in the map, because that is `Origin`'s order
75        // and the resolution wants it that way; reversed here, because a person
76        // reading a list of layers wants the one that wins at the top.
77        self.paths.iter().rev().map(|(o, p)| (*o, p.as_path()))
78    }
79
80    /// The layers this platform keeps in files, at the places concept 10 names.
81    ///
82    /// Linux and macOS, and the same two places on both: a root-owned
83    /// `/etc/slipcase` taking precedence over the user's own configuration
84    /// under `$XDG_CONFIG_HOME`. macOS has `/etc` and keeps it root-owned the
85    /// same way, and the XDG path for the user's file is the family's
86    /// precedent there — `slipcase-desktop` keeps its state under the XDG
87    /// directories on macOS — which is also what lets `tests/the_process.rs`
88    /// hold both platforms to one answer. Sessions are the exception and
89    /// `session::platform_base` says why.
90    ///
91    /// There is no per-user *policy* layer on either, because neither has a
92    /// mechanism in files that would administer one — `Origin::UserPolicy` is
93    /// Windows vocabulary, and the configuration profile concept 10 names for
94    /// macOS is not built. Inventing a file for it would be offering an
95    /// administrator a control that nothing enforces.
96    ///
97    /// Windows gets nothing from this and reads its policy from the registry.
98    /// The Linux filenames are confirmed against the package that installs
99    /// them; on macOS nothing installs them and `packaging/macos/README.md`
100    /// says what an administrator writes by hand.
101    #[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/// `$XDG_CONFIG_HOME`, or the fallback the specification names.
119#[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            // Not there is not an answer of *no*. A machine with no policy
134            // applied has no policy file, which is the common case and not a
135            // condition to report.
136            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    // Spelled out rather than derived. There are two values and an
181    // administrator who writes a third has made a mistake worth a sentence,
182    // where a permissive parser would hand them `replace` and let them find out
183    // from the behaviour.
184    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    // Spelled out for the same reason `mode` is: two values, and a third is a
197    // mistake worth a sentence rather than a silent fallback to the default.
198    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        // And the shipped set still answers.
237        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        // The case concept 10 cares about most. Answering "says nothing" here
281        // would permit whatever the file was written to refuse, quietly, for as
282        // long as the typo survives.
283        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        // The point of `replace` being the default. An administrator writing an
332        // exhaustive list gets an exhaustive one, and the user's own additions
333        // sit beneath it and go — which is the whole reason concept 10 calls
334        // append-by-default a silent hole.
335        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        // An administrator who wants to forbid one thing rather than dictate
364        // the whole list writes only `denied`, and everything beneath still
365        // stacks.
366        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        // So that a broken file the administrator has already overruled cannot
394        // fail a decision it would have played no part in.
395        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        // Concept 10 makes `/etc/slipcase/open.toml` the highest layer on this
430        // platform, so a stray uncommented line in the shipped file is a policy
431        // nobody wrote being enforced on every machine that installs the
432        // package. The file is documentation until an administrator edits it,
433        // and this is what says so.
434        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        // And the built-in set is what decides, which is the same statement
448        // made from the other end.
449        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        // Spelled out rather than derived, like `mode`: a third word is a
478        // mistake worth a sentence, not a silent fall back to the default.
479        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        // The whole reason this lives in concept 10's chain rather than in a
487        // settings file of its own: an administrator gets it for free, on every
488        // platform, through the mechanism already specified.
489        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}