How to Create a Bash File Finder

How to Create a Bash File Finder

There’s a difference between “searching for a file by name” and what I’d call a proper file finder — something that lets me hunt files down by size, type, age, permissions, or content, not just a filename fragment. I built this as a follow-up to my simpler search tool once I realized how often I needed more advanced filtering — things like “find every file over 100MB I haven’t touched in six months” or “find every world-writable file in this directory tree.” This article covers building that more powerful finder from the ground up.

How This Differs From a Simple Search Tool

If you’ve read the Bash File Search Tool article in this series, you’ll notice some overlap — both are built on find. The difference is scope: the search tool is optimized for the common case (searching by name, fast), while this finder tool is built for flexible, multi-criteria filtering: size, age, permissions, ownership, and type, combined in whatever way you need.

Prerequisites

  • Bash 4.0+
  • find, stat, and du (all standard on virtually every Linux/macOS install)

Step 1: A Flexible Argument-Driven Finder

Rather than positional arguments, this tool works better with named flags, since we’re supporting many optional filters. Let’s build that parsing logic first.

#!/usr/bin/env bash
set -euo pipefail

DIR="."
NAME=""
MIN_SIZE=""
MAX_SIZE=""
OLDER_THAN=""
NEWER_THAN=""
TYPE=""

usage() {
    cat <<EOF
Usage: $0 [options]
  -d DIR         Directory to search (default: current directory)
  -n NAME        Filename pattern (supports wildcards)
  -s SIZE        Minimum size (e.g. 100M, 1G)
  -S SIZE        Maximum size
  -o DAYS        Older than N days
  -w DAYS        Newer than (within) N days
  -t TYPE        f (file), d (directory), l (symlink)
EOF
    exit 1
}

while getopts "d:n:s:S:o:w:t:h" opt; do
    case "$opt" in
        d) DIR="$OPTARG" ;;
        n) NAME="$OPTARG" ;;
        s) MIN_SIZE="$OPTARG" ;;
        S) MAX_SIZE="$OPTARG" ;;
        o) OLDER_THAN="$OPTARG" ;;
        w) NEWER_THAN="$OPTARG" ;;
        t) TYPE="$OPTARG" ;;
        h) usage ;;
        *) usage ;;
    esac
done

Understanding getopts

  • getopts "d:n:s:S:o:w:t:h" is Bash’s built-in flag parser. Each letter represents an accepted flag; a colon after a letter (like d:) means that flag requires an argument, while a bare letter (like h) is a standalone toggle flag.
  • Inside the while loop, $opt holds the current flag letter and $OPTARG holds its argument value (if any) — this is the standard, POSIX-compliant way to parse command-line flags in a shell script, and it’s far more robust than manually parsing $1, $2, etc. when you have many optional flags.
  • usage() is defined as a function with a heredoc (cat <<EOF ... EOF), which is the cleanest way to print multi-line help text in Bash without a long chain of echo statements.

Step 2: Building the find Command Dynamically

Now let’s translate our parsed options into an actual find invocation, built up piece by piece as a Bash array:

FIND_ARGS=("$DIR")

[[ -n "$TYPE" ]] && FIND_ARGS+=(-type "$TYPE")
[[ -n "$NAME" ]] && FIND_ARGS+=(-iname "$NAME")
[[ -n "$MIN_SIZE" ]] && FIND_ARGS+=(-size "+${MIN_SIZE}")
[[ -n "$MAX_SIZE" ]] && FIND_ARGS+=(-size "-${MAX_SIZE}")
[[ -n "$OLDER_THAN" ]] && FIND_ARGS+=(-mtime "+${OLDER_THAN}")
[[ -n "$NEWER_THAN" ]] && FIND_ARGS+=(-mtime "-${NEWER_THAN}")

find "${FIND_ARGS[@]}" 2>/dev/null

What’s Happening Here

  • FIND_ARGS=("$DIR") initializes our array with just the search directory as the first element, since that’s always the first positional argument find expects.
  • Each [[ -n "$X" ]] && FIND_ARGS+=(...) line conditionally appends flags to the array only if the user actually supplied that option — this is a compact Bash idiom combining a test and an action on one line using &&.
  • -size "+${MIN_SIZE}" — the + prefix means “greater than,” matching find‘s own size syntax (e.g. +100M means files larger than 100 megabytes). Similarly, -size "-${MAX_SIZE}" with a - prefix means “less than.”
  • -mtime "+${OLDER_THAN}" means “modified more than N days ago,” and -mtime "-${NEWER_THAN}" means “modified less than N days ago” — this mirrors the same +/- convention used for size.
  • find "${FIND_ARGS[@]}" expands our dynamically built array into the actual command, with each element correctly quoted as a separate argument — critical for handling patterns or paths containing spaces.

Step 3: Adding Permission-Based Filtering

Finding files with dangerous permissions (like world-writable files) is a genuinely useful security auditing feature:

PERM_FILTER="${PERM_FILTER:-}"

case "$PERM_FILTER" in
    world-writable)
        FIND_ARGS+=(-perm -0002)
        ;;
    world-readable)
        FIND_ARGS+=(-perm -0004)
        ;;
    setuid)
        FIND_ARGS+=(-perm -4000)
        ;;
esac

-perm -0002 uses find‘s permission-matching syntax: the leading - means “all of these permission bits must be set” (as opposed to + in older find versions, or no prefix for an exact match). 0002 is the octal representation of “write permission for others” — a classic security red flag when found on sensitive files.

Step 4: Formatting Output With Details

Raw paths are fine, but for a finder tool I want size, permissions, and modified date visible immediately:

echo "Search results:"
echo "---------------"

find "${FIND_ARGS[@]}" 2>/dev/null | while IFS= read -r path; do
    if [[ -e "$path" ]]; then
        perms=$(stat -c "%A" "$path" 2>/dev/null || stat -f "%Sp" "$path" 2>/dev/null)
        size=$(du -h "$path" 2>/dev/null | cut -f1)
        modified=$(stat -c "%y" "$path" 2>/dev/null | cut -d'.' -f1 || stat -f "%Sm" "$path" 2>/dev/null)
        printf "%-10s %-8s %-20s %s\n" "$perms" "$size" "$modified" "$path"
    fi
done

stat -c "%A" prints permissions in the familiar rwxr-xr-x style on Linux, with stat -f "%Sp" as the macOS/BSD fallback — the same cross-platform pattern we’ve used consistently throughout this series.

Full Combined Script

#!/usr/bin/env bash
set -euo pipefail

DIR="."
NAME=""
MIN_SIZE=""
MAX_SIZE=""
OLDER_THAN=""
NEWER_THAN=""
TYPE=""
PERM_FILTER=""

usage() {
    cat <<EOF
Usage: $0 [options]
  -d DIR      Directory to search (default: .)
  -n NAME     Filename pattern
  -s SIZE     Minimum size (e.g. 100M)
  -S SIZE     Maximum size
  -o DAYS     Older than N days
  -w DAYS     Newer than N days
  -t TYPE     f, d, or l
  -p PERM     world-writable, world-readable, setuid
EOF
    exit 1
}

while getopts "d:n:s:S:o:w:t:p:h" opt; do
    case "$opt" in
        d) DIR="$OPTARG" ;;
        n) NAME="$OPTARG" ;;
        s) MIN_SIZE="$OPTARG" ;;
        S) MAX_SIZE="$OPTARG" ;;
        o) OLDER_THAN="$OPTARG" ;;
        w) NEWER_THAN="$OPTARG" ;;
        t) TYPE="$OPTARG" ;;
        p) PERM_FILTER="$OPTARG" ;;
        h|*) usage ;;
    esac
done

FIND_ARGS=("$DIR")
[[ -n "$TYPE" ]] && FIND_ARGS+=(-type "$TYPE")
[[ -n "$NAME" ]] && FIND_ARGS+=(-iname "$NAME")
[[ -n "$MIN_SIZE" ]] && FIND_ARGS+=(-size "+${MIN_SIZE}")
[[ -n "$MAX_SIZE" ]] && FIND_ARGS+=(-size "-${MAX_SIZE}")
[[ -n "$OLDER_THAN" ]] && FIND_ARGS+=(-mtime "+${OLDER_THAN}")
[[ -n "$NEWER_THAN" ]] && FIND_ARGS+=(-mtime "-${NEWER_THAN}")

case "$PERM_FILTER" in
    world-writable) FIND_ARGS+=(-perm -0002) ;;
    world-readable) FIND_ARGS+=(-perm -0004) ;;
    setuid) FIND_ARGS+=(-perm -4000) ;;
esac

echo "Search results:"
echo "---------------"
find "${FIND_ARGS[@]}" 2>/dev/null | while IFS= read -r path; do
    perms=$(stat -c "%A" "$path" 2>/dev/null || stat -f "%Sp" "$path" 2>/dev/null)
    size=$(du -h "$path" 2>/dev/null | cut -f1)
    printf "%-11s %-8s %s\n" "$perms" "$size" "$path"
done

Example usage:

./ffinder.sh -d /var/log -S 50M -o 30
./ffinder.sh -d /var/www -p world-writable
./ffinder.sh -n "*.bak" -o 90

Real-World Use Cases

  • Disk cleanup audits — finding every file over a certain size that hasn’t been touched in months.
  • Security audits — hunting for world-writable or setuid files across a system, a classic hardening check.
  • Log management — finding logs older than your retention policy for cleanup or archival.
  • Storage forensics — identifying which specific files are consuming unexpected disk space on a server.

Automation Ideas

Weekly report of large, old files as cleanup candidates:

0 9 * * 1 /usr/local/bin/ffinder.sh -d /home -S 500M -o 180 | mail -s "Weekly Large File Report" you@example.com

Security Considerations

  • Permission-scanning features expose sensitive information about your filesystem — restrict who can run this tool and where its output is sent, especially the world-writable/setuid audit modes.
  • Never automatically delete files found by this tool without a human review step first — a finder should inform decisions, not make destructive ones on its own.
  • Setuid file audits are genuinely security-critical: unexpected setuid binaries are a common privilege escalation vector, so treat any unexpected results from -p setuid as worth investigating immediately.

Optimization Tips

  • Combine multiple filters in a single find invocation (as we do) rather than chaining separate find calls with pipes — this avoids redundant filesystem traversal.
  • Use -maxdepth when you know you don’t need to search deeply nested subdirectories, which can dramatically cut search time on large trees.
  • For repeated searches against a mostly-static filesystem, consider building a locate database with updatedb and using locate for the initial fast pass, then find for precise filtering.

Troubleshooting

  • -size filter doesn’t seem to match expected files — remember find‘s size units default to 512-byte blocks unless you specify a suffix like M or G; always include the unit suffix to avoid confusion.
  • Permission filter returns nothing on a system you know has issues — double-check you’re running with sufficient privileges to traverse the directories in question; permission-denied directories are silently skipped.
  • Script errors with “invalid option”getopts requires flags to come before any non-flag arguments; make sure you’re not mixing positional arguments into the flag list.

Common Mistakes to Avoid

  • Forgetting find‘s default size unit is 512-byte blocks, not bytes, if you omit a suffix.
  • Running permission audits as an unprivileged user and assuming a clean result means the whole system is clean — you may simply lack visibility into some directories.
  • Not testing filter combinations individually before combining several at once, making it hard to debug why a search returns nothing.

Frequently Asked Questions

Can I search by owner or group? Yes — extend the script with -user and -group flags mapped to find‘s native -user USERNAME and -group GROUPNAME options, following the same pattern used for the other filters.

How do I search file contents in addition to metadata? Pipe results into grep -l "search term": find ... -type f | xargs grep -l "search term".

Is this safe to run against a live production system? Yes, find itself is read-only and safe. Just be mindful of I/O load on very large filesystems, and avoid running exhaustive searches during peak traffic hours on busy production servers.

Summary

We built a flexible Bash file finder using getopts for clean flag-based argument parsing, dynamically assembling a find command from whichever filters the user actually specifies — by size, age, type, and permissions — with formatted, informative output. The core skill this article reinforces is building command-line tools with proper flag parsing rather than fragile positional arguments, which scales far better once a tool grows past two or three options.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Create a Bash File Comparer

How to Create a Bash File Comparer

Next Post
How to Create a Bash File Diff Tool

How to Create a Bash File Diff Tool

Related Posts