aboutsummaryrefslogtreecommitdiffstats
path: root/sl0ppij/zellij-utils/src/input/permission.rs
blob: 46f1e60a4adbff2fdeaa2eb03926bfa556dbed94 (plain) (blame)
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
use std::{
    collections::HashMap,
    fs::{self, File},
    io::Write,
    path::{Component, Path, PathBuf},
};

use crate::{
    branding, consts::ZELLIJ_PLUGIN_PERMISSIONS_CACHE, data::PermissionType,
    input::layout::RunPluginLocation, input::plugins::PluginConfig,
};

/// sl0p fork: the plugins we ship do not ask for permission.
///
/// Upstream prompts for every plugin on first load, which is right for a wasm
/// you found on the internet and wrong for the ones that come in the tarball:
/// pane-finder is on a keybinding we ship, and answering a three-item consent
/// screen the first time you press it is a nag about our own software.
///
/// "Ours" is deliberately narrow, and never a blanket grant:
///
///   - **builtin plugins** are compiled into the binary. Asked via
///     `PluginConfig::is_builtin`, the same predicate `check_command_permission`
///     already uses to exempt them from every command check, so the two cannot
///     drift into disagreeing about what "builtin" means.
///   - **files inside a plugin directory we install into** -- the config dir's
///     `plugins/`, where the installer puts `pane-finder.wasm` and
///     `zjstatus.wasm`, and the data dir's `plugins/`, where zellij extracts
///     its own assets. Anything that can write there can already edit
///     `config.kdl`, which can bind arbitrary commands to a key, so a consent
///     screen for the wasm beside it is not buying anything.
///   - **remote plugins are never trusted**, wherever they are cached.
///
/// Everything else still prompts. This is the whole rule -- if a plugin is not
/// covered here it goes through the normal request, so the failure mode of
/// getting the directories wrong is an extra prompt, never a silent grant.
pub fn plugin_is_shipped_with_sl0ppi(
    plugin: &PluginConfig,
    shipped_plugin_dirs: &[PathBuf],
) -> bool {
    if plugin.is_builtin() {
        return true;
    }
    match &plugin.location {
        RunPluginLocation::Zellij(_) => true,
        RunPluginLocation::Remote(_) => false,
        RunPluginLocation::File(path) => {
            let path = resolved(path);
            // a relative path cannot be compared against a directory without
            // knowing the cwd it was resolved in, so we decline to trust it
            if !path.is_absolute() {
                return false;
            }
            shipped_plugin_dirs
                .iter()
                .map(|dir| resolved(dir))
                .any(|dir| dir.is_absolute() && path.starts_with(&dir))
        },
    }
}

/// The directories whose plugins we ship, for `plugin_is_shipped_with_sl0ppi`.
///
/// Derived from the environment and the default locations rather than plumbed
/// from the parsed config: a `--config-dir` given only on the command line is
/// therefore not seen here, which costs a prompt for a plugin in it and can
/// never grant one anywhere else.
pub fn shipped_plugin_dirs() -> Vec<PathBuf> {
    let config_dir = branding::env_var(branding::ENV_CONFIG_DIR, branding::ENV_CONFIG_DIR_COMPAT)
        .ok()
        .map(PathBuf::from)
        .or_else(crate::home::find_default_config_dir);
    config_dir
        .into_iter()
        .chain(std::iter::once(crate::home::get_default_data_dir()))
        .map(|dir| dir.join("plugins"))
        .collect()
}

/// Normalise a path without touching the filesystem, then resolve symlinks if
/// the file happens to be there.
///
/// The lexical pass is what stops `<plugins>/../../anywhere.wasm` from reading
/// as "inside the plugin dir"; `canonicalize` is a best-effort tightening on
/// top, since it only works for paths that exist.
fn resolved(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {},
            Component::ParentDir => {
                normalized.pop();
            },
            other => normalized.push(other.as_os_str()),
        }
    }
    fs::canonicalize(&normalized).unwrap_or(normalized)
}

pub type GrantedPermission = HashMap<String, Vec<PermissionType>>;

#[derive(Default, Debug)]
pub struct PermissionCache {
    path: PathBuf,
    granted: GrantedPermission,
}

impl PermissionCache {
    pub fn cache(&mut self, plugin_name: String, permissions: Vec<PermissionType>) {
        self.granted.insert(plugin_name, permissions);
    }

    pub fn get_permissions(&self, plugin_name: String) -> Option<&Vec<PermissionType>> {
        self.granted.get(&plugin_name)
    }

    pub fn check_permissions(
        &self,
        plugin_name: String,
        permissions_to_check: &Vec<PermissionType>,
    ) -> bool {
        if let Some(target) = self.granted.get(&plugin_name) {
            let mut all_granted = true;
            for permission in permissions_to_check {
                if !target.contains(permission) {
                    all_granted = false;
                }
            }
            return all_granted;
        }

        false
    }

    pub fn from_path_or_default(cache_path: Option<PathBuf>) -> Self {
        let cache_path = cache_path.unwrap_or(ZELLIJ_PLUGIN_PERMISSIONS_CACHE.to_path_buf());

        let granted = match fs::read_to_string(cache_path.clone()) {
            Ok(raw_string) => PermissionCache::from_string(raw_string).unwrap_or_default(),
            Err(e) => {
                log::error!("Failed to read permission cache file: {}", e);
                GrantedPermission::default()
            },
        };

        PermissionCache {
            path: cache_path,
            granted,
        }
    }

    pub fn write_to_file(&self) -> std::io::Result<()> {
        let mut f = File::create(&self.path)?;
        write!(f, "{}", PermissionCache::to_string(&self.granted))?;
        Ok(())
    }
}

#[cfg(test)]
mod shipped_plugin_tests {
    use super::*;
    use crate::input::plugins::PluginTag;

    fn plugin_at(location: RunPluginLocation) -> PluginConfig {
        PluginConfig {
            path: PathBuf::new(),
            _allow_exec_host_cmd: false,
            location,
            initial_userspace_configuration: Default::default(),
            initial_cwd: None,
        }
    }

    fn file(path: &str) -> PluginConfig {
        plugin_at(RunPluginLocation::File(PathBuf::from(path)))
    }

    fn dirs() -> Vec<PathBuf> {
        vec![
            PathBuf::from("/home/someone/.config/sl0ppij/plugins"),
            PathBuf::from("/home/someone/.local/share/sl0ppij/plugins"),
        ]
    }

    #[test]
    fn builtin_plugins_are_ours() {
        // compiled into the binary: a prompt protects nothing, and
        // check_command_permission already waves them through
        let builtin = plugin_at(RunPluginLocation::Zellij(PluginTag::new("session-manager")));
        assert!(builtin.is_builtin());
        assert!(plugin_is_shipped_with_sl0ppi(&builtin, &[]));
    }

    #[test]
    fn plugins_in_our_plugin_dirs_are_ours() {
        for path in [
            "/home/someone/.config/sl0ppij/plugins/pane-finder.wasm",
            "/home/someone/.config/sl0ppij/plugins/zjstatus.wasm",
            "/home/someone/.local/share/sl0ppij/plugins/tab-bar.wasm",
        ] {
            assert!(
                plugin_is_shipped_with_sl0ppi(&file(path), &dirs()),
                "{} should be trusted",
                path
            );
        }
    }

    #[test]
    fn plugins_from_anywhere_else_still_ask() {
        for path in [
            "/tmp/downloaded.wasm",
            "/home/someone/.config/sl0ppij/not-plugins/x.wasm",
            // a sibling directory that merely starts with the same string:
            // `starts_with` on a Path compares components, not bytes, so this
            // must not match
            "/home/someone/.config/sl0ppij/plugins-evil/x.wasm",
            "relative/path.wasm",
        ] {
            assert!(
                !plugin_is_shipped_with_sl0ppi(&file(path), &dirs()),
                "{} should NOT be trusted",
                path
            );
        }
    }

    #[test]
    fn a_traversal_out_of_the_plugin_dir_is_not_ours() {
        // the point of normalising lexically: this path is "inside" the plugin
        // dir only as a string
        assert!(!plugin_is_shipped_with_sl0ppi(
            &file("/home/someone/.config/sl0ppij/plugins/../../../../tmp/evil.wasm"),
            &dirs(),
        ));
    }

    #[test]
    fn remote_plugins_are_never_ours() {
        assert!(!plugin_is_shipped_with_sl0ppi(
            &plugin_at(RunPluginLocation::Remote(
                "https://example.com/plugin.wasm".to_owned()
            )),
            &dirs(),
        ));
    }

    #[test]
    fn no_known_plugin_dirs_means_no_file_is_ours() {
        // getting the directories wrong costs a prompt, never a silent grant
        assert!(!plugin_is_shipped_with_sl0ppi(
            &file("/home/someone/.config/sl0ppij/plugins/pane-finder.wasm"),
            &[],
        ));
    }

    #[test]
    fn the_plugin_dirs_we_look_in_are_named_plugins() {
        // it does not resolve to anything real under test, but it must never
        // hand back a bare config or data dir -- that would trust every file
        // the user keeps beside their config
        for dir in shipped_plugin_dirs() {
            assert_eq!(
                dir.file_name()
                    .map(|name| name.to_string_lossy().to_string()),
                Some("plugins".to_string()),
                "{:?} is not a plugin directory",
                dir
            );
        }
    }
}