From 40e9d4a146a839ba337d484682c4ff22cff8bf90 Mon Sep 17 00:00:00 2001 From: oh4 <7767846+iamteedoh@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:18:07 -0400 Subject: [PATCH] DIR-2: check paths directly and expand them before lookup Paths given to the script were used verbatim, so every spelling other than a bare absolute path was reported as "path does not exist". Normalize each entry first: strip a CRLF tail and surrounding whitespace, remove one pair of quotes, resolve backslash escapes, and expand a leading ~ and any $VAR. Expansion is hand-rolled rather than delegated to eval so an input file can only name files, never run commands. A literal name still wins when it exists, so a file whose name genuinely contains ~, $, a quote, or a backslash is unaffected. The prompt also only accepted a file listing paths, while rejecting the path a user actually wanted to check. Because it validated with -f, a directory that plainly exists was reported as "file not found". Accept either form: a regular file is read as a list, anything else that exists is checked directly. Add --path for the same thing non-interactively, and say what is actually wrong when --file is handed a directory. Give the prompt readline editing with arrow-key recall of earlier entries, and clear HISTFILE so a run never writes back to the user's shell history. Add a test suite covering each spelling an input file can contain, the literal fallback, and the guarantee that command substitution stays inert. The prompt and its history need a tty, so they are covered by a pty harness. --- .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 24 ++- README.md | 72 ++++++- dirPathPerms.sh | 422 +++++++++++++++++++++++++++++++------- tests/interactive_test.py | 151 ++++++++++++++ tests/run_tests.sh | 289 ++++++++++++++++++++++++++ 6 files changed, 867 insertions(+), 94 deletions(-) create mode 100755 tests/interactive_test.py create mode 100755 tests/run_tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741c697..c0040cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: Check Bash syntax run: git ls-files '*.sh' | xargs -n1 bash -n + - name: Run the test suite + run: ./tests/run_tests.sh + secrets: name: Secret scan runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c53fb22..f8945f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,10 +12,13 @@ validation, and the pull request process. ## Prerequisites -- Bash (the script targets `#!/bin/bash`) -- GNU coreutils `stat` (`stat -c '%A'`) to run the script itself — standard on - Linux; on macOS install coreutils or run inside a Linux environment +- Bash (the script targets `#!/usr/bin/env bash` and runs on the Bash 3.2 that + ships with macOS, so avoid Bash 4+ only syntax) +- `stat` — the script auto-detects the GNU (`stat -c '%A'`) and BSD/macOS + (`stat -f '%Sp'`) variants, so no coreutils install is needed on macOS - `shellcheck` +- `python3` — only to run the interactive (pty) tests; they are skipped without + it, and the rest of the suite is pure Bash - gitleaks 8.30.1 or newer ## Set up from a clean clone @@ -37,16 +40,23 @@ Run the same checks that protect `main`: ```bash git ls-files '*.sh' | xargs shellcheck git ls-files '*.sh' | xargs -n1 bash -n +./tests/run_tests.sh gitleaks git . --config .gitleaks.toml --redact --no-banner ``` -When changing script behavior, exercise the interactive flow locally against a -small sample paths file and confirm both the `YES`/`NO` output and the -missing-path handling still work. +`tests/run_tests.sh` drives the real CLI against generated fixtures and asserts +the result for each path spelling an input file can contain. Add a case there +for any change to how a path is read or resolved. + +It also invokes `tests/interactive_test.py`, which attaches the script to a +pseudo-terminal to cover the prompt and its arrow-key history. Those behaviors +only exist when a tty is attached, so they cannot be tested by piping input. ## Project layout -- `dirPathPerms.sh` — the interactive permission checker script +- `dirPathPerms.sh` — the permission checker script +- `tests/run_tests.sh` — black-box test suite for the CLI +- `tests/interactive_test.py` — pty tests for the interactive prompt - `.github/workflows/` — source validation and source-only release automation ## Pull request process diff --git a/README.md b/README.md index 544a885..54344c2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ## Overview -This Bash script checks specific file or directory permissions (Owner, Group, or Other; Read, Write, or Execute) for a list of paths provided in an input file. It provides clear, color-coded output indicating whether the specified permission is set for each path. +This Bash script checks specific file or directory permissions (Owner, Group, or Other; Read, Write, or Execute) for the paths you give it — either named directly, or listed one per line in an input file. It provides clear, color-coded output indicating whether the specified permission is set for each path. It greets you with a big title banner and a short description, then runs either **interactively** — prompting for the input file and the permission to check — @@ -36,8 +36,12 @@ This script is useful for: * **Title banner:** prints a big ASCII title and a one-line description each run. * **Two run modes:** fully interactive prompts, or non-interactive with flags - (`--file`, `--who`, `--perm`) for scripting and CI. + (`--path`/`--file`, `--who`, `--perm`) for scripting and CI. +* **Check a path directly** with `--path` (repeatable) or a bare argument — no + need to author a list file just to check one directory. * Checks permissions for files and directories listed in a specified input file. +* **Command-line history:** the interactive prompt supports arrow-key recall and + full line editing, so a typo does not mean retyping the whole path. * Allows checking for Owner (`u`), Group (`g`), or Other (`o`) permissions, or **all three at once** with `--all` (a compact permission matrix). * Allows checking for Read (`r`), Write (`w`), or Execute (`x`) permissions. @@ -50,7 +54,11 @@ This script is useful for: * Displays the full permission string (e.g., `owner rwx | group r-x | other r--`) for context in the output. * **Comment & blank-line support:** lines in the input file that are empty or - start with `#` are skipped. + start with `#` are skipped, including indented ones. +* **Forgiving path input:** a leading `~`, a `$VAR`, surrounding quotes, + backslash-escaped spaces, stray indentation, and Windows (CRLF) line endings + are all handled, so a path pasted out of Finder or a terminal just works. + Paths are only ever expanded, never executed. * Gracefully handles and reports paths listed in the input file that do not exist. * **Cross-platform:** works with both GNU (`stat -c`) and BSD/macOS (`stat -f`) `stat`, so no GNU coreutils install is required on macOS. @@ -77,9 +85,9 @@ This script is useful for: ## How to Run -Create an input file containing the list of absolute paths you want to check -(see [Input File Format](#input-file-format) below), then run the script in -whichever mode suits you. +You can either name the paths you want to check directly, or put them in an +input file (see [Input File Format](#input-file-format) below) when you have +many to audit at once. ### Interactive @@ -89,10 +97,12 @@ whichever mode suits you. Follow the prompts: -* Enter the path to your input file when prompted. -* Choose whether to check permissions for Owner (`O`), Group (`G`), Other (`E`), - or All (`A`). -* Choose whether to check for Read (`R`), Write (`W`), or Execute (`X`) permission. +* Enter **either** a path to check (`~/Documents`) **or** a file listing paths + to check (`myPaths.txt`). A directory is checked directly; a regular file is + read as a list of paths. +* Use the **up and down arrows** to recall anything you have already typed this + session, and the usual line-editing keys to fix a typo. +* Choose whose permissions to check, and which permission to look for. The script processes each path and prints the results to the console. @@ -102,16 +112,29 @@ Supply the values as flags and the script runs without prompting — ideal for scripts, cron jobs, and CI: ```bash +# Check a single path directly +./dirPathPerms.sh --path ~/Documents --who owner --perm read + +# A bare path works the same way +./dirPathPerms.sh -w owner -p r ~/Documents + +# Several paths at once +./dirPathPerms.sh -P /etc/passwd -P /var/log --who group --perm write + # Does the group have write access to every listed path? ./dirPathPerms.sh --file paths.txt --who group --perm write # Show read access for owner/group/other across all paths, colors off ./dirPathPerms.sh --all --perm read --file paths.txt --no-color -# The file can also be passed positionally +# A list file can also be passed positionally ./dirPathPerms.sh -w owner -p x paths.txt ``` +A bare argument is read as a **list of paths** when it is a regular file, and +**checked directly** when it is a directory. Use `--path` or `--file` when you +want to be explicit. + If some (but not all) values are provided, the script prompts for the rest when a terminal is attached, or exits with a helpful error when one is not. @@ -119,6 +142,7 @@ a terminal is attached, or exits with a helpful error when one is not. | Option | Description | |---|---| +| `-P`, `--path PATH` | Check `PATH` directly. Repeatable. Use instead of `--file` to check one or more paths without writing a list file. | | `-f`, `--file FILE` | Input file: one absolute path per line. Blank lines and `#` comments are ignored. | | `-w`, `--who WHO` | Whose permission to check: `owner`\|`group`\|`other` (aliases `u`\|`g`\|`o`). | | `-p`, `--perm PERM` | Permission to check: `read`\|`write`\|`execute` (aliases `r`\|`w`\|`x`). | @@ -134,6 +158,28 @@ The input file should be a plain text file where **each line contains exactly on * **Absolute paths are required** to ensure the script can find the files/directories regardless of where the script itself is executed from. * **Blank lines and lines starting with `#` are ignored**, so you can annotate and space out the file freely. +### How a path line is read + +Paths get written by hand and pasted out of file managers, so each line is +tidied up before it is looked up. In order: + +| Written in the file | Checked as | +|---|---| +| ` /var/log ` | `/var/log` — surrounding whitespace is trimmed | +| `/var/log` + `CRLF` | `/var/log` — a Windows line ending is stripped | +| `"/my file.txt"` or `'/my file.txt'` | `/my file.txt` — one surrounding pair of quotes is removed | +| `/my\ file.txt` | `/my file.txt` — backslash escapes are resolved (this is what dragging a file from Finder into a terminal produces) | +| `~/Documents` | `/Users/you/Documents` — a leading `~` expands to your home directory | +| `~alice/Documents` | `alice`'s home directory | +| `$HOME/Documents`, `${HOME}/Documents` | environment variables are expanded | + +Two guarantees worth knowing: + +* **Nothing is ever executed.** `$(...)` and backticks are left as literal text, + so an input file can only ever name files — it is data, never a script. +* **A literal name always wins.** If a file's name genuinely contains a `~`, + `$`, quote, or backslash, it is still checked exactly as written. + **Example Input File (`myPaths.txt`):** ```text @@ -142,6 +188,10 @@ The input file should be a plain text file where **each line contains exactly on /home/user/important_script.sh /var/log/app.log +# your own files — ~ and $VAR work too +~/Documents/notes.txt +$HOME/.ssh/id_ed25519 + /tmp /non/existent/path /data/shared_folder diff --git a/dirPathPerms.sh b/dirPathPerms.sh index a6adcaa..2cc2b49 100755 --- a/dirPathPerms.sh +++ b/dirPathPerms.sh @@ -6,7 +6,8 @@ ## Date Created: 2026-07-13 ## Description: Interactive and non-interactive checker that reports whether a ## chosen permission (read/write/execute) is set for the owner, -## group, or other on every path listed in an input file. +## group, or other on the paths it is given — either named +## directly or listed one per line in an input file. set -uo pipefail @@ -20,11 +21,16 @@ USE_COLOR="auto" setup_colors() { if [[ "$USE_COLOR" == "no" || -n "${NO_COLOR:-}" || ! -t 1 ]]; then BOLD="" DIM="" RED="" GREEN="" YELLOW="" CYAN="" MAGENTA="" RESET="" + RL_S="" RL_E="" else BOLD=$'\e[1m' DIM=$'\e[2m' RED=$'\e[1;31m' GREEN=$'\e[1;32m' YELLOW=$'\e[1;33m' MAGENTA=$'\e[1;35m' CYAN=$'\e[1;36m' RESET=$'\e[0m' + # Readline's non-printing markers. A `read -e` prompt must wrap its escape + # sequences in these or readline counts them as visible characters and puts + # the cursor in the wrong column as soon as a line is recalled or edited. + RL_S=$'\001' RL_E=$'\002' fi } @@ -64,12 +70,18 @@ usage() { ${BOLD}dirPathPerms${RESET} ${DIM}v${VERSION}${RESET} — file & directory permission checker ${BOLD}USAGE${RESET} - dirPathPerms.sh [OPTIONS] [FILE] + dirPathPerms.sh [OPTIONS] [PATH|FILE] + +Checks the paths you name directly, or every path listed in an input file. +A bare argument is read as a list of paths when it is a regular file, and +checked directly when it is a directory. Runs interactively when a required value is missing and a terminal is attached; runs non-interactively when everything is supplied via flags. ${BOLD}OPTIONS${RESET} + -P, --path PATH Check PATH directly. Repeatable. Use this instead of + --file when you just want to check one or more paths. -f, --file FILE Input file: one absolute path per line. Blank lines and lines starting with '#' are ignored. -w, --who WHO Whose permission to check: owner|group|other @@ -81,9 +93,18 @@ ${BOLD}OPTIONS${RESET} -V, --version Print the version and exit. ${BOLD}EXAMPLES${RESET} - # Fully interactive (prompts for file, who, and permission) + # Fully interactive (prompts for the path, who, and permission) dirPathPerms.sh + # Check one path directly + dirPathPerms.sh --path ~/Documents --who owner --perm read + + # A bare path works the same way + dirPathPerms.sh -w owner -p r ~/Documents + + # Several paths at once + dirPathPerms.sh -P /etc/passwd -P /var/log -w group -p w + # Non-interactive: does the group have write on every listed path? dirPathPerms.sh --file paths.txt --who group --perm write @@ -92,7 +113,7 @@ ${BOLD}EXAMPLES${RESET} ${BOLD}INPUT FILE FORMAT${RESET} /etc/passwd - /home/user/report.log + ~/report.log # comments and blank lines are skipped /var/www USAGE @@ -129,6 +150,153 @@ mode_string() { stat -c '%A' "$1" 2>/dev/null || stat -f '%Sp' "$1" 2>/dev/null } +# --------------------------------------------------------------------------- +# Path normalization. +# +# A line in the input file is typed by a human or pasted out of a file manager, +# so it is frequently not a bare literal path. It arrives wrapped in quotes, +# with a leading `~`, with a `$VAR` in it, with backslash-escaped spaces (what +# dragging a file from Finder into a terminal produces), with stray +# indentation, or with a CRLF tail. Each of those was previously looked up +# verbatim and written off as "path does not exist". +# +# Expansion is hand-rolled rather than delegated to `eval` on purpose: an input +# file must only ever be able to name files, never run commands, so `$(...)` +# and backticks are deliberately left as literal text. +# --------------------------------------------------------------------------- + +# The helpers below return through this global rather than on stdout. They run +# once per input line, and command substitution would fork a subshell each time. +NORM_PATH="" + +# Drop a CRLF tail and any surrounding whitespace. +trim_ws() { + local s="${1%$'\r'}" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + NORM_PATH="$s" +} + +# Home directory of a username. Linux keeps local accounts in the passwd +# database; macOS keeps them in Directory Services, where getent does not exist. +# The lookups read from /dev/null because this runs inside the loop that has the +# input file on stdin, and a helper that read from it would eat the paths. +home_of() { + local u="$1" h="" + if command -v getent >/dev/null 2>&1; then + h=$(getent passwd "$u" 2>/dev/null /dev/null 2>&1; then + h=$(dscl . -read "/Users/$u" NFSHomeDirectory 2>/dev/null /dev/null || true + unset HISTFILE + emsg "" - emsg "Example file format (one absolute path per line):" + emsg "Enter ${BOLD}either${RESET} a path to check ${BOLD}or${RESET} a file listing paths to check:" emsg "" - emsg " /location/of/dirname1" - emsg " /location/of/filename1" + emsg " ~/Documents a path — checked directly" + emsg " /location/of/myPaths.txt a text file, one path per line" emsg "" - wrap_text >&2 <<< "Enter the path to the file listing the directories or files to check. Absolute paths are required if the script runs from another location." + wrap_text >&2 <<< "Absolute paths are required if the script runs from another location. A leading ~ and any \$VAR are expanded for you. Use the up and down arrows to recall anything you have already typed this session." + + local reply candidate while true; do - printf '%s' "${BOLD}File path:${RESET} " >&2 - read -r file_path - if [[ -f "$file_path" ]]; then - break + if ! read -e -p "${RL_S}${BOLD}${RL_E}Path or list file:${RL_S}${RESET}${RL_E} " -r reply; then + emsg "" + exit 2 + fi + [[ -n "$reply" ]] && history -s "$reply" + + resolve_path "$reply" + candidate="$NORM_PATH" + + # A regular file is read as a list of paths, which is this tool's original + # mode. A directory cannot be a list, so it is the path to check itself. + if [[ -f "$candidate" ]]; then + file_path="$candidate" + return + fi + if [[ -e "$candidate" ]]; then + direct_paths=("$candidate") + return fi + emsg "" - emsg "${RED}Error: file not found.${RESET} Please try again." + if [[ -z "$candidate" ]]; then + emsg "${RED}Nothing entered.${RESET} Type a path, or Ctrl-C to quit." + else + emsg "${RED}No such path:${RESET} $candidate" + fi done } @@ -211,70 +406,106 @@ has_perm() { [[ "$1" == *"$perm"* ]]; } # --------------------------------------------------------------------------- # The check loop. # --------------------------------------------------------------------------- -run_checks() { - local granted=0 denied=0 skipped=0 total=0 - local line path perms owner_perm group_perm other_perm current +granted=0 +denied=0 +skipped=0 +total=0 + +# Check one raw entry — a line from the input file, or a --path value — and +# print its verdict. Tallies land in the counters above. +check_one_raw() { + local path perms owner_perm group_perm other_perm current local badge_u badge_g badge_o - emsg "${BOLD}Checking ${perm_text} permission for ${who_text}${RESET} — from ${file_path}" - emsg "" + resolve_path "$1" + path="$NORM_PATH" - while IFS= read -r line || [[ -n "$line" ]]; do - # Skip blanks and comments. - case "$line" in - '' | '#'*) continue ;; - esac - path="$line" + if [[ ! -e "$path" ]]; then + printf '%s %-44s SKIP (path does not exist)%s\n' \ + "$YELLOW" "$path" "$RESET" + skipped=$((skipped + 1)) + return + fi - if [[ ! -e "$path" ]]; then - printf '%s %-44s SKIP (path does not exist)%s\n' \ - "$YELLOW" "$path" "$RESET" - skipped=$((skipped + 1)) - continue - fi + perms=$(mode_string "$path") + if [[ -z "$perms" ]]; then + printf '%s %-44s SKIP (could not read mode)%s\n' \ + "$YELLOW" "$path" "$RESET" + skipped=$((skipped + 1)) + return + fi - perms=$(mode_string "$path") - if [[ -z "$perms" ]]; then - printf '%s %-44s SKIP (could not read mode)%s\n' \ - "$YELLOW" "$path" "$RESET" - skipped=$((skipped + 1)) - continue - fi + owner_perm=${perms:1:3} + group_perm=${perms:4:3} + other_perm=${perms:7:3} + total=$((total + 1)) - owner_perm=${perms:1:3} - group_perm=${perms:4:3} - other_perm=${perms:7:3} - total=$((total + 1)) - - if [[ "$who" == "all" ]]; then - # Permission matrix: show the requested permission per class. - if has_perm "$owner_perm"; then badge_u="${GREEN}u+${RESET}"; granted=$((granted + 1)); else badge_u="${RED}u-${RESET}"; fi - if has_perm "$group_perm"; then badge_g="${GREEN}g+${RESET}"; granted=$((granted + 1)); else badge_g="${RED}g-${RESET}"; fi - if has_perm "$other_perm"; then badge_o="${GREEN}o+${RESET}"; granted=$((granted + 1)); else badge_o="${RED}o-${RESET}"; fi - printf ' %-44s %s %s %s %s[%s %s %s]%s\n' \ - "$path" "$badge_u" "$badge_g" "$badge_o" \ - "$DIM" "$owner_perm" "$group_perm" "$other_perm" "$RESET" - continue - fi + if [[ "$who" == "all" ]]; then + # Permission matrix: show the requested permission per class. + if has_perm "$owner_perm"; then badge_u="${GREEN}u+${RESET}"; granted=$((granted + 1)); else badge_u="${RED}u-${RESET}"; fi + if has_perm "$group_perm"; then badge_g="${GREEN}g+${RESET}"; granted=$((granted + 1)); else badge_g="${RED}g-${RESET}"; fi + if has_perm "$other_perm"; then badge_o="${GREEN}o+${RESET}"; granted=$((granted + 1)); else badge_o="${RED}o-${RESET}"; fi + printf ' %-44s %s %s %s %s[%s %s %s]%s\n' \ + "$path" "$badge_u" "$badge_g" "$badge_o" \ + "$DIM" "$owner_perm" "$group_perm" "$other_perm" "$RESET" + return + fi - case "$who" in - u) current=$owner_perm ;; - g) current=$group_perm ;; - o) current=$other_perm ;; - esac + case "$who" in + u) current=$owner_perm ;; + g) current=$group_perm ;; + o) current=$other_perm ;; + esac - if has_perm "$current"; then - printf '%s %-44s YES%s %s[owner %s | group %s | other %s]%s\n' \ - "$GREEN" "$path" "$RESET" \ - "$DIM" "$owner_perm" "$group_perm" "$other_perm" "$RESET" - granted=$((granted + 1)) - else - printf '%s %-44s NO (no %s for %s)%s %s[owner %s | group %s | other %s]%s\n' \ - "$RED" "$path" "$perm_text" "$who_text" "$RESET" \ - "$DIM" "$owner_perm" "$group_perm" "$other_perm" "$RESET" - denied=$((denied + 1)) - fi - done < "$file_path" + if has_perm "$current"; then + printf '%s %-44s YES%s %s[owner %s | group %s | other %s]%s\n' \ + "$GREEN" "$path" "$RESET" \ + "$DIM" "$owner_perm" "$group_perm" "$other_perm" "$RESET" + granted=$((granted + 1)) + else + printf '%s %-44s NO (no %s for %s)%s %s[owner %s | group %s | other %s]%s\n' \ + "$RED" "$path" "$perm_text" "$who_text" "$RESET" \ + "$DIM" "$owner_perm" "$group_perm" "$other_perm" "$RESET" + denied=$((denied + 1)) + fi +} + +# --------------------------------------------------------------------------- +# The check loop. Entries come either from --path values or from the input +# file, never both. +# --------------------------------------------------------------------------- +run_checks() { + local line source_label + + if [[ ${#direct_paths[@]} -eq 1 ]]; then + resolve_path "${direct_paths[0]}" + source_label="$NORM_PATH" + elif [[ ${#direct_paths[@]} -gt 1 ]]; then + source_label="${#direct_paths[@]} paths given" + else + source_label="from ${file_path}" + fi + + emsg "${BOLD}Checking ${perm_text} permission for ${who_text}${RESET} — ${source_label}" + emsg "" + + if [[ ${#direct_paths[@]} -gt 0 ]]; then + for line in ${direct_paths[@]+"${direct_paths[@]}"}; do + check_one_raw "$line" + done + else + while IFS= read -r line || [[ -n "$line" ]]; do + # Trim before testing for blanks and comments: a CRLF file would + # otherwise turn every blank line into a bogus path, and an indented + # comment would be treated as one too. + trim_ws "$line" + line="$NORM_PATH" + case "$line" in + '' | '#'*) continue ;; + esac + check_one_raw "$line" + done < "$file_path" + fi emsg "" if [[ "$who" == "all" ]]; then @@ -288,13 +519,17 @@ run_checks() { # Argument parsing. # --------------------------------------------------------------------------- file_path="" +direct_paths=() main() { - local positional="" + local positional="" candidate while [[ $# -gt 0 ]]; do case "$1" in -f|--file) [[ $# -ge 2 ]] || { setup_colors; emsg "Option $1 requires a value."; exit 2; } file_path="$2"; shift 2 ;; + -P|--path) + [[ $# -ge 2 ]] || { setup_colors; emsg "Option $1 requires a value."; exit 2; } + direct_paths+=("$2"); shift 2 ;; -w|--who) [[ $# -ge 2 ]] || { setup_colors; emsg "Option $1 requires a value."; exit 2; } if ! normalize_who "$2"; then setup_colors; emsg "Invalid --who value: $2 (use owner|group|other|all)."; exit 2; fi @@ -317,18 +552,53 @@ main() { esac done - [[ -z "$file_path" && -n "$positional" ]] && file_path="$positional" - setup_colors print_banner + if [[ -n "$file_path" && ${#direct_paths[@]} -gt 0 ]]; then + emsg "${RED}--file and --path cannot be combined.${RESET}" + emsg "--file reads a list of paths; --path names the paths itself." + exit 2 + fi + + # A bare argument is whichever it turns out to be: a regular file is read as + # a list of paths, and anything else that exists is a path to check. + if [[ -z "$file_path" && ${#direct_paths[@]} -eq 0 && -n "$positional" ]]; then + resolve_path "$positional" + candidate="$NORM_PATH" + if [[ -f "$candidate" ]]; then + file_path="$candidate" + elif [[ -e "$candidate" ]]; then + direct_paths=("$candidate") + else + emsg "${RED}No such path:${RESET} $candidate"; exit 2 + fi + fi + # Fill in whatever wasn't provided on the command line. Prompt only when a # terminal is attached; otherwise fail clearly (non-interactive contract). - if [[ -z "$file_path" ]]; then + if [[ -z "$file_path" && ${#direct_paths[@]} -eq 0 ]]; then if [[ -t 0 ]]; then prompt_file - else emsg "${RED}No input file given.${RESET} Pass --file FILE (see --help)."; exit 2; fi - elif [[ ! -f "$file_path" ]]; then - emsg "${RED}Error: file not found:${RESET} $file_path"; exit 2 + else emsg "${RED}Nothing to check.${RESET} Pass --path PATH or --file FILE (see --help)."; exit 2; fi + elif [[ -n "$file_path" ]]; then + # A quoted --file value reaches us unexpanded (e.g. --file '~/paths.txt'), + # so the input file gets the same treatment as the paths listed inside it. + resolve_path "$file_path" + file_path="$NORM_PATH" + if [[ ! -f "$file_path" ]]; then + # Say what is actually wrong. Reporting "file not found" for a directory + # that plainly exists sends people hunting for the wrong problem. + if [[ -d "$file_path" ]]; then + emsg "${RED}Error:${RESET} $file_path is a directory, not a list of paths." + emsg "To check it directly, use: ${BOLD}--path $file_path${RESET}" + elif [[ -e "$file_path" ]]; then + emsg "${RED}Error:${RESET} $file_path is not a regular file, so it cannot be read as a list of paths." + emsg "To check it directly, use: ${BOLD}--path $file_path${RESET}" + else + emsg "${RED}Error: no such file:${RESET} $file_path" + fi + exit 2 + fi fi if [[ -z "$who" ]]; then diff --git a/tests/interactive_test.py b/tests/interactive_test.py new file mode 100755 index 0000000..b43d16b --- /dev/null +++ b/tests/interactive_test.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# +## Author: Tito Valentin +## Name of Program: dirPathPerms interactive test +## Date Created: 2026-07-16 +## Description: Drives dirPathPerms.sh through a real pseudo-terminal to cover +## the two behaviors that only exist interactively: entering a path +## to check at the prompt, and recalling earlier entries with the +## arrow keys. Both need a tty, so they cannot be tested by piping. + +import os +import pty +import select +import sys +import tempfile +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPT = os.path.join(ROOT, "dirPathPerms.sh") + +PROMPT = "Path or list file:" +WHO_PROMPT = "(O)wner" +PERM_PROMPT = "(R)ead" + + +class Session: + """A dirPathPerms.sh process attached to a pty.""" + + def __init__(self, *args): + self.buf = "" + self.pid, self.fd = pty.fork() + if self.pid == 0: + os.execvp(SCRIPT, [SCRIPT, "--no-color", *args]) + + def expect(self, marker, timeout=15): + deadline = time.time() + timeout + while time.time() < deadline: + if marker in self.buf: + return + if select.select([self.fd], [], [], 0.2)[0]: + try: + chunk = os.read(self.fd, 8192) + except OSError: + break + if not chunk: + break + self.buf += chunk.decode(errors="replace").replace("\r", "") + raise AssertionError( + "timed out waiting for %r\n--- output so far ---\n%s" % (marker, self.buf) + ) + + def send(self, data): + os.write(self.fd, data) + + def drain(self, timeout=2.5): + deadline = time.time() + timeout + while time.time() < deadline: + if select.select([self.fd], [], [], 0.2)[0]: + try: + chunk = os.read(self.fd, 8192) + except OSError: + break + if not chunk: + break + self.buf += chunk.decode(errors="replace").replace("\r", "") + return self.buf + + def close(self): + try: + os.close(self.fd) + except OSError: + pass + try: + os.waitpid(self.pid, 0) + except OSError: + pass + + +results = [] + + +def check(name, condition, detail=""): + results.append((name, condition, detail)) + if condition: + print(" PASS %s" % name) + else: + print(" FAIL %s" % name) + if detail: + print(" " + detail.replace("\n", "\n ")) + + +def test_path_at_prompt(): + """A path typed at the prompt is checked directly, tilde and all.""" + d = tempfile.mkdtemp(prefix=".dirpathperms_it_", dir=os.path.expanduser("~")) + rel = os.path.basename(d) + s = Session() + try: + s.expect(PROMPT) + s.send(("~/%s\n" % rel).encode()) + s.expect(WHO_PROMPT) + s.send(b"u\n") + s.expect(PERM_PROMPT) + s.send(b"r\n") + s.expect("Summary:") + out = s.drain() + check("a ~ path typed at the prompt is checked directly", d in out, + "expected the resolved path %r in the output" % d) + check("that path reports a result rather than an error", + "1 granted" in out and "file not found" not in out, + "output:\n%s" % out) + finally: + s.close() + os.rmdir(d) + + +def test_arrow_key_history(): + """Up-arrow recalls what was typed earlier in the session.""" + s = Session() + try: + s.expect(PROMPT) + s.send(b"/nope/typo/path\n") + s.expect("No such path") + before = s.buf.count("No such path") + # Up-arrow, then Enter: readline should resubmit the recalled line. + s.send(b"\x1b[A\n") + time.sleep(0.6) + s.drain(timeout=1.5) + after = s.buf.count("No such path") + check("up-arrow recalls the previous entry", after > before, + "saw %d 'No such path' errors, expected more than %d\n%s" + % (after, before, s.buf)) + check("the recalled text is the original entry", + s.buf.count("/nope/typo/path") >= 2, + "output:\n%s" % s.buf) + finally: + s.send(b"\x03") + s.close() + + +def main(): + print("\ndirPathPerms interactive (pty) tests\n") + test_path_at_prompt() + test_arrow_key_history() + failed = [n for n, ok, _ in results if not ok] + print("\nSummary: %d passed, %d failed\n" % (len(results) - len(failed), len(failed))) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..fa87c7b --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# +## Author: Tito Valentin +## Name of Program: dirPathPerms test suite +## Date Created: 2026-07-16 +## Description: Black-box tests for dirPathPerms.sh. Drives the real CLI against +## generated fixtures and asserts the reported result for each of +## the path spellings a user can realistically put in an input file. + +set -uo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +SCRIPT="$SCRIPT_DIR/dirPathPerms.sh" + +pass=0 +fail=0 + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Colors only when a terminal is attached, matching the script's own behavior. +if [[ -t 1 ]]; then + T_RED=$'\e[1;31m' T_GREEN=$'\e[1;32m' T_BOLD=$'\e[1m' T_RESET=$'\e[0m' +else + T_RED="" T_GREEN="" T_BOLD="" T_RESET="" +fi + +# run_case +# +# Writes the content verbatim to a paths file, runs an owner/read check over it, +# and asserts the expected substring shows up in the result output. +run_case() { + local name="$1" content="$2" expect="$3" + local paths="$WORK/paths.txt" out + + printf '%b' "$content" > "$paths" + out=$("$SCRIPT" --file "$paths" --who owner --perm read --no-color 2>/dev/null) + + if [[ "$out" == *"$expect"* ]]; then + printf ' %sPASS%s %s\n' "$T_GREEN" "$T_RESET" "$name" + pass=$((pass + 1)) + else + printf ' %sFAIL%s %s\n' "$T_RED" "$T_RESET" "$name" + printf ' expected to find: %s\n' "$expect" + printf ' actual output:\n' + printf '%s\n' "$out" | sed 's/^/ /' + fail=$((fail + 1)) + fi +} + +# run_summary_case +# +# Same, but asserts against the summary line, which the script writes to stderr +# along with the rest of its chrome so that stdout stays pipe-friendly. +run_summary_case() { + local name="$1" content="$2" expect="$3" + local paths="$WORK/paths.txt" out + + printf '%b' "$content" > "$paths" + out=$("$SCRIPT" --file "$paths" --who owner --perm read --no-color 2>&1 >/dev/null) + + if [[ "$out" == *"$expect"* ]]; then + printf ' %sPASS%s %s\n' "$T_GREEN" "$T_RESET" "$name" + pass=$((pass + 1)) + else + printf ' %sFAIL%s %s\n' "$T_RED" "$T_RESET" "$name" + printf ' expected to find: %s\n' "$expect" + printf ' actual stderr:\n' + printf '%s\n' "$out" | sed 's/^/ /' + fail=$((fail + 1)) + fi +} + +# run_argv_case +# +# Runs the CLI with arbitrary arguments, asserting against stdout and stderr +# together so that both results and error messages can be checked. +run_argv_case() { + local name="$1" expect="$2" + shift 2 + local out + out=$("$SCRIPT" --no-color "$@" 2>&1) + + if [[ "$out" == *"$expect"* ]]; then + printf ' %sPASS%s %s\n' "$T_GREEN" "$T_RESET" "$name" + pass=$((pass + 1)) + else + printf ' %sFAIL%s %s\n' "$T_RED" "$T_RESET" "$name" + printf ' expected to find: %s\n' "$expect" + printf ' actual output:\n' + printf '%s\n' "$out" | sed 's/^/ /' + fail=$((fail + 1)) + fi +} + +# refute_case +refute_case() { + local name="$1" content="$2" forbidden="$3" + local paths="$WORK/paths.txt" out + + printf '%b' "$content" > "$paths" + out=$("$SCRIPT" --file "$paths" --who owner --perm read --no-color 2>/dev/null) + + if [[ "$out" != *"$forbidden"* ]]; then + printf ' %sPASS%s %s\n' "$T_GREEN" "$T_RESET" "$name" + pass=$((pass + 1)) + else + printf ' %sFAIL%s %s\n' "$T_RED" "$T_RESET" "$name" + printf ' expected NOT to find: %s\n' "$forbidden" + printf ' actual output:\n' + printf '%s\n' "$out" | sed 's/^/ /' + fail=$((fail + 1)) + fi +} + +# --------------------------------------------------------------------------- +# Fixtures. +# --------------------------------------------------------------------------- +FIX="$WORK/fixtures" +mkdir -p "$FIX" +printf 'x\n' > "$FIX/plain.txt" +printf 'x\n' > "$FIX/my file.txt" # a space, as macOS filenames often have +chmod 644 "$FIX/plain.txt" "$FIX/my file.txt" + +# A file whose name genuinely contains characters the normalizer expands. It +# must still be checked literally rather than mangled into something else. +printf 'x\n' > "$FIX/lit_\$HOME.txt" +chmod 644 "$FIX/lit_\$HOME.txt" + +# A real list-of-paths file, for the cases that assert list mode still applies. +printf '%s\n' "$FIX/plain.txt" > "$WORK/list_of_paths.txt" + +# A file under the real home directory, for the tilde and $HOME cases. +HOME_FIX=$(mktemp "$HOME/.dirpathperms_test_XXXXXX") +chmod 644 "$HOME_FIX" +HOME_REL="${HOME_FIX#"$HOME"/}" +trap 'rm -rf "$WORK"; rm -f "$HOME_FIX"' EXIT + +ME=$(id -un) + +printf '\n%sdirPathPerms test suite%s\n\n' "$T_BOLD" "$T_RESET" + +# --------------------------------------------------------------------------- +# Baseline: the spelling that already worked must keep working. +# --------------------------------------------------------------------------- +run_case "plain absolute path" \ + "$FIX/plain.txt\n" "YES" + +run_case "absolute path containing a literal space" \ + "$FIX/my file.txt\n" "YES" + +run_case "missing path is still reported as skipped" \ + "/no/such/path/anywhere\n" "SKIP (path does not exist)" + +# --------------------------------------------------------------------------- +# DIR-2: the spellings that used to be reported as nonexistent. +# --------------------------------------------------------------------------- +# The tildes in these fixtures are literal text written into the input file for +# the script to parse, not paths for this shell to expand (SC2088). +# shellcheck disable=SC2088 +run_case "tilde: ~/path" \ + "~/$HOME_REL\n" "YES" + +run_case "tilde: bare ~ (the home directory itself)" \ + "~\n" "YES" + +run_case "tilde: ~user" \ + "~$ME\n" "YES" + +run_case "env var: \$HOME/path" \ + "\$HOME/$HOME_REL\n" "YES" + +run_case "env var: \${HOME}/path" \ + "\${HOME}/$HOME_REL\n" "YES" + +run_case "backslash-escaped space (Finder drag-and-drop)" \ + "$FIX/my\\\\ file.txt\n" "YES" + +run_case "double-quoted path" \ + "\"$FIX/my file.txt\"\n" "YES" + +run_case "single-quoted path" \ + "'$FIX/my file.txt'\n" "YES" + +run_case "leading whitespace" \ + " $FIX/plain.txt\n" "YES" + +run_case "trailing whitespace" \ + "$FIX/plain.txt \n" "YES" + +run_case "CRLF line ending" \ + "$FIX/plain.txt\r\n" "YES" + +refute_case "CRLF blank line is not treated as a path" \ + "\r\n$FIX/plain.txt\r\n" "SKIP" + +run_summary_case "CRLF file counts only the real path" \ + "\r\n$FIX/plain.txt\r\n" "1 granted" + +refute_case "indented comment is still a comment" \ + " # a comment\n$FIX/plain.txt\n" "SKIP" + +run_summary_case "indented comment is not counted as a path" \ + " # a comment\n$FIX/plain.txt\n" "1 granted" + +# --------------------------------------------------------------------------- +# The literal fallback: expansion must never make a real file unreachable. +# --------------------------------------------------------------------------- +run_case "file whose name literally contains \$HOME" \ + "$FIX/lit_\$HOME.txt\n" "YES" + +# --------------------------------------------------------------------------- +# Safety: an input file names files, it does not run commands. +# --------------------------------------------------------------------------- +refute_case "command substitution is not executed" \ + "/tmp/\$(id -un)\n" "$(id -un)/" + +run_case "command substitution is left literal" \ + "/tmp/\$(id -un)\n" 'SKIP (path does not exist)' + +refute_case "backticks are not executed" \ + "/tmp/\`id -un\`\n" "$(id -un)" + +# --------------------------------------------------------------------------- +# DIR-2: checking a path directly, without authoring a list file first. +# --------------------------------------------------------------------------- +run_argv_case "--path checks a directory directly" \ + "YES" --path "$FIX" --who owner --perm read + +run_argv_case "--path expands a tilde" \ + "$HOME" --path "~" --who owner --perm read + +run_argv_case "--path is repeatable" \ + "2 paths given" --path "$FIX" --path "$FIX/plain.txt" --who owner --perm read + +run_argv_case "a bare directory argument is checked directly" \ + "YES" --who owner --perm read "$FIX" + +run_argv_case "a bare regular-file argument is still read as a list" \ + "YES" --who owner --perm read "$WORK/list_of_paths.txt" + +run_argv_case "a bare missing argument says so" \ + "No such path" --who owner --perm read "/no/such/path/anywhere" + +# The screenshot bug: a directory given where a list was expected used to be +# reported as "file not found", which is not true — it exists. +run_argv_case "--file on a directory names the real problem" \ + "is a directory, not a list of paths" --file "$FIX" --who owner --perm read + +refute_case_argv_not_found() { + local out + out=$("$SCRIPT" --no-color --file "$FIX" --who owner --perm read 2>&1) + if [[ "$out" != *"file not found"* ]]; then + printf ' %sPASS%s %s\n' "$T_GREEN" "$T_RESET" "--file on a directory no longer claims 'file not found'" + pass=$((pass + 1)) + else + printf ' %sFAIL%s %s\n' "$T_RED" "$T_RESET" "--file on a directory no longer claims 'file not found'" + fail=$((fail + 1)) + fi +} +refute_case_argv_not_found + +run_argv_case "--file and --path together is refused" \ + "cannot be combined" --file "$WORK/list_of_paths.txt" --path /etc --who owner --perm read + +run_argv_case "--path with no value is refused" \ + "requires a value" --path + +# --------------------------------------------------------------------------- +# The interactive surface needs a real terminal, so it lives in a pty harness. +# --------------------------------------------------------------------------- +if command -v python3 >/dev/null 2>&1; then + if python3 "$SCRIPT_DIR/tests/interactive_test.py"; then + pass=$((pass + 1)) + else + printf ' %sFAIL%s interactive (pty) tests\n' "$T_RED" "$T_RESET" + fail=$((fail + 1)) + fi +else + printf ' %sSKIP%s interactive (pty) tests — python3 not available\n' \ + "$T_BOLD" "$T_RESET" +fi + +# --------------------------------------------------------------------------- +printf '\n%sSummary:%s %s passed, %s failed\n\n' \ + "$T_BOLD" "$T_RESET" "$pass" "$fail" + +[[ "$fail" -eq 0 ]]