Files
ai-coding-skills/scripts/lib/safe-replace-dir.sh
T
2026-05-03 21:45:49 -05:00

103 lines
3.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# safe-replace-dir.sh — safely replace a directory within a safety-root boundary
#
# Provides safe_replace_dir() for sourcing, or run standalone:
# ./scripts/lib/safe-replace-dir.sh <source> <target> <safety_root>
#
# Safety contract (mirrors safe-replace-dir.mjs):
# - <target> must be a non-empty path.
# - <target> must be a strict descendant of <safety_root> (not equal to it).
# - Prints an error and returns/exits 1 if either constraint is violated.
#
# Usage (sourced):
# source "$(dirname "${BASH_SOURCE[0]}")/safe-replace-dir.sh"
# safe_replace_dir "$source" "$target" "$safety_root"
#
# Usage (standalone):
# ./scripts/lib/safe-replace-dir.sh /path/to/source /safe/root/target /safe/root
safe_replace_dir() {
local source=$1
local target=$2
local safety_root=$3
if [[ -z "$target" ]]; then
echo "safe_replace_dir: refusing to replace unsafe target: (empty string)" >&2
return 1
fi
# Resolve the real (symlink-resolved) safety root.
local abs_safety
abs_safety=$(cd "$safety_root" 2>/dev/null && pwd -P) || {
echo "safe_replace_dir: safety root does not exist: $safety_root" >&2
return 1
}
# Build an absolute lexical path for target's parent directory.
local target_parent target_base
target_base=$(basename "$target")
target_parent=$(dirname "$target")
# Make target_parent absolute without relying on cd (target may not exist yet).
if [[ "$target_parent" != /* ]]; then
target_parent="${PWD}/${target_parent}"
fi
# Walk up from target_parent to find the deepest existing directory,
# accumulating the non-existing path suffix as we go.
local suffix=""
local walk="$target_parent"
while [[ ! -d "$walk" ]]; do
local component
component=$(basename "$walk")
if [[ -z "$suffix" ]]; then
suffix="$component"
else
suffix="${component}/${suffix}"
fi
local next
next=$(dirname "$walk")
if [[ "$next" == "$walk" ]]; then
echo "safe_replace_dir: could not find existing ancestor for: $target" >&2
return 1
fi
walk="$next"
done
# Resolve the real path of the existing ancestor (follows symlinks).
local abs_parent
abs_parent=$(cd "$walk" && pwd -P) || {
echo "safe_replace_dir: could not resolve parent directory: $walk" >&2
return 1
}
# Reconstruct the full absolute target path.
local abs_target
if [[ -n "$suffix" ]]; then
abs_target="${abs_parent}/${suffix}/${target_base}"
else
abs_target="${abs_parent}/${target_base}"
fi
# Check that abs_target is strictly inside abs_safety
case "$abs_target" in
"${abs_safety}/"*) ;;
*)
echo "safe_replace_dir: refusing to replace target outside safety root: $target" >&2
return 1
;;
esac
rm -rf "$abs_target"
mkdir -p "$abs_target"
cp -R "${source}/." "$abs_target/"
}
# Allow standalone use
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
if [[ $# -ne 3 ]]; then
echo "Usage: $0 <source> <target> <safety_root>" >&2
exit 1
fi
safe_replace_dir "$1" "$2" "$3" || exit 1
fi