diff --git a/lib/copy.sh b/lib/copy.sh index 3e83e26..7b264ac 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -15,6 +15,78 @@ _is_unsafe_path() { return 1 } +# Check every relative component before a read, mkdir, or write. +# Roots are caller-owned; paths below them may come from the checkout. +_copy_components_safe() { + local current="$1" remaining="$2" component + _is_unsafe_path "$remaining" && return 1 + [ -L "$current" ] && return 1 + while [ -n "$remaining" ]; do + component="${remaining%%/*}" + case "$remaining" in + */*) remaining="${remaining#*/}" ;; + *) remaining="" ;; + esac + case "$component" in + ""|.) continue ;; + .[gG][iI][tT]) return 1 ;; + esac + current="$current/$component" + [ -L "$current" ] && return 1 + done + return 0 +} + +# Keep discovery records intact. The public pattern list format is line based, +# so names containing newlines are skipped rather than split into new paths. +_copy_discovery_records() { + local entry + while IFS= read -r -d '' entry; do + case "$entry" in + *$'\n'*) log_warn "Skipping copy path containing a newline"; continue ;; + esac + printf '%s\n' "$entry" + done +} + +# Merge without following existing destination links. Stage each leaf in its +# destination directory, then rename it so existing hard links are not written. +_copy_tree_entry() { + local src="$1" dest="$2" child name staged dest_mode="" + [ -L "$dest" ] && return 1 + if [ -d "$src" ] && [ ! -L "$src" ]; then + mkdir -p "$dest" || return 1 + while IFS= read -r -d '' child; do + name="${child##*/}" + case "$name" in .[gG][iI][tT]) continue ;; esac + _copy_tree_entry "$child" "$dest/$name" || return 1 + done < <(find "$src" -mindepth 1 -maxdepth 1 -print0) + else + [ -d "$dest" ] && return 1 + if [ -f "$dest" ]; then + dest_mode=$(stat -c '%a' "$dest" 2>/dev/null) || + dest_mode=$(stat -f '%Lp' "$dest" 2>/dev/null) || return 1 + fi + staged=$(mktemp -d "${dest%/*}/.gtr-copy.XXXXXX") || return 1 + if cp -P -- "$src" "$staged/leaf"; then + # Match cp's replacement semantics without writing the existing inode. + # Never chmod a staged symlink: it may point outside the worktree. + if [ -n "$dest_mode" ] && [ ! -L "$staged/leaf" ]; then + if ! chmod "$dest_mode" "$staged/leaf"; then + rm -rf -- "$staged" + return 1 + fi + fi + if mv -f -- "$staged/leaf" "$dest"; then + rmdir "$staged" + return 0 + fi + fi + rm -rf -- "$staged" + return 1 + fi +} + # Check if a path matches any exclude pattern # Usage: is_excluded "path" "excludes_newline_separated" # Returns: 0 if excluded, 1 if not @@ -76,38 +148,53 @@ merge_copy_patterns() { } # Copy a directory using CoW (copy-on-write) when available, falling back to standard cp. -# macOS APFS: cp -cRP (clone); Linux Btrfs/XFS: cp --reflink=auto -RP +# New directories use a private staging directory so failed clones cannot +# remove existing destination data. Existing trees merge with safe leaf writes. # Callers must guard the return value with `if` or `|| true` (set -e safe). # Usage: _fast_copy_dir src dest # Cached OS value for _fast_copy_dir; set on first call. _fast_copy_os="" _fast_copy_dir() { - local src="$1" dest="$2" - if [ -z "$_fast_copy_os" ]; then - _fast_copy_os=$(detect_os) + local src="$1" dest="$2" target staging os + target="${dest%/}/${src##*/}" + if [ -e "$target" ] || [ -L "$target" ] || [ ! -d "$src" ] || [ -L "$src" ]; then + _copy_tree_entry "$src" "$target" + return $? fi - local os="$_fast_copy_os" + staging=$(mktemp -d "${dest%/}/.gtr-copy.XXXXXX") || return 1 + os="${_fast_copy_os:-$(detect_os)}" + _fast_copy_os="$os" case "$os" in darwin) - # Try CoW clone first; if unsupported, fall back to regular copy - if cp -cRP "$src" "$dest" 2>/dev/null; then - return 0 + if ! cp -cRP -- "$src" "$staging/" 2>/dev/null; then + # Only this invocation's staging output is eligible for removal. + rm -rf -- "${staging:?}/${src##*/}" + cp -RP -- "$src" "$staging/" || { rm -rf -- "$staging"; return 1; } fi - # Clean up any partial clone output before fallback - local _clone_target - _clone_target="${dest%/}/$(basename "$src")" - if [ -e "$_clone_target" ]; then rm -rf "$_clone_target"; fi - cp -RP "$src" "$dest" ;; linux) - cp --reflink=auto -RP "$src" "$dest" + cp --reflink=auto -RP -- "$src" "$staging/" || { rm -rf -- "$staging"; return 1; } ;; *) - cp -RP "$src" "$dest" + cp -RP -- "$src" "$staging/" || { rm -rf -- "$staging"; return 1; } ;; esac + # Never introduce Git control files, including nested repositories. + find "$staging" -iname .git -prune -exec rm -rf -- {} + 2>/dev/null || true + local remaining_git + if ! remaining_git=$(find "$staging" -iname .git -print -quit 2>/dev/null) || + [ -n "$remaining_git" ]; then + rm -rf -- "$staging" + return 1 + fi + if mv -- "${staging:?}/${src##*/}" "$target"; then + rmdir "$staging" + else + rm -rf -- "$staging" + return 1 + fi } # Copy a single file to destination, handling exclusion, path preservation, and dry-run @@ -123,6 +210,11 @@ _copy_pattern_file() { # Remove leading ./ file="${file#./}" + if ! _copy_components_safe "." "$file"; then + log_warn "Skipping unsafe source path: $file" + return 1 + fi + # Skip if excluded is_excluded "$file" "$excludes" && return 1 @@ -134,6 +226,11 @@ _copy_pattern_file() { dest_file="$dst_root/$(basename "$file")" fi + if ! _copy_components_safe "$dst_root" "${dest_file#"$dst_root/"}"; then + log_warn "Skipping unsafe destination path: $file" + return 1 + fi + # Copy the file (or show what would be copied in dry-run mode) if [ "$dry_run" = "true" ]; then log_info "[dry-run] Would copy: $file" @@ -142,8 +239,8 @@ _copy_pattern_file() { local dest_dir dest_dir=$(dirname "$dest_file") - mkdir -p "$dest_dir" - if cp "$file" "$dest_file" 2>/dev/null; then + mkdir -p "$dest_dir" || return 1 + if _copy_tree_entry "$file" "$dest_file" 2>/dev/null; then log_info "Copied $file" return 0 else @@ -167,12 +264,12 @@ _expand_and_copy_pattern() { # won't match across the required '/' separator. For **/-prefixed patterns, # also search with the suffix alone so root-level files are found. local _find_results - _find_results=$(find . -path "./$pattern" -type f 2>/dev/null || true) + _find_results=$(find . -path "./$pattern" -type f -print0 2>/dev/null | _copy_discovery_records) case "$pattern" in \*\*/*) local _suffix="${pattern#\*\*/}" local _root_results - _root_results=$(find . -maxdepth 1 -path "./$_suffix" -type f 2>/dev/null || true) + _root_results=$(find . -maxdepth 1 -path "./$_suffix" -type f -print0 2>/dev/null | _copy_discovery_records) if [ -n "$_root_results" ]; then if [ -n "$_find_results" ]; then _find_results="$_find_results"$'\n'"$_root_results" @@ -322,7 +419,7 @@ _exclude_root_is_safe() { esac case "$component" in ""|.) continue ;; - .git) return 1 ;; + .[gG][iI][tT]) return 1 ;; esac current="$current/$component" [ -L "$current" ] && return 1 @@ -354,7 +451,7 @@ _remove_directory_exclude_matches() { name="${matched_path##*/}" # Also reject glob spellings of dot, dot-dot, and Git metadata. case "$name" in - .|..|.git) continue ;; + .|..|.[gG][iI][tT]) continue ;; esac if [ -n "$suffix" ]; then if [ -d "$matched_path" ] && [ ! -L "$matched_path" ]; then @@ -459,10 +556,12 @@ _selective_copy_dir() { local dir_path="$1" dst_root="$2" excludes="$3" local dest_dir="$dst_root/$dir_path" + _copy_components_safe "." "$dir_path" || return 1 + _copy_components_safe "$dst_root" "$dir_path" || return 1 mkdir -p "$dest_dir" || return 1 local find_results - find_results=$(find "$dir_path" -mindepth 1 -maxdepth 1 2>/dev/null || true) + find_results=$(find "$dir_path" -mindepth 1 -maxdepth 1 -print0 2>/dev/null | _copy_discovery_records) local child_path child_name child_rel while IFS= read -r child_path; do @@ -476,7 +575,14 @@ _selective_copy_dir() { continue fi - if ! _fast_copy_dir "$child_path" "$dest_dir/"; then + case "$child_name" in .[gG][iI][tT]) continue ;; esac + if ! _copy_components_safe "$dst_root" "$child_rel"; then + log_warn "Skipping unsafe destination path: $child_rel" + continue + fi + if [ -d "$child_path" ] && [ ! -L "$child_path" ]; then + _selective_copy_dir "$child_rel" "$dst_root" "$excludes" || return 1 + elif ! _fast_copy_dir "$child_path" "$dest_dir/"; then return 1 fi done </dev/null || true) + find_results=$(find . -maxdepth 1 -type d -name "$pattern" -print0 2>/dev/null | _copy_discovery_records) if [ -z "$find_results" ]; then [ "${#recursive_expr[@]}" -gt 0 ] && recursive_expr+=("-o") recursive_expr+=("-name" "$pattern") @@ -574,11 +680,11 @@ $dir_patterns EOF if [ "${#bounded_expr[@]}" -gt 0 ]; then - find . -maxdepth "$bounded_max_depth" -type d \( "${bounded_expr[@]}" \) 2>/dev/null || true + find . -maxdepth "$bounded_max_depth" -type d \( "${bounded_expr[@]}" \) -print0 2>/dev/null | _copy_discovery_records fi if [ "${#recursive_expr[@]}" -gt 0 ]; then - find . -type d \( "${recursive_expr[@]}" \) 2>/dev/null || true + find . -type d \( "${recursive_expr[@]}" \) -print0 2>/dev/null | _copy_discovery_records fi cd "$old_pwd" || return 1 @@ -607,6 +713,10 @@ _copy_resolved_directories() { [ -z "$dir_path" ] && continue dir_path="${dir_path#./}" + if ! _copy_components_safe "." "$dir_path" || ! _copy_components_safe "$dst_root" "$dir_path"; then + log_warn "Skipping unsafe directory path: $dir_path" + continue + fi is_excluded "$dir_path" "$excludes" && continue [ ! -d "$dir_path" ] && continue @@ -622,18 +732,13 @@ _copy_resolved_directories() { mkdir -p "$dest_parent" - # Copy directory using CoW when available (preserves symlinks as symlinks) - if _has_subdir_excludes "$dir_path" "$excludes"; then - if _selective_copy_dir "$dir_path" "$dst_root" "$excludes"; then - log_info "Copied directory $dir_path" - copied_count=$((copied_count + 1)) - else - log_warn "Failed to copy directory $dir_path" - fi - elif _fast_copy_dir "$dir_path" "$dest_parent/"; then + if { if _has_subdir_excludes "$dir_path" "$excludes"; then + _selective_copy_dir "$dir_path" "$dst_root" "$excludes" + else + _fast_copy_dir "$dir_path" "$dest_parent/" + fi; }; then log_info "Copied directory $dir_path" copied_count=$((copied_count + 1)) - _apply_directory_excludes "$dst_root" "$dir_path" "$excludes" else log_warn "Failed to copy directory $dir_path" fi diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index a0e1c64..8c31225 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -505,3 +505,207 @@ teardown() { [ -d "$dest/deps/cache space/keep" ] [ "$(shopt -p nullglob dotglob globstar 2>/dev/null || true)" = "$options_before" ] } + +@test "broad includes never overwrite worktree Git pointers or nested metadata" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/nested/.git" "$dst" + echo source > "$src/.git" + echo secret > "$src/nested/.git/config" + echo destination > "$dst/.git" + copy_patterns "$src" "$dst" '*' '' + copy_directories "$src" "$dst" nested '' + [ "$(cat "$dst/.git")" = destination ] + [ ! -e "$dst/nested/.git" ] +} + +@test "expanded traversal cannot copy outside either root" { + _test_tmpdir=$(mktemp -d) + mkdir -p "$_test_tmpdir/src" "$_test_tmpdir/dst" + echo secret > "$_test_tmpdir/victim" + cd "$_test_tmpdir/src" + ! _copy_pattern_file '../victim' "$_test_tmpdir/dst" '' true false + ! _copy_pattern_file '././../victim' "$_test_tmpdir/dst" '' true false + [ "$(cat "$_test_tmpdir/victim")" = secret ] +} + +@test "file copies reject source links and destination leaf and ancestor links" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" outside="$_test_tmpdir/outside" + mkdir -p "$src/vendor" "$dst" "$outside" + echo private > "$outside/secret" + echo replacement > "$src/vendor/secret" + ln -s "$outside/secret" "$src/leak" + ln -s "$outside/secret" "$dst/leaf" + ln -s "$outside" "$dst/vendor" + echo replacement > "$src/leaf" + copy_patterns "$src" "$dst" $'leak\nleaf\nvendor/*' '' + [ ! -e "$dst/leak" ] + [ "$(cat "$outside/secret")" = private ] +} + +@test "directory copies reject destination links at every depth" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" outside="$_test_tmpdir/outside" + mkdir -p "$src/vendor/cache" "$dst/vendor" "$outside" + echo private > "$outside/secret" + echo replacement > "$src/vendor/cache/secret" + ln -s "$outside" "$dst/vendor/cache" + copy_directories "$src" "$dst" vendor '' + [ "$(cat "$outside/secret")" = private ] +} + +@test "newline discovery never reconstructs a parent traversal record" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/"$'safe\n..' "$dst" + echo private > "$_test_tmpdir/secret" + copy_directories "$src" "$dst" '*' '' + [ ! -e "$dst/secret" ] + cd "$src" + local count + echo value > "$src/"$'safe\n..'"/file" + count=$(_expand_and_copy_pattern '**/*' "$dst" '' true false 0) + [ "$count" -eq 0 ] +} + +@test "directory merge preserves destination-only files and hard-linked external data" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/cache" "$dst/cache" + echo private > "$_test_tmpdir/secret" + ln "$_test_tmpdir/secret" "$dst/cache/value" + echo replacement > "$src/cache/value" + echo keep > "$dst/cache/keep" + copy_directories "$src" "$dst" cache '' + [ "$(cat "$dst/cache/keep")" = keep ] + [ "$(cat "$_test_tmpdir/secret")" = private ] + [ "$(cat "$dst/cache/value")" = replacement ] +} + +@test "failed clone fallback only removes its own staging output" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/cache" "$dst" + echo value > "$src/cache/file" + _fast_copy_os=darwin + cp() { + if [ "$1" = -cRP ]; then + mkdir -p "$4/cache" + echo partial > "$4/cache/partial" + return 1 + fi + command cp "$@" + } + _fast_copy_dir "$src/cache" "$dst/" + [ "$(cat "$dst/cache/file")" = value ] + [ ! -e "$dst/cache/partial" ] + [ "$(find "$dst" -name '.gtr-copy.*' | wc -l | tr -d ' ')" -eq 0 ] +} + +@test "staged directory merges preserve executable file permissions" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/tools" "$dst/tools" + printf '#!/bin/sh\necho executable\n' > "$src/tools/run" + chmod +x "$src/tools/run" + echo unrelated > "$dst/tools/existing" + _fast_copy_dir "$src/tools" "$dst/" + [ -x "$dst/tools/run" ] + [ "$("$dst/tools/run")" = executable ] +} + +@test "staged file copies respect a restrictive umask" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src" "$dst" + echo secret > "$src/.env" + chmod 644 "$src/.env" + (umask 077; copy_patterns "$src" "$dst" .env '') + local mode + mode=$(stat -c '%a' "$dst/.env" 2>/dev/null) || mode=$(stat -f '%Lp' "$dst/.env") + [ "$mode" = 600 ] +} + +@test "staged replacements retain restrictive existing destination permissions" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src" "$dst" + echo secret > "$src/.env" + chmod 644 "$src/.env" + echo old > "$dst/.env" + chmod 600 "$dst/.env" + (umask 022; copy_patterns "$src" "$dst" .env '') + local mode + mode=$(stat -c '%a' "$dst/.env" 2>/dev/null) || mode=$(stat -f '%Lp' "$dst/.env") + [ "$mode" = 600 ] + [ "$(cat "$dst/.env")" = secret ] +} + +@test "preserved directory symlinks never chmod their external referents" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" outside="$_test_tmpdir/secret" + mkdir -p "$src/cache" "$dst/cache" + echo private > "$outside" + chmod 600 "$outside" + ln -s "$outside" "$src/cache/link" + echo old > "$dst/cache/link" + chmod 644 "$dst/cache/link" + copy_directories "$src" "$dst" cache '' + [ -L "$dst/cache/link" ] + local mode + mode=$(stat -c '%a' "$outside" 2>/dev/null) || mode=$(stat -f '%Lp' "$outside") + [ "$mode" = 600 ] + [ "$(cat "$outside")" = private ] +} + +@test "copy guards reject case variants of Git metadata on every platform" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/nested/.GiT" "$dst" + echo source > "$src/.GIT" + echo metadata > "$src/nested/.GiT/config" + echo destination > "$dst/.git" + copy_patterns "$src" "$dst" '.GIT' '' + copy_directories "$src" "$dst" nested '' + [ "$(cat "$dst/.git")" = destination ] + [ ! -e "$dst/.GIT" ] || [ "$(cat "$dst/.GIT")" = destination ] + [ ! -e "$dst/nested/.GiT" ] + mkdir -p "$dst/nested/.GiT" + echo keep > "$dst/nested/.GiT/config" + _apply_directory_excludes "$dst" nested 'nested/.GiT' + [ "$(cat "$dst/nested/.GiT/config")" = keep ] +} + +@test "directory staging is rejected if Git metadata removal fails" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" mock_bin="$_test_tmpdir/bin" + mkdir -p "$src/cache/.git" "$dst" "$mock_bin" + echo metadata > "$src/cache/.git/config" + cat > "$mock_bin/rm" <<'SH' +#!/usr/bin/env bash +for arg in "$@"; do + case "$arg" in */.git) exit 1 ;; esac +done +exec /bin/rm "$@" +SH + chmod +x "$mock_bin/rm" + PATH="$mock_bin:$PATH" run _fast_copy_dir "$src/cache" "$dst/" + [ "$status" -ne 0 ] + [ ! -e "$dst/cache" ] + [ "$(find "$dst" -name '.gtr-copy.*' | wc -l | tr -d ' ')" -eq 0 ] +} + +@test "directory staging is rejected when metadata verification cannot scan it" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/cache" "$dst" + echo value > "$src/cache/file" + find() { + case "$*" in *'-print -quit'*) return 1 ;; esac + command find "$@" + } + run _fast_copy_dir "$src/cache" "$dst/" + [ "$status" -ne 0 ] + [ ! -e "$dst/cache" ] +}