From df5dca96c6f3a68e2a61742605f3b208372687ee Mon Sep 17 00:00:00 2001 From: fatalerrors Date: Tue, 13 Oct 2026 12:31:51 +0200 Subject: [PATCH] filefct bug fix --- profile.d/filefct.sh | 240 ++++++++++++++++++++++++++++++------------- 1 file changed, 171 insertions(+), 69 deletions(-) diff --git a/profile.d/filefct.sh b/profile.d/filefct.sh index 1461ae8..728ac13 100644 --- a/profile.d/filefct.sh +++ b/profile.d/filefct.sh @@ -37,7 +37,9 @@ load_conf "filefct" # ------------------------------------------------------------------------------ -# Expand wildcards in a file/directory list and quote the results +# Expand wildcards in a file/directory list and quote the results: each match is +# wrapped in double quotes (with \, ", $ and ` escaped) and the matches are +# joined with the separator. Items without wildcards are quoted as they are. # Usage: expandlist [options] expandlist() { @@ -49,9 +51,8 @@ expandlist() disp E "Invalid options, use \"expandlist --help\" to display usage." return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in @@ -60,7 +61,7 @@ expandlist() printf "Usage: expandlist [options] \n\n" printf "Options:\n" printf "\t-h, --help\t\tDisplay this help screen\n" - printf "\t-s, --separator SEP\tSet output separator (default: space)\n" + printf "\t-s, --separator SEP\tSet output separator (default: space); \\\\n, \\\\t... are interpreted\n" printf "\t-n, --newline\t\tUse a newline as separator\n" return 0 ;; @@ -83,15 +84,25 @@ expandlist() esac done + # Backslash escapes in the separator (\n, \t, ...) are interpreted, so the + # configuration file can express a newline as EXPANDLIST_DEFAULT_SEPARATOR=\n + printf -v separator '%b' "$separator" + local item="" result="" matched=0 + # Restore the caller's nullglob setting on exit instead of forcing it off. + local _saved_nullglob + _saved_nullglob=$(shopt -p nullglob) shopt -s nullglob + # An empty IFS keeps the unquoted expansion below from splitting on spaces + # (it would cut "my dir/*.txt" in two) while globbing still happens. + local IFS= for item in "$@"; do local expanded=() # True glob expansion when wildcards are present. if [[ "$item" == *'*'* || "$item" == *'?'* || "$item" == *'['* ]]; then - # shellcheck disable=SC2206 # We actually want the word splitting + # shellcheck disable=SC2206 # Intentional glob expansion, no splitting (IFS is empty) expanded=( $item ) else expanded=( "$item" ) @@ -106,13 +117,19 @@ expandlist() if (( matched )); then result+="$separator" fi + # Escape what stays special inside double quotes so that the output + # can safely be re-evaluated (eval, command substitution, ...). + content=${content//\\/\\\\} + content=${content//\"/\\\"} + content=${content//\$/\\\$} + content=${content//\`/\\\`} result+="\"$content\"" matched=1 done unset content done - shopt -u nullglob + eval "$_saved_nullglob" printf '%s\n' "$result" } export -f expandlist @@ -140,9 +157,8 @@ clean() return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in @@ -190,13 +206,17 @@ clean() local dir f for dir in "${dirlist[@]}"; do - find "$dir" "${findopt[@]}" -type f \( -name "*~" -o -name "#*#" -o -name "*.bak" -o -name ".~*#" \) -print0 | + # "./" keeps a name starting with "-" from being read as a find option. + # -H follows a symlink given as argument (a link to a directory). + [[ $dir == -* ]] && dir="./$dir" + find -H "$dir" "${findopt[@]}" -type f \( -name "*~" -o -name "#*#" -o -name "*.bak" -o -name ".~*#" \) -print0 | while IFS= read -r -d '' f; do if (( outshell )); then + # %q prints a re-usable (shell-quoted) form of the name if (( ${#rmopt[@]} )); then - printf 'rm %s -- "%s"\n' "${rmopt[*]}" "$f" + printf 'rm %s -- %q\n' "${rmopt[*]}" "$f" else - printf 'rm -- "%s"\n' "$f" + printf 'rm -- %q\n' "$f" fi elif (( force )); then # No confirmation: rm reads no stdin, so leave it attached @@ -233,11 +253,11 @@ mcd() disp E "Missing parameter. Use \"mcd --help\" to display usage." return 1 fi - if ! mkdir -pv "$1"; then + if ! mkdir -pv -- "$1"; then disp E "Failed to create directory \"$1\"." return 1 fi - if ! cd "$1"; then + if ! cd -- "$1"; then disp E "Failed to change to directory \"$1\"." return 1 fi @@ -268,9 +288,8 @@ rmspc() disp E "Invalid options, use \"rmspc --help\" to display usage." return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in @@ -280,11 +299,12 @@ rmspc() printf "Options:\n" printf "\t-h, --help\t\tDisplay that help screen\n" printf "\t-r, --recursive\t\tTreat subdirectories of the given directory\n" - printf "\t-c, --subst-char\tChange the replacement character (default is underscore)\n" + printf "\t-c, --subst-char[=C]\tChange the replacement character (default is underscore)\n" printf "\t-v, --verbose\t\tDisplay more details (recursive mode only)\n" printf "\t-s, --shell\t\tDo nothing and display commands that would be executed\n\n" - printf "Note: if the --subst-char option is given without parameters, spaces will be\n" - printf " replaced with nothing (concatenation).\n" + printf "Note: the character must be attached (-c- or --subst-char=-). If the option\n" + printf " is given without a character, spaces are replaced with nothing\n" + printf " (concatenation). An existing file is never overwritten.\n" return 0 ;; -r|--recursive) @@ -315,16 +335,36 @@ rmspc() esac done + # getopt only attaches an optional argument written as -cX or --subst-char=X; + # "-c X" leaves X behind as a positional argument, which rmspc does not take + # (it works on the current directory) and would otherwise silently ignore. + if [[ $# -gt 0 ]]; then + disp E "Unexpected argument '$1'. The replacement character must be attached: -c$1 or --subst-char=$1" + return 1 + fi + [[ "$substchar" == "none" ]] && substchar="" + # A slash would turn the new name into a path + if [[ "$substchar" == */* ]]; then + disp E "The replacement character must not contain a slash." + return 1 + fi (( verb )) && mvopt=(-v) + # Restore the caller's nullglob setting on exit instead of forcing it off. + local _saved_nullglob + _saved_nullglob=$(shopt -p nullglob) shopt -s nullglob local f for f in *; do - if (( recurs )) && [[ -d "$f" ]]; then + # Symlinks to directories are not followed (no escape from the tree, + # no loops). + if (( recurs )) && [[ -d "$f" && ! -L "$f" ]]; then ( local lastdir=$f - (( verb )) && disp I "Entering directory $(pwd)/$f ..." + if (( verb )); then + disp I "Entering directory $(pwd)/$f ..." + fi pushd "$f" >/dev/null || return 1 # Build the recursive argument list by value: the ${var:+-flag} @@ -333,25 +373,36 @@ rmspc() local rargs=(-r) (( verb )) && rargs+=(-v) (( shell )) && rargs+=(-s) - (( substchar_set )) && rargs+=(-c "$substchar") + # Attached form, mandatory for an optional argument (see above) + (( substchar_set )) && rargs+=(--subst-char="$substchar") rmspc "${rargs[@]}" popd >/dev/null || return 1 - (( verb )) && disp I "Leaving directory $(pwd)/$lastdir" + # "if" rather than "(( verb )) && ...": the subshell status is + # that of its last command and must stay 0 when verb is off. + if (( verb )); then + disp I "Leaving directory $(pwd)/$lastdir" + fi ) fi if [[ "$f" == *" "* ]]; then - local newf="${f// /${substchar}}" + # Quoted replacement: an unquoted "&" would stand for the match + local newf="${f// /"$substchar"}" [[ "$f" == "$newf" ]] && continue + # Never overwrite (mv would silently replace an existing file) + if [[ -e "$newf" || -L "$newf" ]]; then + disp E "Cannot rename \"$f\": \"$newf\" already exists." + continue + fi if (( shell )); then if (( ${#mvopt[@]} )); then - printf 'mv %s -- "%s" "%s"\n' "${mvopt[*]}" "$f" "$newf" + printf 'mv %s -- %q %q\n' "${mvopt[*]}" "$f" "$newf" else - printf 'mv -- "%s" "%s"\n' "$f" "$newf" + printf 'mv -- %q %q\n' "$f" "$newf" fi else - mv "${mvopt[@]}" -- "$f" "$newf" || { + mv -n "${mvopt[@]}" -- "$f" "$newf" || { disp E "Failed renaming \"$f\" to \"$newf\"." continue } @@ -359,7 +410,7 @@ rmspc() fi done unset f - shopt -u nullglob + eval "$_saved_nullglob" } export -f rmspc # ------------------------------------------------------------------------------ @@ -369,7 +420,7 @@ export -f rmspc # Display statistics about a file tree # Usage: file_stats [options] [path] # Options: -# -H, --human Human readable sizes\n" +# -H, --human Human readable sizes # -d, --details Display details (min/max/average/median) # -m, --average Display only average size # -M, --median Display only median size @@ -394,9 +445,8 @@ file_stats() disp E "Invalid options, use \"file_stats --help\" to display usage." return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in @@ -476,16 +526,23 @@ file_stats() done [[ -n "$1" ]] && path="$1" + if [[ ! -e "$path" ]]; then + disp E "file_stats: no such file or directory: ${path}" + return 1 + fi + # "./" keeps a name starting with "-" from being read as a find option + [[ $path == -* ]] && path="./$path" # Prepare find filters local find_cmd=(find "$path" -type f) - # Single extension filter + # Single extension filter (case-insensitive). When combined with + # --ext-list, a file must satisfy both, as find ANDs the tests. if [[ -n "$ext_filter" ]]; then find_cmd+=(-iname "*.$ext_filter") fi - # Extension list filter + # Extension list filter: ( -iname *.a -o -iname *.b ... ) local exts if [[ -n "$ext_list" ]]; then IFS=',' read -ra exts <<< "$ext_list" @@ -507,20 +564,35 @@ file_stats() return 1 fi fi + local bytes if [[ -n "$min_size" ]]; then - find_cmd+=(-size +"$(numfmt --from=iec "$min_size")"c) + bytes=$(numfmt --from=iec "$min_size" 2>/dev/null) || { + disp E "file_stats: invalid --min size: ${min_size}" + return 1 + } + # find's "-size +N" means "strictly more than N bytes"; the minimum + # is inclusive, hence N-1 (nothing to filter for a minimum of 0). + (( bytes > 0 )) && find_cmd+=(-size +"$(( bytes - 1 ))"c) fi if [[ -n "$max_size" ]]; then - find_cmd+=(-size -"$(( $(numfmt --from=iec "$max_size") + 1 ))"c) + bytes=$(numfmt --from=iec "$max_size" 2>/dev/null) || { + disp E "file_stats: invalid --max size: ${max_size}" + return 1 + } + # "-size -N" means "less than N bytes": N+1 makes the maximum inclusive. + find_cmd+=(-size -"$(( bytes + 1 ))"c) fi - # Execution + # Execution: one size per line, sorted ascending (the median needs it), + # then summarised by awk. The path goes through ENVIRON, as awk -v would + # interpret backslashes in it. "${find_cmd[@]}" -printf "%s\n" 2>/dev/null | sort -n | \ + FILE_STATS_PATH="$path" \ awk -v human="$human" -v details="$details" -v only_avg="$only_avg" \ -v only_med="$only_med" -v only_count="$only_count" \ - -v only_total="$only_total" -v show_all="$show_all" -v path="$path" ' - # Convert function - function human_readable(x) { + -v only_total="$only_total" -v show_all="$show_all" ' + # Convert function (i and units are local: extra parameters) + function human_readable(x, i, units) { split("B KiB MiB GiB TiB", units) i = 1 while (x >= 1024 && i < 5) { @@ -541,14 +613,17 @@ file_stats() total += $1 if (min == "" || $1 < min) min = $1 if (max == "" || $1 > max) max = $1 - if ($1 == 0) bucket[0]++ - else { - b = int(log($1)/log(1024)) - bucket[b]++ - } + # Histogram bucket b holds sizes in [1024^b, 1024^(b+1)); bucket 0 + # also takes empty files. Computed with an integer loop: a + # log() ratio can fall just below an exact power of 1024. + v = $1; b = 0 + while (v >= 1024) { v = int(v / 1024); b++ } + bucket[b]++ + if (b > maxb) maxb = b } END { + path = ENVIRON["FILE_STATS_PATH"] count = NR if (count == 0) { print "No files found." @@ -584,8 +659,10 @@ file_stats() if (details) { print "\nSize histogram:" - # Use a separate array for the loop to avoid collision - for (b in bucket) { + # Walk the buckets in ascending order (awk "for (b in array)" gives + # no defined order); empty buckets are skipped. + for (b = 0; b <= maxb; b++) { + if (!(b in bucket)) continue # Pre-calculate label parts # 1024^0 = 1 (B), 1024^1 = 1K, etc. low = (b == 0) ? 0 : (1024^b) @@ -595,7 +672,6 @@ file_stats() (b == 0) ? "0" : human_readable(low), human_readable(high)) - # We store buckets in an array, access them by index b printf "%-25s : %6d files\n", label, bucket[b] } } @@ -624,9 +700,8 @@ findbig() disp E "Invalid options, use \"findbig --help\" to display usage." return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in @@ -646,10 +721,6 @@ findbig() ;; -l|--limit) limit="$2" - [[ "$limit" =~ ^[0-9]+$ ]] || { - disp E "Invalid limit: must be a positive integer." - return 1 - } shift 2 ;; -x|--one-fs) @@ -667,7 +738,16 @@ findbig() esac done + # Checked here so that the configured default (FINDBIG_DEFAULT_LIMIT) is + # validated as well as -l. + if [[ ! "$limit" =~ ^[1-9][0-9]*$ ]]; then + disp E "Invalid limit '${limit}': must be a positive integer." + return 1 + fi + local dir="${1:-.}" + # "./" keeps a name starting with "-" from being read as a find option + [[ $dir == -* ]] && dir="./$dir" # Prepare find arguments in an array for cleaner handling local find_args=(-L "$dir") @@ -731,9 +811,8 @@ findzero() disp E "Invalid options, use \"findzero --help\" to display usage." return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in @@ -770,7 +849,18 @@ findzero() done local dir="${1:-.}" - local find_args=("-L" "$dir" "-type" "f" "-empty") + # "./" keeps a name starting with "-" from being read as a find option + [[ $dir == -* ]] && dir="./$dir" + + # Symlinks are followed to report/count empty targets, but never when + # deleting: with -L, a link to an empty file matches and "delete" would + # remove the link itself. + local follow=1 + (( delete )) && follow=0 + + local find_args=() + (( follow )) && find_args+=("-L") + find_args+=("$dir" "-type" "f" "-empty") (( one_fs )) && find_args+=("-xdev") @@ -779,13 +869,19 @@ findzero() _fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null) if [[ -n "$_fd" ]]; then - local _fd_args=(--follow --no-ignore --hidden --absolute-path -t f --size -1b) + local _fd_args=(--no-ignore --hidden --absolute-path -t f --size -1b) + (( follow )) && _fd_args+=(--follow) (( one_fs )) && _fd_args+=(--one-file-system) _fd_args+=(. "$dir") + # fd's "--size -1b" means "at most 1 byte", so it also lists files of + # exactly one byte. Every result is therefore re-checked with [[ -s ]] + # (true when the size is not zero) and skipped if not empty: deleting a + # one-byte file would be data loss. if (( delete )); then disp W "Deleting empty files in $dir..." local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do + [[ -s "$f" ]] && continue printf "%s\n" "$f" rm -f -- "$f" done @@ -793,11 +889,16 @@ findzero() elif (( details )); then local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do + [[ -s "$f" ]] && continue ls -ls -- "$f" done unset f else - "$_fd" "${_fd_args[@]}" 2>/dev/null + local f + "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do + if [[ ! -s "$f" ]]; then printf "%s\n" "$f"; fi + done + unset f fi elif (( delete )); then disp W "Deleting empty files in $dir..." @@ -831,9 +932,8 @@ finddead() disp E "Invalid options, use \"finddead --help\" to display usage." return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in @@ -870,6 +970,8 @@ finddead() done local dir="${1:-.}" + # "./" keeps a name starting with "-" from being read as a find option + [[ $dir == -* ]] && dir="./$dir" # -xtype l searches for links that do not point to an existing file local find_args=("$dir" "-xtype" "l") (( one_fs )) && find_args+=("-xdev") @@ -887,19 +989,19 @@ finddead() disp W "Deleting dead symlinks in $dir..." local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do - [[ -e "$f" ]] || { printf "%s\n" "$f"; rm -f -- "$f"; } + if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; rm -f -- "$f"; fi done unset f elif (( details )); then local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do - [[ -e "$f" ]] || ls -ls -- "$f" + if [[ ! -e "$f" ]]; then ls -ls -- "$f"; fi done unset f else local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do - [[ -e "$f" ]] || printf "%s\n" "$f" + if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; fi done unset f fi