fs.bash
## fs.bash: primitives for files, symlinks, and managed blocks.
##
## Every primitive reports a change only when it made one, refuses (rather than clobbers)
## anything that isn't the kind of thing it manages, and replaces files by temp + rename,
## so there's never a half-written file.
## They never rely on errexit, so they behave the same inside chains, outside them, and in conditions.
## _pave_need_abs fails unless every argument is an absolute path.
## (Relative paths would resolve against the goals dir, which is never what a goal means.)
_pave_need_abs() {
local p
for p in "$@"; do
if [[ $p != /* ]]; then
_pave_error "'$p' is not an absolute path"
return 1
fi
done
}
## _pave_read_exact reads a whole file into the named variable, trailing newlines included.
## (`$(<file)` would strip them, and then a file differing only in those would read as converged.)
##
## - @param $1 variable name.
## - @param $2 file path.
_pave_read_exact() {
if [ ! -r "$2" ]; then
_pave_error "$2 is not readable"
return 1
fi
# read returns 1 at end of file, which is the normal way for it to stop here.
IFS= read -r -d '' "$1" <"$2" || :
}
## _pave_replace atomically replaces a file with new content and mode.
##
## - @param $1 file path.
## - @param $2 content, written exactly.
## - @param $3 octal mode, or empty for the umask default.
_pave_replace() {
local path=$1 content=$2 mode=$3 tmp="$1.pave.$$"
if ! {
printf '%s' "$content" >"$tmp" &&
{ [ -z "$mode" ] || chmod "$mode" -- "$tmp"; } &&
mv -T -- "$tmp" "$path"
}; then
rm -f -- "$tmp"
return 1
fi
}
## ensure_file converges a regular file's whole content and mode.
## CONTENT is written followed by one newline,
## so both `"$(< somefile)"` and quoted literals come out as conventional text files.
## Parent directories are created as needed.
##
## - @param $1 absolute path.
## - @param $2 octal mode, e.g. 644.
## - @param $3 content.
ensure_file() {
if [ "$#" -ne 3 ]; then
_pave_error "usage: ensure_file PATH MODE CONTENT"
return 1
fi
local path=$1 mode=$2 want=$3$'\n' have='' havemode
_pave_need_abs "$path" || return 1
if [[ ! $mode =~ ^[0-7]{3,4}$ ]]; then
_pave_error "ensure_file $path: mode '$mode' is not octal"
return 1
fi
mode=$(printf '%o' "$((8#$mode))") # the same spelling `stat -c %a` uses
if [ -L "$path" ] || { [ -e "$path" ] && [ ! -f "$path" ]; }; then
_pave_error "$path exists and is not a regular file; refusing to replace it"
return 1
fi
if [ ! -e "$path" ]; then
mkdir -p -- "${path%/*}/" || return 1
_pave_replace "$path" "$want" "$mode" || return 1
changed "created $path"
return 0
fi
_pave_read_exact have "$path" || return 1
havemode=$(stat -c %a -- "$path") || return 1
if [ "$have" != "$want" ]; then
_pave_replace "$path" "$want" "$mode" || return 1
changed "rewrote $path"
elif [ "$havemode" != "$mode" ]; then
chmod "$mode" -- "$path" || return 1
changed "chmod $mode $path (was $havemode)"
fi
}
## ensure_link converges a symlink to have exactly the given target text.
## Parent directories are created as needed.
##
## - @param $1 target text (relative targets are relative to the link's directory, as always).
## - @param $2 absolute path of the link.
ensure_link() {
if [ "$#" -ne 2 ]; then
_pave_error "usage: ensure_link TARGET PATH"
return 1
fi
local target=$1 path=$2 have tmp
_pave_need_abs "$path" || return 1
if [ -L "$path" ]; then
have=$(readlink -- "$path") || return 1
[ "$have" = "$target" ] && return 0
tmp="$path.pave.$$"
if ! { ln -s -- "$target" "$tmp" && mv -T -- "$tmp" "$path"; }; then
rm -f -- "$tmp"
return 1
fi
changed "repointed $path -> $target (was $have)"
elif [ -e "$path" ]; then
_pave_error "$path exists and is not a symlink; refusing to replace it"
return 1
else
mkdir -p -- "${path%/*}/" || return 1
ln -s -- "$target" "$path" || return 1
changed "linked $path -> $target"
fi
}
## ensure_block converges one named, marker-delimited block of lines
## inside a file that otherwise belongs to someone else (a shell rc file, rc.local, ...).
## Exactly one block of that name ends up in the file, with the given content, at the given end.
## A block found elsewhere in the file is moved there.
## 'top' goes after a leading `#!` line, if the file has one.
## The file's mode is kept; a file that doesn't exist yet is created.
##
## - @param $1 absolute path of the file.
## - @param $2 placement: top or bottom.
## - @param $3 block name; becomes part of the marker lines.
## - @param $4 content: the lines between the markers.
ensure_block() {
if [ "$#" -ne 4 ]; then
_pave_error "usage: ensure_block FILE top|bottom NAME CONTENT"
return 1
fi
local file=$1 placement=$2 name=$3 content=$4
_pave_need_abs "$file" || return 1
case $placement in
top | bottom) ;;
*)
_pave_error "ensure_block $file: placement must be top or bottom, not '$placement'"
return 1
;;
esac
if [ -z "$name" ] || [[ $name == *$'\n'* ]]; then
_pave_error "ensure_block $file: block name must be one non-empty line"
return 1
fi
local begin="# >>> pave:$name >>>" end="# <<< pave:$name <<<"
local block=$begin$'\n'$content$'\n'$end
local -a others=()
local in_block=0 saw_block=0 line have='' mode=''
if [ -e "$file" ]; then
if [ -L "$file" ] || [ ! -f "$file" ]; then
_pave_error "$file exists and is not a regular file; refusing to replace it"
return 1
fi
_pave_read_exact have "$file" || return 1
mode=$(stat -c %a -- "$file") || return 1
while IFS= read -r line || [ -n "$line" ]; do
if [ "$in_block" -eq 0 ] && [ "$line" = "$begin" ]; then
in_block=1
saw_block=1
elif [ "$in_block" -eq 1 ]; then
[ "$line" = "$end" ] && in_block=0
else
others+=("$line")
fi
done <<<"${have%$'\n'}"
if [ "$in_block" -eq 1 ]; then
_pave_error "$file: block '$name' has a begin marker but no end marker (fix the file by hand)"
return 1
fi
fi
# `<<<` adds a newline, so an empty file reads as one empty line; that's not content.
if [ "${#others[@]}" -eq 1 ] && [ -z "${others[0]}" ]; then
others=()
fi
local -a lines=()
if [ "$placement" = top ]; then
if [ "${#others[@]}" -gt 0 ] && [[ ${others[0]} == '#!'* ]]; then
lines=("${others[0]}" "$block" "${others[@]:1}")
else
lines=("$block" "${others[@]}")
fi
else
lines=("${others[@]}" "$block")
fi
local want
printf -v want '%s\n' "${lines[@]}"
[ "$have" = "$want" ] && return 0
if [ ! -e "$file" ]; then
mkdir -p -- "${file%/*}/" || return 1
fi
_pave_replace "$file" "$want" "$mode" || return 1
if [ "$saw_block" -eq 1 ]; then
changed "updated block '$name' in $file"
else
changed "added block '$name' to $file"
fi
}
lib/fs.bash · 6.5 KiB · 7240887182