Skip to main content

zsh/
plugin_cache.rs

1//! Plugin source cache — stores side effects of `source`/`.` in SQLite.
2//!
3//! First source: execute normally, capture state delta, write cache on worker thread.
4//! Subsequent sources: check mtime, replay cached side effects in microseconds.
5//!
6//! Cache key: (canonical_path, mtime_secs, mtime_nsecs)
7//! Cache invalidation: mtime mismatch → re-source, update cache.
8
9use rusqlite::{params, Connection};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13/// On-disk format version for cached fusevm chunks. Bumped when fusevm's
14/// bincode layout changes in a non-backward-compat way. Cached blobs are
15/// stored as `[VERSION_BYTE, bincode_bytes...]`; readers verify the prefix
16/// and treat any mismatch as a cache miss (the source file is recompiled).
17///
18/// Version history:
19///   0  — fusevm 0.10.0 (Phase F baseline; no version prefix in storage)
20///   1  — fusevm 0.10.1 (Tier C: argv-flatten in Op::Exec/ExecBg/CallFunction
21///        + ShellHost::exec_bg; current)
22///
23/// Bumping is a one-line change. Existing caches transparently rebuild — no
24/// migration code needed because the unwrap function returns None on
25/// mismatch and the caller's "cache miss → compile" path takes over.
26pub const BYTECODE_VERSION: u8 = 1;
27
28/// Wrap raw bincode bytes with the format version prefix. Called by
29/// `store_bytecode` (and any other persisted-chunk writer in the future)
30/// before the INSERT.
31#[inline]
32fn wrap_bytecode(bytes: &[u8]) -> Vec<u8> {
33    let mut out = Vec::with_capacity(bytes.len() + 1);
34    out.push(BYTECODE_VERSION);
35    out.extend_from_slice(bytes);
36    out
37}
38
39/// Strip and verify the format version prefix. Returns `Some(inner_bytes)`
40/// if the prefix matches the current `BYTECODE_VERSION`, `None` otherwise.
41/// `None` triggers cache miss in the caller, which silently recompiles from
42/// source — no warning, no error (the maintainer's "no nag" rule).
43#[inline]
44fn unwrap_bytecode(bytes: &[u8]) -> Option<Vec<u8>> {
45    if bytes.is_empty() || bytes[0] != BYTECODE_VERSION {
46        return None;
47    }
48    Some(bytes[1..].to_vec())
49}
50
51/// Side effects captured from sourcing a plugin file.
52#[derive(Debug, Clone, Default)]
53pub struct PluginDelta {
54    pub functions: Vec<(String, Vec<u8>)>, // name → bincode-serialized bytecode
55    pub aliases: Vec<(String, String, AliasKind)>, // name → value, kind
56    pub global_aliases: Vec<(String, String)>,
57    pub suffix_aliases: Vec<(String, String)>,
58    pub variables: Vec<(String, String)>,
59    pub exports: Vec<(String, String)>, // also set in env
60    pub arrays: Vec<(String, Vec<String>)>,
61    pub assoc_arrays: Vec<(String, HashMap<String, String>)>,
62    pub completions: Vec<(String, String)>, // command → function
63    pub fpath_additions: Vec<String>,
64    pub hooks: Vec<(String, String)>, // hook_name → function
65    pub bindkeys: Vec<(String, String, String)>, // keyseq, widget, keymap
66    pub zstyles: Vec<(String, String, String)>, // pattern, style, value
67    pub options_changed: Vec<(String, bool)>, // option → on/off
68    pub autoloads: Vec<(String, String)>, // function → flags
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum AliasKind {
73    Regular,
74    Global,
75    Suffix,
76}
77
78impl AliasKind {
79    fn as_i32(self) -> i32 {
80        match self {
81            AliasKind::Regular => 0,
82            AliasKind::Global => 1,
83            AliasKind::Suffix => 2,
84        }
85    }
86    fn from_i32(v: i32) -> Self {
87        match v {
88            1 => AliasKind::Global,
89            2 => AliasKind::Suffix,
90            _ => AliasKind::Regular,
91        }
92    }
93}
94
95/// SQLite-backed plugin cache.
96pub struct PluginCache {
97    conn: Connection,
98}
99
100impl PluginCache {
101    pub fn open(path: &Path) -> rusqlite::Result<Self> {
102        let conn = Connection::open(path)?;
103        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
104        let cache = Self { conn };
105        cache.init_schema()?;
106        Ok(cache)
107    }
108
109    fn init_schema(&self) -> rusqlite::Result<()> {
110        self.conn.execute_batch(
111            r#"
112            CREATE TABLE IF NOT EXISTS plugins (
113                id INTEGER PRIMARY KEY,
114                path TEXT NOT NULL UNIQUE,
115                mtime_secs INTEGER NOT NULL,
116                mtime_nsecs INTEGER NOT NULL,
117                source_time_ms INTEGER NOT NULL,
118                cached_at INTEGER NOT NULL
119            );
120
121            CREATE TABLE IF NOT EXISTS plugin_functions (
122                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
123                name TEXT NOT NULL,
124                body BLOB NOT NULL
125            );
126
127            CREATE TABLE IF NOT EXISTS plugin_aliases (
128                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
129                name TEXT NOT NULL,
130                value TEXT NOT NULL,
131                kind INTEGER NOT NULL DEFAULT 0
132            );
133
134            CREATE TABLE IF NOT EXISTS plugin_variables (
135                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
136                name TEXT NOT NULL,
137                value TEXT NOT NULL,
138                is_export INTEGER NOT NULL DEFAULT 0
139            );
140
141            CREATE TABLE IF NOT EXISTS plugin_arrays (
142                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
143                name TEXT NOT NULL,
144                value_json TEXT NOT NULL
145            );
146
147            CREATE TABLE IF NOT EXISTS plugin_completions (
148                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
149                command TEXT NOT NULL,
150                function TEXT NOT NULL
151            );
152
153            CREATE TABLE IF NOT EXISTS plugin_fpath (
154                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
155                path TEXT NOT NULL
156            );
157
158            CREATE TABLE IF NOT EXISTS plugin_hooks (
159                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
160                hook TEXT NOT NULL,
161                function TEXT NOT NULL
162            );
163
164            CREATE TABLE IF NOT EXISTS plugin_bindkeys (
165                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
166                keyseq TEXT NOT NULL,
167                widget TEXT NOT NULL,
168                keymap TEXT NOT NULL DEFAULT 'main'
169            );
170
171            CREATE TABLE IF NOT EXISTS plugin_zstyles (
172                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
173                pattern TEXT NOT NULL,
174                style TEXT NOT NULL,
175                value TEXT NOT NULL
176            );
177
178            CREATE TABLE IF NOT EXISTS plugin_options (
179                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
180                name TEXT NOT NULL,
181                enabled INTEGER NOT NULL
182            );
183
184            CREATE TABLE IF NOT EXISTS plugin_autoloads (
185                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
186                function TEXT NOT NULL,
187                flags TEXT NOT NULL DEFAULT ''
188            );
189
190            -- Bytecode cache: skip lex+parse+compile entirely on cache hit
191            CREATE TABLE IF NOT EXISTS script_bytecode (
192                id INTEGER PRIMARY KEY,
193                path TEXT NOT NULL UNIQUE,
194                mtime_secs INTEGER NOT NULL,
195                mtime_nsecs INTEGER NOT NULL,
196                bytecode BLOB NOT NULL,
197                cached_at INTEGER NOT NULL
198            );
199
200            -- compaudit cache: security audit results per fpath directory
201            CREATE TABLE IF NOT EXISTS compaudit_cache (
202                id INTEGER PRIMARY KEY,
203                path TEXT NOT NULL UNIQUE,
204                mtime_secs INTEGER NOT NULL,
205                mtime_nsecs INTEGER NOT NULL,
206                uid INTEGER NOT NULL,
207                mode INTEGER NOT NULL,
208                is_secure INTEGER NOT NULL,
209                checked_at INTEGER NOT NULL
210            );
211
212            CREATE INDEX IF NOT EXISTS idx_plugins_path ON plugins(path);
213            CREATE INDEX IF NOT EXISTS idx_script_bytecode_path ON script_bytecode(path);
214            CREATE INDEX IF NOT EXISTS idx_compaudit_path ON compaudit_cache(path);
215        "#,
216        )?;
217        Ok(())
218    }
219
220    /// Check if a cached entry exists with matching mtime.
221    /// Returns the plugin id if cache is valid, None if miss.
222    pub fn check(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<i64> {
223        self.conn
224            .query_row(
225                "SELECT id FROM plugins WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
226                params![path, mtime_secs, mtime_nsecs],
227                |row| row.get(0),
228            )
229            .ok()
230    }
231
232    /// Load cached delta for a plugin by id.
233    pub fn load(&self, plugin_id: i64) -> rusqlite::Result<PluginDelta> {
234        let mut delta = PluginDelta::default();
235
236        // Functions (bincode-serialized AST blobs)
237        let mut stmt = self
238            .conn
239            .prepare("SELECT name, body FROM plugin_functions WHERE plugin_id = ?1")?;
240        let rows = stmt.query_map(params![plugin_id], |row| {
241            Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
242        })?;
243        for r in rows {
244            delta.functions.push(r?);
245        }
246
247        // Aliases
248        let mut stmt = self
249            .conn
250            .prepare("SELECT name, value, kind FROM plugin_aliases WHERE plugin_id = ?1")?;
251        let rows = stmt.query_map(params![plugin_id], |row| {
252            Ok((
253                row.get::<_, String>(0)?,
254                row.get::<_, String>(1)?,
255                AliasKind::from_i32(row.get::<_, i32>(2)?),
256            ))
257        })?;
258        for r in rows {
259            delta.aliases.push(r?);
260        }
261
262        // Variables
263        let mut stmt = self
264            .conn
265            .prepare("SELECT name, value, is_export FROM plugin_variables WHERE plugin_id = ?1")?;
266        let rows = stmt.query_map(params![plugin_id], |row| {
267            Ok((
268                row.get::<_, String>(0)?,
269                row.get::<_, String>(1)?,
270                row.get::<_, bool>(2)?,
271            ))
272        })?;
273        for r in rows {
274            let (name, value, is_export) = r?;
275            if is_export {
276                delta.exports.push((name, value));
277            } else {
278                delta.variables.push((name, value));
279            }
280        }
281
282        // Arrays
283        let mut stmt = self
284            .conn
285            .prepare("SELECT name, value_json FROM plugin_arrays WHERE plugin_id = ?1")?;
286        let rows = stmt.query_map(params![plugin_id], |row| {
287            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
288        })?;
289        for r in rows {
290            let (name, json) = r?;
291            // Simple JSON array: ["a","b","c"]
292            let vals: Vec<String> = json
293                .trim_matches(|c| c == '[' || c == ']')
294                .split(',')
295                .map(|s| s.trim().trim_matches('"').to_string())
296                .filter(|s| !s.is_empty())
297                .collect();
298            delta.arrays.push((name, vals));
299        }
300
301        // Completions
302        let mut stmt = self
303            .conn
304            .prepare("SELECT command, function FROM plugin_completions WHERE plugin_id = ?1")?;
305        let rows = stmt.query_map(params![plugin_id], |row| {
306            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
307        })?;
308        for r in rows {
309            delta.completions.push(r?);
310        }
311
312        // Fpath
313        let mut stmt = self
314            .conn
315            .prepare("SELECT path FROM plugin_fpath WHERE plugin_id = ?1")?;
316        let rows = stmt.query_map(params![plugin_id], |row| row.get::<_, String>(0))?;
317        for r in rows {
318            delta.fpath_additions.push(r?);
319        }
320
321        // Hooks
322        let mut stmt = self
323            .conn
324            .prepare("SELECT hook, function FROM plugin_hooks WHERE plugin_id = ?1")?;
325        let rows = stmt.query_map(params![plugin_id], |row| {
326            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
327        })?;
328        for r in rows {
329            delta.hooks.push(r?);
330        }
331
332        // Bindkeys
333        let mut stmt = self
334            .conn
335            .prepare("SELECT keyseq, widget, keymap FROM plugin_bindkeys WHERE plugin_id = ?1")?;
336        let rows = stmt.query_map(params![plugin_id], |row| {
337            Ok((
338                row.get::<_, String>(0)?,
339                row.get::<_, String>(1)?,
340                row.get::<_, String>(2)?,
341            ))
342        })?;
343        for r in rows {
344            delta.bindkeys.push(r?);
345        }
346
347        // Zstyles
348        let mut stmt = self
349            .conn
350            .prepare("SELECT pattern, style, value FROM plugin_zstyles WHERE plugin_id = ?1")?;
351        let rows = stmt.query_map(params![plugin_id], |row| {
352            Ok((
353                row.get::<_, String>(0)?,
354                row.get::<_, String>(1)?,
355                row.get::<_, String>(2)?,
356            ))
357        })?;
358        for r in rows {
359            delta.zstyles.push(r?);
360        }
361
362        // Options
363        let mut stmt = self
364            .conn
365            .prepare("SELECT name, enabled FROM plugin_options WHERE plugin_id = ?1")?;
366        let rows = stmt.query_map(params![plugin_id], |row| {
367            Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?))
368        })?;
369        for r in rows {
370            delta.options_changed.push(r?);
371        }
372
373        // Autoloads
374        let mut stmt = self
375            .conn
376            .prepare("SELECT function, flags FROM plugin_autoloads WHERE plugin_id = ?1")?;
377        let rows = stmt.query_map(params![plugin_id], |row| {
378            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
379        })?;
380        for r in rows {
381            delta.autoloads.push(r?);
382        }
383
384        Ok(delta)
385    }
386
387    /// Store a plugin delta. Replaces any existing entry for this path.
388    pub fn store(
389        &self,
390        path: &str,
391        mtime_secs: i64,
392        mtime_nsecs: i64,
393        source_time_ms: u64,
394        delta: &PluginDelta,
395    ) -> rusqlite::Result<()> {
396        let now = std::time::SystemTime::now()
397            .duration_since(std::time::UNIX_EPOCH)
398            .map(|d| d.as_secs() as i64)
399            .unwrap_or(0);
400
401        // Delete old entry if exists
402        self.conn
403            .execute("DELETE FROM plugins WHERE path = ?1", params![path])?;
404
405        self.conn.execute(
406            "INSERT INTO plugins (path, mtime_secs, mtime_nsecs, source_time_ms, cached_at) VALUES (?1, ?2, ?3, ?4, ?5)",
407            params![path, mtime_secs, mtime_nsecs, source_time_ms as i64, now],
408        )?;
409        let plugin_id = self.conn.last_insert_rowid();
410
411        // Functions
412        for (name, body) in &delta.functions {
413            self.conn.execute(
414                "INSERT INTO plugin_functions (plugin_id, name, body) VALUES (?1, ?2, ?3)",
415                params![plugin_id, name, body],
416            )?;
417        }
418
419        // Aliases
420        for (name, value, kind) in &delta.aliases {
421            self.conn.execute(
422                "INSERT INTO plugin_aliases (plugin_id, name, value, kind) VALUES (?1, ?2, ?3, ?4)",
423                params![plugin_id, name, value, kind.as_i32()],
424            )?;
425        }
426
427        // Variables + exports
428        for (name, value) in &delta.variables {
429            self.conn.execute(
430                "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 0)",
431                params![plugin_id, name, value],
432            )?;
433        }
434        for (name, value) in &delta.exports {
435            self.conn.execute(
436                "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 1)",
437                params![plugin_id, name, value],
438            )?;
439        }
440
441        // Arrays
442        for (name, vals) in &delta.arrays {
443            let json = format!(
444                "[{}]",
445                vals.iter()
446                    .map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
447                    .collect::<Vec<_>>()
448                    .join(",")
449            );
450            self.conn.execute(
451                "INSERT INTO plugin_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
452                params![plugin_id, name, json],
453            )?;
454        }
455
456        // Completions
457        for (cmd, func) in &delta.completions {
458            self.conn.execute(
459                "INSERT INTO plugin_completions (plugin_id, command, function) VALUES (?1, ?2, ?3)",
460                params![plugin_id, cmd, func],
461            )?;
462        }
463
464        // Fpath
465        for p in &delta.fpath_additions {
466            self.conn.execute(
467                "INSERT INTO plugin_fpath (plugin_id, path) VALUES (?1, ?2)",
468                params![plugin_id, p],
469            )?;
470        }
471
472        // Hooks
473        for (hook, func) in &delta.hooks {
474            self.conn.execute(
475                "INSERT INTO plugin_hooks (plugin_id, hook, function) VALUES (?1, ?2, ?3)",
476                params![plugin_id, hook, func],
477            )?;
478        }
479
480        // Bindkeys
481        for (keyseq, widget, keymap) in &delta.bindkeys {
482            self.conn.execute(
483                "INSERT INTO plugin_bindkeys (plugin_id, keyseq, widget, keymap) VALUES (?1, ?2, ?3, ?4)",
484                params![plugin_id, keyseq, widget, keymap],
485            )?;
486        }
487
488        // Zstyles
489        for (pattern, style, value) in &delta.zstyles {
490            self.conn.execute(
491                "INSERT INTO plugin_zstyles (plugin_id, pattern, style, value) VALUES (?1, ?2, ?3, ?4)",
492                params![plugin_id, pattern, style, value],
493            )?;
494        }
495
496        // Options
497        for (name, enabled) in &delta.options_changed {
498            self.conn.execute(
499                "INSERT INTO plugin_options (plugin_id, name, enabled) VALUES (?1, ?2, ?3)",
500                params![plugin_id, name, *enabled],
501            )?;
502        }
503
504        // Autoloads
505        for (func, flags) in &delta.autoloads {
506            self.conn.execute(
507                "INSERT INTO plugin_autoloads (plugin_id, function, flags) VALUES (?1, ?2, ?3)",
508                params![plugin_id, func, flags],
509            )?;
510        }
511
512        Ok(())
513    }
514
515    /// Stats for logging.
516    pub fn stats(&self) -> (i64, i64) {
517        let plugins: i64 = self
518            .conn
519            .query_row("SELECT COUNT(*) FROM plugins", [], |r| r.get(0))
520            .unwrap_or(0);
521        let functions: i64 = self
522            .conn
523            .query_row("SELECT COUNT(*) FROM plugin_functions", [], |r| r.get(0))
524            .unwrap_or(0);
525        (plugins, functions)
526    }
527
528    /// Count plugins whose file mtime no longer matches the cache.
529    pub fn count_stale(&self) -> usize {
530        let mut stmt = match self
531            .conn
532            .prepare("SELECT path, mtime_secs, mtime_nsecs FROM plugins")
533        {
534            Ok(s) => s,
535            Err(_) => return 0,
536        };
537        let rows = match stmt.query_map([], |row| {
538            Ok((
539                row.get::<_, String>(0)?,
540                row.get::<_, i64>(1)?,
541                row.get::<_, i64>(2)?,
542            ))
543        }) {
544            Ok(r) => r,
545            Err(_) => return 0,
546        };
547        let mut count = 0;
548        for row in rows {
549            if let Ok((path, cached_s, cached_ns)) = row {
550                match file_mtime(std::path::Path::new(&path)) {
551                    Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
552                    None => count += 1, // file deleted
553                    _ => {}
554                }
555            }
556        }
557        count
558    }
559
560    /// Count bytecode cache entries whose file mtime no longer matches.
561    pub fn count_stale_bytecode(&self) -> usize {
562        let mut stmt = match self
563            .conn
564            .prepare("SELECT path, mtime_secs, mtime_nsecs FROM script_bytecode")
565        {
566            Ok(s) => s,
567            Err(_) => return 0,
568        };
569        let rows = match stmt.query_map([], |row| {
570            Ok((
571                row.get::<_, String>(0)?,
572                row.get::<_, i64>(1)?,
573                row.get::<_, i64>(2)?,
574            ))
575        }) {
576            Ok(r) => r,
577            Err(_) => return 0,
578        };
579        let mut count = 0;
580        for (path, cached_s, cached_ns) in rows.flatten() {
581            match file_mtime(std::path::Path::new(&path)) {
582                Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
583                None => count += 1,
584                _ => {}
585            }
586        }
587        count
588    }
589
590    // -----------------------------------------------------------------
591    // Script bytecode cache — skip lex+parse+compile entirely
592    // -----------------------------------------------------------------
593
594    /// Check if cached bytecode exists with matching mtime AND a current
595    /// format-version prefix. A prefix mismatch returns None — the caller
596    /// treats this as a cache miss and recompiles from source. No warning is
597    /// printed; the rebuild is silent.
598    pub fn check_bytecode(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<Vec<u8>> {
599        let raw = self.conn.query_row(
600            "SELECT bytecode FROM script_bytecode WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
601            params![path, mtime_secs, mtime_nsecs],
602            |row| row.get::<_, Vec<u8>>(0),
603        ).ok()?;
604        unwrap_bytecode(&raw)
605    }
606
607    /// Store compiled bytecode for a script file. The blob is wrapped with
608    /// the format version prefix so future readers (after a fusevm bump) can
609    /// detect the staleness without parsing the bincode body.
610    pub fn store_bytecode(
611        &self,
612        path: &str,
613        mtime_secs: i64,
614        mtime_nsecs: i64,
615        bytecode: &[u8],
616    ) -> rusqlite::Result<()> {
617        let now = std::time::SystemTime::now()
618            .duration_since(std::time::UNIX_EPOCH)
619            .map(|d| d.as_secs() as i64)
620            .unwrap_or(0);
621
622        let wrapped = wrap_bytecode(bytecode);
623        self.conn
624            .execute("DELETE FROM script_bytecode WHERE path = ?1", params![path])?;
625        self.conn.execute(
626            "INSERT INTO script_bytecode (path, mtime_secs, mtime_nsecs, bytecode, cached_at) VALUES (?1, ?2, ?3, ?4, ?5)",
627            params![path, mtime_secs, mtime_nsecs, wrapped, now],
628        )?;
629        Ok(())
630    }
631
632    // -----------------------------------------------------------------
633    // compaudit cache — security audit results per fpath directory
634    // -----------------------------------------------------------------
635
636    /// Check if a directory's security audit result is cached and still valid.
637    /// Returns Some(is_secure) if cache hit, None if miss or stale.
638    pub fn check_compaudit(&self, dir: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<bool> {
639        self.conn.query_row(
640            "SELECT is_secure FROM compaudit_cache WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
641            params![dir, mtime_secs, mtime_nsecs],
642            |row| row.get::<_, bool>(0),
643        ).ok()
644    }
645
646    /// Store a compaudit result for a directory.
647    pub fn store_compaudit(
648        &self,
649        dir: &str,
650        mtime_secs: i64,
651        mtime_nsecs: i64,
652        uid: u32,
653        mode: u32,
654        is_secure: bool,
655    ) -> rusqlite::Result<()> {
656        let now = std::time::SystemTime::now()
657            .duration_since(std::time::UNIX_EPOCH)
658            .map(|d| d.as_secs() as i64)
659            .unwrap_or(0);
660
661        self.conn.execute(
662            "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)",
663            params![dir, mtime_secs, mtime_nsecs, uid as i64, mode as i64, is_secure, now],
664        )?;
665        Ok(())
666    }
667
668    /// Run a full compaudit against fpath directories, using cache where valid.
669    /// Returns list of insecure directories (empty = all secure).
670    pub fn compaudit_cached(&self, fpath: &[std::path::PathBuf]) -> Vec<String> {
671        use std::os::unix::fs::MetadataExt;
672
673        let euid = unsafe { libc::geteuid() };
674        let mut insecure = Vec::new();
675
676        for dir in fpath {
677            let dir_str = dir.to_string_lossy().to_string();
678            let meta = match std::fs::metadata(dir) {
679                Ok(m) => m,
680                Err(_) => continue, // dir doesn't exist, skip
681            };
682            let mt_s = meta.mtime();
683            let mt_ns = meta.mtime_nsec();
684
685            // Check cache first
686            if let Some(is_secure) = self.check_compaudit(&dir_str, mt_s, mt_ns) {
687                if !is_secure {
688                    insecure.push(dir_str);
689                }
690                continue;
691            }
692
693            // Cache miss — do the actual security check
694            let mode = meta.mode();
695            let uid = meta.uid();
696            let is_secure = Self::check_dir_security(&meta, euid);
697
698            // Also check parent directory
699            let parent_secure = dir
700                .parent()
701                .and_then(|p| std::fs::metadata(p).ok())
702                .map(|pm| Self::check_dir_security(&pm, euid))
703                .unwrap_or(true);
704
705            let secure = is_secure && parent_secure;
706
707            // Cache the result
708            let _ = self.store_compaudit(&dir_str, mt_s, mt_ns, uid, mode, secure);
709
710            if !secure {
711                insecure.push(dir_str);
712            }
713        }
714
715        if insecure.is_empty() {
716            tracing::debug!(
717                dirs = fpath.len(),
718                "compaudit: all directories secure (cached)"
719            );
720        } else {
721            tracing::warn!(
722                insecure_count = insecure.len(),
723                dirs = fpath.len(),
724                "compaudit: insecure directories found"
725            );
726        }
727
728        insecure
729    }
730
731    /// Check if a directory's permissions are secure.
732    /// Insecure = world-writable or group-writable AND not owned by root or EUID.
733    fn check_dir_security(meta: &std::fs::Metadata, euid: u32) -> bool {
734        use std::os::unix::fs::MetadataExt;
735        let mode = meta.mode();
736        let uid = meta.uid();
737
738        // Owned by root or the current user — always OK
739        if uid == 0 || uid == euid {
740            return true;
741        }
742
743        // Not owned by us — check if world/group writable
744        let group_writable = mode & 0o020 != 0;
745        let world_writable = mode & 0o002 != 0;
746
747        !group_writable && !world_writable
748    }
749}
750
751/// Get mtime from file metadata as (secs, nsecs).
752pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
753    use std::os::unix::fs::MetadataExt;
754    let meta = std::fs::metadata(path).ok()?;
755    Some((meta.mtime(), meta.mtime_nsec()))
756}
757
758/// Default path for the plugin cache db.
759pub fn default_cache_path() -> PathBuf {
760    dirs::home_dir()
761        .unwrap_or_else(|| PathBuf::from("/tmp"))
762        .join(".cache/zshrs/plugins.db")
763}
764
765#[cfg(test)]
766mod version_tests {
767    use super::*;
768
769    #[test]
770    fn wrap_unwrap_round_trip() {
771        let raw = b"some-bincode-blob".to_vec();
772        let wrapped = wrap_bytecode(&raw);
773        assert_eq!(wrapped[0], BYTECODE_VERSION);
774        let unwrapped = unwrap_bytecode(&wrapped).expect("matching version unwraps");
775        assert_eq!(unwrapped, raw);
776    }
777
778    #[test]
779    fn unwrap_rejects_old_version() {
780        // Pre-version-byte cache (or a bumped version) should be rejected.
781        // The caller's cache-miss branch then recompiles from source.
782        let mut bogus = vec![0u8]; // version 0 (pre-Tier C)
783        bogus.extend_from_slice(b"old-bincode-blob");
784        assert!(unwrap_bytecode(&bogus).is_none());
785
786        let mut future = vec![BYTECODE_VERSION.wrapping_add(1)];
787        future.extend_from_slice(b"future-bincode-blob");
788        assert!(unwrap_bytecode(&future).is_none());
789    }
790
791    #[test]
792    fn unwrap_rejects_empty_blob() {
793        assert!(unwrap_bytecode(&[]).is_none());
794    }
795
796    #[test]
797    fn store_then_check_round_trips_through_sqlite() {
798        // End-to-end: serialize a tiny chunk-shaped blob, store via the
799        // cache, read it back, confirm it matches. Proves the version byte
800        // is invisible to callers under normal operation.
801        let tmp = tempfile::tempdir().unwrap();
802        let db_path = tmp.path().join("test_cache.db");
803        let cache = PluginCache::open(&db_path).expect("open temp cache");
804
805        let path = "/fake/script.zsh";
806        let blob = b"bincode-bytes-here".to_vec();
807        cache
808            .store_bytecode(path, 12345, 6789, &blob)
809            .expect("store");
810        let got = cache.check_bytecode(path, 12345, 6789).expect("hit");
811        assert_eq!(got, blob);
812    }
813
814    #[test]
815    fn manually_inserted_old_version_invalidates() {
816        // Simulate a pre-Tier-C cache by INSERTing a row with version byte 0.
817        // check_bytecode must return None so the caller falls back to
818        // recompile-from-source.
819        let tmp = tempfile::tempdir().unwrap();
820        let db_path = tmp.path().join("legacy_cache.db");
821        let cache = PluginCache::open(&db_path).expect("open temp cache");
822
823        let mut legacy = vec![0u8]; // wrong version
824        legacy.extend_from_slice(b"would-be-bincode");
825        cache
826            .conn
827            .execute(
828                "INSERT INTO script_bytecode (path, mtime_secs, mtime_nsecs, bytecode, cached_at) VALUES (?1, ?2, ?3, ?4, ?5)",
829                params!["/fake/legacy.zsh", 0i64, 0i64, legacy, 0i64],
830            )
831            .unwrap();
832
833        let result = cache.check_bytecode("/fake/legacy.zsh", 0, 0);
834        assert!(result.is_none(), "legacy bytecode must invalidate");
835    }
836}