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}