#!/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 # # Safety contract (mirrors safe-replace-dir.mjs): # - must be a non-empty path. # - must be a strict descendant of (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 " >&2 exit 1 fi safe_replace_dir "$1" "$2" "$3" || exit 1 fi