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}