diff --git a/README.md b/README.md
index 54344c2..52fee44 100644
--- a/README.md
+++ b/README.md
@@ -40,11 +40,18 @@ This script is useful for:
* **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.
+* **Interactive session:** run it with no arguments and it keeps asking for
+ paths until you press `q`, so checking ten paths does not mean launching it
+ ten times.
* **Command-line history:** the interactive prompt supports arrow-key recall and
full line editing, so a typo does not mean retyping the whole path.
+* **Results as a table:** one row per path, with headers and alternating row
+ shading so adjacent rows stay easy to tell apart.
* 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.
+ **all three at once** with `--all`.
+* Allows checking for Read (`r`), Write (`w`), or Execute (`x`) permissions, or
+ **all three at once** with `--perm all`. Combine with `--all` for the full
+ 3×3 matrix of every permission against every class.
* Interactive prompts guide the user to select the input file and the desired
permission check, and validate every choice.
* Clear, color-coded output:
@@ -95,16 +102,15 @@ many to audit at once.
./dirPathPerms.sh
```
-Follow the prompts:
+Run with no arguments it becomes a **session**:
* 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.
+ to check (`myPaths.txt`) — see [how the two are told apart](#paths-vs-lists).
+* Choose whose permissions to check, and which permission to look for.
+* Read the results table, then **enter another path**. The script keeps asking,
+ reusing your answers, until you press **`q`**.
* 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.
### Non-interactive
@@ -121,6 +127,9 @@ scripts, cron jobs, and CI:
# Several paths at once
./dirPathPerms.sh -P /etc/passwd -P /var/log --who group --perm write
+# The full picture: every permission, for every class
+./dirPathPerms.sh --path ~/Documents --all --perm all
+
# Does the group have write access to every listed path?
./dirPathPerms.sh --file paths.txt --who group --perm write
@@ -131,9 +140,20 @@ scripts, cron jobs, and CI:
./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.
+Supplying everything via flags checks once and exits — no session loop — which
+is what makes it usable from cron and CI.
+
+
Paths vs. lists
+
+A bare argument might be the path you want to check, or a file listing paths to
+check. The script decides by **looking inside it**: a file whose first
+meaningful line is a path (starting with `/`, `~`, or `$`) is read as a list;
+anything else is checked directly. So `myPaths.txt` is a list, while
+`/etc/passwd` is checked as a file — rather than having its contents mistaken
+for a list of paths.
+
+Use `--path` or `--file` when you want to say which you meant, instead of
+relying on the guess.
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.
@@ -145,8 +165,8 @@ a terminal is attached, or exits with a helpful error when one is not.
| `-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`). |
-| `-a`, `--all` | Check owner, group **and** other at once (permission matrix). |
+| `-p`, `--perm PERM` | Permission to check: `read`\|`write`\|`execute`\|`all` (aliases `r`\|`w`\|`x`\|`a`). `all` checks read, write and execute together. |
+| `-a`, `--all` | Check owner, group **and** other at once. |
| `--no-color` | Disable colored output (also honors the `NO_COLOR` env var). |
| `-h`, `--help` | Show help and exit. |
| `-V`, `--version` | Print the version and exit. |
@@ -221,28 +241,49 @@ Let's say you want to check if members of the owning **Group** have **Write** ac
```text
Checking write permission for Group — from myPaths.txt
- /etc/passwd NO (no write for Group) [owner rw- | group r-- | other r--]
- /home/user/important_script.sh NO (no write for Group) [owner rwx | group r-x | other ---]
- /var/log/app.log YES [owner rw- | group rw- | other ---]
- /tmp YES [owner rwx | group rwx | other rwt]
- /non/existent/path SKIP (path does not exist)
- /data/shared_folder YES [owner rwx | group rwx | other ---]
+┌────────────────────────────────┬───────┬────────────────┐
+│ Path │ Group │ Mode │
+├────────────────────────────────┼───────┼────────────────┤
+│ /etc/passwd │ NO │ -rw-r--r-- │
+│ /home/user/important_script.sh │ NO │ -rwxr-x--- │
+│ /var/log/app.log │ YES │ -rw-rw---- │
+│ /tmp │ YES │ drwxrwxrwt │
+│ /non/existent/path │ — │ does not exist │
+│ /data/shared_folder │ YES │ drwxrwx--- │
+└────────────────────────────────┴───────┴────────────────┘
Summary: 3 granted, 2 denied, 1 skipped (5 checked).
```
-`YES` lines are green, `NO` lines red, and `SKIP` lines yellow (colors are
-omitted when output is piped or `--no-color` is set). With `--all`, each path
-instead shows a per-class matrix, e.g. `u+ g- o-` for a permission present for
-the owner but not the group or other.
+`YES` is green and `NO` is red, and every other row is shaded so neighbouring
+rows never blur together. Colors are omitted when output is piped or
+`--no-color` is set.
+
+With `--all`, each class gets its own column. With `--perm all`, each class
+column becomes an `R W X` matrix of check marks:
+
+```text
+┌─────────────────────┬─────────────┬─────────────┬─────────────┬────────────┐
+│ │ Owner │ Group │ Other │ │
+│ Path │ R W X │ R W X │ R W X │ Mode │
+├─────────────────────┼─────────────┼─────────────┼─────────────┼────────────┤
+│ /etc/passwd │ ✓ ✓ · │ ✓ · · │ ✓ · · │ -rw-r--r-- │
+│ /var/log │ ✓ ✓ ✓ │ ✓ · ✓ │ ✓ · ✓ │ drwxr-xr-x │
+└─────────────────────┴─────────────┴─────────────┴─────────────┴────────────┘
+
+Summary: 2 path(s) checked, 0 skipped — 11 of 18 permission checks granted.
+```
## Output Explanation
-* `YES` (Green): The requested permission (`write` in the example) is set for the specified entity (`Group` in the example) on that path.
-* `NO` (Red): The requested permission is not set for the specified entity. The message clarifies which permission and entity were checked.
-* `[owner ... | group ... | other ...]`: Appears on both `YES` and `NO` lines, showing the actual permission breakdown (`rwx` format) for context.
-* `SKIP ...` (Yellow): Printed when a path listed in the input file does not exist or its mode can't be read.
-* `Summary`: A final tally of granted / denied / skipped paths.
+* **`YES`** (green): the requested permission is set for that class on that path.
+* **`NO`** (red): it is not set.
+* **`✓` / `·`** (with `--perm all`): the permission is, or is not, set.
+* **`Mode`**: the actual mode (`-rw-r--r--`), or why there is no result —
+ `does not exist` or `mode unreadable` (yellow).
+* **`—`**: no result to report for that class, because the mode could not be read.
+* **`Summary`**: a tally of granted / denied / skipped. When more than one class
+ or permission is shown, it counts individual permission checks instead.
## License
diff --git a/dirPathPerms.sh b/dirPathPerms.sh
index 2cc2b49..5a7f904 100755
--- a/dirPathPerms.sh
+++ b/dirPathPerms.sh
@@ -22,11 +22,19 @@ setup_colors() {
if [[ "$USE_COLOR" == "no" || -n "${NO_COLOR:-}" || ! -t 1 ]]; then
BOLD="" DIM="" RED="" GREEN="" YELLOW="" CYAN="" MAGENTA="" RESET=""
RL_S="" RL_E=""
+ STRIPE="" FG_RESET="" ROW_END=""
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'
+ # Zebra striping for the results table. A row sets STRIPE once and closes
+ # with ROW_END; anything colored inside it must return to the default
+ # foreground with FG_RESET rather than RESET, which would also drop the
+ # row's background colour partway along the line.
+ STRIPE=$'\e[48;5;236m'
+ FG_RESET=$'\e[22;39m'
+ ROW_END=$'\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.
@@ -73,11 +81,14 @@ ${BOLD}USAGE${RESET}
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.
+A bare argument is read as a list of paths when it looks like one (its first
+meaningful line is a path); otherwise it is the path to check.
-Runs interactively when a required value is missing and a terminal is
-attached; runs non-interactively when everything is supplied via flags.
+With no arguments it runs as a session: it keeps asking for paths, with
+arrow-key recall, until you press q. Runs non-interactively, checking once
+and exiting, when everything is supplied via flags.
+
+Results are printed as a table of one row per path.
${BOLD}OPTIONS${RESET}
-P, --path PATH Check PATH directly. Repeatable. Use this instead of
@@ -86,19 +97,24 @@ ${BOLD}OPTIONS${RESET}
lines starting with '#' 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).
+ -p, --perm PERM Permission to check: read|write|execute|all
+ (aliases: r|w|x|a). 'all' checks read, write and execute
+ together.
-a, --all Check owner, group AND other at once (permission matrix).
--no-color Disable colored output.
-h, --help Show this help and exit.
-V, --version Print the version and exit.
${BOLD}EXAMPLES${RESET}
- # Fully interactive (prompts for the path, who, and permission)
+ # Interactive session: asks for paths until you press q
dirPathPerms.sh
# Check one path directly
dirPathPerms.sh --path ~/Documents --who owner --perm read
+ # The full picture: every permission, for every class
+ dirPathPerms.sh --path ~/Documents --all --perm all
+
# A bare path works the same way
dirPathPerms.sh -w owner -p r ~/Documents
@@ -284,6 +300,32 @@ normalize_path() {
unescape "$NORM_PATH"
}
+# A regular file given where a path was expected is ambiguous: it may be the
+# path to check, or a file listing paths to check. Decide by reading it. A list
+# of paths contains paths, so its first meaningful line starts with /, ~ or $.
+# Anything else — /etc/passwd, a binary, prose — is the path to check itself.
+# Only --file forces the list reading unconditionally.
+looks_like_path_list() {
+ local line seen=0
+ [[ -s "$1" ]] || return 1
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ trim_ws "$line"
+ line="$NORM_PATH"
+ # The tilde below is a literal being matched, not a path for this shell to
+ # expand, which is what SC2088 warns about.
+ # shellcheck disable=SC2088
+ case "$line" in
+ '' | '#'*) ;;
+ /* | '~'* | '$'*) return 0 ;;
+ *) return 1 ;;
+ esac
+ # Give up rather than read a whole binary looking for a path.
+ seen=$((seen + 1))
+ (( seen > 20 )) && return 1
+ done < "$1"
+ return 1
+}
+
# Resolve one raw line to the path that should be checked. Normalization is a
# convenience, so when the normalized form does not exist but the raw line
# does, the raw line wins: a file whose name genuinely contains a tilde, dollar
@@ -312,13 +354,14 @@ normalize_who() {
esac
}
-perm="" # r | w | x
+perm="" # r | w | x | all
perm_text=""
normalize_perm() {
case "$(lc "$1")" in
read|r) perm="r"; perm_text="read" ;;
write|w) perm="w"; perm_text="write" ;;
execute|exec|x) perm="x"; perm_text="execute" ;;
+ all|a) perm="all"; perm_text="read/write/execute" ;;
*) return 1 ;;
esac
}
@@ -339,33 +382,52 @@ prompt_file() {
emsg " ~/Documents a path — checked directly"
emsg " /location/of/myPaths.txt a text file, one path per line"
emsg ""
- 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."
+ 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, and press q to quit."
+ prompt_next
+}
+
+# Ask for one path or list file. Returns 1 when the user asks to quit, which
+# ends the session rather than the whole script.
+prompt_next() {
local reply candidate
+
+ # Each round replaces the previous entry rather than adding to it.
+ file_path=""
+ direct_paths=()
+
while true; do
- if ! read -e -p "${RL_S}${BOLD}${RL_E}Path or list file:${RL_S}${RESET}${RL_E} " -r reply; then
+ if ! read -e -p "${RL_S}${BOLD}${RL_E}Path or list file${RL_S}${DIM}${RL_E} (q to quit)${RL_S}${RESET}${BOLD}${RL_E}:${RL_S}${RESET}${RL_E} " -r reply; then
+ # Ctrl-D reads as end of input, which means the same thing as q.
emsg ""
- exit 2
+ return 1
fi
+
+ case "$(lc "$reply")" in
+ q | quit | exit) return 1 ;;
+ esac
+
[[ -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
+ if looks_like_path_list "$candidate"; then
+ file_path="$candidate"
+ else
+ direct_paths=("$candidate")
+ fi
+ return 0
fi
if [[ -e "$candidate" ]]; then
direct_paths=("$candidate")
- return
+ return 0
fi
emsg ""
if [[ -z "$candidate" ]]; then
- emsg "${RED}Nothing entered.${RESET} Type a path, or Ctrl-C to quit."
+ emsg "${RED}Nothing entered.${RESET} Type a path, or q to quit."
else
emsg "${RED}No such path:${RESET} $candidate"
fi
@@ -388,85 +450,233 @@ prompt_who() {
prompt_perm() {
while true; do
emsg ""
- printf '%sCheck for (R)ead, (W)rite, or e(X)ecute permission?%s ' \
+ printf '%sCheck for (R)ead, (W)rite, e(X)ecute, or (A)ll permissions?%s ' \
"$BOLD" "$RESET" >&2
read -r reply
if normalize_perm "$reply"; then
break
fi
- emsg "${RED}Invalid choice.${RESET} Enter R, W, or X."
+ emsg "${RED}Invalid choice.${RESET} Enter R, W, X, or A."
done
}
# ---------------------------------------------------------------------------
-# Does the 3-char class field (e.g. "rwx" or "r-x") grant $perm?
+# Does the 3-char class field (e.g. "rwx" or "r-x") grant permission $2?
# ---------------------------------------------------------------------------
-has_perm() { [[ "$1" == *"$perm"* ]]; }
+has_perm() { [[ "$1" == *"$2"* ]]; }
+
+# The 3-char field a class owns within a 10-char mode string.
+class_field() {
+ case "$2" in
+ u) printf '%s' "${1:1:3}" ;;
+ g) printf '%s' "${1:4:3}" ;;
+ o) printf '%s' "${1:7:3}" ;;
+ esac
+}
+
+class_label() {
+ case "$1" in
+ u) printf 'Owner' ;;
+ g) printf 'Group' ;;
+ o) printf 'Other' ;;
+ esac
+}
# ---------------------------------------------------------------------------
-# The check loop.
+# Results for the current table. Parallel arrays, one entry per checked path.
+# Checking and rendering are kept apart so that the table can size its columns
+# to the widest value it is about to print.
# ---------------------------------------------------------------------------
-granted=0
-denied=0
-skipped=0
-total=0
+ROW_PATH=()
+ROW_MODE=() # 10-char mode string, empty when there is nothing to report
+ROW_NOTE=() # why a row has no mode
-# 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
+reset_rows() { ROW_PATH=(); ROW_MODE=(); ROW_NOTE=(); }
+# Resolve one raw entry and record what was found.
+record_path() {
+ local path mode
resolve_path "$1"
path="$NORM_PATH"
if [[ ! -e "$path" ]]; then
- printf '%s %-44s SKIP (path does not exist)%s\n' \
- "$YELLOW" "$path" "$RESET"
- skipped=$((skipped + 1))
+ ROW_PATH+=("$path"); ROW_MODE+=(""); ROW_NOTE+=("does not exist")
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))
+ mode=$(mode_string "$path")
+ if [[ -z "$mode" ]]; then
+ ROW_PATH+=("$path"); ROW_MODE+=(""); ROW_NOTE+=("mode unreadable")
return
fi
+ ROW_PATH+=("$path"); ROW_MODE+=("$mode"); ROW_NOTE+=("")
+}
- 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"
- return
+# Which classes and permissions get columns.
+display_classes=()
+display_perms=()
+setup_columns() {
+ if [[ "$who" == "all" ]]; then display_classes=(u g o); else display_classes=("$who"); fi
+ if [[ "$perm" == "all" ]]; then display_perms=(r w x); else display_perms=("$perm"); fi
+}
+
+# ---------------------------------------------------------------------------
+# Table rendering.
+# ---------------------------------------------------------------------------
+
+# A run of $2 copies of $1, built without a loop.
+hbar() {
+ local s
+ printf -v s '%*s' "$2" ''
+ printf '%s' "${s// /$1}"
+}
+
+# $1 centered within $3 columns, where $2 is how many columns $1 occupies on
+# screen. The width is passed in rather than measured because ${#s} counts bytes
+# outside a UTF-8 locale, which would silently skew every border once a
+# multi-byte glyph appears in a cell.
+center_w() {
+ local text="$1" cols="$2" width="$3" pad_l pad_r
+ if (( cols >= width )); then printf '%s' "$text"; return; fi
+ pad_l=$(( (width - cols) / 2 ))
+ pad_r=$(( width - cols - pad_l ))
+ printf '%*s%s%*s' "$pad_l" '' "$text" "$pad_r" ''
+}
+
+# $1 centered within $2 columns. ASCII only — see center_w.
+center() { center_w "$1" "${#1}" "$2"; }
+
+# Render every recorded row as a bordered table with headers. Adjacent rows get
+# alternating backgrounds so that a long list stays readable.
+render_table() {
+ local n=${#ROW_PATH[@]}
+ local pathw=4 modew=4 classw i s c p
+ local mode field cell stripe
+
+ (( n == 0 )) && return
+
+ for (( i = 0; i < n; i++ )); do
+ s="${ROW_PATH[$i]}"
+ (( ${#s} > pathw )) && pathw=${#s}
+ s="${ROW_MODE[$i]}"
+ [[ -z "$s" ]] && s="${ROW_NOTE[$i]}"
+ (( ${#s} > modew )) && modew=${#s}
+ done
+
+ # One cell per class, or three when every permission is being shown.
+ if [[ "$perm" == "all" ]]; then classw=13; else classw=7; fi
+
+ # ── borders ──
+ rule() {
+ local left="$1" mid="$2" right="$3" out
+ out="$left$(hbar '─' $((pathw + 2)))"
+ for c in ${display_classes[@]+"${display_classes[@]}"}; do
+ out="$out$mid$(hbar '─' "$classw")"
+ done
+ out="$out$mid$(hbar '─' $((modew + 2)))$right"
+ printf '%s\n' "$out"
+ }
+
+ rule '┌' '┬' '┐'
+
+ # ── header ──
+ # With every permission shown, the class name spans its three cells and a
+ # second header row names them.
+ if [[ "$perm" == "all" ]]; then
+ s="│ $(center '' "$pathw") │"
+ for c in ${display_classes[@]+"${display_classes[@]}"}; do
+ s="$s$(center "$(class_label "$c")" "$classw")│"
+ done
+ s="$s$(center '' $((modew + 2)))│"
+ printf '%s%s%s\n' "$BOLD" "$s" "$RESET"
fi
- case "$who" in
- u) current=$owner_perm ;;
- g) current=$group_perm ;;
- o) current=$other_perm ;;
- esac
+ s="│ $(printf '%-*s' "$pathw" 'Path') │"
+ for c in ${display_classes[@]+"${display_classes[@]}"}; do
+ if [[ "$perm" == "all" ]]; then
+ s="$s$(center 'R W X' "$classw")│"
+ else
+ s="$s$(center "$(class_label "$c")" "$classw")│"
+ fi
+ done
+ s="$s $(printf '%-*s' "$modew" 'Mode') │"
+ printf '%s%s%s\n' "$BOLD" "$s" "$RESET"
+
+ rule '├' '┼' '┤'
+
+ # ── rows ──
+ for (( i = 0; i < n; i++ )); do
+ # Alternate the background so neighbouring rows never look alike.
+ if (( i % 2 == 1 )); then stripe="$STRIPE"; else stripe=""; fi
+ mode="${ROW_MODE[$i]}"
+
+ printf '%s' "$stripe"
+ printf '│ %-*s │' "$pathw" "${ROW_PATH[$i]}"
- 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))
+ for c in ${display_classes[@]+"${display_classes[@]}"}; do
+ if [[ -z "$mode" ]]; then
+ # Nothing was readable, so no cell can claim anything.
+ printf '%s%s%s│' "$DIM" "$(center_w '—' 1 "$classw")" "$FG_RESET"
+ continue
+ fi
+ field=$(class_field "$mode" "$c")
+ if [[ "$perm" == "all" ]]; then
+ # Built with literal spacing rather than centered, so the glyphs line up
+ # under the R/W/X header without depending on how wide the shell thinks
+ # a check mark is.
+ cell=""
+ for p in ${display_perms[@]+"${display_perms[@]}"}; do
+ [[ -n "$cell" ]] && cell="$cell "
+ if has_perm "$field" "$p"; then
+ cell="$cell${GREEN}✓${FG_RESET}"
+ else
+ cell="$cell${DIM}·${FG_RESET}"
+ fi
+ done
+ printf ' %s │' "$cell"
+ else
+ if has_perm "$field" "$perm"; then
+ printf '%s%s%s│' "$GREEN" "$(center 'YES' "$classw")" "$FG_RESET"
+ else
+ printf '%s%s%s│' "$RED" "$(center 'NO' "$classw")" "$FG_RESET"
+ fi
+ fi
+ done
+
+ if [[ -n "$mode" ]]; then
+ printf ' %s%-*s%s │' "$DIM" "$modew" "$mode" "$FG_RESET"
+ else
+ printf ' %s%-*s%s │' "$YELLOW" "$modew" "${ROW_NOTE[$i]}" "$FG_RESET"
+ fi
+ printf '%s\n' "$ROW_END"
+ done
+
+ rule '└' '┴' '┘'
+}
+
+# The tally under the table.
+render_summary() {
+ local n=${#ROW_PATH[@]}
+ local i c p mode field
+ local checked=0 skipped=0 cells=0 granted=0 denied=0
+
+ for (( i = 0; i < n; i++ )); do
+ mode="${ROW_MODE[$i]}"
+ if [[ -z "$mode" ]]; then skipped=$((skipped + 1)); continue; fi
+ checked=$((checked + 1))
+ for c in ${display_classes[@]+"${display_classes[@]}"}; do
+ field=$(class_field "$mode" "$c")
+ for p in ${display_perms[@]+"${display_perms[@]}"}; do
+ cells=$((cells + 1))
+ if has_perm "$field" "$p"; then granted=$((granted + 1)); else denied=$((denied + 1)); fi
+ done
+ done
+ done
+
+ emsg ""
+ if (( ${#display_classes[@]} == 1 && ${#display_perms[@]} == 1 )); then
+ emsg "${BOLD}Summary:${RESET} ${GREEN}${granted} granted${RESET}, ${RED}${denied} denied${RESET}, ${YELLOW}${skipped} skipped${RESET} (${checked} checked)."
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))
+ emsg "${BOLD}Summary:${RESET} ${checked} path(s) checked, ${YELLOW}${skipped} skipped${RESET} — ${GREEN}${granted}${RESET} of ${cells} permission checks granted."
fi
}
@@ -477,6 +687,8 @@ check_one_raw() {
run_checks() {
local line source_label
+ reset_rows
+
if [[ ${#direct_paths[@]} -eq 1 ]]; then
resolve_path "${direct_paths[0]}"
source_label="$NORM_PATH"
@@ -491,7 +703,7 @@ run_checks() {
if [[ ${#direct_paths[@]} -gt 0 ]]; then
for line in ${direct_paths[@]+"${direct_paths[@]}"}; do
- check_one_raw "$line"
+ record_path "$line"
done
else
while IFS= read -r line || [[ -n "$line" ]]; do
@@ -503,16 +715,12 @@ run_checks() {
case "$line" in
'' | '#'*) continue ;;
esac
- check_one_raw "$line"
+ record_path "$line"
done < "$file_path"
fi
- emsg ""
- if [[ "$who" == "all" ]]; then
- emsg "${BOLD}Summary:${RESET} ${total} path(s) checked, ${skipped} skipped — ${GREEN}${granted}${RESET} class-grants of ${perm_text}."
- else
- emsg "${BOLD}Summary:${RESET} ${GREEN}${granted} granted${RESET}, ${RED}${denied} denied${RESET}, ${YELLOW}${skipped} skipped${RESET} (${total} checked)."
- fi
+ render_table
+ render_summary
}
# ---------------------------------------------------------------------------
@@ -520,6 +728,7 @@ run_checks() {
# ---------------------------------------------------------------------------
file_path=""
direct_paths=()
+session=0
main() {
local positional="" candidate
while [[ $# -gt 0 ]]; do
@@ -536,7 +745,7 @@ main() {
shift 2 ;;
-p|--perm)
[[ $# -ge 2 ]] || { setup_colors; emsg "Option $1 requires a value."; exit 2; }
- if ! normalize_perm "$2"; then setup_colors; emsg "Invalid --perm value: $2 (use read|write|execute)."; exit 2; fi
+ if ! normalize_perm "$2"; then setup_colors; emsg "Invalid --perm value: $2 (use read|write|execute|all)."; exit 2; fi
shift 2 ;;
-a|--all) who="all"; who_text="All"; shift ;;
--no-color) USE_COLOR="no"; shift ;;
@@ -561,12 +770,12 @@ main() {
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.
+ # A bare argument is whichever it turns out to be: a file that reads like a
+ # list of paths is one, 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
+ if [[ -f "$candidate" ]] && looks_like_path_list "$candidate"; then
file_path="$candidate"
elif [[ -e "$candidate" ]]; then
direct_paths=("$candidate")
@@ -578,8 +787,14 @@ main() {
# 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" && ${#direct_paths[@]} -eq 0 ]]; then
- if [[ -t 0 ]]; then prompt_file
- else emsg "${RED}Nothing to check.${RESET} Pass --path PATH or --file FILE (see --help)."; exit 2; fi
+ if [[ -t 0 ]]; then
+ # Nothing was named on the command line, so this is a session: keep
+ # asking for paths until the user quits, rather than exiting after one.
+ session=1
+ prompt_file || { emsg "Nothing checked."; exit 0; }
+ 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.
@@ -608,10 +823,20 @@ main() {
if [[ -z "$perm" ]]; then
if [[ -t 0 ]]; then prompt_perm
- else emsg "${RED}No permission given.${RESET} Pass --perm read|write|execute (see --help)."; exit 2; fi
+ else emsg "${RED}No permission given.${RESET} Pass --perm read|write|execute|all (see --help)."; exit 2; fi
fi
+ setup_columns
+
run_checks
+ (( session == 0 )) && return 0
+
+ # Same question, same who/perm — only the paths change.
+ while prompt_next; do
+ run_checks
+ done
+ emsg ""
+ emsg "${DIM}Done.${RESET}"
}
main "$@"
diff --git a/tests/interactive_test.py b/tests/interactive_test.py
index b43d16b..70eb702 100755
--- a/tests/interactive_test.py
+++ b/tests/interactive_test.py
@@ -19,7 +19,7 @@
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPT = os.path.join(ROOT, "dirPathPerms.sh")
-PROMPT = "Path or list file:"
+PROMPT = "Path or list file"
WHO_PROMPT = "(O)wner"
PERM_PROMPT = "(R)ead"
@@ -27,11 +27,15 @@
class Session:
"""A dirPathPerms.sh process attached to a pty."""
- def __init__(self, *args):
+ def __init__(self, *args, color=False):
self.buf = ""
+ argv = [SCRIPT] + ([] if color else ["--no-color"]) + list(args)
self.pid, self.fd = pty.fork()
if self.pid == 0:
- os.execvp(SCRIPT, [SCRIPT, "--no-color", *args])
+ if color:
+ os.environ["TERM"] = "xterm-256color"
+ os.environ.pop("NO_COLOR", None)
+ os.execvp(SCRIPT, argv)
def expect(self, marker, timeout=15):
deadline = time.time() + timeout
@@ -138,10 +142,97 @@ def test_arrow_key_history():
s.close()
+def test_session_loop():
+ """After a result the session keeps asking, and q ends it."""
+ 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:")
+ # It must come back for another path instead of exiting.
+ s.drain(timeout=0.8)
+ check("the session asks for another path after a result",
+ s.buf.count(PROMPT) >= 2,
+ "saw the prompt %d time(s):\n%s" % (s.buf.count(PROMPT), s.buf[-400:]))
+ # /etc/passwd's mode only appears in a second table.
+ s.send(b"/etc/passwd\n")
+ s.expect("-rw-r--r--")
+ s.drain(timeout=0.8)
+ check("a second path is checked without re-asking who/perm",
+ s.buf.count("Summary:") >= 2 and s.buf.count(WHO_PROMPT) == 1,
+ "saw %d summaries, %d who-prompts"
+ % (s.buf.count("Summary:"), s.buf.count(WHO_PROMPT)))
+ # q ends the session cleanly.
+ s.send(b"q\n")
+ out = s.drain()
+ check("q ends the session", "Done." in out, "output tail:\n%s" % out[-400:])
+ finally:
+ s.close()
+ os.rmdir(d)
+
+
+def test_table_is_rendered():
+ """The interactive result is the bordered table, with headers."""
+ s = Session()
+ try:
+ s.expect(PROMPT)
+ s.send(b"/etc/passwd\n")
+ s.expect(WHO_PROMPT)
+ s.send(b"a\n")
+ s.expect(PERM_PROMPT)
+ s.send(b"a\n")
+ s.expect("Summary:")
+ out = s.drain()
+ check("interactive output is a bordered table",
+ "┌" in out and "├" in out and "└" in out, "output:\n%s" % out[-600:])
+ check("the table carries headers", "Path" in out and "Mode" in out and "Owner" in out)
+ check("--perm all renders the R/W/X matrix", "R W X" in out)
+ finally:
+ s.send(b"q\n")
+ s.close()
+
+
+def test_zebra_striping():
+ """Adjacent rows differ: every other row carries the stripe."""
+ home = os.path.expanduser("~")
+ listing = tempfile.NamedTemporaryFile(
+ mode="w", suffix=".txt", prefix="dpp_", delete=False, dir=home
+ )
+ for _ in range(3):
+ listing.write("/etc/passwd\n/var/log\n")
+ listing.close()
+ s = Session(color=True)
+ try:
+ s.expect(PROMPT)
+ s.send((listing.name + "\n").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()
+ striped = out.count("48;5;236")
+ check("alternate rows carry a background stripe", striped >= 2,
+ "found %d striped rows" % striped)
+ finally:
+ s.send(b"q\n")
+ s.close()
+ os.unlink(listing.name)
+
+
def main():
print("\ndirPathPerms interactive (pty) tests\n")
test_path_at_prompt()
test_arrow_key_history()
+ test_session_loop()
+ test_table_is_rendered()
+ test_zebra_striping()
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
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index fa87c7b..abfc10c 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -151,7 +151,7 @@ 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)"
+ "/no/such/path/anywhere\n" "does not exist"
# ---------------------------------------------------------------------------
# DIR-2: the spellings that used to be reported as nonexistent.
@@ -217,7 +217,7 @@ 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)'
+ "/tmp/\$(id -un)\n" 'does not exist'
refute_case "backticks are not executed" \
"/tmp/\`id -un\`\n" "$(id -un)"
@@ -267,6 +267,68 @@ run_argv_case "--file and --path together is refused" \
run_argv_case "--path with no value is refused" \
"requires a value" --path
+# ---------------------------------------------------------------------------
+# DIR-3: the results table.
+# ---------------------------------------------------------------------------
+run_argv_case "the table has a Path header" \
+ "Path" --path "$FIX/plain.txt" --who owner --perm read
+
+run_argv_case "the table names the class it checked" \
+ "Owner" --path "$FIX/plain.txt" --who owner --perm read
+
+run_argv_case "the table has a Mode column showing the real mode" \
+ "-rw-r--r--" --path "$FIX/plain.txt" --who owner --perm read
+
+run_argv_case "the table is bordered" \
+ "├" --path "$FIX/plain.txt" --who owner --perm read
+
+run_argv_case "--who all gives a column per class" \
+ "Group" --path "$FIX/plain.txt" --who all --perm read
+
+# --perm all: the matrix layout.
+run_argv_case "--perm all is accepted" \
+ "read/write/execute" --path "$FIX/plain.txt" --who owner --perm all
+
+run_argv_case "--perm all gives an R/W/X sub-header" \
+ "R W X" --path "$FIX/plain.txt" --who owner --perm all
+
+run_argv_case "--perm all marks a granted permission" \
+ "✓" --path "$FIX/plain.txt" --who owner --perm all
+
+run_argv_case "--perm all reports a matrix summary" \
+ "permission checks granted" --path "$FIX/plain.txt" --who all --perm all
+
+run_argv_case "-p a is accepted as an alias for --perm all" \
+ "read/write/execute" --path "$FIX/plain.txt" -w owner -p a
+
+# A path whose columns must line up regardless of the locale: ${#s} counts
+# bytes outside a UTF-8 locale, which would skew the border widths.
+run_argv_case "a missing row still renders its placeholder" \
+ "does not exist" --path "/no/such/path" --who all --perm all
+
+# ---------------------------------------------------------------------------
+# DIR-3: telling a list of paths from a path that happens to be a file.
+# ---------------------------------------------------------------------------
+run_argv_case "a regular file that is not a list is checked, not parsed" \
+ "-rw-r--r--" --who owner --perm read /etc/passwd
+
+refute_case_passwd_parsed() {
+ local out
+ out=$("$SCRIPT" --no-color --who owner --perm read /etc/passwd 2>&1)
+ if [[ "$out" != *"root:"* ]]; then
+ printf ' %sPASS%s %s\n' "$T_GREEN" "$T_RESET" "/etc/passwd contents are not read as paths"
+ pass=$((pass + 1))
+ else
+ printf ' %sFAIL%s %s\n' "$T_RED" "$T_RESET" "/etc/passwd contents are not read as paths"
+ printf ' the file was parsed as a list of paths\n'
+ fail=$((fail + 1))
+ fi
+}
+refute_case_passwd_parsed
+
+run_argv_case "--file still forces list reading" \
+ "does not exist" --file /etc/passwd --who owner --perm read
+
# ---------------------------------------------------------------------------
# The interactive surface needs a real terminal, so it lives in a pty harness.
# ---------------------------------------------------------------------------