slipcase-open 0.1.6

Open the payload of a Slipcase container in its own application, and write edits back into the container
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
//! Policy layers read from files.
//
// Author: David M. Anderson
// Built with AI assistance (Claude, Anthropic)
//
//! Concept 10's four layers, as TOML documents. The paths are given rather than
//! discovered, so the precedence can be tested without three operating systems
//! and so a test never reads a real machine's policy.
//!
//! **This is the portable shape and not the whole of concept 10.** Windows
//! reads its two policy layers from the `Policies` registry subtree, which is
//! access-controlled against standard users and cleaned up by Group Policy on
//! unapply, and that is not a file and does not belong here. Concept 10 gives
//! macOS a configuration profile read through `CFPreferencesAppValueIsForced`,
//! and that is not built: PLAN.md Phase 5 takes the file shape there instead,
//! for as long as the channel is one that runs nothing as root at install.
//! What is here is the shape Linux and macOS share, and the trait
//! implementation every test uses.
//!
//! ## What a layer looks like
//!
//! ```toml
//! allowed = ["pdf", "docx", "odt"]
//! mode = "replace"                  # or "append"; replace is the default
//! denied = ["exe", "dll"]
//! user_may_extend = false
//! confirm_each_write_back = true
//! notify = "important"             # or "everything"
//! ```
//!
//! Every key is optional, and omitting one means this layer says nothing about
//! it rather than saying no.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use super::{Error, Layer, Mode, Notify, Origin, Read, Source};

/// Policy layers, each read from a path.
///
/// A layer with no path, or whose path is not there, says nothing.
#[derive(Debug, Default, Clone)]
pub struct Files {
    paths: BTreeMap<Origin, PathBuf>,
}

impl Files {
    /// Nothing anywhere. Layers are added with [`at`](Self::at).
    #[must_use]
    pub fn none() -> Self {
        Self::default()
    }

    /// Read this layer from this path.
    #[must_use]
    pub fn at(mut self, origin: Origin, path: impl Into<PathBuf>) -> Self {
        self.paths.insert(origin, path.into());
        self
    }

    /// Where each layer is looked for, highest authority first.
    ///
    /// For an interface that reports the file it is actually reading rather
    /// than the one the documentation names. Every path here comes out of the
    /// environment — `XDG_CONFIG_HOME` on this platform, and the equivalents
    /// elsewhere — so where a person's settings live and where they live *by
    /// default* are two questions, and only the running program can answer the
    /// first.
    ///
    /// A layer being listed says nothing about the file being there. Ask
    /// [`Source::layer`] for that, which reads it.
    #[must_use]
    pub fn locations(&self) -> impl DoubleEndedIterator<Item = (Origin, &Path)> {
        // Ascending by authority in the map, because that is `Origin`'s order
        // and the resolution wants it that way; reversed here, because a person
        // reading a list of layers wants the one that wins at the top.
        self.paths.iter().rev().map(|(o, p)| (*o, p.as_path()))
    }

    /// The layers this platform keeps in files, at the places concept 10 names.
    ///
    /// Linux and macOS, and the same two places on both: a root-owned
    /// `/etc/slipcase` taking precedence over the user's own configuration
    /// under `$XDG_CONFIG_HOME`. macOS has `/etc` and keeps it root-owned the
    /// same way, and the XDG path for the user's file is the family's
    /// precedent there — `slipcase-desktop` keeps its state under the XDG
    /// directories on macOS — which is also what lets `tests/the_process.rs`
    /// hold both platforms to one answer. Sessions are the exception and
    /// `session::platform_base` says why.
    ///
    /// There is no per-user *policy* layer on either, because neither has a
    /// mechanism in files that would administer one — `Origin::UserPolicy` is
    /// Windows vocabulary, and the configuration profile concept 10 names for
    /// macOS is not built. Inventing a file for it would be offering an
    /// administrator a control that nothing enforces.
    ///
    /// Windows gets nothing from this and reads its policy from the registry.
    /// The Linux filenames are confirmed against the package that installs
    /// them; on macOS nothing installs them and `packaging/macos/README.md`
    /// says what an administrator writes by hand.
    #[must_use]
    pub fn for_this_platform() -> Self {
        #[cfg(any(target_os = "linux", target_os = "macos"))]
        {
            let mut files = Self::none().at(Origin::MachinePolicy, "/etc/slipcase/open.toml");
            if let Some(dir) = config_home() {
                files = files.at(Origin::Configuration, dir.join("slipcase-open/policy.toml"));
            }
            files
        }
        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
        {
            Self::none()
        }
    }
}

/// `$XDG_CONFIG_HOME`, or the fallback the specification names.
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn config_home() -> Option<PathBuf> {
    if let Some(x) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
        return Some(PathBuf::from(x));
    }
    Some(PathBuf::from(std::env::var_os("HOME")?).join(".config"))
}

impl Source for Files {
    fn layer(&self, origin: Origin) -> Read {
        let Some(path) = self.paths.get(&origin) else {
            return Ok(None);
        };
        match std::fs::read_to_string(path) {
            // Not there is not an answer of *no*. A machine with no policy
            // applied has no policy file, which is the common case and not a
            // condition to report.
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(cause) => Err(Error::Unreadable {
                path: path.clone(),
                cause,
            }),
            Ok(text) => parse(path, &text).map(Some),
        }
    }
}

fn parse(path: &Path, text: &str) -> std::result::Result<Layer, Error> {
    let bad = |cause: String| Error::Malformed {
        path: path.to_owned(),
        cause,
    };
    let doc: toml_edit::DocumentMut = text.parse().map_err(|e| bad(format!("{e}")))?;

    let list = |key: &str| -> std::result::Result<Option<Vec<String>>, Error> {
        let Some(item) = doc.get(key) else {
            return Ok(None);
        };
        let array = item
            .as_array()
            .ok_or_else(|| bad(format!("`{key}` must be an array of strings")))?;
        array
            .iter()
            .map(|v| {
                v.as_str()
                    .map(ToOwned::to_owned)
                    .ok_or_else(|| bad(format!("`{key}` must be an array of strings")))
            })
            .collect::<std::result::Result<Vec<_>, _>>()
            .map(Some)
    };

    let flag = |key: &str| -> std::result::Result<Option<bool>, Error> {
        doc.get(key)
            .map(|v| {
                v.as_bool()
                    .ok_or_else(|| bad(format!("`{key}` must be true or false")))
            })
            .transpose()
    };

    // Spelled out rather than derived. There are two values and an
    // administrator who writes a third has made a mistake worth a sentence,
    // where a permissive parser would hand them `replace` and let them find out
    // from the behaviour.
    let mode = match doc.get("mode").map(|v| v.as_str()) {
        None => None,
        Some(Some("replace")) => Some(Mode::Replace),
        Some(Some("append")) => Some(Mode::Append),
        Some(other) => {
            return Err(bad(format!(
                "`mode` must be \"replace\" or \"append\", not {}",
                other.map_or_else(|| "that".to_string(), |s| format!("\"{s}\"")),
            )))
        }
    };

    // Spelled out for the same reason `mode` is: two values, and a third is a
    // mistake worth a sentence rather than a silent fallback to the default.
    let notify = match doc.get("notify").map(|v| v.as_str()) {
        None => None,
        Some(Some("everything")) => Some(Notify::Everything),
        Some(Some("important")) => Some(Notify::Important),
        Some(other) => {
            return Err(bad(format!(
                "`notify` must be \"everything\" or \"important\", not {}",
                other.map_or_else(|| "that".to_string(), |s| format!("\"{s}\"")),
            )))
        }
    };

    Ok(Layer {
        allowed: list("allowed")?,
        mode,
        denied: list("denied")?,
        user_may_extend: flag("user_may_extend")?,
        confirm_each_write_back: flag("confirm_each_write_back")?,
        notify,
    })
}

#[cfg(test)]
mod tests {
    use super::Files;
    use crate::policy::{decide, resolve, Decision, Error, Origin, Source};
    use std::fs;

    fn write(dir: &std::path::Path, name: &str, text: &str) -> std::path::PathBuf {
        let p = dir.join(name);
        fs::write(&p, text).unwrap();
        p
    }

    #[test]
    fn a_layer_that_is_not_there_says_nothing() {
        let files = Files::none().at(Origin::MachinePolicy, "/nonexistent/policy.toml");
        assert!(files.layer(Origin::MachinePolicy).unwrap().is_none());
        // And the shipped set still answers.
        assert!(matches!(
            decide(&files, "report.pdf").unwrap(),
            Decision::Open { .. }
        ));
    }

    #[test]
    fn a_layer_reads_every_key_it_carries() {
        let tmp = tempfile::tempdir().unwrap();
        let p = write(
            tmp.path(),
            "policy.toml",
            "allowed = [\"pdf\", \"txt\"]\nmode = \"append\"\ndenied = [\"exe\"]\n\
             user_may_extend = false\nconfirm_each_write_back = true\n",
        );
        let files = Files::none().at(Origin::MachinePolicy, p);
        let layer = files.layer(Origin::MachinePolicy).unwrap().unwrap();

        assert_eq!(
            layer.allowed.as_deref(),
            Some(&["pdf".into(), "txt".into()][..])
        );
        assert_eq!(layer.mode, Some(crate::policy::Mode::Append));
        assert_eq!(layer.denied.as_deref(), Some(&["exe".into()][..]));
        assert_eq!(layer.user_may_extend, Some(false));
        assert_eq!(layer.confirm_each_write_back, Some(true));
    }

    #[test]
    fn an_omitted_key_says_nothing_rather_than_no() {
        let tmp = tempfile::tempdir().unwrap();
        let p = write(tmp.path(), "policy.toml", "denied = [\"exe\"]\n");
        let layer = Files::none()
            .at(Origin::MachinePolicy, p)
            .layer(Origin::MachinePolicy)
            .unwrap()
            .unwrap();
        assert!(layer.allowed.is_none());
        assert!(layer.user_may_extend.is_none());
    }

    #[test]
    fn a_policy_file_that_will_not_parse_stops_the_decision() {
        // The case concept 10 cares about most. Answering "says nothing" here
        // would permit whatever the file was written to refuse, quietly, for as
        // long as the typo survives.
        let tmp = tempfile::tempdir().unwrap();
        let p = write(tmp.path(), "policy.toml", "denied = [\"exe\"\n");
        let files = Files::none().at(Origin::MachinePolicy, &p);

        match decide(&files, "report.pdf") {
            Err(Error::Malformed { path, .. }) => assert_eq!(path, p),
            other => panic!("{other:?}"),
        }
    }

    #[test]
    fn a_key_of_the_wrong_type_is_named_rather_than_ignored() {
        let tmp = tempfile::tempdir().unwrap();
        for (text, want) in [
            (
                "allowed = \"pdf\"\n",
                "`allowed` must be an array of strings",
            ),
            (
                "allowed = [1, 2]\n",
                "`allowed` must be an array of strings",
            ),
            (
                "user_may_extend = \"no\"\n",
                "`user_may_extend` must be true or false",
            ),
            (
                "mode = \"merge\"\n",
                "`mode` must be \"replace\" or \"append\", not \"merge\"",
            ),
            (
                "mode = 3\n",
                "`mode` must be \"replace\" or \"append\", not that",
            ),
        ] {
            let p = write(tmp.path(), "policy.toml", text);
            match Files::none()
                .at(Origin::MachinePolicy, &p)
                .layer(Origin::MachinePolicy)
            {
                Err(Error::Malformed { cause, .. }) => assert_eq!(cause, want, "{text}"),
                other => panic!("{text}: {other:?}"),
            }
        }
    }

    #[test]
    fn a_machine_list_discards_what_the_user_added_beneath_it() {
        // The point of `replace` being the default. An administrator writing an
        // exhaustive list gets an exhaustive one, and the user's own additions
        // sit beneath it and go — which is the whole reason concept 10 calls
        // append-by-default a silent hole.
        let tmp = tempfile::tempdir().unwrap();
        let machine = write(tmp.path(), "machine.toml", "allowed = [\"txt\"]\n");
        let config = write(
            tmp.path(),
            "config.toml",
            "allowed = [\"dwg\"]\nmode = \"append\"\n",
        );
        let files = Files::none()
            .at(Origin::MachinePolicy, machine)
            .at(Origin::Configuration, config);

        assert!(matches!(
            decide(&files, "notes.txt").unwrap(),
            Decision::Open { .. }
        ));
        assert!(matches!(
            decide(&files, "plan.dwg").unwrap(),
            Decision::NotPermitted { .. }
        ));
        assert!(matches!(
            decide(&files, "report.pdf").unwrap(),
            Decision::NotPermitted { .. }
        ));
        assert!(resolve(&files).unwrap().managed);
    }

    #[test]
    fn a_machine_layer_that_only_denies_leaves_the_user_free_to_add() {
        // An administrator who wants to forbid one thing rather than dictate
        // the whole list writes only `denied`, and everything beneath still
        // stacks.
        let tmp = tempfile::tempdir().unwrap();
        let machine = write(tmp.path(), "machine.toml", "denied = [\"exe\"]\n");
        let config = write(
            tmp.path(),
            "config.toml",
            "allowed = [\"dwg\"]\nmode = \"append\"\n",
        );
        let files = Files::none()
            .at(Origin::MachinePolicy, machine)
            .at(Origin::Configuration, config);

        assert!(matches!(
            decide(&files, "plan.dwg").unwrap(),
            Decision::Open { .. }
        ));
        assert!(matches!(
            decide(&files, "report.pdf").unwrap(),
            Decision::Open { .. }
        ));
        assert!(matches!(
            decide(&files, "setup.exe").unwrap(),
            Decision::Denied { .. }
        ));
    }

    #[test]
    fn a_suppressed_configuration_is_not_read_at_all() {
        // So that a broken file the administrator has already overruled cannot
        // fail a decision it would have played no part in.
        let tmp = tempfile::tempdir().unwrap();
        let machine = write(
            tmp.path(),
            "machine.toml",
            "allowed = [\"txt\"]\nuser_may_extend = false\n",
        );
        let config = write(tmp.path(), "config.toml", "this is not toml at all [[[\n");
        let files = Files::none()
            .at(Origin::MachinePolicy, machine)
            .at(Origin::Configuration, config);

        assert!(matches!(
            decide(&files, "notes.txt").unwrap(),
            Decision::Open { .. }
        ));
        assert!(resolve(&files).unwrap().configuration_suppressed);
    }

    #[test]
    fn a_deny_in_the_users_own_file_still_wins() {
        let tmp = tempfile::tempdir().unwrap();
        let machine = write(tmp.path(), "machine.toml", "allowed = [\"pdf\"]\n");
        let config = write(tmp.path(), "config.toml", "denied = [\"pdf\"]\n");
        let files = Files::none()
            .at(Origin::MachinePolicy, machine)
            .at(Origin::Configuration, config);
        assert!(matches!(
            decide(&files, "report.pdf").unwrap(),
            Decision::Denied { .. }
        ));
    }

    #[test]
    fn the_policy_file_the_package_ships_says_nothing() {
        // Concept 10 makes `/etc/slipcase/open.toml` the highest layer on this
        // platform, so a stray uncommented line in the shipped file is a policy
        // nobody wrote being enforced on every machine that installs the
        // package. The file is documentation until an administrator edits it,
        // and this is what says so.
        let shipped =
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("packaging/linux/open.toml");
        assert!(shipped.exists(), "{} is not there", shipped.display());

        let files = Files::none().at(Origin::MachinePolicy, &shipped);
        let effective = resolve(&files).unwrap();
        assert!(
            !effective.managed,
            "the shipped file must not read as policy"
        );
        assert!(!effective.confirm_each_write_back);
        assert!(effective.uncomparable_entries.is_empty());

        // And the built-in set is what decides, which is the same statement
        // made from the other end.
        for name in ["report.pdf", "notes.txt", "sheet.xlsx"] {
            assert!(
                matches!(decide(&files, name).unwrap(), Decision::Open { .. }),
                "{name}"
            );
        }
        assert!(matches!(
            decide(&files, "inner.zip").unwrap(),
            Decision::NotPermitted { .. }
        ));
    }

    #[test]
    fn notify_is_read_and_a_third_word_is_refused() {
        let tmp = tempfile::tempdir().unwrap();
        let quiet = write(tmp.path(), "quiet.toml", "notify = \"important\"\n");
        let loud = write(tmp.path(), "loud.toml", "notify = \"everything\"\n");
        let wrong = write(tmp.path(), "wrong.toml", "notify = \"off\"\n");

        let at = |p| Files::none().at(Origin::Configuration, p);
        assert_eq!(
            resolve(&at(quiet)).unwrap().notify,
            crate::policy::Notify::Important
        );
        assert_eq!(
            resolve(&at(loud)).unwrap().notify,
            crate::policy::Notify::Everything
        );
        // Spelled out rather than derived, like `mode`: a third word is a
        // mistake worth a sentence, not a silent fall back to the default.
        let refused = resolve(&at(wrong)).unwrap_err().to_string();
        assert!(refused.contains("everything"), "{refused}");
        assert!(refused.contains("\"off\""), "{refused}");
    }

    #[test]
    fn a_machine_can_hold_the_volume_down_over_the_user() {
        // The whole reason this lives in concept 10's chain rather than in a
        // settings file of its own: an administrator gets it for free, on every
        // platform, through the mechanism already specified.
        let tmp = tempfile::tempdir().unwrap();
        let machine = write(tmp.path(), "machine.toml", "notify = \"important\"\n");
        let user = write(tmp.path(), "user.toml", "notify = \"everything\"\n");
        let files = Files::none()
            .at(Origin::MachinePolicy, machine)
            .at(Origin::Configuration, user);
        assert_eq!(
            resolve(&files).unwrap().notify,
            crate::policy::Notify::Important
        );
    }
}