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 crate::parser::ShellCommand;
10use rusqlite::{params, Connection};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14/// Side effects captured from sourcing a plugin file.
15#[derive(Debug, Clone, Default)]
16pub struct PluginDelta {
17    pub functions: Vec<(String, Vec<u8>)>,         // name → bincode-serialized bytecode
18    pub aliases: Vec<(String, String, AliasKind)>, // name → value, kind
19    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)>,            // also set in env
23    pub arrays: Vec<(String, Vec<String>)>,
24    pub assoc_arrays: Vec<(String, HashMap<String, String>)>,
25    pub completions: Vec<(String, String)>,         // command → function
26    pub fpath_additions: Vec<String>,
27    pub hooks: Vec<(String, String)>,               // hook_name → function
28    pub bindkeys: Vec<(String, String, String)>,    // keyseq, widget, keymap
29    pub zstyles: Vec<(String, String, String)>,     // pattern, style, value
30    pub options_changed: Vec<(String, bool)>,        // option → on/off
31    pub autoloads: Vec<(String, String)>,            // function → flags
32}
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
58/// SQLite-backed plugin cache.
59pub 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(r#"
74            CREATE TABLE IF NOT EXISTS plugins (
75                id INTEGER PRIMARY KEY,
76                path TEXT NOT NULL UNIQUE,
77                mtime_secs INTEGER NOT NULL,
78                mtime_nsecs INTEGER NOT NULL,
79                source_time_ms INTEGER NOT NULL,
80                cached_at INTEGER NOT NULL
81            );
82
83            CREATE TABLE IF NOT EXISTS plugin_functions (
84                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
85                name TEXT NOT NULL,
86                body BLOB NOT NULL
87            );
88
89            CREATE TABLE IF NOT EXISTS plugin_aliases (
90                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
91                name TEXT NOT NULL,
92                value TEXT NOT NULL,
93                kind INTEGER NOT NULL DEFAULT 0
94            );
95
96            CREATE TABLE IF NOT EXISTS plugin_variables (
97                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
98                name TEXT NOT NULL,
99                value TEXT NOT NULL,
100                is_export INTEGER NOT NULL DEFAULT 0
101            );
102
103            CREATE TABLE IF NOT EXISTS plugin_arrays (
104                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
105                name TEXT NOT NULL,
106                value_json TEXT NOT NULL
107            );
108
109            CREATE TABLE IF NOT EXISTS plugin_completions (
110                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
111                command TEXT NOT NULL,
112                function TEXT NOT NULL
113            );
114
115            CREATE TABLE IF NOT EXISTS plugin_fpath (
116                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
117                path TEXT NOT NULL
118            );
119
120            CREATE TABLE IF NOT EXISTS plugin_hooks (
121                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
122                hook TEXT NOT NULL,
123                function TEXT NOT NULL
124            );
125
126            CREATE TABLE IF NOT EXISTS plugin_bindkeys (
127                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
128                keyseq TEXT NOT NULL,
129                widget TEXT NOT NULL,
130                keymap TEXT NOT NULL DEFAULT 'main'
131            );
132
133            CREATE TABLE IF NOT EXISTS plugin_zstyles (
134                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
135                pattern TEXT NOT NULL,
136                style TEXT NOT NULL,
137                value TEXT NOT NULL
138            );
139
140            CREATE TABLE IF NOT EXISTS plugin_options (
141                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
142                name TEXT NOT NULL,
143                enabled INTEGER NOT NULL
144            );
145
146            CREATE TABLE IF NOT EXISTS plugin_autoloads (
147                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
148                function TEXT NOT NULL,
149                flags TEXT NOT NULL DEFAULT ''
150            );
151
152            -- Full parsed AST cache: skip lex+parse entirely on cache hit
153            CREATE TABLE IF NOT EXISTS script_ast (
154                id INTEGER PRIMARY KEY,
155                path TEXT NOT NULL UNIQUE,
156                mtime_secs INTEGER NOT NULL,
157                mtime_nsecs INTEGER NOT NULL,
158                ast BLOB NOT NULL,
159                cached_at INTEGER NOT NULL
160            );
161
162            -- compaudit cache: security audit results per fpath directory
163            CREATE TABLE IF NOT EXISTS compaudit_cache (
164                id INTEGER PRIMARY KEY,
165                path TEXT NOT NULL UNIQUE,
166                mtime_secs INTEGER NOT NULL,
167                mtime_nsecs INTEGER NOT NULL,
168                uid INTEGER NOT NULL,
169                mode INTEGER NOT NULL,
170                is_secure INTEGER NOT NULL,
171                checked_at INTEGER NOT NULL
172            );
173
174            CREATE INDEX IF NOT EXISTS idx_plugins_path ON plugins(path);
175            CREATE INDEX IF NOT EXISTS idx_script_ast_path ON script_ast(path);
176            CREATE INDEX IF NOT EXISTS idx_compaudit_path ON compaudit_cache(path);
177        "#)?;
178        Ok(())
179    }
180
181    /// Check if a cached entry exists with matching mtime.
182    /// Returns the plugin id if cache is valid, None if miss.
183    pub fn check(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<i64> {
184        self.conn.query_row(
185            "SELECT id FROM plugins WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
186            params![path, mtime_secs, mtime_nsecs],
187            |row| row.get(0),
188        ).ok()
189    }
190
191    /// Load cached delta for a plugin by id.
192    pub fn load(&self, plugin_id: i64) -> rusqlite::Result<PluginDelta> {
193        let mut delta = PluginDelta::default();
194
195        // Functions (bincode-serialized AST blobs)
196        let mut stmt = self.conn.prepare(
197            "SELECT name, body FROM plugin_functions WHERE plugin_id = ?1"
198        )?;
199        let rows = stmt.query_map(params![plugin_id], |row| {
200            Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
201        })?;
202        for r in rows { delta.functions.push(r?); }
203
204        // Aliases
205        let mut stmt = self.conn.prepare(
206            "SELECT name, value, kind FROM plugin_aliases WHERE plugin_id = ?1"
207        )?;
208        let rows = stmt.query_map(params![plugin_id], |row| {
209            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, AliasKind::from_i32(row.get::<_, i32>(2)?)))
210        })?;
211        for r in rows { delta.aliases.push(r?); }
212
213        // Variables
214        let mut stmt = self.conn.prepare(
215            "SELECT name, value, is_export FROM plugin_variables WHERE plugin_id = ?1"
216        )?;
217        let rows = stmt.query_map(params![plugin_id], |row| {
218            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, bool>(2)?))
219        })?;
220        for r in rows {
221            let (name, value, is_export) = r?;
222            if is_export {
223                delta.exports.push((name, value));
224            } else {
225                delta.variables.push((name, value));
226            }
227        }
228
229        // Arrays
230        let mut stmt = self.conn.prepare(
231            "SELECT name, value_json FROM plugin_arrays WHERE plugin_id = ?1"
232        )?;
233        let rows = stmt.query_map(params![plugin_id], |row| {
234            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
235        })?;
236        for r in rows {
237            let (name, json) = r?;
238            // Simple JSON array: ["a","b","c"]
239            let vals: Vec<String> = json.trim_matches(|c| c == '[' || c == ']')
240                .split(',')
241                .map(|s| s.trim().trim_matches('"').to_string())
242                .filter(|s| !s.is_empty())
243                .collect();
244            delta.arrays.push((name, vals));
245        }
246
247        // Completions
248        let mut stmt = self.conn.prepare(
249            "SELECT command, function FROM plugin_completions WHERE plugin_id = ?1"
250        )?;
251        let rows = stmt.query_map(params![plugin_id], |row| {
252            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
253        })?;
254        for r in rows { delta.completions.push(r?); }
255
256        // Fpath
257        let mut stmt = self.conn.prepare(
258            "SELECT path FROM plugin_fpath WHERE plugin_id = ?1"
259        )?;
260        let rows = stmt.query_map(params![plugin_id], |row| {
261            row.get::<_, String>(0)
262        })?;
263        for r in rows { delta.fpath_additions.push(r?); }
264
265        // Hooks
266        let mut stmt = self.conn.prepare(
267            "SELECT hook, function FROM plugin_hooks WHERE plugin_id = ?1"
268        )?;
269        let rows = stmt.query_map(params![plugin_id], |row| {
270            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
271        })?;
272        for r in rows { delta.hooks.push(r?); }
273
274        // Bindkeys
275        let mut stmt = self.conn.prepare(
276            "SELECT keyseq, widget, keymap FROM plugin_bindkeys WHERE plugin_id = ?1"
277        )?;
278        let rows = stmt.query_map(params![plugin_id], |row| {
279            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
280        })?;
281        for r in rows { delta.bindkeys.push(r?); }
282
283        // Zstyles
284        let mut stmt = self.conn.prepare(
285            "SELECT pattern, style, value FROM plugin_zstyles WHERE plugin_id = ?1"
286        )?;
287        let rows = stmt.query_map(params![plugin_id], |row| {
288            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
289        })?;
290        for r in rows { delta.zstyles.push(r?); }
291
292        // Options
293        let mut stmt = self.conn.prepare(
294            "SELECT name, enabled FROM plugin_options WHERE plugin_id = ?1"
295        )?;
296        let rows = stmt.query_map(params![plugin_id], |row| {
297            Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?))
298        })?;
299        for r in rows { delta.options_changed.push(r?); }
300
301        // Autoloads
302        let mut stmt = self.conn.prepare(
303            "SELECT function, flags FROM plugin_autoloads WHERE plugin_id = ?1"
304        )?;
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 { delta.autoloads.push(r?); }
309
310        Ok(delta)
311    }
312
313    /// Store a plugin delta. Replaces any existing entry for this path.
314    pub fn store(
315        &self,
316        path: &str,
317        mtime_secs: i64,
318        mtime_nsecs: i64,
319        source_time_ms: u64,
320        delta: &PluginDelta,
321    ) -> rusqlite::Result<()> {
322        let now = std::time::SystemTime::now()
323            .duration_since(std::time::UNIX_EPOCH)
324            .map(|d| d.as_secs() as i64)
325            .unwrap_or(0);
326
327        // Delete old entry if exists
328        self.conn.execute("DELETE FROM plugins WHERE path = ?1", params![path])?;
329
330        self.conn.execute(
331            "INSERT INTO plugins (path, mtime_secs, mtime_nsecs, source_time_ms, cached_at) VALUES (?1, ?2, ?3, ?4, ?5)",
332            params![path, mtime_secs, mtime_nsecs, source_time_ms as i64, now],
333        )?;
334        let plugin_id = self.conn.last_insert_rowid();
335
336        // Functions
337        for (name, body) in &delta.functions {
338            self.conn.execute(
339                "INSERT INTO plugin_functions (plugin_id, name, body) VALUES (?1, ?2, ?3)",
340                params![plugin_id, name, body],
341            )?;
342        }
343
344        // Aliases
345        for (name, value, kind) in &delta.aliases {
346            self.conn.execute(
347                "INSERT INTO plugin_aliases (plugin_id, name, value, kind) VALUES (?1, ?2, ?3, ?4)",
348                params![plugin_id, name, value, kind.as_i32()],
349            )?;
350        }
351
352        // Variables + exports
353        for (name, value) in &delta.variables {
354            self.conn.execute(
355                "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 0)",
356                params![plugin_id, name, value],
357            )?;
358        }
359        for (name, value) in &delta.exports {
360            self.conn.execute(
361                "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 1)",
362                params![plugin_id, name, value],
363            )?;
364        }
365
366        // Arrays
367        for (name, vals) in &delta.arrays {
368            let json = format!("[{}]", vals.iter().map(|v| format!("\"{}\"", v.replace('"', "\\\""))).collect::<Vec<_>>().join(","));
369            self.conn.execute(
370                "INSERT INTO plugin_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
371                params![plugin_id, name, json],
372            )?;
373        }
374
375        // Completions
376        for (cmd, func) in &delta.completions {
377            self.conn.execute(
378                "INSERT INTO plugin_completions (plugin_id, command, function) VALUES (?1, ?2, ?3)",
379                params![plugin_id, cmd, func],
380            )?;
381        }
382
383        // Fpath
384        for p in &delta.fpath_additions {
385            self.conn.execute(
386                "INSERT INTO plugin_fpath (plugin_id, path) VALUES (?1, ?2)",
387                params![plugin_id, p],
388            )?;
389        }
390
391        // Hooks
392        for (hook, func) in &delta.hooks {
393            self.conn.execute(
394                "INSERT INTO plugin_hooks (plugin_id, hook, function) VALUES (?1, ?2, ?3)",
395                params![plugin_id, hook, func],
396            )?;
397        }
398
399        // Bindkeys
400        for (keyseq, widget, keymap) in &delta.bindkeys {
401            self.conn.execute(
402                "INSERT INTO plugin_bindkeys (plugin_id, keyseq, widget, keymap) VALUES (?1, ?2, ?3, ?4)",
403                params![plugin_id, keyseq, widget, keymap],
404            )?;
405        }
406
407        // Zstyles
408        for (pattern, style, value) in &delta.zstyles {
409            self.conn.execute(
410                "INSERT INTO plugin_zstyles (plugin_id, pattern, style, value) VALUES (?1, ?2, ?3, ?4)",
411                params![plugin_id, pattern, style, value],
412            )?;
413        }
414
415        // Options
416        for (name, enabled) in &delta.options_changed {
417            self.conn.execute(
418                "INSERT INTO plugin_options (plugin_id, name, enabled) VALUES (?1, ?2, ?3)",
419                params![plugin_id, name, *enabled],
420            )?;
421        }
422
423        // Autoloads
424        for (func, flags) in &delta.autoloads {
425            self.conn.execute(
426                "INSERT INTO plugin_autoloads (plugin_id, function, flags) VALUES (?1, ?2, ?3)",
427                params![plugin_id, func, flags],
428            )?;
429        }
430
431        Ok(())
432    }
433
434    /// Stats for logging.
435    pub fn stats(&self) -> (i64, i64) {
436        let plugins: i64 = self.conn.query_row(
437            "SELECT COUNT(*) FROM plugins", [], |r| r.get(0)
438        ).unwrap_or(0);
439        let functions: i64 = self.conn.query_row(
440            "SELECT COUNT(*) FROM plugin_functions", [], |r| r.get(0)
441        ).unwrap_or(0);
442        (plugins, functions)
443    }
444
445    /// Count plugins whose file mtime no longer matches the cache.
446    pub fn count_stale(&self) -> usize {
447        let mut stmt = match self.conn.prepare(
448            "SELECT path, mtime_secs, mtime_nsecs FROM plugins"
449        ) {
450            Ok(s) => s,
451            Err(_) => return 0,
452        };
453        let rows = match stmt.query_map([], |row| {
454            Ok((
455                row.get::<_, String>(0)?,
456                row.get::<_, i64>(1)?,
457                row.get::<_, i64>(2)?,
458            ))
459        }) {
460            Ok(r) => r,
461            Err(_) => return 0,
462        };
463        let mut count = 0;
464        for row in rows {
465            if let Ok((path, cached_s, cached_ns)) = row {
466                match file_mtime(std::path::Path::new(&path)) {
467                    Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
468                    None => count += 1, // file deleted
469                    _ => {}
470                }
471            }
472        }
473        count
474    }
475
476    /// Count AST cache entries whose file mtime no longer matches.
477    pub fn count_stale_ast(&self) -> usize {
478        let mut stmt = match self.conn.prepare(
479            "SELECT path, mtime_secs, mtime_nsecs FROM script_ast"
480        ) {
481            Ok(s) => s,
482            Err(_) => return 0,
483        };
484        let rows = match stmt.query_map([], |row| {
485            Ok((
486                row.get::<_, String>(0)?,
487                row.get::<_, i64>(1)?,
488                row.get::<_, i64>(2)?,
489            ))
490        }) {
491            Ok(r) => r,
492            Err(_) => return 0,
493        };
494        let mut count = 0;
495        for row in rows {
496            if let Ok((path, cached_s, cached_ns)) = row {
497                match file_mtime(std::path::Path::new(&path)) {
498                    Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
499                    None => count += 1,
500                    _ => {}
501                }
502            }
503        }
504        count
505    }
506
507    // -----------------------------------------------------------------
508    // Script AST cache — skip lex+parse entirely
509    // -----------------------------------------------------------------
510
511    /// Check if a cached AST exists with matching mtime.
512    pub fn check_ast(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<Vec<u8>> {
513        self.conn.query_row(
514            "SELECT ast FROM script_ast WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
515            params![path, mtime_secs, mtime_nsecs],
516            |row| row.get::<_, Vec<u8>>(0),
517        ).ok()
518    }
519
520    /// Store a parsed AST for a script file.
521    pub fn store_ast(
522        &self,
523        path: &str,
524        mtime_secs: i64,
525        mtime_nsecs: i64,
526        ast_bytes: &[u8],
527    ) -> rusqlite::Result<()> {
528        let now = std::time::SystemTime::now()
529            .duration_since(std::time::UNIX_EPOCH)
530            .map(|d| d.as_secs() as i64)
531            .unwrap_or(0);
532
533        self.conn.execute("DELETE FROM script_ast WHERE path = ?1", params![path])?;
534        self.conn.execute(
535            "INSERT INTO script_ast (path, mtime_secs, mtime_nsecs, ast, cached_at) VALUES (?1, ?2, ?3, ?4, ?5)",
536            params![path, mtime_secs, mtime_nsecs, ast_bytes, now],
537        )?;
538        Ok(())
539    }
540
541    // -----------------------------------------------------------------
542    // compaudit cache — security audit results per fpath directory
543    // -----------------------------------------------------------------
544
545    /// Check if a directory's security audit result is cached and still valid.
546    /// Returns Some(is_secure) if cache hit, None if miss or stale.
547    pub fn check_compaudit(&self, dir: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<bool> {
548        self.conn.query_row(
549            "SELECT is_secure FROM compaudit_cache WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
550            params![dir, mtime_secs, mtime_nsecs],
551            |row| row.get::<_, bool>(0),
552        ).ok()
553    }
554
555    /// Store a compaudit result for a directory.
556    pub fn store_compaudit(
557        &self,
558        dir: &str,
559        mtime_secs: i64,
560        mtime_nsecs: i64,
561        uid: u32,
562        mode: u32,
563        is_secure: bool,
564    ) -> rusqlite::Result<()> {
565        let now = std::time::SystemTime::now()
566            .duration_since(std::time::UNIX_EPOCH)
567            .map(|d| d.as_secs() as i64)
568            .unwrap_or(0);
569
570        self.conn.execute(
571            "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)",
572            params![dir, mtime_secs, mtime_nsecs, uid as i64, mode as i64, is_secure, now],
573        )?;
574        Ok(())
575    }
576
577    /// Run a full compaudit against fpath directories, using cache where valid.
578    /// Returns list of insecure directories (empty = all secure).
579    pub fn compaudit_cached(&self, fpath: &[std::path::PathBuf]) -> Vec<String> {
580        use std::os::unix::fs::MetadataExt;
581
582        let euid = unsafe { libc::geteuid() };
583        let mut insecure = Vec::new();
584
585        for dir in fpath {
586            let dir_str = dir.to_string_lossy().to_string();
587            let meta = match std::fs::metadata(dir) {
588                Ok(m) => m,
589                Err(_) => continue, // dir doesn't exist, skip
590            };
591            let mt_s = meta.mtime();
592            let mt_ns = meta.mtime_nsec();
593
594            // Check cache first
595            if let Some(is_secure) = self.check_compaudit(&dir_str, mt_s, mt_ns) {
596                if !is_secure {
597                    insecure.push(dir_str);
598                }
599                continue;
600            }
601
602            // Cache miss — do the actual security check
603            let mode = meta.mode();
604            let uid = meta.uid();
605            let is_secure = Self::check_dir_security(&meta, euid);
606
607            // Also check parent directory
608            let parent_secure = dir.parent()
609                .and_then(|p| std::fs::metadata(p).ok())
610                .map(|pm| Self::check_dir_security(&pm, euid))
611                .unwrap_or(true);
612
613            let secure = is_secure && parent_secure;
614
615            // Cache the result
616            let _ = self.store_compaudit(&dir_str, mt_s, mt_ns, uid, mode, secure);
617
618            if !secure {
619                insecure.push(dir_str);
620            }
621        }
622
623        if insecure.is_empty() {
624            tracing::debug!(dirs = fpath.len(), "compaudit: all directories secure (cached)");
625        } else {
626            tracing::warn!(
627                insecure_count = insecure.len(),
628                dirs = fpath.len(),
629                "compaudit: insecure directories found"
630            );
631        }
632
633        insecure
634    }
635
636    /// Check if a directory's permissions are secure.
637    /// Insecure = world-writable or group-writable AND not owned by root or EUID.
638    fn check_dir_security(meta: &std::fs::Metadata, euid: u32) -> bool {
639        use std::os::unix::fs::MetadataExt;
640        let mode = meta.mode();
641        let uid = meta.uid();
642
643        // Owned by root or the current user — always OK
644        if uid == 0 || uid == euid {
645            return true;
646        }
647
648        // Not owned by us — check if world/group writable
649        let group_writable = mode & 0o020 != 0;
650        let world_writable = mode & 0o002 != 0;
651
652        !group_writable && !world_writable
653    }
654}
655
656/// Get mtime from file metadata as (secs, nsecs).
657pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
658    use std::os::unix::fs::MetadataExt;
659    let meta = std::fs::metadata(path).ok()?;
660    Some((meta.mtime(), meta.mtime_nsec()))
661}
662
663/// Default path for the plugin cache db.
664pub fn default_cache_path() -> PathBuf {
665    dirs::home_dir()
666        .unwrap_or_else(|| PathBuf::from("/tmp"))
667        .join(".cache/zshrs/plugins.db")
668}