From a60927da4eafc2971080753692cb64b2c07fbdf6 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 16:16:53 -0700 Subject: [PATCH 1/7] fix(security): confine configured copies to worktree paths (12106) --- lib/copy.sh | 148 +++++++++++++++++++++++++++++++---------- tests/copy_safety.bats | 77 +++++++++++++++++++++ 2 files changed, 191 insertions(+), 34 deletions(-) diff --git a/lib/copy.sh b/lib/copy.sh index 3e83e26..c3e9340 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -15,6 +15,62 @@ _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 + while [ -n "$remaining" ]; do + component="${remaining%%/*}" + case "$remaining" in + */*) remaining="${remaining#*/}" ;; + *) remaining="" ;; + esac + case "$component" in + ""|.) continue ;; + .git) 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 + [ -L "$dest" ] && return 1 + if [ -d "$src" ] && [ ! -L "$src" ]; then + mkdir -p "$dest" || return 1 + while IFS= read -r -d '' child; do + name="${child##*/}" + [ "$name" = .git ] && continue + _copy_tree_entry "$child" "$dest/$name" || return 1 + done < <(find "$src" -mindepth 1 -maxdepth 1 -print0) + else + [ -d "$dest" ] && return 1 + staged=$(mktemp "${dest%/*}/.gtr-copy.XXXXXX") || return 1 + if cp -P -- "$src" "$staged" && mv -f -- "$staged" "$dest"; then + return 0 + fi + rm -f "$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 +132,47 @@ 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" -name .git -prune -exec rm -rf -- {} + + 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 +188,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 +204,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 +217,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 +242,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" @@ -459,10 +534,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 +553,11 @@ _selective_copy_dir() { continue fi - if ! _fast_copy_dir "$child_path" "$dest_dir/"; then + case "$child_name" in .git) continue ;; esac + _copy_components_safe "$dst_root" "$child_rel" || return 1 + 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 +655,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 +688,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 +707,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..93733b6 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -505,3 +505,80 @@ 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 ] +} From 86862ddd72e937e73cb11f33539401eb919219e1 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 16:20:42 -0700 Subject: [PATCH 2/7] fix(copy): continue exclusions around skipped destination links --- lib/copy.sh | 6 +++++- tests/copy_safety.bats | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/copy.sh b/lib/copy.sh index c3e9340..e1ed386 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -20,6 +20,7 @@ _is_unsafe_path() { _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 @@ -554,7 +555,10 @@ _selective_copy_dir() { fi case "$child_name" in .git) continue ;; esac - _copy_components_safe "$dst_root" "$child_rel" || return 1 + 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 diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index 93733b6..72605b4 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -582,3 +582,23 @@ teardown() { [ "$(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 ] +} From 8c18413805201959524245e31328351ffeaaa24c Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 16:28:42 -0700 Subject: [PATCH 3/7] fix(copy): preserve permissions during staged replacements --- lib/copy.sh | 2 +- tests/copy_safety.bats | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/copy.sh b/lib/copy.sh index e1ed386..b9e0099 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -64,7 +64,7 @@ _copy_tree_entry() { else [ -d "$dest" ] && return 1 staged=$(mktemp "${dest%/*}/.gtr-copy.XXXXXX") || return 1 - if cp -P -- "$src" "$staged" && mv -f -- "$staged" "$dest"; then + if cp -pP -- "$src" "$staged" && mv -f -- "$staged" "$dest"; then return 0 fi rm -f "$staged" diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index 72605b4..4212074 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -602,3 +602,15 @@ teardown() { [ ! -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 old > "$dst/tools/run" + _fast_copy_dir "$src/tools" "$dst/" + [ -x "$dst/tools/run" ] + [ "$("$dst/tools/run")" = executable ] +} From 9cf7eeb99f36f5586f7c6aed5746d853cefc6de2 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 16:31:26 -0700 Subject: [PATCH 4/7] fix(copy): preserve source modes within the caller's umask --- lib/copy.sh | 7 ++++--- tests/copy_safety.bats | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/copy.sh b/lib/copy.sh index b9e0099..ed2ca22 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -63,11 +63,12 @@ _copy_tree_entry() { done < <(find "$src" -mindepth 1 -maxdepth 1 -print0) else [ -d "$dest" ] && return 1 - staged=$(mktemp "${dest%/*}/.gtr-copy.XXXXXX") || return 1 - if cp -pP -- "$src" "$staged" && mv -f -- "$staged" "$dest"; then + staged=$(mktemp -d "${dest%/*}/.gtr-copy.XXXXXX") || return 1 + if cp -P -- "$src" "$staged/leaf" && mv -f -- "$staged/leaf" "$dest"; then + rmdir "$staged" return 0 fi - rm -f "$staged" + rm -rf -- "$staged" return 1 fi } diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index 4212074..aac6ea0 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -614,3 +614,15 @@ teardown() { [ -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 ] +} From 28807683bd837d1afe5df22b5b5d0bc01adcf28e Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 16:43:14 -0700 Subject: [PATCH 5/7] fix(copy): retain restrictive destination modes on replacement --- lib/copy.sh | 22 ++++++++++++++++++---- tests/copy_safety.bats | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/lib/copy.sh b/lib/copy.sh index ed2ca22..4b2adb7 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -52,7 +52,7 @@ _copy_discovery_records() { # 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 + 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 @@ -63,10 +63,24 @@ _copy_tree_entry() { 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" && mv -f -- "$staged/leaf" "$dest"; then - rmdir "$staged" - return 0 + 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 diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index aac6ea0..bd3c25f 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -609,7 +609,7 @@ teardown() { mkdir -p "$src/tools" "$dst/tools" printf '#!/bin/sh\necho executable\n' > "$src/tools/run" chmod +x "$src/tools/run" - echo old > "$dst/tools/run" + echo unrelated > "$dst/tools/existing" _fast_copy_dir "$src/tools" "$dst/" [ -x "$dst/tools/run" ] [ "$("$dst/tools/run")" = executable ] @@ -626,3 +626,35 @@ teardown() { 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 ] +} From 2cede9b0bfefaba5a32058cc4429e87b4ba97e77 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 16:49:34 -0700 Subject: [PATCH 6/7] fix(copy): protect case variants of Git control paths --- lib/copy.sh | 12 ++++++------ tests/copy_safety.bats | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/copy.sh b/lib/copy.sh index 4b2adb7..b552f48 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -29,7 +29,7 @@ _copy_components_safe() { esac case "$component" in ""|.) continue ;; - .git) return 1 ;; + .[gG][iI][tT]) return 1 ;; esac current="$current/$component" [ -L "$current" ] && return 1 @@ -58,7 +58,7 @@ _copy_tree_entry() { mkdir -p "$dest" || return 1 while IFS= read -r -d '' child; do name="${child##*/}" - [ "$name" = .git ] && continue + 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 @@ -182,7 +182,7 @@ _fast_copy_dir() { ;; esac # Never introduce Git control files, including nested repositories. - find "$staging" -name .git -prune -exec rm -rf -- {} + + find "$staging" -iname .git -prune -exec rm -rf -- {} + if mv -- "${staging:?}/${src##*/}" "$target"; then rmdir "$staging" else @@ -413,7 +413,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 @@ -445,7 +445,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 @@ -569,7 +569,7 @@ _selective_copy_dir() { continue fi - case "$child_name" in .git) continue ;; esac + 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 diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index bd3c25f..6ea5b7d 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -658,3 +658,21 @@ teardown() { [ "$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 ] +} From 2e333aadb7e19d0a1ef0788520c2e098ac647882 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 17:13:19 -0700 Subject: [PATCH 7/7] fix(copy): verify Git metadata removal before publishing clones --- lib/copy.sh | 8 +++++++- tests/copy_safety.bats | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/copy.sh b/lib/copy.sh index b552f48..7b264ac 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -182,7 +182,13 @@ _fast_copy_dir() { ;; esac # Never introduce Git control files, including nested repositories. - find "$staging" -iname .git -prune -exec rm -rf -- {} + + 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 diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index 6ea5b7d..8c31225 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -676,3 +676,36 @@ teardown() { _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" ] +}