## 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. ## (`$("$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 }