diff --git a/docs/configuration.md b/docs/configuration.md index 4f6211f..b45766c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -318,6 +318,11 @@ git gtr config add gtr.copy.excludeDirs "node_modules/.*" # Exclude all hid git gtr config add gtr.copy.excludeDirs "*/.cache" # Exclude .cache at any level ``` +Directory exclusions never descend through symlinks. An exclusion matching a +symlink itself removes only the link, including when the pattern ends in `/`; +the link target is preserved. Exclusions are skipped when the copied directory +or one of its path components is a symlink. + > [!WARNING] > Dependency directories may contain sensitive files (credentials, tokens, cached secrets). Always use `gtr.copy.excludeDirs` to exclude sensitive subdirectories if needed. diff --git a/lib/copy.sh b/lib/copy.sh index 1569821..3e83e26 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -307,6 +307,69 @@ _directory_exclude_suffix() { return 1 } +# Check each directory component before entering an exclusion root. +# Do not resolve symlinks: even an in-tree link can redirect a later removal. +# Usage: _exclude_root_is_safe +_exclude_root_is_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 + [ -d "$current" ] +} + +# Expand one glob component at a time so expansion never traverses symlinks. +# Runs inside the exclusion subshell with nullglob/dotglob enabled. +# Sets the caller's removed_any flag when at least one removal succeeds. +# Usage: _remove_directory_exclude_matches +_remove_directory_exclude_matches() { + local parent="$1" pattern="$2" component suffix="" matched_path name + component="${pattern%%/*}" + case "$pattern" in + */*) suffix="${pattern#*/}" ;; + esac + + # Empty components (repeated slashes) do not change the current directory. + if [ -z "$component" ]; then + [ -n "$suffix" ] && _remove_directory_exclude_matches "$parent" "$suffix" + return 0 + fi + + # Disable word splitting while retaining glob expansion, including spaces. + local IFS="" + # shellcheck disable=SC2086 + for matched_path in "$parent"/$component; do + name="${matched_path##*/}" + # Also reject glob spellings of dot, dot-dot, and Git metadata. + case "$name" in + .|..|.git) continue ;; + esac + if [ -n "$suffix" ]; then + if [ -d "$matched_path" ] && [ ! -L "$matched_path" ]; then + _remove_directory_exclude_matches "$matched_path" "$suffix" + fi + elif [ -e "$matched_path" ] || [ -L "$matched_path" ]; then + # A leaf symlink is safe to unlink; never append a slash to it. + if rm -rf -- "$matched_path" 2>/dev/null; then + removed_any=1 + fi + fi + done + return 0 +} + # Remove excluded subdirectories from a copied directory. # Supports patterns like "node_modules/.cache", "*/.cache", "node_modules/*", "*/.*" # Usage: _apply_directory_excludes @@ -338,30 +401,23 @@ _apply_directory_excludes() { continue fi - local exclude_old_pwd - exclude_old_pwd=$(pwd) - cd "$dest_parent/$dir_path" 2>/dev/null || continue - - local exclude_shopt_save - exclude_shopt_save="$(shopt -p dotglob 2>/dev/null || true)" - shopt -s dotglob 2>/dev/null || true + if ! _exclude_root_is_safe "$dest_parent" "$dir_path"; then + log_warn "Skipping excludes through unsafe directory path: $dir_path" + continue + fi local removed_any=0 - # shellcheck disable=SC2086 - for matched_path in $pattern_suffix; do - if [ -e "$matched_path" ]; then - # Never remove .git directory via exclude patterns - case "$matched_path" in - .git|.git/*) continue ;; - esac - if rm -rf "$matched_path" 2>/dev/null; then - removed_any=1 - fi - fi - done - - eval "$exclude_shopt_save" 2>/dev/null || true - cd "$exclude_old_pwd" || true + # Keep cwd and shell option changes local, and prevent inherited globstar + # from recursively expanding a component before we can check symlinks. + if ( + cd "$dest_parent/$dir_path" || exit 1 + shopt -s nullglob dotglob + shopt -u globstar 2>/dev/null || true + _remove_directory_exclude_matches "." "$pattern_suffix" + [ "$removed_any" -eq 1 ] + ); then + removed_any=1 + fi if [ "$removed_any" -eq 1 ]; then log_info "Excluded subdirectory $exclude_pattern" diff --git a/tests/cmd_create_integration.bats b/tests/cmd_create_integration.bats index 462efb3..dc2fd34 100644 --- a/tests/cmd_create_integration.bats +++ b/tests/cmd_create_integration.bats @@ -161,3 +161,23 @@ _cmd_create_capture_stdout() { cmd_create feature/deep --from HEAD --no-fetch --yes [ -d "$TEST_WORKTREES_DIR/feature-deep" ] } + +@test "cmd_create confines untrusted config exclusions around committed symlinks" { + # Both the repository and the external sentinel live in disposable fixtures. + local outside="$TEST_WORKTREES_DIR/external-fixture" + mkdir -p "$outside/cache" "$TEST_REPO/deps/nested" + echo "sentinel" > "$outside/cache/keep" + echo "remove" > "$TEST_REPO/deps/nested/remove" + ln -s "$outside" "$TEST_REPO/deps/nested/link" + git config -f "$TEST_REPO/.gtrconfig" --add copy.includeDirs deps + git config -f "$TEST_REPO/.gtrconfig" --add copy.excludeDirs 'deps/*/link/*/*' + git config -f "$TEST_REPO/.gtrconfig" --add copy.excludeDirs 'deps/nested/remove' + git add .gtrconfig deps + git commit -m "fixture copy configuration and symlink" --quiet + + cmd_create confined-copy --from HEAD --no-fetch --yes --no-hooks + + [ "$(cat "$outside/cache/keep")" = sentinel ] + [ -L "$TEST_WORKTREES_DIR/confined-copy/deps/nested/link" ] + [ ! -e "$TEST_WORKTREES_DIR/confined-copy/deps/nested/remove" ] +} diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index aebdcf5..a0e1c64 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -434,3 +434,74 @@ teardown() { [ "$(printf '%s\n' "$resolved" | grep -c '^./one/deep/cache$')" -eq 1 ] [ "$(printf '%s\n' "$resolved" | wc -l | tr -d ' ')" -eq 2 ] } + +@test "directory excludes never traverse copied symlinks" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" outside="$_test_tmpdir/outside" + mkdir -p "$src/deps/nested" "$dst" "$outside/cache" + echo "sentinel" > "$outside/cache/keep" + echo "remove" > "$src/deps/nested/remove" + ln -s "$outside" "$src/deps/nested/link" + + copy_directories "$src" "$dst" deps $'deps/*/link/*/*\ndeps/nested/remove' + + [ "$(cat "$outside/cache/keep")" = sentinel ] + [ -L "$dst/deps/nested/link" ] + [ ! -e "$dst/deps/nested/remove" ] +} + +@test "directory excludes reject symlinks in the exclusion root" { + _test_tmpdir=$(mktemp -d) + local dest="$_test_tmpdir/dest" outside="$_test_tmpdir/outside" + mkdir -p "$dest" "$outside/deps" + echo "sentinel" > "$outside/deps/keep" + ln -s "$outside" "$dest/vendor" + ln -s "$outside/deps" "$dest/deps" + + _apply_directory_excludes "$dest" vendor/deps 'vendor/deps/*' + _apply_directory_excludes "$dest" deps 'deps/*' + + [ "$(cat "$outside/deps/keep")" = sentinel ] +} + +@test "directory excludes can unlink leaf symlinks including dangling links" { + _test_tmpdir=$(mktemp -d) + local dest="$_test_tmpdir/dest" outside="$_test_tmpdir/outside" + mkdir -p "$dest/deps" "$outside" + echo "sentinel" > "$outside/keep" + ln -s "$outside" "$dest/deps/link" + ln -s "$outside/missing" "$dest/deps/dangling" + + _apply_directory_excludes "$dest" deps $'deps/link/\ndeps/dangling' + + [ ! -L "$dest/deps/link" ] + [ ! -L "$dest/deps/dangling" ] + [ "$(cat "$outside/keep")" = sentinel ] +} + +@test "directory excludes reject glob spellings of parent traversal and metadata" { + _test_tmpdir=$(mktemp -d) + local dest="$_test_tmpdir/dest" + mkdir -p "$dest/deps/.git" "$dest/sibling" + echo "metadata" > "$dest/deps/.git/config" + echo "sentinel" > "$dest/sibling/keep" + + _apply_directory_excludes "$dest" deps $'deps/[.][.]/*\ndeps/[.]git/*\ndeps/./*' + + [ "$(cat "$dest/sibling/keep")" = sentinel ] + [ "$(cat "$dest/deps/.git/config")" = metadata ] +} + +@test "directory excludes preserve spaces and shell options" { + _test_tmpdir=$(mktemp -d) + local dest="$_test_tmpdir/dest" + mkdir -p "$dest/deps/cache space/remove" "$dest/deps/cache space/keep" + local options_before + options_before=$(shopt -p nullglob dotglob globstar 2>/dev/null || true) + + _apply_directory_excludes "$dest" deps 'deps/cache space/remove' + + [ ! -e "$dest/deps/cache space/remove" ] + [ -d "$dest/deps/cache space/keep" ] + [ "$(shopt -p nullglob dotglob globstar 2>/dev/null || true)" = "$options_before" ] +}