1use crate::parser::ShellCommand;
10use rusqlite::{params, Connection};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Default)]
16pub struct PluginDelta {
17 pub functions: Vec<(String, Vec<u8>)>, pub aliases: Vec<(String, String, AliasKind)>, pub global_aliases: Vec<(String, String)>,
20 pub suffix_aliases: Vec<(String, String)>,
21 pub variables: Vec<(String, String)>,
22 pub exports: Vec<(String, String)>, pub arrays: Vec<(String, Vec<String>)>,
24 pub assoc_arrays: Vec<(String, HashMap<String, String>)>,
25 pub completions: Vec<(String, String)>, pub fpath_additions: Vec<String>,
27 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)>, }
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AliasKind {
36 Regular,
37 Global,
38 Suffix,
39}
40
41impl AliasKind {
42 fn as_i32(self) -> i32 {
43 match self {
44 AliasKind::Regular => 0,
45 AliasKind::Global => 1,
46 AliasKind::Suffix => 2,
47 }
48 }
49 fn from_i32(v: i32) -> Self {
50 match v {
51 1 => AliasKind::Global,
52 2 => AliasKind::Suffix,
53 _ => AliasKind::Regular,
54 }
55 }
56}
57
58pub struct PluginCache {
60 conn: Connection,
61}
62
63impl PluginCache {
64 pub fn open(path: &Path) -> rusqlite::Result<Self> {
65 let conn = Connection::open(path)?;
66 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
67 let cache = Self { conn };
68 cache.init_schema()?;
69 Ok(cache)
70 }
71
72 fn init_schema(&self) -> rusqlite::Result<()> {
73 self.conn.execute_batch(
74 r#"
75 CREATE TABLE IF NOT EXISTS plugins (
76 id INTEGER PRIMARY KEY,
77 path TEXT NOT NULL UNIQUE,
78 mtime_secs INTEGER NOT NULL,
79 mtime_nsecs INTEGER NOT NULL,
80 source_time_ms INTEGER NOT NULL,
81 cached_at INTEGER NOT NULL
82 );
83
84 CREATE TABLE IF NOT EXISTS plugin_functions (
85 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
86 name TEXT NOT NULL,
87 body BLOB NOT NULL
88 );
89
90 CREATE TABLE IF NOT EXISTS plugin_aliases (
91 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
92 name TEXT NOT NULL,
93 value TEXT NOT NULL,
94 kind INTEGER NOT NULL DEFAULT 0
95 );
96
97 CREATE TABLE IF NOT EXISTS plugin_variables (
98 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
99 name TEXT NOT NULL,
100 value TEXT NOT NULL,
101 is_export INTEGER NOT NULL DEFAULT 0
102 );
103
104 CREATE TABLE IF NOT EXISTS plugin_arrays (
105 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
106 name TEXT NOT NULL,
107 value_json TEXT NOT NULL
108 );
109
110 CREATE TABLE IF NOT EXISTS plugin_completions (
111 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
112 command TEXT NOT NULL,
113 function TEXT NOT NULL
114 );
115
116 CREATE TABLE IF NOT EXISTS plugin_fpath (
117 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
118 path TEXT NOT NULL
119 );
120
121 CREATE TABLE IF NOT EXISTS plugin_hooks (
122 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
123 hook TEXT NOT NULL,
124 function TEXT NOT NULL
125 );
126
127 CREATE TABLE IF NOT EXISTS plugin_bindkeys (
128 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
129 keyseq TEXT NOT NULL,
130 widget TEXT NOT NULL,
131 keymap TEXT NOT NULL DEFAULT 'main'
132 );
133
134 CREATE TABLE IF NOT EXISTS plugin_zstyles (
135 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
136 pattern TEXT NOT NULL,
137 style TEXT NOT NULL,
138 value TEXT NOT NULL
139 );
140
141 CREATE TABLE IF NOT EXISTS plugin_options (
142 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
143 name TEXT NOT NULL,
144 enabled INTEGER NOT NULL
145 );
146
147 CREATE TABLE IF NOT EXISTS plugin_autoloads (
148 plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
149 function TEXT NOT NULL,
150 flags TEXT NOT NULL DEFAULT ''
151 );
152
153 -- Full parsed AST cache: skip lex+parse entirely on cache hit
154 CREATE TABLE IF NOT EXISTS script_ast (
155 id INTEGER PRIMARY KEY,
156 path TEXT NOT NULL UNIQUE,
157 mtime_secs INTEGER NOT NULL,
158 mtime_nsecs INTEGER NOT NULL,
159 ast BLOB NOT NULL,
160 cached_at INTEGER NOT NULL
161 );
162
163 -- compaudit cache: security audit results per fpath directory
164 CREATE TABLE IF NOT EXISTS compaudit_cache (
165 id INTEGER PRIMARY KEY,
166 path TEXT NOT NULL UNIQUE,
167 mtime_secs INTEGER NOT NULL,
168 mtime_nsecs INTEGER NOT NULL,
169 uid INTEGER NOT NULL,
170 mode INTEGER NOT NULL,
171 is_secure INTEGER NOT NULL,
172 checked_at INTEGER NOT NULL
173 );
174
175 CREATE INDEX IF NOT EXISTS idx_plugins_path ON plugins(path);
176 CREATE INDEX IF NOT EXISTS idx_script_ast_path ON script_ast(path);
177 CREATE INDEX IF NOT EXISTS idx_compaudit_path ON compaudit_cache(path);
178 "#,
179 )?;
180 Ok(())
181 }
182
183 pub fn check(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<i64> {
186 self.conn
187 .query_row(
188 "SELECT id FROM plugins WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
189 params![path, mtime_secs, mtime_nsecs],
190 |row| row.get(0),
191 )
192 .ok()
193 }
194
195 pub fn load(&self, plugin_id: i64) -> rusqlite::Result<PluginDelta> {
197 let mut delta = PluginDelta::default();
198
199 let mut stmt = self
201 .conn
202 .prepare("SELECT name, body FROM plugin_functions WHERE plugin_id = ?1")?;
203 let rows = stmt.query_map(params![plugin_id], |row| {
204 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
205 })?;
206 for r in rows {
207 delta.functions.push(r?);
208 }
209
210 let mut stmt = self
212 .conn
213 .prepare("SELECT name, value, kind FROM plugin_aliases WHERE plugin_id = ?1")?;
214 let rows = stmt.query_map(params![plugin_id], |row| {
215 Ok((
216 row.get::<_, String>(0)?,
217 row.get::<_, String>(1)?,
218 AliasKind::from_i32(row.get::<_, i32>(2)?),
219 ))
220 })?;
221 for r in rows {
222 delta.aliases.push(r?);
223 }
224
225 let mut stmt = self
227 .conn
228 .prepare("SELECT name, value, is_export FROM plugin_variables WHERE plugin_id = ?1")?;
229 let rows = stmt.query_map(params![plugin_id], |row| {
230 Ok((
231 row.get::<_, String>(0)?,
232 row.get::<_, String>(1)?,
233 row.get::<_, bool>(2)?,
234 ))
235 })?;
236 for r in rows {
237 let (name, value, is_export) = r?;
238 if is_export {
239 delta.exports.push((name, value));
240 } else {
241 delta.variables.push((name, value));
242 }
243 }
244
245 let mut stmt = self
247 .conn
248 .prepare("SELECT name, value_json FROM plugin_arrays WHERE plugin_id = ?1")?;
249 let rows = stmt.query_map(params![plugin_id], |row| {
250 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
251 })?;
252 for r in rows {
253 let (name, json) = r?;
254 let vals: Vec<String> = json
256 .trim_matches(|c| c == '[' || c == ']')
257 .split(',')
258 .map(|s| s.trim().trim_matches('"').to_string())
259 .filter(|s| !s.is_empty())
260 .collect();
261 delta.arrays.push((name, vals));
262 }
263
264 let mut stmt = self
266 .conn
267 .prepare("SELECT command, function FROM plugin_completions WHERE plugin_id = ?1")?;
268 let rows = stmt.query_map(params![plugin_id], |row| {
269 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
270 })?;
271 for r in rows {
272 delta.completions.push(r?);
273 }
274
275 let mut stmt = self
277 .conn
278 .prepare("SELECT path FROM plugin_fpath WHERE plugin_id = ?1")?;
279 let rows = stmt.query_map(params![plugin_id], |row| row.get::<_, String>(0))?;
280 for r in rows {
281 delta.fpath_additions.push(r?);
282 }
283
284 let mut stmt = self
286 .conn
287 .prepare("SELECT hook, function FROM plugin_hooks WHERE plugin_id = ?1")?;
288 let rows = stmt.query_map(params![plugin_id], |row| {
289 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
290 })?;
291 for r in rows {
292 delta.hooks.push(r?);
293 }
294
295 let mut stmt = self
297 .conn
298 .prepare("SELECT keyseq, widget, keymap FROM plugin_bindkeys WHERE plugin_id = ?1")?;
299 let rows = stmt.query_map(params![plugin_id], |row| {
300 Ok((
301 row.get::<_, String>(0)?,
302 row.get::<_, String>(1)?,
303 row.get::<_, String>(2)?,
304 ))
305 })?;
306 for r in rows {
307 delta.bindkeys.push(r?);
308 }
309
310 let mut stmt = self
312 .conn
313 .prepare("SELECT pattern, style, value FROM plugin_zstyles WHERE plugin_id = ?1")?;
314 let rows = stmt.query_map(params![plugin_id], |row| {
315 Ok((
316 row.get::<_, String>(0)?,
317 row.get::<_, String>(1)?,
318 row.get::<_, String>(2)?,
319 ))
320 })?;
321 for r in rows {
322 delta.zstyles.push(r?);
323 }
324
325 let mut stmt = self
327 .conn
328 .prepare("SELECT name, enabled FROM plugin_options WHERE plugin_id = ?1")?;
329 let rows = stmt.query_map(params![plugin_id], |row| {
330 Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?))
331 })?;
332 for r in rows {
333 delta.options_changed.push(r?);
334 }
335
336 let mut stmt = self
338 .conn
339 .prepare("SELECT function, flags FROM plugin_autoloads WHERE plugin_id = ?1")?;
340 let rows = stmt.query_map(params![plugin_id], |row| {
341 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
342 })?;
343 for r in rows {
344 delta.autoloads.push(r?);
345 }
346
347 Ok(delta)
348 }
349
350 pub fn store(
352 &self,
353 path: &str,
354 mtime_secs: i64,
355 mtime_nsecs: i64,
356 source_time_ms: u64,
357 delta: &PluginDelta,
358 ) -> rusqlite::Result<()> {
359 let now = std::time::SystemTime::now()
360 .duration_since(std::time::UNIX_EPOCH)
361 .map(|d| d.as_secs() as i64)
362 .unwrap_or(0);
363
364 self.conn
366 .execute("DELETE FROM plugins WHERE path = ?1", params![path])?;
367
368 self.conn.execute(
369 "INSERT INTO plugins (path, mtime_secs, mtime_nsecs, source_time_ms, cached_at) VALUES (?1, ?2, ?3, ?4, ?5)",
370 params![path, mtime_secs, mtime_nsecs, source_time_ms as i64, now],
371 )?;
372 let plugin_id = self.conn.last_insert_rowid();
373
374 for (name, body) in &delta.functions {
376 self.conn.execute(
377 "INSERT INTO plugin_functions (plugin_id, name, body) VALUES (?1, ?2, ?3)",
378 params![plugin_id, name, body],
379 )?;
380 }
381
382 for (name, value, kind) in &delta.aliases {
384 self.conn.execute(
385 "INSERT INTO plugin_aliases (plugin_id, name, value, kind) VALUES (?1, ?2, ?3, ?4)",
386 params![plugin_id, name, value, kind.as_i32()],
387 )?;
388 }
389
390 for (name, value) in &delta.variables {
392 self.conn.execute(
393 "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 0)",
394 params![plugin_id, name, value],
395 )?;
396 }
397 for (name, value) in &delta.exports {
398 self.conn.execute(
399 "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 1)",
400 params![plugin_id, name, value],
401 )?;
402 }
403
404 for (name, vals) in &delta.arrays {
406 let json = format!(
407 "[{}]",
408 vals.iter()
409 .map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
410 .collect::<Vec<_>>()
411 .join(",")
412 );
413 self.conn.execute(
414 "INSERT INTO plugin_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
415 params![plugin_id, name, json],
416 )?;
417 }
418
419 for (cmd, func) in &delta.completions {
421 self.conn.execute(
422 "INSERT INTO plugin_completions (plugin_id, command, function) VALUES (?1, ?2, ?3)",
423 params![plugin_id, cmd, func],
424 )?;
425 }
426
427 for p in &delta.fpath_additions {
429 self.conn.execute(
430 "INSERT INTO plugin_fpath (plugin_id, path) VALUES (?1, ?2)",
431 params![plugin_id, p],
432 )?;
433 }
434
435 for (hook, func) in &delta.hooks {
437 self.conn.execute(
438 "INSERT INTO plugin_hooks (plugin_id, hook, function) VALUES (?1, ?2, ?3)",
439 params![plugin_id, hook, func],
440 )?;
441 }
442
443 for (keyseq, widget, keymap) in &delta.bindkeys {
445 self.conn.execute(
446 "INSERT INTO plugin_bindkeys (plugin_id, keyseq, widget, keymap) VALUES (?1, ?2, ?3, ?4)",
447 params![plugin_id, keyseq, widget, keymap],
448 )?;
449 }
450
451 for (pattern, style, value) in &delta.zstyles {
453 self.conn.execute(
454 "INSERT INTO plugin_zstyles (plugin_id, pattern, style, value) VALUES (?1, ?2, ?3, ?4)",
455 params![plugin_id, pattern, style, value],
456 )?;
457 }
458
459 for (name, enabled) in &delta.options_changed {
461 self.conn.execute(
462 "INSERT INTO plugin_options (plugin_id, name, enabled) VALUES (?1, ?2, ?3)",
463 params![plugin_id, name, *enabled],
464 )?;
465 }
466
467 for (func, flags) in &delta.autoloads {
469 self.conn.execute(
470 "INSERT INTO plugin_autoloads (plugin_id, function, flags) VALUES (?1, ?2, ?3)",
471 params![plugin_id, func, flags],
472 )?;
473 }
474
475 Ok(())
476 }
477
478 pub fn stats(&self) -> (i64, i64) {
480 let plugins: i64 = self
481 .conn
482 .query_row("SELECT COUNT(*) FROM plugins", [], |r| r.get(0))
483 .unwrap_or(0);
484 let functions: i64 = self
485 .conn
486 .query_row("SELECT COUNT(*) FROM plugin_functions", [], |r| r.get(0))
487 .unwrap_or(0);
488 (plugins, functions)
489 }
490
491 pub fn count_stale(&self) -> usize {
493 let mut stmt = match self
494 .conn
495 .prepare("SELECT path, mtime_secs, mtime_nsecs FROM plugins")
496 {
497 Ok(s) => s,
498 Err(_) => return 0,
499 };
500 let rows = match stmt.query_map([], |row| {
501 Ok((
502 row.get::<_, String>(0)?,
503 row.get::<_, i64>(1)?,
504 row.get::<_, i64>(2)?,
505 ))
506 }) {
507 Ok(r) => r,
508 Err(_) => return 0,
509 };
510 let mut count = 0;
511 for row in rows {
512 if let Ok((path, cached_s, cached_ns)) = row {
513 match file_mtime(std::path::Path::new(&path)) {
514 Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
515 None => count += 1, _ => {}
517 }
518 }
519 }
520 count
521 }
522
523 pub fn count_stale_ast(&self) -> usize {
525 let mut stmt = match self
526 .conn
527 .prepare("SELECT path, mtime_secs, mtime_nsecs FROM script_ast")
528 {
529 Ok(s) => s,
530 Err(_) => return 0,
531 };
532 let rows = match stmt.query_map([], |row| {
533 Ok((
534 row.get::<_, String>(0)?,
535 row.get::<_, i64>(1)?,
536 row.get::<_, i64>(2)?,
537 ))
538 }) {
539 Ok(r) => r,
540 Err(_) => return 0,
541 };
542 let mut count = 0;
543 for row in rows {
544 if let Ok((path, cached_s, cached_ns)) = row {
545 match file_mtime(std::path::Path::new(&path)) {
546 Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
547 None => count += 1,
548 _ => {}
549 }
550 }
551 }
552 count
553 }
554
555 pub fn check_ast(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<Vec<u8>> {
561 self.conn.query_row(
562 "SELECT ast FROM script_ast WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
563 params![path, mtime_secs, mtime_nsecs],
564 |row| row.get::<_, Vec<u8>>(0),
565 ).ok()
566 }
567
568 pub fn store_ast(
570 &self,
571 path: &str,
572 mtime_secs: i64,
573 mtime_nsecs: i64,
574 ast_bytes: &[u8],
575 ) -> rusqlite::Result<()> {
576 let now = std::time::SystemTime::now()
577 .duration_since(std::time::UNIX_EPOCH)
578 .map(|d| d.as_secs() as i64)
579 .unwrap_or(0);
580
581 self.conn
582 .execute("DELETE FROM script_ast WHERE path = ?1", params![path])?;
583 self.conn.execute(
584 "INSERT INTO script_ast (path, mtime_secs, mtime_nsecs, ast, cached_at) VALUES (?1, ?2, ?3, ?4, ?5)",
585 params![path, mtime_secs, mtime_nsecs, ast_bytes, now],
586 )?;
587 Ok(())
588 }
589
590 pub fn check_compaudit(&self, dir: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<bool> {
597 self.conn.query_row(
598 "SELECT is_secure FROM compaudit_cache WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
599 params![dir, mtime_secs, mtime_nsecs],
600 |row| row.get::<_, bool>(0),
601 ).ok()
602 }
603
604 pub fn store_compaudit(
606 &self,
607 dir: &str,
608 mtime_secs: i64,
609 mtime_nsecs: i64,
610 uid: u32,
611 mode: u32,
612 is_secure: bool,
613 ) -> rusqlite::Result<()> {
614 let now = std::time::SystemTime::now()
615 .duration_since(std::time::UNIX_EPOCH)
616 .map(|d| d.as_secs() as i64)
617 .unwrap_or(0);
618
619 self.conn.execute(
620 "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)",
621 params![dir, mtime_secs, mtime_nsecs, uid as i64, mode as i64, is_secure, now],
622 )?;
623 Ok(())
624 }
625
626 pub fn compaudit_cached(&self, fpath: &[std::path::PathBuf]) -> Vec<String> {
629 use std::os::unix::fs::MetadataExt;
630
631 let euid = unsafe { libc::geteuid() };
632 let mut insecure = Vec::new();
633
634 for dir in fpath {
635 let dir_str = dir.to_string_lossy().to_string();
636 let meta = match std::fs::metadata(dir) {
637 Ok(m) => m,
638 Err(_) => continue, };
640 let mt_s = meta.mtime();
641 let mt_ns = meta.mtime_nsec();
642
643 if let Some(is_secure) = self.check_compaudit(&dir_str, mt_s, mt_ns) {
645 if !is_secure {
646 insecure.push(dir_str);
647 }
648 continue;
649 }
650
651 let mode = meta.mode();
653 let uid = meta.uid();
654 let is_secure = Self::check_dir_security(&meta, euid);
655
656 let parent_secure = dir
658 .parent()
659 .and_then(|p| std::fs::metadata(p).ok())
660 .map(|pm| Self::check_dir_security(&pm, euid))
661 .unwrap_or(true);
662
663 let secure = is_secure && parent_secure;
664
665 let _ = self.store_compaudit(&dir_str, mt_s, mt_ns, uid, mode, secure);
667
668 if !secure {
669 insecure.push(dir_str);
670 }
671 }
672
673 if insecure.is_empty() {
674 tracing::debug!(
675 dirs = fpath.len(),
676 "compaudit: all directories secure (cached)"
677 );
678 } else {
679 tracing::warn!(
680 insecure_count = insecure.len(),
681 dirs = fpath.len(),
682 "compaudit: insecure directories found"
683 );
684 }
685
686 insecure
687 }
688
689 fn check_dir_security(meta: &std::fs::Metadata, euid: u32) -> bool {
692 use std::os::unix::fs::MetadataExt;
693 let mode = meta.mode();
694 let uid = meta.uid();
695
696 if uid == 0 || uid == euid {
698 return true;
699 }
700
701 let group_writable = mode & 0o020 != 0;
703 let world_writable = mode & 0o002 != 0;
704
705 !group_writable && !world_writable
706 }
707}
708
709pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
711 use std::os::unix::fs::MetadataExt;
712 let meta = std::fs::metadata(path).ok()?;
713 Some((meta.mtime(), meta.mtime_nsec()))
714}
715
716pub fn default_cache_path() -> PathBuf {
718 dirs::home_dir()
719 .unwrap_or_else(|| PathBuf::from("/tmp"))
720 .join(".cache/zshrs/plugins.db")
721}