From d9282dad94b45f548d75f703ce0f560eb35ceb90 Mon Sep 17 00:00:00 2001 From: Shi Qi Date: Wed, 4 Nov 2026 09:56:35 -0800 Subject: [PATCH 1/2] fix(copy): honor file exclusions inside directory copies --- docs/configuration.md | 10 ++++++- lib/commands/copy.sh | 4 +-- lib/commands/create.sh | 2 +- lib/copy.sh | 27 ++++++++++++------- tests/cmd_copy.bats | 24 +++++++++++++++++ tests/cmd_create_integration.bats | 32 ++++++++++++++++++++++ tests/copy_safety.bats | 45 +++++++++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 14 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3514cd5..a690d29 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -346,6 +346,14 @@ 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. + 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 +370,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..76b7f25 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -577,9 +577,10 @@ EOF # 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 +# 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 @@ -607,9 +608,12 @@ _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..15131f8 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -540,6 +540,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" From 655bda4adc532ce42681639109f389f96a753245 Mon Sep 17 00:00:00 2001 From: Shi Qi Date: Wed, 4 Nov 2026 10:36:59 -0800 Subject: [PATCH 2/2] fix(copy): preserve directory permissions during filtered copies --- docs/configuration.md | 7 ++ lib/copy.sh | 86 +++++++++++++++- tests/copy_safety.bats | 226 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a690d29..33a0702 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -354,6 +354,13 @@ 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 diff --git a/lib/copy.sh b/lib/copy.sh index 76b7f25..41c27e0 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -575,8 +575,20 @@ 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. +# 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" @@ -585,16 +597,23 @@ _selective_copy_dir() { _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 @@ -619,7 +638,13 @@ _selective_copy_dir() { $find_results EOF - _apply_directory_excludes "$dst_root" "$dir_path" "$excludes" + _apply_directory_excludes "$dst_root" "$dir_path" "$excludes" || return 1 + if [ -n "$final_mode" ]; then + # Recheck before chmod so a replacement symlink cannot redirect it. + _copy_components_safe "$dst_root" "$dir_path" || return 1 + chmod "$final_mode" "$dest_dir" || return 1 + fi + return 0 } # Return the number of path components in a directory pattern. @@ -748,6 +773,9 @@ _copy_resolved_directories() { cd "$src_root" || return 1 local copied_count=0 failed=0 + # Missing ancestors of nested selections must remain private across all + # selections, including siblings under a read-only source parent. + local -a parent_paths=() parent_modes=() local dir_path while IFS= read -r dir_path; do [ -z "$dir_path" ] && continue @@ -770,8 +798,41 @@ _copy_resolved_directories() { continue fi - if ! mkdir -p "$dest_parent"; then + if [ ! -d "$dst_root" ] && ! mkdir -p "$dst_root"; then failed=1 + log_warn "Failed to prepare destination root $dst_root" + continue + fi + local parent_path="" remaining component parent_mode parents_ready=1 + case "$dir_path" in + */*) remaining="${dir_path%/*}" ;; + *) remaining="" ;; + esac + while [ -n "$remaining" ]; do + component="${remaining%%/*}" + case "$remaining" in + */*) remaining="${remaining#*/}" ;; + *) remaining="" ;; + esac + case "$component" in ""|.) continue ;; esac + parent_path="${parent_path:+$parent_path/}$component" + if ! _copy_components_safe "$dst_root" "$parent_path"; then + parents_ready=0 + break + fi + if [ ! -d "$dst_root/$parent_path" ]; then + if ! parent_mode=$(_copy_directory_mode "$parent_path") || + ! mkdir -m 700 "$dst_root/$parent_path"; then + parents_ready=0 + break + fi + parent_paths+=("$parent_path") + parent_modes+=("$parent_mode") + fi + done + if [ "$parents_ready" -eq 0 ]; then + failed=1 + log_warn "Failed to prepare parent directories for $dir_path" continue fi @@ -790,6 +851,21 @@ _copy_resolved_directories() { $resolved_dirs EOF + # Finalize deepest-first only after every selected directory has completed. + # On failure, leave the newly created ancestors private around partial data. + local parent_index=${#parent_paths[@]} + if [ "$failed" -eq 0 ]; then + while [ "$parent_index" -gt 0 ]; do + parent_index=$((parent_index - 1)) + if ! _copy_components_safe "$dst_root" "${parent_paths[$parent_index]}" || + ! chmod "${parent_modes[$parent_index]}" "$dst_root/${parent_paths[$parent_index]}"; then + log_warn "Failed to finalize parent directory ${parent_paths[$parent_index]}" + failed=1 + break + fi + done + fi + cd "$old_pwd" || return 1 if [ "$copied_count" -gt 0 ]; then diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index 15131f8..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" {