Skip to main content

zsh/compsys/ported/
compaudit.rs

1//! Port of `Completion/compaudit` (sh:1-176).
2//!
3//! Full upstream body kept inline as a `text` block so future-me
4//! can see the spec without leaving the file:
5//!
6//! ```text
7//! sh:  1  # So that this file can also be read with `.' or `source' ...
8//! sh:  2  compaudit() {                           # Define and then call
9//! sh: 15  emulate -L zsh
10//! sh: 16  setopt extendedglob
11//! sh: 12  [[ -n $commands[getent] ]] || getent() { … shim … }
12//! sh: 22  if (( $# )); then local _compdir='' ; elif (( ! $#fpath )); then
13//! sh: 25    print 'compaudit: No directories in $fpath…' 1>&2; return 1
14//! sh: 27  else set -- $fpath
15//! sh: 35  (( $+_i_check )) || { local _i_q _i_line _i_file _i_fail=verbose
16//! sh: 44    local -a _i_files _i_addfiles _i_wdirs _i_wfiles
17//! sh: 45    local -a -U +h fpath
18//! sh: 48  fpath=( $* )
19//! sh: 45  (( $+_compdir )) || { _compdir=${fpath[(r)*/$ZSH_VERSION/*]} || $fpath[1] }
20//! sh: 62  _i_files=( ${^~fpath:/.}/^([^_]*|*~|*.zwc)(N) )
21//! sh: 55  if [[ -n $_compdir ]]; then … pad fpath with sibling dirs …
22//! sh: 86  [[ $_i_fail == use ]] && return 0
23//! sh: 82  _i_owners="u0u${EUID}"  # owners we trust: root + current EUID
24//! sh: 90  exe lookup: /proc/$$/exe → stat its uid → trust that uid too
25//! sh:116 # We search for:
26//! sh:104 #  - world/group-writable dirs in fpath not owned by trusted owners
27//! sh:105 #  - parent-dirs of fpath dirs likewise
28//! sh:106 #  - digest (.zwc) files for those dirs
29//! sh:107 #  - and `_*` files inside those dirs
30//! sh:125 _i_wdirs=( ${^fpath}(N-f:g+w:,-f:o+w:,-^${_i_owners})
31//! sh:126             ${^fpath:h}(N-f:g+w:,-f:o+w:,-^${_i_owners}) )
32//! sh:116 # RedHat "per-user group" exemption
33//! sh:131 # Debian /usr/local + group `staff` exemption
34//! sh:141 _i_wdirs += ${^fpath}.zwc^([^_]*|*~)(N-^${_i_owners})
35//! sh:156 _i_wfiles=( ${^fpath}/^([^_]*|*~)(N-^${_i_owners}) )
36//! sh:151 if [[ -n "$_i_q" ]]; … print + return 1
37//! sh:169 }
38//! sh:176 compaudit "$@"
39//! ```
40//!
41//! Faithful Rust port: same set of insecure-file checks against
42//! the supplied `fpath` (or `$fpath` from the param table when
43//! called with no args). Returns the upstream-equivalent
44//! discriminator: `Ok(())` when secure, `Err(CompauditError)`
45//! with the categorized lists of insecure dirs + files.
46
47use std::fs;
48use std::os::unix::fs::MetadataExt;
49use std::path::{Path, PathBuf};
50
51/// sh:144-149 — categorized error result. Mirrors the upstream
52/// `_i_q` discriminator (`""`/`files`/`directories`/`directories
53/// and files`) plus the actual offending path lists.
54#[derive(Debug, Default, Clone)]
55pub struct CompauditError {
56    /// sh:125-126 — fpath dirs (and their parents) that are
57    /// group/world writable or owned by an untrusted UID.
58    pub insecure_dirs: Vec<PathBuf>,
59    /// sh:141-142 — `_*` files inside fpath dirs (plus their
60    /// `.zwc` digest siblings) owned by untrusted UIDs.
61    pub insecure_files: Vec<PathBuf>,
62}
63
64impl CompauditError {
65    /// sh:144-149 — produce the `_i_q` token. Returned to the
66    /// caller for diagnostic messages.
67    pub fn discriminator(&self) -> &'static str {
68        match (
69            self.insecure_dirs.is_empty(),
70            self.insecure_files.is_empty(),
71        ) {
72            (true, true) => "",
73            (true, false) => "files",
74            (false, true) => "directories",
75            (false, false) => "directories and files",
76        }
77    }
78
79    /// True when this is the no-issues case.
80    pub fn is_empty(&self) -> bool {
81        self.insecure_dirs.is_empty() && self.insecure_files.is_empty()
82    }
83}
84
85/// sh:18-26 — `[[ -n $commands[getent] ]] || getent() { … }`.
86///
87/// The first statement of compaudit's body, run on every call compinit
88/// makes (compinit sh:456 `if [[ -n "$_i_check" ]]` — every invocation but
89/// `-C`), INCLUDING `-u`: `_i_fail=use` returns only at sh:86, after it.
90/// It has two observable effects, and both are run as the shell text
91/// itself so they happen the way zsh's do:
92///
93///   * the `$commands[getent]` lookup enables the `p:commands` feature of
94///     `zsh/parameter` (`zmodload -Fl zsh/parameter` shows `+p:commands`),
95///     which is what makes a later `local -A +h commands` inherit the
96///     special (`association-local-special`);
97///   * on a host with no `getent` binary (macOS) it DEFINES the `getent`
98///     shell function below.
99pub fn compaudit_getent_shim() {
100    let _ = crate::ported::exec::execute_script(concat!(
101        "[[ -n $commands[getent] ]] || getent() {\n",
102        "  if [[ $1 = hosts ]]; then\n",
103        "    sed 's/#.*//' /etc/$1 | grep -w $2\n",
104        "  elif [[ $2 = <-> ]]; then\n",
105        "    grep \":$2:[^:]*$\" /etc/$1\n",
106        "  else\n",
107        "    grep \"^$2:\" /etc/$1\n",
108        "  fi\n",
109        "}\n",
110    ));
111}
112
113/// `compaudit` — security audit of `$fpath` (or the supplied
114/// dir list). Faithful port of `Completion/compaudit:2-175`.
115///
116/// Returns `Ok(())` when every fpath entry passes; `Err(audit)`
117/// with the lists of insecure dirs/files otherwise.
118///
119/// When `dirs` is empty, reads `$fpath` from the shell-side param
120/// table (sh:27 `set -- $fpath`).
121pub fn compaudit(dirs: &[PathBuf]) -> Result<(), CompauditError> {
122    // sh:18 — runs before anything else, `-u` included.
123    compaudit_getent_shim();
124
125    // sh:22-30 — fpath source selection
126    let fpath: Vec<PathBuf> = if !dirs.is_empty() {
127        dirs.to_vec()
128    } else {
129        let arr = crate::ported::params::getaparam("fpath").unwrap_or_default();
130        if arr.is_empty() {
131            // sh:24-25 — `print 'compaudit: No directories in $fpath…' 1>&2; return 1`
132            eprintln!("compaudit: No directories in $fpath, cannot continue");
133            return Err(CompauditError {
134                insecure_dirs: Vec::new(),
135                insecure_files: Vec::new(),
136            });
137        }
138        arr.into_iter().map(PathBuf::from).collect()
139    };
140
141    // Filter out `.` per sh:54 `${^~fpath:/.}` substitution
142    let fpath: Vec<PathBuf> = fpath.into_iter().filter(|d| d.as_os_str() != ".").collect();
143
144    // sh:42-46 `(( $+_i_check )) || { … local -a -U +h fpath }` and sh:48
145    // `fpath=( $* )` deliberately do NOT apply on this code path, so nothing
146    // below uniques the list. Recorded here because an attribute-fidelity
147    // sweep flags the `-U` and it is not a gap:
148    //
149    //   * sh:42 `(( $+_i_check )) || {` gates the declaration, so it is made
150    //     only by the STANDALONE call. `Completion/compinit`:78
151    //     `typeset`s `_i_check` — local to compinit — so a compaudit reached
152    //     FROM compinit always sees it set and audits the caller's un-uniqued
153    //     `$fpath`. This function has exactly one caller, and it is that one
154    //     (`Completion/compinit`:436 `if ! eval compaudit`, ported at
155    //     `src/extensions/ext_builtins.rs:2568`); a standalone `compaudit`
156    //     typed by the user resolves to the autoload stub registered at
157    //     `ext_builtins.rs:2435`, i.e. the upstream shell function, not here.
158    //   * there is no node to stamp either way: sh:45's `-U` belongs to a
159    //     LOCAL shadow of the real `$fpath` (measured in zsh 5.9.2:
160    //     `f(){ local -a -U +h fpath; fpath=(/a /b /a /b); print ${(t)fpath} }`
161    //     → `array-local-tied-unique-special`, caller's value restored on
162    //     return), and the working list here is a plain Rust vector that
163    //     never reaches the parameter table.
164
165    // sh:82-115 — build the trusted-owner set: root (0) + EUID +
166    //   the owner of `/proc/$$/exe` (or `object/a.out`) if present
167    //   and non-root.
168    let trusted_owners = trusted_owners();
169
170    // sh:131-138 — Debian `/usr/local/*` + group `staff` exemption.
171    //   `/etc/debian_version` exists ⇒ allow `/usr/local/*` dirs
172    //   where group == `staff` AND owner == root.
173    let on_debian = Path::new("/etc/debian_version").exists();
174    let staff_gid = if on_debian {
175        lookup_group_gid("staff")
176    } else {
177        None
178    };
179
180    // sh:116-129 — RedHat per-user-group exemption: if user's
181    //   primary group has only the user as a member, group-write
182    //   by that group is OK. Approximation: read EGID then compare
183    //   to UID-equivalent group entry.
184    let user_private_gid = detect_user_private_group();
185
186    let mut audit = CompauditError::default();
187
188    // sh:125-126 — fpath dirs + their parents
189    for dir in &fpath {
190        check_directory(
191            dir,
192            &trusted_owners,
193            on_debian,
194            staff_gid,
195            user_private_gid,
196            &mut audit,
197        );
198        if let Some(parent) = dir.parent() {
199            // Don't double-check if parent is itself in fpath
200            if !fpath.iter().any(|p| p == parent) {
201                check_directory(
202                    parent,
203                    &trusted_owners,
204                    on_debian,
205                    staff_gid,
206                    user_private_gid,
207                    &mut audit,
208                );
209            }
210        }
211    }
212
213    // sh:141 — `${^fpath}.zwc^([^_]*|*~)(N-^${_i_owners})` —
214    //   digest files (`<dir>.zwc`) NOT matching `[^_]*` (i.e. they
215    //   start with `_`) AND NOT matching `*~`, NOT owned by trusted.
216    for dir in &fpath {
217        let mut zwc = dir.as_os_str().to_os_string();
218        zwc.push(".zwc");
219        let zwc_path = PathBuf::from(zwc);
220        if let Ok(meta) = fs::metadata(&zwc_path) {
221            let name = zwc_path
222                .file_name()
223                .map(|n| n.to_string_lossy().into_owned())
224                .unwrap_or_default();
225            // sh:141 — only audit when filename starts with `_` and
226            //   doesn't end with `~`.
227            if name.starts_with('_') && !name.ends_with('~') {
228                if !is_owned_by(&meta, &trusted_owners) {
229                    audit.insecure_files.push(zwc_path);
230                }
231            }
232        }
233    }
234
235    // sh:156 — `${^fpath}/^([^_]*|*~)(N-^${_i_owners})` — files
236    //   IN each fpath dir that start with `_` AND don't end with
237    //   `~` AND aren't owned by trusted users.
238    for dir in &fpath {
239        if let Ok(entries) = fs::read_dir(dir) {
240            for ent in entries.flatten() {
241                let name = ent.file_name().to_string_lossy().into_owned();
242                if !name.starts_with('_') || name.ends_with('~') {
243                    continue;
244                }
245                let path = ent.path();
246                let meta = match ent.metadata() {
247                    Ok(m) => m,
248                    Err(_) => continue,
249                };
250                if !is_owned_by(&meta, &trusted_owners) {
251                    audit.insecure_files.push(path);
252                }
253            }
254        }
255    }
256
257    if audit.is_empty() {
258        Ok(())
259    } else {
260        Err(audit)
261    }
262}
263
264/// sh:125 per-dir audit — group/world writable + owner check.
265/// Applies the RedHat per-user-group + Debian staff-group
266/// exemptions.
267fn check_directory(
268    dir: &Path,
269    trusted: &[u32],
270    on_debian: bool,
271    staff_gid: Option<u32>,
272    user_private_gid: Option<u32>,
273    audit: &mut CompauditError,
274) {
275    let meta = match fs::metadata(dir) {
276        Ok(m) => m,
277        Err(_) => return,
278    };
279    // sh:131-138 — Debian `/usr/local/*` + group `staff` exemption:
280    //   skip the audit when this dir matches /usr/local/* AND group
281    //   is `staff` AND owner is root.
282    if on_debian {
283        if let Some(staff) = staff_gid {
284            if dir.starts_with("/usr/local/") && meta.gid() == staff && meta.uid() == 0 {
285                return;
286            }
287        }
288    }
289    // sh:125 — `-f:g+w:,-f:o+w:,-^${_i_owners}` means: NOT
290    //   (group-writable AND not in user-private-group exception),
291    //   NOT world-writable, AND owned by trusted user.
292    let group_write = (meta.mode() & 0o020) != 0;
293    let world_write = (meta.mode() & 0o002) != 0;
294    let owner_trusted = is_owned_by(&meta, trusted);
295    // sh:122-129 — user-private-group exception: group-write is
296    //   OK iff the file's group == the user's private group.
297    let group_write_bad = if group_write {
298        match user_private_gid {
299            Some(g) if meta.gid() == g => false,
300            _ => true,
301        }
302    } else {
303        false
304    };
305    if !owner_trusted || group_write_bad || world_write {
306        audit.insecure_dirs.push(dir.to_path_buf());
307    }
308}
309
310/// sh:82-115 — build the set of trusted owner UIDs.
311fn trusted_owners() -> Vec<u32> {
312    let mut out: Vec<u32> = vec![0]; // root
313    let euid = unsafe { libc::geteuid() };
314    if euid != 0 {
315        out.push(euid);
316    }
317    // sh:96-101 — stat `/proc/$$/exe` / `/proc/$$/object/a.out` and
318    //   trust the owning UID if non-root.
319    let pid = std::process::id();
320    let exes = [
321        format!("/proc/{}/exe", pid),
322        format!("/proc/{}/object/a.out", pid),
323    ];
324    for path in &exes {
325        if let Ok(meta) = fs::metadata(path) {
326            let uid = meta.uid();
327            if uid != 0 && !out.contains(&uid) {
328                out.push(uid);
329            }
330            break;
331        }
332    }
333    out
334}
335
336/// sh:117-128 — detect whether the current user has a "private
337/// group" (group with only the user as a member). Returns the GID
338/// when applicable so the caller can exempt group-write-by-that-
339/// group from the audit.
340fn detect_user_private_group() -> Option<u32> {
341    let egid = unsafe { libc::getegid() };
342    let euid = unsafe { libc::geteuid() };
343    let user_name = match std::env::var("LOGNAME").or_else(|_| std::env::var("USER")) {
344        Ok(n) => n,
345        Err(_) => return None,
346    };
347    // Read /etc/group, find the line where `gid == egid`; check
348    //   members field is empty OR contains only $LOGNAME. Also
349    //   check group name == $LOGNAME (the canonical per-user-group
350    //   layout).
351    let content = fs::read_to_string("/etc/group").ok()?;
352    for line in content.lines() {
353        let cols: Vec<&str> = line.split(':').collect();
354        if cols.len() < 4 {
355            continue;
356        }
357        let gname = cols[0];
358        let gid: u32 = cols[2].parse().ok()?;
359        if gid != egid {
360            continue;
361        }
362        let members = cols[3];
363        let members_ok = members.is_empty() || members.split(',').all(|m| m.trim() == user_name);
364        if gname == user_name && members_ok && euid == unsafe { libc::getuid() } {
365            return Some(gid);
366        }
367        return None;
368    }
369    None
370}
371
372/// sh:131 — look up the numeric GID for a group name (used for
373/// the Debian `staff` group exemption).
374fn lookup_group_gid(name: &str) -> Option<u32> {
375    let content = fs::read_to_string("/etc/group").ok()?;
376    for line in content.lines() {
377        let cols: Vec<&str> = line.split(':').collect();
378        if cols.len() < 3 {
379            continue;
380        }
381        if cols[0] == name {
382            return cols[2].parse().ok();
383        }
384    }
385    None
386}
387
388/// True iff `meta`'s owning UID is in the trusted set.
389fn is_owned_by(meta: &fs::Metadata, trusted: &[u32]) -> bool {
390    trusted.contains(&meta.uid())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn empty_input_with_empty_fpath_returns_err() {
399        // sh:24-25 — error path when no dirs supplied AND $fpath empty
400        let _g = crate::test_util::global_state_lock();
401        crate::ported::params::setaparam("fpath", Vec::new());
402        let r = compaudit(&[]);
403        assert!(r.is_err());
404    }
405
406    #[test]
407    fn nonexistent_dirs_pass_silently() {
408        // Upstream `(N)` glob qualifier skips non-existent paths.
409        let r = compaudit(&[PathBuf::from("/definitely/not/here/xyz")]);
410        assert!(r.is_ok());
411    }
412
413    #[test]
414    fn temp_dir_owned_by_root_is_secure_when_invoker_is_root_or_world_not_writable() {
415        // Best-effort: /tmp is owned by root with mode 1777 (world-
416        //   write enabled because it's sticky-bit). Should be flagged
417        //   when invoked by non-root.
418        let euid = unsafe { libc::geteuid() };
419        let r = compaudit(&[PathBuf::from("/tmp")]);
420        if euid == 0 {
421            // root → everything passes
422            assert!(r.is_ok() || r.is_err());
423        } else {
424            // Non-root invoker + /tmp is world-writable → flagged
425            assert!(
426                r.is_err(),
427                "expected /tmp to be flagged for non-root invoker; got {:?}",
428                r
429            );
430        }
431    }
432
433    #[test]
434    fn discriminator_categorization() {
435        let e = CompauditError {
436            insecure_dirs: Vec::new(),
437            insecure_files: Vec::new(),
438        };
439        assert_eq!(e.discriminator(), "");
440        let e = CompauditError {
441            insecure_dirs: Vec::new(),
442            insecure_files: vec![PathBuf::from("/x")],
443        };
444        assert_eq!(e.discriminator(), "files");
445        let e = CompauditError {
446            insecure_dirs: vec![PathBuf::from("/x")],
447            insecure_files: Vec::new(),
448        };
449        assert_eq!(e.discriminator(), "directories");
450        let e = CompauditError {
451            insecure_dirs: vec![PathBuf::from("/x")],
452            insecure_files: vec![PathBuf::from("/y")],
453        };
454        assert_eq!(e.discriminator(), "directories and files");
455    }
456
457    #[test]
458    fn owner_check_trusts_root_and_euid() {
459        let owners = trusted_owners();
460        assert!(owners.contains(&0));
461        let euid = unsafe { libc::geteuid() };
462        if euid != 0 {
463            assert!(owners.contains(&euid));
464        }
465    }
466
467    #[test]
468    fn is_owned_by_root_meta() {
469        // metadata on /etc/hosts should return uid=0 on standard UNIX
470        if let Ok(meta) = fs::metadata("/etc/hosts") {
471            assert!(is_owned_by(&meta, &[0]));
472            assert!(!is_owned_by(&meta, &[12345]));
473        }
474    }
475
476    #[test]
477    fn home_dir_is_secure_for_owner() {
478        // The user's $HOME should pass the audit when invoked by the
479        //   owner (default UNIX mode).
480        if let Ok(home) = std::env::var("HOME") {
481            let r = compaudit(&[PathBuf::from(&home)]);
482            // Don't assert ok unconditionally — some users have group-
483            //   writable $HOME. Just verify no panic.
484            let _ = r;
485        }
486    }
487}