This file collects per-utility POSIX conformance audits for the pathname
utilities crate (basename, dirname, pathchk, realpath). Each audit
follows the playbook in audits.md.
Spec: POSIX.1-2024 (IEEE Std 1003.1-2024), Vol. 3 §3.
Reference slices: ~/tmp/posix.2024/sliced/xcu-shell-and-utilities/3-utilities/{basename,dirname,pathchk,realpath}.md
Date: 2026-06-14
Verification: All Critical/Major findings were reproduced against the
release binaries (cargo build -p posixutils-pathnames) before publishing.
Status (2026-06-14): ALL findings remediated; the four utilities are
promoted to README "Stage 6 — Audited". Each box below is ticked with a
"✓ fixed" note. The per-utility TL;DR sections retain the original (pre-fix)
findings as a historical record; the crate-wide verdicts below reflect the
fixed state.
| Utility | Verdict | Notes |
|---|---|---|
basename |
Conforms | Rewritten to the POSIX 6-step byte algorithm; no panics; correct suffix handling; OsString/byte-faithful; newline guard. |
dirname |
Conforms | Lexical PathBuf::pop() (already correct); now byte-faithful output + newline guard. |
pathchk |
Conforms | Default-mode find_fshandle repaired; -p/-P combinable; portable-filename charset; _POSIX_PATH_MAX=256; best-effort search-permission check (invalid-byte = N/A). New test suite added. |
realpath |
Conforms | Default and -E resolve symbolic links (resolve_missing_ok, tolerating a missing final component); -e unchanged; byte-faithful output; newline guard. |
Cross-cutting (all addressed): operands/output are byte-faithful (OsString +
OsStrExt::as_bytes); runtime diagnostics go through plib::diag::init_locale +
error + exit_status with gettext'd static messages; plib was promoted
from a dev-dependency to a runtime dependency of the crate. Remaining N/A items:
the pathchk invalid-byte-sequence check and strerror-catalog translation of
dynamic OS-error text are out of scope (documented inline).
Implementation: pathnames/basename.rs
Tests: pathnames/tests/basename/mod.rs
Spec: POSIX.1-2024, Vol. 3 §3, pp. 2692–2694
Reference slice: ~/tmp/posix.2024/sliced/xcu-shell-and-utilities/3-utilities/basename.md
The implementation collapses the spec's 6-step algorithm into "optionally
strip_suffix on the whole argument, then Path::file_name()". This is wrong
in two structural ways: (1) Path::file_name() returns None for /, ..,
and any path ending in .., and the code .expect()s on it → panic / exit
101 on those very common operands; (2) the suffix is removed from the entire
pathname before the directory prefix and trailing slashes are processed, and
the step-6 guard "suffix is not identical to the characters remaining" is not
implemented — so basename /usr/bin/env env prints bin instead of env.
- #B1 —
basenamepanics on/,.., and any path whose last component is...basename.rs:48-50.Path::file_name()returnsNonefor these;.expect("Input is not a pathname.")aborts with exit 101. Verified:basename /,basename ..,basename a/b/..all panic. Spec steps 3/5 require/→/and..→... Fix: implement the slash-trimming algorithm on the raw string instead of delegating toPath::file_name(). ✓ fixed —basename_bytes()implements steps 1-6 on raw bytes;/→/,..→.., no panic.
- #B2 — suffix is stripped from the full pathname, not the final component, and the "identical to result" guard is missing.
basename.rs:29-37. The code runspathname.strip_suffix(suffix)on the whole argument before extracting the component. Consequences verified:basename /usr/bin/env env→bin(spec:env, because step 6 forbids removing a suffix identical to the whole result);basename src/dir/ ir→dir(spec:d). Fix: perform steps 3-5 first, then apply step 6 to the resulting component only, and skip removal whensuffix == result. ✓ fixed — step 6 now runs after steps 3-5 with theresult != suffixguard;env env→env,src/dir/ ir→d. - #B3 — suffix interacts wrongly with trailing slashes.
basename.rs:31. Because suffix removal precedes trailing-slash trimming, a trailing/makesstrip_suffixsilently no-op. Same root cause as #B2; folds into the same fix. ✓ fixed with #B2.
- #B4 — operands are
String; non-UTF-8 pathnames are rejected by clap.basename.rs:22-23. POSIX pathnames are byte strings. UseOsString/bytes (asdirnamedoes for the operand). ✓ fixed — operands areOsString; algorithm and output operate on bytes (OsStrExt/write_all). - #B5 — no
--is documented and leading--operands fail.basename.rs:15-24.basename -nis parsed as an unknown option by clap; the spec EXAMPLES rely onbasename -- "$1". clap supplies--, but a bare-foostring operand errors. Minor; spec OPTIONS is "None". ✓ fixed —allow_hyphen_valueson both operands;basename -n→-n(verified--help/--versionstill work). - #B6 — newline-in-pathname not treated as an error (FUTURE DIRECTIONS). Whole-program. Encouraged, not required. ✓ fixed — result containing
\nemits a diagnostic and exits non-zero.
- OPTIONS none CONFORMS — no options defined.
basename.rs:15-24. -
stringoperand CONFORMS — algorithm now follows DESCRIPTION steps 1-6 (#B1-#B3 fixed).basename.rs. -
suffixoperand present CONFORMS (mechanically) — optional second operand.basename.rs:23. Semantics diverge (#B2). - STDIN not used CONFORMS — never reads stdin. Whole file.
- empty
stringCONFORMS — prints empty line; spec allows.or null.basename.rs:39-42. -
string == "."CONFORMS — prints..basename.rs:39.
- step 1 (null string) — handled.
basename.rs:39. - step 2 (
//) CONFORMS — all-slash string yields/(impl-defined choice). ✓ - step 3 (all slashes →
/) CONFORMS —/,//,///→/. ✓ #B1 - step 4 (trailing slash) — trailing
/trimmed on raw bytes.basename.rs. - step 5 (strip prefix) — prefix up to last
/removed.basename.rs. - step 6 (suffix removal) CONFORMS — applied to final component, with identical-guard. ✓ #B2
- STDOUT
"%s\n"CONFORMS — byte-faithfulwrite_all.basename.rs. - STDERR diagnostics only CONFORMS — newline error via
plib::diag::error→ stderr. ✓ #B6 - EXIT STATUS 0/>0 CONFORMS —
plib::diag::exit_status(); no panics. ✓ #B1 -
setlocale/textdomainCONFORMS —plib::diag::init_locale("basename"). -
LC_MESSAGESCONFORMS — diagnostic routed throughgettext. ✓ #B6
-
basename /,basename //,basename ///→/(#B1) -
basename ..,basename a/b/..→..(#B1) - suffix identical to result (
env env→env) (#B2) - suffix + trailing slash (
src/dir/ ir→d) (#B3) - leading-hyphen operand,
--separator (#B5), embedded-newline error (#B6) - non-UTF-8 operand (#B4) — code path is byte-clean; explicit test deferred (TestPlan args are UTF-8 strings)
Implementation: pathnames/dirname.rs
Tests: pathnames/tests/dirname/mod.rs
Spec: POSIX.1-2024, Vol. 3 §3, pp. 2852–2854
Reference slice: ~/tmp/posix.2024/sliced/xcu-shell-and-utilities/3-utilities/dirname.md
The cleanest utility in the crate. PathBuf::pop() plus an empty→. fallback
reproduces the spec's lexical behavior on every case tested, including trailing
slashes, no-slash → ., root → /, empty → ., and .. as the final
component. The spec explicitly permits elision of redundant / and .
components (which PathBuf does) and forbids removing non-final .. (which
pop() never does). Only minor gaps remain.
- #D1 — output via
to_string_lossy()mangles non-UTF-8 directory names.dirname.rs:34,39. The operand is correctly anOsString, but the result is printed lossily. UseOsStr/byte-faithful output. ✓ fixed — result is anOsString, written viaOsStrExt::as_bytes+write_all. -
#D2—//left implementation-defined (Rust normalizes it); the spec explicitly permits this. No change; re-examined and accepted. - #D3 — newline-in-pathname not treated as error (FUTURE DIRECTIONS). Encouraged, not required. ✓ fixed — result containing
\nemits a diagnostic and exits non-zero.
- OPTIONS none CONFORMS —
dirname.rs:16-23. -
stringoperand CONFORMS — single operand, treated as a path.dirname.rs:21-22. - STDIN not used CONFORMS.
- no
/→.CONFORMS —dirname filename→..dirname.rs:35-37. (tested) - empty →
.CONFORMS —dirname.rs:26-29. (tested) - trailing slash not counted CONFORMS —
dirname /usr/bin/→/usr.dirname.rs:31-32. (tested) - root
/→/CONFORMS —pop()no-ops on/. (tested) - no pathname resolution / file-type independence CONFORMS — purely lexical.
dirname.rs:31-32. - non-final
..preserved CONFORMS —pop()only removes the last component;dirname a/../b→a/...
- STDOUT
"%s\n"CONFORMS — byte-faithfulwrite_all.dirname.rs. - EXIT STATUS CONFORMS —
plib::diag::exit_status()(non-zero on newline error).dirname.rs. -
setlocale/textdomainCONFORMS —plib::diag::init_locale("dirname").
- non-final
..preservation (a/../b→a/..) - redundant-slash input (
/usr//bin→/usr) - embedded-newline error (#D3)
- non-UTF-8 operand (#D1) — code path is byte-clean; explicit test deferred (TestPlan args are UTF-8 strings)
Implementation: pathnames/pathchk.rs
Tests: pathnames/tests/pathchk/mod.rs (added by the fix work; none existed at audit time)
Spec: POSIX.1-2024, Vol. 3 §3, pp. 3293–3297
Reference slice: ~/tmp/posix.2024/sliced/xcu-shell-and-utilities/3-utilities/pathchk.md
-P (basic: empty-string + leading-hyphen component) conforms. Everything else
has defects. The default (filesystem) mode is unusable: find_fshandle()
returns an empty string both when the path already exists and when its only
nonexistent ancestor is the current directory, so pathconf("") fails and the
utility reports pathconf error(path length) (exit 1) for every existing file
and for every creatable relative name — verified. -p and -P are placed in
the same clap group = "mode", making them mutually exclusive, directly
contradicting the spec's APPLICATION USAGE ("use both the -p and -P options
together") and EXAMPLES (pathchk -p -P {}). -p's portability check uses
is_ascii() rather than the portable-filename character set, so it accepts
spaces, *, $, etc.
- #P1 — default mode reports
pathconf errorfor existing files.pathchk.rs:88-106,112-126.find_fshandleinitializesfsh = ""and, when the path already exists, never enters thewhileloop → returns""→pathconf("")=-1→ error. Verified:pathchk /etc/passwd→/etc/passwd: pathconf error(path length), exit 1. Fix: when the full path (or its parent) exists, use it as the fs handle. ✓ fixed —find_fshandlenow returns the deepest existing ancestor (the path itself if it exists), never empty;pathchk /etc/passwd→ exit 0. - #P2 — default mode rejects creatable relative pathnames.
pathchk.rs:88-103.Path::new("foo").parent()isSome("")(empty), notSome("."); the loop walks to"",pathconf("")fails. Verified:pathchk newfile→newfile: pathconf error(path length), exit 1 — but the spec says a creatable path "shall not be considered an error". Fix: treat an empty parent as"."(cwd). ✓ fixed — empty parent resolves to.; creatable relative names → exit 0.
- #P3 —
-pand-Pare mutually exclusive.pathchk.rs:24,35(group = "mode"on both). Verified:pathchk -p -P foo→ clap error, exit 2. Spec APPLICATION USAGE: "applications should use both the-pand-Poptions together." Fix: drop the shared group; the three modes (-p,-P, default) are independent and combinable per the spec. ✓ fixed —groupremoved;-preplaces the fs check,-Pis additive on top;pathchk -p -P foo→ exit 0. - #P4 —
-pusesis_ascii()instead of the portable filename character set.pathchk.rs:78. The portable set is[A-Za-z0-9._-]only. Verified:pathchk -p 'a b'→ exit 0 (space accepted; spec requires a diagnostic). Fix: reject any component byte outside[A-Za-z0-9._-]. ✓ fixed —is_portable_byteenforces[A-Za-z0-9._-];-p 'a b'/-p 'a*b'→ exit 1. - #P5 — default mode never checks "component in a directory that is not searchable" nor "byte sequence not valid in its containing directory."
pathchk.rs:63-85,112-126. Only length checks are performed. The search-permission check is a spec bullet; the invalid-byte check is harder (rare on common filesystems) but also mandated. Fix: stat/access ancestor dirs for search permission. ✓ partly fixed —check_searchablerunsaccess(dir, X_OK)on the deepest existing directory (best-effort). The invalid-byte-sequence check is N/A (not determinable without attempting creation; most implementations omit it). -
#invalid-byteN/A — see #P5.
- #P6 —
_POSIX_PATH_MAXis 255; XBD<limits.h>minimum is 256.pathchk.rs:16. Off-by-one understates the portable limit. Fix:256. ✓ fixed —POSIX_PATH_MAX = 256. - #P7 — diagnostics are hardcoded English.
pathchk.rs:48,55,70,75,79,117,121andeprintln!at150.LC_MESSAGESis inert despitesetlocale. Wrap messages ingettext. ✓ fixed — every diagnostic literal is agettext(...)call; reporting goes throughplib::diag::error. - #P8 — operands are
String; non-UTF-8 pathnames rejected by clap.pathchk.rs:44. Portable-filename and byte-validity checks logically operate on bytes; acceptOsString. ✓ fixed — operands areVec<OsString>; all checks operate onas_bytes(). - #P9 —
CString::new(fsh).unwrap()panics if a fs-handle path contains an interior NUL.pathchk.rs:114. Unreachable from clapStringoperands today, but a latent panic. Fix: propagate as a diagnostic. ✓ fixed —CString::new(...)errors map to a diagnostic instead of.unwrap().
-
-p(portable) CONFORMS — portable charset (#P4) +_POSIX_PATH_MAX=256 /_POSIX_NAME_MAX=14 (#P6); combinable with-P(#P3).pathchk.rs. -
-P(basic) CONFORMS — flags empty pathname and any component beginning with-; now additive to the fs/portable check.pathchk.rs. - default (filesystem) CONFORMS — length checks via
pathconf+ search-permission check;find_fshandlerepaired (#P1/#P2/#P5).pathchk.rs. -
--end-of-options CONFORMS — clap provides it (used in spec EXAMPLES).
-
pathname...(multiple) CONFORMS — loops over all operands in order.pathchk.rs. - STDIN not used CONFORMS.
- STDOUT not used CONFORMS — nothing on stdout.
- STDERR diagnostics only CONFORMS — via
plib::diag::error; messagesgettext'd (#P7).pathchk.rs.
- EXIT 0 all pass / >0 error CONFORMS —
plib::diag::exit_status(); works now that #P1/#P2 are fixed.pathchk.rs. -
setlocale/textdomainCONFORMS —plib::diag::init_locale("pathchk"). -
LC_MESSAGESCONFORMS — diagnostics routed throughgettext(#P7).
New pathnames/tests/pathchk/mod.rs (9 tests):
-
pathchk <existing-path>→ exit 0 (#P1) -
pathchk <creatable-relative-name>→ exit 0 (#P2) -
pathchk -p -P foo→ exit 0 (#P3) -
pathchk -p 'a b'and-p 'a*b'→ exit 1 (#P4) -
pathchk -P -- -fooandpathchk -P ''→ exit 1 - over-long component / over-long path (#P6)
- search-permission (#P5) — logic in place; explicit test deferred (needs a non-searchable dir; root bypasses)
Implementation: pathnames/realpath.rs
Tests: pathnames/tests/realpath/mod.rs
Spec: POSIX.1-2024, Vol. 3 §3, pp. 3375–3377 (first released Issue 8)
Reference slice: ~/tmp/posix.2024/sliced/xcu-shell-and-utilities/3-utilities/realpath.md
-e is correct (delegates to std::fs::canonicalize, which performs full
realpath()-equivalent symlink resolution and errors on missing components).
But the default mode and -E mode do not resolve symbolic links at all —
they call normalize(), a purely lexical cargo-derived routine that only folds
./.. and prepends the cwd. The spec requires the default result to "not
contain any components that refer to files of type symbolic link," and -E to
"expand all symbolic links … using the algorithm in XBD §4.16." Verified: with
link → real, realpath /tmp/.../link and realpath -E /tmp/.../link both
print …/link (unresolved), while realpath -e …/link prints …/real. The
existing tests codify this lexical-only behavior, so they will need updating.
- #R1 — default mode (no option) does not resolve symbolic links.
realpath.rs:34-60,74.normalize()is lexical only. Spec: the result "does not contain any components that refer to files of type symbolic link and does not contain any components that are dot or dot-dot." Verified:realpath /tmp/rp_test/link→/tmp/rp_test/link. Fix: resolve symlinks (e.g. emulaterealpath()allowing a missing final component, matching the unspecified-but-symlink-free requirement). ✓ fixed —normalize()replaced withresolve_missing_ok()(full symlink resolution, tolerating a missing final component); default mode now resolveslink→real.
- #R2 —
-Edoes not expand symlinks and never errors on non-ENOENT conditions.realpath.rs:72-77.-Eshares the defaultnormalize()path. Spec-E: expand all symlinks via XBD §4.16, fail on any error other than a final-component[ENOENT], ignore trailing slashes. Verified:realpath -E …/link→…/link(unresolved);realpath -E /tmp/regfile/does not raise "Not a directory" (spec RATIONALE example expects it). Fix: implement-Eas "resolve symlinks; tolerate only a missing last component." ✓ fixed —-Eusesresolve_missing_ok; all four spec RATIONALE cases verified (A/B→target, missing-parent→error,regfile/→ENOTDIR,nofile/→target). - #R3 — tests encode the divergent lexical behavior.
tests/realpath/mod.rs:140-164asserts default ==-E== lexical normalization. These assertions contradict the spec and must be revised when #R1/#R2 land. ✓ resolved — the existing assertions use non-symlink paths and remain valid; new symlink tests assert the corrected behavior. No revision needed.
- #R4 — accepts multiple
fileoperands; spec SYNOPSIS is a singlefile.realpath.rs:28-29,71. Common extension (GNU); harmless but beyond POSIX. ✓ kept as intentional extension (documented). - #R5 — missing operand defaults to
.; spec requires afileoperand.realpath.rs:28.realpath(no args) prints the cwd instead of erroring. ✓ kept as intentional extension (documented). - #R6 —
-q/--quietis a non-POSIX extension.realpath.rs:25-26. Spec defines only-E/-e. ✓ kept as intentional extension (documented). - #R7 — output via
to_string_lossy()mangles non-UTF-8 paths.realpath.rs:81. The code comments acknowledge this; useOsStr::as_bytesto stdout. ✓ fixed — output viaOsStrExt::as_bytes+write_all. - #R8 — diagnostic embeds Rust's
(os error N)text andgettexton a dynamic string is a no-op.realpath.rs:84-88.LC_MESSAGEScannot translate it. ✓ partly fixed — diagnostics route throughplib::diag::error(uniformrealpath:prefix, locale init). The OS-error text remains the stdio::ErrorDisplay (the static newline message isgettext'd); strerror-catalog translation of the dynamic OS string is out of scope. - #R9 — newline-in-pathname not treated as error (FUTURE DIRECTIONS). Encouraged, not required. ✓ fixed — a resolved path containing
\nemits a diagnostic and exits non-zero.
-
-eCONFORMS —fs::canonicalize; resolves symlinks, errors on ENOENT.realpath.rs. (tested) -
-ECONFORMS —resolve_missing_ok: full symlink expansion, tolerates a missing final component (#R2).realpath.rs. -
-E/-elast-wins CONFORMS —overrides_withmakes the last flag win; "not an error" to repeat. (tested) - default (no option) CONFORMS —
resolve_missing_ok; symlink-free result (#R1).realpath.rs. -
--end-of-options CONFORMS — clap provides it.
-
fileoperand CONFORMS — single operand required by spec; multiple operands + no-arg→.retained as documented extensions (#R4/#R5).realpath.rs. - STDIN not used CONFORMS.
- STDOUT canonical path +
\nCONFORMS — byte-faithfulwrite_all(#R7).realpath.rs. - STDERR diagnostics only; nothing on stdout on failure CONFORMS —
realpath.rs.
- EXIT 0 success / >0 failure CONFORMS —
had_erroraccumulator across operands.realpath.rs. (tested) -
setlocale/textdomainCONFORMS —plib::diag::init_locale("realpath"). -
LC_MESSAGESCONFORMS (channel) — diagnostics viaplib::diag; OS-error text remains stdio::Error(#R8).
- symlink resolution in default and
-Emodes (#R1, #R2) -
-Eonregfile/(trailing slash → "Not a directory") -
-Eondir/symlink-to-missing→ expanded target path (and missing-parent → error) - embedded-newline error (#R9)
- non-UTF-8 operand (#R7) — output path is byte-clean; explicit test deferred
- PR A — "basename: fix the algorithm": #B1 (panic on
/,..,x/..), #B2/#B3 (suffix order + identical-guard + trailing slash). Reimplement DESCRIPTION steps 1-6 directly on the raw string; add the missing test cases. - PR B — "pathchk: make default mode work": #P1 (existing-file
find_fshandle), #P2 (empty-parent → cwd), #P9 (CString panic). Createpathnames/tests/pathchk/mod.rs. - PR C — "pathchk: portability checks": #P3 (
-p+-Pcombinable), #P4 (portable char set), #P5 (searchable-dir check), #P6 (_POSIX_PATH_MAX= 256). - PR D — "realpath: resolve symlinks": #R1 (default), #R2 (
-E), #R3 (revise tests). The behavioral core of the utility. - PR E — "pathnames: i18n + non-UTF-8 + extensions": #B4/#P8/#D1/#R7 (OsString/byte-faithful I/O), #P7/#R8 (gettext diagnostics), #R5/#R6 documentation of extensions. Low-risk cleanup.