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(
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    /// Check if a cached entry exists with matching mtime.
184    /// Returns the plugin id if cache is valid, None if miss.
185    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    /// Load cached delta for a plugin by id.
196    pub fn load(&self, plugin_id: i64) -> rusqlite::Result<PluginDelta> {
197        let mut delta = PluginDelta::default();
198
199        // Functions (bincode-serialized AST blobs)
200        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        // Aliases
211        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        // Variables
226        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        // Arrays
246        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            // Simple JSON array: ["a","b","c"]
255            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        // Completions
265        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        // Fpath
276        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        // Hooks
285        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        // Bindkeys
296        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        // Zstyles
311        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        // Options
326        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        // Autoloads
337        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    /// Store a plugin delta. Replaces any existing entry for this path.
351    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        // Delete old entry if exists
365        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        // Functions
375        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        // Aliases
383        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        // Variables + exports
391        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        // Arrays
405        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        // Completions
420        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        // Fpath
428        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        // Hooks
436        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        // Bindkeys
444        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        // Zstyles
452        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        // Options
460        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        // Autoloads
468        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    /// Stats for logging.
479    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    /// Count plugins whose file mtime no longer matches the cache.
492    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, // file deleted
516                    _ => {}
517                }
518            }
519        }
520        count
521    }
522
523    /// Count AST cache entries whose file mtime no longer matches.
524    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    // -----------------------------------------------------------------
556    // Script AST cache — skip lex+parse entirely
557    // -----------------------------------------------------------------
558
559    /// Check if a cached AST exists with matching mtime.
560    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    /// Store a parsed AST for a script file.
569    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    // -----------------------------------------------------------------
591    // compaudit cache — security audit results per fpath directory
592    // -----------------------------------------------------------------
593
594    /// Check if a directory's security audit result is cached and still valid.
595    /// Returns Some(is_secure) if cache hit, None if miss or stale.
596    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    /// Store a compaudit result for a directory.
605    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    /// Run a full compaudit against fpath directories, using cache where valid.
627    /// Returns list of insecure directories (empty = all secure).
628    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, // dir doesn't exist, skip
639            };
640            let mt_s = meta.mtime();
641            let mt_ns = meta.mtime_nsec();
642
643            // Check cache first
644            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            // Cache miss — do the actual security check
652            let mode = meta.mode();
653            let uid = meta.uid();
654            let is_secure = Self::check_dir_security(&meta, euid);
655
656            // Also check parent directory
657            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            // Cache the result
666            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    /// Check if a directory's permissions are secure.
690    /// Insecure = world-writable or group-writable AND not owned by root or EUID.
691    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        // Owned by root or the current user — always OK
697        if uid == 0 || uid == euid {
698            return true;
699        }
700
701        // Not owned by us — check if world/group writable
702        let group_writable = mode & 0o020 != 0;
703        let world_writable = mode & 0o002 != 0;
704
705        !group_writable && !world_writable
706    }
707}
708
709/// Get mtime from file metadata as (secs, nsecs).
710pub 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
716/// Default path for the plugin cache db.
717pub fn default_cache_path() -> PathBuf {
718    dirs::home_dir()
719        .unwrap_or_else(|| PathBuf::from("/tmp"))
720        .join(".cache/zshrs/plugins.db")
721}