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}