use crate::ported::utils::{errflag, ERRFLAG_ERROR};
#[allow(unused_imports)]
use crate::ported::vm_helper::ShellExecutor;
use crate::ported::zsh_h::PM_UNDEFINED;
use rusqlite::{params, Connection};
use std::collections::HashMap;
use std::env;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::OnceLock;
pub(crate) struct PluginSnapshot {
pub(crate) functions: std::collections::HashSet<String>,
pub(crate) aliases: std::collections::HashSet<String>,
pub(crate) global_aliases: std::collections::HashSet<String>,
pub(crate) suffix_aliases: std::collections::HashSet<String>,
pub(crate) variables: HashMap<String, String>,
pub(crate) arrays: std::collections::HashSet<String>,
pub(crate) assoc_arrays: std::collections::HashSet<String>,
pub(crate) fpath: Vec<PathBuf>,
pub(crate) options: HashMap<String, bool>,
pub(crate) hooks: HashMap<String, Vec<String>>,
pub(crate) autoloads: std::collections::HashSet<String>,
}
fn current_binary_mtime() -> Option<i64> {
static BIN_MTIME: OnceLock<Option<i64>> = OnceLock::new();
*BIN_MTIME.get_or_init(|| {
let exe = std::env::current_exe().ok()?;
let meta = std::fs::metadata(&exe).ok()?;
Some(meta.mtime())
})
}
#[derive(Debug, Clone, Default)]
pub struct PluginDelta {
pub functions: Vec<(String, Vec<u8>)>, pub aliases: Vec<(String, String, AliasKind)>, pub global_aliases: Vec<(String, String)>,
pub suffix_aliases: Vec<(String, String)>,
pub variables: Vec<(String, String)>,
pub exports: Vec<(String, String)>, pub arrays: Vec<(String, Vec<String>)>,
pub assoc_arrays: Vec<(String, HashMap<String, String>)>,
pub completions: Vec<(String, String)>, pub fpath_additions: Vec<String>,
pub hooks: Vec<(String, String)>, pub bindkeys: Vec<(String, String, String)>, pub zstyles: Vec<(String, String, String)>, pub options_changed: Vec<(String, bool)>, pub autoloads: Vec<(String, String)>, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AliasKind {
Regular,
Global,
Suffix,
}
impl AliasKind {
fn as_i32(self) -> i32 {
match self {
AliasKind::Regular => 0,
AliasKind::Global => 1,
AliasKind::Suffix => 2,
}
}
fn from_i32(v: i32) -> Self {
match v {
1 => AliasKind::Global,
2 => AliasKind::Suffix,
_ => AliasKind::Regular,
}
}
}
pub struct PluginCache {
conn: Connection,
}
impl PluginCache {
pub fn open(path: &Path) -> rusqlite::Result<Self> {
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
let cache = Self { conn };
cache.init_schema()?;
Ok(cache)
}
fn init_schema(&self) -> rusqlite::Result<()> {
self.conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS plugins (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
mtime_secs INTEGER NOT NULL,
mtime_nsecs INTEGER NOT NULL,
source_time_ms INTEGER NOT NULL,
cached_at INTEGER NOT NULL,
binary_mtime INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS plugin_functions (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
name TEXT NOT NULL,
body BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_aliases (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL,
kind INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS plugin_variables (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL,
is_export INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS plugin_arrays (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value_json TEXT NOT NULL
);
-- Associative-array deltas (e.g. ZINIT[BIN_DIR]=...). Stored
-- as JSON {key: value} so insertion order isn't load-bearing
-- (matches HashMap semantics on the Rust side). Direct
-- analogue of plugin_arrays for assoc shape.
CREATE TABLE IF NOT EXISTS plugin_assoc_arrays (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_completions (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
command TEXT NOT NULL,
function TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_fpath (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
path TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_hooks (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
hook TEXT NOT NULL,
function TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_bindkeys (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
keyseq TEXT NOT NULL,
widget TEXT NOT NULL,
keymap TEXT NOT NULL DEFAULT 'main'
);
CREATE TABLE IF NOT EXISTS plugin_zstyles (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
style TEXT NOT NULL,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_options (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
name TEXT NOT NULL,
enabled INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_autoloads (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
function TEXT NOT NULL,
flags TEXT NOT NULL DEFAULT ''
);
-- compaudit cache: security audit results per fpath directory
CREATE TABLE IF NOT EXISTS compaudit_cache (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
mtime_secs INTEGER NOT NULL,
mtime_nsecs INTEGER NOT NULL,
uid INTEGER NOT NULL,
mode INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
checked_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugins_path ON plugins(path);
CREATE INDEX IF NOT EXISTS idx_compaudit_path ON compaudit_cache(path);
-- Migration: legacy script_bytecode table (bytecode now lives in
-- the rkyv shard at ~/.zshrs/scripts.rkyv). Drop on open so
-- existing DBs reclaim the space and don't carry stale bytecode.
DROP INDEX IF EXISTS idx_script_bytecode_path;
DROP TABLE IF EXISTS script_bytecode;
"#,
)?;
let _ = self.conn.execute(
"ALTER TABLE plugins ADD COLUMN binary_mtime INTEGER NOT NULL DEFAULT 0",
[],
);
Ok(())
}
pub fn check(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<i64> {
let row: Option<(i64, i64)> = self
.conn
.query_row(
"SELECT id, binary_mtime FROM plugins WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
params![path, mtime_secs, mtime_nsecs],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
let (id, cached_bin_mtime) = row?;
if let Some(bin_mtime) = current_binary_mtime() {
if cached_bin_mtime < bin_mtime {
return None;
}
}
Some(id)
}
pub fn load(&self, plugin_id: i64) -> rusqlite::Result<PluginDelta> {
let mut delta = PluginDelta::default();
let mut stmt = self
.conn
.prepare("SELECT name, body FROM plugin_functions WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
})?;
for r in rows {
delta.functions.push(r?);
}
let mut stmt = self
.conn
.prepare("SELECT name, value, kind FROM plugin_aliases WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
AliasKind::from_i32(row.get::<_, i32>(2)?),
))
})?;
for r in rows {
delta.aliases.push(r?);
}
let mut stmt = self
.conn
.prepare("SELECT name, value, is_export FROM plugin_variables WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
))
})?;
for r in rows {
let (name, value, is_export) = r?;
if is_export {
delta.exports.push((name, value));
} else {
delta.variables.push((name, value));
}
}
let mut stmt = self
.conn
.prepare("SELECT name, value_json FROM plugin_arrays WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for r in rows {
let (name, json) = r?;
let vals: Vec<String> = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect();
delta.arrays.push((name, vals));
}
let mut stmt = self
.conn
.prepare("SELECT name, value_json FROM plugin_assoc_arrays WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for r in rows {
let (name, json) = r?;
let map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or_default();
delta.assoc_arrays.push((name, map));
}
let mut stmt = self
.conn
.prepare("SELECT command, function FROM plugin_completions WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for r in rows {
delta.completions.push(r?);
}
let mut stmt = self
.conn
.prepare("SELECT path FROM plugin_fpath WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| row.get::<_, String>(0))?;
for r in rows {
delta.fpath_additions.push(r?);
}
let mut stmt = self
.conn
.prepare("SELECT hook, function FROM plugin_hooks WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for r in rows {
delta.hooks.push(r?);
}
let mut stmt = self
.conn
.prepare("SELECT keyseq, widget, keymap FROM plugin_bindkeys WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for r in rows {
delta.bindkeys.push(r?);
}
let mut stmt = self
.conn
.prepare("SELECT pattern, style, value FROM plugin_zstyles WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for r in rows {
delta.zstyles.push(r?);
}
let mut stmt = self
.conn
.prepare("SELECT name, enabled FROM plugin_options WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?))
})?;
for r in rows {
delta.options_changed.push(r?);
}
let mut stmt = self
.conn
.prepare("SELECT function, flags FROM plugin_autoloads WHERE plugin_id = ?1")?;
let rows = stmt.query_map(params![plugin_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for r in rows {
delta.autoloads.push(r?);
}
Ok(delta)
}
pub fn store(
&self,
path: &str,
mtime_secs: i64,
mtime_nsecs: i64,
source_time_ms: u64,
delta: &PluginDelta,
) -> rusqlite::Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.conn
.execute("DELETE FROM plugins WHERE path = ?1", params![path])?;
let bin_mtime = current_binary_mtime().unwrap_or(0);
self.conn.execute(
"INSERT INTO plugins (path, mtime_secs, mtime_nsecs, source_time_ms, cached_at, binary_mtime) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![path, mtime_secs, mtime_nsecs, source_time_ms as i64, now, bin_mtime],
)?;
let plugin_id = self.conn.last_insert_rowid();
for (name, body) in &delta.functions {
self.conn.execute(
"INSERT INTO plugin_functions (plugin_id, name, body) VALUES (?1, ?2, ?3)",
params![plugin_id, name, body],
)?;
}
for (name, value, kind) in &delta.aliases {
self.conn.execute(
"INSERT INTO plugin_aliases (plugin_id, name, value, kind) VALUES (?1, ?2, ?3, ?4)",
params![plugin_id, name, value, kind.as_i32()],
)?;
}
for (name, value) in &delta.variables {
self.conn.execute(
"INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 0)",
params![plugin_id, name, value],
)?;
}
for (name, value) in &delta.exports {
self.conn.execute(
"INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 1)",
params![plugin_id, name, value],
)?;
}
for (name, vals) in &delta.arrays {
let json = format!(
"[{}]",
vals.iter()
.map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(",")
);
self.conn.execute(
"INSERT INTO plugin_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
params![plugin_id, name, json],
)?;
}
for (name, map) in &delta.assoc_arrays {
let json = serde_json::to_string(map).unwrap_or_else(|_| "{}".to_string());
self.conn.execute(
"INSERT INTO plugin_assoc_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
params![plugin_id, name, json],
)?;
}
for (cmd, func) in &delta.completions {
self.conn.execute(
"INSERT INTO plugin_completions (plugin_id, command, function) VALUES (?1, ?2, ?3)",
params![plugin_id, cmd, func],
)?;
}
for p in &delta.fpath_additions {
self.conn.execute(
"INSERT INTO plugin_fpath (plugin_id, path) VALUES (?1, ?2)",
params![plugin_id, p],
)?;
}
for (hook, func) in &delta.hooks {
self.conn.execute(
"INSERT INTO plugin_hooks (plugin_id, hook, function) VALUES (?1, ?2, ?3)",
params![plugin_id, hook, func],
)?;
}
for (keyseq, widget, keymap) in &delta.bindkeys {
self.conn.execute(
"INSERT INTO plugin_bindkeys (plugin_id, keyseq, widget, keymap) VALUES (?1, ?2, ?3, ?4)",
params![plugin_id, keyseq, widget, keymap],
)?;
}
for (pattern, style, value) in &delta.zstyles {
self.conn.execute(
"INSERT INTO plugin_zstyles (plugin_id, pattern, style, value) VALUES (?1, ?2, ?3, ?4)",
params![plugin_id, pattern, style, value],
)?;
}
for (name, enabled) in &delta.options_changed {
self.conn.execute(
"INSERT INTO plugin_options (plugin_id, name, enabled) VALUES (?1, ?2, ?3)",
params![plugin_id, name, *enabled],
)?;
}
for (func, flags) in &delta.autoloads {
self.conn.execute(
"INSERT INTO plugin_autoloads (plugin_id, function, flags) VALUES (?1, ?2, ?3)",
params![plugin_id, func, flags],
)?;
}
Ok(())
}
pub fn stats(&self) -> (i64, i64) {
let plugins: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM plugins", [], |r| r.get(0))
.unwrap_or(0);
let functions: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM plugin_functions", [], |r| r.get(0))
.unwrap_or(0);
(plugins, functions)
}
pub fn count_stale(&self) -> usize {
let mut stmt = match self
.conn
.prepare("SELECT path, mtime_secs, mtime_nsecs FROM plugins")
{
Ok(s) => s,
Err(_) => return 0,
};
let rows = match stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
))
}) {
Ok(r) => r,
Err(_) => return 0,
};
let mut count = 0;
for (path, cached_s, cached_ns) in rows.flatten() {
match file_mtime(std::path::Path::new(&path)) {
Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
None => count += 1, _ => {}
}
}
count
}
pub fn check_compaudit(&self, dir: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<bool> {
self.conn.query_row(
"SELECT is_secure FROM compaudit_cache WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
params![dir, mtime_secs, mtime_nsecs],
|row| row.get::<_, bool>(0),
).ok()
}
pub fn store_compaudit(
&self,
dir: &str,
mtime_secs: i64,
mtime_nsecs: i64,
uid: u32,
mode: u32,
is_secure: bool,
) -> rusqlite::Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.conn.execute(
"INSERT OR REPLACE INTO compaudit_cache (path, mtime_secs, mtime_nsecs, uid, mode, is_secure, checked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![dir, mtime_secs, mtime_nsecs, uid as i64, mode as i64, is_secure, now],
)?;
Ok(())
}
pub fn compaudit_cached(&self, fpath: &[std::path::PathBuf]) -> Vec<String> {
let euid = unsafe { libc::geteuid() };
let mut insecure = Vec::new();
for dir in fpath {
let dir_str = dir.to_string_lossy().to_string();
let meta = match std::fs::metadata(dir) {
Ok(m) => m,
Err(_) => continue, };
let mt_s = meta.mtime();
let mt_ns = meta.mtime_nsec();
if let Some(is_secure) = self.check_compaudit(&dir_str, mt_s, mt_ns) {
if !is_secure {
insecure.push(dir_str);
}
continue;
}
let mode = meta.mode();
let uid = meta.uid();
let is_secure = Self::check_dir_security(&meta, euid);
let parent_secure = dir
.parent()
.and_then(|p| std::fs::metadata(p).ok())
.map(|pm| Self::check_dir_security(&pm, euid))
.unwrap_or(true);
let secure = is_secure && parent_secure;
let _ = self.store_compaudit(&dir_str, mt_s, mt_ns, uid, mode, secure);
if !secure {
insecure.push(dir_str);
}
}
if insecure.is_empty() {
tracing::debug!(
dirs = fpath.len(),
"compaudit: all directories secure (cached)"
);
} else {
tracing::warn!(
insecure_count = insecure.len(),
dirs = fpath.len(),
"compaudit: insecure directories found"
);
}
insecure
}
pub fn list_plugin_paths(&self) -> Vec<(String, i64)> {
let mut stmt = match self
.conn
.prepare("SELECT path, mtime_secs FROM plugins ORDER BY id")
{
Ok(s) => s,
Err(_) => return Vec::new(),
};
let rows = match stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
}) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
rows.flatten().collect()
}
fn check_dir_security(meta: &std::fs::Metadata, euid: u32) -> bool {
let mode = meta.mode();
let uid = meta.uid();
if uid == 0 || uid == euid {
return true;
}
let group_writable = mode & 0o020 != 0;
let world_writable = mode & 0o002 != 0;
!group_writable && !world_writable
}
}
pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
let meta = std::fs::metadata(path).ok()?;
Some((meta.mtime(), meta.mtime_nsec()))
}
#[derive(Debug, Clone)]
pub struct PluginEntry {
pub manager: String,
pub name: String,
pub root: PathBuf,
}
fn classify_plugin_path(path: &Path) -> PluginEntry {
let s = path.to_string_lossy();
for marker in ["/.zinit/plugins/", "/zinit/plugins/"] {
if let Some(start) = s.find(marker) {
let after = &s[start + marker.len()..];
if let Some(end) = after.find('/') {
let dir = &after[..end];
let name = dir.replacen("---", "/", 1);
let root: PathBuf = s[..start + marker.len() + end].into();
return PluginEntry { manager: "zinit".into(), name, root };
}
}
}
for (marker, kind) in [
("/.oh-my-zsh/custom/plugins/", "plugin"),
("/.oh-my-zsh/plugins/", "plugin"),
("/.oh-my-zsh/custom/themes/", "theme"),
("/.oh-my-zsh/themes/", "theme"),
] {
if let Some(start) = s.find(marker) {
let after = &s[start + marker.len()..];
let end = after.find('/').unwrap_or(after.len());
let leaf = &after[..end];
let name = if kind == "theme" { format!("{}.theme", leaf) } else { leaf.to_string() };
let root: PathBuf = s[..start + marker.len() + end].into();
return PluginEntry { manager: "oh-my-zsh".into(), name, root };
}
}
if let Some(start) = s.find("/.zprezto/modules/") {
let after = &s[start + "/.zprezto/modules/".len()..];
let end = after.find('/').unwrap_or(after.len());
let name = after[..end].to_string();
let root: PathBuf = s[..start + "/.zprezto/modules/".len() + end].into();
return PluginEntry { manager: "prezto".into(), name, root };
}
for marker in ["/antidote/repos/", "/.cache/antidote/"] {
if let Some(start) = s.find(marker) {
let after = &s[start + marker.len()..];
let mut split = after.splitn(3, '/');
if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
let name = format!("{}/{}", user, repo);
let root: PathBuf = format!(
"{}{}/{}",
&s[..start + marker.len()],
user,
repo
)
.into();
return PluginEntry { manager: "antidote".into(), name, root };
}
}
}
if let Some(start) = s.find("/.antigen/bundles/") {
let after = &s[start + "/.antigen/bundles/".len()..];
let mut split = after.splitn(3, '/');
if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
let name = format!("{}/{}", user, repo);
let root: PathBuf = format!(
"{}/{}/{}",
&s[..start + "/.antigen/bundles".len()],
user,
repo
)
.into();
return PluginEntry { manager: "antigen".into(), name, root };
}
}
if let Some(start) = s.find("/.zplug/repos/") {
let after = &s[start + "/.zplug/repos/".len()..];
let mut split = after.splitn(3, '/');
if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
let name = format!("{}/{}", user, repo);
let root: PathBuf = format!(
"{}/{}/{}",
&s[..start + "/.zplug/repos".len()],
user,
repo
)
.into();
return PluginEntry { manager: "zplug".into(), name, root };
}
}
if let Some(start) = s.find("/zsh-more-completions/") {
let root: PathBuf = s[..start + "/zsh-more-completions".len()].into();
return PluginEntry {
manager: "zsh-more-completions".into(),
name: "zsh-more-completions".into(),
root,
};
}
for marker in ["/.zpwr/", "/zpwr/"] {
if let Some(start) = s.find(marker) {
let root: PathBuf = s[..start + marker.len() - 1].into();
return PluginEntry {
manager: "zpwr".into(),
name: "zpwr".into(),
root,
};
}
}
let root = path.parent().map(PathBuf::from).unwrap_or_else(|| path.into());
let name = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "(loose)".into());
PluginEntry { manager: "loose".into(), name, root }
}
pub fn list_plugins(cache_path: &Path) -> Vec<PluginEntry> {
let cache = match PluginCache::open(cache_path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut seen: std::collections::BTreeMap<(String, String, PathBuf), PluginEntry> =
std::collections::BTreeMap::new();
for (path, _mtime) in cache.list_plugin_paths() {
let entry = classify_plugin_path(Path::new(&path));
seen.entry((entry.manager.clone(), entry.name.clone(), entry.root.clone()))
.or_insert(entry);
}
seen.into_values().collect()
}
pub fn dump_plugins_json() -> String {
let entries = list_plugins(&default_cache_path());
let mut s = String::from("{\"schema\":1,\"plugins\":[");
for (i, e) in entries.iter().enumerate() {
if i > 0 { s.push(','); }
s.push_str(&format!(
"{{\"manager\":{},\"name\":{},\"root\":{}}}",
json_str(&e.manager),
json_str(&e.name),
json_str(&e.root.to_string_lossy())
));
}
s.push_str("]}");
s
}
fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
pub fn default_cache_path() -> PathBuf {
if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
return PathBuf::from(custom).join("plugins.db");
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".zshrs/plugins.db")
}
#[cfg(test)]
mod migration_tests {
use super::*;
#[test]
fn opening_an_existing_db_drops_legacy_script_bytecode_table() {
let _g = crate::test_util::global_state_lock();
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("legacy.db");
let pre = Connection::open(&db_path).unwrap();
pre.execute_batch(
r#"
CREATE TABLE script_bytecode (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
mtime_secs INTEGER NOT NULL,
mtime_nsecs INTEGER NOT NULL,
bytecode BLOB NOT NULL,
cached_at INTEGER NOT NULL
);
CREATE INDEX idx_script_bytecode_path ON script_bytecode(path);
INSERT INTO script_bytecode (id, path, mtime_secs, mtime_nsecs, bytecode, cached_at)
VALUES (1, '/fake/legacy.zsh', 0, 0, x'00deadbeef', 0);
"#,
)
.unwrap();
drop(pre);
let _cache = PluginCache::open(&db_path).expect("open after migration");
let post = Connection::open(&db_path).unwrap();
let exists: i64 = post
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='script_bytecode'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(exists, 0, "legacy script_bytecode must be dropped");
}
fn with_zshrs_home<F: FnOnce()>(value: Option<&str>, f: F) {
let prev = std::env::var_os("ZSHRS_HOME");
match value {
Some(v) => std::env::set_var("ZSHRS_HOME", v),
None => std::env::remove_var("ZSHRS_HOME"),
}
f();
match prev {
Some(v) => std::env::set_var("ZSHRS_HOME", v),
None => std::env::remove_var("ZSHRS_HOME"),
}
}
#[test]
fn default_cache_path_honors_zshrs_home() {
let _g = crate::test_util::global_state_lock();
with_zshrs_home(Some("/tmp/zshrs-plugin-cache-home"), || {
assert_eq!(
default_cache_path(),
PathBuf::from("/tmp/zshrs-plugin-cache-home/plugins.db")
);
});
}
#[test]
fn default_cache_path_filename_is_plugins_db() {
let _g = crate::test_util::global_state_lock();
with_zshrs_home(Some("/tmp/zshrs-plugin-fname"), || {
assert_eq!(
default_cache_path().file_name().and_then(|s| s.to_str()),
Some("plugins.db")
);
});
}
#[test]
fn default_cache_path_falls_back_to_home_dot_zshrs() {
let _g = crate::test_util::global_state_lock();
with_zshrs_home(None, || {
let p = default_cache_path();
let s = p.to_string_lossy();
assert!(
s.ends_with(".zshrs/plugins.db"),
"expected .zshrs/plugins.db tail, got: {}",
s
);
});
}
#[test]
fn default_cache_path_uses_distinct_dir_per_zshrs_home_change() {
let _g = crate::test_util::global_state_lock();
with_zshrs_home(Some("/tmp/zshrs-plugin-a"), || {
let a = default_cache_path();
with_zshrs_home(Some("/tmp/zshrs-plugin-b"), || {
let b = default_cache_path();
assert_ne!(a, b, "different ZSHRS_HOME must yield different paths");
});
});
}
#[test]
fn file_mtime_returns_some_for_existing_file() {
let _g = crate::test_util::global_state_lock();
let tmp = std::env::temp_dir().join("zshrs_plugin_cache_mtime.txt");
std::fs::write(&tmp, b"x").unwrap();
let mt = file_mtime(&tmp);
assert!(mt.is_some(), "existing file should produce mtime");
let (secs, _ns) = mt.unwrap();
assert!(secs > 0, "mtime secs must be positive: {}", secs);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn file_mtime_returns_none_for_missing_path() {
let _g = crate::test_util::global_state_lock();
assert!(file_mtime(Path::new("/nonexistent/zshrs/missing.bin")).is_none());
}
#[test]
fn file_mtime_secs_monotonic_after_rewrite() {
let _g = crate::test_util::global_state_lock();
let tmp = std::env::temp_dir().join("zshrs_plugin_cache_mtime_two.txt");
std::fs::write(&tmp, b"a").unwrap();
let first = file_mtime(&tmp).unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(&tmp, b"b").unwrap();
let second = file_mtime(&tmp).unwrap();
assert!(
second >= first,
"mtime regressed: first={:?} second={:?}",
first,
second
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn file_mtime_path_with_special_chars_resolves() {
let _g = crate::test_util::global_state_lock();
let tmp = std::env::temp_dir().join("zshrs plugin cache (space).bin");
std::fs::write(&tmp, b"x").unwrap();
let mt = file_mtime(&tmp);
assert!(mt.is_some(), "spaces in filename must not block resolution");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn default_cache_path_relative_zshrs_home_taken_verbatim() {
let _g = crate::test_util::global_state_lock();
with_zshrs_home(Some("relative-dir"), || {
assert_eq!(
default_cache_path(),
PathBuf::from("relative-dir/plugins.db")
);
});
}
#[test]
fn default_cache_path_empty_zshrs_home_is_empty_dir_plus_db() {
let _g = crate::test_util::global_state_lock();
with_zshrs_home(Some(""), || {
assert_eq!(default_cache_path(), PathBuf::from("plugins.db"));
});
}
}
#[cfg(test)]
mod classify_tests {
use super::*;
use std::path::Path;
fn classify(p: &str) -> (String, String, String) {
let e = classify_plugin_path(Path::new(p));
(e.manager, e.name, e.root.to_string_lossy().into_owned())
}
#[test]
fn zinit_legacy_dir_user_repo() {
let (m, n, r) = classify(
"/Users/wizard/.zinit/plugins/zsh-users---zsh-autosuggestions/zsh-autosuggestions.plugin.zsh",
);
assert_eq!(m, "zinit");
assert_eq!(n, "zsh-users/zsh-autosuggestions");
assert_eq!(
r,
"/Users/wizard/.zinit/plugins/zsh-users---zsh-autosuggestions"
);
}
#[test]
fn zinit_xdg_dir_user_repo() {
let (m, n, _) = classify(
"/home/u/.local/share/zinit/plugins/romkatv---powerlevel10k/p10k.zsh",
);
assert_eq!(m, "zinit");
assert_eq!(n, "romkatv/powerlevel10k");
}
#[test]
fn oh_my_zsh_core_plugin() {
let (m, n, r) = classify("/Users/wizard/.oh-my-zsh/plugins/git/git.plugin.zsh");
assert_eq!(m, "oh-my-zsh");
assert_eq!(n, "git");
assert_eq!(r, "/Users/wizard/.oh-my-zsh/plugins/git");
}
#[test]
fn oh_my_zsh_custom_plugin() {
let (m, n, _) = classify(
"/Users/wizard/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh",
);
assert_eq!(m, "oh-my-zsh");
assert_eq!(n, "zsh-syntax-highlighting");
}
#[test]
fn oh_my_zsh_theme_tagged_with_theme_suffix() {
let (m, n, _) =
classify("/Users/wizard/.oh-my-zsh/themes/agnoster.zsh-theme");
assert_eq!(m, "oh-my-zsh");
assert_eq!(n, "agnoster.zsh-theme.theme");
}
#[test]
fn prezto_module() {
let (m, n, _) = classify("/Users/wizard/.zprezto/modules/git/init.zsh");
assert_eq!(m, "prezto");
assert_eq!(n, "git");
}
#[test]
fn antidote_repo() {
let (m, n, _) = classify(
"/Users/wizard/.cache/antidote/zsh-users/zsh-autosuggestions/zsh-autosuggestions.zsh",
);
assert_eq!(m, "antidote");
assert_eq!(n, "zsh-users/zsh-autosuggestions");
}
#[test]
fn antigen_bundle() {
let (m, n, _) = classify(
"/Users/wizard/.antigen/bundles/zsh-users/zsh-completions/zsh-completions.plugin.zsh",
);
assert_eq!(m, "antigen");
assert_eq!(n, "zsh-users/zsh-completions");
}
#[test]
fn zplug_repo() {
let (m, n, _) = classify(
"/Users/wizard/.zplug/repos/zsh-users/zsh-history-substring-search/zsh-history-substring-search.zsh",
);
assert_eq!(m, "zplug");
assert_eq!(n, "zsh-users/zsh-history-substring-search");
}
#[test]
fn zsh_more_completions_groups_into_one() {
let (m, n, _) = classify(
"/Users/wizard/forkedRepos/zsh-more-completions/src/_some_long_completion",
);
assert_eq!(m, "zsh-more-completions");
assert_eq!(n, "zsh-more-completions");
}
#[test]
fn zpwr_root_recognized() {
let (m, n, _) = classify("/Users/wizard/.zpwr/local/.aliases.sh");
assert_eq!(m, "zpwr");
assert_eq!(n, "zpwr");
}
#[test]
fn loose_plugin_uses_parent_dir_as_name() {
let (m, n, r) = classify("/opt/local/share/zsh/something/init.zsh");
assert_eq!(m, "loose");
assert_eq!(n, "something");
assert_eq!(r, "/opt/local/share/zsh/something");
}
}
impl crate::ported::vm_helper::ShellExecutor {
pub(crate) fn snapshot_state(&self) -> PluginSnapshot {
PluginSnapshot {
functions: self.function_names().into_iter().collect(),
aliases: self.alias_entries().into_iter().map(|(k, _)| k).collect(),
global_aliases: self
.global_alias_entries()
.into_iter()
.map(|(k, _)| k)
.collect(),
suffix_aliases: self
.suffix_alias_entries()
.into_iter()
.map(|(k, _)| k)
.collect(),
variables: if let Ok(tab) = crate::ported::params::paramtab().read() {
tab.iter()
.filter(|(_, pm)| pm.u_arr.is_none())
.map(|(k, pm)| (k.clone(), pm.u_str.clone().unwrap_or_default()))
.collect()
} else {
std::collections::HashMap::new()
},
arrays: if let Ok(tab) = crate::ported::params::paramtab().read() {
tab.iter()
.filter(|(_, pm)| pm.u_arr.is_some())
.map(|(k, _)| k.clone())
.collect()
} else {
std::collections::HashSet::new()
},
assoc_arrays: if let Ok(m) = crate::ported::params::paramtab_hashed_storage().lock() {
m.keys().cloned().collect()
} else {
std::collections::HashSet::new()
},
fpath: self.fpath.clone(),
options: crate::ported::options::opt_state_snapshot(),
hooks: {
let names = [
"chpwd",
"precmd",
"preexec",
"periodic",
"zshexit",
"zshaddhistory",
];
let mut m = std::collections::HashMap::new();
for h in &names {
let arr_name = format!("{}_functions", h);
if let Some(arr) = self.array(&arr_name) {
if !arr.is_empty() {
m.insert(h.to_string(), arr);
}
}
}
m
},
autoloads: {
crate::ported::hashtable::shfunctab_lock()
.read()
.ok()
.map(|t| {
t.iter()
.filter(|(_, shf)| (shf.node.flags as u32 & PM_UNDEFINED) != 0)
.map(|(name, _)| name.clone())
.collect()
})
.unwrap_or_default()
},
}
}
pub(crate) fn diff_state(&self, snap: &PluginSnapshot) -> crate::plugin_cache::PluginDelta {
let mut delta = PluginDelta::default();
let mut fn_keys: Vec<&String> = self.function_source.keys().collect();
fn_keys.sort();
for name in fn_keys {
if !snap.functions.contains(name) {
let source = self.function_source.get(name).unwrap();
delta
.functions
.push((name.clone(), source.as_bytes().to_vec()));
}
}
let push_alias = |delta: &mut PluginDelta,
entries: Vec<(String, String)>,
snap_set: &std::collections::HashSet<String>,
kind: AliasKind| {
let mut entries = entries;
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (name, value) in entries {
if !snap_set.contains(&name) {
delta.aliases.push((name, value, kind));
}
}
};
push_alias(
&mut delta,
self.alias_entries(),
&snap.aliases,
AliasKind::Regular,
);
push_alias(
&mut delta,
self.global_alias_entries(),
&snap.global_aliases,
AliasKind::Global,
);
push_alias(
&mut delta,
self.suffix_alias_entries(),
&snap.suffix_aliases,
AliasKind::Suffix,
);
const NON_REPLAYABLE_VARS: &[&str] = &[
"0",
"_",
"?",
"$",
"!",
"PPID",
"RANDOM",
"SECONDS",
"EPOCHSECONDS",
"EPOCHREALTIME",
"LINENO",
"OLDPWD",
"PWD",
"STATUS",
"OPTIND",
"OPTARG",
"IFS",
"FUNCNAME",
"BASHPID",
"BASH_LINENO",
"BASH_SOURCE",
"ZSH_ARGZERO",
"ZSH_EVAL_CONTEXT",
"ZSH_SUBSHELL",
"HISTCMD",
"MATCH",
"MBEGIN",
"MEND",
];
let mut var_keys: Vec<String> = if let Ok(tab) = crate::ported::params::paramtab().read() {
tab.iter()
.filter(|(_, pm)| pm.u_arr.is_none())
.map(|(k, _)| k.clone())
.collect()
} else {
Vec::new()
};
var_keys.sort();
for name in &var_keys {
if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
continue;
}
let value = crate::ported::params::getsparam(name).unwrap_or_default();
match snap.variables.get(name) {
Some(old) if old == &value => {} _ => {
if env::var(name).ok().as_ref() == Some(&value) {
delta.exports.push((name.clone(), value.clone()));
} else {
delta.variables.push((name.clone(), value.clone()));
}
}
}
}
let arr_entries: Vec<(String, Vec<String>)> =
if let Ok(tab) = crate::ported::params::paramtab().read() {
let mut v: Vec<(String, Vec<String>)> = tab
.iter()
.filter_map(|(k, pm)| pm.u_arr.clone().map(|a| (k.clone(), a)))
.collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
} else {
Vec::new()
};
for (name, values) in arr_entries {
if !snap.arrays.contains(&name) {
delta.arrays.push((name, values));
}
}
let assoc_entries: Vec<(String, indexmap::IndexMap<String, String>)> =
if let Ok(m) = crate::ported::params::paramtab_hashed_storage().lock() {
let mut v: Vec<(String, indexmap::IndexMap<String, String>)> =
m.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
} else {
Vec::new()
};
for (name, map) in assoc_entries {
if !snap.assoc_arrays.contains(&name) {
let plain: HashMap<String, String> =
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
delta.assoc_arrays.push((name, plain));
}
}
for p in &self.fpath {
if !snap.fpath.contains(p) {
delta.fpath_additions.push(p.to_string_lossy().to_string());
}
}
let current = crate::ported::options::opt_state_snapshot();
let mut opt_keys: Vec<&String> = current.keys().collect();
opt_keys.sort();
for name in opt_keys {
let value = current.get(name).unwrap();
match snap.options.get(name) {
Some(old) if old == value => {}
_ => delta.options_changed.push((name.clone(), *value)),
}
}
let names = [
"chpwd",
"precmd",
"preexec",
"periodic",
"zshexit",
"zshaddhistory",
];
let mut hook_names: Vec<&&str> = names.iter().collect();
hook_names.sort();
for &h in hook_names {
let arr_name = format!("{}_functions", h);
let funcs = self.array(&arr_name).unwrap_or_default();
let old_funcs = snap.hooks.get(h);
for f in &funcs {
let is_new = old_funcs.is_none_or(|old| !old.contains(f));
if is_new {
delta.hooks.push((h.to_string(), f.clone()));
}
}
}
let current_autoloads: Vec<String> = crate::ported::hashtable::shfunctab_lock()
.read()
.ok()
.map(|t| {
t.iter()
.filter(|(_, shf)| (shf.node.flags as u32 & PM_UNDEFINED) != 0)
.map(|(name, _)| name.clone())
.collect()
})
.unwrap_or_default();
let mut autoload_keys: Vec<&String> = current_autoloads.iter().collect();
autoload_keys.sort();
for name in autoload_keys {
if !snap.autoloads.contains(name) {
delta.autoloads.push((name.clone(), String::new()));
}
}
delta
}
pub(crate) fn replay_plugin_delta(&mut self, delta: &crate::plugin_cache::PluginDelta) {
for (name, value, kind) in &delta.aliases {
match kind {
AliasKind::Regular => {
self.set_alias(name.clone(), value.clone());
}
AliasKind::Global => {
self.set_global_alias(name.clone(), value.clone());
}
AliasKind::Suffix => {
self.set_suffix_alias(name.clone(), value.clone());
}
}
}
const NON_REPLAYABLE_VARS: &[&str] = &[
"0",
"_",
"?",
"$",
"!",
"PPID",
"RANDOM",
"SECONDS",
"EPOCHSECONDS",
"EPOCHREALTIME",
"LINENO",
"OLDPWD",
"PWD",
"STATUS",
"OPTIND",
"OPTARG",
"IFS",
"FUNCNAME",
"BASHPID",
"BASH_LINENO",
"BASH_SOURCE",
"ZSH_ARGZERO",
"ZSH_EVAL_CONTEXT",
"ZSH_SUBSHELL",
"HISTCMD",
"MATCH",
"MBEGIN",
"MEND",
];
for (name, value) in &delta.variables {
if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
continue;
}
self.set_scalar(name.clone(), value.clone());
}
for (name, value) in &delta.exports {
if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
continue;
}
self.set_scalar(name.clone(), value.clone());
env::set_var(name, value);
}
for (name, values) in &delta.arrays {
self.set_array(name.clone(), values.clone());
}
for (name, map) in &delta.assoc_arrays {
let mut idx_map: indexmap::IndexMap<String, String> =
indexmap::IndexMap::with_capacity(map.len());
let mut entries: Vec<(&String, &String)> = map.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in entries {
idx_map.insert(k.clone(), v.clone());
}
self.set_assoc(name.clone(), idx_map);
}
for p in &delta.fpath_additions {
let pb = PathBuf::from(p);
if !self.fpath.contains(&pb) {
self.fpath.push(pb);
}
}
if !delta.completions.is_empty() {
let mut comps = self.assoc("_comps").unwrap_or_default();
for (cmd, func) in &delta.completions {
comps.insert(cmd.clone(), func.clone());
}
self.set_assoc("_comps".to_string(), comps);
}
for (name, enabled) in &delta.options_changed {
crate::ported::options::opt_state_set(name, *enabled);
}
for (hook, func) in &delta.hooks {
let array_name = format!("{}_functions", hook);
let mut arr = self.array(&array_name).unwrap_or_default();
if !arr.iter().any(|f| f == func) {
arr.push(func.clone());
crate::ported::params::setaparam(&array_name, arr);
}
}
for (name, bytes) in &delta.functions {
let Ok(source) = std::str::from_utf8(bytes) else {
continue;
};
let saved_errflag = errflag.load(Ordering::Relaxed);
errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
crate::ported::parse::parse_init(source);
let program = crate::ported::parse::parse();
let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
errflag.store(saved_errflag, Ordering::Relaxed);
if parse_failed || program.lists.is_empty() {
continue;
}
let chunk = crate::compile_zsh::ZshCompiler::new().compile(&program);
self.functions_compiled.insert(name.clone(), chunk);
self.function_source
.insert(name.clone(), source.to_string());
}
}
}