Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
100 changes: 78 additions & 22 deletions lib/copy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dest_parent> <dir_path>
_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 <parent> <pattern>
_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 <dest_parent> <dir_path> <excludes>
Expand Down Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions tests/cmd_create_integration.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
}
71 changes: 71 additions & 0 deletions tests/copy_safety.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
}
Loading