Skip to main content

slipcase_open/policy/
mod.rs

1//! What the tool will open, and who gets to say.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 10. Four layers — machine policy, user policy, user configuration,
7//! built-in default — resolved into one answer, and one function that answers
8//! it for a content file.
9//!
10//! **This module is the resolution and not the sources.** Reading a registry
11//! subtree, a configuration profile, or a file under `/etc` is a platform's
12//! business and lives behind [`Source`]. What is here is pure, which is what
13//! makes the precedence testable without three operating systems.
14//!
15//! **Nothing here is cached, and that is the security property.** Concept 10
16//! puts enforcement in the launch path, immediately before execution: a value
17//! read at startup, or held across a policy push, or handed in over IPC, is a
18//! bypass. [`decide`] resolves from the sources on every call for that reason,
19//! and [`Effective`] exists for the interface to describe the state of things
20//! rather than for the launch path to consult twice.
21
22use std::collections::BTreeSet;
23use std::fmt;
24use std::path::PathBuf;
25
26use crate::extension;
27
28pub mod files;
29#[cfg(windows)]
30pub mod registry;
31
32/// The layers this platform actually keeps, and where it keeps them.
33///
34/// **One type rather than a trait object, because the two implementations are
35/// the whole set.** A platform keeps its policy in files or in a registry, this
36/// enum is those two, and `for_this_platform` is the only thing that builds
37/// one. A `Box<dyn Source>` would buy extensibility nobody wants and lose
38/// `locations`, which is not on [`Source`] deliberately — reading a layer and
39/// naming where it came from are different questions, and only the interface
40/// asks the second.
41#[derive(Debug, Clone)]
42pub enum Settings {
43    /// Files at paths, which is Linux and the tests.
44    Files(files::Files),
45    /// Registry keys, which is Windows.
46    #[cfg(windows)]
47    Registry(registry::Registry),
48}
49
50impl Settings {
51    /// Where this machine reads its layers from, most authoritative first, in
52    /// whatever words a person would use to go and look.
53    ///
54    /// A path on one platform and a key on another, so the answer is a string
55    /// rather than a `Path`: concept 10's point is that only the running
56    /// program can say where its settings are, and a type that can only hold
57    /// filenames cannot say it on Windows.
58    #[must_use]
59    pub fn locations(&self) -> Vec<(Origin, String)> {
60        match self {
61            Self::Files(f) => f
62                .locations()
63                .map(|(o, p)| (o, slpc::display_path(p).clone()))
64                .collect(),
65            #[cfg(windows)]
66            Self::Registry(r) => r.locations(),
67        }
68    }
69}
70
71impl Source for Settings {
72    fn layer(&self, origin: Origin) -> Read {
73        match self {
74            Self::Files(f) => f.layer(origin),
75            #[cfg(windows)]
76            Self::Registry(r) => r.layer(origin),
77        }
78    }
79}
80
81/// What this machine reads, chosen by what platform it is.
82#[must_use]
83pub fn for_this_platform() -> Settings {
84    #[cfg(windows)]
85    {
86        Settings::Registry(registry::Registry::for_this_platform())
87    }
88    #[cfg(not(windows))]
89    {
90        Settings::Files(files::Files::for_this_platform())
91    }
92}
93
94/// Why a layer could not be read.
95///
96/// **A policy source that fails is not a policy source that says nothing.** An
97/// administrator's deny list that cannot be read is the case concept 10 cares
98/// about most, and answering `None` for it would permit whatever it was written
99/// to refuse — quietly, and for as long as the typo survives. So reading a
100/// layer can fail, and the failure travels to whoever is deciding.
101#[derive(Debug)]
102pub enum Error {
103    /// The file is there and could not be read.
104    Unreadable {
105        /// The file.
106        path: PathBuf,
107        /// What the filesystem said.
108        cause: std::io::Error,
109    },
110    /// The file is there and is not what it claims to be.
111    Malformed {
112        /// The file.
113        path: PathBuf,
114        /// What was wrong with it.
115        cause: String,
116    },
117}
118
119impl fmt::Display for Error {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::Unreadable { path, cause } => {
123                write!(f, "{} cannot be read: {cause}", path.display())
124            }
125            Self::Malformed { path, cause } => write!(f, "{}: {cause}", path.display()),
126        }
127    }
128}
129
130impl std::error::Error for Error {}
131
132/// A layer, or nothing, or a reason neither could be established.
133pub type Read = std::result::Result<Option<Layer>, Error>;
134
135/// Where a layer came from, in order of authority.
136///
137/// Machine policy wins over user policy, which wins over the user's own
138/// configuration, which wins over what this build ships.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
140pub enum Origin {
141    /// What this build ships when nothing else says otherwise.
142    BuiltIn,
143    /// The user's own settings. The only layer [`Layer::user_may_extend`] gates.
144    Configuration,
145    /// Policy applied to this user, and administered rather than chosen.
146    UserPolicy,
147    /// Policy applied to this machine. Nothing overrides it.
148    MachinePolicy,
149}
150
151impl Origin {
152    /// Whether a layer from here is administered rather than chosen, which is
153    /// what the interface indicates so that a refusal reads as a decision
154    /// somebody made rather than as the application being unpredictable.
155    #[must_use]
156    pub fn is_managed(self) -> bool {
157        matches!(self, Self::UserPolicy | Self::MachinePolicy)
158    }
159}
160
161/// The name a person knows a layer by, which is the vocabulary concept 10 uses
162/// and the vocabulary the shipped policy file and the manual page use with it.
163impl fmt::Display for Origin {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        // `pad` rather than `write_str`, so that a caller lining these up in a
166        // column gets the width it asked for. `write_str` ignores it silently,
167        // which is a formatting bug that only shows up in the output.
168        f.pad(match self {
169            Self::BuiltIn => "built-in",
170            Self::Configuration => "configuration",
171            Self::UserPolicy => "user policy",
172            Self::MachinePolicy => "machine policy",
173        })
174    }
175}
176
177/// How much the tool says without being asked.
178///
179/// A threshold on [`crate::present::Weight`] rather than a list of switches, so
180/// that adding a report does not add a setting: it is written at the weight it
181/// deserves and lands on the right side of the line by itself.
182///
183/// **Questions are outside this and cannot be quietened.** They go through
184/// [`crate::present::Channel::ask`] rather than `report`, so nothing here
185/// reaches them. That is structural rather than a rule somebody has to
186/// remember: a session waiting on a decision would otherwise be silenced into
187/// stranding its content file, and concept 6.3 has nothing else to offer that
188/// person until the next launch.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
190pub enum Notify {
191    /// Everything, including what happens on its own.
192    Everything,
193    /// Everything except [`crate::present::Weight::Routine`] — so warnings,
194    /// failures, questions, and the answer to anything the person did. **The
195    /// default**, because the routine half is a notification per save and
196    /// somebody editing a document for an hour gets dozens of them. Concept 6.2
197    /// wants a session visible and wants somewhere to look when an edit is
198    /// expected to have landed, and `sessions` answers that better than a
199    /// stream of banners.
200    #[default]
201    Important,
202}
203
204/// Whether a layer's allowed list stands alone or adds to what is beneath it.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum Mode {
207    /// The list is the permitted set. Everything beneath is discarded.
208    #[default]
209    Replace,
210    /// The list is added to what is beneath.
211    Append,
212}
213
214/// One layer's contribution. Every field is optional, and `None` means the
215/// layer says nothing about it rather than saying no.
216#[derive(Debug, Clone, Default)]
217pub struct Layer {
218    /// Extensions this layer permits.
219    pub allowed: Option<Vec<String>>,
220    /// Whether `allowed` stands alone or adds. **Defaults to [`Mode::Replace`]
221    /// deliberately.** Concept 10 names the silent hole: an administrator who
222    /// writes a list expecting it to be exhaustive and gets it unioned with the
223    /// defaults has permitted things they never listed, and never finds out.
224    /// Appending is the surprising reading, so it is the one that has to be
225    /// asked for.
226    pub mode: Option<Mode>,
227    /// Extensions this layer refuses. Always wins, everywhere.
228    pub denied: Option<Vec<String>>,
229    /// Whether the user's own configuration is honoured at all. Only a policy
230    /// layer setting this to `false` has any effect, and what it suppresses is
231    /// [`Origin::Configuration`] — not another policy layer, which is
232    /// administered too.
233    pub user_may_extend: Option<bool>,
234    /// Whether every write-back is confirmed, rather than only the session
235    /// close. Concept 6.2 keeps this off by default: a repack is atomic and
236    /// unremarkable, and a prompt on every save is friction for everyone who is
237    /// not archiving.
238    pub confirm_each_write_back: Option<bool>,
239    /// How much the tool says without being asked. See [`Notify`].
240    pub notify: Option<Notify>,
241}
242
243impl Layer {
244    /// Whether this layer sets nothing at all.
245    ///
246    /// A file that parses and holds no key is a layer that exists and has no
247    /// opinion, which is different from one that is not there — but not
248    /// different in any way a decision can see. What it changes is whether
249    /// [`Effective::managed`] should call the machine administered, and it
250    /// should not: see the note there.
251    ///
252    /// `allowed = []` is not this. An empty list is a layer permitting nothing,
253    /// which says a great deal.
254    #[must_use]
255    pub fn says_nothing(&self) -> bool {
256        self.allowed.is_none()
257            && self.mode.is_none()
258            && self.denied.is_none()
259            && self.user_may_extend.is_none()
260            && self.confirm_each_write_back.is_none()
261            && self.notify.is_none()
262    }
263}
264
265/// Where layers come from. One implementation per platform, plus whatever the
266/// tests need.
267///
268/// Returning `None` for a layer means this platform has no such source, or the
269/// source is empty. It is not an error and is not a refusal.
270pub trait Source {
271    /// This layer, read now rather than remembered. An implementation that
272    /// caches is the bypass concept 10 warns about.
273    ///
274    /// `Ok(None)` means this platform has no such source, or it is not there.
275    /// That is not an error and not a refusal. An `Err` means the source exists
276    /// and could not be understood, which is a different thing entirely and
277    /// must not be flattened into the first.
278    ///
279    /// # Errors
280    ///
281    /// See [`Error`].
282    fn layer(&self, origin: Origin) -> Read;
283}
284
285/// What this build permits when nothing else says otherwise.
286///
287/// Documents and images: what an information-management shop actually sends
288/// somebody. It errs small, because everything missing from it is one setting
289/// away and everything wrongly on it is a guardrail this tool claimed and did
290/// not provide.
291///
292/// **`slpc` is not here**, and concept 10 explains why. A container whose
293/// content file is a container is usually somebody having packed one by mistake, or
294/// an archival wrapper, and neither wants an automatic recursive open. It stays
295/// allowlistable for the archival user who nests deliberately.
296///
297/// Nor is anything the browser opens — `html`, `htm`, `svg`, `xhtml`. They are
298/// harmless through this path, because the handler is a browser and a browser
299/// is built for hostile documents, but they are the one document family whose
300/// content routinely executes and the default set is not the place to argue
301/// about it.
302pub const BUILT_IN_ALLOWED: &[&str] = &[
303    // Documents
304    "pdf", "rtf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "odg",
305    // Plain text and its families
306    "txt", "md", "csv", "tsv", "log", "json", "xml", "yaml", "yml", // Images
307    "jpg", "jpeg", "png", "gif", "tif", "tiff", "webp", "heic", "bmp",
308];
309
310/// The resolved answer, for an interface that wants to describe it.
311///
312/// The launch path calls [`decide`] and does not hold one of these. See the
313/// module note on caching.
314#[derive(Debug, Clone)]
315pub struct Effective {
316    allowed: BTreeSet<String>,
317    denied: BTreeSet<String>,
318    /// Whether a policy layer set anything, so the interface can say that
319    /// settings are administered.
320    ///
321    /// **Set something, rather than merely be there.** The Linux package ships
322    /// `/etc/slipcase/open.toml` documenting every key and setting none of
323    /// them, so a rule that counted the file's presence would have every
324    /// machine that installed the package told its settings were administered
325    /// when nothing had been administered — which is the support load concept
326    /// 10 wants this to reduce, arriving by the front door.
327    pub managed: bool,
328    /// Whether the user's own configuration was suppressed by policy.
329    pub configuration_suppressed: bool,
330    /// See [`Layer::confirm_each_write_back`].
331    pub confirm_each_write_back: bool,
332    /// See [`Notify`].
333    ///
334    /// **Read once and held, unlike the lists.** The note at the top of this
335    /// module is about what may be opened, where a value cached across a policy
336    /// push is a bypass. This one governs how loud the tool is and gates no
337    /// decision, so nothing is lost by resolving it when the instance starts.
338    pub notify: Notify,
339    /// List entries that can never match anything, because they are not
340    /// comparable under concept 5.2. Surfaced rather than dropped: an entry an
341    /// administrator wrote and this will never honour is worth a word, and
342    /// silently ignoring one on a deny list is the worse half.
343    pub uncomparable_entries: Vec<String>,
344}
345
346/// What is to be done with a content file.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum Decision {
349    /// Permitted. The folded extension is carried so a caller reports the same
350    /// value the decision was made against.
351    Open { key: String },
352    /// On a deny list, which wins over every allow list.
353    Denied { key: String },
354    /// Not on any allow list. A different answer from `Denied`, because the
355    /// remedy differs and so does the sentence a person should be shown.
356    NotPermitted { key: String },
357    /// No extension, or one nothing can compare. Concept 5.1 refuses this
358    /// whatever the lists say, because `ShellExecuteEx` answers it with the
359    /// Open With dialog, which offers every executable on the machine inside a
360    /// flow the person reads as *open the document*.
361    NoUsableExtension,
362}
363
364/// Resolve the layers and answer for this content file, in one call.
365///
366/// **Takes the content file's name and not an extension.** Concept 10 requires
367/// the decision to be made after the extension is taken and folded, and a
368/// signature accepting a key would let a caller hand in one it computed some
369/// other way. The folding is [`extension::policy_key`]'s, once, here.
370///
371/// # Errors
372///
373/// Where a layer exists and cannot be read. Nothing is decided in that case,
374/// because a policy that cannot be established is not a policy that permits.
375pub fn decide(source: &dyn Source, content_name: &str) -> std::result::Result<Decision, Error> {
376    let Some(key) = extension::policy_key(content_name) else {
377        return Ok(Decision::NoUsableExtension);
378    };
379    let effective = resolve(source)?;
380    Ok(if effective.denied.contains(&key) {
381        Decision::Denied { key }
382    } else if effective.allowed.contains(&key) {
383        Decision::Open { key }
384    } else {
385        Decision::NotPermitted { key }
386    })
387}
388
389/// Resolve the layers without deciding anything, for an interface describing
390/// the state of things.
391///
392/// # Errors
393///
394/// Where a layer exists and cannot be read.
395pub fn resolve(source: &dyn Source) -> std::result::Result<Effective, Error> {
396    let mut uncomparable = Vec::new();
397
398    // Highest authority first, so that `user_may_extend` is known before the
399    // layer it gates is looked at.
400    let machine = source.layer(Origin::MachinePolicy)?;
401    let user_policy = source.layer(Origin::UserPolicy)?;
402
403    let may_extend = machine
404        .as_ref()
405        .and_then(|l| l.user_may_extend)
406        .or_else(|| user_policy.as_ref().and_then(|l| l.user_may_extend))
407        .unwrap_or(true);
408
409    // Not read at all where policy has suppressed it, which is the point: a
410    // suppressed layer is not consulted, so a broken one cannot fail a decision
411    // that would have ignored it anyway.
412    let configuration = if may_extend {
413        source.layer(Origin::Configuration)?
414    } else {
415        None
416    };
417
418    let built_in = source.layer(Origin::BuiltIn)?.unwrap_or(Layer {
419        allowed: Some(BUILT_IN_ALLOWED.iter().map(|s| (*s).to_string()).collect()),
420        ..Layer::default()
421    });
422
423    let stack = [
424        (Origin::MachinePolicy, machine),
425        (Origin::UserPolicy, user_policy),
426        (Origin::Configuration, configuration),
427        (Origin::BuiltIn, Some(built_in)),
428    ];
429
430    // Every layer's refusals, unioned. A deny is never overridden, including by
431    // a layer with more authority: a user refusing something for themselves is
432    // a preference an administrator has no reason to overrule, and concept 10
433    // says the deny list wins regardless of every other setting.
434    let mut denied = BTreeSet::new();
435    for layer in stack.iter().filter_map(|(_, l)| l.as_ref()) {
436        fold_into(&mut denied, layer.denied.as_deref(), &mut uncomparable);
437    }
438
439    // Allowed is built from the bottom up, so that a `Replace` discards what is
440    // beneath it and an `Append` adds to it.
441    let mut allowed = BTreeSet::new();
442    for layer in stack.iter().rev().filter_map(|(_, l)| l.as_ref()) {
443        let Some(list) = layer.allowed.as_deref() else {
444            continue;
445        };
446        if layer.mode.unwrap_or_default() == Mode::Replace {
447            allowed.clear();
448        }
449        fold_into(&mut allowed, Some(list), &mut uncomparable);
450    }
451
452    let managed = stack
453        .iter()
454        .any(|(o, l)| o.is_managed() && l.as_ref().is_some_and(|l| !l.says_nothing()));
455
456    let confirm = stack
457        .iter()
458        .find_map(|(_, l)| l.as_ref().and_then(|l| l.confirm_each_write_back))
459        .unwrap_or(false);
460
461    let notify = stack
462        .iter()
463        .find_map(|(_, l)| l.as_ref().and_then(|l| l.notify))
464        .unwrap_or_default();
465
466    uncomparable.sort_unstable();
467    uncomparable.dedup();
468
469    Ok(Effective {
470        allowed,
471        denied,
472        managed,
473        configuration_suppressed: !may_extend,
474        confirm_each_write_back: confirm,
475        notify,
476        uncomparable_entries: uncomparable,
477    })
478}
479
480impl Effective {
481    /// The permitted set, folded, in a stable order.
482    pub fn allowed(&self) -> impl Iterator<Item = &str> {
483        self.allowed.iter().map(String::as_str)
484    }
485
486    /// The refused set, folded, in a stable order.
487    pub fn denied(&self) -> impl Iterator<Item = &str> {
488        self.denied.iter().map(String::as_str)
489    }
490}
491
492/// Fold each entry the way a content file's extension is folded, so that a list and
493/// a filename are compared as the same kind of thing. An entry that will not
494/// fold is collected rather than dropped.
495fn fold_into(into: &mut BTreeSet<String>, list: Option<&[String]>, uncomparable: &mut Vec<String>) {
496    for entry in list.unwrap_or_default() {
497        // Written as `pdf` or as `.pdf`; both mean the same thing to whoever
498        // typed it, and refusing one of them would be pedantry with a support
499        // cost.
500        let bare = entry.strip_prefix('.').unwrap_or(entry);
501        if !bare.is_empty() && bare.chars().all(|c| c.is_ascii_alphanumeric()) {
502            into.insert(bare.to_ascii_lowercase());
503        } else {
504            uncomparable.push(entry.clone());
505        }
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::{decide, resolve, Decision, Layer, Mode, Origin, Read, Source, BUILT_IN_ALLOWED};
512
513    /// A source built from whatever a test wants to say, layer by layer.
514    #[derive(Default)]
515    struct Stack {
516        machine: Option<Layer>,
517        user_policy: Option<Layer>,
518        configuration: Option<Layer>,
519    }
520
521    impl Source for Stack {
522        fn layer(&self, origin: Origin) -> Read {
523            Ok(match origin {
524                Origin::MachinePolicy => self.machine.clone(),
525                Origin::UserPolicy => self.user_policy.clone(),
526                Origin::Configuration => self.configuration.clone(),
527                // `resolve` supplies the shipped set where a source says
528                // nothing, which is what every arm below relies on.
529                Origin::BuiltIn => None,
530            })
531        }
532    }
533
534    /// Wrapped, because that is the shape every `Layer` field takes: a list
535    /// and *said nothing* are different answers.
536    #[allow(clippy::unnecessary_wraps)]
537    fn list(of: &[&str]) -> Option<Vec<String>> {
538        Some(of.iter().map(|s| (*s).to_string()).collect())
539    }
540
541    #[test]
542    fn with_nothing_configured_the_shipped_set_is_what_answers() {
543        let s = Stack::default();
544        assert_eq!(
545            decide(&s, "report.pdf").unwrap(),
546            Decision::Open {
547                key: "pdf".to_string()
548            }
549        );
550        assert_eq!(
551            decide(&s, "setup.exe").unwrap(),
552            Decision::NotPermitted {
553                key: "exe".to_string()
554            }
555        );
556    }
557
558    #[test]
559    fn a_nested_container_is_not_permitted_by_default() {
560        // Concept 10. Allowlistable, and not shipped allowed.
561        assert!(!BUILT_IN_ALLOWED.contains(&"slpc"));
562        assert_eq!(
563            decide(&Stack::default(), "inner.slpc").unwrap(),
564            Decision::NotPermitted {
565                key: "slpc".to_string()
566            }
567        );
568    }
569
570    #[test]
571    fn a_policy_list_replaces_rather_than_appends_when_it_does_not_say() {
572        // The silent hole concept 10 names: an administrator writing an
573        // exhaustive list and getting it unioned with the defaults has
574        // permitted things they never listed and will not find out.
575        let s = Stack {
576            machine: Some(Layer {
577                allowed: list(&["txt"]),
578                ..Layer::default()
579            }),
580            ..Stack::default()
581        };
582        assert_eq!(
583            decide(&s, "notes.txt").unwrap(),
584            Decision::Open {
585                key: "txt".to_string()
586            }
587        );
588        assert_eq!(
589            decide(&s, "report.pdf").unwrap(),
590            Decision::NotPermitted {
591                key: "pdf".to_string()
592            }
593        );
594    }
595
596    #[test]
597    fn appending_is_available_and_has_to_be_asked_for() {
598        let s = Stack {
599            configuration: Some(Layer {
600                allowed: list(&["slpc"]),
601                mode: Some(Mode::Append),
602                ..Layer::default()
603            }),
604            ..Stack::default()
605        };
606        assert!(matches!(
607            decide(&s, "inner.slpc").unwrap(),
608            Decision::Open { .. }
609        ));
610        assert!(matches!(
611            decide(&s, "report.pdf").unwrap(),
612            Decision::Open { .. }
613        ));
614    }
615
616    #[test]
617    fn a_deny_wins_over_an_allow_in_the_same_layer() {
618        let s = Stack {
619            machine: Some(Layer {
620                allowed: list(&["pdf", "txt"]),
621                denied: list(&["pdf"]),
622                ..Layer::default()
623            }),
624            ..Stack::default()
625        };
626        assert_eq!(
627            decide(&s, "report.pdf").unwrap(),
628            Decision::Denied {
629                key: "pdf".to_string()
630            }
631        );
632    }
633
634    #[test]
635    fn a_deny_beneath_wins_over_an_allow_above_it() {
636        // Concept 10 says the deny list wins regardless of every other
637        // setting, and that includes authority. A user refusing something for
638        // themselves is a preference an administrator has no reason to
639        // overrule, and nothing is made less safe by honouring it.
640        let s = Stack {
641            machine: Some(Layer {
642                allowed: list(&["pdf"]),
643                ..Layer::default()
644            }),
645            configuration: Some(Layer {
646                denied: list(&["pdf"]),
647                ..Layer::default()
648            }),
649            ..Stack::default()
650        };
651        assert!(matches!(
652            decide(&s, "report.pdf").unwrap(),
653            Decision::Denied { .. }
654        ));
655    }
656
657    #[test]
658    fn policy_can_suppress_the_users_own_configuration() {
659        let s = Stack {
660            machine: Some(Layer {
661                allowed: list(&["pdf"]),
662                user_may_extend: Some(false),
663                ..Layer::default()
664            }),
665            configuration: Some(Layer {
666                allowed: list(&["exe"]),
667                mode: Some(Mode::Append),
668                ..Layer::default()
669            }),
670            ..Stack::default()
671        };
672        assert!(matches!(
673            decide(&s, "setup.exe").unwrap(),
674            Decision::NotPermitted { .. }
675        ));
676        assert!(resolve(&s).unwrap().configuration_suppressed);
677    }
678
679    #[test]
680    fn suppressing_the_configuration_does_not_suppress_the_other_policy_layer() {
681        // `user_may_extend` gates what the user chose, not what was
682        // administered to them. Both policy layers are somebody's decision.
683        let s = Stack {
684            machine: Some(Layer {
685                user_may_extend: Some(false),
686                ..Layer::default()
687            }),
688            user_policy: Some(Layer {
689                allowed: list(&["dwg"]),
690                ..Layer::default()
691            }),
692            ..Stack::default()
693        };
694        assert!(matches!(
695            decide(&s, "plan.dwg").unwrap(),
696            Decision::Open { .. }
697        ));
698    }
699
700    #[test]
701    fn machine_policy_outranks_user_policy_on_the_allowed_set() {
702        let s = Stack {
703            machine: Some(Layer {
704                allowed: list(&["txt"]),
705                ..Layer::default()
706            }),
707            user_policy: Some(Layer {
708                allowed: list(&["dwg"]),
709                ..Layer::default()
710            }),
711            ..Stack::default()
712        };
713        assert!(matches!(
714            decide(&s, "notes.txt").unwrap(),
715            Decision::Open { .. }
716        ));
717        assert!(matches!(
718            decide(&s, "plan.dwg").unwrap(),
719            Decision::NotPermitted { .. }
720        ));
721    }
722
723    #[test]
724    fn a_content_file_with_no_usable_extension_is_refused_whatever_the_lists_say() {
725        // Concept 5.1: there is no setting for this, because the dialog it
726        // would otherwise raise offers every executable on the machine.
727        let s = Stack {
728            machine: Some(Layer {
729                allowed: list(&["pdf"]),
730                mode: Some(Mode::Append),
731                ..Layer::default()
732            }),
733            ..Stack::default()
734        };
735        assert_eq!(decide(&s, "README").unwrap(), Decision::NoUsableExtension);
736        assert_eq!(decide(&s, ".bashrc").unwrap(), Decision::NoUsableExtension);
737        assert_eq!(
738            decide(&s, "notes.tëxt").unwrap(),
739            Decision::NoUsableExtension
740        );
741    }
742
743    #[test]
744    fn list_entries_are_folded_the_way_a_content_name_is() {
745        let s = Stack {
746            machine: Some(Layer {
747                allowed: list(&["PDF", ".Txt"]),
748                ..Layer::default()
749            }),
750            ..Stack::default()
751        };
752        // Both spellings on both sides: the list may be shouted or dotted, and
753        // the container may spell its own name however it likes.
754        assert!(matches!(
755            decide(&s, "REPORT.PDF").unwrap(),
756            Decision::Open { .. }
757        ));
758        assert!(matches!(
759            decide(&s, "notes.txt").unwrap(),
760            Decision::Open { .. }
761        ));
762    }
763
764    #[test]
765    fn the_decision_carries_the_key_it_was_made_against() {
766        // So that whatever reports the refusal names the value that was
767        // compared, rather than folding the name a second time and possibly
768        // differently.
769        assert_eq!(
770            decide(&Stack::default(), "SETUP.EXE").unwrap(),
771            Decision::NotPermitted {
772                key: "exe".to_string()
773            }
774        );
775    }
776
777    #[test]
778    fn an_entry_nothing_can_compare_is_surfaced_rather_than_dropped() {
779        // An administrator wrote it and this will never honour it. Silently
780        // ignoring one on a deny list is the half that matters.
781        let s = Stack {
782            machine: Some(Layer {
783                denied: list(&["exe", "*.exe", "ex\u{212a}"]),
784                ..Layer::default()
785            }),
786            ..Stack::default()
787        };
788        let e = resolve(&s).unwrap();
789        assert_eq!(e.uncomparable_entries, vec!["*.exe", "ex\u{212a}"]);
790        assert!(e.denied().any(|d| d == "exe"));
791    }
792
793    #[test]
794    fn managed_says_whether_a_policy_layer_contributed() {
795        assert!(!resolve(&Stack::default()).unwrap().managed);
796        // Present and empty is not administered. The package ships a policy
797        // file that sets nothing, and this is the rule that keeps every install
798        // of it from claiming otherwise.
799        let empty = Stack {
800            user_policy: Some(Layer::default()),
801            ..Stack::default()
802        };
803        assert!(!resolve(&empty).unwrap().managed);
804        // Setting anything is, including permitting nothing.
805        let refusing_everything = Stack {
806            user_policy: Some(Layer {
807                allowed: Some(Vec::new()),
808                ..Layer::default()
809            }),
810            ..Stack::default()
811        };
812        assert!(resolve(&refusing_everything).unwrap().managed);
813    }
814
815    #[test]
816    fn confirming_each_write_back_is_off_until_a_layer_asks() {
817        assert!(!resolve(&Stack::default()).unwrap().confirm_each_write_back);
818        let s = Stack {
819            configuration: Some(Layer {
820                confirm_each_write_back: Some(true),
821                ..Layer::default()
822            }),
823            ..Stack::default()
824        };
825        assert!(resolve(&s).unwrap().confirm_each_write_back);
826    }
827}