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: 9  emulate -L zsh
10//! sh:10  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:37    local -a _i_files _i_addfiles _i_wdirs _i_wfiles
17//! sh:38    local -a -U +h fpath
18//! sh:40  fpath=( $* )
19//! sh:45  (( $+_compdir )) || { _compdir=${fpath[(r)*/$ZSH_VERSION/*]} || $fpath[1] }
20//! sh:54  _i_files=( ${^~fpath:/.}/^([^_]*|*~|*.zwc)(N) )
21//! sh:55  if [[ -n $_compdir ]]; then … pad fpath with sibling dirs …
22//! sh:78  [[ $_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:103 # 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:111 _i_wdirs=( ${^fpath}(N-f:g+w:,-f:o+w:,-^${_i_owners})
31//! sh:112             ${^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:142 _i_wfiles=( ${^fpath}/^([^_]*|*~)(N-^${_i_owners}) )
36//! sh:151 if [[ -n "$_i_q" ]]; … print + return 1
37//! sh:175 }
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:111-112 — 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/// `compaudit` — security audit of `$fpath` (or the supplied
86/// dir list). Faithful port of `Completion/compaudit:2-175`.
87///
88/// Returns `Ok(())` when every fpath entry passes; `Err(audit)`
89/// with the lists of insecure dirs/files otherwise.
90///
91/// When `dirs` is empty, reads `$fpath` from the shell-side param
92/// table (sh:27 `set -- $fpath`).
93pub fn compaudit(dirs: &[PathBuf]) -> Result<(), CompauditError> {
94    // sh:22-30 — fpath source selection
95    let fpath: Vec<PathBuf> = if !dirs.is_empty() {
96        dirs.to_vec()
97    } else {
98        let arr = crate::ported::params::getaparam("fpath").unwrap_or_default();
99        if arr.is_empty() {
100            // sh:24-25 — `print 'compaudit: No directories in $fpath…' 1>&2; return 1`
101            eprintln!("compaudit: No directories in $fpath, cannot continue");
102            return Err(CompauditError {
103                insecure_dirs: Vec::new(),
104                insecure_files: Vec::new(),
105            });
106        }
107        arr.into_iter().map(PathBuf::from).collect()
108    };
109
110    // Filter out `.` per sh:54 `${^~fpath:/.}` substitution
111    let fpath: Vec<PathBuf> = fpath.into_iter().filter(|d| d.as_os_str() != ".").collect();
112
113    // sh:82-115 — build the trusted-owner set: root (0) + EUID +
114    //   the owner of `/proc/$$/exe` (or `object/a.out`) if present
115    //   and non-root.
116    let trusted_owners = trusted_owners();
117
118    // sh:131-138 — Debian `/usr/local/*` + group `staff` exemption.
119    //   `/etc/debian_version` exists ⇒ allow `/usr/local/*` dirs
120    //   where group == `staff` AND owner == root.
121    let on_debian = Path::new("/etc/debian_version").exists();
122    let staff_gid = if on_debian {
123        lookup_group_gid("staff")
124    } else {
125        None
126    };
127
128    // sh:116-129 — RedHat per-user-group exemption: if user's
129    //   primary group has only the user as a member, group-write
130    //   by that group is OK. Approximation: read EGID then compare
131    //   to UID-equivalent group entry.
132    let user_private_gid = detect_user_private_group();
133
134    let mut audit = CompauditError::default();
135
136    // sh:111-112 — fpath dirs + their parents
137    for dir in &fpath {
138        check_directory(
139            dir,
140            &trusted_owners,
141            on_debian,
142            staff_gid,
143            user_private_gid,
144            &mut audit,
145        );
146        if let Some(parent) = dir.parent() {
147            // Don't double-check if parent is itself in fpath
148            if !fpath.iter().any(|p| p == parent) {
149                check_directory(
150                    parent,
151                    &trusted_owners,
152                    on_debian,
153                    staff_gid,
154                    user_private_gid,
155                    &mut audit,
156                );
157            }
158        }
159    }
160
161    // sh:141 — `${^fpath}.zwc^([^_]*|*~)(N-^${_i_owners})` —
162    //   digest files (`<dir>.zwc`) NOT matching `[^_]*` (i.e. they
163    //   start with `_`) AND NOT matching `*~`, NOT owned by trusted.
164    for dir in &fpath {
165        let mut zwc = dir.as_os_str().to_os_string();
166        zwc.push(".zwc");
167        let zwc_path = PathBuf::from(zwc);
168        if let Ok(meta) = fs::metadata(&zwc_path) {
169            let name = zwc_path
170                .file_name()
171                .map(|n| n.to_string_lossy().into_owned())
172                .unwrap_or_default();
173            // sh:141 — only audit when filename starts with `_` and
174            //   doesn't end with `~`.
175            if name.starts_with('_') && !name.ends_with('~') {
176                if !is_owned_by(&meta, &trusted_owners) {
177                    audit.insecure_files.push(zwc_path);
178                }
179            }
180        }
181    }
182
183    // sh:142 — `${^fpath}/^([^_]*|*~)(N-^${_i_owners})` — files
184    //   IN each fpath dir that start with `_` AND don't end with
185    //   `~` AND aren't owned by trusted users.
186    for dir in &fpath {
187        if let Ok(entries) = fs::read_dir(dir) {
188            for ent in entries.flatten() {
189                let name = ent.file_name().to_string_lossy().into_owned();
190                if !name.starts_with('_') || name.ends_with('~') {
191                    continue;
192                }
193                let path = ent.path();
194                let meta = match ent.metadata() {
195                    Ok(m) => m,
196                    Err(_) => continue,
197                };
198                if !is_owned_by(&meta, &trusted_owners) {
199                    audit.insecure_files.push(path);
200                }
201            }
202        }
203    }
204
205    if audit.is_empty() {
206        Ok(())
207    } else {
208        Err(audit)
209    }
210}
211
212/// sh:111 per-dir audit — group/world writable + owner check.
213/// Applies the RedHat per-user-group + Debian staff-group
214/// exemptions.
215fn check_directory(
216    dir: &Path,
217    trusted: &[u32],
218    on_debian: bool,
219    staff_gid: Option<u32>,
220    user_private_gid: Option<u32>,
221    audit: &mut CompauditError,
222) {
223    let meta = match fs::metadata(dir) {
224        Ok(m) => m,
225        Err(_) => return,
226    };
227    // sh:131-138 — Debian `/usr/local/*` + group `staff` exemption:
228    //   skip the audit when this dir matches /usr/local/* AND group
229    //   is `staff` AND owner is root.
230    if on_debian {
231        if let Some(staff) = staff_gid {
232            if dir.starts_with("/usr/local/") && meta.gid() == staff && meta.uid() == 0 {
233                return;
234            }
235        }
236    }
237    // sh:111 — `-f:g+w:,-f:o+w:,-^${_i_owners}` means: NOT
238    //   (group-writable AND not in user-private-group exception),
239    //   NOT world-writable, AND owned by trusted user.
240    let group_write = (meta.mode() & 0o020) != 0;
241    let world_write = (meta.mode() & 0o002) != 0;
242    let owner_trusted = is_owned_by(&meta, trusted);
243    // sh:122-129 — user-private-group exception: group-write is
244    //   OK iff the file's group == the user's private group.
245    let group_write_bad = if group_write {
246        match user_private_gid {
247            Some(g) if meta.gid() == g => false,
248            _ => true,
249        }
250    } else {
251        false
252    };
253    if !owner_trusted || group_write_bad || world_write {
254        audit.insecure_dirs.push(dir.to_path_buf());
255    }
256}
257
258/// sh:82-115 — build the set of trusted owner UIDs.
259fn trusted_owners() -> Vec<u32> {
260    let mut out: Vec<u32> = vec![0]; // root
261    let euid = unsafe { libc::geteuid() };
262    if euid != 0 {
263        out.push(euid);
264    }
265    // sh:96-101 — stat `/proc/$$/exe` / `/proc/$$/object/a.out` and
266    //   trust the owning UID if non-root.
267    let pid = std::process::id();
268    let exes = [
269        format!("/proc/{}/exe", pid),
270        format!("/proc/{}/object/a.out", pid),
271    ];
272    for path in &exes {
273        if let Ok(meta) = fs::metadata(path) {
274            let uid = meta.uid();
275            if uid != 0 && !out.contains(&uid) {
276                out.push(uid);
277            }
278            break;
279        }
280    }
281    out
282}
283
284/// sh:117-128 — detect whether the current user has a "private
285/// group" (group with only the user as a member). Returns the GID
286/// when applicable so the caller can exempt group-write-by-that-
287/// group from the audit.
288fn detect_user_private_group() -> Option<u32> {
289    let egid = unsafe { libc::getegid() };
290    let euid = unsafe { libc::geteuid() };
291    let user_name = match std::env::var("LOGNAME").or_else(|_| std::env::var("USER")) {
292        Ok(n) => n,
293        Err(_) => return None,
294    };
295    // Read /etc/group, find the line where `gid == egid`; check
296    //   members field is empty OR contains only $LOGNAME. Also
297    //   check group name == $LOGNAME (the canonical per-user-group
298    //   layout).
299    let content = fs::read_to_string("/etc/group").ok()?;
300    for line in content.lines() {
301        let cols: Vec<&str> = line.split(':').collect();
302        if cols.len() < 4 {
303            continue;
304        }
305        let gname = cols[0];
306        let gid: u32 = cols[2].parse().ok()?;
307        if gid != egid {
308            continue;
309        }
310        let members = cols[3];
311        let members_ok = members.is_empty() || members.split(',').all(|m| m.trim() == user_name);
312        if gname == user_name && members_ok && euid == unsafe { libc::getuid() } {
313            return Some(gid);
314        }
315        return None;
316    }
317    None
318}
319
320/// sh:131 — look up the numeric GID for a group name (used for
321/// the Debian `staff` group exemption).
322fn lookup_group_gid(name: &str) -> Option<u32> {
323    let content = fs::read_to_string("/etc/group").ok()?;
324    for line in content.lines() {
325        let cols: Vec<&str> = line.split(':').collect();
326        if cols.len() < 3 {
327            continue;
328        }
329        if cols[0] == name {
330            return cols[2].parse().ok();
331        }
332    }
333    None
334}
335
336/// True iff `meta`'s owning UID is in the trusted set.
337fn is_owned_by(meta: &fs::Metadata, trusted: &[u32]) -> bool {
338    trusted.contains(&meta.uid())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn empty_input_with_empty_fpath_returns_err() {
347        // sh:24-25 — error path when no dirs supplied AND $fpath empty
348        let _g = crate::test_util::global_state_lock();
349        crate::ported::params::setaparam("fpath", Vec::new());
350        let r = compaudit(&[]);
351        assert!(r.is_err());
352    }
353
354    #[test]
355    fn nonexistent_dirs_pass_silently() {
356        // Upstream `(N)` glob qualifier skips non-existent paths.
357        let r = compaudit(&[PathBuf::from("/definitely/not/here/xyz")]);
358        assert!(r.is_ok());
359    }
360
361    #[test]
362    fn temp_dir_owned_by_root_is_secure_when_invoker_is_root_or_world_not_writable() {
363        // Best-effort: /tmp is owned by root with mode 1777 (world-
364        //   write enabled because it's sticky-bit). Should be flagged
365        //   when invoked by non-root.
366        let euid = unsafe { libc::geteuid() };
367        let r = compaudit(&[PathBuf::from("/tmp")]);
368        if euid == 0 {
369            // root → everything passes
370            assert!(r.is_ok() || r.is_err());
371        } else {
372            // Non-root invoker + /tmp is world-writable → flagged
373            assert!(
374                r.is_err(),
375                "expected /tmp to be flagged for non-root invoker; got {:?}",
376                r
377            );
378        }
379    }
380
381    #[test]
382    fn discriminator_categorization() {
383        let e = CompauditError {
384            insecure_dirs: Vec::new(),
385            insecure_files: Vec::new(),
386        };
387        assert_eq!(e.discriminator(), "");
388        let e = CompauditError {
389            insecure_dirs: Vec::new(),
390            insecure_files: vec![PathBuf::from("/x")],
391        };
392        assert_eq!(e.discriminator(), "files");
393        let e = CompauditError {
394            insecure_dirs: vec![PathBuf::from("/x")],
395            insecure_files: Vec::new(),
396        };
397        assert_eq!(e.discriminator(), "directories");
398        let e = CompauditError {
399            insecure_dirs: vec![PathBuf::from("/x")],
400            insecure_files: vec![PathBuf::from("/y")],
401        };
402        assert_eq!(e.discriminator(), "directories and files");
403    }
404
405    #[test]
406    fn owner_check_trusts_root_and_euid() {
407        let owners = trusted_owners();
408        assert!(owners.contains(&0));
409        let euid = unsafe { libc::geteuid() };
410        if euid != 0 {
411            assert!(owners.contains(&euid));
412        }
413    }
414
415    #[test]
416    fn is_owned_by_root_meta() {
417        // metadata on /etc/hosts should return uid=0 on standard UNIX
418        if let Ok(meta) = fs::metadata("/etc/hosts") {
419            assert!(is_owned_by(&meta, &[0]));
420            assert!(!is_owned_by(&meta, &[12345]));
421        }
422    }
423
424    #[test]
425    fn home_dir_is_secure_for_owner() {
426        // The user's $HOME should pass the audit when invoked by the
427        //   owner (default UNIX mode).
428        if let Ok(home) = std::env::var("HOME") {
429            let r = compaudit(&[PathBuf::from(&home)]);
430            // Don't assert ok unconditionally — some users have group-
431            //   writable $HOME. Just verify no panic.
432            let _ = r;
433        }
434    }
435}