From: Shane Jaroch Date: Sat, 3 Jan 2026 05:00:31 +0000 (-0500) Subject: linting, testing; coverage reported 63.8% X-Git-Url: https://git.nutra.tk/v2?a=commitdiff_plain;h=5146891d8226bb5ec100aeaccdf5aa83488611cc;p=gamesguru%2Fgit-remote-gcrypt.git linting, testing; coverage reported 63.8% monkeypatch to fix kcov invocation on posix shell (add dedicated target for pure /bin/sh testing) remove old redundant exit 0 in test add force push/SIGINT tests fix silently passing (actually failing!) test force push reword [TODO: restore require-explicit-force-push] manifest versioning to help verify compatible & authenticity show signer version; inject version to test require --force to init or overwrite manifest split up large repacking test add separate test for repack with large objects add method to clean unencrypted files off remote prevent privacy leaks of previously unencrypted blob privacy test more safety/privacy checks and clean command/check early in execution don't publish participants in new test remove useless debug log statement tidy default fetch; small fix to init Signed-off-by: Shane Jaroch --- diff --git a/.envrc b/.envrc deleted file mode 100644 index 0c60dc4..0000000 --- a/.envrc +++ /dev/null @@ -1,3 +0,0 @@ -# NOTE: for fish add .fish on end -source ./completions/$(basename $SHELL)/git-remote-gcrypt - diff --git a/.github/workflows/ci.yaml b/.github/workflows/lint.yaml similarity index 94% rename from .github/workflows/ci.yaml rename to .github/workflows/lint.yaml index 62e1af8..c4866c7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/lint.yaml @@ -1,12 +1,12 @@ --- -name: CI +name: lint "on": push: workflow_dispatch: inputs: debug: - description: 'Enable debug logging (GCRYPT_DEBUG=1)' + description: "Enable debug logging (GCRYPT_DEBUG=1)" required: false type: boolean default: false @@ -49,7 +49,6 @@ jobs: - name: Verify [make check/install] run: make check/install - # Handles RedHat (UBI Container) install-rh: runs-on: ubuntu-latest @@ -87,8 +86,7 @@ jobs: - name: Verify [make check/install] run: make check/install - - # Lint job (no-op currently) + # Lint job lint: runs-on: ubuntu-latest steps: @@ -98,5 +96,4 @@ jobs: run: sudo apt-get update && sudo apt-get install -y shellcheck - name: Lint [make lint] - continue-on-error: true run: make lint diff --git a/Makefile b/Makefile index a1d9670..c1c823f 100644 --- a/Makefile +++ b/Makefile @@ -25,27 +25,28 @@ _all: lint test/installer test/system @$(call print_success,All checks passed!) .PHONY: vars -vars: ##H Debug: Print project variables - @$(foreach v,$(sort $(.VARIABLES)), \ - $(if $(filter file command line override,$(origin $(v))), \ - $(info $(v) = $($(v))) \ +vars: ##H Display all Makefile variables (simple) + $(info === Makefile Variables (file/command/line origin) ===) + @$(foreach V,$(sort $(.VARIABLES)), \ + $(if $(filter file command line,$(origin $(V))), \ + $(info $(shell printf "%-30s" "$(V)") = $(value $(V))) \ ) \ ) define print_err - printf "\033[1;31m%s\033[0m\n" "$(1)" +printf "\033[1;31m%s\033[0m\n" "$(1)" endef define print_warn - printf "\033[1;33m%s\033[0m\n" "$(1)" +printf "\033[1;33m%s\033[0m\n" "$(1)" endef define print_success - printf "\033[1;34m✓ %s\033[0m\n" "$(1)" +printf "\033[1;34m✓ %s\033[0m\n" "$(1)" endef define print_info - printf "\033[1;36m%s\033[0m\n" "$(1)" +printf "\033[1;36m%s\033[0m\n" "$(1)" endef @@ -58,6 +59,19 @@ check/deps: ##H Verify kcov & shellcheck @$(call print_success,Dependencies OK.) + +LINT_LOCS_PY ?= $(shell git ls-files '*.py') +LINT_LOCS_SH ?= + +.PHONY: format +format: ##H Format scripts + @$(call print_target,format) + @$(call print_info,Formatting Python scripts...) + $(if $(LINT_LOCS_SH),shfmt -i 4 -ci -bn -s -w $(LINT_LOCS_SH)) + -black $(LINT_LOCS_PY) + -isort $(LINT_LOCS_PY) + @$(call print_success,OK.) + .PHONY: lint lint: ##H Run shellcheck # lint install script @@ -92,39 +106,64 @@ test/installer: check/deps ##H Test installer logic ./tests/test-install-logic.sh +.PHONY: test/purity +test/purity: check/deps ##H Run logic tests with native shell (As Shipped Integrity Check) + @echo "running system tests (native /bin/sh)..." + @export GPG_TTY=$$(tty); \ + [ -n "$(DEBUG)$(V)" ] && export GCRYPT_DEBUG=1; \ + export GIT_CONFIG_PARAMETERS="'gcrypt.gpg-args=--pinentry-mode loopback --no-tty'"; \ + for test_script in tests/system-test*.sh; do \ + ./$$test_script || exit 1; \ + done + .PHONY: test/system -test/system: check/deps ##H Test system functionality +test/system: check/deps ##H Run coverage tests (Dynamic Bash) + @echo "running system tests (coverage/bash)..." @rm -rf $(COV_SYSTEM) @mkdir -p $(COV_SYSTEM) @export GPG_TTY=$$(tty); \ [ -n "$(DEBUG)$(V)" ] && export GCRYPT_DEBUG=1 && print_warn "Debug mode enabled"; \ export GIT_CONFIG_PARAMETERS="'gcrypt.gpg-args=--pinentry-mode loopback --no-tty'"; \ - export COV_DIR=$(COV_SYSTEM); \ + sed -i 's|^#!/bin/sh|#!/bin/bash|' git-remote-gcrypt; \ + trap "sed -i 's|^#!/bin/bash|#!/bin/sh|' git-remote-gcrypt" EXIT; \ for test_script in tests/system-test*.sh; do \ kcov --include-path=$(PWD) \ --include-pattern=git-remote-gcrypt \ --exclude-path=$(PWD)/.git,$(PWD)/tests \ $(COV_SYSTEM) \ - ./$$test_script || true; \ - done - - -define CHECK_COVERAGE -@XML_FILE=$$(find $(1) -name "cobertura.xml" 2>/dev/null | grep "merged" | head -n 1); \ -[ -z "$$XML_FILE" ] && XML_FILE=$$(find $(1) -name "cobertura.xml" 2>/dev/null | head -n 1); \ -if [ -f "$$XML_FILE" ]; then \ - echo ""; \ - echo "Report for: file://$$(dirname "$$XML_FILE")/index.html"; \ - XML_FILE="$$XML_FILE" PATT="$(2)" python3 tests/coverage_report.py; \ - fi -endef - -.PHONY: test/cov + ./$$test_script; \ + done; \ + sed -i 's|^#!/bin/bash|#!/bin/sh|' git-remote-gcrypt; \ + trap - EXIT + + +# Find coverage XML: preference for "merged" > any other (search depth: 2 subdirs) +find_coverage_xml = $(or \ + $(filter %/merged/cobertura.xml, $(wildcard $(1)/cobertura.xml $(1)/*/cobertura.xml $(1)/*/*/cobertura.xml)), \ + $(firstword $(wildcard $(1)/cobertura.xml $(1)/*/cobertura.xml $(1)/*/*/cobertura.xml)) \ +) + +CHECK_COVERAGE = $(if $(call find_coverage_xml,$(1)), \ + echo "" ; \ + echo "Report for: file://$(abspath $(dir $(call find_coverage_xml,$(1))))/index.html" ; \ + XML_FILE="$(call find_coverage_xml,$(1))" PATT="$(2)" FAIL_UNDER="$(3)" python3 tests/coverage_report.py, \ + echo "" ; \ + echo "Error: No coverage report found for $(2) in $(1)" ; \ + exit 1) + +.PHONY: test/cov _test_cov_internal test/cov: ##H Show coverage gaps - $(call CHECK_COVERAGE,$(COV_SYSTEM),git-remote-gcrypt) - $(call CHECK_COVERAGE,$(COV_INSTALL),install.sh) + $(MAKE) _test_cov_internal + +_test_cov_internal: + @err=0; \ + $(call CHECK_COVERAGE,$(COV_SYSTEM),git-remote-gcrypt,60) || err=1; \ + $(call CHECK_COVERAGE,$(COV_INSTALL),install.sh,80) || err=1; \ + exit $$err +# Version from git describe (or fallback) +__VERSION__ := $(shell git describe --tags --always --dirty 2>/dev/null || echo "@@DEV_VERSION@@") .PHONY: install/, install install/: install diff --git a/git-remote-gcrypt b/git-remote-gcrypt index 42009e9..c54de98 100755 --- a/git-remote-gcrypt +++ b/git-remote-gcrypt @@ -34,32 +34,58 @@ VERSION="@@DEV_VERSION@@" # Help function show_help() { - cat >&2 <<-EOF - git-remote-gcrypt version $VERSION - GPG-encrypted git remote helper - - Usage: Automatically invoked by git when using gcrypt:: URLs - See: man git-remote-gcrypt - Or: https://github.com/spwhitton/git-remote-gcrypt - - Options: - -h, --help Show this help message - -v, --version Show version information - --check Check if URL is a gcrypt repository - - Git Protocol Commands (for debugging): - capabilities List remote helper capabilities - list List refs in remote repository - push Push refs to remote repository - fetch Fetch refs from remote repository - - Environment Variables: - GCRYPT_DEBUG=1 Enable verbose debug logging to stderr - GCRYPT_TRACE=1 Enable shell tracing (set -x) for rsync/curl commands - GCRYPT_FULL_REPACK=1 Force full repack when pushing - EOF + cat >&2 < Check if URL is a gcrypt repository + --clean Remove unencrypted files from remote (with confirmation) + --clean --dry-run Show what would be deleted without deleting + --clean --force Delete without confirmation + +Git Protocol Commands (for debugging): + capabilities List remote helper capabilities + list List refs in remote repository + push Push refs to remote repository + fetch Fetch refs from remote repository + +Environment Variables: + GCRYPT_DEBUG=1 Enable verbose debug logging to stderr + GCRYPT_TRACE=1 Enable shell tracing (set -x) for rsync/curl commands + GCRYPT_FULL_REPACK=1 Force full repack when pushing +EOF } +# Handle subcommands early (before getopts consumes them) +# These are not options but subcommands that need their own argument handling +case "$1" in + --check) + NAME=dummy-gcrypt-check + URL=$2 + # Will be handled at the end of the script + ;; + --clean) + NAME=dummy-gcrypt-clean + URL=$2 + FORCE_CLEAN= + DRY_RUN= + if [ "$3" = "--force" ] || [ "$3" = "-f" ]; then + FORCE_CLEAN=yes + fi + if [ "$3" = "--dry-run" ] || [ "$3" = "-n" ]; then + DRY_RUN=yes + fi + # Will be handled at the end of the script + ;; +esac + # Parse flags while getopts "hv-:" opt; do case "$opt" in @@ -82,6 +108,12 @@ while getopts "hv-:" opt; do echo "git-remote-gcrypt version $VERSION" >&2 exit 0 ;; + check) + # Allow --check to pass through to the main logic at the bottom + ;; + clean) + # Allow --clean to pass through to the main logic at the bottom + ;; *) echo "Unknown option: --$OPTARG" >&2 exit 1 @@ -99,45 +131,45 @@ shift $((OPTIND - 1)) case "${1:-}" in capabilities) if [ "${2:-}" = "-h" ] || [ "${2:-}" = "--help" ]; then - cat >&2 <<-EOF - capabilities - List git remote helper capabilities + cat >&2 < - Invoked by git to query what operations this helper supports. - EOF +Usage: echo "capabilities" | git-remote-gcrypt + Invoked by git to query what operations this helper supports. +EOF exit 0 fi ;; list) if [ "${2:-}" = "-h" ] || [ "${2:-}" = "--help" ]; then - cat >&2 <<-EOF - list - List refs in remote repository + cat >&2 < - Invoked by git to list available refs (branches/tags). - EOF +Usage: echo "list" | git-remote-gcrypt + Invoked by git to list available refs (branches/tags). +EOF exit 0 fi ;; push) if [ "${2:-}" = "-h" ] || [ "${2:-}" = "--help" ]; then - cat >&2 <<-EOF - push - Push refs to remote repository + cat >&2 <:" | git-remote-gcrypt - Invoked by git to push local refs to the remote. - EOF +Usage: echo "push :" | git-remote-gcrypt + Invoked by git to push local refs to the remote. +EOF exit 0 fi ;; fetch) if [ "${2:-}" = "-h" ] || [ "${2:-}" = "--help" ]; then - cat >&2 <<-EOF - fetch - Fetch refs from remote repository + cat >&2 < " | git-remote-gcrypt - Invoked by git to fetch objects from the remote. - EOF +Usage: echo "fetch " | git-remote-gcrypt + Invoked by git to fetch objects from the remote. +EOF exit 0 fi ;; @@ -166,7 +198,8 @@ Recipients= # xfeed: The most basic output function puts $1 into the stdin of $2..$# xfeed() { - local input_= + # shellcheck disable=SC3043 + local input_="" input_=$1; shift "$@" </dev/null && - obj_id="$(git ls-tree "$Gref" | xgrep -E '\b'"$2"'$' | awk '{print $3}')" && - isnonnull "$obj_id" && git cat-file blob "$obj_id" && ret_=: || - { ret_=false && : ; } - [ -e "$fet_head.$$~" ] && command mv -f "$fet_head.$$~" "$fet_head" || : + # shellcheck disable=SC3043 + local ret_=: obj_id="" fet_head="$GIT_DIR/FETCH_HEAD" + if [ -e "$fet_head" ]; then + command mv -f "$fet_head" "$fet_head.$$~" || : + fi + if git fetch -q -f "$1" "$Gref_rbranch:$Gref" >/dev/null; then + obj_id="$(git ls-tree "$Gref" | xgrep -E '\b'"$2"'$' | awk '{print $3}')" + if isnonnull "$obj_id" && git cat-file blob "$obj_id"; then + ret_=: + else + ret_=false + fi + else + ret_=false + fi + if [ -e "$fet_head.$$~" ]; then + command mv -f "$fet_head.$$~" "$fet_head" || : + fi $ret_ } @@ -303,6 +353,7 @@ EOF # Get 'tree' from $1, change file $2 to obj id $3 update_tree() { + # shellcheck disable=SC3043 local tab_=" " # $2 is a filename from the repo format (set +e; @@ -315,7 +366,8 @@ update_tree() # depends on previous GET to set $Gref and depends on PUT_FINAL later gitception_put() { - local obj_id= tree_id= commit_id= + # shellcheck disable=SC3043 + local obj_id="" tree_id="" commit_id="" obj_id=$(git hash-object -w --stdin) && tree_id=$(update_tree "$Gref" "$2" "$obj_id") && commit_id=$(anon_commit "$tree_id") && @@ -326,16 +378,18 @@ gitception_put() # depends on previous GET like put gitception_remove() { - local tree_id= commit_id= tab_=" " + # shellcheck disable=SC3043 + local tree_id="" commit_id="" tab_=" " # $2 is a filename from the repo format - tree_id=$(git ls-tree "$Gref" | xgrep -v -E '\b'"$2"'$' | git mktree) && + tree_id=$(git ls-tree "$Gref" | awk -F'\t' -v f="$2" '$2 != f' | git mktree) && commit_id=$(anon_commit "$tree_id") && git update-ref "$Gref" "$commit_id" } gitception_new_repo() { - local commit_id= empty_tree=4b825dc642cb6eb9a060e54bf8d69288fbee4904 + # shellcheck disable=SC3043 + local commit_id="" empty_tree=4b825dc642cb6eb9a060e54bf8d69288fbee4904 # get any file to update Gref, and if it's not updated we create empty git update-ref -d "$Gref" || : gitception_get "$1" "x" 2>/dev/null >&2 || : @@ -384,6 +438,7 @@ PUT() ( if [ -n "${GCRYPT_TRACE:-}" ]; then set -x; fi exec 0&2 ) elif isurl rclone "$1" @@ -421,6 +476,7 @@ PUTREPO() ( if [ -n "${GCRYPT_TRACE:-}" ]; then set -x; fi exec 0&2 ) @@ -438,7 +494,8 @@ PUTREPO() # For repo $1, delete all newline-separated files in $2 REMOVE() { - local fn_= + # shellcheck disable=SC3043 + local fn_="" print_debug "REMOVE $1 $2" if isurl sftp "$1" then @@ -498,6 +555,7 @@ EOF # Encrypt to recipients $1 PRIVENCRYPT() { + # shellcheck disable=SC2086 set -- $1 if isnonnull "$Conf_signkey"; then set -- "$@" -u "$Conf_signkey" @@ -508,7 +566,8 @@ PRIVENCRYPT() # $1 is the match for good signature, $2 is the textual signers list PRIVDECRYPT() { - local status_= + # shellcheck disable=SC3043 + local status_="" signer_="" exec 4>&1 && status_=$(rungpg --status-fd 3 -q -d 3>&1 1>&4 || { rc=$? @@ -526,6 +585,12 @@ PRIVDECRYPT() echo_info "Only accepting signatories: ${2:-(none)}" && return 1 }) + + # Extract signer + signer_=$(xfeed "$status_" grep "^\[GNUPG:\] GOODSIG " | cut -d ' ' -f 3) + if isnonnull "$signer_"; then + echo_info "Decrypting manifest signed with $signer_" + fi } # Generate $1 random bytes @@ -536,7 +601,8 @@ genkey() gpg_hash() { - local hash_= + # shellcheck disable=SC3043 + local hash_="" hash_=$(rungpg --with-colons --print-md "$1" | tr A-F a-f) hash_=${hash_#:*:} xecho "${hash_%:}" @@ -550,10 +616,10 @@ rungpg() # gpg will fail to run when there is no controlling tty, # due to trying to print messages to it, even if a gpg agent is set # up. --no-tty fixes this. - if [ "x$GPG_AGENT_INFO" != "x" ]; then - ${GPG} --no-tty $@ + if [ "${GPG_AGENT_INFO:-}" != "" ]; then + ${GPG} --no-tty "$@" else - ${GPG} $@ + ${GPG} "$@" fi } @@ -574,14 +640,15 @@ make_new_repo() iseq "${NAME#gcrypt::}" "$URL" || git config "remote.$NAME.gcrypt-id" "$Repoid" echo_info "Remote ID is $Repoid" - Extnlist="extn comment" + Extnlist="extn gcrypt-version $VERSION" } # $1 return var for goodsig match, $2 return var for signers text read_config() { - local recp_= r_tail= r_keyinfo= r_keyfpr= gpg_list= cap_= conf_part= good_sig= signers_= + # shellcheck disable=SC3043,SC2034 + local recp_="" r_tail="" r_keyinfo="" r_keyfpr="" gpg_list="" cap_="" conf_part="" good_sig="" signers_="" Conf_signkey=$(git config --get "remote.$NAME.gcrypt-signingkey" '.+' || git config --path user.signingkey || :) conf_part=$(git config --get "remote.$NAME.gcrypt-participants" '.+' || @@ -624,13 +691,13 @@ read_config() keyid_=$(xfeed "$r_keyinfo" cut -f 5 -d :) fprid_=$(xfeed "$r_keyfpr" cut -f 10 -d :) print_debug "Resolved participant $recp_ to fpr: $fprid_" - - isnonnull "$fprid_" && - signers_="$signers_ $keyid_" && - append_to @good_sig "^\[GNUPG:\] VALIDSIG .*$fprid_$" || { + if isnonnull "$fprid_"; then + signers_="$signers_ $keyid_" + append_to @good_sig "^\[GNUPG:\] VALIDSIG .*$fprid_$" + else echo_info "WARNING: Skipping missing key $recp_" continue - } + fi # Check 'E'ncrypt capability cap_=$(xfeed "$r_keyinfo" cut -f 12 -d :) if ! iseq "${cap_#*E}" "$cap_"; then @@ -657,13 +724,52 @@ read_config() ensure_connected() { - local manifest_= r_repoid= r_name= url_frag= r_sigmatch= r_signers= \ - tmp_manifest= tmp_stderr= + # shellcheck disable=SC3043 + local manifest_="" r_repoid="" r_name="" url_frag="" r_sigmatch="" r_signers="" \ + tmp_manifest="" tmp_stderr="" early_bad_files="" if isnonnull "$Did_find_repo" then return fi + + # EARLY SAFETY CHECK for gitception backends: + # Before GPG validation, check if the remote has unencrypted files. + # This prevents the GPG error from masking the privacy leak warning. + # Skip this check if we are explicitly running the clean command. + if [ "$NAME" != "dummy-gcrypt-clean" ] && ! isurl sftp "$URL" && ! isurl rsync "$URL" && ! isurl rclone "$URL" && ! islocalrepo "$URL"; then + # It's a gitception backend - do early safety check + # Fetch the default branch to see what files exist + # shellcheck disable=SC3043 + local check_files="" + git fetch --quiet "$URL" "refs/heads/master:refs/gcrypt/safety-check" 2>/dev/null || + git fetch --quiet "$URL" "refs/heads/main:refs/gcrypt/safety-check" 2>/dev/null || true + + if git rev-parse --verify "refs/gcrypt/safety-check" >/dev/null 2>&1; then + check_files=$(git ls-tree --name-only "refs/gcrypt/safety-check" 2>/dev/null || :) + # Clean up the temp ref + git update-ref -d "refs/gcrypt/safety-check" 2>/dev/null || true + + if isnonnull "$check_files"; then + # Check if ANY file doesn't match gcrypt pattern (hash filenames) + early_bad_files=$(echo "$check_files" | grep -v -E '^[a-f0-9]{56}$|^[a-f0-9]{64}$|^[a-f0-9]{96}$|^[a-f0-9]{128}$' || :) + if isnonnull "$early_bad_files"; then + # Check config to see if we should ignore + if [ "$(git config --bool gcrypt.allow-unencrypted-remote)" != "true" ]; then + echo_info "ERROR: Remote repository contains unencrypted or unknown files!" + echo_info "To protect your privacy, git-remote-gcrypt will NOT push to this remote." + echo_info "Found the following unexpected files:" + echo_info "$early_bad_files" | head -n 5 | sed 's/^/ /' >&2 + echo_info "" + echo_info "To fix: use 'git-remote-gcrypt --clean $URL' to remove these files," + echo_info "or set 'git config gcrypt.allow-unencrypted-remote true' to ignore." + exit 1 + fi + fi + fi + fi + fi + Did_find_repo=no print_debug "Calling read_config" read_config @r_sigmatch @r_signers @@ -715,9 +821,11 @@ ensure_connected() Did_find_repo=yes echo_info "Decrypting manifest" - manifest_=$(PRIVDECRYPT "$r_sigmatch" "$r_signers" < "$tmp_manifest") && - isnonnull "$manifest_" || + if ! manifest_=$(PRIVDECRYPT "$r_sigmatch" "$r_signers" < "$tmp_manifest") || \ + isnull "$manifest_" + then echo_die "Failed to decrypt manifest!" + fi rm -f "$tmp_manifest" filter_to @Refslist "$Hex40 *" "$manifest_" @@ -726,6 +834,18 @@ ensure_connected() filter_to @Extnlist "extn *" "$manifest_" filter_to @r_repoid "repo *" "$manifest_" + # Check gcrypt version from manifest + filter_to @Manifest_version "extn gcrypt-version *" "$manifest_" + Manifest_version=${Manifest_version#extn gcrypt-version } + if isnonnull "$Manifest_version" + then + echo_info "Manifest encrypted with gcrypt $Manifest_version" + if isnoteq "$Manifest_version" "$VERSION" + then + echo_info "WARNING: You are running gcrypt $VERSION" + fi + fi + r_repoid=${r_repoid#repo } r_repoid=${r_repoid% *} if isnull "$Repoid" @@ -752,11 +872,13 @@ ensure_connected() # $3 the key get_verify_decrypt_pack() { - local rcv_id= tmp_encrypted= + # shellcheck disable=SC3043 + local rcv_id="" tmp_encrypted="" tmp_encrypted="$Tempdir/packF" GET "$URL" "$2" "$tmp_encrypted" && - rcv_id=$(gpg_hash "$1" < "$tmp_encrypted") && - iseq "$rcv_id" "$2" || echo_die "Packfile $2 does not match digest!" + if ! rcv_id=$(gpg_hash "$1" < "$tmp_encrypted") || isnoteq "$rcv_id" "$2"; then + echo_die "Packfile $2 does not match digest!" + fi DECRYPT "$3" < "$tmp_encrypted" rm -f "$tmp_encrypted" } @@ -765,7 +887,8 @@ get_verify_decrypt_pack() # $1 destdir (when repack, else "") get_pack_files() { - local pack_id= r_pack_key_line= htype_= pack_= key_= + # shellcheck disable=SC3043 + local pack_id="" r_pack_key_line="" htype_="" pack_="" key_="" while IFS=': ' read -r _ htype_ pack_ # </dev/null || :) + + # If no files, nothing to check + isnonnull "$remote_files" || return 0 + + # Build whitelist of valid gcrypt files + if iseq "$Did_find_repo" "yes"; then + # We found a gcrypt manifest, so we know what files are valid: + # 1. The manifest file itself + valid_files="$Manifestfile" + # 2. All packfiles listed in Packlist (extract hash from "pack :HASHTYPE:HASH key") + for f in $(xecho "$Packlist" | cut -d: -f3 | cut -d' ' -f1); do + valid_files="$valid_files$Newline$f" + done + else + # No gcrypt manifest found = fresh push. + # ANY file in the remote is suspicious (we're about to initialize gcrypt). + bad_files="$remote_files" + fi + + # If we have a whitelist, compare + if isnull "$bad_files" && isnonnull "$valid_files"; then + for f in $remote_files; do + if ! xfeed "$valid_files" grep -qxF "$f"; then + bad_files="$bad_files$Newline$f" + fi + done + bad_files="${bad_files#"$Newline"}" fi + + if isnonnull "$bad_files"; then + echo_info "ERROR: Remote repository contains unencrypted or unknown files!" + echo_info "To protect your privacy, git-remote-gcrypt will NOT push to this remote." + echo_info "Found the following unexpected files:" + echo_info "$bad_files" | head -n 5 | sed 's/^/ /' >&2 + if [ "$(line_count "$bad_files")" -gt 5 ]; then + echo_info " ... (and others)" + fi + + # Check config to see if we should ignore + if [ "$(git config --bool gcrypt.allow-unencrypted-remote)" = "true" ]; then + echo_info "WARNING: Proceeding because gcrypt.allow-unencrypted-remote is set." + return 0 + fi + + echo_info "" + echo_info "EXPLANATION:" + echo_info "This remote appears to have been used without encryption previously." + echo_info "Pushing encrypted data now would reveal that you are using this repo," + echo_info "and leaves the old unencrypted files visible to the server." + echo_info "" + echo_info "HOW TO FIX:" + echo_info "1. Backup your data:" + echo_info " git clone $URL backup-repo" + echo_info " # IMPORTANT: If the remote has non-tracked files, make a full" + echo_info " # copy of the remote directory (e.g. cp/rsync) before proceeding!" + echo_info "2. Clean the remote (DANGEROUS - deletes unencrypted files):" + echo_info " # In a separate clone of the remote:" + echo_info " git rm -r ." + echo_info " git commit -m 'Clean up for gcrypt'" + echo_info " git push origin master" + echo_info "3. Retry your push." + echo_info "" + echo_info "OR, to ignore this and leak that you are using gcrypt:" + echo_info " git config remote.$NAME.gcrypt-allow-unencrypted-remote true" + echo_info "" + + echo_die "Aborted because remote contains unencrypted files." + fi +} + + + ensure_connected + check_safety + + if isnonnull "$Refslist" then @@ -917,6 +1131,7 @@ do_push() while IFS=: read -r src_ dst_ # << +src:dst do + # shellcheck disable=SC2046 if [ $(echo "$src_" | cut -c1) != + ] then force_passed=false @@ -935,15 +1150,24 @@ do_push() $1 EOF + if iseq "$Did_find_repo" "no" + then + if [ "$force_passed" = true ] + then + make_new_repo + else + echo_die "Remote manifest not found. Use --force to create valid new repository." + fi + fi + if [ "$force_passed" = false ] then if [ "$Conf_force_required" = true ] then echo_die "Implicit force push disallowed by gcrypt configuration." else - echo_info "Due to a longstanding bug, this push implicitly has --force." - echo_info "Consider explicitly passing --force, and setting" - echo_info "gcrypt's require-explicit-force-push git config key." + echo_info "Note: gcrypt overwrites the remote manifest on each push." + echo_info "In multi-user setups, coordinate pushes to avoid conflicts." fi fi @@ -959,7 +1183,7 @@ EOF if [ -s "$tmp_objlist" ] then key_=$(genkey "$Packkey_bytes") - pack_id=$(export GIT_ALTERNATE_OBJECT_DIRECTORIES=$Tempdir; + pack_id=$(export GIT_ALTERNATE_OBJECT_DIRECTORIES="$Tempdir"; pipefail git pack-objects --stdout < "$tmp_objlist" | pipefail ENCRYPT "$key_" | tee "$tmp_encrypted" | gpg_hash "$Hashtype") @@ -972,6 +1196,10 @@ EOF fi # Generate manifest + # Update the gcrypt version in extensions (remove old, add current) + filter_to ! @Extnlist "extn gcrypt-version *" "$Extnlist" + append_to @Extnlist "extn gcrypt-version $VERSION" + echo_info "Encrypting to: $Recipients" echo_info "Requesting manifest signature" @@ -1058,7 +1286,8 @@ setup() # handle git-remote-helpers protocol gcrypt_main_loop() { - local input_= input_inner= r_args= temp_key= + # shellcheck disable=SC3043 + local input_="" input_inner="" r_args="" temp_key="" NAME=$1 # Remote name URL=$2 # Remote URL @@ -1067,7 +1296,7 @@ gcrypt_main_loop() setup - while read input_ + while read -r input_ do case "$input_" in capabilities) @@ -1078,7 +1307,7 @@ gcrypt_main_loop() ;; fetch\ *) r_args=${input_##fetch } - while read input_inner + while read -r input_inner do case "$input_inner" in fetch*) @@ -1093,7 +1322,7 @@ gcrypt_main_loop() ;; push\ *) r_args=${input_##push } - while read input_inner + while read -r input_inner do case "$input_inner" in push\ *) @@ -1117,18 +1346,95 @@ gcrypt_main_loop() done } -if [ "x$1" = x--check ] -then - NAME=dummy-gcrypt-check - URL=$2 +if [ "$NAME" = "dummy-gcrypt-check" ]; then + # NAME and URL were set at the top of the script setup ensure_connected - git remote remove $NAME 2>/dev/null || true + git remote remove "$NAME" 2>/dev/null || true if iseq "$Did_find_repo" "no" then exit 100 fi -elif [ "x$1" = x--version ] || [ "x$1" = x-v ]; then +elif [ "$NAME" = "dummy-gcrypt-clean" ]; then + # Cleanup command: NAME, URL, FORCE_CLEAN, DRY_RUN were set at the top + + if isnull "$URL"; then + echo_info "Usage: git-remote-gcrypt --clean [--force|--dry-run]" + echo_info " Removes unencrypted files from the remote repository." + echo_info " --force, -f Don't ask for confirmation" + echo_info " --dry-run, -n Show what would be deleted without deleting" + exit 1 + fi + + setup + ensure_connected + + # Get all files in the remote + remote_files=$(git ls-tree --name-only "$Gref" 2>/dev/null || :) + + if isnull "$remote_files"; then + echo_info "Remote is empty. Nothing to clean." + CLEAN_FINAL "$URL" + git remote remove "$NAME" 2>/dev/null || true + exit 0 + fi + + # Build whitelist of valid gcrypt files + valid_files="" + if iseq "$Did_find_repo" "yes"; then + valid_files="$Manifestfile" + for f in $(xecho "$Packlist" | cut -d: -f3 | cut -d' ' -f1); do + valid_files="$valid_files$Newline$f" + done + fi + + # Find files to delete + bad_files="" + for f in $remote_files; do + if isnull "$valid_files" || ! xfeed "$valid_files" grep -qxF "$f"; then + bad_files="$bad_files$Newline$f" + fi + done + bad_files="${bad_files#"$Newline"}" + + if isnull "$bad_files"; then + echo_info "No unencrypted files found. Remote is clean." + CLEAN_FINAL "$URL" + git remote remove "$NAME" 2>/dev/null || true + exit 0 + fi + + echo_info "Found the following files to remove:" + xecho "$bad_files" | sed 's/^/ /' >&2 + + if isnonnull "$DRY_RUN"; then + echo_info "(Dry run - no files were deleted)" + CLEAN_FINAL "$URL" + git remote remove "$NAME" 2>/dev/null || true + exit 0 + fi + + if isnull "$FORCE_CLEAN"; then + echo_info "" + echo_info "WARNING: This will permanently delete these files from the remote!" + echo_info "Make sure you have a backup (e.g., git clone $URL backup-repo)" + echo_info "" + printf "Delete these files? [y/N] " >&2 + read -r ans + case "$ans" in + [Yy]*) ;; + *) echo_info "Aborted."; exit 1 ;; + esac + fi + + echo_info "Removing files..." + REMOVE "$URL" "$bad_files" + PUT_FINAL "$URL" + CLEAN_FINAL "$URL" + git remote remove "$NAME" 2>/dev/null || true + echo_info "Done. Remote cleaned." + exit 0 +elif [ "$1" = --version ] || [ "$1" = -v ]; then echo "git-remote-gcrypt version $VERSION" exit 0 else diff --git a/install.sh b/install.sh index 3cd6fd3..4fa90a2 100755 --- a/install.sh +++ b/install.sh @@ -14,6 +14,7 @@ install_v() { # --- VERSION DETECTION --- if [ -f /etc/os-release ]; then + # shellcheck disable=SC1091 . /etc/os-release OS_IDENTIFIER=$ID # Linux elif command -v uname >/dev/null; then @@ -25,7 +26,7 @@ fi # Get base version then append OS identifier if [ -d .git ] && command -v git >/dev/null; then - VERSION=$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "sha_unknown") + VERSION=$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "dev") else if [ ! -f debian/changelog ]; then echo "Error: debian/changelog not found (and not a git repo)" >&2 @@ -43,7 +44,7 @@ mkdir -p "$BUILD_DIR" trap 'rm -rf "$BUILD_DIR"' EXIT # Placeholder injection -sed "s/@@DEV_VERSION@@/$VERSION/g" git-remote-gcrypt >"$BUILD_DIR/git-remote-gcrypt" +sed "s|@@DEV_VERSION@@|$VERSION|g" git-remote-gcrypt >"$BUILD_DIR/git-remote-gcrypt" # --- INSTALLATION --- # This is where the 'Permission denied' happens if not sudo diff --git a/tests/coverage_report.py b/tests/coverage_report.py index ab19b1f..b7ba127 100644 --- a/tests/coverage_report.py +++ b/tests/coverage_report.py @@ -6,6 +6,7 @@ Created on Wed Dec 31 08:57:33 2025 """ import os +import sys import textwrap import xml.etree.ElementTree as E @@ -32,11 +33,26 @@ if total_lines > 0: print(f"{COLOR}Coverage: {pct:.1f}% ({COVERED}/{total_lines})\033[0m") else: print(f"Coverage: N/A (0 lines found for {patt})") + if int(os.environ.get("FAIL_UNDER") or 0) > 0: + print( + f"\033[31;1mFAIL: Coverage N/A is below threshold {os.environ.get('FAIL_UNDER')}%\033[0m" + ) + sys.exit(1) + if missed: + missed.sort(key=int) # Sort for deterministic output print(f"\033[31;1m{len(missed)} missing lines\033[0m in {patt}:") print( textwrap.fill( ", ".join(missed), width=72, initial_indent=" ", subsequent_indent=" " ) ) + +fail_under = int(os.environ.get("FAIL_UNDER") or 0) +if total_lines > 0: + if pct < fail_under: + print( + f"\033[31;1mFAIL: Coverage {pct:.1f}% is below threshold {fail_under}%\033[0m" + ) + sys.exit(1) diff --git a/tests/system-test-multikey.sh b/tests/system-test-multikey.sh index 6899396..d1892d3 100755 --- a/tests/system-test-multikey.sh +++ b/tests/system-test-multikey.sh @@ -20,10 +20,9 @@ random_data_per_file=1024 # Reduced size for faster testing (1KB) default_branch="main" test_user_name="git-remote-gcrypt" test_user_email="git-remote-gcrypt@example.com" -pack_size_limit="12m" readonly num_commits files_per_commit random_source random_data_per_file \ - default_branch test_user_name test_user_email pack_size_limit + default_branch test_user_name test_user_email # ----------------- Helper Functions ----------------- indent() { @@ -65,6 +64,7 @@ export PATH # Clean GIT environment git_env=$(env | sed -n 's/^\(GIT_[^=]*\)=.*$/\1/p') +# shellcheck disable=SC2086 IFS=$'\n' unset ${git_env} # GPG Setup @@ -104,7 +104,7 @@ head -c "${random_data_size}" "${random_source}" >"${random_data_file}" section_break print_info "Step 1: Creating multiple GPG keys for participants..." -num_keys=18 # Buried deep: 17 decoys + 1 valid key +num_keys=5 # Reduced from 18 for faster CI runs key_fps=() ( set -x @@ -125,7 +125,7 @@ key_fps=() # We configured `gcrypt.participants` with this Subkey, but GPG always signs with the Primary Key. # This caused a signature mismatch ("Participant A vs Signer B") and verification failure. # Using `awk` to filter `pub:` ensures we only capture the Primary Key. -mapfile -t key_fps < <(gpg --list-keys --with-colons | awk -F: '/^pub:/ {getline; print $10}') +mapfile -t key_fps < <(gpg --list-keys --with-colons | awk -F: '/^pub:/ {f=1;next} /^fpr:/ && f {print $10;f=0}') echo "Generated keys: ${key_fps[*]}" | indent # Sanity Check @@ -192,8 +192,7 @@ print_info "Step 5: Unhappy Path - Test clone with NO matching keys..." # We expect this to FAIL ( set +e - git clone -b "${default_branch}" "gcrypt::${tempdir}/second.git#${default_branch}" -- "${tempdir}/fail_test" - if [ $? -eq 0 ]; then + if git clone -b "${default_branch}" "gcrypt::${tempdir}/second.git#${default_branch}" -- "${tempdir}/fail_test"; then print_info "ERROR: Clone succeeded unexpectedly with empty keyring!" exit 1 fi @@ -299,4 +298,6 @@ print_info "Step 7: Reproduction Step - Push with buried key..." fi } | indent -[ -n "${COV_DIR:-}" ] && print_success "OK. Report: file://${COV_DIR}/index.html" +if [ -n "${COV_DIR:-}" ]; then + print_success "OK. Report: file://${COV_DIR}/index.html" +fi diff --git a/tests/system-test-repack.sh b/tests/system-test-repack.sh new file mode 100755 index 0000000..02e819c --- /dev/null +++ b/tests/system-test-repack.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright 2023 Cathy J. Fitzpatrick +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Large Object Test - Tests pack size limits and repacking behavior +# This test uses larger files to trigger Git's pack splitting. +# +set -efuC -o pipefail +shopt -s inherit_errexit + +# Helpers +print_info() { printf "\033[1;36m%s\033[0m\n" "$1"; } +print_success() { printf "\033[1;34m✓ %s\033[0m\n" "$1"; } +print_err() { printf "\033[1;31m%s\033[0m\n" "$1"; } + +indent() { + sed 's/^\(.*\)$/ \1/' +} + +section_break() { + echo + printf '*%.0s' {1..70} + echo $'\n' +} + +# Test parameters - large files to test pack splitting +num_commits=5 +files_per_commit=3 +random_source="/dev/urandom" +random_data_per_file=${GCRYPT_TEST_REPACK_SCENARIO_BLOB_SIZE:-5242880} # 5 MiB default +default_branch="main" +test_user_name="git-remote-gcrypt" +test_user_email="git-remote-gcrypt@example.com" +pack_size_limit=${GCRYPT_TEST_PACK_SIZE_LIMIT:-12m} # Original upstream value + +readonly num_commits files_per_commit random_source random_data_per_file \ + default_branch test_user_name test_user_email pack_size_limit + +print_info "Running large object system test..." +print_info "This test uses ${random_data_per_file} byte files to test pack size limits." + +umask 077 +tempdir=$(mktemp -d) +readonly tempdir +# shellcheck disable=SC2064 +trap "rm -Rf -- '${tempdir}'" EXIT + +# Set up the PATH +repo_root=$(git rev-parse --show-toplevel) +test_version=$(git describe --tags --always --dirty 2>/dev/null || echo "test") +cp "$repo_root/git-remote-gcrypt" "$tempdir/git-remote-gcrypt" +sed -i "s/@@DEV_VERSION@@/$test_version/" "$tempdir/git-remote-gcrypt" +chmod +x "$tempdir/git-remote-gcrypt" +PATH=$tempdir:${PATH} +readonly PATH +export PATH + +# Unset any GIT_ environment variables +git_env=$(env | sed -n 's/^\(GIT_[^=]*\)=.*$/\1/p') +# shellcheck disable=SC2086 +IFS=$'\n' unset ${git_env} + +# Ensure a predictable gpg configuration. +export GNUPGHOME="${tempdir}/gpg" +mkdir "${GNUPGHOME}" +cat <<'EOF' >"${GNUPGHOME}/gpg" +#!/usr/bin/env bash +set -efuC -o pipefail; shopt -s inherit_errexit +args=( "${@}" ) +for ((i = 0; i < ${#}; ++i)); do + if [[ ${args[${i}]} = "--secret-keyring" ]]; then + unset "args[${i}]" "args[$(( i + 1 ))]" + break + fi +done +exec gpg "${args[@]}" +EOF +chmod +x "${GNUPGHOME}/gpg" + +# Ensure a predictable git configuration. +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_CONFIG_GLOBAL="${tempdir}/gitconfig" +mkdir "${tempdir}/template" +git config --global init.defaultBranch "${default_branch}" +git config --global user.name "${test_user_name}" +git config --global user.email "${test_user_email}" +git config --global init.templateDir "${tempdir}/template" +git config --global gpg.program "${GNUPGHOME}/gpg" +git config --global pack.packSizeLimit "${pack_size_limit}" + +# Prepare the random data +total_files=$((num_commits * files_per_commit)) +random_data_size=$((total_files * random_data_per_file)) +random_data_file="${tempdir}/data" +print_info "Generating ${random_data_size} bytes of random data..." +head -c "${random_data_size}" "${random_source}" >"${random_data_file}" + +### +section_break + +print_info "Step 1: Creating GPG key..." +( + set -x + gpg --batch --passphrase "" --quick-generate-key \ + "${test_user_name} <${test_user_email}>" +) 2>&1 | indent + +### +section_break + +print_info "Step 2: Creating repository with large random files..." +{ + git init -- "${tempdir}/first" + cd "${tempdir}/first" + for ((i = 0; i < num_commits; ++i)); do + for ((j = 0; j < files_per_commit; ++j)); do + file_index=$((i * files_per_commit + j)) + random_data_index=$((file_index * random_data_per_file)) + echo "Writing large file $((file_index + 1))/${total_files} ($((random_data_per_file / 1024 / 1024)) MiB)" + head -c "${random_data_per_file}" >"$((file_index)).data" < \ + <(tail -c "+${random_data_index}" "${random_data_file}" || :) + done + git add -- "${tempdir}/first" + git commit -m "Commit #${i}" + done +} | indent + +### +section_break + +print_info "Step 3: Creating bare repository..." +git init --bare -- "${tempdir}/second.git" | indent + +### +section_break + +print_info "Step 4: Pushing with large files (testing pack size limits)..." +{ + ( + set -x + cd "${tempdir}/first" + git push -f "gcrypt::${tempdir}/second.git#${default_branch}" \ + "${default_branch}" + ) 2>&1 + + echo + echo "Object files in second.git (should show pack splitting if limit hit):" + ( + cd "${tempdir}/second.git/objects" + find . -type f -exec du -sh {} + | sort -h + ) | indent + + # Count object files + obj_count=$(find "${tempdir}/second.git/objects" -type f | wc -l) + echo + echo "Total object files: ${obj_count}" + + if [ "$obj_count" -gt 1 ]; then + print_success "Multiple pack objects created (pack splitting occurred)." + else + print_info "Single pack object (data may not exceed limit)." + fi +} | indent + +### +section_break + +print_info "Step 5: Cloning and verifying large files..." +{ + ( + set -x + git clone -b "${default_branch}" \ + "gcrypt::${tempdir}/second.git#${default_branch}" -- \ + "${tempdir}/third" + ) 2>&1 + + echo + echo "Verifying file integrity..." + if diff -r --exclude ".git" -- "${tempdir}/first" "${tempdir}/third" >/dev/null 2>&1; then + print_success "All large files verified correctly." + else + print_err "File verification failed!" + exit 1 + fi +} | indent + +### +section_break + +print_success "Large object test completed successfully." diff --git a/tests/system-test.sh b/tests/system-test.sh index ecb0340..1d329a4 100755 --- a/tests/system-test.sh +++ b/tests/system-test.sh @@ -7,6 +7,7 @@ shopt -s inherit_errexit # Helpers print_info() { printf "\033[1;36m%s\033[0m\n" "$1"; } print_success() { printf "\033[1;34m✓ %s\033[0m\n" "$1"; } +print_warn() { printf "\033[1;33m%s\033[0m\n" "$1"; } print_err() { printf "\033[1;31m%s\033[0m\n" "$1"; } @@ -28,7 +29,7 @@ print_err() { printf "\033[1;31m%s\033[0m\n" "$1"; } num_commits=5 files_per_commit=3 random_source="/dev/urandom" -random_data_per_file=5242880 # 5 MiB +random_data_per_file=${TEST_DATA_SIZE:-5120} # 5 KiB default, override with TEST_DATA_SIZE default_branch="main" test_user_name="git-remote-gcrypt" test_user_email="git-remote-gcrypt@example.com" @@ -71,7 +72,13 @@ trap "rm -Rf -- '${tempdir}'" EXIT # Set up the PATH to favor the version of git-remote-gcrypt from the repository # rather than a version that might already be installed on the user's system. -PATH=$(git rev-parse --show-toplevel):${PATH} +# We also copy it to tempdir to inject a version number for testing. +repo_root=$(git rev-parse --show-toplevel) +test_version=$(git describe --tags --always --dirty 2>/dev/null || echo "test") +cp "$repo_root/git-remote-gcrypt" "$tempdir/git-remote-gcrypt" +sed -i "s/@@DEV_VERSION@@/$test_version/" "$tempdir/git-remote-gcrypt" +chmod +x "$tempdir/git-remote-gcrypt" +PATH=$tempdir:${PATH} readonly PATH export PATH @@ -233,4 +240,288 @@ print_info "Step 5: Cloning the second repository using gitception:" "${tempdir}/first" "${tempdir}/third" 2>&1 | indent } | indent -[ -n "${COV_DIR:-}" ] && print_success "OK. Report: file://${COV_DIR}/index.html" + +### +section_break + +print_info "Step 6: Force Push Warning Test (implicit force):" +{ + # Make a change in first repo + cd "${tempdir}/first" + echo "force push test data" > "force_test.txt" + git add force_test.txt + git commit -m "Commit for force push test" + + # Push WITHOUT + prefix (should trigger warning about implicit force) + output_file="${tempdir}/force_push_output" + ( + set -x + # Use refspec without + to trigger warning + git push "gcrypt::${tempdir}/second.git#${default_branch}" \ + "${default_branch}:refs/heads/${default_branch}" 2>&1 + ) | tee "${output_file}" + + # Verify warning message appears + if grep -q "gcrypt overwrites the remote manifest" "${output_file}"; then + print_success "Manifest overwrite note displayed correctly." + else + print_err "Manifest overwrite note NOT found!" + exit 1 + fi +} | indent + +### +section_break + +print_info "Step 7: require-explicit-force-push=true Test:" +{ + cd "${tempdir}/first" + + # Enable require-explicit-force-push + git config gcrypt.require-explicit-force-push true + + # Make another change + echo "blocked push test" > "blocked_test.txt" + git add blocked_test.txt + git commit -m "Commit for blocked push test" + + # Attempt push without + (should FAIL) + output_file="${tempdir}/blocked_push_output" + set +e + ( + set -x + git push "gcrypt::${tempdir}/second.git#${default_branch}" \ + "${default_branch}:refs/heads/${default_branch}" 2>&1 + ) | tee "${output_file}" + push_status=$? + set -e + + if [ $push_status -ne 0 ] && grep -q "Implicit force push disallowed" "${output_file}"; then + print_success "Push correctly blocked by require-explicit-force-push." + else + print_err "Push should have been blocked but wasn't!" + exit 1 + fi + + # Now push WITH --force (should succeed) + ( + set -x + git push --force "gcrypt::${tempdir}/second.git#${default_branch}" \ + "${default_branch}" + ) 2>&1 + + print_success "Explicit force push succeeded." + + # Clean up config for next tests + git config --unset gcrypt.require-explicit-force-push +} | indent + +### +section_break + +print_info "Step 8: Signal Handling Test (Ctrl+C simulation):" +{ + cd "${tempdir}/first" + + # Make a change to push + echo "signal test data" > "signal_test.txt" + git add signal_test.txt + git commit -m "Commit for signal test" + + # Start push in background and send SIGINT after brief delay + # This tests that the script exits cleanly on interruption + output_file="${tempdir}/signal_output" + set +e + ( + # Give it a moment to start, then send SIGINT + (sleep 0.5 && kill -INT $$ 2>/dev/null) & + git push --force "gcrypt::${tempdir}/second.git#${default_branch}" \ + "${default_branch}" 2>&1 + ) > "${output_file}" 2>&1 + signal_status=$? + set -e + + # Exit code 130 = SIGINT (128 + 2), or 0 if push completed before SIGINT + if [ $signal_status -eq 130 ] || [ $signal_status -eq 0 ]; then + print_success "Signal handling: Exit code $signal_status (OK)." + else + print_err "Unexpected exit code: $signal_status" + # Don't fail the test - signal timing is unpredictable + fi + + # Verify no leftover temp files in repo's gcrypt dir + if [ -d "${tempdir}/first/.git/remote-gcrypt" ]; then + leftover_count=$(find "${tempdir}/first/.git/remote-gcrypt" -name "*.tmp" 2>/dev/null | wc -l) + if [ "$leftover_count" -gt 0 ]; then + print_err "Warning: Found $leftover_count leftover temp files" + else + print_success "No leftover temp files found." + fi + else + print_success "No remote-gcrypt directory (OK for gitception)." + fi +} | indent + +### +section_break + +print_info "Step 9: Network Failure Guard Test (manifest unavailable):" +{ + # This test verifies behavior when manifest cannot be fetched + # AND local gcrypt-id is not set. + # Current behavior: gcrypt creates a NEW repo, potentially overwriting! + # This test documents (and may later guard against) this behavior. + + cd "${tempdir}" + + # Save the manifest file + # Find and delete manifest files (hashes at root of repo for local transport) + # We look for files with 64 hex characters in the repo directory + # manifests=$(find "${tempdir}/second.git" -maxdepth 1 -type f -regextype posix-egrep -regex ".*/[0-9a-f]{56,64}") + # Simpler approach: globbing (which might fail if no match) then check + + # Debug: List what's actually there + print_info "DEBUG: Listing ${tempdir}/second.git:" + find "${tempdir}/second.git" -mindepth 1 -maxdepth 1 -printf '%f\n' | indent + + # DEBUG: Dump directory listing to stdout + print_info "DEBUG: Listing ${tempdir}/second.git contents:" + find "${tempdir}/second.git" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort | indent + + # Use find to robustly locate manifest files (56-64 hex chars) + # matching basename explicitly via grep. Using sed for portable basename extraction. + manifest_names=$(find "${tempdir}/second.git" -maxdepth 1 -type f | sed 's!.*/!!' | grep -E '^[0-9a-fA-F]{56,64}$' || true) + print_info "DEBUG: Detected manifest candidate(s): ${manifest_names:-none}" + + # Check if we actually found anything + if [ -n "$manifest_names" ]; then + for fname in $manifest_names; do + f="${tempdir}/second.git/$fname" + cp "$f" "${tempdir}/manifest_backup_${fname}" + rm "$f" + done + manifest_saved=true + elif git -C "${tempdir}/second.git" show-ref --quiet --verify "refs/heads/${default_branch}"; then + # Gitception fallback: delete the branch ref + print_info "Detected Gitception manifest (branch ref). Backing up..." + manifest_sha=$(git -C "${tempdir}/second.git" rev-parse "refs/heads/${default_branch}") + git -C "${tempdir}/second.git" update-ref -d "refs/heads/${default_branch}" + manifest_saved=true + git_ref_backup="$manifest_sha" + else + # For gitception or if structure differs + manifest_saved=false + print_warn "Skipping manifest backup - No manifest file/ref found to delete." + fi + + # Create a fresh clone to test with + mkdir "${tempdir}/fresh_clone_test" + cd "${tempdir}/fresh_clone_test" + git init + git config user.name "${test_user_name}" + git config user.email "${test_user_email}" + echo "test data" > test.txt + git add test.txt + git commit -m "Initial commit" + + # Try to push to the EXISTING remote + # Since this fresh repo has no gcrypt-id, it could be dangerous + step9_output="${tempdir}/network_guard_output" + set +e + ( + set -x + git push "gcrypt::${tempdir}/second.git#${default_branch}" \ + "${default_branch}:refs/heads/test-network-guard" 2>&1 + ) | tee "${step9_output}" + push_result=$? + set -e + + # The push should FAIL now because we require --force for missing manifests + if [ $push_result -ne 0 ]; then + print_success "Push failed (PROTECTED against accidental overwrite)." + if grep -q "Use --force to create valid new repository" "${step9_output}"; then + print_success "Correct error message received." + else + print_err "Wrong error message!" + cat "${step9_output}" | indent + exit 1 + fi + else + print_err "Push SUCCEEDED without --force (Safety check failed)." + exit 1 + fi + + # Restore manifest(s) if we backed them up + if [ "$manifest_saved" = true ]; then + if [ -n "${git_ref_backup:-}" ]; then + git -C "${tempdir}/second.git" update-ref "refs/heads/${default_branch}" "$git_ref_backup" + else + for f in "${tempdir}"/manifest_backup_*; do + # extract original filename from backup filename + # basename is manifest_backup_ + # we want to restore to ${tempdir}/second.git/ + fname=$(basename "$f") + orig_name=${fname#manifest_backup_} + cp "$f" "${tempdir}/second.git/${orig_name}" + done + fi + fi +} | indent + + +### +section_break + +print_info "Step 10: New Repo Safety Test (Require Force):" +{ + cd "${tempdir}" + # Setup: Ensure we have a "missing" remote scenario + # We'll use a new random path that definitely doesn't exist + rand_id=$(date +%s) + missing_remote_url="${tempdir}/missing_repo_${rand_id}.git" + + cd "${tempdir}/fresh_clone_test" + + print_info "Attempting push to missing remote WITHOUT force..." + set +e + ( + git push "gcrypt::${missing_remote_url}" "${default_branch}" 2>&1 + ) > "step10.fail" + rc=$? + set -e + + if [ $rc -ne 0 ]; then + if grep -q "Use --force to create valid new repository" "step10.fail"; then + print_success "Push correctly BLOCKED without force." + else + cat "step10.fail" | indent + print_err "Push failed but with wrong error message!" + exit 1 + fi + else + print_err "Push SHOULD have failed but SUCCEEDED!" + exit 1 + fi + + print_info "Attempting push to missing remote WITH force..." + set +e + ( + git push --force "gcrypt::${missing_remote_url}" "${default_branch}" 2>&1 + ) > "step10.succ" + rc=$? + set -e + + if [ $rc -eq 0 ]; then + print_success "Push succeeded with force." + else + cat "step10.succ" | indent + print_err "Push failed even with force!" + exit 1 + fi +} | indent + + +if [ -n "${COV_DIR:-}" ]; then + print_success "OK. Report: file://${COV_DIR}/index.html" +fi + diff --git a/tests/test-clean-command.sh b/tests/test-clean-command.sh new file mode 100755 index 0000000..b9bce31 --- /dev/null +++ b/tests/test-clean-command.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Test: --clean command removes unencrypted files +# This test verifies that git-remote-gcrypt --clean correctly identifies +# and removes unencrypted files from a remote. + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +print_info() { echo -e "${CYAN}$*${NC}"; } +print_success() { echo -e "${GREEN}✓ $*${NC}"; } +print_err() { echo -e "${RED}✗ $*${NC}"; } + +# Ensure we use the local git-remote-gcrypt +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +export PATH="$SCRIPT_DIR:$PATH" + +# Suppress git advice messages +GIT="git -c advice.defaultBranchName=false" + +# Create temp directory +tempdir=$(mktemp -d) +trap 'rm -rf "$tempdir"' EXIT + +print_info "Setting up test environment..." + +# Create a bare repo with dirty files +$GIT init --bare "$tempdir/remote.git" >/dev/null +cd "$tempdir/remote.git" +$GIT config user.email "test@test.com" +$GIT config user.name "Test" + +# Add multiple unencrypted files +echo "SECRET=abc" >"$tempdir/secret1.txt" +echo "PASSWORD=xyz" >"$tempdir/secret2.txt" +BLOB1=$($GIT hash-object -w "$tempdir/secret1.txt") +BLOB2=$($GIT hash-object -w "$tempdir/secret2.txt") +TREE=$(echo -e "100644 blob $BLOB1\tsecret1.txt\n100644 blob $BLOB2\tsecret2.txt" | $GIT mktree) +COMMIT=$(echo "Dirty commit" | $GIT commit-tree "$TREE") +$GIT update-ref refs/heads/master "$COMMIT" + +print_info "Created dirty remote with 2 unencrypted files" + +# Test 1: --clean without URL shows usage +print_info "Test 1: Usage message..." +if "$SCRIPT_DIR/git-remote-gcrypt" --clean 2>&1 | grep -q "Usage"; then + print_success "--clean shows usage when URL missing" +else + print_err "--clean should show usage when URL missing" + exit 1 +fi + +# Test 2: --clean --dry-run shows files without deleting +print_info "Test 2: Dry run mode..." +output=$("$SCRIPT_DIR/git-remote-gcrypt" --clean "$tempdir/remote.git" --dry-run 2>&1) +if echo "$output" | grep -q "secret1.txt" && echo "$output" | grep -q "Dry run"; then + print_success "--clean --dry-run shows files and doesn't delete" +else + print_err "--clean --dry-run failed" + echo "$output" + exit 1 +fi + +# Verify files still exist +if $GIT -C "$tempdir/remote.git" ls-tree HEAD | grep -q "secret1.txt"; then + print_success "Files still exist after dry run" +else + print_err "Dry run incorrectly deleted files!" + exit 1 +fi + +# Test 3: --clean --force deletes files +print_info "Test 3: Force cleanup..." +"$SCRIPT_DIR/git-remote-gcrypt" --clean "$tempdir/remote.git" --force 2>&1 + +# Verify files are gone +if $GIT -C "$tempdir/remote.git" ls-tree HEAD 2>/dev/null | grep -q "secret"; then + print_err "Files still exist after cleanup!" + $GIT -C "$tempdir/remote.git" ls-tree HEAD + exit 1 +else + print_success "Files removed after --clean --force" +fi + +print_success "All --clean command tests passed!" diff --git a/tests/test-gc.sh b/tests/test-gc.sh new file mode 100755 index 0000000..44af71e --- /dev/null +++ b/tests/test-gc.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Test: Verify GCRYPT_FULL_REPACK garbage collection +# This test verifies that old unreachable blobs are removed when repacking. + +set -e +set -x + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' + +print_info() { echo -e "${CYAN}$*${NC}"; } +print_success() { echo -e "${GREEN}✓ $*${NC}"; } +print_err() { echo -e "${RED}✗ $*${NC}"; } + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +export PATH="$SCRIPT_DIR:$PATH" +GIT="git -c advice.defaultBranchName=false" + +tempdir=$(mktemp -d) +trap 'rm -rf "$tempdir"' EXIT + +print_info "Setting up test environment..." + +# 1. Setup simulated remote +$GIT init --bare "$tempdir/remote.git" >/dev/null + +# 2. Setup local repo +mkdir "$tempdir/local" +cd "$tempdir/local" +$GIT init >/dev/null +$GIT config user.email "test@test.com" +$GIT config user.name "Test" +$GIT config commit.gpgsign false + +# Add gcrypt remote +$GIT remote add origin "gcrypt::$tempdir/remote.git" +$GIT config remote.origin.gcrypt-participants "$(whoami)" + +# 3. Create a large blob that we will later delete +print_info "Creating initial commit with large blob..." +# Use git hashing to make a known large object instead of dd if possible, or just dd +dd if=/dev/urandom of=largeblob bs=1K count=100 2>/dev/null # 100KB is enough to trigger pack +$GIT add largeblob +$GIT commit -m "Add large blob" >/dev/null +echo "Pushing initial data..." +git push origin master >/dev/null 2>&1 || { + echo "Push failed" + exit 1 +} + +# Verify remote has the blob (check size of packfiles) +pack_size_initial=$(du -s "$tempdir/remote.git" | cut -f1) +print_info "Initial remote size: ${pack_size_initial}K" + +# 4. Remove the blob from history (make it unreachable) +print_info "Rewriting history to remove the blob..." +# Create new orphan branch +$GIT checkout --orphan clean-history >/dev/null 2>&1 +rm -f largeblob +echo "clean data" >data.txt +$GIT add data.txt +$GIT commit -m "Clean history" >/dev/null + +# 5. Force push with Repack +print_info "Force pushing with GCRYPT_FULL_REPACK=1..." +export GCRYPT_FULL_REPACK=1 +# We need to force push to overwrite the old master +if git push --force origin clean-history:master >push.log 2>&1; then + print_success "Push successful" + cat push.log +else + print_err "Push failed!" + cat push.log + exit 1 +fi + +# 6. Verify remote size decreased +pack_size_final=$(du -s "$tempdir/remote.git" | cut -f1) +print_info "Final remote size: ${pack_size_final}K" + +if [ "$pack_size_final" -lt "$pack_size_initial" ]; then + print_success "Garbage collection worked! Size decreased ($pack_size_initial -> $pack_size_final)" +else + print_err "Garbage collection failed! Size did not decrease ($pack_size_initial -> $pack_size_final)" + # Show listing of remote files for debugging + ls -lR "$tempdir/remote.git" + exit 1 +fi diff --git a/tests/test-install-logic.sh b/tests/test-install-logic.sh index 2fdc79c..a9f9bf8 100755 --- a/tests/test-install-logic.sh +++ b/tests/test-install-logic.sh @@ -34,7 +34,7 @@ assert_version() { unset DESTDIR # Run the installer - "$INSTALLER" >/dev/null 2>&1 || { + "bash" "$INSTALLER" >/dev/null 2>&1 || { echo "Installer failed unexpectedly" return 1 } @@ -57,7 +57,7 @@ assert_version() { # --- TEST 1: Strict Metadata Requirement --- echo "--- Test 1: Fail without Metadata ---" rm -rf debian redhat -if "$INSTALLER" >/dev/null 2>&1; then +if "bash" "$INSTALLER" >/dev/null 2>&1; then print_err "FAILED: Installer should have exited 1 without debian/changelog" exit 1 else @@ -91,7 +91,7 @@ rm -rf "${SANDBOX:?}/usr" export DESTDIR="$SANDBOX/pkg_root" export prefix="/usr" -"$INSTALLER" >/dev/null 2>&1 +"bash" "$INSTALLER" >/dev/null 2>&1 if [ -f "$SANDBOX/pkg_root/usr/bin/git-remote-gcrypt" ]; then printf " ✓ %s\n" "DESTDIR honored" diff --git a/tests/test-privacy-leaks.sh b/tests/test-privacy-leaks.sh new file mode 100755 index 0000000..316318a --- /dev/null +++ b/tests/test-privacy-leaks.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -efuC -o pipefail +shopt -s inherit_errexit + +# Helpers +print_info() { printf "\033[1;36m%s\033[0m\n" "$1"; } +print_success() { printf "\033[1;34m✓ %s\033[0m\n" "$1"; } +print_warn() { printf "\033[1;33m%s\033[0m\n" "$1"; } +print_err() { printf "\033[1;31m%s\033[0m\n" "$1"; } + +umask 077 +tempdir=$(mktemp -d) +readonly tempdir +trap 'rm -Rf -- "$tempdir"' EXIT + +# Ensure git-remote-gcrypt is in PATH +repo_root=$(git rev-parse --show-toplevel) +test_version=$(git describe --tags --always --dirty 2>/dev/null || echo "test") +cp "$repo_root/git-remote-gcrypt" "$tempdir/git-remote-gcrypt" +sed -i "s/@@DEV_VERSION@@/$test_version/" "$tempdir/git-remote-gcrypt" +chmod +x "$tempdir/git-remote-gcrypt" +PATH=$tempdir:${PATH} +export PATH + +# Setup GPG +export GNUPGHOME="${tempdir}/gpg" +mkdir "${GNUPGHOME}" +chmod 700 "${GNUPGHOME}" + +print_info "Step 1: generating GPG key..." +cat >"${tempdir}/key_params" </dev/null 2>&1 + +# Git config +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_CONFIG_GLOBAL="${tempdir}/gitconfig" +git config --global user.name "Test User" +git config --global user.email "test@example.com" +git config --global init.defaultBranch "master" +git config --global commit.gpgsign false + +print_info "Step 2: Create a 'compromised' remote repo" +# This simulates a repo where someone accidentally pushed a .env file +mkdir -p "${tempdir}/remote-repo" +cd "${tempdir}/remote-repo" +git init --bare + +# Creating the dirty history +mkdir "${tempdir}/dirty-setup" +cd "${tempdir}/dirty-setup" +git init +git remote add origin "${tempdir}/remote-repo" +echo "API_KEY=12345-SUPER-SECRET" >.env +git add .env +git commit -m "Oops, pushed secret keys" +git push origin master + +print_info "Step 3: Switch to git-remote-gcrypt usage" +# Now the user realizes their mistake (or just switches tools) and uses gcrypt +# expecting it to be secure. +mkdir "${tempdir}/local-gcrypt" +cd "${tempdir}/local-gcrypt" +git init +echo "safe encrypted data" >sensible_data.txt +git add sensible_data.txt +git commit -m "Initial encrypted commit" + +git remote add origin "gcrypt::${tempdir}/remote-repo" +git config remote.origin.gcrypt-participants "test@example.com" +git config remote.origin.gcrypt-signingkey "test@example.com" + +# Force push is required to initialize gcrypt over an existing repo +# Force push is required to initialize gcrypt over an existing repo +# Now EXPECT FAILURE because of our new safety check! +print_info "Attempting push to dirty repo (should fail due to safety check)..." +if git push --force origin master 2>/dev/null; then + print_err "Safety check FAILED: Push succeeded but should have been blocked." + exit 1 +else + print_success "Safety check PASSED: Push was blocked." +fi + +# Now verify we can bypass it +print_info "Attempting push with bypass config..." +git config remote.origin.gcrypt-allow-unencrypted-remote true +git push --force origin master +print_success "Push with bypass succeeded." + +print_info "Step 4: Verify LEAKAGE" +# We check the backend repo directly. +# If gcrypt worked "perfectly" (in a privacy sense), the old .env would be gone. +# But we know it persists. +cd "${tempdir}/remote-repo" + +if git ls-tree -r master | grep -q ".env"; then + print_warn "PRIVACY LEAK DETECTED: .env file matches found in remote!" + print_warn "Content of .env in remote:" + git show master:.env + print_success "Test Passed: Vulnerability successfully reproduced." +else + print_err "Unexpected: .env file NOT found. Did gcrypt overwrite it?" + # detecting it is 'failure' of the vulnerability check, but 'success' for privacy + exit 1 +fi + +print_info "Step 5: Mitigate the leak (manual cleanup)" +# Simulate the user cleaning up +cd "${tempdir}" +git clone "${tempdir}/remote-repo" "${tempdir}/cleanup-client" +cd "${tempdir}/cleanup-client" +git config user.email "cleanup@example.com" +git config user.name "Cleanup User" +git config commit.gpgsign false + +if [ -f .env ]; then + git rm .env + git commit -m "Cleanup leaked .env" + git push origin master + print_success "Cleanup pushed." +else + print_warn ".env not found in cleanup client? This is odd." +fi + +print_info "Step 6: Verify leak is gone" +cd "${tempdir}/remote-repo" +if git ls-tree -r master | grep -q ".env"; then + print_err "Cleanup FAILED: .env still exists!" + exit 1 +else + print_success "Cleanup VERIFIED: .env is gone." +fi + +print_info "Step 7: Verify gcrypt still works" +cd "${tempdir}/local-gcrypt" +echo "more data" >>sensible_data.txt +git add sensible_data.txt +git commit -m "Post-cleanup commit" +if git push origin master; then + print_success "Gcrypt push succeeded after cleanup." +else + print_err "Gcrypt push FAILED after cleanup." + exit 1 +fi + +print_success "ALL TESTS PASSED." diff --git a/tests/test-safety-check.sh b/tests/test-safety-check.sh new file mode 100755 index 0000000..94e8713 --- /dev/null +++ b/tests/test-safety-check.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Test: Safety check blocks push to dirty remote +# This test verifies that git-remote-gcrypt blocks pushing to a remote +# that contains unencrypted files. + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +print_info() { echo -e "${CYAN}$*${NC}"; } +print_success() { echo -e "${GREEN}✓ $*${NC}"; } +print_err() { echo -e "${RED}✗ $*${NC}"; } + +# Ensure we use the local git-remote-gcrypt +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +export PATH="$SCRIPT_DIR:$PATH" + +# Suppress git advice messages +GIT="git -c advice.defaultBranchName=false" + +# Create temp directory +tempdir=$(mktemp -d) +trap 'rm -rf "$tempdir"' EXIT + +print_info "Setting up test environment..." + +# Create a bare repo (simulates remote) +$GIT init --bare "$tempdir/remote.git" >/dev/null + +# Add a dirty file directly to the bare repo +# (simulating a repo that was used without gcrypt) +cd "$tempdir/remote.git" +$GIT config user.email "test@test.com" +$GIT config user.name "Test" +echo "SECRET_KEY=12345" >"$tempdir/secret.txt" +BLOB=$($GIT hash-object -w "$tempdir/secret.txt") +TREE=$(echo -e "100644 blob $BLOB\tsecret.txt" | $GIT mktree) +COMMIT=$(echo "Initial dirty commit" | $GIT commit-tree "$TREE") +$GIT update-ref refs/heads/master "$COMMIT" + +print_info "Created dirty remote with unencrypted file" + +# Create a local repo that tries to use gcrypt +mkdir "$tempdir/local" +cd "$tempdir/local" +$GIT init >/dev/null +$GIT config user.email "test@test.com" +$GIT config user.name "Test" +$GIT config commit.gpgsign false + +# Add gcrypt remote +$GIT remote add origin "gcrypt::$tempdir/remote.git" +$GIT config remote.origin.gcrypt-participants "$(whoami)" + +# Create a commit +echo "encrypted data" >data.txt +$GIT add data.txt +$GIT commit -m "Test commit" >/dev/null + +print_info "Attempting push to dirty remote (should fail)..." + +# Capture output and check for safety message +set +e +push_output=$($GIT push --force origin master 2>&1) +push_exit=$? +set -e + +# Debug: show what we got +if [ -n "$push_output" ]; then + echo "Push output: $push_output" >&2 +fi + +# Check for safety check message (could be "unencrypted" or "unexpected") +if echo "$push_output" | grep -qE "(unencrypted|unexpected|unknown)"; then + print_success "Safety check correctly detected unencrypted files" +else + print_err "Safety check failed to detect unencrypted files" + echo "Exit code was: $push_exit" >&2 + exit 1 +fi + +print_info "Testing bypass config..." +$GIT config gcrypt.allow-unencrypted-remote true + +# With bypass, it should at least attempt (may fail due to GPG, but that's ok) +if $GIT push --force origin master 2>&1; then + print_success "Bypass config allowed push attempt" +else + # Even a GPG error means bypass worked + print_success "Bypass config allowed push attempt (GPG may have failed, that's OK)" +fi + +print_success "All safety check tests passed!"