From 57ab6d811e5eb37e78e9a46eef5629a6ca213542 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Sat, 7 Feb 2026 17:46:02 +0000 Subject: [PATCH 01/60] refactor: refine console fingerprinting to reduce PS1 false positives - Tighten 'PLAYSTATION' pattern matching to distinguish between PS1 and PS2 - Reduce log noise by improving header-sniffing accuracy - Maintain size-based safety fallback for non-standard hybrid discs --- chdtool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index 2253c88..b0af15f 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -897,7 +897,7 @@ detect_disc_type() { case "$header" in *"PLAYSTATION 2"*|*"NTSC-U/C PS2 DVD"*) echo "ps2"; return 0 ;; - *"PLAYSTATION "*|*"NTSC-U/C PS1 CD"*) + *"PLAYSTATION "*|*"NTSC-U/C PS1 CD"*) # If it says PS1 but it's >1GB, it's a mislabeled PS2 DVD if [[ "$is_large_disc" == true ]]; then log WARN "โš ๏ธ Header indicates PS1 but size suggests PS2 DVD. Treating as PS2: $img" From 602825bb80b20e4b008205f4d7d5890bf7f15b5a Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 11 Feb 2026 15:33:30 +0000 Subject: [PATCH 02/60] fix: resolve race condition and refine PS2 CD/DVD handling - Add 1s sleep before chdman to prevent I/O collisions on fast runs - Decouple subcommand (createcd/dvd) from hunk size (2048) for PS2 - Ensure PS2 CDs use createcd with 2048-byte sector alignment - Improve stability on virtualized storage with high I/O latency --- chdtool.sh | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index b0af15f..020f997 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -975,7 +975,30 @@ convert_disc_file() { local subcmd icon case "$disc_type" in - ps2|psp|dvd) + ps2) + # PS2 is the special case; Hunk is ALWAYS 2048, but command depends on size + log DEBUG "PS2 image detected, checking size to determine if DVD structure is likely" + local sz + sz=$(get_file_size "$file") + hunk_size=2048 + if (( sz >= 1000000000 )); then + log DEBUG "Large PS2 image suggests DVD structure, CHDMAN_HAS_CREATEDVD=$CHDMAN_HAS_CREATEDVD" + if [[ "$CHDMAN_HAS_CREATEDVD" == true ]]; then + log DEBUG "Using createdvd for large PS2 image" + subcmd="createdvd" + icon="๐Ÿ“€" + else + log WARN "โš ๏ธ PS2 DVD detected but chdman lacks 'createdvd'. Skipping $file." + failures=$((failures + 1)) + return 1 + fi + else + log DEBUG "Smaller PS2 image suggests CD structure, using createcd" + subcmd="createcd" + icon="๐Ÿ’ฟ" + fi + ;; + psp|dvd) log DEBUG "DVD detected, CHDMAN_HAS_CREATEDVD=$CHDMAN_HAS_CREATEDVD" if [[ "$CHDMAN_HAS_CREATEDVD" == true ]]; then subcmd="createdvd" @@ -988,6 +1011,7 @@ convert_disc_file() { fi ;; ps1|dreamcast|segacd|saturn|cd) + log DEBUG "CD-type image detected, using createcd" subcmd="createcd" hunk_size=2448 # CD-ROMs use 2352-byte raw sectors but chdman createcd expects 2448 to include subchannel data for full disc preservation icon="๐Ÿ’ฟ" @@ -1137,6 +1161,7 @@ process_input() { log DEBUG "๐Ÿงน Flushing extraction buffers to free up RAM..." sync + sleep 1 read -r -a disc_find_expr <<< "$(build_find_expr "${disc_exts[@]}")" mapfile -d '' -t disc_files < <(find "$temp_dir" -type f \( "${disc_find_expr[@]}" \) -print0) From ba08c8735c6baaa79c6282846ee30f90ebdf78c6 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 11 Feb 2026 15:45:48 +0000 Subject: [PATCH 03/60] fix: implement strict extraction validation to handle corrupt archives - Capture exit codes for unzip, unrar, and 7z operations - Abort processing if extraction fails or detects CRC/header errors - Prevent chdman from attempting to convert malformed disc images - Ensure failures are logged and temporary directories are cleaned up --- chdtool.sh | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 020f997..6f4cff4 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1153,12 +1153,29 @@ process_input() { temp_dir="$(mktemp -d -p "$TMPDIR" "chdconv_$(basename "$input_file" ".${ext}")_XXXX")" log INFO "๐Ÿ“ฆ Extracting $input_file to $temp_dir" TEMP_DIRS+=("$temp_dir") + local extraction_exit=0 case "$ext" in - zip) unzip -qq "$input_file" -d "$temp_dir" ;; - rar) unrar x -o+ "$input_file" "$temp_dir" >/dev/null ;; - 7z|7zip) 7z x -y -o"$temp_dir" "$input_file" >/dev/null ;; + zip) + unzip -qq "$input_file" -d "$temp_dir" + extraction_exit=$? + ;; + rar) + unrar x -o+ "$input_file" "$temp_dir" >/dev/null + extraction_exit=$? + ;; + 7z|7zip) + 7z x -y -o"$temp_dir" "$input_file" >/dev/null + extraction_exit=$? + ;; esac + # Strict validation: Abort if the extraction tool retrned an error code, which likely means the archive is corrupted or password-protected. + if [[ $extraction_exit -ne 0 ]]; then + log ERROR "โŒ Extraction failed for $input_file (Exit code: $extraction_exit). Skipping." + failures=$((failures + 1)) + return 1 + fi + log DEBUG "๐Ÿงน Flushing extraction buffers to free up RAM..." sync sleep 1 From 59beb514673c1a288c06f1706fd4fda7a3a45465 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 11 Feb 2026 16:15:47 +0000 Subject: [PATCH 04/60] refactor: consolidate disc size detection and add surgical debugging - Fetch file size once at function start to ensure logical consistency - Use 'is_large_disc' flag for all size-based branching - Remove redundant 'stat' calls in fallback logic - Add DEBUG logs to trace raw byte counts and boolean evaluations --- chdtool.sh | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 6f4cff4..8286754 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -879,11 +879,19 @@ detect_disc_type() { ext="${img##*.}"; ext="${ext,,}" local sz sz=$(get_file_size "$img" 2>/dev/null || echo 0) - + # --- DEBUGGING --- + log DEBUG "sz value for $(basename "$img"): [${sz}]" + # ----------------- + #1. Size-based "hard limits" # If it's > 1GB, it's a DVD/PS2 regardless of what the header says (some PS2 games have weird headers that look like CDs) local is_large_disc=false - (( sz >= 1000000000 )) && is_large_disc=true + if [[ -n "$sz" ]] && (( sz >= 1000000000 )); then + is_large_disc=true + fi + # --- DEBUGGING --- + log DEBUG "is_large_disc for $(basename "$img"): $is_large_disc (size: $sz bytes)" + # ----------------- #2. Immediate CD extensions - CUE/CCD/GDI are CD-type by definition case "$ext" in @@ -926,17 +934,11 @@ detect_disc_type() { fi # Size heuristic: โ‰ฅ ~1 GB โ†’ likely DVD; otherwise CD - local sz - sz=$(get_file_size "$img") - (( sz >= 1000000000 )) && { echo "dvd"; return 0; } + [[ "$is_large_disc" == true ]] && { echo "dvd"; return 0; } fi - # Unknown extension โ†’ default to CD (safe for createcd) - if [[ "$is_large_disc" == true ]]; then - echo "dvd"; return 0 - else - echo "cd"; return 0 - fi + #5 Final fallback: Unknown extension โ†’ default to CD (safe for createcd) + [[ "$is_large_disc" == true ]] && echo "dvd" || echo "cd" } convert_disc_file() { From 50f89e8da02819f142e3bf4a20ee53392dcbe48c Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 11 Feb 2026 16:43:12 +0000 Subject: [PATCH 05/60] feat: implement CUE-to-BIN sniffing for accurate console detection - Update detect_disc_type to resolve referenced files in CUE sheets - Enable console fingerprinting on the primary data track (BIN) of CD sets - Prioritize header sniffing over extension-based short-circuits - Ensure PS2 CD-ROMs receive 2048-byte hunk alignment via 'ps2' identity --- chdtool.sh | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 8286754..291699b 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -886,22 +886,25 @@ detect_disc_type() { #1. Size-based "hard limits" # If it's > 1GB, it's a DVD/PS2 regardless of what the header says (some PS2 games have weird headers that look like CDs) local is_large_disc=false - if [[ -n "$sz" ]] && (( sz >= 1000000000 )); then - is_large_disc=true - fi + (( sz >= 1000000000 )) && is_large_disc=true # --- DEBUGGING --- log DEBUG "is_large_disc for $(basename "$img"): $is_large_disc (size: $sz bytes)" # ----------------- - #2. Immediate CD extensions - CUE/CCD/GDI are CD-type by definition - case "$ext" in - cue|ccd|gdi) echo "cd"; return 0 ;; - esac + # Sniff the header before checking extensions + local sniff_target="$img" + # If it's a CUE, we need to sniff the first referenced file instead (usually BIN) to get the real disc type + if [[ "$ext" == "cue" ]]; then + # Grab the first filename inside the CUE (usually the main data track) + local bin_path + bin_path="$(dirname "$img")/$(awk -F'"' '/^FILE/{print $2; exit}' "$img")" + [[ -f "$bin_path" ]] && sniff_target="$bin_path" + fi - #3. Console Fingerprinting + #2. Console Fingerprinting # Reading the first 64KB covers Volume Descriptors and Boot Headers local header - header=$(head -c 65535 "$img" 2>/dev/null | tr -d '\0') + header=$(head -c 65535 "$sniff_target" 2>/dev/null | tr -d '\0') case "$header" in *"PLAYSTATION 2"*|*"NTSC-U/C PS2 DVD"*) echo "ps2"; return 0 ;; @@ -920,6 +923,11 @@ detect_disc_type() { *"PSP GAME"*|*"UMD VIDEO"*) echo "psp"; return 0 ;; esac + #3. Immediate CD extensions - CUE/CCD/GDI are CD-type by definition + case "$ext" in + cue|ccd|gdi) echo "cd"; return 0 ;; + esac + #4. UDF/ISO logic fallback if [[ "$ext" == "iso" ]]; then if command -v file >/dev/null 2>&1; then From 472ee1aa96bb70c80f98759d62e113361d74d93f Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 11 Feb 2026 16:49:55 +0000 Subject: [PATCH 06/60] debug: trace CUE-to-BIN resolution path in detect_disc_type - Log extracted BIN filenames from CUE headers - Verify existence of referenced data tracks before sniffing - Add header-match confirmation for PS2 signatures --- chdtool.sh | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 291699b..15a9203 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -892,13 +892,25 @@ detect_disc_type() { # ----------------- # Sniff the header before checking extensions + # --- UPDATED SNIFF LOGIC WITH DEBUGGING --- local sniff_target="$img" - # If it's a CUE, we need to sniff the first referenced file instead (usually BIN) to get the real disc type if [[ "$ext" == "cue" ]]; then - # Grab the first filename inside the CUE (usually the main data track) + local raw_bin_name + raw_bin_name=$(awk -F'"' '/^FILE/{print $2; exit}' "$img") + local bin_path - bin_path="$(dirname "$img")/$(awk -F'"' '/^FILE/{print $2; exit}' "$img")" - [[ -f "$bin_path" ]] && sniff_target="$bin_path" + bin_path="$(dirname "$img")/$raw_bin_name" + + log DEBUG "DEBUG: CUE refers to file: [$raw_bin_name]" + log DEBUG "DEBUG: Full resolved bin_path: [$bin_path]" + + if [[ -f "$bin_path" ]]; then + log DEBUG "DEBUG: Successfully found BIN file. Switching sniff_target." + sniff_target="$bin_path" + else + log DEBUG "DEBUG: FAILED to find BIN file at that path." + log DEBUG "DEBUG: Directory contents of $(dirname "$img"): $(ls -m "$(dirname "$img")")" + fi fi #2. Console Fingerprinting @@ -906,6 +918,12 @@ detect_disc_type() { local header header=$(head -c 65535 "$sniff_target" 2>/dev/null | tr -d '\0') + # Check for PS2 specifically in debug + if [[ "$header" == *"PLAYSTATION 2"* ]]; then + log DEBUG "DEBUG: 'PLAYSTATION 2' string found in header of $(basename "$sniff_target")" + fi + # ------------------------------------------ + case "$header" in *"PLAYSTATION 2"*|*"NTSC-U/C PS2 DVD"*) echo "ps2"; return 0 ;; *"PLAYSTATION "*|*"NTSC-U/C PS1 CD"*) From 911ea0c9c51887d9d04f4519b7c3bad8cfe0cb7c Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 11 Feb 2026 17:29:40 +0000 Subject: [PATCH 07/60] debugging --- chdtool.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/chdtool.sh b/chdtool.sh index 15a9203..b432b6a 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -874,6 +874,7 @@ validate_cue_file() { } detect_disc_type() { + log DEBUG "DEBUG: Starting detection for: $1" local img="$1" local ext ext="${img##*.}"; ext="${ext,,}" From daed06bc6203cc3d53c9105456cdcbf96af0a0c3 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 14:49:19 +0000 Subject: [PATCH 08/60] fix: remove unsupported PCRE non-capturing group from parse_disc_info regex Bash uses POSIX ERE for [[ =~ ]] matching and does not support the PCRE non-capturing group syntax (?:...). This caused the "1 of 2" and "1/2" disc patterns in parse_disc_info() to never match. Replace the non-capturing group with a standard capturing group so the regex works correctly under Bash. --- chdtool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index b432b6a..9123137 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -536,7 +536,7 @@ parse_disc_info() { fi # Pattern set 4: "1 of 2" / "1/2" - if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([0-9]+)[[:space:]]*(?:of|/)[[:space:]]*[0-9]+\)?([[:space:]]*.*)?$ ]]; then + if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([0-9]+)[[:space:]]*([Oo][Ff]|/)[[:space:]]*[0-9]+\)?([[:space:]]*.*)?$ ]]; then local base="${BASH_REMATCH[1]}" local num="${BASH_REMATCH[2]}" base="$(tidy_base "$base")" From 0d5e415ddc84c0b653bed5ed0762a61a9dc7a908 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 15:01:52 +0000 Subject: [PATCH 09/60] fix: prevent double counting of failures across helper functions Failure counts were incremented in multiple layers including verify_chds() and convert_disc_file(), while callers also incremented the counter when helpers returned non-zero. Remove failure increments from helper functions so they only return status. Failure counting is now centralized in process_input(), ensuring the summary reflects actual input-level failures. --- chdtool | 1178 ---------------------------------------------------- chdtool.sh | 3 - 2 files changed, 1181 deletions(-) delete mode 100755 chdtool diff --git a/chdtool b/chdtool deleted file mode 100755 index 3b779c6..0000000 --- a/chdtool +++ /dev/null @@ -1,1178 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -set +H -script_start_time=$(date +%s) -shopt -s nullglob -shopt -s extglob - -USAGE="Usage: $0 [--keep-originals|-k] [--recursive|-r] [--dry-run|-n] [--file-tee|-F|--no-file-tee|-N] " -KEEP_ORIGINALS=false -RECURSIVE=false -DRY_RUN=false -INPUT_DIR="" -RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)-$$}" -CHDMAN_MSG_LEVEL="${CHDMAN_MSG_LEVEL:-DEBUG}" -case "${CHDMAN_MSG_LEVEL^^}" in - DEBUG|INFO|WARN|ERROR) ;; # OK - *) CHDMAN_MSG_LEVEL="INFO" ;; # fallback -esac - -# Manual parsing to support long options -while [[ $# -gt 0 ]]; do - case "$1" in - --keep-originals|-k) - KEEP_ORIGINALS=true; shift ;; - --recursive|-r) - RECURSIVE=true; shift ;; - --dry-run|-n) - DRY_RUN=true; shift ;; - --file-tee|-F) - LOG_TEE_FILE=1; shift ;; - --no-file-tee|-N) - LOG_TEE_FILE=0; shift ;; - -*) - echo "โŒ Unknown option: $1" >&2 - echo "$USAGE" >&2; exit 1 ;; - *) - if [[ -z "$INPUT_DIR" ]]; then - INPUT_DIR="${1%/}" # Remove trailing slash - else - echo "โŒ Unexpected extra argument: $1" >&2 - echo "$USAGE" >&2; exit 1 - fi - shift ;; - esac -done - -if [[ -z "$INPUT_DIR" ]]; then - echo "$USAGE" >&2; exit 1 -fi -if [[ ! -d "$INPUT_DIR" ]]; then - echo "โŒ Input directory does not exist or is not a directory: $INPUT_DIR" >&2 - exit 1 -fi - -LOGFILE="logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log" - -# --- Pluggable logging: console/file/syslog/journald (auto) ------------------- -# Control via env vars (no root needed to write to journald/syslog): -# LOG_DEST=auto|console|file|syslog|journald -# LOGFILE=/path/to/file (used when LOG_DEST=file; defaults to ./logs/โ€ฆ) -# LOG_TAG=chdtool -LOG_DEST="${LOG_DEST:-auto}" -LOG_TAG="${LOG_TAG:-chdtool}" - -# Mirror policy: auto (TTY only), 1 (always), 0 (never) -LOG_TEE_CONSOLE="${LOG_TEE_CONSOLE:-auto}" - -# New: mirror-to-file policy for journald/syslog backends. -# 1|true|yes (default) โ†’ also append to $LOGFILE -# 0|false|no โ†’ do not write a file when using journald/syslog -LOG_TEE_FILE="${LOG_TEE_FILE:-1}" - -__should_mirror_file() { - case "${LOG_TEE_FILE}" in - 1|true|yes) return 0 ;; - 0|false|no) return 1 ;; - *) return 0 ;; # default to on - esac -} - -_detect_backend() { - case "$LOG_DEST" in - journald) echo journald ;; - syslog) echo syslog ;; - file) echo file ;; - console) echo console ;; - auto) - if [[ -S /run/systemd/journal/socket ]] && command -v systemd-cat >/dev/null 2>&1; then - echo journald - elif command -v logger >/dev/null 2>&1; then - echo syslog - else - echo file - fi - ;; - *) echo file ;; - esac -} -LOG_BACKEND="$(_detect_backend)" - -# add RUN_ID tag only for journald/syslog -if [[ "$LOG_BACKEND" == journald || "$LOG_BACKEND" == syslog ]]; then - LOG_TAG="${LOG_TAG}[${RUN_ID}]" -fi - -# ensure the logfile directory exists when we might write to it -if [[ "$LOG_BACKEND" == file || "$LOG_BACKEND" == console ]] || __should_mirror_file; then - mkdir -p -- "$(dirname -- "$LOGFILE")" -fi - -__should_mirror_console() { - case "${LOG_TEE_CONSOLE}" in - 1|true|yes) return 0 ;; - 0|false|no) return 1 ;; - auto) [[ -t 2 ]] && return 0 || return 1 ;; # use fd 2 - *) return 1 ;; - esac -} - -__console_print() { - # $1=ts, $2=level, $3=message (may be multiline) - local ts="$1" lvl="$2" msg="$3" - while IFS= read -r line; do - printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" >&2 - done <<< "$msg" -} - -# Internal emitter: $1=LEVEL (INFO/WARN/ERROR/DEBUG), $2...=message -_emit_log() { - local lvl="$1"; shift || true - local msg="${*:-}" - local ts; ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" - - local pri=info - case "$lvl" in - DEBUG) pri=debug ;; - INFO) pri=info ;; - WARN) pri=warning ;; - ERROR) pri=err ;; - esac - - case "$LOG_BACKEND" in - journald) - { - echo "LEVEL=$lvl" - echo "RUN_ID=$RUN_ID" - while IFS= read -r line; do echo "$line"; done <<< "$msg" - } | systemd-cat --priority="$pri" --identifier="$LOG_TAG" - # Also append to file if enabled - if __should_mirror_file; then - while IFS= read -r line; do - printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" >> "$LOGFILE" - done <<< "$msg" - fi - __should_mirror_console && __console_print "$ts" "$lvl" "$msg" - ;; - syslog) - while IFS= read -r line; do - logger -p "user.$pri" -t "$LOG_TAG" -- "$lvl: $line" - done <<< "$msg" - # Also append to file if enabled - if __should_mirror_file; then - while IFS= read -r line; do - printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" >> "$LOGFILE" - done <<< "$msg" - fi - __should_mirror_console && __console_print "$ts" "$lvl" "$msg" - ;; - file) - while IFS= read -r line; do - printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" >> "$LOGFILE" - done <<< "$msg" - __should_mirror_console && __console_print "$ts" "$lvl" "$msg" - ;; - console|*) - while IFS= read -r line; do - printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" | tee -a "$LOGFILE" - done <<< "$msg" - ;; - esac -} - -# Public logger. Backwards-compatible: `log "message"` still works. -# Optional levels: `log INFO "message"`, `log WARN "msg"`, etc. -LOG_LEVEL_THRESHOLD="${LOG_LEVEL_THRESHOLD:-INFO}" # DEBUG|INFO|WARN|ERROR -__level_num() { case "$1" in DEBUG) echo 10;; INFO) echo 20;; WARN) echo 30;; ERROR) echo 40;; *) echo 999;; esac; } - -log() { - local lvl="INFO" - case "${1:-}" in DEBUG|INFO|WARN|ERROR) lvl="$1"; shift ;; esac - if (( $(__level_num "$lvl") < $(__level_num "$LOG_LEVEL_THRESHOLD") )); then - return 0 - fi - _emit_log "$lvl" "$*" -} - -log INFO "๐Ÿš€ Script started, input dir: $INPUT_DIR" -[[ "$RECURSIVE" == true ]] && log INFO "๐Ÿ“‚ Recursive mode enabled โ€” scanning subdirectories" -[[ "$DRY_RUN" == true ]] && log INFO "๐Ÿงช Dry-run mode: no files will be written, moved, or deleted" - -is_in_list() { - local needle="$1"; shift - local x - for x in "$@"; do [[ "$x" == "$needle" ]] && return 0; done - return 1 -} - -required_commands=(chdman unzip unrar 7z stat awk) -archive_exts=(zip rar 7z 7zip) -disc_exts=(iso cue gdi ccd) -all_exts=("${archive_exts[@]}" "${disc_exts[@]}") - -get_chd_basename() { - local file="$1" - basename "${file%.*}" -} - -build_find_expr() { - local patterns=("$@") - local expr=() - for ext in "${patterns[@]}"; do - expr+=("-iname" "*.${ext}" "-o") - done - unset "expr[${#expr[@]}-1]" # Remove trailing -o - echo "${expr[@]}" -} - -build_ext_regex() { - local exts=("$@") - local regex="\." - regex+="($(IFS='|'; echo "${exts[*]}"))\$" - echo "$regex" -} - -for cmd in "${required_commands[@]}"; do - if ! command -v "$cmd" >/dev/null 2>&1; then - if [[ "$DRY_RUN" == true ]]; then - log WARN "๐Ÿงช (dry-run) '$cmd' not found โ€” would be required for a real run" - else - log ERROR "โŒ Error: Required command '$cmd' not found. Please install it and ensure it's in your PATH." - exit 1 - fi - fi -done - -if command -v chdman >/dev/null 2>&1; then - chdman_version="$(chdman --help 2>&1 | head -n 1 || true)" - log INFO "โ„น๏ธ Using $chdman_version" -else - if [[ "$DRY_RUN" == true ]]; then - log WARN "๐Ÿงช (dry-run) 'chdman' not found โ€” would be required for a real run" - else - log ERROR "โŒ 'chdman' not found in PATH" - exit 1 - fi -fi - -# Detect 'createdvd' capability (newer chdman versions) -CHDMAN_HAS_CREATEDVD=false -if command -v chdman >/dev/null 2>&1; then - if chdman -help 2>&1 | grep -qiE '(^|[[:space:]])createdvd([[:space:]]|$)'; then - CHDMAN_HAS_CREATEDVD=true - fi - log DEBUG "โ„น๏ธ chdman createdvd support: $CHDMAN_HAS_CREATEDVD" -else - # In dry-run without chdman, just log at DEBUG and keep default false - [[ "$DRY_RUN" == true ]] && log DEBUG "โ„น๏ธ chdman not present (dry-run); assuming no createdvd" -fi - -total_original_size=0 -total_chd_size=0 -archives_processed=0 -chds_created=0 -failures=0 - -human_readable() { - local bytes=$1 - if [[ $bytes -lt 1024 ]]; then - echo "${bytes} B" - elif [[ $bytes -lt 1048576 ]]; then - echo "$((bytes / 1024)) KB" - elif [[ $bytes -lt 1073741824 ]]; then - echo "$((bytes / 1048576)) MB" - else - echo "$((bytes / 1073741824)) GB" - fi -} - -format_duration() { - local total_seconds=$1 - local minutes=$((total_seconds / 60)) - local seconds=$((total_seconds % 60)) - if (( minutes > 0 )); then - printf "%dm %02ds" "$minutes" "$seconds" - else - printf "%ds" "$seconds" - fi -} - -get_file_size() { - if stat --version >/dev/null 2>&1; then - stat -c%s "$1" - else - stat -f%z "$1" - fi -} - -# ---------- chdman progress handling ---------- -# Config: PROGRESS_STYLE=auto|bar|line|none ; default: auto (TTY -> bar, non-TTY -> none) -PROGRESS_STYLE_DEFAULT="auto" -PROGRESS_THROTTLE_MS=250 # reduce flicker -PROGRESS_MARGIN=28 # spare columns to avoid wrap (emoji-width safety) - -# Print N copies of a char -_repeat_char() { local n=$1 c=$2 out=""; while (( n-- > 0 )); do out+="$c"; done; printf "%s" "$out"; } - -# Draw a single-line status (bar or line) to stderr, staying on one row. -PROGRESS_BAR_MAX=${PROGRESS_BAR_MAX:-40} - -_term_print() { - if [[ -t 2 && -w /dev/tty ]]; then - printf "%b" "$1" > /dev/tty - else - printf "%b" "$1" >&2 - fi -} - -_draw_progress() { - local phase="$1" pct="$2" ratio="$3" - local style="${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" - - if [[ "$style" == "auto" ]]; then - if [[ -t 2 ]]; then style="bar"; else style="none"; fi - fi - [[ "$style" == "none" ]] && return 0 - - local cols="${COLUMNS:-}" - [[ -z "$cols" && -t 2 ]] && cols=$(tput cols 2>/dev/null || echo 80) - [[ -z "$cols" ]] && cols=80 - - local left="โณ ${phase} ${pct}%" - [[ -n "$ratio" ]] && left+=" (r=${ratio}%)" - - local text - if [[ "$style" == "line" ]]; then - text="$left" - else - local margin=${PROGRESS_MARGIN:-20} - local barw=$(( cols - (${#left} + margin) )) - local cap=${PROGRESS_BAR_MAX:-40} - (( barw > cap )) && barw=$cap - (( barw < 10 )) && barw=10 - local scaled; scaled="$(awk -v p="$pct" -v w="$barw" 'BEGIN{ printf "%.0f",(p/100.0)*w }')" || scaled=0 - [[ -z "$scaled" ]] && scaled=0 - (( scaled < 0 )) && scaled=0 - (( scaled > barw )) && scaled=$barw - local filled=$scaled - local empty=$(( barw - filled )) - text="$left [$(_repeat_char "$filled" "#")$(_repeat_char "$empty" "-")]" - fi - - (( ${#text} > cols-2 )) && text="${text:0:cols-2}" - - # CR + clear + draw with autowrap temporarily off; NO newline - _term_print "\r\033[2K\033[?7l${text}\033[?7h" -} - -_now_ms() { - local s - if s="$(date +%s%3N 2>/dev/null)"; then - printf '%s' "$s" - else - printf '%s' $(( $(date +%s) * 1000 )) - fi -} - -# Generic chdman progress filter -# Use with: PHASE_DEFAULT="Converting" chdman createcd โ€ฆ | _chdman_progress_filter -# or: PHASE_DEFAULT="Verifying" chdman verify โ€ฆ | _chdman_progress_filter -_chdman_progress_filter() { - # Always re-enable autowrap on exit/interrupt; return non-zero on INT/TERM - _restore_wrap() { _term_print "\033[?7h"; } - trap '_restore_wrap; return 130' INT TERM - trap _restore_wrap EXIT - - local last_draw=0 phase="${PHASE_DEFAULT:-Compressing}" ratio="" progress_active=0 ms - - while IFS= read -r line || [[ -n "$line" ]]; do - if [[ "$line" =~ ([0-9]+([.][0-9]+)?)%[[:space:]]*complete ]]; then - local pct="${BASH_REMATCH[1]}" - [[ "$line" =~ ^([A-Za-z]+), ]] && phase="${BASH_REMATCH[1]}" - if [[ "$line" =~ \(ratio=([0-9]+([.][0-9]+)?)%\) ]]; then ratio="${BASH_REMATCH[1]}"; else ratio=""; fi - - ms="$(_now_ms)" - if (( ms - last_draw >= PROGRESS_THROTTLE_MS )); then - _draw_progress "$phase" "$pct" "$ratio" - last_draw=$ms - progress_active=1 - fi - continue - fi - - # Ignore obvious chopped progress fragments - if [[ "$line" =~ ^[[:space:]]*([A-Za-z]+,)?[[:space:]]*$ ]] || \ - [[ "$line" =~ ^[[:space:]]*[0-9]+([.][0-9]+)?[[:space:]]*$ ]]; then - continue - fi - - log "$CHDMAN_MSG_LEVEL" "$line" - done - - # At EOF: end the progress line neatly with a single newline - (( progress_active )) && _term_print "\r\033[2K\n" -} - -# ---------- M3U (per-iteration) helpers ---------- -# Trim leading/trailing spaces/dots/underscores/dashes -trim() { - local s="$*" - s="${s##+([[:space:]._-])}" - s="${s%%+([[:space:]._-])}" - printf "%s" "$s" -} - -# --- helpers for robust parsing --- -normalize_for_parse() { - # Normalize to make matching easier (full-width โ†’ ASCII, unify spaces/dashes) - local s="$*" - if command -v perl >/dev/null 2>&1; then - s="$(printf '%s' "$s" | perl -CS -Mutf8 -pe ' - # full-width digits โ†’ ASCII - tr/\x{FF10}-\x{FF19}/0-9/; - # full-width parens/brackets โ†’ ASCII - tr/\x{FF08}\x{FF09}/()/; # ๏ผˆ ๏ผ‰ - tr/\x{FF3B}\x{FF3D}/[]/; # ๏ผป ๏ผฝ - # ideographic space โ†’ normal space - tr/\x{3000}/ /; - # unify dashes to ASCII hyphen - s/[-โ€“โ€”โˆ’โ€•]/-/g; - ')" - fi - printf '%s' "$s" -} - -tidy_base() { - # Trim + drop lingering separators/brackets at the end - local s="$*" - s="$(trim "$s")" - if command -v perl >/dev/null 2>&1; then - s="$(printf '%s' "$s" | perl -CS -Mutf8 -pe 's/[ \t._-]*[([{๏ผˆ๏ผป๏ฝ›]*\s*$//')" - else - s="$(printf '%s' "$s" | sed -E 's/[[:space:]._-]*[\(\[\{]+[[:space:]]*$//')" - fi - printf '%s' "$s" -} - -letter_to_num() { - # Aโ†’1, Bโ†’2, โ€ฆ Zโ†’26 - local L="${1:-}" - [[ -z "$L" ]] && { echo ""; return 1; } - L="${L^^}" - printf '%d\n' $(( $(printf '%d' "'${L:0:1}") - 64 )) -} - -# Parse disc info from a base name (no extension). -# On success, echoes "|" and returns 0; else returns 1. -parse_disc_info() { - local name="$1" - local name_norm; name_norm="$(normalize_for_parse "$name")" - - # Pattern set 1: Disc/CD/Disk/GD(-ROM)? with optional separator or none: - # e.g., "Title Disc2", "Title (CD-2)", "Title [Disk02]", "Title GD-ROM 3", "Title Disc 01" - # ERE (bash) has no (?: ). Keep groups simple and predictable. - local re_core='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm])' - local re_num='([0-9]{1,3})' - local re_sep='[[:space:]]*[-_.]?[[:space:]]*' - # For the compact/union pattern, keep it a single capturing group: - local re_label_union='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm]|[Vv]ol|[Vv]olume|[Pp]art|[Pp]t\.?)' - - - if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?$re_core$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then - local base="${BASH_REMATCH[1]}" - local num="${BASH_REMATCH[3]}" # (1=label,2=sep? depends on grouping; ensure index) - # Because of our grouping above, indexes are: - # 1=prefix, 2=label, 3=number, 4=tail - base="$(tidy_base "$base")" - [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; } - fi - - # Pattern set 2: Vol/Volume, Part/Pt - if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([Vv]ol|[Vv]olume|[Pp]art|[Pp]t\.?)$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then - local base="${BASH_REMATCH[1]}" - local num="${BASH_REMATCH[3]}" - base="$(tidy_base "$base")" - [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; } - fi - - # Pattern set 3: Side A/B/Cโ€ฆ (map letters โ†’ 1/2/3โ€ฆ) - if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([Ss]ide)[[:space:]]*([A-Za-z])\)?([[:space:]]*.*)?$ ]]; then - local base="${BASH_REMATCH[1]}" - local letter="${BASH_REMATCH[3]}" - local num; num="$(letter_to_num "$letter")" || num="" - base="$(tidy_base "$base")" - [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; } - fi - - # Pattern set 4: "1 of 2" / "1/2" - if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([0-9]+)[[:space:]]*(?:of|/)[[:space:]]*[0-9]+\)?([[:space:]]*.*)?$ ]]; then - local base="${BASH_REMATCH[1]}" - local num="${BASH_REMATCH[2]}" - base="$(tidy_base "$base")" - [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; } - fi - - # Pattern set 5: compact forms WITHOUT spaces/brackets: - # "Title Disc02", "Title CD2", "Title Vol.2", "Title Pt.3" - if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*$re_label_union$re_sep$re_num([[:space:]]*.*)?$ ]]; then - local base="${BASH_REMATCH[1]}" - local num="${BASH_REMATCH[3]}" - base="$(tidy_base "$base")" - [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; } - fi - - return 1 -} - -# Make a filename safe across Linux/macOS/Windows shares. -# - normalizes Unicode if `uconv` is available -# - removes control chars -# - replaces / \ : * ? " < > | with '-' -# - collapses whitespace; trims ends -# - optional Windows reserved-name guard (SANITIZE_CROSSPLATFORM=0 to disable) -# - truncates to a safe length (default 200 chars) -sanitize_filename() { - local s="$1" - - # Unicode NFKC normalization if ICU's uconv exists (nice-to-have) - if command -v uconv >/dev/null 2>&1; then - s="$(printf '%s' "$s" | uconv -x any-nfkc 2>/dev/null || printf '%s' "$s")" - fi - - # Strip control chars - s="$(printf '%s' "$s" | tr -d '\000-\037\177')" - - # Replace problematic characters and tidy spaces/dashes - s="$(printf '%s' "$s" \ - | sed -E 's/[\/\\:*?"<>|]/-/g; s/[[:space:]]+/ /g; s/[[:space:]]*-[[:space:]]*/ - /g')" - - # Trim leading/trailing separators/spaces - s="$(printf '%s' "$s" | sed -E 's/^[[:space:]._-]+//; s/[[:space:]._-]+$//')" - - # Guard Windows reserved basenames for SMB users - if [[ "${SANITIZE_CROSSPLATFORM:-1}" == 1 ]]; then - case "${s^^}" in - CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) s="_$s";; - esac - fi - - # Length cap (characters). 200 is comfortably under 255-byte limits. - local max=${FILENAME_MAX_CHARS:-200} - if (( ${#s} > max )); then - s="${s:0:max}" - s="$(printf '%s' "$s" | sed -E 's/[[:space:]._-]+$//')" # re-trim tail - fi - - [[ -z "$s" ]] && s="Set" - printf '%s' "$s" -} - -# Build/refresh a single M3U for a given base in one directory. -# We only consider CHDs that share the parsed base (case-insensitive). -generate_m3u_for_base() { - local outdir="$1" - local base="$2" - local -a members=() - - local f stem p p_base - while IFS= read -r -d '' f; do - stem="${f##*/}"; stem="${stem%.chd}" - if p="$(parse_disc_info "$stem")"; then - p_base="${p%%|*}" - if [[ "${p_base,,}" == "${base,,}" ]]; then - members+=("$f") - fi - fi - done < <(find "$outdir" -maxdepth 1 -type f -iname "*.chd" -print0) - - if (( ${#members[@]} < 2 )); then - log DEBUG "โ„น๏ธ Not generating M3U - fewer than two CHDs found for base: $base" - return 0 - fi - - # Sort by parsed disc number - local sorted - sorted="$( - for f in "${members[@]}"; do - stem="${f##*/}"; stem="${stem%.chd}" - p="$(parse_disc_info "$stem")" - echo "${p##*|}|$f" - done | LC_ALL=C sort -t'|' -k1,1n | cut -d'|' -f2 - )" - mapfile -t members <<< "$sorted" - - # Write idempotently (relative file names in body) - # inside generate_m3u_for_base, after computing safe_base - local safe_base; safe_base="$(sanitize_filename "$base")" - local legacy_path="$outdir/${base}.m3u" - local m3u_path="$outdir/${safe_base}.m3u" - - # migrate legacy โ†’ sanitized (only if sanitized doesn't exist yet) - if [[ -f "$legacy_path" && ! -f "$m3u_path" ]]; then - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would rename legacy M3U โ†’ sanitized: $(basename "$legacy_path") โ†’ $(basename "$m3u_path")" - else - mv -f -- "$legacy_path" "$m3u_path" - log INFO "๐Ÿงน Renamed legacy M3U โ†’ sanitized: $(basename "$legacy_path") โ†’ $(basename "$m3u_path")" - fi - fi - - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would (re)write M3U: $m3u_path with ${#members[@]} lines" - else - local tmp_m3u="$m3u_path.tmp" - : > "$tmp_m3u" - for f in "${members[@]}"; do - printf '%s\n' "$(basename "$f")" >> "$tmp_m3u" - done - if [[ -f "$m3u_path" ]] && cmp -s "$tmp_m3u" "$m3u_path"; then - rm -f "$tmp_m3u" - log INFO "๐Ÿงพ M3U up-to-date: $m3u_path" - else - # Remember pre-move existence to log Created vs Updated correctly - local _m3u_existed=false - [[ -f "$m3u_path" ]] && _m3u_existed=true - mv -f -- "$tmp_m3u" "$m3u_path" - if [[ "$_m3u_existed" == true ]]; then - log INFO "๐Ÿ“ Updated M3U: $m3u_path" - else - log INFO "๐Ÿ†• Created M3U: $m3u_path" - fi - fi - fi -} - -# Decide if $chd_base looks like a multi-disc title and (re)generate its M3U now. -maybe_generate_m3u_for() { - local chd_base="$1" # e.g. "Virtua Fighter (Disc 2)" - local outdir="$2" # directory where CHDs live - local parsed - if ! parsed="$(parse_disc_info "$chd_base")"; then - log DEBUG "โ„น๏ธ Not generating M3U - CHD not part of multi-disc set: $chd_base" - return 0 - fi - local base="${parsed%%|*}" - log DEBUG "๐Ÿ”Ž M3U check โ€” base: $base" - - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would generate/update M3U for base: $base" - else - generate_m3u_for_base "$outdir" "$base" - fi -} -# ---------- end M3U helpers ---------- -# --- Global interrupt + cleanup handling -------------------------------------- -# Track temp dirs created during processing so we can clean them on SIGINT/TERM -declare -a TEMP_DIRS=() - -_restore_wrap_global() { - # Make sure terminal autowrap is re-enabled and the progress line cleared - # (safe to emit even if no progress was showing) - _term_print "\r\033[2K\033[?7h\n" -} - -cleanup_all() { - # Remove any temp dirs that might still exist - if ((${#TEMP_DIRS[@]})); then - for d in "${TEMP_DIRS[@]}"; do - if [[ -n "$d" && -d "$d" ]]; then - rm -rf -- "$d" - log INFO "๐Ÿงน Cleaned up temp dir: $d" - fi - done - fi - - # Remove our known temp files in the working directory tree - if [[ -n "$INPUT_DIR" && -d "$INPUT_DIR" ]]; then - while IFS= read -r -d '' f; do - rm -f -- "$f" - log INFO "๐Ÿ—‘๏ธ Removed leftover tmp file: $f" - done < <(find "$INPUT_DIR" -type f \( -name '*.chd.tmp' -o -name '*.m3u.tmp' \) -print0) - fi - - # Remove verify scratch files in /tmp that match our mktemp prefix - # (mktemp -t chdverify_XXXXXX => /tmp/chdverify_*) - while IFS= read -r -d '' f; do - rm -f -- "$f" - log INFO "๐Ÿ—‘๏ธ Removed verify scratch: $f" - done < <(find /tmp -maxdepth 1 -type f -name 'chdverify_*' -print0 2>/dev/null || true) -} - -_on_interrupt() { - # One place to handle Ctrl-C/TERM: restore terminal, clean, then exit(130) - _restore_wrap_global - log WARN "๐Ÿ›‘ Interrupted โ€” cleaning up and exitingโ€ฆ" - trap - EXIT - cleanup_all - exit 130 -} - -# Ctrl-C (INT) and TERM should both stop the whole script -trap _on_interrupt INT TERM -trap cleanup_all EXIT - -verify_chds() { - local outdir="$1"; shift - local chds=("$@") - local all_verified=true - - if [[ "$DRY_RUN" == true ]]; then - for chd in "${chds[@]}"; do - log INFO "๐Ÿงช (dry-run) Would verify: $outdir/$chd" - done - return 0 - fi - - for chd in "${chds[@]}"; do - local chd_path="$outdir/$chd" - if [[ ! -f "$chd_path" ]]; then - all_verified=false - break - fi - - local verify_exit_code=0 - local tmpout - tmpout="$(mktemp -t chdverify_XXXXXX)" - - log INFO "๐Ÿ”Ž Verifying: $chd_path" - if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then - # TTY: show single-line progress, capture full output to tmp for analysis - if PHASE_DEFAULT="Verifying" "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \ - | tee "$tmpout" \ - | _chdman_progress_filter - then - verify_exit_code=0 - else - verify_exit_code=$? - fi - else - # Non-TTY: no progress UI, still capture output - if "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 | tee "$tmpout" >/dev/null - then - verify_exit_code=0 - else - verify_exit_code=$? - fi - fi - - if [[ $verify_exit_code -ne 0 ]]; then - # Summarize failure reasons (quietly) - local failure_reasons - failure_reasons="$(grep -iE 'error|fail|invalid|corrupt' "$tmpout" || true)" - [[ -n "$failure_reasons" ]] && log DEBUG " Failure details: $failure_reasons" - - log WARN "โš ๏ธ Verification failed on first try for: $chd_path" - log INFO "โณ Retrying after delay..." - sleep 2 - - # Retry with a fresh capture file - : > "$tmpout" - log INFO "๐Ÿ”Ž Verifying: $chd_path" - if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then - if PHASE_DEFAULT="Verifying" "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \ - | tee "$tmpout" \ - | _chdman_progress_filter - then - log INFO "โœ… Verified on retry: $chd_path" - rm -f -- "$tmpout" - continue - fi - else - if "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 | tee "$tmpout" >/dev/null - then - log INFO "โœ… Verified on retry: $chd_path" - rm -f -- "$tmpout" - continue - fi - fi - - # Still failed: log concise reasons and clean up - failure_reasons="$(grep -iE 'error|fail|invalid|corrupt' "$tmpout" || true)" - [[ -n "$failure_reasons" ]] && log DEBUG " Failure details: $failure_reasons" - failures=$((failures + 1)) - log ERROR "โŒ Verification failed on retry for: $chd_path โ€” deleting" - rm -f -- "$chd_path" - rm -f -- "$tmpout" - all_verified=false - break - else - log INFO "โœ… Verified CHD: $chd_path" - rm -f -- "$tmpout" - fi - done - - $all_verified && return 0 || return 1 -} - -validate_cue_file() { - local cue_file="$1" - local cuedir - cuedir="$(dirname "$cue_file")" - local cue_basename - cue_basename="$(basename "$cue_file")" - local missing=0 - - declare -A file_map - while IFS= read -r -d '' f; do - file_map["${f,,}"]="$f" - done < <(find "$cuedir" -maxdepth 1 -type f -printf '%f\0') - - while IFS= read -r line; do - if [[ "$line" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then - local ref="${BASH_REMATCH[1]}" - local ref_norm="${ref//\\//}" - local ref_basename; ref_basename="$(basename "$ref_norm")" - local ref_lower="${ref_basename,,}" - - [[ "$ref_lower" == "${cue_basename,,}" ]] && continue - - if [[ "$ref_lower" == *.mp3 || "$ref_lower" == *.wav ]]; then - log WARN "โš ๏ธ CUE file references unsupported audio format: $ref_basename" - fi - if [[ "$ref_norm" == /* || "$ref_norm" == *".."* ]]; then - log WARN "โš ๏ธ Skipping unsafe external path in CUE: $ref_basename" - continue - fi - if [[ -z "${file_map["$ref_lower"]:-}" ]]; then - log ERROR "โŒ Missing referenced file in CUE: $ref_basename (required by $cue_file)" - missing=1 - fi - fi - done < "$cue_file" - - return $missing -} - -detect_disc_type() { - # Echo "cd" or "dvd" based on the image - local img="$1" - local ext="${img##*.}" - ext="${ext,,}" - - # CUE/CCD/GDI are CD-type - case "$ext" in - cue|ccd|gdi) echo "cd"; return 0 ;; - esac - - if [[ "$ext" == "iso" ]]; then - # Prefer 'file' if available - if command -v file >/dev/null 2>&1; then - local sig - sig="$(file -b -- "$img" 2>/dev/null || true)" - if echo "$sig" | grep -qi 'UDF filesystem'; then - echo "dvd"; return 0 - fi - else - # Fallback: sniff for UDF "NSR0[23]" anchor near sector 256 - local anchor_offset=$((256 * 2048)) - if dd if="$img" bs=1 skip="$anchor_offset" count=$((64 * 1024)) status=none 2>/dev/null \ - | grep -aqE 'NSR0(2|3)?'; then - echo "dvd"; return 0 - fi - fi - - # Size heuristic: โ‰ฅ ~1 GB โ†’ likely DVD; otherwise CD - local sz - sz=$(get_file_size "$img") - if (( sz >= 1000000000 )); then - echo "dvd"; return 0 - fi - - echo "cd"; return 0 - fi - - # Unknown extension โ†’ default to CD (safe for createcd) - echo "cd" -} - -convert_disc_file() { - local file="$1" - local outdir="$2" - - # If it's a CUE, validate referenced files first - if [[ "${file,,}" == *.cue ]]; then - if ! validate_cue_file "$file"; then - log ERROR "โŒ Missing referenced file in CUE: $file" - return 1 - fi - fi - - local base - base="$(get_chd_basename "$file")" - local chd_path="$outdir/$base.chd" - local tmp_chd="$outdir/$base.chd.tmp" - - # If a CHD already exists, verify it and skip if good - if [[ -f "$chd_path" ]]; then - log INFO "๐Ÿ”Ž Verifying existing CHD before conversion: $chd_path" - if verify_chds "$outdir" "$base.chd"; then - log INFO "โœ… Existing CHD verified, skipping conversion: $chd_path" - return 0 - else - failures=$((failures + 1)) - log WARN "โŒ Existing CHD verification failed, will convert and replace" - fi - fi - - # Decide CD vs DVD and pick subcommand + icon - local disc_type - disc_type="$(detect_disc_type "$file")" - - local subcmd icon - if [[ "$disc_type" == "dvd" ]]; then - if [[ "$CHDMAN_HAS_CREATEDVD" == true ]]; then - subcmd="createdvd" - icon="๐Ÿ“€" # DVD - else - log WARN "โš ๏ธ Detected DVD image but this chdman lacks 'createdvd'. Skipping: $file" - failures=$((failures + 1)) - return 1 - fi - else - subcmd="createcd" - icon="๐Ÿ’ฟ" # CD - fi - - log INFO "$icon Detected $disc_type image โ†’ using chdman $subcmd" - log INFO "๐Ÿ”ง Converting: $file -> $tmp_chd" - - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would run: ${CHDMAN_BIN:-chdman} $subcmd -i \"$file\" -o \"$tmp_chd\"" - return 0 - fi - - if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then - if ! PHASE_DEFAULT="Converting" "${CHDMAN_BIN:-chdman}" "$subcmd" -i "$file" -o "$tmp_chd" 2>&1 | _chdman_progress_filter; then - log ERROR "โŒ chdman $subcmd failed for: $file" - return 1 - fi - else - if ! "${CHDMAN_BIN:-chdman}" "$subcmd" -i "$file" -o "$tmp_chd"; then - log ERROR "โŒ chdman $subcmd failed for: $file" - return 1 - fi - fi - - return 0 -} - -process_input() { - local input_file="$1" - local ext="${input_file##*.}"; ext="${ext,,}" - local outdir - outdir="$(dirname "$input_file")" - - local archive_entries=() - local disc_files=() - local expected_chds=() - local archive_size_bytes - archive_size_bytes=$(get_file_size "$input_file") - local ext_regex - ext_regex="$(build_ext_regex "${disc_exts[@]}")" - local temp_dir="" - - _cleanup() { - if [[ -n "$temp_dir" && -d "$temp_dir" ]]; then - rm -rf -- "$temp_dir" - log INFO "๐Ÿงน Cleaned up temp dir: $temp_dir" - fi - } - # One-shot traps: cleanup on normal return and on error; clear both on return. - trap '_cleanup; trap - RETURN; trap - ERR' RETURN ERR - - if is_in_list "$ext" "${archive_exts[@]}"; then - archives_processed=$((archives_processed + 1)) - case "$ext" in - zip) mapfile -t archive_entries < <(unzip -Z1 "$input_file" | grep -Ei "$ext_regex") ;; - rar) mapfile -t archive_entries < <(unrar lb "$input_file" | grep -Ei "$ext_regex") ;; - 7z|7zip) mapfile -t archive_entries < <(7z l -ba "$input_file" | grep -Ei "$ext_regex") ;; - esac - for entry in "${archive_entries[@]}"; do - expected_chds+=("$(get_chd_basename "$entry").chd") - done - fi - - if is_in_list "$ext" "${disc_exts[@]}"; then - disc_files+=("$input_file") - expected_chds+=("$(get_chd_basename "$input_file").chd") - fi - - if [[ ${#expected_chds[@]} -eq 0 ]]; then - log WARN "โญ๏ธ Skipping $input_file - no disc files found (not a supported archive or disc format)" - return 0 - fi - - # If all expected CHDs already exist and verify, remove original and done - if verify_chds "$outdir" "${expected_chds[@]}"; then - log INFO "โœ… All expected CHDs verified for $input_file" - if [[ "$KEEP_ORIGINALS" != true ]]; then - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would remove original input file: $input_file" - else - log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" - rm -f -- "$input_file" - fi - else - log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" - fi - # Per-iteration M3U generation for already-present sets - if [[ ${#expected_chds[@]} -gt 0 ]]; then - local chd_base - chd_base="$(basename "${expected_chds[0]}" .chd)" - log DEBUG "๐Ÿ”ค Raw base name: $chd_base" - maybe_generate_m3u_for "$chd_base" "$outdir" - fi - return 0 - fi - - # Extract archive to temp and discover disc files - if is_in_list "$ext" "${archive_exts[@]}"; then - if [[ "$DRY_RUN" == true ]]; then - # No mktemp in dry-run โ€” just show intent - local _dry_temp="(tempdir)" - log INFO "๐Ÿงช (dry-run) Would extract $input_file to $_dry_temp" - # We also skip scanning extracted files in dry-run (no filesystem changes exist). - # But keep expected_chds populated from archive listing (already done above). - else - temp_dir="$(mktemp -d -t "chdconv_$(basename "$input_file" ".${ext}")_XXXX")" - log INFO "๐Ÿ“ฆ Extracting $input_file to $temp_dir" - TEMP_DIRS+=("$temp_dir") - case "$ext" in - zip) unzip -qq "$input_file" -d "$temp_dir" ;; - rar) unrar x -o+ "$input_file" "$temp_dir" >/dev/null ;; - 7z|7zip) 7z x -y -o"$temp_dir" "$input_file" >/dev/null ;; - esac - - read -r -a disc_find_expr <<< "$(build_find_expr "${disc_exts[@]}")" - mapfile -d '' -t disc_files < <(find "$temp_dir" -type f \( "${disc_find_expr[@]}" \) -print0) - - if [[ ${#disc_files[@]} -eq 0 && ${#archive_entries[@]} -gt 0 ]]; then - for entry in "${archive_entries[@]}"; do - local full_path="$temp_dir/$entry" - [[ -f "$full_path" ]] && disc_files+=("$full_path") - done - fi - fi - - # In dry-run, also show which members would be converted - if [[ "$DRY_RUN" == true && ${#archive_entries[@]} -gt 0 ]]; then - for entry in "${archive_entries[@]}"; do - # Mirror the naming used later: basename without extension + .chd - log INFO "๐Ÿงช (dry-run) Would convert: $entry -> $outdir/$(basename "${entry%.*}").chd" - done - fi - fi - - local archive_chd_size=0 - local tmp_chds=() - - if [[ "$DRY_RUN" == true ]]; then - for disc in "${disc_files[@]}"; do - log INFO "๐Ÿงช (dry-run) Would convert: $disc -> $outdir/$(basename "${disc%.*}").chd" - done - else - for disc in "${disc_files[@]}"; do - if convert_disc_file "$disc" "$outdir"; then - tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp") - else - failures=$((failures + 1)) - fi - done - fi - - # Verify .tmp CHDs and finalize - if [[ "$DRY_RUN" != true && ${#tmp_chds[@]} -gt 0 ]]; then - if verify_chds "$outdir" "${tmp_chds[@]##*/}"; then - for tmp_chd in "${tmp_chds[@]}"; do - local final_chd="${tmp_chd%.tmp}" - mv -f -- "$tmp_chd" "$final_chd" - log INFO "๐Ÿ”„ Replaced old CHD with new verified CHD: $final_chd" - chds_created=$((chds_created + 1)) - done - - if [[ "$KEEP_ORIGINALS" != true ]]; then - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would remove original input file: $input_file" - else - log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" - rm -f -- "$input_file" - fi - else - log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" - fi - - for chd in "${expected_chds[@]}"; do - if [[ -f "$outdir/$chd" ]]; then - archive_chd_size=$((archive_chd_size + $(get_file_size "$outdir/$chd"))) - fi - done - if [[ $archive_size_bytes -gt 0 ]]; then - local saving=$((archive_size_bytes - archive_chd_size)) - local saving_percent=$((100 * saving / archive_size_bytes)) - log INFO "๐Ÿ“‰ Space saving for $(basename "$input_file"): $(human_readable "$archive_size_bytes") โ†’ $(human_readable "$archive_chd_size"), saved $(human_readable "$saving") (${saving_percent}%)" - total_original_size=$((total_original_size + archive_size_bytes)) - total_chd_size=$((total_chd_size + archive_chd_size)) - fi - else - for tmp_chd in "${tmp_chds[@]}"; do - if [[ -f "$tmp_chd" ]]; then - rm -f "$tmp_chd" - log INFO "๐Ÿ—‘๏ธ Removed failed tmp CHD: $tmp_chd" - fi - done - failures=$((failures + 1)) - log WARN "โš ๏ธ CHD verification failed after conversion for $input_file, keeping original" - fi - fi - - # Per-iteration M3U generation for newly written CHDs - if [[ ${#expected_chds[@]} -gt 0 ]]; then - local chd_base - chd_base="$(basename "${expected_chds[0]}" .chd)" - log DEBUG "๐Ÿ”ค Raw base name: $chd_base" - maybe_generate_m3u_for "$chd_base" "$outdir" - fi - - return 0 -} - -# Main processing loop -read -r -a find_expr <<< "$(build_find_expr "${all_exts[@]}")" - -if [[ -z "${find_expr[*]:-}" ]]; then - log ERROR "โš ๏ธ No valid file extensions found for searching, exiting." - exit 1 -fi - -if [[ "$RECURSIVE" == true ]]; then - mapfile -d '' -t all_inputs < <( - find "$INPUT_DIR" \( -path '*/.*' -prune \) -o -type f \( "${find_expr[@]}" \) -print0 - ) -else - mapfile -d '' -t all_inputs < <( - find "$INPUT_DIR" -maxdepth 1 -type f \( "${find_expr[@]}" \) -print0 - ) -fi -log INFO "๐Ÿ”Ž Found ${#all_inputs[@]} inputs" - -for input in "${all_inputs[@]}"; do - log INFO "โ–ถ๏ธ Processing file: $input" - if ! process_input "$input"; then - log ERROR "โš ๏ธ Failed to process $input" - fi -done - -log INFO "๐Ÿ“Š Summary:" -if [[ $total_original_size -gt 0 ]]; then - total_saved=$((total_original_size - total_chd_size)) - total_percent=$((100 * total_saved / total_original_size)) - log INFO "๐Ÿ“ฆ Total original size: $(human_readable "$total_original_size")" - log INFO "๐Ÿ’ฟ Total CHD size: $(human_readable "$total_chd_size")" - log INFO "๐Ÿ“‰ Total space saved: $(human_readable "$total_saved") (${total_percent}%)" -fi -log INFO "๐Ÿ“ฆ Archives processed: $archives_processed" -log INFO "๐Ÿ’ฟ CHDs created: $chds_created" -log INFO "โŒ Failures: $failures" -log INFO "โฑ๏ธ Elapsed time: $(format_duration $(( $(date +%s) - script_start_time )))" -log INFO "โœ… Done!" diff --git a/chdtool.sh b/chdtool.sh index 9123137..43a94c0 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -819,7 +819,6 @@ verify_chds() { # Still failed: log concise reasons and clean up failure_reasons="$(grep -iE 'error|fail|invalid|corrupt' "$tmpout" || true)" [[ -n "$failure_reasons" ]] && log DEBUG " Failure details: $failure_reasons" - failures=$((failures + 1)) log ERROR "โŒ Verification failed on retry for: $chd_path โ€” deleting" rm -f -- "$chd_path" rm -f -- "$tmpout" @@ -1018,7 +1017,6 @@ convert_disc_file() { icon="๐Ÿ“€" else log WARN "โš ๏ธ PS2 DVD detected but chdman lacks 'createdvd'. Skipping $file." - failures=$((failures + 1)) return 1 fi else @@ -1035,7 +1033,6 @@ convert_disc_file() { icon="๐Ÿ“€" else log WARN "โš ๏ธ Detected DVD image but this chdman lacks 'createdvd'. Skipping: $file" - failures=$((failures + 1)) return 1 fi ;; From 889bf24d4a54ea64079b17b523542c0f5a8567a3 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 15:28:24 +0000 Subject: [PATCH 10/60] fix: centralise failure accounting in process_input() Introduce an `input_failed` flag within process_input() and move the increment of the global `failures` counter to a single location at the end of the function. Previously, helper functions such as convert_disc_file() and some verification paths modified the global failure counter directly, which could lead to multiple increments for a single input. Failures are now signalled via return codes or by setting `input_failed`, and process_input() increments the counter once before returning a non-zero status. This ensures failure reporting is consistent and avoids double-counting in the summary. --- chdtool.sh | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 43a94c0..71cc5fb 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -991,7 +991,6 @@ convert_disc_file() { log INFO "โœ… Existing CHD verified, skipping conversion: $chd_path" return 0 else - failures=$((failures + 1)) log WARN "โŒ Existing CHD verification failed, will convert and replace" fi fi @@ -1099,6 +1098,7 @@ convert_disc_file() { } process_input() { + local input_failed=false local input_file="$1" local ext="${input_file##*.}"; ext="${ext,,}" local outdir @@ -1119,6 +1119,7 @@ process_input() { log INFO "๐Ÿงน Cleaned up temp dir: $temp_dir" fi } + # One-shot traps: cleanup on normal return and on error; clear both on return. trap '_cleanup; trap - RETURN; trap - ERR' RETURN ERR @@ -1144,8 +1145,17 @@ process_input() { return 0 fi + # If this input is a CUE, validate it even if CHDs already exist. + # Broken CUEs should count as an input failure (even if we can skip conversion). + if [[ "$ext" == "cue" ]]; then + if ! validate_cue_file "$input_file"; then + log ERROR "โŒ CUE references missing files (input considered failed): $input_file" + input_failed=true + fi + fi + # If all expected CHDs already exist and verify, remove original and done - if verify_chds "$outdir" "${expected_chds[@]}"; then + if [[ "$input_failed" != true ]] && verify_chds "$outdir" "${expected_chds[@]}"; then log INFO "โœ… All expected CHDs verified for $input_file" if [[ "$KEEP_ORIGINALS" != true ]]; then if [[ "$DRY_RUN" == true ]]; then @@ -1198,7 +1208,7 @@ process_input() { # Strict validation: Abort if the extraction tool retrned an error code, which likely means the archive is corrupted or password-protected. if [[ $extraction_exit -ne 0 ]]; then log ERROR "โŒ Extraction failed for $input_file (Exit code: $extraction_exit). Skipping." - failures=$((failures + 1)) + input_failed=true return 1 fi @@ -1238,7 +1248,7 @@ process_input() { if convert_disc_file "$disc" "$outdir"; then tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp") else - failures=$((failures + 1)) + input_failed=true fi done fi @@ -1283,7 +1293,7 @@ process_input() { log INFO "๐Ÿ—‘๏ธ Removed failed tmp CHD: $tmp_chd" fi done - failures=$((failures + 1)) + input_failed=true log WARN "โš ๏ธ CHD verification failed after conversion for $input_file, keeping original" fi fi @@ -1296,6 +1306,11 @@ process_input() { maybe_generate_m3u_for "$chd_base" "$outdir" fi + if [[ "$input_failed" == true ]]; then + failures=$((failures + 1)) + return 1 + fi + return 0 } @@ -1320,9 +1335,7 @@ log INFO "๐Ÿ”Ž Found ${#all_inputs[@]} inputs" for input in "${all_inputs[@]}"; do log INFO "โ–ถ๏ธ Processing file: $input" - if ! process_input "$input"; then - log ERROR "โš ๏ธ Failed to process $input" - fi + process_input "$input" || log ERROR "โš ๏ธ Failed to process $input" done log INFO "๐Ÿ“Š Summary:" From 7c945903ff1d69585582ccd99f307cb3d942f8d4 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 15:40:47 +0000 Subject: [PATCH 11/60] refactor(process_input): centralise input removal with _remove_input_if_allowed() - dedupe KEEP_ORIGINALS/DRY_RUN guarded removal logic - call helper in verified-fastpath and post-conversion finalisation --- chdtool.sh | 43 ++++++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 71cc5fb..8b9d0bf 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1114,10 +1114,25 @@ process_input() { local temp_dir="" _cleanup() { - if [[ -n "$temp_dir" && -d "$temp_dir" ]]; then - rm -rf -- "$temp_dir" - log INFO "๐Ÿงน Cleaned up temp dir: $temp_dir" + if [[ -n "$temp_dir" && -d "$temp_dir" ]]; then + rm -rf -- "$temp_dir" + log INFO "๐Ÿงน Cleaned up temp dir: $temp_dir" + fi + } + + _remove_input_if_allowed() { + if [[ "$KEEP_ORIGINALS" == true ]]; then + log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" + return 0 fi + + if [[ "$DRY_RUN" == true ]]; then + log INFO "๐Ÿงช (dry-run) Would remove original input file: $input_file" + return 0 + fi + + log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" + rm -f -- "$input_file" } # One-shot traps: cleanup on normal return and on error; clear both on return. @@ -1157,16 +1172,7 @@ process_input() { # If all expected CHDs already exist and verify, remove original and done if [[ "$input_failed" != true ]] && verify_chds "$outdir" "${expected_chds[@]}"; then log INFO "โœ… All expected CHDs verified for $input_file" - if [[ "$KEEP_ORIGINALS" != true ]]; then - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would remove original input file: $input_file" - else - log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" - rm -f -- "$input_file" - fi - else - log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" - fi + _remove_input_if_allowed # Per-iteration M3U generation for already-present sets if [[ ${#expected_chds[@]} -gt 0 ]]; then local chd_base @@ -1263,16 +1269,7 @@ process_input() { chds_created=$((chds_created + 1)) done - if [[ "$KEEP_ORIGINALS" != true ]]; then - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would remove original input file: $input_file" - else - log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" - rm -f -- "$input_file" - fi - else - log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" - fi + _remove_input_if_allowed for chd in "${expected_chds[@]}"; do if [[ -f "$outdir/$chd" ]]; then From 771e95b397c38238a0b4e8ecf615d5ca2484cc55 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 15:54:08 +0000 Subject: [PATCH 12/60] fix(process_input): only generate M3U when input processing succeeds Prevent M3U generation when an input fails during extraction, conversion, or CHD verification. Previously the M3U update logic could still run with input_failed=true, potentially producing playlists based on incomplete or partially converted sets. Move the failure check before the M3U generation block so maybe_generate_m3u_for() is only called on successful runs. --- chdtool.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 8b9d0bf..810e0a6 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1295,7 +1295,12 @@ process_input() { fi fi - # Per-iteration M3U generation for newly written CHDs + if [[ "$input_failed" == true ]]; then + failures=$((failures + 1)) + return 1 + fi + + # Per-iteration M3U generation (only on success) if [[ ${#expected_chds[@]} -gt 0 ]]; then local chd_base chd_base="$(basename "${expected_chds[0]}" .chd)" @@ -1303,11 +1308,6 @@ process_input() { maybe_generate_m3u_for "$chd_base" "$outdir" fi - if [[ "$input_failed" == true ]]; then - failures=$((failures + 1)) - return 1 - fi - return 0 } From b4d97a61f7b7e8548f5761fdc37531f692c23553 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 15:58:24 +0000 Subject: [PATCH 13/60] fix(archives): parse 7z technical listing instead of table output Replace `7z l -ba | grep` with parsing of `7z l -slt` output. Table output is formatting-dependent and not reliable for scripting. Using `-slt` provides stable `Path = ...` entries for extraction. --- chdtool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index 810e0a6..4f94c2e 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1143,7 +1143,7 @@ process_input() { case "$ext" in zip) mapfile -t archive_entries < <(unzip -Z1 "$input_file" | grep -Ei "$ext_regex") ;; rar) mapfile -t archive_entries < <(unrar lb "$input_file" | grep -Ei "$ext_regex") ;; - 7z|7zip) mapfile -t archive_entries < <(7z l -ba "$input_file" | grep -Ei "$ext_regex") ;; + 7z|7zip) mapfile -t archive_entries < <(7z l -slt -- "$input_file" 2>/dev/null | awk -v IGNORECASE=1 -v re="$ext_regex" '/^Path = /{p=substr($0,8); if(p~re) print p}') ;; esac for entry in "${archive_entries[@]}"; do expected_chds+=("$(get_chd_basename "$entry").chd") From 8564979922edea3b93a15ca9b0132d24fd727f86 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 15:59:45 +0000 Subject: [PATCH 14/60] fix(archives): protect unzip/unrar calls with `--` to stop option parsing Add `--` before the archive filename when calling unzip and unrar. This prevents filenames beginning with `-` from being interpreted as command options. --- chdtool.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 4f94c2e..d00db76 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1141,8 +1141,8 @@ process_input() { if is_in_list "$ext" "${archive_exts[@]}"; then archives_processed=$((archives_processed + 1)) case "$ext" in - zip) mapfile -t archive_entries < <(unzip -Z1 "$input_file" | grep -Ei "$ext_regex") ;; - rar) mapfile -t archive_entries < <(unrar lb "$input_file" | grep -Ei "$ext_regex") ;; + zip) mapfile -t archive_entries < <(unzip -Z1 -- "$input_file" | grep -Ei "$ext_regex") ;; + rar) mapfile -t archive_entries < <(unrar lb -- "$input_file" | grep -Ei "$ext_regex") ;; 7z|7zip) mapfile -t archive_entries < <(7z l -slt -- "$input_file" 2>/dev/null | awk -v IGNORECASE=1 -v re="$ext_regex" '/^Path = /{p=substr($0,8); if(p~re) print p}') ;; esac for entry in "${archive_entries[@]}"; do From e382729a351b1a86c2a1543f77a344283a7cbf55 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 16:02:10 +0000 Subject: [PATCH 15/60] fix(cue): normalize Windows-style paths when validating FILE references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert backslashes to forward slashes before basename matching so CUEs with Windows separators donโ€™t falsely report missing referenced files. --- chdtool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index d00db76..e935778 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -849,7 +849,7 @@ validate_cue_file() { while IFS= read -r line; do if [[ "$line" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then local ref="${BASH_REMATCH[1]}" - local ref_norm="${ref//\\//}" + local ref_norm="${ref//\\\\//}" local ref_basename; ref_basename="$(basename "$ref_norm")" local ref_lower="${ref_basename,,}" From a27b2347d512268346e361178df20fa33fc440a9 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 16:19:49 +0000 Subject: [PATCH 16/60] chore(logging): stop dumping full chdman -help output during createdvd detection Keep createdvd probing but remove the verbose debug log to avoid noisy logs. --- chdtool.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index e935778..bec9bd1 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -265,7 +265,6 @@ fi CHDMAN_HAS_CREATEDVD=false if command -v chdman >/dev/null 2>&1; then set +o pipefail - log DEBUG "chdman -help output: $(chdman -help 2>&1)" if chdman -help 2>&1 | grep -qiE 'createdvd'; then log DEBUG "Found 'createdvd' in chdman -help output" CHDMAN_HAS_CREATEDVD=true From 227e6ed405e73676d62c1c1217a4ea24ee508d14 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 16:37:13 +0000 Subject: [PATCH 17/60] fix(chdman): detect createdvd support via explicit help probe Replace detection based on `chdman -help` output with an explicit capability probe using `chdman help createdvd`. This avoids parsing large help output and provides a more reliable feature check across chdman versions and builds. --- chdtool.sh | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index bec9bd1..f124c8c 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -264,19 +264,15 @@ fi # Detect 'createdvd' capability (newer chdman versions) CHDMAN_HAS_CREATEDVD=false if command -v chdman >/dev/null 2>&1; then - set +o pipefail - if chdman -help 2>&1 | grep -qiE 'createdvd'; then - log DEBUG "Found 'createdvd' in chdman -help output" - CHDMAN_HAS_CREATEDVD=true - else - log DEBUG "Did NOT find 'createdvd' in chdman -help output" - CHDMAN_HAS_CREATEDVD=false - fi - set -o pipefail - log DEBUG "โ„น๏ธ chdman createdvd support: $CHDMAN_HAS_CREATEDVD" + if chdman help createdvd >/dev/null 2>&1; then + CHDMAN_HAS_CREATEDVD=true + log DEBUG "โ„น๏ธ chdman createdvd support: true (via 'chdman help createdvd')" + else + CHDMAN_HAS_CREATEDVD=false + log DEBUG "โ„น๏ธ chdman createdvd support: false (no 'createdvd' help topic)" + fi else - # In dry-run without chdman, just log at DEBUG and keep default false - [[ "$DRY_RUN" == true ]] && log DEBUG "โ„น๏ธ chdman not present (dry-run); assuming no createdvd" + [[ "$DRY_RUN" == true ]] && log DEBUG "โ„น๏ธ chdman not present (dry-run); assuming no createdvd" fi total_original_size=0 From d55113f5833506d4c9017f8a102e9947a222a5f3 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 16:38:59 +0000 Subject: [PATCH 18/60] fix(detection): guard header sniff in detect_disc_type against pipefail Add `|| true` when reading the first 64KB of the image so failures in `head` do not terminate the script under `set -euo pipefail`. Also add `--` to protect against filenames beginning with `-`. --- chdtool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index f124c8c..b2eacfc 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -911,7 +911,7 @@ detect_disc_type() { #2. Console Fingerprinting # Reading the first 64KB covers Volume Descriptors and Boot Headers local header - header=$(head -c 65535 "$sniff_target" 2>/dev/null | tr -d '\0') + header="$(head -c 65535 -- "$sniff_target" 2>/dev/null | tr -d '\0' || true)" # Check for PS2 specifically in debug if [[ "$header" == *"PLAYSTATION 2"* ]]; then From c24b6b5c425875deacfdbfcf61bcc1ea4ff3aa71 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 16:47:19 +0000 Subject: [PATCH 19/60] refactor(temp): avoid double bookkeeping of per-input temp directories Remove temp directories from the global TEMP_DIRS list when they are already cleaned up by the per-input `_cleanup` trap in process_input(). This avoids duplicate cleanup attempts and simplifies temp directory lifecycle management. --- chdtool.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index b2eacfc..a5ae589 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1189,7 +1189,6 @@ process_input() { else temp_dir="$(mktemp -d -p "$TMPDIR" "chdconv_$(basename "$input_file" ".${ext}")_XXXX")" log INFO "๐Ÿ“ฆ Extracting $input_file to $temp_dir" - TEMP_DIRS+=("$temp_dir") local extraction_exit=0 case "$ext" in zip) From f60ed0c7e1ccb7b66f9e6da32a2cf177edd7942a Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 16:49:15 +0000 Subject: [PATCH 20/60] refactor(disc-detection): remove duplicate label definitions in parse_disc_info Define disc label patterns once and reuse them when constructing the union regex. This reduces duplication and makes the detection patterns easier to maintain without changing behaviour. --- chdtool.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index a5ae589..543061f 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -497,12 +497,11 @@ parse_disc_info() { # Pattern set 1: Disc/CD/Disk/GD(-ROM)? with optional separator or none: # e.g., "Title Disc2", "Title (CD-2)", "Title [Disk02]", "Title GD-ROM 3", "Title Disc 01" # ERE (bash) has no (?: ). Keep groups simple and predictable. - local re_core='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm])' + local re_disc_labels='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm])' local re_num='([0-9]{1,3})' local re_sep='[[:space:]]*[-_.]?[[:space:]]*' # For the compact/union pattern, keep it a single capturing group: - local re_label_union='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm]|[Vv]ol|[Vv]olume|[Pp]art|[Pp]t\.?)' - + local re_label_union="(${re_disc_labels:1:-1}|[Vv]ol|[Vv]olume|[Pp]art|[Pp]t\\.?)" if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?$re_core$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then local base="${BASH_REMATCH[1]}" From d7763102da64e62d6b17d33a81bbac1022f0615a Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 17:02:35 +0000 Subject: [PATCH 21/60] fix(archives): preserve subdir context in CHD output names and avoid collisions - Add optional base override to convert_disc_file() - Drive archive conversions from archive_entries and name outputs via archive_entry_to_chd_name() - Ensure tmp CHD paths align with expected CHD manifests for verify/finalize --- chdtool.sh | 57 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 543061f..4fcaca1 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -329,6 +329,16 @@ check_temp_storage() { fi } +archive_entry_to_chd_name() { + # $1 = path inside archive, e.g. "CD1/Game.cue" + local entry="$1" + local stem="${entry%.*}" + # preserve subdir info to avoid collisions, but keep it filename-safe + stem="${stem//\// - }" + stem="$(sanitize_filename "$stem")" + printf '%s.chd' "$stem" +} + check_temp_storage "$TMPDIR" # ---------- chdman progress handling ---------- @@ -503,7 +513,7 @@ parse_disc_info() { # For the compact/union pattern, keep it a single capturing group: local re_label_union="(${re_disc_labels:1:-1}|[Vv]ol|[Vv]olume|[Pp]art|[Pp]t\\.?)" - if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?$re_core$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then + if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?$re_disc_labels$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then local base="${BASH_REMATCH[1]}" local num="${BASH_REMATCH[3]}" # (1=label,2=sep? depends on grouping; ensure index) # Because of our grouping above, indexes are: @@ -964,6 +974,7 @@ detect_disc_type() { convert_disc_file() { local file="$1" local outdir="$2" + local base_override="${3:-}" # If it's a CUE, validate referenced files first if [[ "${file,,}" == *.cue ]]; then @@ -974,7 +985,12 @@ convert_disc_file() { fi local base - base="$(get_chd_basename "$file")" + if [[ -n "$base_override" ]]; then + base="$base_override" + else + base="$(get_chd_basename "$file")" + fi + local chd_path="$outdir/$base.chd" local tmp_chd="$outdir/$base.chd.tmp" @@ -1140,7 +1156,7 @@ process_input() { 7z|7zip) mapfile -t archive_entries < <(7z l -slt -- "$input_file" 2>/dev/null | awk -v IGNORECASE=1 -v re="$ext_regex" '/^Path = /{p=substr($0,8); if(p~re) print p}') ;; esac for entry in "${archive_entries[@]}"; do - expected_chds+=("$(get_chd_basename "$entry").chd") + expected_chds+=("$(archive_entry_to_chd_name "$entry")") done fi @@ -1230,7 +1246,7 @@ process_input() { if [[ "$DRY_RUN" == true && ${#archive_entries[@]} -gt 0 ]]; then for entry in "${archive_entries[@]}"; do # Mirror the naming used later: basename without extension + .chd - log INFO "๐Ÿงช (dry-run) Would convert: $entry -> $outdir/$(basename "${entry%.*}").chd" + log INFO "๐Ÿงช (dry-run) Would convert: $entry -> $outdir/$(archive_entry_to_chd_name "$entry")" done fi fi @@ -1243,13 +1259,32 @@ process_input() { log INFO "๐Ÿงช (dry-run) Would convert: $disc -> $outdir/$(basename "${disc%.*}").chd" done else - for disc in "${disc_files[@]}"; do - if convert_disc_file "$disc" "$outdir"; then - tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp") - else - input_failed=true - fi - done + if is_in_list "$ext" "${archive_exts[@]}"; then + # Drive conversion from archive_entries so output naming matches expected_chds + for entry in "${archive_entries[@]}"; do + local extracted="$temp_dir/$entry" + [[ -f "$extracted" ]] || continue + + local chd_name chd_base + chd_name="$(archive_entry_to_chd_name "$entry")" # e.g. "CD1 - Game.chd" + chd_base="${chd_name%.chd}" # e.g. "CD1 - Game" + + if convert_disc_file "$extracted" "$outdir" "$chd_base"; then + tmp_chds+=("$outdir/$chd_name.tmp") # matches final naming + else + input_failed=true + fi + done + else + # Non-archive inputs keep the old behaviour + for disc in "${disc_files[@]}"; do + if convert_disc_file "$disc" "$outdir"; then + tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp") + else + input_failed=true + fi + done + fi fi # Verify .tmp CHDs and finalize From 12cd36a7d2431078f10f4518885a1dbc18250029 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 17:10:55 +0000 Subject: [PATCH 22/60] fix(progress): avoid subshell in chdman progress filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Normalize CRโ†’LF without piping the while-loop (keeps state in current shell) - Ensure progress_active/newline handling works reliably at EOF - Improve terminal wrap restore consistency via traps --- chdtool.sh | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 4fcaca1..f052d28 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -414,14 +414,14 @@ _now_ms() { # Use with: PHASE_DEFAULT="Converting" chdman createcd โ€ฆ | _chdman_progress_filter # or: PHASE_DEFAULT="Verifying" chdman verify โ€ฆ | _chdman_progress_filter _chdman_progress_filter() { - # Always re-enable autowrap on exit/interrupt; return non-zero on INT/TERM _restore_wrap() { _term_print "\033[?7h"; } trap '_restore_wrap; return 130' INT TERM trap _restore_wrap EXIT local last_draw=0 phase="${PHASE_DEFAULT:-Compressing}" ratio="" progress_active=0 ms - while IFS= read -d $'\r' -r line || [[ -n "$line" ]]; do + # Read from a process-substitution so the while-loop stays in THIS shell + while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ([0-9]+([.][0-9]+)?)%[[:space:]]*complete ]]; then local pct="${BASH_REMATCH[1]}" [[ "$line" =~ ^([A-Za-z]+), ]] && phase="${BASH_REMATCH[1]}" @@ -436,16 +436,14 @@ _chdman_progress_filter() { continue fi - # Ignore obvious chopped progress fragments if [[ "$line" =~ ^[[:space:]]*([A-Za-z]+,)?[[:space:]]*$ ]] || \ - [[ "$line" =~ ^[[:space:]]*[0-9]+([.][0-9]+)?[[:space:]]*$ ]]; then + [[ "$line" =~ ^[[:space:]]*[0-9]+([.][0-9]+)?[[:space:]]*$ ]]; then continue fi log "$CHDMAN_MSG_LEVEL" "$line" - done + done < <(tr $'\r' $'\n') # CR โ†’ NL normalisation happens here - # At EOF: end the progress line neatly with a single newline (( progress_active )) && _term_print "\r\033[2K\n" } @@ -771,7 +769,7 @@ verify_chds() { log INFO "๐Ÿ”Ž Verifying: $chd_path" if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then # TTY: show single-line progress, capture full output to tmp for analysis - if PHASE_DEFAULT="Verifying" "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \ + if PHASE_DEFAULT="Verifying" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \ | tee "$tmpout" \ | _chdman_progress_filter then @@ -803,7 +801,7 @@ verify_chds() { : > "$tmpout" log INFO "๐Ÿ”Ž Verifying: $chd_path" if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then - if PHASE_DEFAULT="Verifying" "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \ + if PHASE_DEFAULT="Verifying" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \ | tee "$tmpout" \ | _chdman_progress_filter then From f7c9e9fb1c20a94a9c4e4813740cbcc130aa8a44 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 17:13:19 +0000 Subject: [PATCH 23/60] fix(cue): make BIN path detection more robust Improve CUE FILE parsing when resolving the BIN path used for header sniffing. The parser now: - accepts leading whitespace before FILE - handles case-insensitive FILE directives - normalizes Windows-style backslashes in paths This prevents false negatives when locating BIN files referenced by valid CUE sheets. --- chdtool.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index f052d28..ab4ee6c 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -898,11 +898,12 @@ detect_disc_type() { local sniff_target="$img" if [[ "$ext" == "cue" ]]; then local raw_bin_name - raw_bin_name=$(awk -F'"' '/^FILE/{print $2; exit}' "$img") - + raw_bin_name="$(awk -F'"' 'tolower($1) ~ /^[[:space:]]*file[[:space:]]+/ {print $2; exit}' "$img")" + local bin_path - bin_path="$(dirname "$img")/$raw_bin_name" + bin_path="$(dirname "$img")/${raw_bin_name//\\//}" + [[ -z "$raw_bin_name" ]] && log DEBUG "DEBUG: No FILE entry found in CUE: $img" log DEBUG "DEBUG: CUE refers to file: [$raw_bin_name]" log DEBUG "DEBUG: Full resolved bin_path: [$bin_path]" From f9623143f172a0e270af959fb82c34fe777a00a6 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 17:22:52 +0000 Subject: [PATCH 24/60] fix(cue): prefer data track when choosing sniff target When detecting disc type from a .cue, select the first referenced data-like file (bin/iso/img/mdf) instead of blindly taking the first FILE entry, which is often an audio track. Normalize Windows-style path separators and keep detailed debug logging. --- chdtool.sh | 50 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index ab4ee6c..73b0de6 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -894,25 +894,43 @@ detect_disc_type() { # ----------------- # Sniff the header before checking extensions - # --- UPDATED SNIFF LOGIC WITH DEBUGGING --- local sniff_target="$img" if [[ "$ext" == "cue" ]]; then - local raw_bin_name - raw_bin_name="$(awk -F'"' 'tolower($1) ~ /^[[:space:]]*file[[:space:]]+/ {print $2; exit}' "$img")" - - local bin_path - bin_path="$(dirname "$img")/${raw_bin_name//\\//}" - - [[ -z "$raw_bin_name" ]] && log DEBUG "DEBUG: No FILE entry found in CUE: $img" - log DEBUG "DEBUG: CUE refers to file: [$raw_bin_name]" - log DEBUG "DEBUG: Full resolved bin_path: [$bin_path]" - - if [[ -f "$bin_path" ]]; then - log DEBUG "DEBUG: Successfully found BIN file. Switching sniff_target." - sniff_target="$bin_path" + local cue_dir + cue_dir="$(dirname "$img")" + + # Prefer a likely DATA file (BIN/ISO/IMG/MDF) over audio tracks. + local raw_ref="" + raw_ref="$( + awk -F'"' ' + BEGIN { IGNORECASE=1 } + /^[[:space:]]*FILE[[:space:]]+"/ { + ref=$2 + low=tolower(ref) + gsub(/\\/, "/", low) + # pick first "data-ish" file + if (low ~ /\.(bin|iso|img|mdf)$/) { print ref; exit } + # otherwise remember first FILE as fallback + if (first == "") first = ref + } + END { if (first != "") print first } + ' "$img" + )" + + # Normalize Windows path separators + raw_ref="${raw_ref//\\//}" + + local sniff_candidate="$cue_dir/$raw_ref" + + log DEBUG "DEBUG: CUE selected sniff candidate: [$raw_ref]" + log DEBUG "DEBUG: Full resolved sniff path: [$sniff_candidate]" + + if [[ -n "$raw_ref" && -f "$sniff_candidate" ]]; then + log DEBUG "DEBUG: Found referenced file. Switching sniff_target." + sniff_target="$sniff_candidate" else - log DEBUG "DEBUG: FAILED to find BIN file at that path." - log DEBUG "DEBUG: Directory contents of $(dirname "$img"): $(ls -m "$(dirname "$img")")" + log DEBUG "DEBUG: FAILED to resolve referenced file from CUE." + log DEBUG "DEBUG: Directory contents of $cue_dir: $(ls -m "$cue_dir")" fi fi From 66c2504ac04f54010c50de522a284cb1a40f3eb6 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 17:24:44 +0000 Subject: [PATCH 25/60] feat(tempdir): allow TMPDIR override for temporary workspace Allow the temporary working directory used for extraction and verification to be overridden via the TMPDIR environment variable. Defaults to /var/tmp/chdtool to avoid filling tmpfs, but users can now supply a custom location (e.g. faster SSD or larger volume) without modifying the script. --- chdtool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index 73b0de6..f7d2b52 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -55,7 +55,7 @@ if [[ ! -d "$INPUT_DIR" ]]; then fi # Use a disk-based temp directory to avoid filling up RAM -TMPDIR="/var/tmp/chdtool" +TMPDIR="${TMPDIR:-/var/tmp/chdtool}" mkdir -p "$TMPDIR" LOGFILE="logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log" From 107f8a5d12a3e62f299a85501c8e07afd4250887 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 17:26:43 +0000 Subject: [PATCH 26/60] improve(tempdir): isolate temporary workspace per run Create a unique per-run temporary workspace under TMPDIR using RUN_ID. This prevents collisions between concurrent script runs and keeps extraction and verification scratch files isolated. Example layout: /var/tmp/chdtool//chdconv_* /var/tmp/chdtool//chdverify_* --- chdtool.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index f7d2b52..942eca7 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -55,8 +55,10 @@ if [[ ! -d "$INPUT_DIR" ]]; then fi # Use a disk-based temp directory to avoid filling up RAM -TMPDIR="${TMPDIR:-/var/tmp/chdtool}" +TMP_ROOT="${TMPDIR:-/var/tmp/chdtool}" +TMPDIR="$TMP_ROOT/$RUN_ID" mkdir -p "$TMPDIR" +log DEBUG "๐Ÿ“ Using temp workspace: $TMPDIR" LOGFILE="logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log" From fd174341a5d180cd4a0f282b044ceb1d4349f85f Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 17:52:50 +0000 Subject: [PATCH 27/60] refactor(process_input): reduce nesting in conversion flow - early-return in dry-run mode after logging intended conversions - split archive vs non-archive conversion into a single clear branch - keep temp dir cleanup in archive path after conversions complete --- chdtool.sh | 118 ++++++++++++++++++++++++++++------------------------- 1 file changed, 62 insertions(+), 56 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 942eca7..1d71822 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -58,7 +58,6 @@ fi TMP_ROOT="${TMPDIR:-/var/tmp/chdtool}" TMPDIR="$TMP_ROOT/$RUN_ID" mkdir -p "$TMPDIR" -log DEBUG "๐Ÿ“ Using temp workspace: $TMPDIR" LOGFILE="logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log" @@ -202,6 +201,7 @@ log() { _emit_log "$lvl" "$*" } +log DEBUG "๐Ÿ“ Using temp workspace: $TMPDIR" log INFO "๐Ÿš€ Script started, input dir: $INPUT_DIR" [[ "$RECURSIVE" == true ]] && log INFO "๐Ÿ“‚ Recursive mode enabled โ€” scanning subdirectories" [[ "$DRY_RUN" == true ]] && log INFO "๐Ÿงช Dry-run mode: no files will be written, moved, or deleted" @@ -444,7 +444,7 @@ _chdman_progress_filter() { fi log "$CHDMAN_MSG_LEVEL" "$line" - done < <(tr $'\r' $'\n') # CR โ†’ NL normalisation happens here + done < <(tr $'\r' $'\n' <&0) # CR โ†’ NL normalisation happens here (( progress_active )) && _term_print "\r\033[2K\n" } @@ -656,6 +656,7 @@ generate_m3u_for_base() { log INFO "๐Ÿงช (dry-run) Would (re)write M3U: $m3u_path with ${#members[@]} lines" else local tmp_m3u="$m3u_path.tmp" + track_temp_file "$tmp_m3u" : > "$tmp_m3u" for f in "${members[@]}"; do printf '%s\n' "$(basename "$f")" >> "$tmp_m3u" @@ -699,6 +700,22 @@ maybe_generate_m3u_for() { # --- Global interrupt + cleanup handling -------------------------------------- # Track temp dirs created during processing so we can clean them on SIGINT/TERM declare -a TEMP_DIRS=() +declare -a TEMP_FILES=() + +track_temp_dir() { TEMP_DIRS+=("$1"); } +track_temp_file() { TEMP_FILES+=("$1"); } + +cleanup_temp_dir_now() { + local d="$1" + [[ -n "$d" && -d "$d" ]] || return 0 + rm -rf -- "$d" + log INFO "๐Ÿงน Cleaned up temp dir: $d" + # remove it from TEMP_DIRS so cleanup_all won't log it again + local i + for i in "${!TEMP_DIRS[@]}"; do + [[ "${TEMP_DIRS[$i]}" == "$d" ]] && unset 'TEMP_DIRS[i]' + done +} _restore_wrap_global() { # Make sure terminal autowrap is re-enabled and the progress line cleared @@ -707,29 +724,19 @@ _restore_wrap_global() { } cleanup_all() { - # Remove any temp dirs that might still exist - if ((${#TEMP_DIRS[@]})); then - for d in "${TEMP_DIRS[@]}"; do - if [[ -n "$d" && -d "$d" ]]; then + # Temp dirs + for d in "${TEMP_DIRS[@]:-}"; do + [[ -n "$d" && -d "$d" ]] || continue rm -rf -- "$d" log INFO "๐Ÿงน Cleaned up temp dir: $d" - fi done - fi - # Remove our known temp files in the working directory tree - if [[ -n "$INPUT_DIR" && -d "$INPUT_DIR" ]]; then - while IFS= read -r -d '' f; do - rm -f -- "$f" - log INFO "๐Ÿ—‘๏ธ Removed leftover tmp file: $f" - done < <(find "$INPUT_DIR" -type f \( -name '*.chd.tmp' -o -name '*.m3u.tmp' \) -print0) - fi - - # Remove verify scratch files in the new temp directory - while IFS= read -r -d '' f; do - rm -f -- "$f" - log INFO "๐Ÿ—‘๏ธ Removed verify scratch: $f" - done < <(find "$TMPDIR" -maxdepth 1 -type f -name 'chdverify_*' -print0 2>/dev/null || true) + # Temp files + for f in "${TEMP_FILES[@]:-}"; do + [[ -n "$f" && -f "$f" ]] || continue + rm -f -- "$f" + log INFO "๐Ÿ—‘๏ธ Removed temp file: $f" + done } _on_interrupt() { @@ -767,6 +774,7 @@ verify_chds() { local verify_exit_code=0 local tmpout tmpout="$(mktemp -p "$TMPDIR" chdverify_XXXXXX)" + track_temp_file "$tmpout" log INFO "๐Ÿ”Ž Verifying: $chd_path" if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then @@ -1142,13 +1150,6 @@ process_input() { ext_regex="$(build_ext_regex "${disc_exts[@]}")" local temp_dir="" - _cleanup() { - if [[ -n "$temp_dir" && -d "$temp_dir" ]]; then - rm -rf -- "$temp_dir" - log INFO "๐Ÿงน Cleaned up temp dir: $temp_dir" - fi - } - _remove_input_if_allowed() { if [[ "$KEEP_ORIGINALS" == true ]]; then log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" @@ -1164,9 +1165,6 @@ process_input() { rm -f -- "$input_file" } - # One-shot traps: cleanup on normal return and on error; clear both on return. - trap '_cleanup; trap - RETURN; trap - ERR' RETURN ERR - if is_in_list "$ext" "${archive_exts[@]}"; then archives_processed=$((archives_processed + 1)) case "$ext" in @@ -1222,6 +1220,7 @@ process_input() { # But keep expected_chds populated from archive listing (already done above). else temp_dir="$(mktemp -d -p "$TMPDIR" "chdconv_$(basename "$input_file" ".${ext}")_XXXX")" + track_temp_dir "$temp_dir" log INFO "๐Ÿ“ฆ Extracting $input_file to $temp_dir" local extraction_exit=0 case "$ext" in @@ -1277,33 +1276,40 @@ process_input() { for disc in "${disc_files[@]}"; do log INFO "๐Ÿงช (dry-run) Would convert: $disc -> $outdir/$(basename "${disc%.*}").chd" done - else - if is_in_list "$ext" "${archive_exts[@]}"; then - # Drive conversion from archive_entries so output naming matches expected_chds - for entry in "${archive_entries[@]}"; do - local extracted="$temp_dir/$entry" - [[ -f "$extracted" ]] || continue + return 0 + fi - local chd_name chd_base - chd_name="$(archive_entry_to_chd_name "$entry")" # e.g. "CD1 - Game.chd" - chd_base="${chd_name%.chd}" # e.g. "CD1 - Game" + # Real conversion + if is_in_list "$ext" "${archive_exts[@]}"; then + # Drive conversion from archive_entries so output naming matches expected_chds + for entry in "${archive_entries[@]}"; do + local extracted="$temp_dir/$entry" + [[ -f "$extracted" ]] || continue - if convert_disc_file "$extracted" "$outdir" "$chd_base"; then - tmp_chds+=("$outdir/$chd_name.tmp") # matches final naming - else - input_failed=true - fi - done - else - # Non-archive inputs keep the old behaviour - for disc in "${disc_files[@]}"; do - if convert_disc_file "$disc" "$outdir"; then - tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp") - else - input_failed=true - fi - done - fi + local chd_name chd_base + chd_name="$(archive_entry_to_chd_name "$entry")" # e.g. "CD1 - Game.chd" + chd_base="${chd_name%.chd}" # e.g. "CD1 - Game" + + if convert_disc_file "$extracted" "$outdir" "$chd_base"; then + tmp_chds+=("$outdir/$chd_name.tmp") + else + input_failed=true + fi + done + + # We no longer need the extracted archive contents at this point + cleanup_temp_dir_now "$temp_dir" + temp_dir="" + + else + # Non-archive inputs keep the old behaviour + for disc in "${disc_files[@]}"; do + if convert_disc_file "$disc" "$outdir"; then + tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp") + else + input_failed=true + fi + done fi # Verify .tmp CHDs and finalize From bc7b930f0bbf0bcac9b7ec183a6813e37f0270a3 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 18:05:22 +0000 Subject: [PATCH 28/60] fix(cleanup): correctly untrack temp dirs after early deletion - fix array element unset in cleanup_temp_dir_now - prevents duplicate cleanup attempts/log lines on EXIT after per-input cleanup --- chdtool.sh | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 1d71822..2f0318d 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -213,7 +213,7 @@ is_in_list() { return 1 } -required_commands=(chdman unzip unrar 7z stat awk) +required_commands=(chdman unzip unrar 7z stat awk stdbuf) archive_exts=(zip rar 7z 7zip) disc_exts=(iso cue gdi ccd) all_exts=("${archive_exts[@]}" "${disc_exts[@]}") @@ -713,7 +713,7 @@ cleanup_temp_dir_now() { # remove it from TEMP_DIRS so cleanup_all won't log it again local i for i in "${!TEMP_DIRS[@]}"; do - [[ "${TEMP_DIRS[$i]}" == "$d" ]] && unset 'TEMP_DIRS[i]' + [[ "${TEMP_DIRS[$i]}" == "$d" ]] && unset 'TEMP_DIRS[$i]' done } @@ -855,7 +855,7 @@ validate_cue_file() { declare -A file_map while IFS= read -r -d '' f; do - file_map["${f,,}"]="$f" + file_map["${f,,}"]="$f" done < <(find "$cuedir" -maxdepth 1 -type f -printf '%f\0') while IFS= read -r line; do @@ -1151,10 +1151,10 @@ process_input() { local temp_dir="" _remove_input_if_allowed() { - if [[ "$KEEP_ORIGINALS" == true ]]; then - log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" - return 0 - fi + if [[ "$KEEP_ORIGINALS" == true ]]; then + log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" + return 0 + fi if [[ "$DRY_RUN" == true ]]; then log INFO "๐Ÿงช (dry-run) Would remove original input file: $input_file" @@ -1276,6 +1276,14 @@ process_input() { for disc in "${disc_files[@]}"; do log INFO "๐Ÿงช (dry-run) Would convert: $disc -> $outdir/$(basename "${disc%.*}").chd" done + + # Dry-run: also show M3U intent (if this looks like a multi-disc set) + if [[ ${#expected_chds[@]} -gt 0 ]]; then + local chd_base + chd_base="$(basename "${expected_chds[0]}" .chd)" + maybe_generate_m3u_for "$chd_base" "$outdir" + fi + return 0 fi From a94384f6f781344ee374cab30a3f666d3e7571bb Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 4 Mar 2026 18:32:44 +0000 Subject: [PATCH 29/60] fix(workflow): update permissions for workflow files --- .github/workflows/shellcheck.yml | 2 ++ .github/workflows/tests.yml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 39eaaef..dc6b7cc 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -1,4 +1,6 @@ name: ShellCheck +permissions: + contents: read on: [push, pull_request] jobs: shellcheck: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3e09095..aea1b4f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,7 @@ name: tests +permissions: + contents: read + pull-requests: write on: push: pull_request: From fcc20bfcaa1ad2d6d6d21c677a2b16d87160b95a Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Fri, 6 Mar 2026 12:01:26 +0000 Subject: [PATCH 30/60] fix: count failed inputs consistently in process_input - add internal `_fail_input()` helper to centralise failure accounting - increment `failures` for immediate hard failures such as archive extraction errors - keep deferred failures tracked via `input_failed=true` and finalised once at the end of `process_input` - avoid under-reporting failures in the summary when returning early from error paths - retain defensive final gate with `${input_failed:-false} && _fail_input` This makes failure accounting sane and ensures each failed input is counted exactly once. --- chdtool.sh | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 2f0318d..867e5bd 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1140,7 +1140,6 @@ process_input() { local ext="${input_file##*.}"; ext="${ext,,}" local outdir outdir="$(dirname "$input_file")" - local archive_entries=() local disc_files=() local expected_chds=() @@ -1150,19 +1149,24 @@ process_input() { ext_regex="$(build_ext_regex "${disc_exts[@]}")" local temp_dir="" + _fail_input() { + failures=$((failures + 1)) + return 1 + } + _remove_input_if_allowed() { if [[ "$KEEP_ORIGINALS" == true ]]; then log INFO "๐Ÿ“ฆ Keeping original input file due to KEEP_ORIGINALS=true" return 0 fi - if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would remove original input file: $input_file" - return 0 - fi + if [[ "$DRY_RUN" == true ]]; then + log INFO "๐Ÿงช (dry-run) Would remove original input file: $input_file" + return 0 + fi - log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" - rm -f -- "$input_file" + log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" + rm -f -- "$input_file" } if is_in_list "$ext" "${archive_exts[@]}"; then @@ -1242,7 +1246,7 @@ process_input() { if [[ $extraction_exit -ne 0 ]]; then log ERROR "โŒ Extraction failed for $input_file (Exit code: $extraction_exit). Skipping." input_failed=true - return 1 + _fail_input fi log DEBUG "๐Ÿงน Flushing extraction buffers to free up RAM..." @@ -1356,10 +1360,7 @@ process_input() { fi fi - if [[ "$input_failed" == true ]]; then - failures=$((failures + 1)) - return 1 - fi + ${input_failed:-false} && _fail_input # Per-iteration M3U generation (only on success) if [[ ${#expected_chds[@]} -gt 0 ]]; then From 51dd23a5dd8cfcbb1a33ee0330118a1fd4b251b8 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Fri, 6 Mar 2026 13:20:52 +0000 Subject: [PATCH 31/60] fix: count failed inputs once and stop on hard extraction errors - keep `input_failed` as a simple boolean flag within `process_input` - add `_return_failed_input()` to centralise failure counting and non-zero return - ensure archive extraction failures clean up the temp dir and exit `process_input` immediately - finalize deferred failures once at the end of `process_input` - prevent a single input from being counted multiple times in the summary This makes failure accounting sane and ensures each failed input is only counted once. --- chdtool.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 867e5bd..603e2f9 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1149,7 +1149,7 @@ process_input() { ext_regex="$(build_ext_regex "${disc_exts[@]}")" local temp_dir="" - _fail_input() { + _return_failed_input() { failures=$((failures + 1)) return 1 } @@ -1246,7 +1246,9 @@ process_input() { if [[ $extraction_exit -ne 0 ]]; then log ERROR "โŒ Extraction failed for $input_file (Exit code: $extraction_exit). Skipping." input_failed=true - _fail_input + cleanup_temp_dir_now "$temp_dir" + _return_failed_input + return 1 fi log DEBUG "๐Ÿงน Flushing extraction buffers to free up RAM..." @@ -1360,7 +1362,10 @@ process_input() { fi fi - ${input_failed:-false} && _fail_input + if [[ "$input_failed" == true ]]; then + _return_failed_input + return 1 + fi # Per-iteration M3U generation (only on success) if [[ ${#expected_chds[@]} -gt 0 ]]; then From 050e337f888b5e7748b4c503ad58ea36a501d038 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Sat, 7 Mar 2026 20:59:34 +0000 Subject: [PATCH 32/60] fix: prioritise descriptor files when selecting disc inputs from archives - add helper to prefer CUE, then GDI, then CCD, then ISO entries - build expected CHD outputs from filtered archive entries only - avoid treating descriptor-backed component files as separate discs - add debug logging for selected archive entries --- chdtool.sh | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 603e2f9..a722fe9 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -341,6 +341,33 @@ archive_entry_to_chd_name() { printf '%s.chd' "$stem" } +select_preferred_disc_entries() { + local entries=("$@") + local -a cues=() gdis=() ccds=() isos=() + local entry ext + + for entry in "${entries[@]}"; do + ext="${entry##*.}" + ext="${ext,,}" + case "$ext" in + cue) cues+=("$entry") ;; + gdi) gdis+=("$entry") ;; + ccd) ccds+=("$entry") ;; + iso) isos+=("$entry") ;; + esac + done + + if (( ${#cues[@]} > 0 )); then + printf '%s\n' "${cues[@]}" + elif (( ${#gdis[@]} > 0 )); then + printf '%s\n' "${gdis[@]}" + elif (( ${#ccds[@]} > 0 )); then + printf '%s\n' "${ccds[@]}" + else + printf '%s\n' "${isos[@]}" + fi +} + check_temp_storage "$TMPDIR" # ---------- chdman progress handling ---------- @@ -1176,9 +1203,15 @@ process_input() { rar) mapfile -t archive_entries < <(unrar lb -- "$input_file" | grep -Ei "$ext_regex") ;; 7z|7zip) mapfile -t archive_entries < <(7z l -slt -- "$input_file" 2>/dev/null | awk -v IGNORECASE=1 -v re="$ext_regex" '/^Path = /{p=substr($0,8); if(p~re) print p}') ;; esac - for entry in "${archive_entries[@]}"; do - expected_chds+=("$(archive_entry_to_chd_name "$entry")") - done + + if [[ ${#archive_entries[@]} -gt 0 ]]; then + mapfile -t archive_entries < <(select_preferred_disc_entries "${archive_entries[@]}") + log DEBUG "๐Ÿ“€ Selected ${#archive_entries[@]} preferred disc descriptor(s) from archive: $(basename "$input_file")" + for entry in "${archive_entries[@]}"; do + log DEBUG " Selected archive entry: $entry" + expected_chds+=("$(archive_entry_to_chd_name "$entry")") + done + fi fi if is_in_list "$ext" "${disc_exts[@]}"; then From b58cd371c983e0b1c5a0930d55a3d74d1314312a Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Sat, 7 Mar 2026 22:25:28 +0000 Subject: [PATCH 33/60] fix: apply descriptor priority when discovering extracted disc files - prefer CUE, then GDI, then CCD, then ISO after archive extraction - keep extracted file discovery consistent with archive entry selection - add debug logging for selected extracted disc files - avoid reintroducing duplicate component-file handling in extracted sets refactor: reuse descriptor-priority selection for disc candidates --- chdtool.sh | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index a722fe9..5071cda 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -341,19 +341,19 @@ archive_entry_to_chd_name() { printf '%s.chd' "$stem" } -select_preferred_disc_entries() { - local entries=("$@") +select_preferred_disc_candidates() { + local candidates=("$@") local -a cues=() gdis=() ccds=() isos=() - local entry ext + local candidate ext - for entry in "${entries[@]}"; do - ext="${entry##*.}" + for candidate in "${candidates[@]}"; do + ext="${candidate##*.}" ext="${ext,,}" case "$ext" in - cue) cues+=("$entry") ;; - gdi) gdis+=("$entry") ;; - ccd) ccds+=("$entry") ;; - iso) isos+=("$entry") ;; + cue) cues+=("$candidate") ;; + gdi) gdis+=("$candidate") ;; + ccd) ccds+=("$candidate") ;; + iso) isos+=("$candidate") ;; esac done @@ -1205,7 +1205,7 @@ process_input() { esac if [[ ${#archive_entries[@]} -gt 0 ]]; then - mapfile -t archive_entries < <(select_preferred_disc_entries "${archive_entries[@]}") + mapfile -t archive_entries < <(select_preferred_disc_candidates "${archive_entries[@]}") log DEBUG "๐Ÿ“€ Selected ${#archive_entries[@]} preferred disc descriptor(s) from archive: $(basename "$input_file")" for entry in "${archive_entries[@]}"; do log DEBUG " Selected archive entry: $entry" @@ -1291,11 +1291,18 @@ process_input() { read -r -a disc_find_expr <<< "$(build_find_expr "${disc_exts[@]}")" mapfile -d '' -t disc_files < <(find "$temp_dir" -type f \( "${disc_find_expr[@]}" \) -print0) - if [[ ${#disc_files[@]} -eq 0 && ${#archive_entries[@]} -gt 0 ]]; then + if [[ ${#disc_files[@]} -gt 0 ]]; then + mapfile -t disc_files < <(select_preferred_disc_candidates "${disc_files[@]}") + log DEBUG "๐Ÿ“€ Selected ${#disc_files[@]} preferred extracted disc file(s) from: $temp_dir" + for disc in "${disc_files[@]}"; do + log DEBUG " Selected extracted file: $disc" + done + elif [[ ${#archive_entries[@]} -gt 0 ]]; then for entry in "${archive_entries[@]}"; do local full_path="$temp_dir/$entry" [[ -f "$full_path" ]] && disc_files+=("$full_path") done + log DEBUG "๐Ÿ“€ Falling back to archive-selected extracted entries: ${#disc_files[@]}" fi fi From f6f189be5c947c72830aace8e2aebb8b3892eaad Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Sat, 7 Mar 2026 22:32:54 +0000 Subject: [PATCH 34/60] fix: allow wav audio tracks in cue validation - stop warning for WAV files referenced by CUE sheets - log WAV audio tracks at DEBUG level instead - continue warning for lossy/unsupported audio formats such as MP3 and OGG --- chdtool.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 5071cda..5c520d6 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -894,8 +894,10 @@ validate_cue_file() { [[ "$ref_lower" == "${cue_basename,,}" ]] && continue - if [[ "$ref_lower" == *.mp3 || "$ref_lower" == *.wav ]]; then - log WARN "โš ๏ธ CUE file references unsupported audio format: $ref_basename" + if [[ "$ref_lower" == *.wav ]]; then + log DEBUG "๐ŸŽต CUE file references WAV audio track: $ref_basename" + elif [[ "$ref_lower" == *.mp3 || "$ref_lower" == *.ogg || "$ref_lower" == *.opus || "$ref_lower" == *.m4a ]]; then + log WARN "โš ๏ธ CUE file references lossy/unsupported audio format: $ref_basename" fi if [[ "$ref_norm" == /* || "$ref_norm" == *".."* ]]; then log WARN "โš ๏ธ Skipping unsafe external path in CUE: $ref_basename" From ccc8f5fdb87b59859cefce9098405cc373ae547d Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Sat, 7 Mar 2026 22:39:13 +0000 Subject: [PATCH 35/60] fix: fail cue validation on lossy or unverified audio tracks - allow WAV audio tracks referenced by CUE sheets - reject lossy audio formats such as MP3 and OGG - reject FLAC conservatively until chdman input support is confirmed - keep missing referenced files as hard validation failures --- chdtool.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 5c520d6..df1136a 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -879,6 +879,7 @@ validate_cue_file() { local cue_basename cue_basename="$(basename "$cue_file")" local missing=0 + local unsupported_audio=0 declare -A file_map while IFS= read -r -d '' f; do @@ -894,23 +895,34 @@ validate_cue_file() { [[ "$ref_lower" == "${cue_basename,,}" ]] && continue - if [[ "$ref_lower" == *.wav ]]; then - log DEBUG "๐ŸŽต CUE file references WAV audio track: $ref_basename" - elif [[ "$ref_lower" == *.mp3 || "$ref_lower" == *.ogg || "$ref_lower" == *.opus || "$ref_lower" == *.m4a ]]; then - log WARN "โš ๏ธ CUE file references lossy/unsupported audio format: $ref_basename" - fi if [[ "$ref_norm" == /* || "$ref_norm" == *".."* ]]; then log WARN "โš ๏ธ Skipping unsafe external path in CUE: $ref_basename" continue fi + if [[ -z "${file_map["$ref_lower"]:-}" ]]; then log ERROR "โŒ Missing referenced file in CUE: $ref_basename (required by $cue_file)" missing=1 + continue fi + + case "$ref_lower" in + *.wav) + log DEBUG "๐ŸŽต CUE file references WAV audio track: $ref_basename" + ;; + *.mp3|*.ogg|*.opus|*.m4a) + log ERROR "โŒ CUE file references lossy/unsupported audio format: $ref_basename" + unsupported_audio=1 + ;; + *.flac) + log ERROR "โŒ CUE file references FLAC audio track: $ref_basename (lossless, but chdman input support is not yet confirmed)" + unsupported_audio=1 + ;; + esac fi done < "$cue_file" - return $missing + (( missing == 0 && unsupported_audio == 0 )) } detect_disc_type() { From 12e32a129a3e3cca08cd16fdf7b94eb83675841b Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Sat, 7 Mar 2026 22:44:12 +0000 Subject: [PATCH 36/60] feat: add override for unverified cue audio tracks - add --allow-unverified-cue-audio / -a flag - allow lossy or unverified CUE audio references when explicitly requested - keep strict validation as the default behaviour - log a warning when the override is enabled --- chdtool.sh | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index df1136a..703e7b7 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -6,10 +6,11 @@ script_start_time=$(date +%s) shopt -s nullglob shopt -s extglob -USAGE="Usage: $0 [--keep-originals|-k] [--recursive|-r] [--dry-run|-n] [--file-tee|-F|--no-file-tee|-N] " +USAGE="Usage: $0 [--keep-originals|-k] [--recursive|-r] [--dry-run|-n] [--allow-unverified-cue-audio|-a] [--file-tee|-F|--no-file-tee|-N] " KEEP_ORIGINALS=false RECURSIVE=false DRY_RUN=false +ALLOW_UNVERIFIED_CUE_AUDIO=false INPUT_DIR="" IS_RAM_DISK=false RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)-$$}" @@ -18,7 +19,7 @@ case "${CHDMAN_MSG_LEVEL^^}" in DEBUG|INFO|WARN|ERROR) ;; # OK *) CHDMAN_MSG_LEVEL="INFO" ;; # fallback esac - + # Manual parsing to support long options while [[ $# -gt 0 ]]; do case "$1" in @@ -28,6 +29,8 @@ while [[ $# -gt 0 ]]; do RECURSIVE=true; shift ;; --dry-run|-n) DRY_RUN=true; shift ;; + --allow-unverified-cue-audio|-a) + ALLOW_UNVERIFIED_CUE_AUDIO=true; shift ;; --file-tee|-F) LOG_TEE_FILE=1; shift ;; --no-file-tee|-N) @@ -205,6 +208,7 @@ log DEBUG "๐Ÿ“ Using temp workspace: $TMPDIR" log INFO "๐Ÿš€ Script started, input dir: $INPUT_DIR" [[ "$RECURSIVE" == true ]] && log INFO "๐Ÿ“‚ Recursive mode enabled โ€” scanning subdirectories" [[ "$DRY_RUN" == true ]] && log INFO "๐Ÿงช Dry-run mode: no files will be written, moved, or deleted" +[[ "$ALLOW_UNVERIFIED_CUE_AUDIO" == true ]] && log WARN "โš ๏ธ Allowing unverified/lossy CUE audio tracks โ€” conversion may succeed from non-preservation-grade sources" is_in_list() { local needle="$1"; shift @@ -911,12 +915,20 @@ validate_cue_file() { log DEBUG "๐ŸŽต CUE file references WAV audio track: $ref_basename" ;; *.mp3|*.ogg|*.opus|*.m4a) - log ERROR "โŒ CUE file references lossy/unsupported audio format: $ref_basename" - unsupported_audio=1 + if [[ "$ALLOW_UNVERIFIED_CUE_AUDIO" == true ]]; then + log WARN "โš ๏ธ CUE file references lossy/unsupported audio format, but override is enabled: $ref_basename" + else + log ERROR "โŒ CUE file references lossy/unsupported audio format: $ref_basename" + unsupported_audio=1 + fi ;; *.flac) - log ERROR "โŒ CUE file references FLAC audio track: $ref_basename (lossless, but chdman input support is not yet confirmed)" - unsupported_audio=1 + if [[ "$ALLOW_UNVERIFIED_CUE_AUDIO" == true ]]; then + log WARN "โš ๏ธ CUE file references FLAC audio track, but override is enabled: $ref_basename" + else + log ERROR "โŒ CUE file references FLAC audio track: $ref_basename (lossless, but chdman input support is not yet confirmed)" + unsupported_audio=1 + fi ;; esac fi @@ -1047,7 +1059,7 @@ convert_disc_file() { # If it's a CUE, validate referenced files first if [[ "${file,,}" == *.cue ]]; then if ! validate_cue_file "$file"; then - log ERROR "โŒ Missing referenced file in CUE: $file" + log ERROR "โŒ CUE validation failed: $file" return 1 fi fi From 5a70e8dcf3e3746242ad855a3d66ed34f5fd22e9 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Sat, 7 Mar 2026 22:50:03 +0000 Subject: [PATCH 37/60] chore: add debug logging for expected chd names and cue validation success --- chdtool.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 703e7b7..a56560b 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -934,7 +934,11 @@ validate_cue_file() { fi done < "$cue_file" - (( missing == 0 && unsupported_audio == 0 )) + if (( missing == 0 && unsupported_audio == 0 )); then + log DEBUG "โœ… CUE validation passed: $cue_file" + return 0 + fi + return 1 } detect_disc_type() { @@ -1234,8 +1238,11 @@ process_input() { mapfile -t archive_entries < <(select_preferred_disc_candidates "${archive_entries[@]}") log DEBUG "๐Ÿ“€ Selected ${#archive_entries[@]} preferred disc descriptor(s) from archive: $(basename "$input_file")" for entry in "${archive_entries[@]}"; do + local expected_chd + expected_chd="$(archive_entry_to_chd_name "$entry")" log DEBUG " Selected archive entry: $entry" - expected_chds+=("$(archive_entry_to_chd_name "$entry")") + log DEBUG " Expected CHD: $expected_chd" + expected_chds+=("$expected_chd") done fi fi From da974aef2434e3a7c9732034261f638c11aa46d5 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Sat, 7 Mar 2026 22:51:39 +0000 Subject: [PATCH 38/60] chore: Change error message to be more informative --- chdtool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index a56560b..d4bdac1 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1261,7 +1261,7 @@ process_input() { # Broken CUEs should count as an input failure (even if we can skip conversion). if [[ "$ext" == "cue" ]]; then if ! validate_cue_file "$input_file"; then - log ERROR "โŒ CUE references missing files (input considered failed): $input_file" + log ERROR "โŒ CUE validation failed (input considered failed): $input_file" input_failed=true fi fi From 1a2eff29a32da526dffeb322d0b6850f6be970ef Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Tue, 24 Mar 2026 14:14:54 +0000 Subject: [PATCH 39/60] docs: overhaul README with clickable badges and comprehensive documentation - Add license, release, and ShellCheck badges (clickable) - Include quick usage example near top - Document supported formats, logging, verification, and M3U handling - Clarify dry-run behaviour and requirements - Improve overall structure and readability --- README.md | 246 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 180 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 34e87ef..7870be0 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,216 @@ -# ๐ŸŽฎ chdtool +# chdtool -[![License](https://img.shields.io/github/license/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/blob/master/LICENSE) [![Latest Release](https://img.shields.io/github/v/release/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/releases) [![ShellCheck](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml/badge.svg)](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml) +[![License](https://img.shields.io/github/license/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/blob/main/LICENSE) +[![Release](https://img.shields.io/github/v/release/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/releases) +[![ShellCheck](https://img.shields.io/github/actions/workflow/status/lloydsmart/chdtool/shellcheck.yml?branch=main&label=shellcheck)](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml) -A Bash script that converts CD/DVD disc images (ZIP/RAR/7Z/ISO/CUE/GDI/CCD) into CHD using chdman. +A robust Bash script for converting CD/DVD disc images and archives into **CHD (Compressed Hunks of Data)** format using `chdman`. -Convert, verify, and manage disc-based game images into CHD format with ease. -Supports recursive scanning, CD/DVD detection, archive extraction, and more. +Designed for batch processing, validation, logging, and multi-disc handling โ€” ideal for emulation libraries. --- -## โœจ Features +Convert everything in a directory: -- ๐Ÿ“ฆ Extracts archives (`.zip`, `.rar`, `.7z`) -- ๐Ÿ’ฟ Converts **CD images** via `chdman createcd` -- ๐Ÿ“€ Converts **DVD images** via `chdman createdvd` (if available) -- ๐Ÿ” Verifies existing CHDs before conversion -- ๐Ÿ—‘๏ธ Cleans up temp files automatically -- ๐Ÿ“Š Logs space savings per file and in total -- ๐Ÿ”„ Recursive directory scanning -- โš™๏ธ Option to keep originals with `--keep-originals` +```bash +./chdtool.sh -r /path/to/roms +``` + +--- + +## โœจ Features + +- ๐Ÿ“ฆ Supports common input formats: + - Archives: `zip`, `rar`, `7z` + - Disc images: `iso`, `cue`, `gdi`, `ccd` +- ๐Ÿ”„ Automatic extraction of archives before conversion +- ๐Ÿ’ฟ Intelligent CD vs DVD detection (`createcd` vs `createdvd`) +- โœ… Verification of CHDs using `chdman verify` + - Automatic retry on failure + - Deletes invalid CHDs +- ๐Ÿ“‰ Space savings reporting +- ๐Ÿงพ Automatic M3U generation for multi-disc sets +- ๐Ÿงน Safe temp directory handling with cleanup traps +- ๐Ÿงช Dry-run mode (no changes made) +- ๐Ÿชต Structured logging system: + - Console / file / syslog / journald + - Configurable verbosity +- โšก Progress bar (TTY-aware, non-spammy) --- ## ๐Ÿš€ Usage + ```bash -./chdtool.sh [--keep-originals|-k] [--recursive|-r] +./chdtool.sh [options] ``` -Examples: +### Options + +| Option | Description | +|------|-------------| +| `-k`, `--keep-originals` | Do not delete source files after conversion | +| `-r`, `--recursive` | Process subdirectories | +| `-n`, `--dry-run` | Show what would happen without making changes | +| `-F`, `--file-tee` | Force logging to file | +| `-N`, `--no-file-tee` | Disable logging to file | + +--- + +## ๐Ÿ“ Example + ```bash -./chdtool.sh /path/to/dir -./chdtool.sh -k -r /roms +./chdtool.sh -r /mnt/roms ``` -### Options +Dry run: + +```bash +./chdtool.sh -n /mnt/roms +``` + +--- + +## ๐Ÿ“ฆ Supported Input Formats + +### Disc images +- `.iso` +- `.cue` (with referenced BIN/WAV/MP3 validation) +- `.gdi` +- `.ccd` + +### Archives +- `.zip` +- `.rar` +- `.7z` + +Archives are extracted to a temporary directory and processed automatically. + +--- + +## ๐Ÿ’ฟ Output + +- CHDs are created alongside the input files +- Temporary files use `.tmp` suffix until verified +- Originals are removed unless `--keep-originals` is set + +--- + +## ๐Ÿงพ Multi-disc Support + +- Automatically detects disc numbering patterns: + - `Disc 1`, `CD2`, `Part 3`, `Side A`, `1 of 2`, etc. +- Generates `.m3u` playlists when **2+ discs** are detected +- Filenames are sanitized for cross-platform compatibility + +Example: -- `--keep-originals` โ†’ Keep input archives/discs after conversion -- `--help` โ†’ Show usage +``` +Final Fantasy VII (Disc 1).chd +Final Fantasy VII (Disc 2).chd +Final Fantasy VII.m3u +``` + +--- + +## ๐Ÿ” Verification + +- All CHDs are verified with `chdman verify` +- Failed verification: + - Retried once + - Deleted if still invalid +- Existing CHDs are verified before skipping conversion --- -## ๐Ÿ“‹ Example Run - -```text -[2025-07-05 14:00:12] ๐Ÿš€ Script started, input dir: /mnt/retronas/roms/sega/saturn/ -[2025-07-05 14:00:12] โ„น๏ธ Using chdman - MAME Compressed Hunks of Data (CHD) manager 0.251 (unknown) -[2025-07-05 14:00:12] ๐Ÿ”Ž Found 6 inputs -[2025-07-05 14:00:12] โ–ถ๏ธ Processing file: /mnt/retronas/roms/sega/saturn/Virtua Fighter (Europe).zip -[2025-07-05 14:00:12] ๐Ÿ“ฆ Extracting Virtua Fighter (Europe).zip to /tmp/chdconv_Virtua Fighter (Europe)_a1B2 -[2025-07-05 14:00:14] ๐Ÿ’ฟ Converting: Virtua Fighter (Europe).cue โ†’ Virtua Fighter (Europe).chd.tmp -Compression complete ... final ratio = 58.7% -[2025-07-05 14:01:02] ๐Ÿ”Ž Verifying: Virtua Fighter (Europe).chd.tmp -[2025-07-05 14:01:20] โœ… Verified CHD: Virtua Fighter (Europe).chd.tmp -[2025-07-05 14:01:20] ๐Ÿ”„ Replaced old CHD with new verified CHD: Virtua Fighter (Europe).chd -[2025-07-05 14:01:20] ๐Ÿ—‘๏ธ Removing original input file: Virtua Fighter (Europe).zip -[2025-07-05 14:01:20] ๐Ÿ“‰ Space saving for Virtua Fighter (Europe).zip: 640 MB โ†’ 375 MB, saved 265 MB (41%) -[2025-07-05 14:01:20] ๐Ÿ”ค Raw base name: Virtua Fighter (Europe) -[2025-07-05 14:01:20] ๐Ÿ”ค Normalized base name for M3U: virtua fighter -[2025-07-05 14:01:20] ๐Ÿ” Found 1 CHD candidate, remaining inputs: yes -[2025-07-05 14:01:20] ๐Ÿงน Cleaned up temp dir on exit: /tmp/chdconv_Virtua Fighter (Europe)_a1B2 -[2025-07-05 14:01:20] ๐ŸŽ‰ All inputs processed successfully! -[2025-07-05 14:01:20] ๐Ÿ“Š Summary: -[2025-07-05 14:01:20] ๐Ÿ“ฆ Total original size: 5 GB -[2025-07-05 14:01:20] ๐Ÿ’ฟ Total CHD size: 4 GB -[2025-07-05 14:01:20] ๐Ÿ“‰ Total space saved: 1 GB (23%) -[2025-07-05 14:01:20] ๐Ÿ“ฆ Archives processed: 6 -[2025-07-05 14:01:20] ๐Ÿ’ฟ CHDs created: 6 -[2025-07-05 14:01:20] โŒ Failures: 0 -[2025-07-05 14:01:20] โฑ๏ธ Elapsed time: 55m 56s -[2025-07-05 14:01:20] โœ… Done! +## ๐Ÿชต Logging + +Configurable via environment variables: + +```bash +LOG_DEST=auto|console|file|syslog|journald +LOG_LEVEL_THRESHOLD=DEBUG|INFO|WARN|ERROR +LOG_TEE_CONSOLE=auto|1|0 +LOG_TEE_FILE=1|0 +LOG_TAG=chdtool +``` +Default log file: + +``` +logs/chd_conversion_.log ``` --- -## Notes -- Temporary CHDs are created as `*.chd.tmp` then verified and renamed. -- Logs are written under `logs/`. -- DVD detection: prefers UDF via `file`, otherwise size heuristic (โ‰ฅ1 GB โ†’ DVD). +## ๐Ÿ“Š Output Summary + +At the end of a run: + +- Total original size +- Total CHD size +- Space saved +- Archives processed +- CHDs created +- Failures +- Elapsed time + +--- + +## โš™๏ธ Requirements + +The following tools must be installed: + +- `chdman` +- `unzip` +- `unrar` +- `7z` +- `stat` +- `awk` + +Optional (enhancements): + +- `file` (better ISO detection) +- `perl` (improved filename parsing) +- `uconv` (Unicode normalization) +- `systemd-cat` / `logger` (logging backends) + +--- + +## ๐Ÿงช Dry Run Mode + +Use `--dry-run` to preview actions: + +```bash +./chdtool.sh -n /roms +``` + +- No files are created, moved, or deleted +- All intended operations are logged + +--- -## ๐ŸŒŸ Roadmap +## โš ๏ธ Notes -- [ ] M3U playlist generation for multi-disc sets -- [ ] Detect multi-part RAR/7z archives -- [ ] Add parallel conversion mode +- DVD support requires a version of `chdman` with `createdvd` +- CUE files referencing missing files will fail validation +- Temporary files are cleaned automatically, even on interruption --- -## ๐Ÿ›  Requirements +## ๐Ÿ› ๏ธ Design Goals -- `bash` โ‰ฅ 5.0 -- `chdman` (from [MAME](https://www.mamedev.org/)) -- `p7zip`, `unrar`, `unzip` for archives -- `file` utility (for CD/DVD detection) -- stat, file, find, grep, sort, tee +- Safe by default (verify before replace) +- Idempotent (re-runs donโ€™t duplicate work) +- Transparent logging +- Minimal dependencies +- Works well on large ROM collections --- -## ๐Ÿ“œ License +## ๐Ÿ“Œ Future Ideas -[MIT License](LICENSE) ยฉ 2025 Lloyd Smart +- Parallel processing +- Better metadata integration +- Optional compression tuning +- Integration with tools like Retromount / ROM managers \ No newline at end of file From 1ef5cca08dd405dd307b65e60dd9b1ba4dabeac4 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Tue, 24 Mar 2026 14:16:57 +0000 Subject: [PATCH 40/60] docs: tweaks to README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7870be0..ab6c137 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# chdtool - [![License](https://img.shields.io/github/license/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/blob/main/LICENSE) [![Release](https://img.shields.io/github/v/release/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/releases) [![ShellCheck](https://img.shields.io/github/actions/workflow/status/lloydsmart/chdtool/shellcheck.yml?branch=main&label=shellcheck)](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml) +# ๐ŸŽฎ chdtool + A robust Bash script for converting CD/DVD disc images and archives into **CHD (Compressed Hunks of Data)** format using `chdman`. Designed for batch processing, validation, logging, and multi-disc handling โ€” ideal for emulation libraries. From ce7449299039de916d253348b92ee84c92819f98 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Tue, 24 Mar 2026 14:20:11 +0000 Subject: [PATCH 41/60] ci: update actions/checkout to v5 and opt into Node 24 - Replace checkout@v4 with v5 (Node 24 compatible) - Enable FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 - Prevent upcoming Node 20 deprecation issues --- .github/workflows/release.yml | 5 +++-- .github/workflows/shellcheck.yml | 4 +++- .github/workflows/tests.yml | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc5e932..eb6c498 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,10 +12,11 @@ permissions: jobs: release: runs-on: ubuntu-latest - + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - name: Check out repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index dc6b7cc..4bbe1d8 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -5,8 +5,10 @@ on: [push, pull_request] jobs: shellcheck: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install ShellCheck run: sudo apt-get update && sudo apt-get install -y shellcheck - name: Run ShellCheck diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aea1b4f..134ee11 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,8 +9,10 @@ on: jobs: tests: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install test deps run: | From 875c0721e1bfaf5d476a131faa350ea44430a509 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Tue, 24 Mar 2026 14:23:53 +0000 Subject: [PATCH 42/60] ci: upgrade upload-artifact to v6 for Node 24 compatibility --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 134ee11..773d769 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,7 +30,7 @@ jobs: - name: Upload logs from tests (always) if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: logs-from-tests path: logs/ From 08f5b841a2a6c10ed853f7edf4394889fdca9bff Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 00:27:41 +0100 Subject: [PATCH 43/60] fix: make source deletion transactional Retain the original archive when any expected member fails conversion. Verify the complete final CHD set and generate its M3U before deleting the source. Add a black-box regression test for partial multi-disc conversion. --- Makefile | 9 ++-- chdtool.sh | 20 ++++++--- tests/bin/chdman | 19 ++++++-- tests/test_transactional_source_deletion.sh | 49 +++++++++++++++++++++ 4 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 tests/test_transactional_source_deletion.sh diff --git a/Makefile b/Makefile index cf57f67..6b69e5f 100644 --- a/Makefile +++ b/Makefile @@ -3,15 +3,15 @@ SHELL := /usr/bin/env bash # Path to your script; override with: make test SCRIPT=./path/to/script.sh SCRIPT ?= ./chdtool.sh -.PHONY: test test-m3u test-m3u-single test-logging test-setup clean changelog changelog-tag +.PHONY: test test-m3u test-m3u-single test-transactional-source-deletion test-logging test-setup clean changelog changelog-tag -test: test-setup test-m3u test-m3u-single test-logging +test: test-setup test-m3u test-m3u-single test-transactional-source-deletion test-logging test-setup: chmod +x tests/bin/chdman chmod +x tests/bin/unrar -chmod +x tests/bin/7z - chmod +x tests/test_m3u.sh tests/test_m3u_single.sh + chmod +x tests/test_m3u.sh tests/test_m3u_single.sh tests/test_transactional_source_deletion.sh @if [ -f tests/test_logging.sh ]; then chmod +x tests/test_logging.sh; fi test-m3u: @@ -20,6 +20,9 @@ test-m3u: test-m3u-single: SCRIPT=$(SCRIPT) bash tests/test_m3u_single.sh +test-transactional-source-deletion: + SCRIPT=$(SCRIPT) bash tests/test_transactional_source_deletion.sh + test-logging: @if [ -f tests/test_logging.sh ]; then \ SCRIPT=$(SCRIPT) bash tests/test_logging.sh ; \ diff --git a/chdtool.sh b/chdtool.sh index d4bdac1..b0506d0 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1266,17 +1266,17 @@ process_input() { fi fi - # If all expected CHDs already exist and verify, remove original and done + # If all expected CHDs already exist and verify, generate the complete-set + # playlist before treating source removal as the final action. if [[ "$input_failed" != true ]] && verify_chds "$outdir" "${expected_chds[@]}"; then log INFO "โœ… All expected CHDs verified for $input_file" - _remove_input_if_allowed - # Per-iteration M3U generation for already-present sets if [[ ${#expected_chds[@]} -gt 0 ]]; then local chd_base chd_base="$(basename "${expected_chds[0]}" .chd)" log DEBUG "๐Ÿ”ค Raw base name: $chd_base" maybe_generate_m3u_for "$chd_base" "$outdir" fi + _remove_input_if_allowed return 0 fi @@ -1409,8 +1409,6 @@ process_input() { chds_created=$((chds_created + 1)) done - _remove_input_if_allowed - for chd in "${expected_chds[@]}"; do if [[ -f "$outdir/$chd" ]]; then archive_chd_size=$((archive_chd_size + $(get_file_size "$outdir/$chd"))) @@ -1436,11 +1434,19 @@ process_input() { fi if [[ "$input_failed" == true ]]; then + log WARN "โš ๏ธ Not all expected members converted successfully for $input_file, keeping original" + _return_failed_input + return 1 + fi + + # Source deletion is the final action. First require the complete expected + # final CHD set to exist and pass verification, then generate its playlist. + if ! verify_chds "$outdir" "${expected_chds[@]}"; then + log WARN "โš ๏ธ Complete final CHD set failed verification for $input_file, keeping original" _return_failed_input return 1 fi - # Per-iteration M3U generation (only on success) if [[ ${#expected_chds[@]} -gt 0 ]]; then local chd_base chd_base="$(basename "${expected_chds[0]}" .chd)" @@ -1448,6 +1454,8 @@ process_input() { maybe_generate_m3u_for "$chd_base" "$outdir" fi + _remove_input_if_allowed + return 0 } diff --git a/tests/bin/chdman b/tests/bin/chdman index a2c4063..bd4164b 100755 --- a/tests/bin/chdman +++ b/tests/bin/chdman @@ -11,9 +11,22 @@ case "$1" in exit 0 ;; createcd|createdvd) - # not used in the test; if called, fail fast so we notice - echo "stub chdman: create not supported in test" >&2 - exit 2 + input="" + output="" + while [[ $# -gt 0 ]]; do + case "$1" in + -i) input="$2"; shift 2 ;; + -o) output="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ -n "${CHDMAN_FAIL_INPUT:-}" && "$input" == *"$CHDMAN_FAIL_INPUT"* ]]; then + echo "stub chdman: requested failure for $input" >&2 + exit 2 + fi + : > "$output" + echo "Compressing, 100% complete (ratio=50%)" >&2 + exit 0 ;; *) echo "stub chdman: unknown args: $*" >&2 diff --git a/tests/test_transactional_source_deletion.sh b/tests/test_transactional_source_deletion.sh new file mode 100644 index 0000000..8df888d --- /dev/null +++ b/tests/test_transactional_source_deletion.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}" +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT + +export PATH="$REPO_ROOT/tests/bin:$PATH" +export PROGRESS_STYLE=none +export LOG_DEST=console +export LOG_LEVEL_THRESHOLD=ERROR +export CHDMAN_FAIL_INPUT="Transactional Game (Disc 2).iso" + +ARCHIVE="$FIX/transactional-game.zip" +( + cd "$FIX" + : > "Transactional Game (Disc 1).iso" + : > "Transactional Game (Disc 2).iso" + zip -q "$(basename "$ARCHIVE")" \ + "Transactional Game (Disc 1).iso" \ + "Transactional Game (Disc 2).iso" + rm -f "Transactional Game (Disc 1).iso" "Transactional Game (Disc 2).iso" +) + +# Issue #30 owns the overall process exit status; this test asserts filesystem effects only. +bash "$SCRIPT" "$FIX" || true + +if [[ ! -f "$ARCHIVE" ]]; then + echo "FAIL: source archive was deleted after a partial conversion" >&2 + exit 1 +fi + +if [[ -f "$FIX/Transactional Game.m3u" ]]; then + echo "FAIL: M3U was generated for an incomplete CHD set" >&2 + exit 1 +fi + +if [[ ! -f "$FIX/Transactional Game (Disc 1).chd" ]]; then + echo "FAIL: successfully verified Disc 1 CHD was not finalised" >&2 + exit 1 +fi + +if [[ -f "$FIX/Transactional Game (Disc 2).chd" ]]; then + echo "FAIL: failed Disc 2 unexpectedly produced a final CHD" >&2 + exit 1 +fi + +echo "PASS: partial archive conversion retains source and omits M3U" From 9aab0955e25eb5979fb9dc49f0a67dbbdec6a647 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 00:47:30 +0100 Subject: [PATCH 44/60] ci: strengthen repository quality checks --- .github/dependabot.yml | 11 +++++++ .github/workflows/lint-actions.yml | 46 ++++++++++++++++++++++++++++++ .github/workflows/lint-docs.yml | 42 +++++++++++++++++++++++++++ .github/workflows/release.yml | 10 +++---- .github/workflows/shellcheck.yml | 46 +++++++++++++++++++++--------- .github/workflows/tests.yml | 34 ++++++++++++++-------- .markdownlint.json | 4 +++ 7 files changed, 163 insertions(+), 30 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/lint-actions.yml create mode 100644 .github/workflows/lint-docs.yml create mode 100644 .markdownlint.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2277541 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/lint-actions.yml b/.github/workflows/lint-actions.yml new file mode 100644 index 0000000..6f9c87f --- /dev/null +++ b/.github/workflows/lint-actions.yml @@ -0,0 +1,46 @@ +name: Lint Actions + +on: + workflow_dispatch: + push: + paths: + - ".github/workflows/**" + pull_request: + paths: + - ".github/workflows/**" + +concurrency: + group: lint-actions-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + actionlint: + name: Lint GitHub Actions workflows + runs-on: ubuntu-latest + env: + ACTIONLINT_VERSION: "1.7.8" + + steps: + - name: Check out repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Install actionlint + shell: bash + run: | + set -euo pipefail + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" + curl -fsSLO "${base_url}/${archive}" + curl -fsSLO "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" + grep " ${archive}$" "actionlint_${ACTIONLINT_VERSION}_checksums.txt" | sha256sum --check + tar -xzf "${archive}" actionlint + sudo install actionlint /usr/local/bin/actionlint + + - name: Lint workflows + run: actionlint diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml new file mode 100644 index 0000000..5137d71 --- /dev/null +++ b/.github/workflows/lint-docs.yml @@ -0,0 +1,42 @@ +name: Lint Docs + +on: + workflow_dispatch: + push: + paths: + - "**/*.md" + - ".markdownlint.json" + - ".github/workflows/lint-docs.yml" + pull_request: + paths: + - "**/*.md" + - ".markdownlint.json" + - ".github/workflows/lint-docs.yml" + +concurrency: + group: lint-docs-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + markdownlint: + name: Lint Markdown + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Lint Markdown + uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 + with: + globs: | + **/*.md + #CHANGELOG.md + #CHANGELOG_RELEASE.md + #LICENSE.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb6c498..7eb1342 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - name: Check out repo - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 fetch-tags: true @@ -24,7 +24,7 @@ jobs: # Generate release notes for this tag (used as Release body) - name: Generate release notes with git-cliff id: cliff_notes - uses: orhun/git-cliff-action@v4 + uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4 with: config: cliff.toml # notes only for the pushed tag, without the big header @@ -43,7 +43,7 @@ jobs: zip -j "dist/chdtool-${GITHUB_REF_NAME}.zip" chdtool README.md LICENSE CHANGELOG.md || true - name: Create GitHub Release and upload assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ github.ref_name }} body_path: ${{ steps.cliff_notes.outputs.changelog }} # file generated above @@ -58,7 +58,7 @@ jobs: # Regenerate the full CHANGELOG.md for main and open a PR - name: Regenerate full CHANGELOG.md id: cliff_full - uses: orhun/git-cliff-action@v4 + uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4 with: config: cliff.toml args: -vv @@ -67,7 +67,7 @@ jobs: GITHUB_REPO: ${{ github.repository }} - name: Open PR to update CHANGELOG.md on main - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6 with: commit-message: "chore(changelog): update for ${{ github.ref_name }}" branch: chore/update-changelog-${{ github.ref_name }} diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 4bbe1d8..251b0df 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -1,15 +1,35 @@ -name: ShellCheck -permissions: - contents: read -on: [push, pull_request] -jobs: - shellcheck: - runs-on: ubuntu-latest +name: ShellCheck + +on: + workflow_dispatch: + push: + paths: + - "chdtool.sh" + - "tests/**/*.sh" + - "tests/bin/*" + - ".github/workflows/shellcheck.yml" + pull_request: + paths: + - "chdtool.sh" + - "tests/**/*.sh" + - "tests/bin/*" + - ".github/workflows/shellcheck.yml" + +concurrency: + group: shellcheck-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + shellcheck: + runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - steps: - - uses: actions/checkout@v5 - - name: Install ShellCheck - run: sudo apt-get update && sudo apt-get install -y shellcheck - - name: Run ShellCheck - run: shellcheck chdtool.sh \ No newline at end of file + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Install ShellCheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Run ShellCheck + run: shellcheck chdtool.sh tests/*.sh tests/bin/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 773d769..b0070cf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,10 +1,26 @@ -name: tests -permissions: - contents: read - pull-requests: write +name: Tests + on: + workflow_dispatch: push: + paths: + - "chdtool.sh" + - "Makefile" + - "tests/**" + - ".github/workflows/tests.yml" pull_request: + paths: + - "chdtool.sh" + - "Makefile" + - "tests/**" + - ".github/workflows/tests.yml" + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: tests: @@ -12,25 +28,19 @@ jobs: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Install test deps run: | sudo apt-get update sudo apt-get install -y unzip zip make - - name: Debug workspace - run: | - pwd - ls -la - sed -n '1,120p' Makefile - - name: Run tests run: make test - name: Upload logs from tests (always) if: ${{ always() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: logs-from-tests path: logs/ diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..b077f0e --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "default": true, + "MD013": false +} From c793f0bc2c84c2bd948ece8375c1a0c876509640 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 00:47:43 +0100 Subject: [PATCH 45/60] docs: fix markdown lint violations --- CONTRIBUTING.md | 37 +++++++++++++++++++++---------------- README.md | 34 ++++++++++++++++++---------------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 084519a..1087fd1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,19 +4,22 @@ Thanks for your interest in contributing to **chdscript**! ๐ŸŽ‰ ## How to Contribute -1. **Fork the repo** and create your feature branch: - ```bash - git checkout -b feature/my-new-feature - ``` - -2. **Commit your changes** with clear messages: - ```bash - git commit -m "Add support for XYZ" - ``` - -3. **Push to the branch**: - ```bash - git push origin feature/my-new-feature +1. **Fork the repo** and create your feature branch: + + ```bash + git checkout -b feature/my-new-feature + ``` + +2. **Commit your changes** with clear messages: + + ```bash + git commit -m "Add support for XYZ" + ``` + +3. **Push to the branch**: + + ```bash + git push origin feature/my-new-feature ``` 4. **Open a Pull Request** against `main`. @@ -37,9 +40,11 @@ Thanks for your interest in contributing to **chdscript**! ๐ŸŽ‰ ## Versioning -We use [Semantic Versioning](https://semver.org/) (semver). -Bump version numbers when you: -- `MAJOR`: Break backward compatibility +We use [Semantic Versioning](https://semver.org/) (semver). + +Bump version numbers when you: + +- `MAJOR`: Break backward compatibility - `MINOR`: Add new functionality in a backward-compatible manner - `PATCH`: Fix bugs or make small improvements diff --git a/README.md b/README.md index ab6c137..42a8cf9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -[![License](https://img.shields.io/github/license/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/blob/main/LICENSE) -[![Release](https://img.shields.io/github/v/release/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/releases) -[![ShellCheck](https://img.shields.io/github/actions/workflow/status/lloydsmart/chdtool/shellcheck.yml?branch=main&label=shellcheck)](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml) - -# ๐ŸŽฎ chdtool +# ๐ŸŽฎ chdtool + +[![License](https://img.shields.io/github/license/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/blob/main/LICENSE) +[![Release](https://img.shields.io/github/v/release/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/releases) +[![ShellCheck](https://img.shields.io/github/actions/workflow/status/lloydsmart/chdtool/shellcheck.yml?branch=main&label=shellcheck)](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml) A robust Bash script for converting CD/DVD disc images and archives into **CHD (Compressed Hunks of Data)** format using `chdman`. @@ -48,7 +48,7 @@ Convert everything in a directory: ### Options | Option | Description | -|------|-------------| +| ------ | ----------- | | `-k`, `--keep-originals` | Do not delete source files after conversion | | `-r`, `--recursive` | Process subdirectories | | `-n`, `--dry-run` | Show what would happen without making changes | @@ -73,14 +73,16 @@ Dry run: ## ๐Ÿ“ฆ Supported Input Formats -### Disc images -- `.iso` +### Disc images + +- `.iso` - `.cue` (with referenced BIN/WAV/MP3 validation) - `.gdi` - `.ccd` -### Archives -- `.zip` +### Archives + +- `.zip` - `.rar` - `.7z` @@ -105,8 +107,8 @@ Archives are extracted to a temporary directory and processed automatically. Example: -``` -Final Fantasy VII (Disc 1).chd +```text +Final Fantasy VII (Disc 1).chd Final Fantasy VII (Disc 2).chd Final Fantasy VII.m3u ``` @@ -137,8 +139,8 @@ LOG_TAG=chdtool Default log file: -``` -logs/chd_conversion_.log +```text +logs/chd_conversion_.log ``` --- @@ -212,5 +214,5 @@ Use `--dry-run` to preview actions: - Parallel processing - Better metadata integration -- Optional compression tuning -- Integration with tools like Retromount / ROM managers \ No newline at end of file +- Optional compression tuning +- Integration with tools like Retromount / ROM managers From b94f2ac6e1308ab7d0f497d23e9ad720d6088521 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 00:50:37 +0100 Subject: [PATCH 46/60] ci: document markdownlint exclusions --- .github/workflows/lint-docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 5137d71..5442087 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -35,6 +35,7 @@ jobs: - name: Lint Markdown uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 with: + # Changelogs are generated by git-cliff; LICENSE.md preserves canonical licence text. globs: | **/*.md #CHANGELOG.md From 3ab3bf1d9f916bc7ddc2732f3b0348dd76987362 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:55:26 +0000 Subject: [PATCH 47/60] chore(deps): bump the github-actions group with 4 updates Bumps the github-actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [softprops/action-gh-release](https://github.com/softprops/action-gh-release), [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) and [actions/upload-artifact](https://github.com/actions/upload-artifact). Updates `actions/checkout` from 5.1.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09...3d3c42e5aac5ba805825da76410c181273ba90b1) Updates `softprops/action-gh-release` from 2.6.2 to 3.0.2 - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/3bb12739c298aeb8a4eeaf626c5b8d85266b0e65...3d0d9888cb7fd7b750713d6e236d1fcb99157228) Updates `peter-evans/create-pull-request` from 6.1.0 to 8.1.1 - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/c5a7806660adbe173f04e3e038b0ccdcd758773c...5f6978faf089d4d20b00c7766989d076bb2fc7f1) Updates `actions/upload-artifact` from 6.0.0 to 7.0.1 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: softprops/action-gh-release dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: peter-evans/create-pull-request dependency-version: 8.1.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/lint-actions.yml | 2 +- .github/workflows/lint-docs.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/shellcheck.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint-actions.yml b/.github/workflows/lint-actions.yml index 6f9c87f..a6066a2 100644 --- a/.github/workflows/lint-actions.yml +++ b/.github/workflows/lint-actions.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install actionlint shell: bash diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 5442087..8652b91 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Lint Markdown uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7eb1342..19233ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - name: Check out repo - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true @@ -43,7 +43,7 @@ jobs: zip -j "dist/chdtool-${GITHUB_REF_NAME}.zip" chdtool README.md LICENSE CHANGELOG.md || true - name: Create GitHub Release and upload assets - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: ${{ github.ref_name }} body_path: ${{ steps.cliff_notes.outputs.changelog }} # file generated above @@ -67,7 +67,7 @@ jobs: GITHUB_REPO: ${{ github.repository }} - name: Open PR to update CHANGELOG.md on main - uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: "chore(changelog): update for ${{ github.ref_name }}" branch: chore/update-changelog-${{ github.ref_name }} diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 251b0df..04fee40 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -28,7 +28,7 @@ jobs: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install ShellCheck run: sudo apt-get update && sudo apt-get install -y shellcheck - name: Run ShellCheck diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b0070cf..17c618d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,7 @@ jobs: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install test deps run: | @@ -40,7 +40,7 @@ jobs: - name: Upload logs from tests (always) if: ${{ always() }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: logs-from-tests path: logs/ From 42d7de785cf726fdcb9d7c20047d8f98344cf14c Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 01:06:30 +0100 Subject: [PATCH 48/60] fix: use unique tracked temporary CHD outputs --- Makefile | 8 ++- chdtool.sh | 82 +++++++++++++++++++++++------ tests/bin/chdman | 7 +++ tests/test_temporary_chd_cleanup.sh | 65 +++++++++++++++++++++++ 4 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 tests/test_temporary_chd_cleanup.sh diff --git a/Makefile b/Makefile index 6b69e5f..e16f16d 100644 --- a/Makefile +++ b/Makefile @@ -3,15 +3,16 @@ SHELL := /usr/bin/env bash # Path to your script; override with: make test SCRIPT=./path/to/script.sh SCRIPT ?= ./chdtool.sh -.PHONY: test test-m3u test-m3u-single test-transactional-source-deletion test-logging test-setup clean changelog changelog-tag +.PHONY: test test-m3u test-m3u-single test-transactional-source-deletion test-temporary-chd-cleanup test-logging test-setup clean changelog changelog-tag -test: test-setup test-m3u test-m3u-single test-transactional-source-deletion test-logging +test: test-setup test-m3u test-m3u-single test-transactional-source-deletion test-temporary-chd-cleanup test-logging test-setup: chmod +x tests/bin/chdman chmod +x tests/bin/unrar -chmod +x tests/bin/7z chmod +x tests/test_m3u.sh tests/test_m3u_single.sh tests/test_transactional_source_deletion.sh + chmod +x tests/test_temporary_chd_cleanup.sh @if [ -f tests/test_logging.sh ]; then chmod +x tests/test_logging.sh; fi test-m3u: @@ -23,6 +24,9 @@ test-m3u-single: test-transactional-source-deletion: SCRIPT=$(SCRIPT) bash tests/test_transactional_source_deletion.sh +test-temporary-chd-cleanup: + SCRIPT=$(SCRIPT) bash tests/test_temporary_chd_cleanup.sh + test-logging: @if [ -f tests/test_logging.sh ]; then \ SCRIPT=$(SCRIPT) bash tests/test_logging.sh ; \ diff --git a/chdtool.sh b/chdtool.sh index b0506d0..d1a8656 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -736,6 +736,22 @@ declare -a TEMP_FILES=() track_temp_dir() { TEMP_DIRS+=("$1"); } track_temp_file() { TEMP_FILES+=("$1"); } +untrack_temp_file() { + local f="$1" i + for i in "${!TEMP_FILES[@]}"; do + [[ "${TEMP_FILES[$i]}" == "$f" ]] && unset 'TEMP_FILES[$i]' + done +} + +cleanup_temp_file_now() { + local f="$1" + if [[ -n "$f" && -f "$f" ]]; then + rm -f -- "$f" + log INFO "๐Ÿ—‘๏ธ Removed temp file: $f" + fi + untrack_temp_file "$f" +} + cleanup_temp_dir_now() { local d="$1" [[ -n "$d" && -d "$d" ]] || return 0 @@ -790,13 +806,15 @@ verify_chds() { if [[ "$DRY_RUN" == true ]]; then for chd in "${chds[@]}"; do - log INFO "๐Ÿงช (dry-run) Would verify: $outdir/$chd" + [[ -n "$outdir" ]] && chd="$outdir/$chd" + log INFO "๐Ÿงช (dry-run) Would verify: $chd" done return 0 fi for chd in "${chds[@]}"; do - local chd_path="$outdir/$chd" + local chd_path="$chd" + [[ -n "$outdir" ]] && chd_path="$outdir/$chd" if [[ ! -f "$chd_path" ]]; then all_verified=false break @@ -1059,6 +1077,9 @@ convert_disc_file() { local file="$1" local outdir="$2" local base_override="${3:-}" + local result_var="${4:-}" + + [[ -n "$result_var" ]] && printf -v "$result_var" '%s' "" # If it's a CUE, validate referenced files first if [[ "${file,,}" == *.cue ]]; then @@ -1076,7 +1097,6 @@ convert_disc_file() { fi local chd_path="$outdir/$base.chd" - local tmp_chd="$outdir/$base.chd.tmp" # If a CHD already exists, verify it and skip if good if [[ -f "$chd_path" ]]; then @@ -1144,7 +1164,6 @@ convert_disc_file() { esac log INFO "$icon Detected $disc_type image โ†’ using chdman $subcmd" - log INFO "๐Ÿ”ง Converting: $file -> $tmp_chd" # Calculate safe threads: ~one thread per 2GB RAM (for CDs) or 4GB RAM (for DVDs) local total_ram_mb=$(( $(awk '/MemTotal|SwapTotal/{sum+=$2} END{print sum}' /proc/meminfo) / 1024 )) @@ -1170,18 +1189,36 @@ convert_disc_file() { (( threads > cpu_cores )) && threads=$cpu_cores if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would run: ${CHDMAN_BIN:-chdman} $subcmd -np $threads -i \"$file\" -o \"$tmp_chd\"" + log INFO "๐Ÿงช (dry-run) Would run: ${CHDMAN_BIN:-chdman} $subcmd -np $threads -i \"$file\" -o a unique temporary CHD beside \"$chd_path\"" return 0 fi + # Reserve a unique location in the destination filesystem. Keeping the + # reservation directory until finalisation prevents concurrent runs from + # ever allocating or writing the same temporary path. + local tmp_dir tmp_chd + tmp_dir="$(mktemp -d -p "$outdir" ".${base}.chd.tmp.${RUN_ID}.XXXXXX")" || { + log ERROR "โŒ Could not allocate temporary CHD path for: $chd_path" + return 1 + } + track_temp_dir "$tmp_dir" + tmp_chd="$tmp_dir/$base.chd" + track_temp_file "$tmp_chd" + [[ -n "$result_var" ]] && printf -v "$result_var" '%s' "$tmp_chd" + log INFO "๐Ÿ”ง Converting: $file -> $tmp_chd" + if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then - if ! PHASE_DEFAULT="Converting" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd" 2>&1 | _chdman_progress_filter; then + if ! PHASE_DEFAULT="Converting" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd" 2>&1 | _chdman_progress_filter; then log ERROR "โŒ chdman $subcmd failed for: $file" + cleanup_temp_file_now "$tmp_chd" + cleanup_temp_dir_now "$tmp_dir" return 1 fi else if ! "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd"; then log ERROR "โŒ chdman $subcmd failed for: $file" + cleanup_temp_file_now "$tmp_chd" + cleanup_temp_dir_now "$tmp_dir" return 1 fi fi @@ -1350,6 +1387,7 @@ process_input() { local archive_chd_size=0 local tmp_chds=() + local final_chds=() if [[ "$DRY_RUN" == true ]]; then for disc in "${disc_files[@]}"; do @@ -1377,8 +1415,12 @@ process_input() { chd_name="$(archive_entry_to_chd_name "$entry")" # e.g. "CD1 - Game.chd" chd_base="${chd_name%.chd}" # e.g. "CD1 - Game" - if convert_disc_file "$extracted" "$outdir" "$chd_base"; then - tmp_chds+=("$outdir/$chd_name.tmp") + local converted_tmp="" + if convert_disc_file "$extracted" "$outdir" "$chd_base" converted_tmp; then + if [[ -n "$converted_tmp" ]]; then + tmp_chds+=("$converted_tmp") + final_chds+=("$outdir/$chd_name") + fi else input_failed=true fi @@ -1391,8 +1433,12 @@ process_input() { else # Non-archive inputs keep the old behaviour for disc in "${disc_files[@]}"; do - if convert_disc_file "$disc" "$outdir"; then - tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp") + local converted_tmp="" + if convert_disc_file "$disc" "$outdir" "" converted_tmp; then + if [[ -n "$converted_tmp" ]]; then + tmp_chds+=("$converted_tmp") + final_chds+=("$outdir/$(basename "${disc%.*}").chd") + fi else input_failed=true fi @@ -1401,10 +1447,14 @@ process_input() { # Verify .tmp CHDs and finalize if [[ "$DRY_RUN" != true && ${#tmp_chds[@]} -gt 0 ]]; then - if verify_chds "$outdir" "${tmp_chds[@]##*/}"; then - for tmp_chd in "${tmp_chds[@]}"; do - local final_chd="${tmp_chd%.tmp}" + if verify_chds "" "${tmp_chds[@]}"; then + local i + for i in "${!tmp_chds[@]}"; do + local tmp_chd="${tmp_chds[$i]}" + local final_chd="${final_chds[$i]}" mv -f -- "$tmp_chd" "$final_chd" + untrack_temp_file "$tmp_chd" + cleanup_temp_dir_now "$(dirname "$tmp_chd")" log INFO "๐Ÿ”„ Replaced old CHD with new verified CHD: $final_chd" chds_created=$((chds_created + 1)) done @@ -1423,10 +1473,8 @@ process_input() { fi else for tmp_chd in "${tmp_chds[@]}"; do - if [[ -f "$tmp_chd" ]]; then - rm -f "$tmp_chd" - log INFO "๐Ÿ—‘๏ธ Removed failed tmp CHD: $tmp_chd" - fi + cleanup_temp_file_now "$tmp_chd" + cleanup_temp_dir_now "$(dirname "$tmp_chd")" done input_failed=true log WARN "โš ๏ธ CHD verification failed after conversion for $input_file, keeping original" diff --git a/tests/bin/chdman b/tests/bin/chdman index bd4164b..7931850 100755 --- a/tests/bin/chdman +++ b/tests/bin/chdman @@ -20,7 +20,14 @@ case "$1" in *) shift ;; esac done + if [[ -n "${CHDMAN_READY_FILE:-}" ]]; then + printf '%s\n' "$output" > "$CHDMAN_READY_FILE" + fi + if [[ -n "${CHDMAN_PAUSE_SECONDS:-}" ]]; then + sleep "$CHDMAN_PAUSE_SECONDS" + fi if [[ -n "${CHDMAN_FAIL_INPUT:-}" && "$input" == *"$CHDMAN_FAIL_INPUT"* ]]; then + : > "$output" echo "stub chdman: requested failure for $input" >&2 exit 2 fi diff --git a/tests/test_temporary_chd_cleanup.sh b/tests/test_temporary_chd_cleanup.sh new file mode 100644 index 0000000..81a8633 --- /dev/null +++ b/tests/test_temporary_chd_cleanup.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}" +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT + +export PATH="$REPO_ROOT/tests/bin:$PATH" +export PROGRESS_STYLE=none +export LOG_DEST=console +export LOG_LEVEL_THRESHOLD=ERROR + +assert_no_temporary_chds() { + if find "$FIX" -name '*.chd.tmp.*' -print -quit | grep -q .; then + echo "FAIL: temporary CHD allocation remained in $FIX" >&2 + find "$FIX" -name '*.chd.tmp.*' -print >&2 + exit 1 + fi +} + +# A failed converter writes a partial output; chdtool must remove it immediately. +: > "$FIX/Failure Game.iso" +CHDMAN_FAIL_INPUT="Failure Game.iso" bash "$SCRIPT" "$FIX" || true +assert_no_temporary_chds +[[ ! -e "$FIX/Failure Game.chd" ]] || { + echo "FAIL: failed conversion produced a final CHD" >&2 + exit 1 +} +rm -f "$FIX/Failure Game.iso" + +# A signal while chdman is active must run the same tracked-file cleanup. +: > "$FIX/Interrupted Game.iso" +READY_FILE="$FIX/chdman.ready" +CHDMAN_READY_FILE="$READY_FILE" CHDMAN_PAUSE_SECONDS=2 \ + bash "$SCRIPT" "$FIX" & +script_pid=$! + +for _ in {1..100}; do + [[ -s "$READY_FILE" ]] && break + sleep 0.05 +done +[[ -s "$READY_FILE" ]] || { + echo "FAIL: timed out waiting for chdman to start" >&2 + kill "$script_pid" 2>/dev/null || true + wait "$script_pid" 2>/dev/null || true + exit 1 +} + +tmp_path="$(tr -d '\r\n' < "$READY_FILE")" +[[ "$tmp_path" == "$FIX"/* && "$tmp_path" == *'.chd.tmp.'*/*'.chd' ]] || { + echo "FAIL: chdman did not receive a unique destination-local temporary path: $tmp_path" >&2 + exit 1 +} + +kill -TERM "$script_pid" +wait "$script_pid" 2>/dev/null || true +sleep 2.2 +assert_no_temporary_chds +[[ ! -e "$FIX/Interrupted Game.chd" ]] || { + echo "FAIL: interrupted conversion produced a final CHD" >&2 + exit 1 +} + +echo "PASS: failed and interrupted conversions clean unique temporary CHDs" From 67caf42783d5f0dc888d3e7ec3676c249450e5da Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 09:59:21 +0100 Subject: [PATCH 49/60] fix: support reliable partial resume states --- Makefile | 8 ++- chdtool.sh | 97 +++++++++++++++++++++++------------- tests/bin/chdman | 14 +++++- tests/test_partial_resume.sh | 37 ++++++++++++++ 4 files changed, 118 insertions(+), 38 deletions(-) create mode 100644 tests/test_partial_resume.sh diff --git a/Makefile b/Makefile index e16f16d..2ab3b5c 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,9 @@ SHELL := /usr/bin/env bash # Path to your script; override with: make test SCRIPT=./path/to/script.sh SCRIPT ?= ./chdtool.sh -.PHONY: test test-m3u test-m3u-single test-transactional-source-deletion test-temporary-chd-cleanup test-logging test-setup clean changelog changelog-tag +.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-logging test-setup clean changelog changelog-tag -test: test-setup test-m3u test-m3u-single test-transactional-source-deletion test-temporary-chd-cleanup test-logging +test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-logging test-setup: chmod +x tests/bin/chdman @@ -13,6 +13,7 @@ test-setup: -chmod +x tests/bin/7z chmod +x tests/test_m3u.sh tests/test_m3u_single.sh tests/test_transactional_source_deletion.sh chmod +x tests/test_temporary_chd_cleanup.sh + chmod +x tests/test_partial_resume.sh @if [ -f tests/test_logging.sh ]; then chmod +x tests/test_logging.sh; fi test-m3u: @@ -21,6 +22,9 @@ test-m3u: test-m3u-single: SCRIPT=$(SCRIPT) bash tests/test_m3u_single.sh +test-partial-resume: + SCRIPT=$(SCRIPT) bash tests/test_partial_resume.sh + test-transactional-source-deletion: SCRIPT=$(SCRIPT) bash tests/test_transactional_source_deletion.sh diff --git a/chdtool.sh b/chdtool.sh index d1a8656..038854a 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1242,6 +1242,17 @@ process_input() { local ext_regex ext_regex="$(build_ext_regex "${disc_exts[@]}")" local temp_dir="" + declare -A output_states=() + declare -A output_temps=() + + _all_outputs_complete() { + local expected state + for expected in "${expected_chds[@]}"; do + state="${output_states[$expected]:-missing}" + [[ "$state" == "already_verified" || "$state" == "finalised" ]] || return 1 + done + return 0 + } _return_failed_input() { failures=$((failures + 1)) @@ -1303,9 +1314,19 @@ process_input() { fi fi + # Establish one authoritative state for every expected output. + local expected_chd + for expected_chd in "${expected_chds[@]}"; do + if [[ -f "$outdir/$expected_chd" ]] && verify_chds "$outdir" "$expected_chd"; then + output_states["$expected_chd"]="already_verified" + else + output_states["$expected_chd"]="missing" + fi + done + # If all expected CHDs already exist and verify, generate the complete-set # playlist before treating source removal as the final action. - if [[ "$input_failed" != true ]] && verify_chds "$outdir" "${expected_chds[@]}"; then + if [[ "$input_failed" != true ]] && _all_outputs_complete; then log INFO "โœ… All expected CHDs verified for $input_file" if [[ ${#expected_chds[@]} -gt 0 ]]; then local chd_base @@ -1415,13 +1436,18 @@ process_input() { chd_name="$(archive_entry_to_chd_name "$entry")" # e.g. "CD1 - Game.chd" chd_base="${chd_name%.chd}" # e.g. "CD1 - Game" + [[ "${output_states[$chd_name]:-missing}" == "already_verified" ]] && continue + local converted_tmp="" if convert_disc_file "$extracted" "$outdir" "$chd_base" converted_tmp; then if [[ -n "$converted_tmp" ]]; then tmp_chds+=("$converted_tmp") final_chds+=("$outdir/$chd_name") + output_temps["$chd_name"]="$converted_tmp" + output_states["$chd_name"]="converted_pending_verification" fi else + output_states["$chd_name"]="failed" input_failed=true fi done @@ -1433,66 +1459,67 @@ process_input() { else # Non-archive inputs keep the old behaviour for disc in "${disc_files[@]}"; do + local chd_name + chd_name="$(get_chd_basename "$disc").chd" + [[ "${output_states[$chd_name]:-missing}" == "already_verified" ]] && continue + local converted_tmp="" if convert_disc_file "$disc" "$outdir" "" converted_tmp; then if [[ -n "$converted_tmp" ]]; then tmp_chds+=("$converted_tmp") - final_chds+=("$outdir/$(basename "${disc%.*}").chd") + final_chds+=("$outdir/$chd_name") + output_temps["$chd_name"]="$converted_tmp" + output_states["$chd_name"]="converted_pending_verification" fi else + output_states["$chd_name"]="failed" input_failed=true fi done fi - # Verify .tmp CHDs and finalize + # Verify and finalise pending outputs independently. Existing verified + # outputs are never added to this temporary-output verification pass. if [[ "$DRY_RUN" != true && ${#tmp_chds[@]} -gt 0 ]]; then - if verify_chds "" "${tmp_chds[@]}"; then - local i - for i in "${!tmp_chds[@]}"; do - local tmp_chd="${tmp_chds[$i]}" - local final_chd="${final_chds[$i]}" + local chd_name tmp_chd final_chd + for chd_name in "${expected_chds[@]}"; do + [[ "${output_states[$chd_name]:-missing}" == "converted_pending_verification" ]] || continue + tmp_chd="${output_temps[$chd_name]}" + final_chd="$outdir/$chd_name" + if verify_chds "" "$tmp_chd"; then mv -f -- "$tmp_chd" "$final_chd" untrack_temp_file "$tmp_chd" cleanup_temp_dir_now "$(dirname "$tmp_chd")" log INFO "๐Ÿ”„ Replaced old CHD with new verified CHD: $final_chd" chds_created=$((chds_created + 1)) - done - - for chd in "${expected_chds[@]}"; do - if [[ -f "$outdir/$chd" ]]; then - archive_chd_size=$((archive_chd_size + $(get_file_size "$outdir/$chd"))) - fi - done - if [[ $archive_size_bytes -gt 0 ]]; then - local saving=$((archive_size_bytes - archive_chd_size)) - local saving_percent=$((100 * saving / archive_size_bytes)) - log INFO "๐Ÿ“‰ Space saving for $(basename "$input_file"): $(human_readable "$archive_size_bytes") โ†’ $(human_readable "$archive_chd_size"), saved $(human_readable "$saving") (${saving_percent}%)" - total_original_size=$((total_original_size + archive_size_bytes)) - total_chd_size=$((total_chd_size + archive_chd_size)) - fi - else - for tmp_chd in "${tmp_chds[@]}"; do + output_states["$chd_name"]="finalised" + else cleanup_temp_file_now "$tmp_chd" cleanup_temp_dir_now "$(dirname "$tmp_chd")" - done - input_failed=true - log WARN "โš ๏ธ CHD verification failed after conversion for $input_file, keeping original" - fi + output_states["$chd_name"]="failed" + input_failed=true + log WARN "โš ๏ธ CHD verification failed after conversion for $chd_name" + fi + done fi - if [[ "$input_failed" == true ]]; then + if [[ "$input_failed" == true ]] || ! _all_outputs_complete; then log WARN "โš ๏ธ Not all expected members converted successfully for $input_file, keeping original" _return_failed_input return 1 fi - # Source deletion is the final action. First require the complete expected - # final CHD set to exist and pass verification, then generate its playlist. - if ! verify_chds "$outdir" "${expected_chds[@]}"; then - log WARN "โš ๏ธ Complete final CHD set failed verification for $input_file, keeping original" - _return_failed_input - return 1 + # Source deletion, accounting, and M3U generation all consume the same + # complete manifest rather than independently inferring success. + for chd_name in "${expected_chds[@]}"; do + archive_chd_size=$((archive_chd_size + $(get_file_size "$outdir/$chd_name"))) + done + if [[ $archive_size_bytes -gt 0 ]]; then + local saving=$((archive_size_bytes - archive_chd_size)) + local saving_percent=$((100 * saving / archive_size_bytes)) + log INFO "๐Ÿ“‰ Space saving for $(basename "$input_file"): $(human_readable "$archive_size_bytes") โ†’ $(human_readable "$archive_chd_size"), saved $(human_readable "$saving") (${saving_percent}%)" + total_original_size=$((total_original_size + archive_size_bytes)) + total_chd_size=$((total_chd_size + archive_chd_size)) fi if [[ ${#expected_chds[@]} -gt 0 ]]; then diff --git a/tests/bin/chdman b/tests/bin/chdman index 7931850..a4bc380 100755 --- a/tests/bin/chdman +++ b/tests/bin/chdman @@ -6,6 +6,17 @@ case "$1" in echo "chdman 0.000 (stub) - createdvd createcd verify"; exit 0 ;; verify) + verify_input="" + while [[ $# -gt 0 ]]; do + case "$1" in + -i) verify_input="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ -f "$verify_input" ]] && grep -q '^INVALID$' "$verify_input"; then + echo "invalid CHD" >&2 + exit 2 + fi # your script greps for "complete" and optionally "(ratio=..%)" echo "Verifying, 100% complete (ratio=50%)" >&2 exit 0 @@ -31,7 +42,8 @@ case "$1" in echo "stub chdman: requested failure for $input" >&2 exit 2 fi - : > "$output" + [[ -n "${CHDMAN_CREATE_LOG:-}" ]] && printf '%s\n' "$input" >> "$CHDMAN_CREATE_LOG" + printf 'converted:%s\n' "$input" > "$output" echo "Compressing, 100% complete (ratio=50%)" >&2 exit 0 ;; diff --git a/tests/test_partial_resume.sh b/tests/test_partial_resume.sh new file mode 100644 index 0000000..330dcdc --- /dev/null +++ b/tests/test_partial_resume.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}" +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT +export PATH="$REPO_ROOT/tests/bin:$PATH" +export PROGRESS_STYLE=none LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR + +make_archive() { + local archive="$1"; shift + (cd "$FIX" && zip -q "$(basename "$archive")" "$@" && rm -f -- "$@") +} + +printf 'existing-valid\n' > "$FIX/Resume Game (Disc 1).chd" +: > "$FIX/Resume Game (Disc 1).iso" +: > "$FIX/Resume Game (Disc 2).iso" +archive="$FIX/resume-game.zip" +make_archive "$archive" "Resume Game (Disc 1).iso" "Resume Game (Disc 2).iso" +create_log="$FIX/create.log" +CHDMAN_CREATE_LOG="$create_log" bash "$SCRIPT" "$FIX" +[[ "$(cat "$FIX/Resume Game (Disc 1).chd")" == "existing-valid" ]] || { echo "FAIL: valid CHD replaced" >&2; exit 1; } +[[ "$(wc -l < "$create_log")" -eq 1 ]] && grep -q 'Disc 2' "$create_log" || { echo "FAIL: expected only Disc 2 conversion" >&2; exit 1; } +[[ -f "$FIX/Resume Game (Disc 2).chd" && -f "$FIX/Resume Game.m3u" && ! -e "$archive" ]] || { echo "FAIL: resumed set incomplete" >&2; exit 1; } + +printf 'INVALID\n' > "$FIX/Replace Game.chd" +: > "$FIX/Replace Game.iso" +archive="$FIX/replace-game.zip" +make_archive "$archive" "Replace Game.iso" +: > "$create_log" +CHDMAN_CREATE_LOG="$create_log" bash "$SCRIPT" "$FIX" +[[ "$(wc -l < "$create_log")" -eq 1 ]] || { echo "FAIL: invalid CHD not converted once" >&2; exit 1; } +grep -q '^converted:' "$FIX/Replace Game.chd" || { echo "FAIL: invalid CHD not replaced" >&2; exit 1; } +[[ ! -e "$archive" ]] || { echo "FAIL: source retained after replacement" >&2; exit 1; } + +echo "PASS: partial resume uses explicit output states" From 9c9c774d1c02f96cc1ceb61de3f464173266d64c Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 10:17:35 +0100 Subject: [PATCH 50/60] test: clarify partial resume assertion --- tests/test_partial_resume.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_partial_resume.sh b/tests/test_partial_resume.sh index 330dcdc..b6bc78f 100644 --- a/tests/test_partial_resume.sh +++ b/tests/test_partial_resume.sh @@ -21,7 +21,10 @@ make_archive "$archive" "Resume Game (Disc 1).iso" "Resume Game (Disc 2).iso" create_log="$FIX/create.log" CHDMAN_CREATE_LOG="$create_log" bash "$SCRIPT" "$FIX" [[ "$(cat "$FIX/Resume Game (Disc 1).chd")" == "existing-valid" ]] || { echo "FAIL: valid CHD replaced" >&2; exit 1; } -[[ "$(wc -l < "$create_log")" -eq 1 ]] && grep -q 'Disc 2' "$create_log" || { echo "FAIL: expected only Disc 2 conversion" >&2; exit 1; } +if [[ "$(wc -l < "$create_log")" -ne 1 ]] || ! grep -q 'Disc 2' "$create_log"; then + echo "FAIL: expected only Disc 2 conversion" >&2 + exit 1 +fi [[ -f "$FIX/Resume Game (Disc 2).chd" && -f "$FIX/Resume Game.m3u" && ! -e "$archive" ]] || { echo "FAIL: resumed set incomplete" >&2; exit 1; } printf 'INVALID\n' > "$FIX/Replace Game.chd" From 29bbea3e71f6fd1e5e505f3d470918dff42468a1 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 10:37:32 +0100 Subject: [PATCH 51/60] fix: return non-zero status for input failures --- Makefile | 8 +++- README.md | 15 +++++-- chdtool.sh | 33 ++++++++++++-- tests/test_exit_status.sh | 48 +++++++++++++++++++++ tests/test_temporary_chd_cleanup.sh | 9 +++- tests/test_transactional_source_deletion.sh | 7 ++- 6 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 tests/test_exit_status.sh diff --git a/Makefile b/Makefile index 2ab3b5c..48ef1c9 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,9 @@ SHELL := /usr/bin/env bash # Path to your script; override with: make test SCRIPT=./path/to/script.sh SCRIPT ?= ./chdtool.sh -.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-logging test-setup clean changelog changelog-tag +.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-setup clean changelog changelog-tag -test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-logging +test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-setup: chmod +x tests/bin/chdman @@ -14,6 +14,7 @@ test-setup: chmod +x tests/test_m3u.sh tests/test_m3u_single.sh tests/test_transactional_source_deletion.sh chmod +x tests/test_temporary_chd_cleanup.sh chmod +x tests/test_partial_resume.sh + chmod +x tests/test_exit_status.sh @if [ -f tests/test_logging.sh ]; then chmod +x tests/test_logging.sh; fi test-m3u: @@ -31,6 +32,9 @@ test-transactional-source-deletion: test-temporary-chd-cleanup: SCRIPT=$(SCRIPT) bash tests/test_temporary_chd_cleanup.sh +test-exit-status: + SCRIPT=$(SCRIPT) bash tests/test_exit_status.sh + test-logging: @if [ -f tests/test_logging.sh ]; then \ SCRIPT=$(SCRIPT) bash tests/test_logging.sh ; \ diff --git a/README.md b/README.md index 42a8cf9..77425ce 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ logs/chd_conversion_.log --- -## ๐Ÿ“Š Output Summary +## ๐Ÿ“Š Output Summary At the end of a run: @@ -155,9 +155,16 @@ At the end of a run: - Archives processed - CHDs created - Failures -- Elapsed time - ---- +- Elapsed time + +### Exit statuses + +- `0` โ€” clean success, including intentional skips and already-complete inputs +- `1` โ€” usage, configuration, or startup failure +- `2` โ€” one or more discovered inputs failed; independent inputs were still processed +- `130` โ€” interrupted by SIGINT or SIGTERM + +--- ## โš™๏ธ Requirements diff --git a/chdtool.sh b/chdtool.sh index 038854a..da4e5c6 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1235,6 +1235,8 @@ process_input() { local outdir outdir="$(dirname "$input_file")" local archive_entries=() + local archive_listing="" + local archive_listing_exit=0 local disc_files=() local expected_chds=() local archive_size_bytes @@ -1277,9 +1279,26 @@ process_input() { if is_in_list "$ext" "${archive_exts[@]}"; then archives_processed=$((archives_processed + 1)) case "$ext" in - zip) mapfile -t archive_entries < <(unzip -Z1 -- "$input_file" | grep -Ei "$ext_regex") ;; - rar) mapfile -t archive_entries < <(unrar lb -- "$input_file" | grep -Ei "$ext_regex") ;; - 7z|7zip) mapfile -t archive_entries < <(7z l -slt -- "$input_file" 2>/dev/null | awk -v IGNORECASE=1 -v re="$ext_regex" '/^Path = /{p=substr($0,8); if(p~re) print p}') ;; + zip) + if archive_listing="$(unzip -Z1 -- "$input_file")"; then :; else archive_listing_exit=$?; fi + ;; + rar) + if archive_listing="$(unrar lb -- "$input_file")"; then :; else archive_listing_exit=$?; fi + ;; + 7z|7zip) + if archive_listing="$(7z l -slt -- "$input_file")"; then :; else archive_listing_exit=$?; fi + ;; + esac + + if [[ $archive_listing_exit -ne 0 ]]; then + log ERROR "โŒ Archive listing failed for $input_file (Exit code: $archive_listing_exit). Skipping." + _return_failed_input + return 1 + fi + + case "$ext" in + zip|rar) mapfile -t archive_entries < <(grep -Ei "$ext_regex" <<< "$archive_listing" || true) ;; + 7z|7zip) mapfile -t archive_entries < <(awk -v IGNORECASE=1 -v re="$ext_regex" '/^Path = /{p=substr($0,8); if(p~re) print p}' <<< "$archive_listing") ;; esac if [[ ${#archive_entries[@]} -gt 0 ]]; then @@ -1570,4 +1589,10 @@ log INFO "๐Ÿ“ฆ Archives processed: $archives_processed" log INFO "๐Ÿ’ฟ CHDs created: $chds_created" log INFO "โŒ Failures: $failures" log INFO "โฑ๏ธ Elapsed time: $(format_duration $(( $(date +%s) - script_start_time )))" -log INFO "โœ… Done!" +if [[ $failures -gt 0 ]]; then + log ERROR "โš ๏ธ Completed with failures ($failures input(s) failed)." + exit 2 +fi + +log INFO "โœ… Completed successfully." +exit 0 diff --git a/tests/test_exit_status.sh b/tests/test_exit_status.sh new file mode 100644 index 0000000..0256fdc --- /dev/null +++ b/tests/test_exit_status.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}" +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT + +export PATH="$REPO_ROOT/tests/bin:$PATH" +export PROGRESS_STYLE=none +export LOG_DEST=console +export LOG_LEVEL_THRESHOLD=INFO + +run_and_capture() { + local output_file="$1"; shift + set +e + "$@" >"$output_file" 2>&1 + RUN_STATUS=$? + set -e +} + +mkdir "$FIX/success" +: > "$FIX/success/Success Game.iso" +run_and_capture "$FIX/success.log" bash "$SCRIPT" "$FIX/success" +[[ $RUN_STATUS -eq 0 ]] || { echo "FAIL: success returned $RUN_STATUS, expected 0" >&2; exit 1; } +grep -q 'Completed successfully' "$FIX/success.log" || { echo "FAIL: clean completion log missing" >&2; exit 1; } + +run_and_capture "$FIX/startup.log" bash "$SCRIPT" "$FIX/does-not-exist" +[[ $RUN_STATUS -eq 1 ]] || { echo "FAIL: startup failure returned $RUN_STATUS, expected 1" >&2; exit 1; } + +mkdir "$FIX/listing" "$FIX/listing-bin" +: > "$FIX/listing/Broken.zip" +cat > "$FIX/listing-bin/unzip" <<'STUB' +#!/usr/bin/env bash +echo "stub unzip: unreadable archive" >&2 +exit 9 +STUB +chmod +x "$FIX/listing-bin/unzip" +run_and_capture "$FIX/listing.log" env PATH="$FIX/listing-bin:$PATH" bash "$SCRIPT" "$FIX/listing" +[[ $RUN_STATUS -eq 2 ]] || { echo "FAIL: archive listing failure returned $RUN_STATUS, expected 2" >&2; exit 1; } +grep -q 'Archive listing failed' "$FIX/listing.log" || { echo "FAIL: listing failure was not reported explicitly" >&2; exit 1; } +if grep -q 'no disc files found' "$FIX/listing.log"; then + echo "FAIL: listing failure was reported as an empty archive" >&2 + exit 1 +fi +grep -q 'Completed with failures' "$FIX/listing.log" || { echo "FAIL: failed completion log missing" >&2; exit 1; } + +echo "PASS: success, startup, and archive-listing exit statuses" diff --git a/tests/test_temporary_chd_cleanup.sh b/tests/test_temporary_chd_cleanup.sh index 81a8633..f313246 100644 --- a/tests/test_temporary_chd_cleanup.sh +++ b/tests/test_temporary_chd_cleanup.sh @@ -54,7 +54,14 @@ tmp_path="$(tr -d '\r\n' < "$READY_FILE")" } kill -TERM "$script_pid" -wait "$script_pid" 2>/dev/null || true +set +e +wait "$script_pid" 2>/dev/null +interrupt_status=$? +set -e +[[ $interrupt_status -eq 130 ]] || { + echo "FAIL: interruption returned $interrupt_status, expected 130" >&2 + exit 1 +} sleep 2.2 assert_no_temporary_chds [[ ! -e "$FIX/Interrupted Game.chd" ]] || { diff --git a/tests/test_transactional_source_deletion.sh b/tests/test_transactional_source_deletion.sh index 8df888d..3ec5b2e 100644 --- a/tests/test_transactional_source_deletion.sh +++ b/tests/test_transactional_source_deletion.sh @@ -23,8 +23,11 @@ ARCHIVE="$FIX/transactional-game.zip" rm -f "Transactional Game (Disc 1).iso" "Transactional Game (Disc 2).iso" ) -# Issue #30 owns the overall process exit status; this test asserts filesystem effects only. -bash "$SCRIPT" "$FIX" || true +set +e +bash "$SCRIPT" "$FIX" +status=$? +set -e +[[ $status -eq 2 ]] || { echo "FAIL: partial conversion returned $status, expected 2" >&2; exit 1; } if [[ ! -f "$ARCHIVE" ]]; then echo "FAIL: source archive was deleted after a partial conversion" >&2 From 066d801a628c5fb3d28ea5459ca6278e322e99d4 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 10:42:47 +0100 Subject: [PATCH 52/60] chore: annotate trap callbacks for shellcheck --- chdtool.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/chdtool.sh b/chdtool.sh index da4e5c6..842bf5d 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -447,6 +447,8 @@ _now_ms() { # Use with: PHASE_DEFAULT="Converting" chdman createcd โ€ฆ | _chdman_progress_filter # or: PHASE_DEFAULT="Verifying" chdman verify โ€ฆ | _chdman_progress_filter _chdman_progress_filter() { + # Invoked indirectly by the traps below. + # shellcheck disable=SC2317 _restore_wrap() { _term_print "\033[?7h"; } trap '_restore_wrap; return 130' INT TERM trap _restore_wrap EXIT @@ -764,12 +766,16 @@ cleanup_temp_dir_now() { done } +# Invoked indirectly through _on_interrupt's signal trap. +# shellcheck disable=SC2317 _restore_wrap_global() { # Make sure terminal autowrap is re-enabled and the progress line cleared # (safe to emit even if no progress was showing) _term_print "\r\033[2K\033[?7h\n" } +# Invoked indirectly by the EXIT trap and the signal handler. +# shellcheck disable=SC2317 cleanup_all() { # Temp dirs for d in "${TEMP_DIRS[@]:-}"; do @@ -786,6 +792,8 @@ cleanup_all() { done } +# Invoked indirectly by the INT and TERM traps. +# shellcheck disable=SC2317 _on_interrupt() { # One place to handle Ctrl-C/TERM: restore terminal, clean, then exit(130) _restore_wrap_global From 02c0fe67c6bd78316c8b0e0d65d423d86cb4d3c6 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 11:11:07 +0100 Subject: [PATCH 53/60] fix: harden descriptor and archive validation (#32) Fixes #32. --- chdtool.sh | 278 +++++++++++++++++++++--------- tests/test_security_validation.sh | 58 +++++++ 2 files changed, 253 insertions(+), 83 deletions(-) create mode 100644 tests/test_security_validation.sh diff --git a/chdtool.sh b/chdtool.sh index 842bf5d..50e48e9 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -372,6 +372,138 @@ select_preferred_disc_candidates() { fi } +is_safe_relative_path() { + local path="${1//\\//}" + [[ -n "$path" && "$path" != /* && ! "$path" =~ ^[[:alpha:]]: && "$path" != //* ]] || return 1 + local part + IFS='/' read -r -a _path_parts <<< "$path" + for part in "${_path_parts[@]}"; do + [[ "$part" != ".." ]] || return 1 + done +} + +# Resolve each path component independently. This preserves safe subdirectories, +# permits case-insensitive media layouts, and rejects ambiguous case-fold matches. +resolve_descriptor_reference() { + local base_dir="$1" ref="${2//\\//}" current="$1" part match + is_safe_relative_path "$ref" || return 1 + IFS='/' read -r -a _ref_parts <<< "$ref" + for part in "${_ref_parts[@]}"; do + [[ -n "$part" && "$part" != "." ]] || continue + if [[ -e "$current/$part" ]]; then + current="$current/$part" + continue + fi + local -a matches=() + while IFS= read -r -d '' match; do matches+=("$match"); done < <( + find "$current" -mindepth 1 -maxdepth 1 -iname "$part" -print0 2>/dev/null + ) + (( ${#matches[@]} == 1 )) || return 1 + current="${matches[0]}" + done + [[ -f "$current" ]] || return 1 + printf '%s\n' "$current" +} + +DESCRIPTOR_SOURCE_SET=() + +validate_descriptor_file() { + local descriptor="$1" ext="${1##*.}" base_dir stem line ref resolved + ext="${ext,,}" + base_dir="$(dirname "$descriptor")" + stem="${descriptor%.*}" + DESCRIPTOR_SOURCE_SET=("$descriptor") + + case "$ext" in + cue) + local unsupported_audio=0 found=0 + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then + ref="${BASH_REMATCH[1]}"; found=1 + if ! is_safe_relative_path "$ref"; then + log ERROR "โŒ Unsafe path in CUE: $ref (required by $descriptor)" + return 1 + fi + if ! resolved="$(resolve_descriptor_reference "$base_dir" "$ref")"; then + log ERROR "โŒ Missing or ambiguous referenced file in CUE: $ref (required by $descriptor)" + return 1 + fi + DESCRIPTOR_SOURCE_SET+=("$resolved") + case "${ref,,}" in + *.mp3|*.ogg|*.opus|*.m4a|*.flac) + if [[ "$ALLOW_UNVERIFIED_CUE_AUDIO" != true ]]; then + log ERROR "โŒ CUE references unsupported audio format: $ref" + unsupported_audio=1 + fi ;; + esac + fi + done < "$descriptor" + (( unsupported_audio == 0 )) || return 1 + ;; + gdi) + local track_count=0 + while IFS= read -r line; do + [[ "$line" =~ ^[[:space:]]*[0-9]+[[:space:]] ]] || continue + # GDI track filenames are the fifth field; quoted names may contain spaces. + ref="$(awk 'match($0,/^([^[:space:]]+[[:space:]]+){4}("[^"]+"|[^[:space:]]+)/){v=substr($0,RSTART,RLENGTH); sub(/^([^[:space:]]+[[:space:]]+){4}/,"",v); gsub(/^"|"$/,"",v); print v}' <<< "$line")" + [[ -n "$ref" ]] || { log ERROR "โŒ Invalid GDI track entry: $line"; return 1; } + is_safe_relative_path "$ref" || { log ERROR "โŒ Unsafe path in GDI: $ref"; return 1; } + resolved="$(resolve_descriptor_reference "$base_dir" "$ref")" || { log ERROR "โŒ Missing or ambiguous GDI track: $ref"; return 1; } + DESCRIPTOR_SOURCE_SET+=("$resolved"); track_count=$((track_count + 1)) + done < "$descriptor" + (( track_count > 0 )) || { log ERROR "โŒ GDI contains no valid track entries: $descriptor"; return 1; } + ;; + ccd) + local companion + for companion in img sub; do + ref="$(basename "$stem").$companion" + resolved="$(resolve_descriptor_reference "$base_dir" "$ref")" || { log ERROR "โŒ Missing CCD companion file: $ref"; return 1; } + DESCRIPTOR_SOURCE_SET+=("$resolved") + done + ;; + *) return 0 ;; + esac + log DEBUG "โœ… Descriptor source-set validation passed: $descriptor" +} + +validate_cue_file() { validate_descriptor_file "$1"; } + +validate_archive_member_paths() { + local member normalized part + for member in "$@"; do + normalized="${member//\\//}" + [[ "$normalized" == */ ]] && normalized="${normalized%/}" + [[ -z "$normalized" ]] && continue + if ! is_safe_relative_path "$normalized"; then + log ERROR "โŒ Unsafe archive member path: $member" + return 1 + fi + done +} + +validate_archive_output_names() { + declare -A seen=() + local entry output key + for entry in "$@"; do + output="$(archive_entry_to_chd_name "$entry")" + key="${output,,}" + if [[ -n "${seen[$key]:-}" ]]; then + log ERROR "โŒ Archive entries collide after output-name sanitisation: ${seen[$key]} and $entry -> $output" + return 1 + fi + seen["$key"]="$entry" + done +} + +validate_extracted_tree() { + local root="$1" link target + while IFS= read -r -d '' link; do + target="$(readlink -- "$link" 2>/dev/null || true)" + log ERROR "โŒ Unsafe archive link rejected: ${link#"$root/"} -> $target" + return 1 + done < <(find "$root" -type l -print0) +} + check_temp_storage "$TMPDIR" # ---------- chdman progress handling ---------- @@ -902,71 +1034,6 @@ verify_chds() { $all_verified && return 0 || return 1 } -validate_cue_file() { - local cue_file="$1" - local cuedir - cuedir="$(dirname "$cue_file")" - local cue_basename - cue_basename="$(basename "$cue_file")" - local missing=0 - local unsupported_audio=0 - - declare -A file_map - while IFS= read -r -d '' f; do - file_map["${f,,}"]="$f" - done < <(find "$cuedir" -maxdepth 1 -type f -printf '%f\0') - - while IFS= read -r line; do - if [[ "$line" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then - local ref="${BASH_REMATCH[1]}" - local ref_norm="${ref//\\\\//}" - local ref_basename; ref_basename="$(basename "$ref_norm")" - local ref_lower="${ref_basename,,}" - - [[ "$ref_lower" == "${cue_basename,,}" ]] && continue - - if [[ "$ref_norm" == /* || "$ref_norm" == *".."* ]]; then - log WARN "โš ๏ธ Skipping unsafe external path in CUE: $ref_basename" - continue - fi - - if [[ -z "${file_map["$ref_lower"]:-}" ]]; then - log ERROR "โŒ Missing referenced file in CUE: $ref_basename (required by $cue_file)" - missing=1 - continue - fi - - case "$ref_lower" in - *.wav) - log DEBUG "๐ŸŽต CUE file references WAV audio track: $ref_basename" - ;; - *.mp3|*.ogg|*.opus|*.m4a) - if [[ "$ALLOW_UNVERIFIED_CUE_AUDIO" == true ]]; then - log WARN "โš ๏ธ CUE file references lossy/unsupported audio format, but override is enabled: $ref_basename" - else - log ERROR "โŒ CUE file references lossy/unsupported audio format: $ref_basename" - unsupported_audio=1 - fi - ;; - *.flac) - if [[ "$ALLOW_UNVERIFIED_CUE_AUDIO" == true ]]; then - log WARN "โš ๏ธ CUE file references FLAC audio track, but override is enabled: $ref_basename" - else - log ERROR "โŒ CUE file references FLAC audio track: $ref_basename (lossless, but chdman input support is not yet confirmed)" - unsupported_audio=1 - fi - ;; - esac - fi - done < "$cue_file" - - if (( missing == 0 && unsupported_audio == 0 )); then - log DEBUG "โœ… CUE validation passed: $cue_file" - return 0 - fi - return 1 -} - detect_disc_type() { log DEBUG "DEBUG: Starting detection for: $1" local img="$1" @@ -1002,18 +1069,19 @@ detect_disc_type() { low=tolower(ref) gsub(/\\/, "/", low) # pick first "data-ish" file - if (low ~ /\.(bin|iso|img|mdf)$/) { print ref; exit } + if (low ~ /\.(bin|iso|img|mdf)$/) { print ref; selected=1; exit } # otherwise remember first FILE as fallback if (first == "") first = ref } - END { if (first != "") print first } + END { if (!selected && first != "") print first } ' "$img" )" # Normalize Windows path separators raw_ref="${raw_ref//\\//}" - local sniff_candidate="$cue_dir/$raw_ref" + local sniff_candidate="" + sniff_candidate="$(resolve_descriptor_reference "$cue_dir" "$raw_ref" 2>/dev/null || true)" log DEBUG "DEBUG: CUE selected sniff candidate: [$raw_ref]" log DEBUG "DEBUG: Full resolved sniff path: [$sniff_candidate]" @@ -1089,10 +1157,10 @@ convert_disc_file() { [[ -n "$result_var" ]] && printf -v "$result_var" '%s' "" - # If it's a CUE, validate referenced files first - if [[ "${file,,}" == *.cue ]]; then - if ! validate_cue_file "$file"; then - log ERROR "โŒ CUE validation failed: $file" + local file_ext="${file##*.}"; file_ext="${file_ext,,}" + if [[ "$file_ext" == "cue" || "$file_ext" == "gdi" || "$file_ext" == "ccd" ]]; then + if ! validate_descriptor_file "$file"; then + log ERROR "โŒ Descriptor validation failed: $file" return 1 fi fi @@ -1243,6 +1311,7 @@ process_input() { local outdir outdir="$(dirname "$input_file")" local archive_entries=() + local archive_all_entries=() local archive_listing="" local archive_listing_exit=0 local disc_files=() @@ -1254,6 +1323,7 @@ process_input() { local temp_dir="" declare -A output_states=() declare -A output_temps=() + local direct_source_set=() _all_outputs_complete() { local expected state @@ -1280,8 +1350,16 @@ process_input() { return 0 fi - log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" - rm -f -- "$input_file" + if (( ${#direct_source_set[@]} > 0 )); then + local source + for source in "${direct_source_set[@]}"; do + log INFO "๐Ÿ—‘๏ธ Removing validated descriptor source: $source" + rm -f -- "$source" + done + else + log INFO "๐Ÿ—‘๏ธ Removing original input file: $input_file" + rm -f -- "$input_file" + fi } if is_in_list "$ext" "${archive_exts[@]}"; then @@ -1304,13 +1382,29 @@ process_input() { return 1 fi + case "$ext" in + zip|rar) mapfile -t archive_all_entries <<< "$archive_listing" ;; + # The first Path field describes the archive itself. Member records + # begin after the separator in 7z's technical listing. + 7z|7zip) mapfile -t archive_all_entries < <(awk 'seen && /^Path = /{print substr($0,8)} /^----------$/{seen=1}' <<< "$archive_listing") ;; + esac + if ! validate_archive_member_paths "${archive_all_entries[@]}"; then + log ERROR "โŒ Archive path preflight failed: $input_file" + _return_failed_input + return 1 + fi + case "$ext" in zip|rar) mapfile -t archive_entries < <(grep -Ei "$ext_regex" <<< "$archive_listing" || true) ;; - 7z|7zip) mapfile -t archive_entries < <(awk -v IGNORECASE=1 -v re="$ext_regex" '/^Path = /{p=substr($0,8); if(p~re) print p}' <<< "$archive_listing") ;; + 7z|7zip) mapfile -t archive_entries < <(awk -v IGNORECASE=1 -v re="$ext_regex" 'seen && /^Path = /{p=substr($0,8); if(p~re) print p} /^----------$/{seen=1}' <<< "$archive_listing") ;; esac if [[ ${#archive_entries[@]} -gt 0 ]]; then mapfile -t archive_entries < <(select_preferred_disc_candidates "${archive_entries[@]}") + if ! validate_archive_output_names "${archive_entries[@]}"; then + _return_failed_input + return 1 + fi log DEBUG "๐Ÿ“€ Selected ${#archive_entries[@]} preferred disc descriptor(s) from archive: $(basename "$input_file")" for entry in "${archive_entries[@]}"; do local expected_chd @@ -1332,14 +1426,20 @@ process_input() { return 0 fi - # If this input is a CUE, validate it even if CHDs already exist. - # Broken CUEs should count as an input failure (even if we can skip conversion). - if [[ "$ext" == "cue" ]]; then - if ! validate_cue_file "$input_file"; then - log ERROR "โŒ CUE validation failed (input considered failed): $input_file" + # Direct descriptors own a complete validated source set. On success, all + # members are removed together; on any failure, every source is retained. + if [[ "$ext" == "cue" || "$ext" == "gdi" || "$ext" == "ccd" ]]; then + if ! validate_descriptor_file "$input_file"; then + log ERROR "โŒ Descriptor validation failed (input considered failed): $input_file" input_failed=true + else + direct_source_set=("${DESCRIPTOR_SOURCE_SET[@]}") fi fi + if [[ "$input_failed" == true ]]; then + _return_failed_input + return 1 + fi # Establish one authoritative state for every expected output. local expected_chd @@ -1402,9 +1502,12 @@ process_input() { return 1 fi - log DEBUG "๐Ÿงน Flushing extraction buffers to free up RAM..." - sync - sleep 1 + if ! validate_extracted_tree "$temp_dir"; then + input_failed=true + cleanup_temp_dir_now "$temp_dir" + _return_failed_input + return 1 + fi read -r -a disc_find_expr <<< "$(build_find_expr "${disc_exts[@]}")" mapfile -d '' -t disc_files < <(find "$temp_dir" -type f \( "${disc_find_expr[@]}" \) -print0) @@ -1459,6 +1562,15 @@ process_input() { local extracted="$temp_dir/$entry" [[ -f "$extracted" ]] || continue + local extracted_ext="${extracted##*.}"; extracted_ext="${extracted_ext,,}" + if [[ "$extracted_ext" == "cue" || "$extracted_ext" == "gdi" || "$extracted_ext" == "ccd" ]]; then + if ! validate_descriptor_file "$extracted"; then + log ERROR "โŒ Extracted descriptor validation failed: $entry" + input_failed=true + continue + fi + fi + local chd_name chd_base chd_name="$(archive_entry_to_chd_name "$entry")" # e.g. "CD1 - Game.chd" chd_base="${chd_name%.chd}" # e.g. "CD1 - Game" diff --git a/tests/test_security_validation.sh b/tests/test_security_validation.sh new file mode 100644 index 0000000..bd11da4 --- /dev/null +++ b/tests/test_security_validation.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}" +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT + +export PATH="$REPO_ROOT/tests/bin:$PATH" +export PROGRESS_STYLE=none LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR + +expect_failure() { + local dir="$1" + set +e + bash "$SCRIPT" "$dir" >/dev/null 2>&1 + local status=$? + set -e + [[ $status -eq 2 ]] || { echo "FAIL: $dir returned $status, expected 2" >&2; exit 1; } +} + +# Safe nested references resolve component-by-component and successful direct +# descriptor cleanup removes the complete validated source set. +mkdir -p "$FIX/nested/Tracks" +printf 'FILE "tracks/DATA.BIN" BINARY\n TRACK 01 MODE1/2352\n' > "$FIX/nested/Game.cue" +: > "$FIX/nested/Tracks/data.bin" +bash "$SCRIPT" "$FIX/nested" >/dev/null +[[ -f "$FIX/nested/Game.chd" && ! -e "$FIX/nested/Game.cue" && ! -e "$FIX/nested/Tracks/data.bin" ]] || { + echo "FAIL: nested CUE conversion or source-set cleanup" >&2; exit 1; +} + +mkdir -p "$FIX/parent" "$FIX/absolute" +printf 'FILE "../outside.bin" BINARY\n' > "$FIX/parent/Unsafe.cue" +: > "$FIX/outside.bin" +printf 'FILE "/tmp/outside.bin" BINARY\n' > "$FIX/absolute/Unsafe.cue" +expect_failure "$FIX/parent" +expect_failure "$FIX/absolute" + +mkdir -p "$FIX/gdi" "$FIX/ccd" +printf '1\n1 0 4 2352 missing.bin 0\n' > "$FIX/gdi/Missing.gdi" +printf '[CloneCD]\nVersion=3\n' > "$FIX/ccd/Missing.ccd" +: > "$FIX/ccd/Missing.img" +expect_failure "$FIX/gdi" +expect_failure "$FIX/ccd" + +# Both selected entries map to "A - B.chd" after sanitisation. +mkdir -p "$FIX/collision/build" +: > "$FIX/collision/build/A:B.iso" +: > "$FIX/collision/build/A?B.iso" +(cd "$FIX/collision/build" && zip -q ../collision.zip ./*) +expect_failure "$FIX/collision" + +# A traversal member must be rejected from the listing before extraction. +mkdir -p "$FIX/traversal/build" +: > "$FIX/traversal/escape.iso" +(cd "$FIX/traversal/build" && zip -q ../traversal.zip ../escape.iso) +expect_failure "$FIX/traversal" + +echo "PASS: descriptor and archive security validation" From 4f8f0d6b092055e7ff1f317eea5a3f902be722c3 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 11:11:53 +0100 Subject: [PATCH 54/60] perf: improve chdman resource handling (#31) Fixes #31. --- chdtool.sh | 54 ++++++++++++++++++--------------- tests/bin/chdman | 1 + tests/test_resource_handling.sh | 26 ++++++++++++++++ 3 files changed, 56 insertions(+), 25 deletions(-) create mode 100644 tests/test_resource_handling.sh diff --git a/chdtool.sh b/chdtool.sh index 50e48e9..3fcaa71 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -1188,16 +1188,12 @@ convert_disc_file() { # Decide CD vs DVD and pick subcommand + icon local disc_type disc_type="$(detect_disc_type "$file")" - local hunk_size=2448 #Default - local subcmd icon case "$disc_type" in ps2) - # PS2 is the special case; Hunk is ALWAYS 2048, but command depends on size log DEBUG "PS2 image detected, checking size to determine if DVD structure is likely" local sz sz=$(get_file_size "$file") - hunk_size=2048 if (( sz >= 1000000000 )); then log DEBUG "Large PS2 image suggests DVD structure, CHDMAN_HAS_CREATEDVD=$CHDMAN_HAS_CREATEDVD" if [[ "$CHDMAN_HAS_CREATEDVD" == true ]]; then @@ -1218,7 +1214,6 @@ convert_disc_file() { log DEBUG "DVD detected, CHDMAN_HAS_CREATEDVD=$CHDMAN_HAS_CREATEDVD" if [[ "$CHDMAN_HAS_CREATEDVD" == true ]]; then subcmd="createdvd" - hunk_size=2048 # DVD-ROM and PS2/PSP discs use standard 2K data sectors icon="๐Ÿ“€" else log WARN "โš ๏ธ Detected DVD image but this chdman lacks 'createdvd'. Skipping: $file" @@ -1228,44 +1223,52 @@ convert_disc_file() { ps1|dreamcast|segacd|saturn|cd) log DEBUG "CD-type image detected, using createcd" subcmd="createcd" - hunk_size=2448 # CD-ROMs use 2352-byte raw sectors but chdman createcd expects 2448 to include subchannel data for full disc preservation icon="๐Ÿ’ฟ" ;; *) log WARN "โš ๏ธ Unknown disc type detected, defaulting to CD settings: $file" subcmd="createcd" - hunk_size=2448 icon="๐Ÿ’ฟ" ;; esac log INFO "$icon Detected $disc_type image โ†’ using chdman $subcmd" - # Calculate safe threads: ~one thread per 2GB RAM (for CDs) or 4GB RAM (for DVDs) - local total_ram_mb=$(( $(awk '/MemTotal|SwapTotal/{sum+=$2} END{print sum}' /proc/meminfo) / 1024 )) - local available_ram=$total_ram_mb - - if [[ "$IS_RAM_DISK" == true ]]; then - local iso_size_mb=$(( $(stat -c%s "$file") / 1048576 )) - (( available_ram = total_ram_mb - iso_size_mb)) - log DEBUG "๐Ÿ“‰ RAM disk detected. Adjusting available RAM for chdman to ${available_ram}MB" - fi - + # Base concurrency on currently available physical memory. Swap is not + # interchangeable with RAM for compression and can cause severe thrashing. + local available_ram + available_ram=$(( $(awk '/^MemAvailable:/{print $2; found=1} END{if(!found) print 0}' /proc/meminfo 2>/dev/null) / 1024 )) + (( available_ram < 1 )) && available_ram=1024 local cpu_cores - cpu_cores=$(nproc) + cpu_cores=$(nproc 2>/dev/null || echo 1) + (( cpu_cores < 1 )) && cpu_cores=1 local ram_per_thread=2048 # Default for CDs if [[ "$subcmd" == "createdvd" ]]; then ram_per_thread=4096 # 4GB floor for DVDs (LZMA is a beast here) fi - # Calculate threads based on RAM (aiming for ~3GB per thread) + # Calculate threads from available RAM, then apply the explicit override. local threads=$(( available_ram / ram_per_thread )) (( threads < 1 )) && threads=1 (( threads > cpu_cores )) && threads=$cpu_cores + if [[ -n "${CHDMAN_THREADS:-}" ]]; then + [[ "$CHDMAN_THREADS" =~ ^[1-9][0-9]*$ ]] || { log ERROR "โŒ CHDMAN_THREADS must be a positive integer"; return 1; } + threads="$CHDMAN_THREADS" + (( threads > cpu_cores )) && threads=$cpu_cores + fi + + local -a chdman_args=("$subcmd" -np "$threads") + if [[ -n "${CHDMAN_HUNK_SIZE:-}" ]]; then + [[ "$CHDMAN_HUNK_SIZE" =~ ^[1-9][0-9]*$ ]] || { log ERROR "โŒ CHDMAN_HUNK_SIZE must be a positive integer"; return 1; } + chdman_args+=(-hs "$CHDMAN_HUNK_SIZE") + fi + chdman_args+=(-i "$file") if [[ "$DRY_RUN" == true ]]; then - log INFO "๐Ÿงช (dry-run) Would run: ${CHDMAN_BIN:-chdman} $subcmd -np $threads -i \"$file\" -o a unique temporary CHD beside \"$chd_path\"" + local command_preview + printf -v command_preview '%q ' "${CHDMAN_BIN:-chdman}" "${chdman_args[@]}" -o "" + log INFO "๐Ÿงช (dry-run) Would run: ${command_preview% }" return 0 fi @@ -1282,16 +1285,17 @@ convert_disc_file() { track_temp_file "$tmp_chd" [[ -n "$result_var" ]] && printf -v "$result_var" '%s' "$tmp_chd" log INFO "๐Ÿ”ง Converting: $file -> $tmp_chd" + local -a command=("${CHDMAN_BIN:-chdman}" "${chdman_args[@]}" -o "$tmp_chd") if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then - if ! PHASE_DEFAULT="Converting" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd" 2>&1 | _chdman_progress_filter; then + if ! PHASE_DEFAULT="Converting" stdbuf -oL -eL "${command[@]}" 2>&1 | _chdman_progress_filter; then log ERROR "โŒ chdman $subcmd failed for: $file" cleanup_temp_file_now "$tmp_chd" cleanup_temp_dir_now "$tmp_dir" return 1 fi else - if ! "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd"; then + if ! "${command[@]}"; then log ERROR "โŒ chdman $subcmd failed for: $file" cleanup_temp_file_now "$tmp_chd" cleanup_temp_dir_now "$tmp_dir" @@ -1299,8 +1303,6 @@ convert_disc_file() { fi fi - sync - sleep 1 return 0 } @@ -1542,7 +1544,8 @@ process_input() { if [[ "$DRY_RUN" == true ]]; then for disc in "${disc_files[@]}"; do - log INFO "๐Ÿงช (dry-run) Would convert: $disc -> $outdir/$(basename "${disc%.*}").chd" + local dry_result="" + convert_disc_file "$disc" "$outdir" "" dry_result || input_failed=true done # Dry-run: also show M3U intent (if this looks like a multi-disc set) @@ -1552,6 +1555,7 @@ process_input() { maybe_generate_m3u_for "$chd_base" "$outdir" fi + [[ "$input_failed" == true ]] && { _return_failed_input; return 1; } return 0 fi diff --git a/tests/bin/chdman b/tests/bin/chdman index a4bc380..304fb14 100755 --- a/tests/bin/chdman +++ b/tests/bin/chdman @@ -22,6 +22,7 @@ case "$1" in exit 0 ;; createcd|createdvd) + [[ -n "${CHDMAN_ARGS_LOG:-}" ]] && printf '%s\n' "$*" >> "$CHDMAN_ARGS_LOG" input="" output="" while [[ $# -gt 0 ]]; do diff --git a/tests/test_resource_handling.sh b/tests/test_resource_handling.sh new file mode 100644 index 0000000..e648e2e --- /dev/null +++ b/tests/test_resource_handling.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}" +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT +export PATH="$REPO_ROOT/tests/bin:$PATH" PROGRESS_STYLE=none LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR + +mkdir "$FIX/default" "$FIX/override" "$FIX/dry" +: > "$FIX/default/Default.iso" +CHDMAN_ARGS_LOG="$FIX/default.args" bash "$SCRIPT" --keep-originals "$FIX/default" >/dev/null +! grep -q -- ' -hs ' "$FIX/default.args" || { echo "FAIL: default conversion forced a hunk size" >&2; exit 1; } + +: > "$FIX/override/Override.iso" +CHDMAN_ARGS_LOG="$FIX/override.args" CHDMAN_THREADS=999999 CHDMAN_HUNK_SIZE=8192 \ + bash "$SCRIPT" --keep-originals "$FIX/override" >/dev/null +grep -q -- '-hs 8192' "$FIX/override.args" || { echo "FAIL: hunk override missing" >&2; exit 1; } +threads="$(sed -n 's/.*-np \([0-9][0-9]*\).*/\1/p' "$FIX/override.args")" +(( threads >= 1 && threads <= $(nproc) )) || { echo "FAIL: thread override was not CPU-bounded" >&2; exit 1; } + +: > "$FIX/dry/Dry.iso" +dry_output="$(LOG_LEVEL_THRESHOLD=INFO CHDMAN_THREADS=1 CHDMAN_HUNK_SIZE=4096 bash "$SCRIPT" --dry-run "$FIX/dry" 2>&1)" +grep -q -- '-np 1 -hs 4096 -i' <<< "$dry_output" || { echo "FAIL: dry-run command omitted effective options" >&2; exit 1; } + +echo "PASS: resource selection and chdman command construction" From 069bab0bcf477dc8cb8c86b88efd59855a7e4594 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 11:12:11 +0100 Subject: [PATCH 55/60] docs: document validation and resource controls (#31, #32) Registers the focused regression suites and documents the behavior added for #31 and #32. --- Makefile | 12 ++++++++++-- README.md | 26 ++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 48ef1c9..a025c84 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,9 @@ SHELL := /usr/bin/env bash # Path to your script; override with: make test SCRIPT=./path/to/script.sh SCRIPT ?= ./chdtool.sh -.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-setup clean changelog changelog-tag +.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-setup clean changelog changelog-tag -test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging +test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-setup: chmod +x tests/bin/chdman @@ -15,6 +15,8 @@ test-setup: chmod +x tests/test_temporary_chd_cleanup.sh chmod +x tests/test_partial_resume.sh chmod +x tests/test_exit_status.sh + chmod +x tests/test_security_validation.sh + chmod +x tests/test_resource_handling.sh @if [ -f tests/test_logging.sh ]; then chmod +x tests/test_logging.sh; fi test-m3u: @@ -42,6 +44,12 @@ test-logging: echo "Skipping logging test (tests/test_logging.sh not present)"; \ fi +test-security-validation: + SCRIPT=$(SCRIPT) bash tests/test_security_validation.sh + +test-resource-handling: + SCRIPT=$(SCRIPT) bash tests/test_resource_handling.sh + clean: @echo "Nothing to clean; tests use mktemp dirs." diff --git a/README.md b/README.md index 77425ce..cd5defe 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Optional (enhancements): --- -## ๐Ÿงช Dry Run Mode +## ๐Ÿงช Dry Run Mode Use `--dry-run` to preview actions: @@ -195,15 +195,33 @@ Use `--dry-run` to preview actions: ``` - No files are created, moved, or deleted -- All intended operations are logged +- All intended operations are logged + +### Resource selection + +By default, CHD Tool lets `chdman` choose its format-appropriate hunk size. It +selects compression threads conservatively from Linux `MemAvailable` (never +swap), using roughly 2 GiB per CD thread or 4 GiB per DVD thread, and caps the +result at the available CPU count with a minimum of one. + +- `CHDMAN_THREADS=N` requests a positive thread count; values above the CPU count + are capped +- `CHDMAN_HUNK_SIZE=N` is an advanced opt-in that passes `-hs N` to `chdman` --- ## โš ๏ธ Notes - DVD support requires a version of `chdman` with `createdvd` -- CUE files referencing missing files will fail validation -- Temporary files are cleaned automatically, even on interruption +- CUE files referencing missing files will fail validation +- CUE/GDI/CCD paths must be relative and remain below the descriptor directory; + nested paths and unambiguous case-insensitive matches are supported +- A successfully converted direct descriptor is treated as one source set: unless + `--keep-originals` is used, the descriptor and all validated companion tracks + are removed together. Any validation or conversion failure retains the full set +- Archive members are rejected before conversion when paths escape the extraction + directory, links are present, or selected entries collide after name sanitisation +- Temporary files are cleaned automatically, even on interruption --- From b3dfc09edb5ac44b66e5650c0eb55184b8fd3d04 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 11:16:14 +0100 Subject: [PATCH 56/60] ci: resolve shellcheck warnings (#31, #32) Removes obsolete state and unused compatibility variables introduced while addressing #31 and #32. --- chdtool.sh | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/chdtool.sh b/chdtool.sh index 3fcaa71..3d741e4 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -12,7 +12,6 @@ RECURSIVE=false DRY_RUN=false ALLOW_UNVERIFIED_CUE_AUDIO=false INPUT_DIR="" -IS_RAM_DISK=false RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)-$$}" CHDMAN_MSG_LEVEL="${CHDMAN_MSG_LEVEL:-DEBUG}" case "${CHDMAN_MSG_LEVEL^^}" in @@ -330,8 +329,6 @@ check_temp_storage() { log WARN "โš ๏ธ $tmp_dir is a RAM disk (tmpfs). Extracted ISOs will consume physical RAM!" log WARN "๐Ÿ’ก Available space in RAM disk: $tmp_limit" - # Force thread reduction if we are in a RAM disk - IS_RAM_DISK=true fi } @@ -416,10 +413,10 @@ validate_descriptor_file() { case "$ext" in cue) - local unsupported_audio=0 found=0 + local unsupported_audio=0 while IFS= read -r line; do if [[ "$line" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then - ref="${BASH_REMATCH[1]}"; found=1 + ref="${BASH_REMATCH[1]}" if ! is_safe_relative_path "$ref"; then log ERROR "โŒ Unsafe path in CUE: $ref (required by $descriptor)" return 1 @@ -466,8 +463,6 @@ validate_descriptor_file() { log DEBUG "โœ… Descriptor source-set validation passed: $descriptor" } -validate_cue_file() { validate_descriptor_file "$1"; } - validate_archive_member_paths() { local member normalized part for member in "$@"; do @@ -1544,8 +1539,7 @@ process_input() { if [[ "$DRY_RUN" == true ]]; then for disc in "${disc_files[@]}"; do - local dry_result="" - convert_disc_file "$disc" "$outdir" "" dry_result || input_failed=true + convert_disc_file "$disc" "$outdir" || input_failed=true done # Dry-run: also show M3U intent (if this looks like a multi-disc set) From a59b89fb8cce8016c92b20d8933aea8645c994ef Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 11:36:46 +0100 Subject: [PATCH 57/60] test: expand conversion regression coverage (#33) --- Makefile | 8 ++- chdtool.sh | 6 +- tests/bin/chdman | 5 ++ tests/test_regression_gaps.sh | 106 ++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 tests/test_regression_gaps.sh diff --git a/Makefile b/Makefile index a025c84..2f15298 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,9 @@ SHELL := /usr/bin/env bash # Path to your script; override with: make test SCRIPT=./path/to/script.sh SCRIPT ?= ./chdtool.sh -.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-setup clean changelog changelog-tag +.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-regression-gaps test-setup clean changelog changelog-tag -test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling +test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-regression-gaps test-setup: chmod +x tests/bin/chdman @@ -17,6 +17,7 @@ test-setup: chmod +x tests/test_exit_status.sh chmod +x tests/test_security_validation.sh chmod +x tests/test_resource_handling.sh + chmod +x tests/test_regression_gaps.sh @if [ -f tests/test_logging.sh ]; then chmod +x tests/test_logging.sh; fi test-m3u: @@ -50,6 +51,9 @@ test-security-validation: test-resource-handling: SCRIPT=$(SCRIPT) bash tests/test_resource_handling.sh +test-regression-gaps: + SCRIPT=$(SCRIPT) bash tests/test_regression_gaps.sh + clean: @echo "Nothing to clean; tests use mktemp dirs." diff --git a/chdtool.sh b/chdtool.sh index 3d741e4..5cd957e 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -61,7 +61,7 @@ TMP_ROOT="${TMPDIR:-/var/tmp/chdtool}" TMPDIR="$TMP_ROOT/$RUN_ID" mkdir -p "$TMPDIR" -LOGFILE="logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log" +LOGFILE="${LOGFILE:-logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log}" # --- Pluggable logging: console/file/syslog/journald (auto) ------------------- # Control via env vars (no root needed to write to journald/syslog): @@ -187,6 +187,10 @@ _emit_log() { done <<< "$msg" ;; esac + + # Optional mirrors may legitimately be disabled. Logging still succeeded + # when the selected backend accepted the message. + return 0 } # Public logger. Backwards-compatible: `log "message"` still works. diff --git a/tests/bin/chdman b/tests/bin/chdman index 304fb14..f061ed1 100755 --- a/tests/bin/chdman +++ b/tests/bin/chdman @@ -13,6 +13,11 @@ case "$1" in *) shift ;; esac done + [[ -n "${CHDMAN_VERIFY_LOG:-}" ]] && printf '%s\n' "$verify_input" >> "$CHDMAN_VERIFY_LOG" + if [[ -n "${CHDMAN_FAIL_VERIFY_INPUT:-}" && "$verify_input" == *"$CHDMAN_FAIL_VERIFY_INPUT"* ]]; then + echo "stub chdman: requested verification failure for $verify_input" >&2 + exit 2 + fi if [[ -f "$verify_input" ]] && grep -q '^INVALID$' "$verify_input"; then echo "invalid CHD" >&2 exit 2 diff --git a/tests/test_regression_gaps.sh b/tests/test_regression_gaps.sh new file mode 100644 index 0000000..5a9e4fd --- /dev/null +++ b/tests/test_regression_gaps.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}" +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT + +export PATH="$REPO_ROOT/tests/bin:$PATH" +export PROGRESS_STYLE=none LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR + +run_and_capture() { + local output_file="$1"; shift + set +e + "$@" >"$output_file" 2>&1 + RUN_STATUS=$? + set -e +} + +assert_no_temporary_chds() { + local directory="$1" + if find "$directory" -name '*.chd.tmp.*' -print -quit | grep -q .; then + echo "FAIL: temporary CHD remained in $directory" >&2 + find "$directory" -name '*.chd.tmp.*' -print >&2 + exit 1 + fi +} + +# Listing succeeds, but extraction fails: report the extraction error, retain the +# archive, and do not create either final or temporary conversion output. +mkdir -p "$FIX/extraction" "$FIX/extraction-bin" +: > "$FIX/extraction/Broken.zip" +cat > "$FIX/extraction-bin/unzip" <<'STUB' +#!/usr/bin/env bash +if [[ " $* " == *" -Z1 "* ]]; then + printf '%s\n' 'Extraction Game.iso' + exit 0 +fi +echo 'stub unzip: requested extraction failure' >&2 +exit 7 +STUB +chmod +x "$FIX/extraction-bin/unzip" +run_and_capture "$FIX/extraction.log" env PATH="$FIX/extraction-bin:$PATH" bash "$SCRIPT" "$FIX/extraction" +[[ $RUN_STATUS -eq 2 ]] || { echo "FAIL: extraction failure returned $RUN_STATUS, expected 2" >&2; exit 1; } +grep -q 'Extraction failed' "$FIX/extraction.log" || { echo "FAIL: extraction failure was not reported" >&2; exit 1; } +[[ -f "$FIX/extraction/Broken.zip" && ! -e "$FIX/extraction/Extraction Game.chd" ]] || { + echo "FAIL: extraction failure changed source/final output state" >&2; exit 1; +} +assert_no_temporary_chds "$FIX/extraction" + +# A newly converted CHD that fails both verification attempts must be removed, +# leave its source intact, and make the overall process fail. +mkdir "$FIX/verification" +: > "$FIX/verification/Verify Game.iso" +verify_log="$FIX/verification-attempts.log" +run_and_capture "$FIX/verification.log" env \ + CHDMAN_FAIL_VERIFY_INPUT='Verify Game.chd' CHDMAN_VERIFY_LOG="$verify_log" \ + bash "$SCRIPT" "$FIX/verification" +[[ $RUN_STATUS -eq 2 ]] || { echo "FAIL: double verification failure returned $RUN_STATUS, expected 2" >&2; exit 1; } +[[ "$(wc -l < "$verify_log")" -eq 2 ]] || { echo "FAIL: expected exactly two verification attempts" >&2; exit 1; } +[[ -f "$FIX/verification/Verify Game.iso" && ! -e "$FIX/verification/Verify Game.chd" ]] || { + echo "FAIL: unverified output was finalised or its source removed" >&2; exit 1; +} +assert_no_temporary_chds "$FIX/verification" + +# A caller-supplied logfile path is authoritative. +mkdir "$FIX/custom-log-input" +: > "$FIX/custom-log-input/Custom Log Game.iso" +custom_log="$FIX/custom/logs/conversion.log" +run_and_capture "$FIX/custom-log.stderr" env LOG_DEST=file LOGFILE="$custom_log" \ + LOG_LEVEL_THRESHOLD=INFO bash "$SCRIPT" --dry-run "$FIX/custom-log-input" +[[ $RUN_STATUS -eq 0 ]] || { echo "FAIL: custom LOGFILE run returned $RUN_STATUS" >&2; cat "$FIX/custom-log.stderr" "$custom_log" >&2; exit 1; } +[[ -s "$custom_log" ]] || { echo "FAIL: custom LOGFILE was not created" >&2; exit 1; } +grep -q 'Script started' "$custom_log" || { echo "FAIL: custom LOGFILE is missing run output" >&2; exit 1; } + +# Dry-run may log outside the source directory, but must not mutate anything in +# the input tree (including conversion output, playlists, or temporary files). +mkdir "$FIX/dry-run" +: > "$FIX/dry-run/Dry Game.iso" +before="$(find "$FIX/dry-run" -mindepth 1 -printf '%P|%s|%T@\n' | sort)" +LOG_DEST=file LOGFILE="$FIX/dry-run.log" bash "$SCRIPT" --dry-run "$FIX/dry-run" +after="$(find "$FIX/dry-run" -mindepth 1 -printf '%P|%s|%T@\n' | sort)" +[[ "$before" == "$after" ]] || { echo "FAIL: dry-run mutated the input tree" >&2; diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") >&2 || true; exit 1; } + +# Independent RUN_ID/TMPDIR/log paths keep repeated parallel invocations from +# colliding. Run enough jobs to expose shared-name or cleanup races cheaply. +pids=() +for index in 1 2 3 4 5 6; do + job_dir="$FIX/parallel-$index" + mkdir "$job_dir" + : > "$job_dir/Parallel Game $index.iso" + LOG_DEST=file LOGFILE="$FIX/parallel-$index.log" RUN_ID="regression-$index" \ + TMPDIR="$FIX/tmp-$index" bash "$SCRIPT" "$job_dir" & + pids+=("$!") +done +for pid in "${pids[@]}"; do + wait "$pid" || { echo "FAIL: parallel invocation $pid failed" >&2; exit 1; } +done +for index in 1 2 3 4 5 6; do + [[ -f "$FIX/parallel-$index/Parallel Game $index.chd" && ! -e "$FIX/parallel-$index/Parallel Game $index.iso" ]] || { + echo "FAIL: parallel invocation $index produced an unstable result" >&2; exit 1; + } + assert_no_temporary_chds "$FIX/parallel-$index" +done + +echo "PASS: extraction, verification, custom logging, dry-run, and parallel regressions" From 258340c70e0b621c87debb05d36309cbcde1a89a Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 11:58:24 +0100 Subject: [PATCH 58/60] chore: harden release packaging and CLI (#34) --- .github/workflows/release.yml | 33 +++++++++------ .github/workflows/shellcheck.yml | 4 +- .github/workflows/tests.yml | 2 + Makefile | 18 ++++++-- README.md | 57 ++++++++++++++++++++----- chdtool.sh | 50 +++++++++++++++++++--- scripts/package-release.sh | 73 ++++++++++++++++++++++++++++++++ tests/test_cli.sh | 36 ++++++++++++++++ tests/test_package.sh | 23 ++++++++++ 9 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 scripts/package-release.sh create mode 100644 tests/test_cli.sh create mode 100644 tests/test_package.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19233ab..d1b9fad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,11 +15,21 @@ jobs: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - - name: Check out repo + - name: Check out repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - fetch-tags: true + fetch-tags: true + + - name: Install test and packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y make shellcheck unzip zip + + - name: Run tests and ShellCheck + run: | + make test + shellcheck chdtool.sh scripts/*.sh tests/*.sh tests/bin/* # Generate release notes for this tag (used as Release body) - name: Generate release notes with git-cliff @@ -33,23 +43,20 @@ jobs: OUTPUT: CHANGELOG_RELEASE.md GITHUB_REPO: ${{ github.repository }} - - name: Prepare artifacts - run: | - set -euo pipefail - chmod +x chdtool.sh || true - cp chdtool.sh chdtool - mkdir -p dist - tar -czf "dist/chdtool-${GITHUB_REF_NAME}.tar.gz" chdtool README.md LICENSE CHANGELOG.md || true - zip -j "dist/chdtool-${GITHUB_REF_NAME}.zip" chdtool README.md LICENSE CHANGELOG.md || true + - name: Prepare artifacts + run: | + set -euo pipefail + release_version="${GITHUB_REF_NAME#v}" + SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" \ + bash scripts/package-release.sh "$release_version" - name: Create GitHub Release and upload assets uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: ${{ github.ref_name }} body_path: ${{ steps.cliff_notes.outputs.changelog }} # file generated above - files: | - dist/* - chdtool.sh + files: | + dist/* draft: false prerelease: ${{ contains(github.ref_name, '-') }} env: diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 04fee40..55b271f 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -7,12 +7,14 @@ on: - "chdtool.sh" - "tests/**/*.sh" - "tests/bin/*" + - "scripts/**/*.sh" - ".github/workflows/shellcheck.yml" pull_request: paths: - "chdtool.sh" - "tests/**/*.sh" - "tests/bin/*" + - "scripts/**/*.sh" - ".github/workflows/shellcheck.yml" concurrency: @@ -32,4 +34,4 @@ jobs: - name: Install ShellCheck run: sudo apt-get update && sudo apt-get install -y shellcheck - name: Run ShellCheck - run: shellcheck chdtool.sh tests/*.sh tests/bin/* + run: shellcheck chdtool.sh scripts/*.sh tests/*.sh tests/bin/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17c618d..0601dcc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,12 +7,14 @@ on: - "chdtool.sh" - "Makefile" - "tests/**" + - "scripts/**" - ".github/workflows/tests.yml" pull_request: paths: - "chdtool.sh" - "Makefile" - "tests/**" + - "scripts/**" - ".github/workflows/tests.yml" concurrency: diff --git a/Makefile b/Makefile index 2f15298..0e7f378 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,9 @@ SHELL := /usr/bin/env bash # Path to your script; override with: make test SCRIPT=./path/to/script.sh SCRIPT ?= ./chdtool.sh -.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-regression-gaps test-setup clean changelog changelog-tag +.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-regression-gaps test-cli test-package test-setup clean changelog changelog-tag package -test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-regression-gaps +test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-regression-gaps test-cli test-package test-setup: chmod +x tests/bin/chdman @@ -18,6 +18,7 @@ test-setup: chmod +x tests/test_security_validation.sh chmod +x tests/test_resource_handling.sh chmod +x tests/test_regression_gaps.sh + chmod +x tests/test_cli.sh tests/test_package.sh scripts/package-release.sh @if [ -f tests/test_logging.sh ]; then chmod +x tests/test_logging.sh; fi test-m3u: @@ -54,11 +55,20 @@ test-resource-handling: test-regression-gaps: SCRIPT=$(SCRIPT) bash tests/test_regression_gaps.sh +test-cli: + SCRIPT=$(SCRIPT) bash tests/test_cli.sh + +test-package: + bash tests/test_package.sh + clean: @echo "Nothing to clean; tests use mktemp dirs." changelog: - git cliff --config .cliff.toml -o CHANGELOG.md + git cliff --config cliff.toml -o CHANGELOG.md changelog-tag: - git cliff --config .cliff.toml --tag $${TAG} --strip header > CHANGELOG_RELEASE.md + git cliff --config cliff.toml --tag $${TAG} --strip header > CHANGELOG_RELEASE.md + +package: + bash scripts/package-release.sh "$${VERSION:-$$(sed -n 's/^CHDTOOL_VERSION="\([^"]*\)"/\1/p' chdtool.sh)}" diff --git a/README.md b/README.md index cd5defe..acbb701 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ๐ŸŽฎ chdtool -[![License](https://img.shields.io/github/license/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/blob/main/LICENSE) +[![License](https://img.shields.io/github/license/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/blob/main/LICENSE.md) [![Release](https://img.shields.io/github/v/release/lloydsmart/chdtool)](https://github.com/lloydsmart/chdtool/releases) [![ShellCheck](https://img.shields.io/github/actions/workflow/status/lloydsmart/chdtool/shellcheck.yml?branch=main&label=shellcheck)](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml) @@ -31,7 +31,7 @@ Convert everything in a directory: - ๐Ÿ“‰ Space savings reporting - ๐Ÿงพ Automatic M3U generation for multi-disc sets - ๐Ÿงน Safe temp directory handling with cleanup traps -- ๐Ÿงช Dry-run mode (no changes made) +- ๐Ÿงช Dry-run mode (no source or conversion changes) - ๐Ÿชต Structured logging system: - Console / file / syslog / journald - Configurable verbosity @@ -51,9 +51,18 @@ Convert everything in a directory: | ------ | ----------- | | `-k`, `--keep-originals` | Do not delete source files after conversion | | `-r`, `--recursive` | Process subdirectories | -| `-n`, `--dry-run` | Show what would happen without making changes | -| `-F`, `--file-tee` | Force logging to file | -| `-N`, `--no-file-tee` | Disable logging to file | +| `-n`, `--dry-run` | Show what would happen without making changes | +| `-a`, `--allow-unverified-cue-audio` | Permit lossy or unverified CUE audio tracks (not preservation-safe) | +| `-F`, `--file-tee` | Force logging to file | +| `-N`, `--no-file-tee` | Disable logging to file | +| `-h`, `--help` | Show usage information | +| `-V`, `--version` | Show the packaged version | + +Use `--` to end option parsing when an input directory starts with `-`: + +```bash +./chdtool.sh -- -roms +``` --- @@ -133,15 +142,21 @@ Configurable via environment variables: LOG_DEST=auto|console|file|syslog|journald LOG_LEVEL_THRESHOLD=DEBUG|INFO|WARN|ERROR LOG_TEE_CONSOLE=auto|1|0 -LOG_TEE_FILE=1|0 -LOG_TAG=chdtool +LOG_TEE_FILE=1|0 +LOG_TAG=chdtool +LOGFILE=/custom/path/chdtool.log ``` Default log file: ```text logs/chd_conversion_.log -``` +``` + +`--file-tee` and `--no-file-tee` control the optional file mirror for console, +syslog, and journald backends. When `LOG_DEST=file`, the file is the primary +destination and is therefore still written. A caller-supplied `LOGFILE` path is +used verbatim. --- @@ -174,8 +189,9 @@ The following tools must be installed: - `unzip` - `unrar` - `7z` -- `stat` -- `awk` +- `stat` +- `awk` +- `stdbuf` Optional (enhancements): @@ -194,9 +210,14 @@ Use `--dry-run` to preview actions: ./chdtool.sh -n /roms ``` -- No files are created, moved, or deleted +- No source files, CHDs, playlists, or temporary conversion workspaces are + created, moved, or deleted - All intended operations are logged +Logging remains active during a dry run and may create the configured log file. +Use `--no-file-tee` with the console, syslog, or journald backend to prevent a +file mirror. + ### Resource selection By default, CHD Tool lets `chdman` choose its format-appropriate hunk size. It @@ -222,6 +243,20 @@ result at the available CPU count with a minimum of one. - Archive members are rejected before conversion when paths escape the extraction directory, links are present, or selected entries collide after name sanitisation - Temporary files are cleaned automatically, even on interruption +- `--allow-unverified-cue-audio` relaxes preservation checks for lossy or + otherwise unverified CUE audio tracks; use it only when accepting that risk + +--- + +## ๐Ÿ“ฆ Release assets + +Each release publishes `.tar.gz` and `.zip` archives containing `chdtool`, +`README.md`, `LICENSE.md`, and `CHANGELOG.md`, plus a standalone `chdtool` +script. Verify downloads with the published checksum file: + +```bash +sha256sum --check SHA256SUMS +``` --- diff --git a/chdtool.sh b/chdtool.sh index 5cd957e..9fbddf6 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -6,7 +6,25 @@ script_start_time=$(date +%s) shopt -s nullglob shopt -s extglob -USAGE="Usage: $0 [--keep-originals|-k] [--recursive|-r] [--dry-run|-n] [--allow-unverified-cue-audio|-a] [--file-tee|-F|--no-file-tee|-N] " +CHDTOOL_VERSION="0.2.4" +PROGRAM_NAME="$(basename -- "$0")" +USAGE="Usage: $PROGRAM_NAME [options] [--] " + +print_usage() { + cat <&2 + echo "$USAGE" >&2; exit 1 + fi + INPUT_DIR="${1%/}" + shift + break ;; -*) echo "โŒ Unknown option: $1" >&2 echo "$USAGE" >&2; exit 1 ;; @@ -51,6 +82,7 @@ done if [[ -z "$INPUT_DIR" ]]; then echo "$USAGE" >&2; exit 1 fi +[[ "$INPUT_DIR" == -* ]] && INPUT_DIR="./$INPUT_DIR" if [[ ! -d "$INPUT_DIR" ]]; then echo "โŒ Input directory does not exist or is not a directory: $INPUT_DIR" >&2 exit 1 @@ -59,7 +91,7 @@ fi # Use a disk-based temp directory to avoid filling up RAM TMP_ROOT="${TMPDIR:-/var/tmp/chdtool}" TMPDIR="$TMP_ROOT/$RUN_ID" -mkdir -p "$TMPDIR" +[[ "$DRY_RUN" == true ]] || mkdir -p "$TMPDIR" LOGFILE="${LOGFILE:-logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log}" @@ -113,7 +145,7 @@ if [[ "$LOG_BACKEND" == journald || "$LOG_BACKEND" == syslog ]]; then fi # ensure the logfile directory exists when we might write to it -if [[ "$LOG_BACKEND" == file || "$LOG_BACKEND" == console ]] || __should_mirror_file; then +if [[ "$LOG_BACKEND" == file ]] || __should_mirror_file; then mkdir -p -- "$(dirname -- "$LOGFILE")" fi @@ -182,9 +214,13 @@ _emit_log() { __should_mirror_console && __console_print "$ts" "$lvl" "$msg" ;; console|*) - while IFS= read -r line; do - printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" | tee -a "$LOGFILE" - done <<< "$msg" + if __should_mirror_file; then + while IFS= read -r line; do + printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" | tee -a "$LOGFILE" + done <<< "$msg" + else + __console_print "$ts" "$lvl" "$msg" + fi ;; esac @@ -503,7 +539,7 @@ validate_extracted_tree() { done < <(find "$root" -type l -print0) } -check_temp_storage "$TMPDIR" +[[ "$DRY_RUN" == true ]] || check_temp_storage "$TMPDIR" # ---------- chdman progress handling ---------- # Config: PROGRESS_STYLE=auto|bar|line|none ; default: auto (TTY -> bar, non-TTY -> none) diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100644 index 0000000..df569c3 --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +version="${1:-}" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { + echo "Usage: $0 " >&2 + exit 1 +} + +script_version="$(sed -n 's/^CHDTOOL_VERSION="\([^"]*\)"/\1/p' chdtool.sh)" +[[ "$script_version" == "$version" ]] || { + echo "Release version $version does not match chdtool.sh version $script_version" >&2 + exit 1 +} + +for command in tar gzip zip unzip sha256sum; do + command -v "$command" >/dev/null 2>&1 || { echo "Missing packaging command: $command" >&2; exit 1; } +done + +archive_base="chdtool-v$version" +dist_dir="${DIST_DIR:-$REPO_ROOT/dist}" +[[ "$dist_dir" == /* ]] || dist_dir="$REPO_ROOT/$dist_dir" +[[ "$dist_dir" != "/" && "$dist_dir" != "$REPO_ROOT" ]] || { + echo "Refusing unsafe distribution directory: $dist_dir" >&2 + exit 1 +} +stage_root="$(mktemp -d)" +trap 'rm -rf "$stage_root"' EXIT +stage_dir="$stage_root/$archive_base" +mkdir -p "$stage_dir" + +install -m 0755 chdtool.sh "$stage_dir/chdtool" +install -m 0644 README.md LICENSE.md CHANGELOG.md "$stage_dir/" + +# Use the tagged commit timestamp when available, with a stable fallback for +# source archives or local packaging outside a Git checkout. +source_date_epoch="${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || printf '0')}" +find "$stage_dir" -exec touch -d "@$source_date_epoch" {} + + +rm -rf -- "$dist_dir" +mkdir -p "$dist_dir" + +tar --sort=name --mtime="@$source_date_epoch" --owner=0 --group=0 --numeric-owner \ + -C "$stage_root" -cf - "$archive_base" | gzip -n > "$dist_dir/$archive_base.tar.gz" + +zip_path="$dist_dir/$archive_base.zip" +( + cd "$stage_root" + find "$archive_base" -type f -print | LC_ALL=C sort | zip -X -q "$zip_path" -@ +) + +install -m 0755 chdtool.sh "$dist_dir/chdtool" + +expected="$(printf '%s\n' \ + "$archive_base/CHANGELOG.md" \ + "$archive_base/LICENSE.md" \ + "$archive_base/README.md" \ + "$archive_base/chdtool")" +tar_contents="$(tar -tzf "$dist_dir/$archive_base.tar.gz" | sed '/\/$/d' | LC_ALL=C sort)" +zip_contents="$(unzip -Z1 "$dist_dir/$archive_base.zip" | sed '/\/$/d' | LC_ALL=C sort)" +[[ "$tar_contents" == "$expected" ]] || { echo "Unexpected tar archive contents" >&2; exit 1; } +[[ "$zip_contents" == "$expected" ]] || { echo "Unexpected zip archive contents" >&2; exit 1; } + +( + cd "$dist_dir" + sha256sum chdtool "$archive_base.tar.gz" "$archive_base.zip" > SHA256SUMS + sha256sum --check SHA256SUMS +) + +printf 'Created validated release assets in %s\n' "$dist_dir" diff --git a/tests/test_cli.sh b/tests/test_cli.sh new file mode 100644 index 0000000..816169c --- /dev/null +++ b/tests/test_cli.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}" +if [[ "$SCRIPT" != /* ]]; then + SCRIPT="$(cd "$(dirname "$SCRIPT")" && pwd)/$(basename "$SCRIPT")" +fi +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT +export PATH="$REPO_ROOT/tests/bin:$PATH" + +help_output="$(bash "$SCRIPT" --help)" +grep -q '^Usage:' <<< "$help_output" || { echo "FAIL: --help omitted usage" >&2; exit 1; } +grep -q -- '--allow-unverified-cue-audio' <<< "$help_output" || { echo "FAIL: --help omitted audio warning option" >&2; exit 1; } + +version_output="$(bash "$SCRIPT" --version)" +[[ "$version_output" =~ ^chdtool\.sh\ [0-9]+\.[0-9]+\.[0-9]+ ]] || { echo "FAIL: unexpected --version output: $version_output" >&2; exit 1; } + +mkdir "$FIX/-input" +( + cd "$FIX" + cli_output="$(LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR bash "$SCRIPT" --dry-run --no-file-tee -- -input 2>&1)" + [[ "$cli_output" != *'unknown predicate'* ]] || { echo "FAIL: leading-dash path reached find as an option" >&2; exit 1; } +) +[[ ! -e "$FIX/logs" ]] || { echo "FAIL: --no-file-tee created a console-backend logfile" >&2; exit 1; } + +if TMPDIR="$FIX/dry-temp" LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR \ + bash "$SCRIPT" --dry-run --no-file-tee "$FIX/-input"; then + [[ ! -e "$FIX/dry-temp" ]] || { echo "FAIL: dry-run created a temporary workspace" >&2; exit 1; } +else + echo "FAIL: dry-run CLI invocation failed" >&2 + exit 1 +fi + +echo "PASS: help, version, option terminator, logging, and dry-run CLI behaviour" diff --git a/tests/test_package.sh b/tests/test_package.sh new file mode 100644 index 0000000..115917a --- /dev/null +++ b/tests/test_package.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="$(sed -n 's/^CHDTOOL_VERSION="\([^"]*\)"/\1/p' "$REPO_ROOT/chdtool.sh")" +FIX="$(mktemp -d)" +trap 'rm -rf "$FIX"' EXIT +dist_dir="$FIX/dist" + +(cd "$REPO_ROOT" && DIST_DIR="$dist_dir" SOURCE_DATE_EPOCH=0 bash scripts/package-release.sh "$version") + +archive_base="chdtool-v$version" +[[ -s "$dist_dir/$archive_base.tar.gz" ]] || { echo "FAIL: tar archive missing" >&2; exit 1; } +[[ -s "$dist_dir/$archive_base.zip" ]] || { echo "FAIL: zip archive missing" >&2; exit 1; } +[[ -s "$dist_dir/chdtool" && -s "$dist_dir/SHA256SUMS" ]] || { echo "FAIL: standalone script or checksums missing" >&2; exit 1; } +(cd "$dist_dir" && sha256sum --check SHA256SUMS >/dev/null) +first_hashes="$(cd "$dist_dir" && sha256sum chdtool "$archive_base.tar.gz" "$archive_base.zip")" + +(cd "$REPO_ROOT" && DIST_DIR="$dist_dir" SOURCE_DATE_EPOCH=0 bash scripts/package-release.sh "$version" >/dev/null) +second_hashes="$(cd "$dist_dir" && sha256sum chdtool "$archive_base.tar.gz" "$archive_base.zip")" +[[ "$first_hashes" == "$second_hashes" ]] || { echo "FAIL: repeated packaging was not reproducible" >&2; exit 1; } + +echo "PASS: release assets are complete, validated, and checksummed" From 8a7d716d671eefa500aa96b68693f3da8c13644d Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 12:09:43 +0100 Subject: [PATCH 59/60] docs: document release procedure --- CONTRIBUTING.md | 9 ++- README.md | 3 + RELEASING.md | 145 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 RELEASING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1087fd1..9e55213 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for your interest in contributing to **chdscript**! ๐ŸŽ‰ +Thanks for your interest in contributing to **chdtool**! ๐ŸŽ‰ ## How to Contribute @@ -22,7 +22,7 @@ Thanks for your interest in contributing to **chdscript**! ๐ŸŽ‰ git push origin feature/my-new-feature ``` -4. **Open a Pull Request** against `main`. +4. **Open a Pull Request** against `develop`. ## Coding Guidelines @@ -46,7 +46,10 @@ Bump version numbers when you: - `MAJOR`: Break backward compatibility - `MINOR`: Add new functionality in a backward-compatible manner -- `PATCH`: Fix bugs or make small improvements +- `PATCH`: Fix bugs or make small improvements + +Maintainers should follow the complete [release procedure](RELEASING.md) when +preparing, tagging, publishing, and verifying a release. --- diff --git a/README.md b/README.md index acbb701..c51d47d 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,9 @@ script. Verify downloads with the published checksum file: ```bash sha256sum --check SHA256SUMS ``` + +Maintainers should use the documented [release procedure](RELEASING.md) rather +than creating tags or assets manually. --- diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..8c309df --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,145 @@ +# Release procedure + +This project uses a gitflow-style release process. Development is integrated on +`develop`; releases are made from `main` and triggered by tags named +`vMAJOR.MINOR.PATCH`. + +## Prerequisites + +- Push access to `lloydsmart/chdtool` +- Permission to merge into `develop` and `main` +- GitHub Actions permission to create pull requests +- Bash, Make, ShellCheck, `tar`, `gzip`, `zip`, `unzip`, and `sha256sum` +- `git-cliff` when generating changelogs locally + +Start with a clean, current checkout: + +```bash +git switch develop +git pull --ff-only origin develop +git status --short +``` + +The final command should print nothing. + +## 1. Choose and set the version + +Choose the next version according to Semantic Versioning. Create a release +branch from `develop`: + +```bash +git switch -c release/0.2.5 +``` + +Update `CHDTOOL_VERSION` near the top of `chdtool.sh`. Confirm that the CLI +reports the intended value: + +```bash +./chdtool.sh --version +``` + +The release workflow rejects a tag whose version does not match this value. + +## 2. Run the preflight checks + +Run the same functional and shell checks used by the release workflow: + +```bash +make test +shellcheck chdtool.sh scripts/*.sh tests/*.sh tests/bin/* +``` + +When `actionlint` and `markdownlint-cli2` are available, also run: + +```bash +actionlint +npx --yes markdownlint-cli2 "**/*.md" "#CHANGELOG.md" \ + "#CHANGELOG_RELEASE.md" "#LICENSE.md" +``` + +Build and validate the release assets locally: + +```bash +make package VERSION=0.2.5 +(cd dist && sha256sum --check SHA256SUMS) +tar -tzf dist/chdtool-v0.2.5.tar.gz +unzip -Z1 dist/chdtool-v0.2.5.zip +``` + +Both archives must contain exactly one top-level `chdtool-v0.2.5` directory +with these files: + +- `chdtool` +- `README.md` +- `LICENSE.md` +- `CHANGELOG.md` + +The `dist/` directory is rebuilt from scratch by the packaging command. + +## 3. Merge the release change + +Commit the version change, push the release branch, and open a pull request into +`develop`: + +```bash +git add chdtool.sh +git commit -m "chore: prepare v0.2.5" +git push -u origin release/0.2.5 +``` + +Merge only after all required checks pass. Then open and merge a pull request +from `develop` into `main`. Do not tag the release branch or an unmerged commit. + +## 4. Tag and publish + +Update local `main` and verify the version one final time: + +```bash +git switch main +git pull --ff-only origin main +./chdtool.sh --version +git status --short +``` + +Create an annotated tag on that exact commit and push only the tag: + +```bash +git tag -a v0.2.5 -m "Release v0.2.5" +git push origin v0.2.5 +``` + +Pushing the tag starts the `Release` workflow. It: + +1. runs the complete test suite and ShellCheck; +2. generates release notes with git-cliff; +3. builds reproducible `.tar.gz` and `.zip` archives; +4. validates their exact contents; +5. creates `SHA256SUMS` and verifies every checksum; +6. publishes the GitHub release and assets; and +7. opens a pull request to update `CHANGELOG.md` on `main`. + +## 5. Verify and synchronise + +On the GitHub release page, confirm: + +- the release is attached to the intended tag and commit; +- the release notes are present; +- `chdtool`, both archives, and `SHA256SUMS` are attached; and +- downloaded assets pass `sha256sum --check SHA256SUMS`. + +Merge the automated changelog pull request after its checks pass. Finally, +merge or fast-forward `main` back into `develop` so both long-lived branches +contain the release tag's code and generated changelog. + +## Failure handling + +- If preflight checks or local packaging fail, fix them on the release branch; + do not tag. +- If the tag workflow fails before publishing a release, inspect the failed job, + fix the problem through the normal branch and pull-request flow, then decide + whether the unused tag can be deleted and recreated. +- Never move or reuse a tag after a GitHub release has been published. Correct a + published release with a new patch version. +- Do not upload hand-built replacement assets to an automated release; rerun a + corrected workflow under a new version so provenance and checksums remain + consistent. From ace06cba89d3d98ff02ca270a15dbdb96e461736 Mon Sep 17 00:00:00 2001 From: Lloyd Smart Date: Wed, 5 Aug 2026 12:18:59 +0100 Subject: [PATCH 60/60] chore: prepare v0.3.0 --- chdtool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chdtool.sh b/chdtool.sh index 9fbddf6..0d441d1 100755 --- a/chdtool.sh +++ b/chdtool.sh @@ -6,7 +6,7 @@ script_start_time=$(date +%s) shopt -s nullglob shopt -s extglob -CHDTOOL_VERSION="0.2.4" +CHDTOOL_VERSION="0.3.0" PROGRAM_NAME="$(basename -- "$0")" USAGE="Usage: $PROGRAM_NAME [options] [--] "