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 { 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 `/../../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>; #[derive(Default, Debug)] pub struct PermissionCache { path: PathBuf, granted: GrantedPermission, } impl PermissionCache { pub fn cache(&mut self, plugin_name: String, permissions: Vec) { self.granted.insert(plugin_name, permissions); } pub fn get_permissions(&self, plugin_name: String) -> Option<&Vec> { self.granted.get(&plugin_name) } pub fn check_permissions( &self, plugin_name: String, permissions_to_check: &Vec, ) -> 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) -> 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 { 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 ); } } }