Files
profile/profile.d/compress.sh
T

1044 lines
37 KiB
Bash

#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * Redistribution and use in source and binary forms,
# * with or without modification, are permitted provided
# * that the following conditions are met:
# *
# * Redistributions of source code must retain the above
# * copyright notice, this list of conditions and the
# * following disclaimer.
# *
# * Redistributions in binary form must reproduce the above
# * copyright notice, this list of conditions and the following
# * disclaimer in the documentation and/or other materials
# * provided with the distribution.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# shellcheck disable=SC2317,SC2329 # nested helper functions are called through their name (shellcheck cannot follow them)
# Common helpers (helpers/common.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
load_conf "compress"
# ------------------------------------------------------------------------------
# Smartly uncompress archives
# Usage: utaz [option] [directorie(s)|file(s)]
# Options:
# -h, --help Display that help screen
# -d, --delete If decompression succeeded, delete the source file
# -c, --create-dir Always create a host directory
# -n, --no-dir Never create a host directory
# -i, --ignore-case Match archive extensions case-insensitively (.ZIP, .Tar.GZ)
# -s, --case-sensitive Match archive extensions case-sensitively (overrides the
# UTAZ_DEFAULT_IGNORE_CASE configuration key)
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()
{
unzip -o "$1" -d "$2" >/dev/null 2>&1
}
local _untar
# shellcheck disable=SC2329
_untar()
{
tar -xf "$1" -C "$2"
}
local _ungzip
# shellcheck disable=SC2329
_ungzip()
{
tar -xzf "$1" -C "$2"
}
local _unbzip2
# shellcheck disable=SC2329
_unbzip2()
{
tar -xjf "$1" -C "$2"
}
local _unxz
# shellcheck disable=SC2329
_unxz()
{
tar -xJf "$1" -C "$2"
}
local _unlzop
# shellcheck disable=SC2329
_unlzop()
{
lzop -d "$1" -o "$2/$(basename "${1%.*}")"
}
local _unlzip
# shellcheck disable=SC2329
_unlzip()
{
# 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
# shellcheck disable=SC2329
_ununrar()
{
unrar x -o+ "$1" "$2/" >/dev/null 2>&1
}
local _ununarj
# shellcheck disable=SC2329
_ununarj()
{
unarj e "$1" "$2/" >/dev/null 2>&1
}
local _unlha
# shellcheck disable=SC2329
_unlha()
{
# lha extracts into the current directory, so we cd into the target
# directory and feed it an absolute path to the archive.
local src
src=$(realpath -- "$1") || return 1
(cd "$2" && lha -x "$src") >/dev/null 2>&1
}
local _ununace
# shellcheck disable=SC2329
_ununace()
{
unace x "$1" "$2/" >/dev/null 2>&1
}
local _un7z
# shellcheck disable=SC2329
_un7z()
{
7z x "$1" -o"$2/" >/dev/null 2>&1
}
local _unzstd
# shellcheck disable=SC2329
_unzstd()
{
# A tar.zst/.tzst is untarred; a bare .zst is a single compressed file
# and is simply decompressed into the target directory.
# Called from utaz: "icase" is utaz's local, visible here through
# dynamic scoping. Lowercase the name so the match follows the mode.
local key=$1
[[ -n $icase ]] && key=${key,,}
case $key in
*.tar.zst|*.tzst)
tar --zstd -xf "$1" -C "$2"
;;
*)
zstd -d -q -c -- "$1" > "$2/$(basename "${1%.*}")"
;;
esac
}
local _uncpio
# shellcheck disable=SC2329
_uncpio()
{
# cpio extracts into the current directory, so we cd into the target
# directory and read from an absolute path to the archive.
local src
src=$(realpath -- "$1") || return 1
(cd "$2" && cpio -id < "$src") >/dev/null 2>&1
}
local _uncabextract
# shellcheck disable=SC2329
_uncabextract()
{
# Requires 'cabextract' package
cabextract "$1" -d "$2/" >/dev/null 2>&1
}
local _undeb
# shellcheck disable=SC2329
_undeb()
{
# Extracts data content from a Debian package
dpkg-deb -x "$1" "$2/" >/dev/null 2>&1
}
local _unrpm
# shellcheck disable=SC2329
_unrpm()
{
# Extracts CPIO-based payload from an RPM package
# 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 hdcnis --long help,delete,create-dir,no-dir,ignore-case,case-sensitive -n 'utaz' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [ $? -ne 0 ]; then
disp E "Invalid options, use \"utaz --help\" to display usage."
return 1
fi
# getopt emits a shell-quoted, normalised argument list ("--" terminates the
# options); _getopt_split rebuilds the arguments from it (spaces and quotes
# intact, no eval). The flags are declared local so that none leaks in from
# the caller's environment.
local willrm="" createdir="" nodir="" icase="" csens=""
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "utaz: uncompress all the given files and/or the ones found in the given\n"
printf " directories creating an host directory where needed.\n\n"
printf "Usage: utaz [option] [directorie(s)|file(s)]\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay that help screen\n"
printf "\t-d, --delete\t\tIf decompression succeeded, delete the source file\n"
printf "\t-c, --create-dir\tAlways create a host directory\n"
printf "\t-n, --no-dir\t\tNever create a host directory\n"
printf "\t-i, --ignore-case\tMatch archive extensions case-insensitively\n"
printf "\t-s, --case-sensitive\tMatch archive extensions case-sensitively (default)\n\n"
printf "Supported archive format:\n"
printf "\t- zip\n"
printf "\t- tar.gz, .tgz\n"
printf "\t- tar.bz2, .tbz2\n"
printf "\t- tar.xz, .txz\n"
printf "\t- tar.lz, .tlz\n"
printf "\t- lzo\n"
printf "\t- rar\n"
printf "\t- arj\n"
printf "\t- lha, lzh\n"
printf "\t- ace\n"
printf "\t- 7z, p7z\n"
printf "\t- tar\n"
printf "\t- zst, tar.zst, .tzst\n"
printf "\t- cpio\n"
printf "\t- cab\n"
printf "\t- deb\n"
printf "\t- rpm\n"
return 0
;;
-d|--delete)
willrm=1
shift
;;
-c|--create-dir)
createdir=1
shift
;;
-n|--no-dir)
nodir=1
shift
;;
-i|--ignore-case)
icase=1
shift
;;
-s|--case-sensitive)
csens=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid option, use \"utaz --help\" to display options list"
return 1
;;
esac
done
# The remaining arguments after -- are the files/directories to process,
# "." is used if none is given
local FILES=("$@")
[[ ${#FILES[@]} -eq 0 ]] && FILES=(".")
# Apply defaults from [compress] configuration if not overridden by flags
[[ -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 ;;
esac
fi
[[ -n ${createdir} && -n ${nodir} ]] && {
disp E "The --create-dir and --no-dir options are mutually exclusive."
return 1
}
# Extension matching: the flags win over the configured default. After this
# block "icase" is non-empty if and only if matching is case-insensitive.
[[ -n $icase && -n $csens ]] && {
disp E "The --ignore-case and --case-sensitive options are mutually exclusive."
return 1
}
if [[ -z $icase && -z $csens && ${UTAZ_DEFAULT_IGNORE_CASE:-0} -eq 1 ]]; then
icase=1
fi
# 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" needs bash 4.4, the profile supports 4.3)
local _zt
targets=()
while IFS= read -r -d '' _zt; do
targets+=("$_zt")
done < <(find "$zitem" -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
if [[ ${#targets[@]} -eq 0 ]]; then
disp I "Directory ${zitem} is empty, skipping."
continue
fi
else
disp W "Path ${zitem} is not a file or directory, skipping."
continue
fi
for f in "${targets[@]}"; do
# "key" is the name the extension patterns are matched against:
# lowercased in ignore-case mode, untouched otherwise.
local key=$f
[[ -n $icase ]] && key=${f,,}
# Host directory name: archive name without its extension. A
# trailing ".tar" is dropped too, so foo.tar.gz gives "foo", not
# "foo.tar". The ".tar" test uses "key" so it follows the case mode
# (the extension is 4 characters either way).
local dir="${f%.*}"
[[ ${key%.*} == *.tar ]] && dir="${dir%.*}"
local extractor=""
case "$key" in
*.zip)
extractor="_ununzip"
;;
*.tar.gz|*.tgz)
extractor="_ungzip"
;;
*.tar.bz2|*.tbz2)
extractor="_unbzip2"
;;
*.tar.xz|*.txz)
extractor="_unxz"
;;
*.tar.lz|*.tlz)
extractor="_unlzip"
;;
*.lzo)
extractor="_unlzop"
;;
*.tar)
extractor="_untar"
;;
*.rar)
extractor="_ununrar"
;;
*.arj)
extractor="_ununarj"
;;
*.lzh|*.lha)
extractor="_unlha"
;;
*.ace)
extractor="_ununace"
;;
*.7z|*.p7z)
extractor="_un7z"
;;
*.zst|*.tzst)
extractor="_unzstd"
;;
*.cpio)
extractor="_uncpio"
;;
*.cab)
extractor="_uncabextract"
;;
*.deb)
extractor="_undeb"
;;
*.rpm)
extractor="_unrpm"
;;
*)
disp I "File ${f} is not a supported archive, skipping."
continue
;; # Skip non-archive files
esac
# 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 || {
disp E "The program 'dpkg-deb' is not installed, aborting."
continue
}
elif [[ $cmd == "rpm" ]]; then
command -v -- rpm2cpio >/dev/null 2>&1 || {
disp E "The program 'rpm2cpio' is not installed, aborting."
continue
}
command -v -- cpio >/dev/null 2>&1 || {
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."
continue
}
fi
disp I "Processing archive ${f} with ${extractor}..."
# Track whether we create the target directory so a corrupted
# 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 "Can't create directory ${dir}, skipping ${f}."
continue
fi
${extractor} "${f}" "${dir}"
case $? in
0)
[[ -n ${willrm} ]] &&
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
disp W "Compression program returned a warning."
fi
;;
*)
disp E "The compressed file ${f} seems corrupted, failed."
[[ ${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
:
elif [[ ${dir_created} -ne 1 ]]; then
:
elif [[ -n ${nodir} ]]; then
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 _had_ng=0 _had_dg=0
shopt -q nullglob && _had_ng=1
shopt -q dotglob && _had_dg=1
shopt -s nullglob dotglob
local contents=( "${dir}"/* )
(( _had_ng )) || shopt -u nullglob
(( _had_dg )) || shopt -u dotglob
[[ ${#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
disp I "Archive extracted successfully in subdirectory."
fi
done
done
}
export -f utaz
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Compress directories or files into one or more archive
# Usage: taz [option] [--parallel=<n|auto>] [--format=<format>] [directory1 ... directoryN]
# Options:
# -h, --help Display that help screen
# -d, --delete Delete source file or directory after success
# -f, --format Chose archive format in the given list. If several format are
# given, the smalest is kept
# -K, --keep-all Keep every produced version instead of only the smallest
# -p, --parallel Number of threads to use, or 'auto' to use detected CPU count
# -v, --verbose Display progress where possible
# -q, --quiet Display less messages (only errors and warnings)
# -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.<ext>" 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()
{
local cpus=1
if command -v nproc >/dev/null 2>&1; then
cpus=$(nproc 2>/dev/null)
elif command -v getconf >/dev/null 2>&1; then
cpus=$(getconf _NPROCESSORS_ONLN 2>/dev/null)
fi
[[ $cpus =~ ^[1-9][0-9]*$ ]] || cpus=1
printf "%s\n" "$cpus"
}
# Render a byte count as a human-readable size (B/KB/MB/GB/...), base 1024.
local _taz_human
# shellcheck disable=SC2329
_taz_human()
{
if command -v numfmt >/dev/null 2>&1; then
numfmt --to=iec --suffix=B -- "$1" 2>/dev/null && return 0
fi
printf "%sB\n" "$1"
}
local _doxz
# shellcheck disable=SC2329
_doxz()
{
command -v xz >/dev/null 2>&1 || {
disp E "The program 'xz' is not installed, aborting."
return 127
}
local verb=()
[[ $4 ]] && verb=('-v')
# Display a warning for this format
disp W "xz format is not suited for long term archiving."
disp I "See https://www.nongnu.org/lzip/xz_inadequate.html for details."
# Compress with xz (lzma2) - Deprecated
xz "${verb[@]}" --compress --keep "-$3" -T "$2" "$1"
return $?
}
local _dolz
# shellcheck disable=SC2329
_dolz()
{
local procopt=(--threads "$2")
local command=plzip
command -v plzip >/dev/null 2>&1 || {
command -v lzip >/dev/null 2>&1 || {
disp E "Program 'plzip' or 'lzip' are not installed, aborting."
return 127
}
command=lzip
local procopt=()
[[ $2 -gt 1 ]] &&
disp W "lzip doesn't support multithreading, falling back to 1 thread." &&
disp W "Consider installing plzip to obtain multithreading abilities."
}
local opt=()
[[ $4 ]] && opt=('-vv')
opt+=("${procopt[@]}")
# Compress with lzip (lzma)
$command "${opt[@]}" --keep "-$3" "$1"
return $?
}
local _dogz
# shellcheck disable=SC2329
_dogz()
{
local procopt=(--processes "$2")
local command=pigz
command -v pigz >/dev/null 2>&1 || {
command -v gzip >/dev/null 2>&1 || {
disp E "Programs 'pigz' or 'gzip' are not installed, aborting."
return 127
}
local command=gzip
local procopt=()
[[ $2 -gt 1 ]] &&
disp W "gzip doesn't support multithreading, falling back to 1 thread." &&
disp W "Consider installing pigz to obtain multithreading abilities."
}
local opt=()
[[ $4 ]] && opt=('--verbose')
opt+=("${procopt[@]}")
# Compress with gzip
$command "${opt[@]}" --keep "-$3" "$1"
return $?
}
local _dobz2
# shellcheck disable=SC2329
_dobz2()
{
local procopt=("-p$2")
local command=pbzip2
command -v pbzip2 >/dev/null 2>&1 || {
command -v bzip2 >/dev/null 2>&1 || {
disp E "The program 'pbzip2' or 'bzip2' are not installed, aborting."
return 127
}
local command=bzip2
local procopt=()
[[ $2 -gt 1 ]] &&
disp W "bzip2 doesn't support multithreading, falling back to 1 thread." &&
disp W "Consider installing pbzip2 to obtain multithreading abilities."
}
local opt=()
[[ $4 ]] && opt=('-v')
opt+=("${procopt[@]}")
# Compress with bz2
$command "${opt[@]}" --compress --keep "-$3" "$1"
return $?
}
local _dolzo
# shellcheck disable=SC2329
_dolzo()
{
command -v lzop >/dev/null 2>&1 || {
disp E "The program 'lzop' is not installed, aborting."
return 127
}
local verb=()
[[ $4 ]] && verb=('-v')
[[ $2 -gt 1 ]] && disp W "lzop doesn't support multithreading, falling back to 1 thread."
# Compress with lzo
lzop "${verb[@]}" --keep "-$3" "$1"
return $?
}
local PARSED
PARSED=$(getopt -o hdf:Kp:vq123456789 --long help,delete,format:,keep-all,parallel:,verbose,quiet --name "taz" -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [ $? -ne 0 ]; then
disp E "Invalid options, use \"taz --help\" to display usage."
return 1
fi
# 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=""
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
local compforms=()
local _f _list
while true; do
case "$1" in
-h|--help)
printf "taz: archive all files of a directory.\n\n"
printf "Usage: taz [option] [--parallel=<n|auto>] [--format=<format>] [directory1 ... directoryN]\n\n"
printf "Options:\n"
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 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"
printf "\t-q, --quiet\tDisplay less messages (only errors and warnings)\n"
printf "\t-1, .., -9\tCompression level to use [1=fast/biggest, 9=slow/smallest]\n\n"
printf "Supported archive format:\n"
printf "\tParam.| programs | Algo. | Description\n"
printf "\t------+---------------+-------+----------------------------------------\n"
printf "\t lz | plzip, lzip | lzma | Safe efficient default format\n"
printf "\t xz | xz | lzma2 | Unsafe, not for long term\n"
printf "\t bz2 | pbzip2, bzip2 | bzip2 | Historical but less efficient than lz\n"
printf "\t gz | pigz, gzip | lz77 | Historical, safe, fast\n"
printf "\t lzo | lzop | lzo | Very fast but no multithread\n"
printf "\t tar | tar | tar | No compression\n"
printf "\n"
return 0
;;
-d|--delete)
willrm=1
shift
;;
-f|--format)
# Accept comma/space separated lists and repeated -f flags.
# "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)
keepall=1
shift
;;
-p|--parallel)
nproc=$2
shift 2
;;
-v|--verbose)
verbose=1
shift
;;
-q|--quiet)
quiet=1
shift
;;
-[1-9])
complevel="${1#-}"
shift
;;
--)
shift
break
;;
*)
disp E "Invalid option, use \"taz --help\" to display options list"
return 1
;;
esac
done
# The remaining arguments after -- are the files/directories to process,
# "." is used if none is given
local FILES=("$@")
[[ ${#FILES[@]} -eq 0 ]] && FILES=(".")
# 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).
# shellcheck disable=SC2034 # read by disp
[[ -n $quiet ]] && local QUIET=1
# Fall back to the configured (or built-in) default format list.
if [[ ${#compforms[@]} -eq 0 ]]; then
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 " <fmt> " 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 "* ]] || {
disp E "Unsupported format '${_f}' (supported: lz xz bz2 gz lzo tar)."
return 1
}
done
# Backward compatibility: 0 previously meant auto-detect.
[[ $nproc == 0 ]] && nproc=auto
if [[ $nproc == auto ]]; then
nproc=$(_taz_detect_cpus)
elif [[ ! $nproc =~ ^[1-9][0-9]*$ ]]; then
disp E "Invalid value for --parallel: '$nproc' (expected auto or a positive integer)."
return 1
fi
# 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.<ext>":
# - 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" "${tar_src[@]}"; then
disp E "tar file creation failed, skipping to next item."
rm -f -- "$item.tar"
continue
fi
donetar=1
fi
# File actually handed to the compressors
local fname=$item
[[ $donetar -gt 0 ]] && fname=$item.tar
# Compress the archive with every requested format; the smallest
# resulting file is kept and the others are discarded (unless --keep-all).
local candidates=() fmt exec_code aborted=0 tar_requested=0
for fmt in "${compforms[@]}"; do
if [[ $fmt == tar ]]; then
tar_requested=1
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<fmt>" 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
0)
candidates+=("$fname.${_taz_ext[$fmt]}")
;;
127)
disp E "Compression program for '${fmt}' unavailable, aborting."
aborted=1
break
;;
*)
disp W "Compression with '${fmt}' failed, skipping this format."
;;
esac
done
# Remove partial output produced before an unrecoverable abort.
local c
if [[ $aborted -eq 1 ]]; then
for c in "${candidates[@]}"; do
[[ $c != "$fname" ]] && rm -f -- "$c"
done
[[ $donetar -gt 0 ]] && rm -f -- "$fname"
return 127
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
[[ -f $c ]] || continue
sz=$(stat -c%s -- "$c" 2>/dev/null) || continue
_csize["$c"]=$sz
if [[ $winner_size -lt 0 || $sz -lt $winner_size ]]; then
winner_size=$sz
winner=$c
fi
done
if [[ -z $winner ]]; then
disp E "No archive could be produced for ${item}, skipping to next item."
[[ $donetar -gt 0 ]] && rm -f -- "$fname"
continue
fi
# In verbose mode, list every produced archive with its size and how
# much larger it is than the smallest one that will be kept.
if [[ -n $verbose && ${#_csize[@]} -gt 0 ]]; then
disp I "\t Produced archives:"
for c in "${candidates[@]}"; do
[[ -n ${_csize[$c]+x} ]] || continue
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 ))"))"
fi
done
fi
# Discard every archive but the smallest, unless --keep-all was given.
# Either way, drop the intermediate tar when it was not itself a
# requested output format.
if [[ -z $keepall ]]; then
for c in "${candidates[@]}"; do
[[ $c != "$winner" ]] && rm -f -- "$c"
done
fi
[[ $donetar -gt 0 && $tar_requested -eq 0 ]] && rm -f -- "$fname"
if [[ -n $keepall ]]; then
[[ ${#compforms[@]} -gt 1 ]] &&
disp I "\t Kept all produced archives (smallest: ${winner}, $(_taz_human "$winner_size"))."
else
[[ ${#compforms[@]} -gt 1 ]] &&
disp I "\t Kept smallest archive: ${winner} ($(_taz_human "$winner_size"))."
fi
if [[ $willrm ]]; then
disp I "\t Deleting original source as asked... "
rm -r -- "$item"
fi
done
}
export -f taz
# ------------------------------------------------------------------------------
# EOF