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: 4 additions & 1 deletion lib/commands/trust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ cmd_trust() {
if prompt_yes_no "Trust these commands?"; then
if _hooks_write_trust_marker "$trust_path" "$config_file"; then
local current_trust_path
current_trust_path=$(_hooks_current_trust_path "$config_file") || true
current_trust_path=$(_hooks_current_trust_path "$config_file") || {
log_error "Failed to verify current executable commands after writing trust marker"
return 1
}
if [ -n "$current_trust_path" ] && [ "$current_trust_path" != "$trust_path" ]; then
log_warn "Executable commands changed during review; current commands remain untrusted"
return 1
Expand Down
30 changes: 28 additions & 2 deletions lib/hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,33 @@ _hooks_read_definitions() {
_hooks_content_hash() {
local hook_content="$1"
[ -n "$hook_content" ] || return 1
printf '%s\n' "$hook_content" | shasum -a 256 | cut -d' ' -f1
printf '%s\n' "$hook_content" | _hooks_sha256
}

# Hash stdin without masking a failed executable behind an output parser.
# Explicit checks also apply when callers suppress errexit (e.g. trust checks).
_hooks_sha256() {
local output digest
local -a hasher
if command -v shasum >/dev/null 2>&1; then
hasher=(shasum -a 256)
elif command -v sha256sum >/dev/null 2>&1; then
hasher=(sha256sum)
else
log_error "Cannot compute trust hash: shasum or sha256sum is required"
return 1
fi

output=$("${hasher[@]}") || {
log_error "Failed to compute trust hash with ${hasher[0]}"
return 1
}
digest=${output%%[[:space:]]*}
if [ "${#digest}" -ne 64 ] || [[ "$digest" == *[!0-9a-f]* ]]; then
log_error "Invalid SHA-256 trust hash from ${hasher[0]}"
return 1
fi
printf '%s\n' "$digest"
}

# Compute a content hash of all current trusted command entries in a .gtrconfig file.
Expand Down Expand Up @@ -73,7 +99,7 @@ _hooks_reviewed_trust_key() {
local hash repo_root
hash=$(_hooks_content_hash "$hook_content") || return 1
repo_root=$(_hooks_repo_root "$config_file") || return 1
printf '%s\n%s\n' "$repo_root" "$hash" | shasum -a 256 | cut -d' ' -f1
printf '%s\n%s\n' "$repo_root" "$hash" | _hooks_sha256
}

# Compute the repo-scoped trust key for the current trusted command content
Expand Down
6 changes: 4 additions & 2 deletions tests/hooks.bats
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ teardown() {
EOF

local expected
expected="$(git config -f "$TEST_REPO/.gtrconfig" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | shasum -a 256 | cut -d' ' -f1)"
# Legacy SHA-256 of "hooks.postcd echo hi\n", independent of installed tools.
expected="ae6e0fecf5334026ed93892641372c5ae5685b8795f8d44b097474514363804d"

[ "$(_hooks_file_hash "$TEST_REPO/.gtrconfig")" = "$expected" ]
}
Expand All @@ -64,7 +65,8 @@ EOF
EOF

local expected
expected="$(git config -f "$TEST_REPO/.gtrconfig" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | shasum -a 256 | cut -d' ' -f1)"
# Legacy SHA-256 of the defaults.ai definition, including its final newline.
expected="10b5ecd698b5cd9ced0c52ec5808f03d3a4578ae9f566e5f02cce95287abcfc1"

[ "$(_hooks_file_hash "$TEST_REPO/.gtrconfig")" = "$expected" ]
}
Expand Down
130 changes: 130 additions & 0 deletions tests/hooks_hash.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env bats
# Exercise actual executables and the public CLI with a controlled PATH.
load test_helper

setup() {
setup_integration_repo
export XDG_CONFIG_HOME="$TEST_REPO/xdg"
source_gtr_libs
HASH_BIN="$TEST_REPO/hash-bin"
mkdir -p "$HASH_BIN"
REAL_SHA256SUM=$(command -v sha256sum || true)
REAL_SHASUM=$(command -v shasum || true)
# Forward ordinary utilities while deliberately excluding both hash tools.
local tool path
for tool in bash env git dirname basename cat mkdir mktemp mv rm date awk sed tr cut grep sort head uname wc find tee readlink realpath cp xargs expr which; do
path=$(command -v "$tool") || continue
printf '#!/bin/bash\nexec %q "$@"\n' "$path" > "$HASH_BIN/$tool"
chmod +x "$HASH_BIN/$tool"
done
git config -f .gtrconfig hooks.postCreate 'echo trusted-hook > hook-proof'
git add .gtrconfig
git commit -qm 'benign reviewed hook'
}

teardown() {
teardown_integration_repo
}

install_real_hasher() {
local name="$1" path
if [ "$name" = shasum ]; then path="$REAL_SHASUM"; else path="$REAL_SHA256SUM"; fi
[ -n "$path" ] || skip "$name is unavailable"
printf '#!/bin/bash\nexec %q "$@"\n' "$path" > "$HASH_BIN/$name"
chmod +x "$HASH_BIN/$name"
}

install_fault() {
printf '#!/bin/bash\n%s\n' "$1" > "$HASH_BIN/shasum"
chmod +x "$HASH_BIN/shasum"
}

public_trust() {
PATH="$HASH_BIN" "$HASH_BIN/bash" "$PROJECT_ROOT/bin/git-gtr" trust <<< y
}

assert_no_grant() {
[ "$status" -ne 0 ]
[[ "$output" != *'marked as trusted'* ]]
[[ "$output" == *'trust hash'* ]]
[ ! -d "$_GTR_TRUST_DIR" ] || [ -z "$(find "$_GTR_TRUST_DIR" -type f -print)" ]
# Conditional caller deliberately suppresses errexit.
if PATH="$HASH_BIN" _hooks_are_trusted "$TEST_REPO/.gtrconfig"; then
return 1
fi
}

@test "public trust fails closed without SHA executables" {
run public_trust
assert_no_grant
}

@test "public trust rejects failing hasher even with valid-looking output" {
install_fault 'printf "%064d -\n" 0; exit 7'
run public_trust
assert_no_grant
}

@test "public trust rejects empty malformed and wrong-length digests" {
local fault
for fault in 'exit 0' 'echo malformed' 'printf "%063d -\n" 0' 'printf "%065d -\n" 0' 'printf "%064s -\n" z'; do
install_fault "$fault"
run public_trust
assert_no_grant
done
}

@test "public trust fails when hashing breaks after marker write" {
install_real_hasher sha256sum
install_fault 'if [ -d "$XDG_CONFIG_HOME/gtr/trusted" ]; then exit 9; fi; exec sha256sum'
run public_trust
[ "$status" -ne 0 ]
[[ "$output" == *'Failed to verify current executable commands'* ]]
[[ "$output" != *'marked as trusted'* ]]
[ -n "$(find "$_GTR_TRUST_DIR" -type f -print)" ]
if PATH="$HASH_BIN" _hooks_are_trusted "$TEST_REPO/.gtrconfig"; then return 1; fi

# Approval survives a transient verification failure once hashing recovers.
install_fault 'exec sha256sum'
PATH="$HASH_BIN" _hooks_are_trusted "$TEST_REPO/.gtrconfig"
run public_trust
[ "$status" -eq 0 ]
[[ "$output" == *'are already trusted'* ]]
[[ "$output" != *'marked as trusted'* ]]
}

@test "sha256sum fallback preserves trust keys and public worktree hooks" {
install_real_hasher sha256sum
local content expected_hash expected_key repo_root marker second_repo
content=$(_hooks_read_definitions "$TEST_REPO/.gtrconfig")
expected_hash=$(printf '%s\n' "$content" | "$REAL_SHA256SUM" | cut -d' ' -f1)
repo_root=$(_hooks_repo_root "$TEST_REPO/.gtrconfig")
expected_key=$(printf '%s\n%s\n' "$repo_root" "$expected_hash" | "$REAL_SHA256SUM" | cut -d' ' -f1)
[ "$(PATH="$HASH_BIN" _hooks_current_trust_key "$TEST_REPO/.gtrconfig")" = "$expected_key" ]
run public_trust
[ "$status" -eq 0 ]
[[ "$output" == *'marked as trusted'* ]]
marker="$_GTR_TRUST_DIR/$expected_key"
[ -s "$marker" ]
PATH="$HASH_BIN" _hooks_are_trusted "$TEST_REPO/.gtrconfig"
run env PATH="$HASH_BIN" bash "$PROJECT_ROOT/bin/git-gtr" new hash-smoke --from HEAD --no-fetch --no-copy
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(cat "$TEST_WORKTREES_DIR/hash-smoke/hook-proof")" = trusted-hook ]
second_repo="$TEST_REPO/other"
mkdir -p "$second_repo"
cp .gtrconfig "$second_repo/.gtrconfig"
if PATH="$HASH_BIN" _hooks_are_trusted "$second_repo/.gtrconfig"; then return 1; fi
git config -f .gtrconfig hooks.postCreate 'echo changed'
if PATH="$HASH_BIN" _hooks_are_trusted "$TEST_REPO/.gtrconfig"; then return 1; fi
}

@test "shasum preserves legacy content and identity bytes" {
install_real_hasher shasum
local content expected_hash expected_key repo_root
content=$(_hooks_read_definitions "$TEST_REPO/.gtrconfig")
expected_hash=$(printf '%s\n' "$content" | "$REAL_SHASUM" -a 256 | cut -d' ' -f1)
repo_root=$(_hooks_repo_root "$TEST_REPO/.gtrconfig")
expected_key=$(printf '%s\n%s\n' "$repo_root" "$expected_hash" | "$REAL_SHASUM" -a 256 | cut -d' ' -f1)
[ "$(PATH="$HASH_BIN" _hooks_content_hash "$content")" = "$expected_hash" ]
[ "$(PATH="$HASH_BIN" _hooks_current_trust_key "$TEST_REPO/.gtrconfig")" = "$expected_key" ]
}
Loading