Skip to main content

ravenrs_sheet/engine/macros/
policy.rs

1//! What a macro step may run. Macros run commands, so this keeps a macro from saving, opening or
2//! closing files, driving the app, running other macros, or reading and writing files without the
3//! person knowing. It goes by command id and the `path` parameter; the runner also runs every
4//! step except an approved file step inside the scripts' file sandbox, so a command that touches a
5//! file under another parameter can't either.
6
7use serde_json::Value as Json;
8
9use crate::engine::Trust;
10
11/// Whether a command may run as a macro step.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum Policy {
14    Allow,
15    /// Allowed once the person approves the macro; the text says why it asks ("came with a
16    /// workbook you opened").
17    Ask(String),
18    /// Reads or writes files: the macro must list the command in its permissions, and the person
19    /// approves the macro before it runs. The text says what it does ("reads a file").
20    NeedsPermission(String),
21    /// Never runs in a macro; the text says why.
22    Deny(String),
23}
24
25/// Prefixes of commands that never run in a macro, with the reason.
26const DENIED_PREFIXES: &[(&str, &str)] = &[
27    ("file.", "macros don't open, save, close or create workbooks"),
28    ("app.", "macros don't change the app"),
29    ("window.", "macros don't switch windows"),
30    ("automate.", "macros don't record or run scripts or other macros"),
31    ("macro.", "macros don't record or run other macros"),
32];
33
34/// Single commands that never run in a macro.
35const DENIED: &[(&str, &str)] = &[
36    ("edit.undo", "a macro is undone as one step after it runs"),
37    ("edit.redo", "a macro is undone as one step after it runs"),
38    ("view.newWindow", "macros don't open, hide or show windows"),
39    ("view.hideWindow", "macros don't open, hide or show windows"),
40    ("view.unhideWindow", "macros don't open, hide or show windows"),
41    ("control.press", "macros don't press form controls"),
42];
43
44/// Commands under a denied prefix that only change the workbook.
45const ALLOWED: &[&str] = &["file.properties"];
46
47/// Commands that read a file when given a `path`.
48const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background"];
49
50/// File commands that only produce bytes unless given a `path` (then they write a file).
51const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"];
52
53/// Whether command `id` with `params` writes a file (an export or print given a `path`).
54pub fn writes_file(id: &str, params: &Json) -> bool {
55    EXPORTS.contains(&id) && params.get("path").is_some_and(|p| !p.is_null())
56}
57
58/// The policy for running command `id` with `params` as a macro step in a document with `trust`.
59///
60/// Any `path` other than `null` counts as touching a file: a token such as `{"$ref": "Input"}`
61/// becomes a path when the step runs, and exports write to `""` and `" "` as file names. The
62/// runner checks the parameters both as written and as resolved.
63pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy {
64    let has_path = params.get("path").is_some_and(|p| !p.is_null());
65    let base = if EXPORTS.contains(&id) {
66        if has_path { Policy::NeedsPermission("writes a file".into()) } else { Policy::Allow }
67    } else if ALLOWED.contains(&id) {
68        Policy::Allow
69    } else if let Some((_, why)) = DENIED.iter().find(|(d, _)| *d == id) {
70        Policy::Deny((*why).into())
71    } else if let Some((_, why)) = DENIED_PREFIXES.iter().find(|(p, _)| id.starts_with(p)) {
72        Policy::Deny((*why).into())
73    } else if READS_FILES.contains(&id) && has_path {
74        Policy::NeedsPermission("reads a file".into())
75    } else {
76        Policy::Allow
77    };
78    match base {
79        Policy::Allow if !trust.allows_code() => Policy::Ask("came with a workbook you opened".into()),
80        other => other,
81    }
82}
83
84/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops
85/// before it and reports the footprint as partial.
86pub fn previewable(id: &str) -> bool {
87    id != "data.solver"
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use serde_json::json;
94
95    #[test]
96    fn the_deny_and_permission_lists() {
97        let none = json!({});
98        let path = json!({"path": "/tmp/x"});
99        for id in [
100            "file.saveAs",
101            "file.save",
102            "file.open",
103            "file.close",
104            "file.new",
105            "app.commands",
106            "window.activate",
107            "automate.runScript",
108            "automate.recordActions",
109            "macro.run",
110            "macro.record",
111            "edit.undo",
112            "edit.redo",
113            "view.newWindow",
114            "view.hideWindow",
115            "view.unhideWindow",
116            "control.press",
117        ] {
118            assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
119            assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
120        }
121        for id in ["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background", "file.exportPdf", "file.print", "file.exportCsv"] {
122            assert!(matches!(macro_policy(id, &path, Trust::Local), Policy::NeedsPermission(_)), "{id}");
123            assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::NeedsPermission(_)), "{id}");
124        }
125        // Without a path they touch no file.
126        for id in ["data.getData", "insert.picture", "file.exportPdf", "file.saveBytes", "file.printPreview"] {
127            assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
128            assert_eq!(macro_policy(id, &json!({"path": null}), Trust::Local), Policy::Allow, "{id}");
129        }
130        // A path that is blank or a token still touches a file once the step runs.
131        for path in [json!(""), json!(" "), json!({"$ref": "Input"}), json!("{{Input}}.csv"), json!(1)] {
132            for id in ["file.exportCsv", "data.fromTextCsv"] {
133                let p = json!({ "path": path });
134                assert!(matches!(macro_policy(id, &p, Trust::Local), Policy::NeedsPermission(_)), "{id} {p}");
135            }
136        }
137        for id in ["cell.set", "home.bold", "view.macros", "edit.copy", "file.properties"] {
138            assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
139            assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}");
140        }
141        assert!(!previewable("data.solver") && previewable("cell.set"));
142    }
143
144    #[test]
145    fn every_listed_command_exists() {
146        for id in ALLOWED.iter().chain(READS_FILES).chain(EXPORTS).chain(DENIED.iter().map(|(id, _)| id)).chain(&["data.solver"]) {
147            assert!(crate::engine::find_command(id).is_some(), "{id}");
148        }
149    }
150}