diff options
| -rw-r--r-- | README.md | 53 | ||||
| -rwxr-xr-x | bin/shuriken | 164 | ||||
| -rw-r--r-- | src/lib/album-photo-select.source.sh | 4 | ||||
| -rw-r--r-- | src/lib/compat.source.sh | 134 | ||||
| -rw-r--r-- | src/lib/config.staging.source.sh | 4 | ||||
| -rw-r--r-- | src/lib/image.source.sh | 8 | ||||
| -rw-r--r-- | src/lib/metadata-cache.source.sh | 4 | ||||
| -rw-r--r-- | src/lib/photo-list.source.sh | 6 | ||||
| -rw-r--r-- | src/lib/random.source.sh | 4 | ||||
| -rwxr-xr-x | tests/cli.sh | 93 |
10 files changed, 394 insertions, 80 deletions
@@ -8,13 +8,46 @@ JavaScript. ## Platform compatibility -shuriken relies on **GNU** versions of the standard Unix tools. Specifically, -it uses GNU-only extensions such as `find -printf`, `stat -c`, `cp -a`, and -`sort -R`, which are not supported by the BSD variants shipped with macOS and -BSD systems. As a result shuriken is currently **Linux-only** and will not run -unmodified on macOS or BSD-based systems (including the stock tools that ship -with macOS). If you are on macOS, run shuriken inside a Linux container or VM -instead. +shuriken relies on **GNU** versions of four standard Unix tools: it uses the +GNU-only extensions `find -printf`, `stat -c`, `cp -a`, and `sort -R`, which +are not supported by the BSD variants of those tools shipped with macOS and +FreeBSD. shuriken runs on Linux, macOS, and FreeBSD: at startup it resolves +each of the four tools to whichever binary is actually GNU, preferring a +`g`-prefixed sibling (`gfind`, `gstat`, `gcp`, `gsort`) over the plain name +when one is on `PATH` (mirroring the tool-selection variables used by the +sibling [gemtexter](https://codeberg.org/snonux/gemtexter) project). On Linux +the plain names are already GNU, so nothing extra is required. On macOS and +FreeBSD you must install GNU coreutils/findutils first — shuriken verifies at +startup that the resolved tools are genuinely GNU and exits with a clear error +naming the missing tool otherwise, rather than failing confusingly deep inside +generation. + +### Installing GNU coreutils on macOS + +Via [Homebrew](https://brew.sh): + +```sh +brew install coreutils findutils +``` + +Homebrew installs these under their GNU names prefixed with `g` +(`gstat`, `gcp`, `gsort` from `coreutils`; `gfind` from `findutils`) so they +do not clobber the system BSD tools of the same bare name; shuriken picks them +up automatically. (`brew install gnu-sed grep` are not required by shuriken +today, but are commonly installed alongside for other GNU-reliant scripts.) + +### Installing GNU coreutils on FreeBSD + +Via `pkg`: + +```sh +pkg install coreutils findutils +``` + +This installs the GNU tools under their `g`-prefixed names (`gstat`, `gcp`, +`gsort`, `gfind`) alongside the base-system BSD tools, which shuriken picks up +automatically. (`pkg install gsed gnugrep` are not required by shuriken today +but provide `gsed`/`ggrep` for other GNU-reliant scripts.) ## Example site @@ -43,9 +76,9 @@ shuriken --clean # remove ./dist and leftover staging dirs ``` ImageMagick (`magick` or `convert`) and Bash 5.1 or newer are required. -GNU coreutils/findutils (the default `find`, `stat`, `cp`, and `sort` on a -Linux distribution) are also required; the BSD/macOS equivalents are not -sufficient (see *Platform compatibility* above). +GNU coreutils/findutils (`find`, `stat`, `cp`, and `sort`) are also required; +on Linux the default tools already are GNU, on macOS/FreeBSD install the +`g`-prefixed GNU versions first (see *Platform compatibility* above). ## Main flags diff --git a/bin/shuriken b/bin/shuriken index a475a3a..5b8f974 100755 --- a/bin/shuriken +++ b/bin/shuriken @@ -115,37 +115,113 @@ log_warning() { } # Inlined from src/lib/compat.source.sh -# Runtime compatibility guard: verify the standard Unix tools shuriken shells -# out to are the GNU variants. shuriken relies on GNU-only extensions -- find -# -printf, stat -c, cp -a, and sort -R -- that the BSD tools shipped with macOS -# and BSD systems do not provide (see the "Platform compatibility" section of -# README.md). require_gnu_tools probes each feature in a throwaway temp dir and -# exits with a clear error naming the offending tool if any probe fails, so the -# check runs before any real work begins (it is called from main, after CLI -# parsing and before run_action). +# Cross-platform tool resolution and runtime compatibility guard. # -# The probes intentionally exercise the exact GNU-only behavior the codebase -# depends on rather than parsing --help text (which is unstable across -# implementations), so a tool that lacks the feature fails the probe regardless -# of its version string. +# shuriken relies on GNU-only extensions of four standard Unix tools -- find +# -printf, stat -c, cp -a, and sort -R (see the "Platform compatibility" +# section of README.md). Those extensions are not supported by the BSD +# variants of the same tools shipped with macOS and FreeBSD. Rather than being +# Linux-only, shuriken resolves each of the four tools to a variable (FIND, +# STAT, CP, SORT) once at startup: on Linux the plain command is already GNU, +# and on macOS/FreeBSD the GNU version is normally installed side-by-side +# under a "g" prefix (gfind, gstat, gcp, gsort, via Homebrew/pkg -- see +# README.md) to avoid clobbering the system tools. This mirrors the +# $SED/$GREP/$DATE tool-selection variables in the sibling gemtexter project +# (github.com/snonux/gemtexter). Every call site elsewhere in the codebase +# that depends on the GNU-only behavior uses these variables instead of the +# bare command name; call sites that only need POSIX-portable behavior are +# left as plain "find"/"sort"/etc and are unaffected by any of this. +# +# require_gnu_tools (called from main, after CLI parsing and before +# run_action) verifies the resolved tools are genuinely GNU before any real +# work begins, so a misconfigured host fails fast with a clear error instead +# of failing confusingly deep inside generation. + +# Resolve the binary name for a single bare coreutils/findutils tool name: +# the GNU-prefixed variant (e.g. "gfind" for "find") if one is on PATH, +# otherwise the bare name unchanged. On Linux a "g"-prefixed sibling is not +# normally installed, so this resolves to the bare (already-GNU) name; on +# macOS/FreeBSD it picks up the Homebrew/pkg-installed GNU version. No uname +# check is needed: preferring the g-prefixed binary when present is correct +# on every platform. +resolve_gnu_tool() { + local -r bare_name="$1" + + if command -v "g$bare_name" >/dev/null 2>&1; then + printf '%s\n' "g$bare_name" + else + printf '%s\n' "$bare_name" + fi +} + +FIND=$(resolve_gnu_tool find) +STAT=$(resolve_gnu_tool stat) +CP=$(resolve_gnu_tool cp) +SORT=$(resolve_gnu_tool sort) +readonly FIND STAT CP SORT + +# Quick preflight: confirm each resolved tool self-reports as GNU before +# running the (slower) behavioral probes in require_gnu_tools. This gives a +# fast, specific error on a genuine BSD/macOS host that has no g-prefixed +# tools installed at all, naming exactly which tool and pointing at the +# README install instructions, rather than only failing on the deeper +# behavioral probe below. A tool whose --version output happens to lie (or +# omit a version string) still falls through to the behavioral probe, which +# is authoritative. +verify_gnu_tool_versions() { + local -r tool="$1" + local -r label="$2" + local version_output + + version_output=$("$tool" --version 2>/dev/null || true) + if [[ "$version_output" != *GNU* ]]; then + printf 'ERROR: "%s" (%s) does not report itself as GNU.\n' \ + "$tool" "$label" >&2 + printf 'ERROR: Install GNU coreutils/findutils -- see the\n' >&2 + printf 'ERROR: "Platform compatibility" section of README.md.\n' >&2 + return 1 + fi +} +# Runtime compatibility guard: verify the resolved tools (FIND/STAT/CP/SORT) +# actually behave like the GNU variants shuriken depends on. Each probe +# exercises the exact GNU-only behavior the codebase relies on (rather than +# parsing --help/--version text, which is unstable across implementations), +# so a tool that merely claims to be GNU but lacks the feature still fails +# here. Called from main, after CLI parsing and before run_action, so the +# check runs before any real work begins. require_gnu_tools() { local probe_dir - local probe_out - local failed='' + + verify_gnu_tool_versions "$FIND" find || return 1 + verify_gnu_tool_versions "$STAT" stat || return 1 + verify_gnu_tool_versions "$CP" cp || return 1 + verify_gnu_tool_versions "$SORT" sort || return 1 probe_dir=$(mktemp -d 2>/dev/null) || return 1 # shellcheck disable=SC2064 # expand probe_dir now, clean up on any return trap "rm -rf '$probe_dir'" RETURN + probe_gnu_tool_behavior "$probe_dir" +} + +# The behavioral half of require_gnu_tools, split out to keep each function +# under ~30 lines (per repo convention): probes FIND/STAT/CP/SORT's actual +# GNU-only behavior in a throwaway temp dir and reports via +# report_gnu_tool_failure on the first failure. +probe_gnu_tool_behavior() { + local -r probe_dir="$1"; shift + local probe_out + local failed='' + # GNU find supports the -printf action; BSD/macOS find does not. - probe_out=$(find "$probe_dir" -maxdepth 0 -printf '%f\n' 2>/dev/null) \ + probe_out=$("$FIND" "$probe_dir" -maxdepth 0 -printf '%f\n' 2>/dev/null) \ && [ -n "$probe_out" ] || failed='find (missing the -printf action)' # GNU stat uses -c FORMAT; BSD/macOS stat uses -f and rejects -c. if [ -z "$failed" ]; then printf 'probe\n' > "$probe_dir/file" - probe_out=$(stat -c '%s' "$probe_dir/file" 2>/dev/null) \ + probe_out=$("$STAT" -c '%s' "$probe_dir/file" 2>/dev/null) \ && [[ "$probe_out" =~ ^[0-9]+$ ]] \ || failed='stat (missing the -c option)' fi @@ -154,7 +230,7 @@ require_gnu_tools() { if [ -z "$failed" ]; then mkdir "$probe_dir/src" printf 'x\n' > "$probe_dir/src/inner" - if ! cp -a "$probe_dir/src" "$probe_dir/dest" 2>/dev/null \ + if ! "$CP" -a "$probe_dir/src" "$probe_dir/dest" 2>/dev/null \ || [ ! -f "$probe_dir/dest/inner" ]; then failed='cp (missing the -a option)' fi @@ -162,19 +238,25 @@ require_gnu_tools() { # GNU sort supports -R (random shuffle); BSD sort lacks it. if [ -z "$failed" ]; then - probe_out=$(printf 'a\nb\nc\n' | sort -R 2>/dev/null) \ + probe_out=$(printf 'a\nb\nc\n' | "$SORT" -R 2>/dev/null) \ && [ -n "$probe_out" ] || failed='sort (missing the -R option)' fi - if [ -n "$failed" ]; then - printf 'ERROR: shuriken requires the GNU versions of the standard Unix\n' >&2 - printf 'ERROR: tools (GNU coreutils/findutils). Non-GNU or unsupported: %s\n' \ - "$failed" >&2 - printf 'ERROR: shuriken is Linux-only and will not run on macOS or BSD.\n' >&2 - return 1 - fi + [ -z "$failed" ] || report_gnu_tool_failure "$failed" +} - return 0 +# Shared error reporter for require_gnu_tools/probe_gnu_tool_behavior: prints +# the clear "install GNU tools" message naming the offending tool/feature and +# returns 1 (never exits directly, so callers stay in control of unwinding). +report_gnu_tool_failure() { + local -r failed="$1"; shift + + printf 'ERROR: shuriken requires the GNU versions of the standard Unix\n' >&2 + printf 'ERROR: tools (GNU coreutils/findutils). Non-GNU or unsupported: %s\n' \ + "$failed" >&2 + printf 'ERROR: On macOS/FreeBSD, install GNU coreutils/findutils (see the\n' >&2 + printf 'ERROR: "Platform compatibility" section of README.md) and retry.\n' >&2 + return 1 } # Inlined from src/lib/bootstrap.source.sh @@ -1741,11 +1823,14 @@ is_supported_image_file() { incoming_image_files() { local file + # $FIND (compat.source.sh): -printf is a GNU-only action, so this must run + # against the resolved GNU find, not whatever "find" plain resolves to on + # macOS/FreeBSD. The trailing sort is POSIX-portable and left as-is. while IFS= read -r file; do if is_supported_image_file "$file"; then printf '%s\n' "$file" fi - done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \ + done < <("$FIND" "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \ | sort } @@ -1790,11 +1875,12 @@ count_tree_files() { warn_unsupported_incoming_files() { local file + # $FIND (compat.source.sh): -printf is a GNU-only action. while IFS= read -r file; do if ! is_supported_image_file "$file"; then log_warning "Ignoring unsupported incoming file: $file" fi - done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort) + done < <("$FIND" "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort) } scalephotos() { @@ -1912,9 +1998,11 @@ maybe_shuffle() { if random_seed_is_set; then deterministic_shuffle else - sort -R + # $SORT (compat.source.sh): -R (random shuffle) is GNU-only. + "$SORT" -R fi else + # Plain sort here is POSIX-portable; no GNU-only flag involved. sort fi } @@ -1945,11 +2033,13 @@ maybe_shuffle() { # suppressed (the stats background loader) adds its own 2>/dev/null. This is the # SORTED listing; album_photo_files deliberately keeps its own maybe_shuffle # variant because the album's display order is the configurable shuffle, not a -# plain sort. +# plain sort. Uses $FIND (compat.source.sh): -printf is GNU-only, so this must +# run against the resolved GNU find, not whatever "find" plain resolves to on +# macOS/FreeBSD. The trailing plain "sort" is POSIX-portable and left as-is. list_photos() { local -r photos_dir="$1"; shift - find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' | sort + "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' | sort } # Pick one seeded-random entry from an already-collected list, addressed by @@ -2063,12 +2153,14 @@ exif_cache_dir() { # Build the cache signature line ("<photo>:<size>:<mtime>") used to decide # whether a cache entry is still valid for the source file. Kept private to this # module alongside its only consumers, plus the stats test that pre-seeds caches. +# Uses $STAT (compat.source.sh): -c FORMAT is GNU-only (BSD/macOS stat uses -f +# and rejects -c), so this must run against the resolved GNU stat. photo_cache_signature() { local -r photo="$1"; shift local -r photo_path="$1"; shift local stat_output - stat_output=$(stat -c '%s:%Y' "$photo_path") + stat_output=$("$STAT" -c '%s:%Y' "$photo_path") printf '%s:%s\n' "$photo" "$stat_output" } @@ -3416,11 +3508,11 @@ build_preview_thumbnail() { # Unlike the other photo listings this one keeps its own find rather than using # list_photos (photo-list.source.sh): it pipes through maybe_shuffle, not sort, # because the album's display order is the configurable (seeded) shuffle, not a -# plain sort. +# plain sort. Uses $FIND (compat.source.sh) since -printf is a GNU-only action. album_photo_files() { local -r photos_dir="$1"; shift - find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ + "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ | maybe_shuffle } @@ -6195,12 +6287,14 @@ prepare_generation_staging_dir() { return fi + # $CP (compat.source.sh): -a (archive mode) is not supported by minimal/older + # BSD cp variants, so this must run against the resolved GNU cp. for cache_dir in photos thumbs blurs; do if [ -d "$final_dist/$cache_dir" ]; then if ! mkdir -p "$staging_dir/$cache_dir"; then return 1 fi - if ! cp -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then + if ! "$CP" -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then return 1 fi fi diff --git a/src/lib/album-photo-select.source.sh b/src/lib/album-photo-select.source.sh index 06190f7..ea661b4 100644 --- a/src/lib/album-photo-select.source.sh +++ b/src/lib/album-photo-select.source.sh @@ -12,11 +12,11 @@ # Unlike the other photo listings this one keeps its own find rather than using # list_photos (photo-list.source.sh): it pipes through maybe_shuffle, not sort, # because the album's display order is the configurable (seeded) shuffle, not a -# plain sort. +# plain sort. Uses $FIND (compat.source.sh) since -printf is a GNU-only action. album_photo_files() { local -r photos_dir="$1"; shift - find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ + "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ | maybe_shuffle } diff --git a/src/lib/compat.source.sh b/src/lib/compat.source.sh index 718c7ed..63111fd 100644 --- a/src/lib/compat.source.sh +++ b/src/lib/compat.source.sh @@ -1,34 +1,110 @@ -# Runtime compatibility guard: verify the standard Unix tools shuriken shells -# out to are the GNU variants. shuriken relies on GNU-only extensions -- find -# -printf, stat -c, cp -a, and sort -R -- that the BSD tools shipped with macOS -# and BSD systems do not provide (see the "Platform compatibility" section of -# README.md). require_gnu_tools probes each feature in a throwaway temp dir and -# exits with a clear error naming the offending tool if any probe fails, so the -# check runs before any real work begins (it is called from main, after CLI -# parsing and before run_action). +# Cross-platform tool resolution and runtime compatibility guard. # -# The probes intentionally exercise the exact GNU-only behavior the codebase -# depends on rather than parsing --help text (which is unstable across -# implementations), so a tool that lacks the feature fails the probe regardless -# of its version string. +# shuriken relies on GNU-only extensions of four standard Unix tools -- find +# -printf, stat -c, cp -a, and sort -R (see the "Platform compatibility" +# section of README.md). Those extensions are not supported by the BSD +# variants of the same tools shipped with macOS and FreeBSD. Rather than being +# Linux-only, shuriken resolves each of the four tools to a variable (FIND, +# STAT, CP, SORT) once at startup: on Linux the plain command is already GNU, +# and on macOS/FreeBSD the GNU version is normally installed side-by-side +# under a "g" prefix (gfind, gstat, gcp, gsort, via Homebrew/pkg -- see +# README.md) to avoid clobbering the system tools. This mirrors the +# $SED/$GREP/$DATE tool-selection variables in the sibling gemtexter project +# (github.com/snonux/gemtexter). Every call site elsewhere in the codebase +# that depends on the GNU-only behavior uses these variables instead of the +# bare command name; call sites that only need POSIX-portable behavior are +# left as plain "find"/"sort"/etc and are unaffected by any of this. +# +# require_gnu_tools (called from main, after CLI parsing and before +# run_action) verifies the resolved tools are genuinely GNU before any real +# work begins, so a misconfigured host fails fast with a clear error instead +# of failing confusingly deep inside generation. + +# Resolve the binary name for a single bare coreutils/findutils tool name: +# the GNU-prefixed variant (e.g. "gfind" for "find") if one is on PATH, +# otherwise the bare name unchanged. On Linux a "g"-prefixed sibling is not +# normally installed, so this resolves to the bare (already-GNU) name; on +# macOS/FreeBSD it picks up the Homebrew/pkg-installed GNU version. No uname +# check is needed: preferring the g-prefixed binary when present is correct +# on every platform. +resolve_gnu_tool() { + local -r bare_name="$1" + + if command -v "g$bare_name" >/dev/null 2>&1; then + printf '%s\n' "g$bare_name" + else + printf '%s\n' "$bare_name" + fi +} + +FIND=$(resolve_gnu_tool find) +STAT=$(resolve_gnu_tool stat) +CP=$(resolve_gnu_tool cp) +SORT=$(resolve_gnu_tool sort) +readonly FIND STAT CP SORT +# Quick preflight: confirm each resolved tool self-reports as GNU before +# running the (slower) behavioral probes in require_gnu_tools. This gives a +# fast, specific error on a genuine BSD/macOS host that has no g-prefixed +# tools installed at all, naming exactly which tool and pointing at the +# README install instructions, rather than only failing on the deeper +# behavioral probe below. A tool whose --version output happens to lie (or +# omit a version string) still falls through to the behavioral probe, which +# is authoritative. +verify_gnu_tool_versions() { + local -r tool="$1" + local -r label="$2" + local version_output + + version_output=$("$tool" --version 2>/dev/null || true) + if [[ "$version_output" != *GNU* ]]; then + printf 'ERROR: "%s" (%s) does not report itself as GNU.\n' \ + "$tool" "$label" >&2 + printf 'ERROR: Install GNU coreutils/findutils -- see the\n' >&2 + printf 'ERROR: "Platform compatibility" section of README.md.\n' >&2 + return 1 + fi +} + +# Runtime compatibility guard: verify the resolved tools (FIND/STAT/CP/SORT) +# actually behave like the GNU variants shuriken depends on. Each probe +# exercises the exact GNU-only behavior the codebase relies on (rather than +# parsing --help/--version text, which is unstable across implementations), +# so a tool that merely claims to be GNU but lacks the feature still fails +# here. Called from main, after CLI parsing and before run_action, so the +# check runs before any real work begins. require_gnu_tools() { local probe_dir - local probe_out - local failed='' + + verify_gnu_tool_versions "$FIND" find || return 1 + verify_gnu_tool_versions "$STAT" stat || return 1 + verify_gnu_tool_versions "$CP" cp || return 1 + verify_gnu_tool_versions "$SORT" sort || return 1 probe_dir=$(mktemp -d 2>/dev/null) || return 1 # shellcheck disable=SC2064 # expand probe_dir now, clean up on any return trap "rm -rf '$probe_dir'" RETURN + probe_gnu_tool_behavior "$probe_dir" +} + +# The behavioral half of require_gnu_tools, split out to keep each function +# under ~30 lines (per repo convention): probes FIND/STAT/CP/SORT's actual +# GNU-only behavior in a throwaway temp dir and reports via +# report_gnu_tool_failure on the first failure. +probe_gnu_tool_behavior() { + local -r probe_dir="$1"; shift + local probe_out + local failed='' + # GNU find supports the -printf action; BSD/macOS find does not. - probe_out=$(find "$probe_dir" -maxdepth 0 -printf '%f\n' 2>/dev/null) \ + probe_out=$("$FIND" "$probe_dir" -maxdepth 0 -printf '%f\n' 2>/dev/null) \ && [ -n "$probe_out" ] || failed='find (missing the -printf action)' # GNU stat uses -c FORMAT; BSD/macOS stat uses -f and rejects -c. if [ -z "$failed" ]; then printf 'probe\n' > "$probe_dir/file" - probe_out=$(stat -c '%s' "$probe_dir/file" 2>/dev/null) \ + probe_out=$("$STAT" -c '%s' "$probe_dir/file" 2>/dev/null) \ && [[ "$probe_out" =~ ^[0-9]+$ ]] \ || failed='stat (missing the -c option)' fi @@ -37,7 +113,7 @@ require_gnu_tools() { if [ -z "$failed" ]; then mkdir "$probe_dir/src" printf 'x\n' > "$probe_dir/src/inner" - if ! cp -a "$probe_dir/src" "$probe_dir/dest" 2>/dev/null \ + if ! "$CP" -a "$probe_dir/src" "$probe_dir/dest" 2>/dev/null \ || [ ! -f "$probe_dir/dest/inner" ]; then failed='cp (missing the -a option)' fi @@ -45,17 +121,23 @@ require_gnu_tools() { # GNU sort supports -R (random shuffle); BSD sort lacks it. if [ -z "$failed" ]; then - probe_out=$(printf 'a\nb\nc\n' | sort -R 2>/dev/null) \ + probe_out=$(printf 'a\nb\nc\n' | "$SORT" -R 2>/dev/null) \ && [ -n "$probe_out" ] || failed='sort (missing the -R option)' fi - if [ -n "$failed" ]; then - printf 'ERROR: shuriken requires the GNU versions of the standard Unix\n' >&2 - printf 'ERROR: tools (GNU coreutils/findutils). Non-GNU or unsupported: %s\n' \ - "$failed" >&2 - printf 'ERROR: shuriken is Linux-only and will not run on macOS or BSD.\n' >&2 - return 1 - fi + [ -z "$failed" ] || report_gnu_tool_failure "$failed" +} + +# Shared error reporter for require_gnu_tools/probe_gnu_tool_behavior: prints +# the clear "install GNU tools" message naming the offending tool/feature and +# returns 1 (never exits directly, so callers stay in control of unwinding). +report_gnu_tool_failure() { + local -r failed="$1"; shift - return 0 + printf 'ERROR: shuriken requires the GNU versions of the standard Unix\n' >&2 + printf 'ERROR: tools (GNU coreutils/findutils). Non-GNU or unsupported: %s\n' \ + "$failed" >&2 + printf 'ERROR: On macOS/FreeBSD, install GNU coreutils/findutils (see the\n' >&2 + printf 'ERROR: "Platform compatibility" section of README.md) and retry.\n' >&2 + return 1 } diff --git a/src/lib/config.staging.source.sh b/src/lib/config.staging.source.sh index b76da58..e48f45b 100644 --- a/src/lib/config.staging.source.sh +++ b/src/lib/config.staging.source.sh @@ -29,12 +29,14 @@ prepare_generation_staging_dir() { return fi + # $CP (compat.source.sh): -a (archive mode) is not supported by minimal/older + # BSD cp variants, so this must run against the resolved GNU cp. for cache_dir in photos thumbs blurs; do if [ -d "$final_dist/$cache_dir" ]; then if ! mkdir -p "$staging_dir/$cache_dir"; then return 1 fi - if ! cp -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then + if ! "$CP" -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then return 1 fi fi diff --git a/src/lib/image.source.sh b/src/lib/image.source.sh index 32d1362..34e215a 100644 --- a/src/lib/image.source.sh +++ b/src/lib/image.source.sh @@ -45,11 +45,14 @@ is_supported_image_file() { incoming_image_files() { local file + # $FIND (compat.source.sh): -printf is a GNU-only action, so this must run + # against the resolved GNU find, not whatever "find" plain resolves to on + # macOS/FreeBSD. The trailing sort is POSIX-portable and left as-is. while IFS= read -r file; do if is_supported_image_file "$file"; then printf '%s\n' "$file" fi - done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \ + done < <("$FIND" "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \ | sort } @@ -94,11 +97,12 @@ count_tree_files() { warn_unsupported_incoming_files() { local file + # $FIND (compat.source.sh): -printf is a GNU-only action. while IFS= read -r file; do if ! is_supported_image_file "$file"; then log_warning "Ignoring unsupported incoming file: $file" fi - done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort) + done < <("$FIND" "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort) } scalephotos() { diff --git a/src/lib/metadata-cache.source.sh b/src/lib/metadata-cache.source.sh index 41ca8d5..971fea8 100644 --- a/src/lib/metadata-cache.source.sh +++ b/src/lib/metadata-cache.source.sh @@ -29,12 +29,14 @@ exif_cache_dir() { # Build the cache signature line ("<photo>:<size>:<mtime>") used to decide # whether a cache entry is still valid for the source file. Kept private to this # module alongside its only consumers, plus the stats test that pre-seeds caches. +# Uses $STAT (compat.source.sh): -c FORMAT is GNU-only (BSD/macOS stat uses -f +# and rejects -c), so this must run against the resolved GNU stat. photo_cache_signature() { local -r photo="$1"; shift local -r photo_path="$1"; shift local stat_output - stat_output=$(stat -c '%s:%Y' "$photo_path") + stat_output=$("$STAT" -c '%s:%Y' "$photo_path") printf '%s:%s\n' "$photo" "$stat_output" } diff --git a/src/lib/photo-list.source.sh b/src/lib/photo-list.source.sh index 9e7ce72..754e09e 100644 --- a/src/lib/photo-list.source.sh +++ b/src/lib/photo-list.source.sh @@ -23,11 +23,13 @@ # suppressed (the stats background loader) adds its own 2>/dev/null. This is the # SORTED listing; album_photo_files deliberately keeps its own maybe_shuffle # variant because the album's display order is the configurable shuffle, not a -# plain sort. +# plain sort. Uses $FIND (compat.source.sh): -printf is GNU-only, so this must +# run against the resolved GNU find, not whatever "find" plain resolves to on +# macOS/FreeBSD. The trailing plain "sort" is POSIX-portable and left as-is. list_photos() { local -r photos_dir="$1"; shift - find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' | sort + "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' | sort } # Pick one seeded-random entry from an already-collected list, addressed by diff --git a/src/lib/random.source.sh b/src/lib/random.source.sh index 5a1ef59..ca5c7d3 100644 --- a/src/lib/random.source.sh +++ b/src/lib/random.source.sh @@ -66,9 +66,11 @@ maybe_shuffle() { if random_seed_is_set; then deterministic_shuffle else - sort -R + # $SORT (compat.source.sh): -R (random shuffle) is GNU-only. + "$SORT" -R fi else + # Plain sort here is POSIX-portable; no GNU-only flag involved. sort fi } diff --git a/tests/cli.sh b/tests/cli.sh index 52952d5..c248b4c 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -6782,6 +6782,93 @@ FAKE test::teardown } +# resolve_gnu_tool (src/lib/compat.source.sh) must prefer a "g"-prefixed +# sibling over a broken plain-named tool, the macOS/FreeBSD layout produced by +# `brew install coreutils findutils` / `pkg install coreutils findutils` +# (see README.md's "Platform compatibility" section): the GNU tool is +# installed as "gfind" alongside the system's own (non-GNU) "find" rather than +# replacing it. Here "find" is replaced with a fake that rejects -printf (as +# BSD/macOS find would), but a real, working find is also installed as +# "gfind" -- shuriken must resolve FIND to "gfind" and succeed, proving the +# preference is applied rather than the guard merely tolerating a fluke. +test_gnu_tool_guard_prefers_g_prefixed_sibling() { + local path_bin + local real_find + local output + + test::setup + path_bin="$TEST_TMPDIR/bin" + real_find=$(command -v find) + test::install_coreutils_without_imagemagick "$path_bin" + rm -f "$path_bin/find" + cat > "$path_bin/find" <<FAKE +#!/usr/bin/env bash +set -euo pipefail +for arg in "\$@"; do + if [ "\$arg" = -printf ]; then + printf 'find: unknown predicate -printf\n' >&2 + exit 2 + fi +done +exec "$real_find" "\$@" +FAKE + chmod 0755 "$path_bin/find" + ln -s "$real_find" "$path_bin/gfind" + + output=$( + cd "$TEST_TMPDIR" + PATH="$path_bin" "$TEST_SHURIKEN" --version + ) + + test::assert_contains 'This is Shuriken Version' "$output" + test::assert_not_contains 'shuriken requires the GNU versions' "$output" + test::teardown +} + +# verify_gnu_tool_versions (src/lib/compat.source.sh) is the fast preflight +# check that runs before the (slower) behavioral probes: it rejects a resolved +# tool outright when its own --version output does not claim to be GNU, naming +# the tool and pointing at the README install instructions. This is distinct +# from (and runs before) the "GNU-tool guard rejects non-GNU find" case above, +# which covers a tool that claims GNU but lacks the specific behavior. +test_gnu_tool_guard_rejects_non_gnu_version_string() { + local path_bin + local real_find + local output + + test::setup + path_bin="$TEST_TMPDIR/bin" + real_find=$(command -v find) + test::install_coreutils_without_imagemagick "$path_bin" + rm -f "$path_bin/find" + cat > "$path_bin/find" <<FAKE +#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = --version ]; then + printf 'find (BSD)\n' + exit 0 +fi +for arg in "\$@"; do + if [ "\$arg" = -printf ]; then + printf 'find: unknown predicate -printf\n' >&2 + exit 2 + fi +done +exec "$real_find" "\$@" +FAKE + chmod 0755 "$path_bin/find" + + output=$( + cd "$TEST_TMPDIR" + PATH="$path_bin" test::capture_failure_output \ + "$TEST_SHURIKEN" --version + ) + + test::assert_contains 'does not report itself as GNU' "$output" + test::assert_contains 'Platform compatibility' "$output" + test::teardown +} + test_extra_args_fail() { test::assert_failure 'extra operand is rejected' "$TEST_SHURIKEN" --version extra test::assert_failure \ @@ -7747,6 +7834,12 @@ main() { 'GNU-tool guard rejects non-GNU stat' \ test_gnu_tool_guard_rejects_non_gnu_stat test::run_case \ + 'GNU-tool guard prefers a g-prefixed sibling tool' \ + test_gnu_tool_guard_prefers_g_prefixed_sibling + test::run_case \ + 'GNU-tool guard rejects a tool with a non-GNU version string' \ + test_gnu_tool_guard_rejects_non_gnu_version_string + test::run_case \ 'src/shuriken.sh lib source list matches Justfile LIB_SOURCES' \ test_lib_sources_match_justfile_lib_sources } |
