pave

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