diff --git a/docs/configuration.md b/docs/configuration.md index 3514cd5..33a0702 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -346,6 +346,21 @@ 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 ``` +File exclusions in `gtr.copy.exclude` also apply inside directories selected by +`gtr.copy.includeDirs`, for both `git gtr new` and `git gtr copy`. They match +repository-relative file paths using the same rules as file-pattern copying. +For example, `*.pem` skips PEM files throughout an included directory. Files +are skipped before copying; existing excluded files in the destination are +left unchanged. File exclusions do not exclude directories with matching names; +use `gtr.copy.excludeDirs` to skip a whole directory. + +When exclusions require selective copying, new destination directories stay +owner-only until their contents are copied successfully. Missing ancestors of +nested directory selections stay private until all selected directories finish. +Their final permission bits come from the corresponding source directory, +restricted by your umask. Existing destination directory permissions are left +unchanged. + 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 @@ -362,7 +377,7 @@ it does not pin the contents of delegated scripts or dependencies. Copy path checks cannot isolate execution from other users who can mutate those paths. > [!WARNING] -> Dependency directories may contain sensitive files (credentials, tokens, cached secrets). Always use `gtr.copy.excludeDirs` to exclude sensitive subdirectories if needed. +> Dependency directories may contain sensitive files (credentials, tokens, cached secrets). Use `gtr.copy.excludeDirs` to exclude sensitive subdirectories and `gtr.copy.exclude` to exclude files. **Use cases:** diff --git a/lib/commands/copy.sh b/lib/commands/copy.sh index 53d999a..791f680 100644 --- a/lib/commands/copy.sh +++ b/lib/commands/copy.sh @@ -96,13 +96,13 @@ cmd_copy() { log_step "[dry-run] Would copy to: $dst_branch" [ -n "$patterns" ] && copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" "true" if [ -n "$resolved_dirs" ]; then - _copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" "true" || return 1 + _copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" "true" "$excludes" || return 1 fi else log_step "Copying to: $dst_branch" [ -n "$patterns" ] && copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" if [ -n "$resolved_dirs" ]; then - _copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" || return 1 + _copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" "false" "$excludes" || return 1 fi fi copied_any=1 diff --git a/lib/commands/create.sh b/lib/commands/create.sh index e57c17a..c279697 100644 --- a/lib/commands/create.sh +++ b/lib/commands/create.sh @@ -24,7 +24,7 @@ _post_create_copy() { if [ -n "$dir_includes" ]; then log_step "Copying directories..." - copy_directories "$repo_root" "$worktree_path" "$dir_includes" "$dir_excludes" || return 1 + copy_directories "$repo_root" "$worktree_path" "$dir_includes" "$dir_excludes" "false" "$excludes" || return 1 fi } diff --git a/lib/copy.sh b/lib/copy.sh index e2350d7..41c27e0 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -575,25 +575,45 @@ EOF return 1 } +# Print the permission bits for a new directory, restricted by the caller's umask. +# Usage: _copy_directory_mode +_copy_directory_mode() { + local source_mode copy_umask + source_mode=$(stat -c '%a' "$1" 2>/dev/null) || + source_mode=$(stat -f '%Lp' "$1" 2>/dev/null) || return 1 + copy_umask=$(umask) + printf '%o' "$((8#$source_mode & 0777 & ~8#$copy_umask))" +} + # Copy a directory one direct child at a time, skipping excluded child subtrees # before they are copied. Deeper excludes are still removed after copy. -# Usage: _selective_copy_dir +# New directories stay owner-only until complete, then receive the source's +# permission bits masked by the caller's umask. Existing directory modes stay intact. +# Usage: _selective_copy_dir [file_excludes] _selective_copy_dir() { local dir_path="$1" dst_root="$2" excludes="$3" + local file_excludes="${4:-}" 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 final_mode="" + if [ ! -d "$dest_dir" ]; then + final_mode=$(_copy_directory_mode "$dir_path") || return 1 + # Explicit owner write access also permits populating read-only sources. + # Do not use -p here: an existing directory must never be reclassified as new. + mkdir -m 700 "$dest_dir" || return 1 + fi local find_results - find_results=$(find "$dir_path" -mindepth 1 -maxdepth 1 -print0 2>/dev/null | _copy_discovery_records) + find_results=$(set -o pipefail; find "$dir_path" -mindepth 1 -maxdepth 1 -print0 2>/dev/null | + _copy_discovery_records) || return 1 local child_path child_name child_rel while IFS= read -r child_path; do [ -z "$child_path" ] && continue - child_name=$(basename "$child_path") + child_name="${child_path##*/}" child_rel="$dir_path/$child_name" if is_excluded "$child_rel" "$excludes" || is_excluded "$child_rel/" "$excludes"; then @@ -607,15 +627,24 @@ _selective_copy_dir() { 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 + _selective_copy_dir "$child_rel" "$dst_root" "$excludes" "$file_excludes" || return 1 + else + # File exclusions use repo-relative paths, just like copy_patterns. + # Skip before copying; never remove pre-existing destination files. + is_excluded "$child_rel" "$file_excludes" && continue + _fast_copy_dir "$child_path" "$dest_dir/" || return 1 fi done < "$TEST_REPO/vendor/root.pem" + echo fixture > "$TEST_REPO/vendor/sub/nested.pem" + echo keep > "$TEST_REPO/vendor/sub/keep.txt" + git config gtr.copy.includeDirs vendor + git config gtr.copy.exclude '*.pem' + + run "$PROJECT_ROOT/bin/gtr" copy --all --dry-run + [ "$status" -eq 0 ] + [ ! -e "$TEST_WORKTREES_DIR/copy-target/vendor" ] + [ ! -e "$TEST_WORKTREES_DIR/copy-target-2/vendor" ] + + run "$PROJECT_ROOT/bin/gtr" copy --all + [ "$status" -eq 0 ] + local target + for target in copy-target copy-target-2; do + [ "$(cat "$TEST_WORKTREES_DIR/$target/vendor/sub/keep.txt")" = keep ] + [ ! -e "$TEST_WORKTREES_DIR/$target/vendor/root.pem" ] + [ ! -e "$TEST_WORKTREES_DIR/$target/vendor/sub/nested.pem" ] + done +} + # ── --all flag ─────────────────────────────────────────────────────────────── @test "cmd_copy --all copies to all worktrees" { diff --git a/tests/cmd_create_integration.bats b/tests/cmd_create_integration.bats index 81150d1..87f612c 100644 --- a/tests/cmd_create_integration.bats +++ b/tests/cmd_create_integration.bats @@ -288,3 +288,35 @@ _cmd_create_capture_stdout() { [ ! -e "$TEST_WORKTREES_DIR/normalized-copy/vendor/cache" ] [ ! -e "$TEST_WORKTREES_DIR/normalized-copy/cache" ] } + +@test "cmd_create applies file exclusions inside ignored includeDirs" { + mkdir -p "$TEST_REPO/vendor/sub" "$TEST_REPO/vendor/cache" + echo fixture > "$TEST_REPO/vendor/root.pem" + echo fixture > "$TEST_REPO/vendor/sub/nested.pem" + echo keep > "$TEST_REPO/vendor/sub/keep.txt" + echo excluded > "$TEST_REPO/vendor/cache/marker" + echo vendor/ > "$TEST_REPO/.gitignore" + git config -f "$TEST_REPO/.gtrconfig" copy.exclude '*.pem' + git config -f "$TEST_REPO/.gtrconfig" copy.excludeDirs vendor/cache/ + git add .gitignore .gtrconfig + git commit -m "fixture copy exclusions" --quiet + git check-ignore vendor/root.pem vendor/sub/nested.pem + [ -z "$(git ls-files vendor)" ] + + # File-pattern copying is the control; both paths must honor copy.exclude. + local mode + for mode in files directories; do + if [ "$mode" = files ]; then + git config gtr.copy.include 'vendor/**/*' + else + git config --unset gtr.copy.include + git config gtr.copy.includeDirs vendor + fi + run "$PROJECT_ROOT/bin/gtr" new "$mode" --from HEAD --no-fetch --no-hooks --porcelain + [ "$status" -eq 0 ] + [ "$(cat "$TEST_WORKTREES_DIR/$mode/vendor/sub/keep.txt")" = keep ] + [ ! -e "$TEST_WORKTREES_DIR/$mode/vendor/root.pem" ] + [ ! -e "$TEST_WORKTREES_DIR/$mode/vendor/sub/nested.pem" ] + done + [ ! -e "$TEST_WORKTREES_DIR/directories/vendor/cache" ] +} diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index 6087442..822f2e2 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -8,11 +8,237 @@ setup() { } teardown() { + # Read-only permission fixtures still need to be removable after assertions fail. + local readonly_path + for readonly_path in "${_readonly_copy_paths[@]}"; do + [ -d "$readonly_path" ] && [ ! -L "$readonly_path" ] && chmod u+rwx "$readonly_path" + done if [ -n "${_test_tmpdir:-}" ]; then rm -rf "$_test_tmpdir" fi } +_copy_test_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + +@test "selective copy keeps new directories private until complete and preserves source modes" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" mode + mkdir -p "$src/vendor/sub/cache" "$dst" + echo keep > "$src/vendor/sub/keep.txt" + echo excluded > "$src/vendor/sub/skip.pem" + echo excluded > "$src/vendor/sub/cache/marker" + chmod 700 "$src/vendor" + chmod 750 "$src/vendor/sub" + + cp() { + [ "$(_copy_test_mode "$dst/$mode/vendor")" = 700 ] || return 1 + [ "$(_copy_test_mode "$dst/$mode/vendor/sub")" = 700 ] || return 1 + command cp "$@" + } + + # Exercise both triggers for selective traversal. + for mode in files directories; do + mkdir "$dst/$mode" + if [ "$mode" = files ]; then + (umask 022; copy_directories "$src" "$dst/$mode" vendor '' false '*.pem') + [ ! -e "$dst/$mode/vendor/sub/skip.pem" ] + else + (umask 022; copy_directories "$src" "$dst/$mode" vendor 'vendor/sub/cache') + [ ! -e "$dst/$mode/vendor/sub/cache" ] + fi + [ "$(cat "$dst/$mode/vendor/sub/keep.txt")" = keep ] + [ "$(_copy_test_mode "$dst/$mode/vendor")" = 700 ] + [ "$(_copy_test_mode "$dst/$mode/vendor/sub")" = 750 ] + done +} + +@test "selective copy applies the caller umask to new directory modes" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/vendor/sub" "$dst" + echo keep > "$src/vendor/sub/keep.txt" + chmod 777 "$src/vendor" + chmod 755 "$src/vendor/sub" + + (umask 077; copy_directories "$src" "$dst" vendor '' false '*.pem') + + [ "$(cat "$dst/vendor/sub/keep.txt")" = keep ] + [ "$(_copy_test_mode "$dst/vendor")" = 700 ] + [ "$(_copy_test_mode "$dst/vendor/sub")" = 700 ] +} + +@test "selective copy populates read-only source directories before restoring their modes" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/vendor/sub" "$dst" + echo keep > "$src/vendor/sub/keep.txt" + chmod 500 "$src/vendor" + chmod 550 "$src/vendor/sub" + _readonly_copy_paths=("$src/vendor" "$src/vendor/sub" "$dst/vendor" "$dst/vendor/sub") + + (umask 022; copy_directories "$src" "$dst" vendor '' false '*.pem') + + [ "$(cat "$dst/vendor/sub/keep.txt")" = keep ] + [ "$(_copy_test_mode "$dst/vendor")" = 500 ] + [ "$(_copy_test_mode "$dst/vendor/sub")" = 550 ] +} + +@test "selective copy keeps partial directories private when a leaf copy fails" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/vendor/sub" "$dst" + echo keep > "$src/vendor/sub/keep.txt" + chmod 755 "$src/vendor" "$src/vendor/sub" + + cp() { + command cp "$@" || return 1 + return 1 + } + + cd "$src" + run _selective_copy_dir vendor "$dst" '' '*.pem' + [ "$status" -ne 0 ] + [ "$(_copy_test_mode "$dst/vendor")" = 700 ] + [ "$(_copy_test_mode "$dst/vendor/sub")" = 700 ] +} + +@test "selective copy preserves existing destination directory modes and files" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/vendor/sub" "$dst/vendor/sub" + echo keep > "$src/vendor/sub/keep.txt" + echo sentinel > "$dst/vendor/sub/existing" + chmod 755 "$src/vendor" "$src/vendor/sub" + chmod 710 "$dst/vendor" + chmod 700 "$dst/vendor/sub" + + (umask 022; copy_directories "$src" "$dst" vendor '' false '*.pem') + + [ "$(cat "$dst/vendor/sub/keep.txt")" = keep ] + [ "$(cat "$dst/vendor/sub/existing")" = sentinel ] + [ "$(_copy_test_mode "$dst/vendor")" = 710 ] + [ "$(_copy_test_mode "$dst/vendor/sub")" = 700 ] +} + +@test "selective copy keeps new directories private when discovery fails" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/vendor" "$dst" + chmod 755 "$src/vendor" + find() { return 1; } + + cd "$src" + run _selective_copy_dir vendor "$dst" '' '*.pem' + + [ "$status" -ne 0 ] + [ "$(_copy_test_mode "$dst/vendor")" = 700 ] +} + +@test "selective copy never finalizes directory permissions through a replacement symlink" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" outside="$_test_tmpdir/outside" + mkdir -p "$src/vendor" "$dst" "$outside" + echo sentinel > "$outside/marker" + chmod 755 "$src/vendor" + chmod 700 "$outside" + _apply_directory_excludes() { + rmdir "$dst/vendor" + ln -s "$outside" "$dst/vendor" + } + + cd "$src" + run _selective_copy_dir vendor "$dst" '' '*.pem' + + [ "$status" -ne 0 ] + [ "$(_copy_test_mode "$outside")" = 700 ] + [ "$(cat "$outside/marker")" = sentinel ] +} + +@test "nested directory selections preserve restrictive missing source ancestors" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" mode + mkdir -p "$src/private space/vendor/bundle/cache" "$dst" + echo keep > "$src/private space/vendor/bundle/keep.txt" + echo excluded > "$src/private space/vendor/bundle/cache/marker" + chmod 700 "$src/private space" + chmod 710 "$src/private space/vendor" + chmod 755 "$src/private space/vendor/bundle" + cp() { + [ "$(_copy_test_mode "$dst/$mode/private space")" = 700 ] || return 1 + [ "$(_copy_test_mode "$dst/$mode/private space/vendor")" = 700 ] || return 1 + command cp "$@" + } + + for mode in files directories fast; do + mkdir "$dst/$mode" + case "$mode" in + files) + (umask 022; copy_directories "$src" "$dst/$mode" 'private space/vendor/bundle' '' false '*.pem') + ;; + directories) + (umask 022; copy_directories "$src" "$dst/$mode" 'private space/vendor/bundle' 'private space/vendor/bundle/cache') + ;; + fast) + (umask 022; copy_directories "$src" "$dst/$mode" 'private space/vendor/bundle' '') + ;; + esac + [ "$(cat "$dst/$mode/private space/vendor/bundle/keep.txt")" = keep ] + [ "$(_copy_test_mode "$dst/$mode/private space")" = 700 ] + [ "$(_copy_test_mode "$dst/$mode/private space/vendor")" = 710 ] + done +} + +@test "nested directory selections populate siblings before restoring a read-only parent" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/private/a" "$src/private/b" "$dst" + echo first > "$src/private/a/keep.txt" + echo second > "$src/private/b/keep.txt" + chmod 500 "$src/private" + _readonly_copy_paths=("$src/private" "$dst/private") + + (umask 022; copy_directories "$src" "$dst" $'private/a\nprivate/b' '' false '*.pem') + + [ "$(cat "$dst/private/a/keep.txt")" = first ] + [ "$(cat "$dst/private/b/keep.txt")" = second ] + [ "$(_copy_test_mode "$dst/private")" = 500 ] +} + +@test "nested directory selections leave missing ancestors private after a copy failure" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/private/vendor" "$dst" + echo keep > "$src/private/vendor/keep.txt" + chmod 755 "$src/private" "$src/private/vendor" + cp() { return 1; } + + local result=0 + (umask 022; copy_directories "$src" "$dst" private/vendor '' false '*.pem') || result=$? + + [ "$result" -ne 0 ] + [ "$(_copy_test_mode "$dst/private")" = 700 ] + [ "$(_copy_test_mode "$dst/private/vendor")" = 700 ] +} + +@test "nested directory selections propagate permission finalization failures" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/private/vendor" "$dst" + echo keep > "$src/private/vendor/keep.txt" + chmod() { + [ "$2" = "$dst/private" ] && return 1 + command chmod "$@" + } + + run copy_directories "$src" "$dst" private/vendor '' false '*.pem' + + [ "$status" -ne 0 ] + [ "$(_copy_test_mode "$dst/private")" = 700 ] + [ "$(cat "$dst/private/vendor/keep.txt")" = keep ] +} + # --- _is_unsafe_path tests --- @test "absolute path is unsafe" { @@ -540,6 +766,51 @@ teardown() { [ "$(cat "$outside/marker")" = sentinel ] } +@test "copy_directories applies file exclusions before copying leaves and preserves existing files" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" copy_log="$_test_tmpdir/copy.log" + mkdir -p "$src/vendor space/sub" "$src/vendor space/assets.pem" "$dst/vendor space/sub" + echo excluded > "$src/vendor space/root.pem" + echo excluded > "$src/vendor space/sub/nested.pem" + echo excluded > "$src/vendor space/sub/.private" + echo keep > "$src/vendor space/sub/keep.txt" + echo keep > "$src/vendor space/assets.pem/keep.txt" + echo sentinel > "$dst/vendor space/sub/nested.pem" + echo sentinel > "$dst/vendor space/sub/destination-only.pem" + + cp() { + printf '%s\n' "$@" >> "$copy_log" + command cp "$@" + } + + copy_directories "$src" "$dst" 'vendor space' '' false $'*.pem\nvendor space/sub/.private' + + [ ! -e "$dst/vendor space/root.pem" ] + [ ! -e "$dst/vendor space/sub/.private" ] + [ "$(cat "$dst/vendor space/sub/nested.pem")" = sentinel ] + [ "$(cat "$dst/vendor space/sub/destination-only.pem")" = sentinel ] + [ "$(cat "$dst/vendor space/sub/keep.txt")" = keep ] + [ "$(cat "$dst/vendor space/assets.pem/keep.txt")" = keep ] + ! grep -q 'root.pem\|nested.pem\|/\.private' "$copy_log" +} + +@test "copy_directories file exclusions do not follow symlinks or remove their targets" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" outside="$_test_tmpdir/outside" + mkdir -p "$src/vendor/sub" "$dst" "$outside" + echo sentinel > "$outside/marker" + ln -s "$outside" "$src/vendor/sub/link.pem" + ln -s "$outside" "$src/vendor/sub/keep-link" + echo keep > "$src/vendor/sub/keep.txt" + + copy_directories "$src" "$dst" vendor '' false '*.pem' + + [ ! -L "$dst/vendor/sub/link.pem" ] + [ -L "$dst/vendor/sub/keep-link" ] + [ "$(cat "$outside/marker")" = sentinel ] + [ "$(cat "$dst/vendor/sub/keep.txt")" = keep ] +} + @test "literal directory patterns do not invoke find" { _test_tmpdir=$(mktemp -d) local src="$_test_tmpdir/src" find_log="$_test_tmpdir/find.log"