set -euf -o pipefail
function print_version {
printf "\
ounce $(httm --version | cut -f2 -d' ')
" 1>&2
exit 0
}
function print_usage {
local ounce="\e[31mounce\e[0m"
local httm="\e[31mhttm\e[0m"
printf "\
$ounce is a wrapper script for $httm which snapshots the datasets of files opened by another programs.
$ounce only snapshots datasets when you have file changes outstanding, uncommitted to a snapshot already
(except in --trace mode).
When $ounce is invoked with only a target executable and its arguments, including paths, and no additional options,
$ounce will perform a snapshot check on those paths are that given as arguments to the target executable, and possibly wait
for a snapshot, before proceeding with execution of the target executable.
USAGE:
ounce [target executable] [argument1 argument2...] [path1 path2...]
ounce [OPTIONS]... [target executable] [argument1 argument2...] [path1 path2...]
ounce [--direct] [path1 path2...]
ounce [--give-priv]
ounce [--help]
OPTIONS:
--background:
Run the snapshot check in the background (because it's faster). Most practical for non-immediate file
modifications (perhaps for use with your \$EDITOR, but not 'rm'). $ounce is fast-ish (for a shell script)
but the time for ZFS to dynamically mount your snapshots will swamp the actual time to search
snapshots and execute any snapshot.
--trace:
Trace file 'open','openat','openat2', and 'fsync' calls of the $ounce target executable using \"strace\"
and eBPF/seccomp to determine when to trigger a snapshot check (because it's faster and more accurate).
Most practical for non-immediate file modifications (perhaps for use with your \$EDITOR, but not 'rm').
--direct:
Execute directly on path/s instead of wrapping a target executable.
--give-priv:
To use $ounce you will need privileges to snapshot ZFS datasets, and the preferred scheme is
\"zfs-allow\". Executing this option will give the current user snapshot privileges on all
imported pools via \"zfs-allow\" . NOTE: The user must execute --give-priv as an unprivileged user.
The user will be prompted later for elevated privileges.
--suffix [suffix name]:
User may specify a special suffix to use for the snapshots you take with $ounce. See the $httm help,
specifically \"httm --snap\", for additional information.
--utc:
User may specify UTC time for the timestamps listed on snapshot names.
--help:
Display this dialog.
--version:
Display script version.
" 1>&2
exit 1
}
function print_err_exit {
printf "%s\n" "Error: $*" 1>&2
exit 1
}
function log_info {
printf "%s\n" "$*" 2>&1 | logger -t ounce
}
function prep_trace {
[[ "$(uname)" == "Linux" ]] || print_err_exit "ounce --trace mode is only available on Linux. Sorry. PRs welcome."
[[ -n "$(
command -v strace
exit 0
)" ]] || print_err_exit "'strace' is required to execute 'ounce' in trace mode. Please check that 'strace' is in your path."
[[ -n "$(
command -v uuidgen
exit 0
)" ]] || print_err_exit "'uuidgen' is required to execute 'ounce' in trace mode. Please check that 'uuidgen' is in your path."
[[ -n "$(
command -v awk
exit 0
)" ]] || print_err_exit "'awk' is required to execute 'ounce' in trace mode. Please check that 'awk' is in your path."
[[ -n "$(
command -v logger
exit 0
)" ]] || print_err_exit "'logger' is required to execute 'ounce' in trace mode. Please check that 'logger' is in your path."
}
function prep_exec {
[[ -n "$(
command -v httm
exit 0
)" ]] || print_err_exit "'httm' is required to execute 'ounce'. Please check that 'httm' is in your path."
[[ -n "$(
command -v zfs
exit 0
)" ]] || print_err_exit "'zfs' is required to execute 'ounce'. Please check that 'zfs' is in your path."
}
function prep_sudo {
local sudo_program=""
local -a program_list=(
sudo
doas
pkexec
)
for p in "${program_list[@]}"; do
sudo_program="$(
command -v "$p"
exit 0
)"
[[ -z "$sudo_program" ]] || break
done
[[ -n "$sudo_program" ]] ||
print_err_exit "'sudo'-like program is required to execute 'ounce' without special zfs-allow permissions. Please check that 'sudo' (or 'doas' or 'pkexec') is in your path."
printf "$sudo_program"
}
function take_snap {
local filenames=""
local suffix=""
local utc=""
filenames="$1"
suffix="$2"
utc="$3"
[[ -z "$utc" ]] || httm "$utc" --snap="$suffix" "$filenames" 2>&1 | grep -v "dataset already exists" | logger -t ounce || true
[[ -n "$utc" ]] || httm --snap="$suffix" "$filenames" 2>&1 | grep -v "dataset already exists" | logger -t ounce || true
if [[ $? -ne 0 ]]; then
local sudo_program
sudo_program="$(prep_sudo)"
[[ -z "$utc" ]] || httm "$utc" --snap="$suffix" "$filenames" 2>&1 | grep -v "dataset already exists" | logger -t ounce || true
[[ -n "$utc" ]] || httm --snap="$suffix" "$filenames" 2>&1 | grep -v "dataset already exists" | logger -t ounce || true
[[ $? -eq 0 ]] ||
print_err_exit "'ounce' failed with a 'httm'/'zfs' snapshot error. Check you have the correct permissions to snapshot."
fi
}
function needs_snap {
local uncut_res=""
local filenames=""
filenames="$1"
uncut_res="$( httm --last-snap=no-ditto-inclusive --not-so-pretty "$filenames" 2>/dev/null)"
[[ $? -eq 0 ]] || uncut_res=""
cut -f1 -d: <<<"$uncut_res"
}
function give_priv {
local pools=""
local user_name=""
local sudo_program=""
user_name="$(whoami)"
sudo_program="$(prep_sudo)"
pools="$(get_pools)"
[[ "$user_name" != "root" ]] || print_err_exit "'ounce' must be executed as an unprivileged user to obtain their true user name. You will be prompted when additional privileges are needed. Quitting."
for p in $pools; do
"$sudo_program" zfs allow "$user_name" mount,snapshot "$p" || print_err_exit "'ounce' could not obtain privileges on $p. Quitting."
done
printf "\
Successfully obtained ZFS snapshot privileges on all the following pools:
$pools
" 1>&2
exit 0
}
function get_pools {
local pools=""
local sudo_program=""
sudo_program=$(prep_sudo)
pools="$(sudo zpool list -o name | grep -v -e "NAME")"
[[ -n "$pools" ]] ||
print_err_exit "'ounce' failed because it appears no pools were imported. Quitting."
printf "$pools"
}
function exec_trace {
local temp_pipe="$1"
stdbuf -i0 -o0 -e0 cat -u "$temp_pipe" |
stdbuf -i0 -o0 -e0 grep --line-buffered -v -e 'O_TMPFILE' -e '/dev/pts' -e 'socket:' |
stdbuf -i0 -o0 -e0 awk -F'[() \"<>]' '$2 ~ /fsync/ { print $4 } $2 ~ /open/ { print $7 }' |
while read -r file; do
canonical_path="$(
realpath "$file" 2>/dev/null
exit 0
)"
[[ -n "$canonical_path" ]] || continue
[[ -f "$canonical_path" || -d "$canonical_path" || -L "$canonical_path" ]] || continue
[[ -w "$canonical_path" ]] || continue
[[ "$canonical_path" != *.swp && "$canonical_path" != ~* && "$canonical_path" != *~ && "$canonical_path" != *.tmp ]] || \
[[ -n "$( find "$canonical_path" -not -newerct '-5 seconds' )" ]] || continue
files_need_snap="$(needs_snap "$canonical_path")"
[[ -n "$files_need_snap" ]] || continue
log_info "File which needed snapshot: $files_need_snap"
take_snap "$files_need_snap" "$snapshot_suffix" "$utc"
done
}
function exec_args {
local filenames_string=""
local files_need_snap=""
local -a filenames_array=()
local canonical_path=""
[[ $# -ge 1 ]] || return 0
for a; do
[[ $a != -* && $a != --* ]] || continue
canonical_path="$(
realpath "$a" 2>/dev/null
exit 0
)"
if [[ -z "$canonical_path" ]]; then
log_info "Could not determine canonical path for: $a"
continue
fi
if [[ ! -f "$canonical_path" && ! -d "$canonical_path" && ! -L "$canonical_path" ]]; then
log_info "Path is not a valid for an ounce snapshot: $canonical_path"
continue
fi
if [[ ! -w "$canonical_path" ]]; then
log_info "Path is not writable: $canonical_path"
continue
fi
filenames_array+=("$canonical_path")
done
[[ ${#filenames_array[@]} -ne 0 ]] || return 0
filenames_string="$( echo ${filenames_array[@]} )"
[[ -n "$filenames_string" ]] || print_err_exit "bash could not convert file names from array to string."
files_need_snap="$(needs_snap "$filenames_string")"
[[ -z "$files_need_snap" ]] || log_info "Files which need snapshot: $( echo $files_need_snap | tr '\n' ' ')"
[[ -z "$files_need_snap" ]] || take_snap "$files_need_snap" "$snapshot_suffix" "$utc"
}
function ounce_of_prevention {
prep_exec
local temp_pipe=""
local uuid=""
local program_name=""
local background=false
local trace=false
local direct=false
local -x snapshot_suffix="ounceSnapFileMount"
local -x utc=""
[[ $# -ge 1 ]] || print_usage
[[ "$1" != "-h" && "$1" != "--help" ]] || print_usage
[[ "$1" != "-V" && "$1" != "--version" ]] || print_version
[[ "$1" != "ounce" ]] || print_err_exit "'ounce' being called recursively. Quitting."
[[ "$1" != "--give-priv" ]] || give_priv
while [[ $# -ge 1 ]]; do
if [[ "$1" == "--suffix" ]]; then
shift
[[ $# -ge 1 ]] || print_err_exit "suffix is empty"
snapshot_suffix="$1"
shift
elif [[ "$1" == "--utc" ]]; then
utc="--utc"
shift
elif [[ "$1" == "--trace" ]]; then
prep_trace
trace=true
uuid="$(uuidgen)"
temp_pipe="/tmp/pipe.$uuid"
trap "[[ ! -p $temp_pipe ]] || rm -f $temp_pipe" EXIT
shift
elif [[ "$1" == "--background" ]]; then
background=true
shift
elif [[ "$1" == "--direct" ]]; then
direct=true
shift
break
else
program_name="$(
command -v "$1"
exit 0
)"
shift
break
fi
done
if $trace; then
local background_pid
[[ ! -p "$temp_pipe" ]] || rm -f "$temp_pipe"
mkfifo "$temp_pipe"
log_info "ounce session opened in trace mode with: $program_name"
exec_trace "$temp_pipe" &
background_pid="$!"
stdbuf -i0 -o0 -e0 strace -A -o "| stdbuf -i0 -o0 -e0 cat -u > $temp_pipe" -f -e open,openat,openat2 -y --seccomp-bpf -- "$program_name" "${@}"
wait "$background_pid"
elif $background; then
local background_pid
log_info "ounce session opened in background mode with: $program_name"
exec_args "${@}" &
background_pid="$!"
"$program_name" "${@}"
wait "$background_pid"
elif $direct; then
log_info "ounce session opened in direct mode"
exec_args "${@}"
else
[[ -x "$program_name" ]] || print_err_exit "'ounce' requires a valid executable name as the first argument."
log_info "ounce session opened in wrapper mode with: $program_name"
exec_args "${@}"
"$program_name" "${@}"
fi
log_info "ounce session closed"
}
ounce_of_prevention "${@}"