diff --git a/profile.d/compress.sh b/profile.d/compress.sh index 5b54e4e..a2645c7 100644 --- a/profile.d/compress.sh +++ b/profile.d/compress.sh @@ -46,6 +46,14 @@ load_conf "compress" # -n, --no-dir Never create a host directory utaz() { + # Extractor helpers. Every one takes the archive path as $1 and an existing + # target directory as $2, and returns the extractor exit status (0 = ok, + # 1 = warning, anything else = failure). The matching one is selected by + # file extension and invoked by name (see the dispatch "case" below), and + # the name minus its "_un" prefix is the binary checked beforehand. + # NOTE: bash defines functions globally, so these helpers are (re)defined in + # the shell on each call and the "local" lines below only declare an unused + # variable of the same name; they are kept as a marker for shellcheck. local _ununzip # shellcheck disable=SC2329 _ununzip() @@ -92,11 +100,15 @@ utaz() # shellcheck disable=SC2329 _unlzip() { - if command -v plzip >/dev/null 2>&1; then - plzip -d -c "$1" > "$2/$(basename "${1%.*}")" - else - lzip -d -c "$1" > "$2/$(basename "${1%.*}")" - fi + # Only *.tar.lz / *.tlz reach this helper: decompress to stdout and + # untar from the pipe. pipefail (subshell-scoped) makes a lzip failure + # visible instead of being masked by tar's exit status. + local lzcmd=lzip + command -v plzip >/dev/null 2>&1 && lzcmd=plzip + ( + set -o pipefail + "$lzcmd" -d -c -- "$1" | tar -xf - -C "$2" + ) } local _ununrar @@ -142,8 +154,16 @@ utaz() # shellcheck disable=SC2329 _unzstd() { - # Zstd decompresses files directly, often requiring tar for archives - tar --zstd -xf "$1" -C "$2" + # A tar.zst/.tzst is untarred; a bare .zst is a single compressed file + # and is simply decompressed into the target directory. + case $1 in + *.tar.zst|*.tzst) + tar --zstd -xf "$1" -C "$2" + ;; + *) + zstd -d -q -c -- "$1" > "$2/$(basename "${1%.*}")" + ;; + esac } local _uncpio @@ -178,10 +198,43 @@ utaz() _unrpm() { # Extracts CPIO-based payload from an RPM package - # Needs rpm2cpio and cpio - rpm2cpio "$1" | (cd "$2/" && cpio -idmv) >/dev/null 2>&1 + # Needs rpm2cpio and cpio. pipefail (subshell-scoped) so that a failing + # rpm2cpio is not masked by cpio's exit status. + ( + set -o pipefail + rpm2cpio "$1" | (cd "$2/" && cpio -idm) + ) >/dev/null 2>&1 } + # Move every entry (hidden ones included) of directory $1 up into its + # parent, then remove $1. Refuses (returns 1, $1 left untouched) if any + # entry would overwrite something already present in the parent. + # The directory is first renamed to a temporary name so that an archive + # holding a top-level entry named like the host directory (foo.zip -> foo/foo) + # does not collide with itself. Runs in a subshell so the shopt calls do not + # leak into the interactive shell. + local _utaz_flatten + # shellcheck disable=SC2329 + _utaz_flatten() + ( + shopt -s nullglob dotglob + local dir=$1 parent tmp child + parent=$(dirname -- "$dir") + tmp="${parent}/.utaz.$$.${RANDOM}" + mv -- "$dir" "$tmp" || return 1 + for child in "$tmp"/*; do + if [[ -e "${parent}/${child##*/}" || -L "${parent}/${child##*/}" ]]; then + disp W "${parent}/${child##*/} already exists, not overwriting." + mv -- "$tmp" "$dir" + return 1 + fi + done + for child in "$tmp"/*; do + mv -- "$child" "${parent}/" || return 1 + done + rmdir -- "$tmp" + ) + local PARSED PARSED=$(getopt -o hdcn --long help,delete,create-dir,no-dir -n 'utaz' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after @@ -189,9 +242,11 @@ utaz() disp E "Invalid options, use \"utaz --help\" to display usage." return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # getopt emits a shell-quoted, normalised argument list ("--" terminates the + # options); re-evaluating it restores the arguments with spaces and quotes + # intact. Declared local so flags never leak in from the caller's environment. + local willrm="" createdir="" nodir="" + eval set -- "$PARSED" while true; do case "$1" in -h|--help) @@ -215,7 +270,8 @@ utaz() printf "\t- lha, lzh\n" printf "\t- ace\n" printf "\t- 7z, p7z\n" - printf "\t- zst\n" + printf "\t- tar\n" + printf "\t- zst, tar.zst, .tzst\n" printf "\t- cpio\n" printf "\t- cab\n" printf "\t- deb\n" @@ -223,15 +279,15 @@ utaz() return 0 ;; -d|--delete) - local willrm=1 + willrm=1 shift ;; -c|--create-dir) - local createdir=1 + createdir=1 shift ;; -n|--no-dir) - local nodir=1 + nodir=1 shift ;; --) @@ -250,8 +306,8 @@ utaz() [[ ${#FILES[@]} -eq 0 ]] && FILES=(".") # Apply defaults from [compress] configuration if not overridden by flags - [[ -z ${willrm+x} ]] && [[ ${UTAZ_DEFAULT_DELETE:-0} -eq 1 ]] && willrm=1 - if [[ -z ${createdir+x} && -z ${nodir+x} ]]; then + [[ -z $willrm ]] && [[ ${UTAZ_DEFAULT_DELETE:-0} -eq 1 ]] && willrm=1 + if [[ -z $createdir && -z $nodir ]]; then case "${UTAZ_DEFAULT_DIR_MODE:-auto}" in always) createdir=1 ;; never) nodir=1 ;; @@ -263,12 +319,19 @@ utaz() return 1 } + # Loop variables are local: this function lives in the interactive shell. + local zitem f for zitem in "${FILES[@]}"; do + # A name starting with "-" would be taken as an option by the tools below + [[ $zitem == -* ]] && zitem="./${zitem}" + # Build list of input files to process, with whitespace-safe handling. local targets=() if [[ -f "$zitem" ]]; then targets=("$zitem") elif [[ -d "$zitem" ]]; then + # Only direct children are considered (no recursion); NUL-separated + # so names containing spaces or newlines survive. mapfile -d '' -t targets < <(find "$zitem" -mindepth 1 -maxdepth 1 -print0 2>/dev/null) if [[ ${#targets[@]} -eq 0 ]]; then disp I "Directory ${zitem} is empty, skipping." @@ -280,7 +343,11 @@ utaz() fi for f in "${targets[@]}"; do + # Host directory name: archive name without its extension. A + # trailing ".tar" is dropped too, so foo.tar.gz gives "foo", not + # "foo.tar". local dir="${f%.*}" + [[ $dir == *.tar ]] && dir="${dir%.tar}" local extractor="" case "$f" in *.zip) @@ -319,7 +386,7 @@ utaz() *.7z|*.p7z) extractor="_un7z" ;; - *.zst) + *.zst|*.tzst) extractor="_unzstd" ;; *.cpio) @@ -340,7 +407,9 @@ utaz() ;; # Skip non-archive files esac - # Verify binary existence + # Verify the extractor's binary exists. The helper name minus its + # "_un" prefix is the program name (_un7z -> 7z); deb/rpm/lzip need + # special handling because their programs are named differently. local cmd=${extractor//_un/} if [[ $cmd == "deb" ]]; then command -v -- dpkg-deb >/dev/null 2>&1 || { @@ -356,6 +425,12 @@ utaz() disp E "The program 'cpio' is not installed, aborting." continue } + elif [[ $cmd == "lzip" ]]; then + # plzip is a drop-in parallel replacement for lzip + command -v -- lzip >/dev/null 2>&1 || command -v -- plzip >/dev/null 2>&1 || { + disp E "Neither 'lzip' nor 'plzip' is installed, skipping ${f}." + continue + } else command -v -- "${cmd}" >/dev/null 2>&1 || { disp E "Binary ${cmd} necessary to extract ${f} is missing." @@ -368,18 +443,19 @@ utaz() # archive never wipes a pre-existing directory of the same name. local dir_created=0 [[ -d "${dir}" ]] || dir_created=1 - if ! mkdir -p "${dir}"; then - disp E "The filesystem can't create directories, exit!" && - return 1 + if ! mkdir -p -- "${dir}"; then + disp E "Can't create directory ${dir}, skipping ${f}." + continue fi ${extractor} "${f}" "${dir}" case $? in 0) [[ -n ${willrm} ]] && - rm -f "${f}" && disp I "File ${zitem}/${f} deleted." + rm -f -- "${f}" && disp I "File ${f} deleted." ;; 1) + # Most extractors use 1 for "finished with warnings" if [[ -n ${willrm} ]]; then disp W "Compression program returned a warning: deletion canceled." else @@ -388,40 +464,38 @@ utaz() ;; *) disp E "The compressed file ${f} seems corrupted, failed." - [[ ${dir_created} -eq 1 ]] && rm -rf "${dir}" >/dev/null 2>&1 + [[ ${dir_created} -eq 1 ]] && rm -rf -- "${dir}" >/dev/null 2>&1 continue ;; esac + # Decide whether the host directory is kept. Flattening is never + # done on a directory that existed before extraction, as it would + # also move the user's own files out of it. + local flatten=0 if [[ -n ${createdir} ]]; then - disp I "Archive extracted successfully in subdirectory." + : + elif [[ ${dir_created} -ne 1 ]]; then + : elif [[ -n ${nodir} ]]; then - shopt -s nullglob - for child in "${dir}"/*; do - mv -- "$child" . - done - shopt -u nullglob - rmdir -- "${dir}" + flatten=1 + else + # Auto mode: flatten only if the archive already holds a single + # top-level directory (it then acts as the host directory). + # nullglob makes the array empty when nothing matches; dotglob + # makes hidden entries count, so ".git" alone is not ignored. + local _old_glob + _old_glob=$(shopt -p nullglob dotglob) + shopt -s nullglob dotglob + local contents=( "${dir}"/* ) + eval "$_old_glob" + [[ ${#contents[@]} -eq 1 && -d "${contents[0]}" && ! -L "${contents[0]}" ]] && flatten=1 + fi + + if [[ $flatten -eq 1 ]] && _utaz_flatten "${dir}"; then disp I "Archive extracted successfully, no subdirectory needed." else - # Set nullglob to ensure the array is empty if no files match - shopt -s nullglob - local contents=( "${dir}"/* ) - - # Check if exactly one item exists and if that item is a directory - if [[ ${#contents[@]} -eq 1 ]] && [[ -d "${contents[0]}" ]]; then - # Single directory detected - shopt -s nullglob - for child in "${contents[0]}"/*; do - mv -- "$child" . - done - shopt -u nullglob - rmdir -- "${dir}" - disp I "Archive extracted successfully, no subdirectory needed." - else - disp I "Archive extracted successfully in subdirectory." - fi - shopt -u nullglob + disp I "Archive extracted successfully in subdirectory." fi done done @@ -445,6 +519,14 @@ export -f utaz # -1, .., -9 Compression level to use [1=fast/biggest, 9=slow/smallest] taz() { + # Compressor helpers. All share the same signature: + # $1 = file to compress, $2 = thread count, $3 = level (1-9), + # $4 = non-empty for verbose output + # and keep the source (--keep), writing "$1." next to it. They return + # 127 when no suitable program is installed (fatal for the whole run), any + # other non-zero status on a compression failure (that format is skipped). + # As with utaz, nested functions are global, "local" does not scope them. + # Resolve runtime CPU count for --parallel=auto. local _taz_detect_cpus _taz_detect_cpus() @@ -532,7 +614,7 @@ taz() disp E "Programs 'pigz' or 'gzip' are not installed, aborting." return 127 } - local command="gzip --compress" + local command=gzip local procopt=() [[ $2 -gt 1 ]] && disp W "gzip doesn't support multithreading, falling back to 1 thread." && @@ -601,10 +683,12 @@ taz() disp E "Invalid options, use \"taz --help\" to display usage." return 1 fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + # See utaz: restore the getopt-normalised argument list, and keep every + # flag local so nothing leaks in from the caller's environment. + local willrm="" keepall="" verbose="" quiet="" nproc="" complevel="" + eval set -- "$PARSED" local compforms=() + local _f _list while true; do case "$1" in -h|--help) @@ -614,7 +698,7 @@ taz() printf "\t-h, --help\tDisplay that help screen\n" printf "\t-d, --delete\tDelete source file or directory after success\n" printf "\t-f, --format\tChose archive format in the given list. If several format are\n" - printf "\t\t\tgiven, the smalest is kept\n" + printf "\t\t\tgiven, the smalest produced archive is kept\n" printf "\t-K, --keep-all\tKeep every produced version instead of only the smallest\n" printf "\t-p, --parallel\tNumber of threads, or 'auto' for runtime CPU count\n" printf "\t-v, --verbose\tDisplay progress where possible\n" @@ -634,42 +718,41 @@ taz() ;; -d|--delete) - local willrm=1 + willrm=1 shift ;; -f|--format) # Accept comma/space separated lists and repeated -f flags. - local _f - # shellcheck disable=SC2086 # intentional word splitting on the list - for _f in ${2//,/ }; do - compforms+=("$_f") - done + # "read -a" splits without pathname expansion (unlike an unquoted + # expansion, which would glob "*" against the current directory). + IFS=', ' read -ra _list <<< "$2" + compforms+=("${_list[@]}") shift 2 ;; -K|--keep-all) - local keepall=1 + keepall=1 shift ;; -p|--parallel) - local nproc=$2 + nproc=$2 shift 2 ;; -v|--verbose) - local verbose=1 + verbose=1 shift ;; -q|--quiet) - local quiet=1 + quiet=1 shift ;; -[1-9]) - local complevel="${1#-}" + complevel="${1#-}" shift ;; --) @@ -686,27 +769,32 @@ taz() # The remaining arguments after -- are the files/directories to process, # "." is used if none is given local FILES=("$@") - [[ ${#FILES[@]} -eq 0 ]] && FILES=(".") + [[ ${#FILES[@]} -eq 0 ]] && FILES=(".") - [[ ! $compform ]] && local compform=${TAZ_DEFAULT_FORMAT:-lz} - [[ ! $nproc ]] && local nproc=${TAZ_DEFAULT_THREADS:-auto} - [[ ! $complevel ]] && local complevel=${TAZ_DEFAULT_LEVEL:-6} + # Defaults from the [compress] configuration for anything not set by flags + [[ -z $nproc ]] && nproc=${TAZ_DEFAULT_THREADS:-auto} + [[ -z $complevel ]] && complevel=${TAZ_DEFAULT_LEVEL:-6} + if [[ ! $complevel =~ ^[1-9]$ ]]; then + disp E "Invalid compression level '${complevel}' (expected 1 to 9)." + return 1 + fi [[ -n $verbose && -n $quiet ]] && { disp E "The --verbose and --quiet options can't be used together." return 1 } + # disp() hides info messages when QUIET=1; being local, this only lasts + # for the duration of this call (dynamic scoping). + [[ -n $quiet ]] && local QUIET=1 # Fall back to the configured (or built-in) default format list. - local _f if [[ ${#compforms[@]} -eq 0 ]]; then - local _deffmt=${TAZ_DEFAULT_FORMAT:-lz} - # shellcheck disable=SC2086 # intentional word splitting on the list - for _f in ${_deffmt//,/ }; do - compforms+=("$_f") - done + IFS=', ' read -ra _list <<< "${TAZ_DEFAULT_FORMAT:-lz}" + compforms+=("${_list[@]}") fi # Reject unknown formats early so we never dispatch to a missing _do*. + # Membership test: the padded list is matched against " " so that + # only whole words match (e.g. "z" or "lz xz" are rejected). local _supported=" lz xz bz2 gz lzo tar " for _f in "${compforms[@]}"; do [[ $_supported == *" $_f "* ]] || { @@ -724,25 +812,62 @@ taz() return 1 fi - # Map each format to the extension its compressor appends. "tar" performs - # no compression, so its candidate is the tar (or source file) itself. - local -A _taz_ext=( [lz]=lz [xz]=xz [bz2]=bz2 [gz]=gz [lzo]=lzo [tar]="" ) + # Map each format to the extension its compressor appends to the input + # name. "tar" is handled separately (its output is always "$item.tar"). + local -A _taz_ext=( [lz]=lz [xz]=xz [bz2]=bz2 [gz]=gz [lzo]=lzo ) + local item for item in "${FILES[@]}"; do local donetar=0 + # Arguments naming the tar members; the default keeps the path as typed + local tar_src=() + + # Normalise the name, since archive names are built as "$item.": + # - strip trailing slashes (shell completion adds them), otherwise + # "dir/.tar" would be created inside the directory being archived; + # - "." / ".." (or "x/..") have no usable name, so resolve them; + # - protect names starting with "-" from being read as options. + while [[ ${#item} -gt 1 && $item == */ ]]; do + item=${item%/} + done + case ${item##*/} in + .|..) + item=$(realpath -- "$item") || continue + # Store the bare directory name, not its absolute path + tar_src=(-C "${item%/*}" -- "${item##*/}") + ;; + esac + if [[ $item == / ]]; then + disp E "Refusing to archive the root directory, skipping." + continue + fi + [[ $item == -* ]] && item="./${item}" + [[ ${#tar_src[@]} -eq 0 ]] && tar_src=(-- "$item") + disp I "Processing $item..." + if [[ ! -e $item ]]; then + disp W "Path ${item} does not exist, skipping." + continue + fi if [[ -d "$item" ]]; then + # Directories can only be compressed through an intermediate tar. + if [[ -e "$item.tar" ]]; then + disp E "${item}.tar already exists, not overwriting it, skipping." + continue + fi disp I "\t Creating $item.tar... " - if ! tar -cf "$item.tar" "$item"; then + if ! tar -cf "$item.tar" "${tar_src[@]}"; then disp E "tar file creation failed, skipping to next item." + rm -f -- "$item.tar" continue fi - local donetar=1 + donetar=1 fi + # File actually handed to the compressors local fname=$item [[ $donetar -gt 0 ]] && fname=$item.tar @@ -752,11 +877,27 @@ taz() for fmt in "${compforms[@]}"; do if [[ $fmt == tar ]]; then tar_requested=1 - candidates+=("$fname") + if [[ $donetar -eq 0 ]]; then + # Plain file: "tar" means wrapping it in a tar. It must be + # a separate candidate, never the source file itself, as + # non-winning candidates get deleted below. + if [[ -e "$item.tar" ]]; then + disp W "${item}.tar already exists, skipping the tar format." + continue + fi + if ! tar -cf "$item.tar" "${tar_src[@]}"; then + disp W "tar creation failed, skipping this format." + rm -f -- "$item.tar" + continue + fi + fi + candidates+=("$item.tar") continue fi disp I "\t Compressing archive with ${fmt}..." + # "_do" is resolved dynamically (formats were validated above). + # "|| exec_code=$?" captures the status without tripping set -e. exec_code=0 "_do$fmt" "$fname" "$nproc" "$complevel" "$verbose" || exec_code=$? case $exec_code in @@ -785,6 +926,8 @@ taz() fi # Select the smallest archive among the produced candidates. + # _csize maps each existing candidate to its size in bytes; winner_size + # starts at -1 meaning "no candidate seen yet". local winner="" winner_size=-1 sz local -A _csize=() for c in "${candidates[@]}"; do @@ -812,7 +955,7 @@ taz() if [[ $c == "$winner" ]]; then disp "\t ${c} $(_taz_human "${_csize[$c]}") (kept, smallest)" else - disp "\t ${c} $(_taz_human "${_csize[$c]}") (+$(_taz_human "$(( _csize[$c] - winner_size ))"))" + disp "\t ${c} $(_taz_human "${_csize[$c]}") (+$(_taz_human "$(( ${_csize[$c]} - winner_size ))"))" fi done fi @@ -837,7 +980,7 @@ taz() if [[ $willrm ]]; then disp I "\t Deleting original source as asked... " - rm -r "$item" + rm -r -- "$item" fi done }