diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..606a808 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +[[bash]] +# For extension-less files (i.e., git-remote-gcrypt) +indent_style=tab +indent_size=4 + +[*.sh] +# For bash scripts with .sh extension +indent_style=tab +indent_size=4 + diff --git a/.github/workflows/compatibility.yaml b/.github/workflows/compatibility.yaml new file mode 100644 index 0000000..447c940 --- /dev/null +++ b/.github/workflows/compatibility.yaml @@ -0,0 +1,106 @@ +--- +name: compat + +on: + pull_request: + schedule: + - cron: "0 0 * * 0" # Weekly on Sundays + +jobs: + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Validate 3 versions of git and GnuPG + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + git: + name: Git & GnuPG + runs-on: ubuntu-latest + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: + image: [ubuntu:20.04, ubuntu:22.04, ubuntu:24.04] + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies (container is root) + run: | + apt-get update + apt-get install -y curl gnupg make git python3-docutils rsync + + - name: Tool versions (check) + run: | + gpg --version + git --version + + - name: Run Tests + run: make test + + # Ubuntu 20.04 + # gpg (GnuPG) 2.2.19 + # libgcrypt 1.8.5 + # Home: /github/home/.gnupg + # Supported algorithms: + # Pubkey: RSA, ELG, DSA, ECDH, ECDSA, EDDSA + # Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH, + # CAMELLIA128, CAMELLIA192, CAMELLIA256 + # Hash: SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224 + # Compression: Uncompressed, ZIP, ZLIB, BZIP2 + # git version 2.25.1 + + # Ubuntu 22.04 + # gpg (GnuPG) 2.2.27 + # libgcrypt 1.9.4 + # Home: /github/home/.gnupg + # Supported algorithms: + # Pubkey: RSA, ELG, DSA, ECDH, ECDSA, EDDSA + # Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH, + # CAMELLIA128, CAMELLIA192, CAMELLIA256 + # Hash: SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224 + # Compression: Uncompressed, ZIP, ZLIB, BZIP2 + # git version 2.34.1 + + # Ubuntu 24.04 + # gpg (GnuPG) 2.4.4 + # libgcrypt 1.10.3 + # Home: /github/home/.gnupg + # Supported algorithms: + # Pubkey: RSA, ELG, DSA, ECDH, ECDSA, EDDSA + # Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH, + # CAMELLIA128, CAMELLIA192, CAMELLIA256 + # Hash: SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224 + # Compression: Uncompressed, ZIP, ZLIB, BZIP2 + # git version 2.43.0 + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Validate completions for bash, zsh, and fish + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + shell-bash-zsh-fish: + name: Shell Completion Syntax + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Shells + run: | + sudo apt-get update + sudo apt-get install -y zsh fish + + - name: Tool versions (check) + run: | + bash --version + zsh --version + fish --version + + - name: Generate Completions (if not up to date) + run: make generate + + - name: Check Bash Syntax + run: bash -n completions/bash/git-remote-gcrypt + + - name: Check Zsh Syntax + run: zsh -n completions/zsh/_git-remote-gcrypt + + - name: Check Fish Syntax + run: fish --no-execute completions/fish/git-remote-gcrypt.fish diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 0000000..4c25036 --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,60 @@ +--- +name: Coverage + +"on": + push: + schedule: + - cron: "0 0 * * 0" # Sunday at 12 AM + +jobs: + test-coverage: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Cache kcov + id: cache-kcov + uses: actions/cache@v4 + with: + path: /usr/local/bin/kcov + key: ${{ runner.os }}-kcov-v1 + + # python3-docutils enables rst2man + - name: Install kcov Dependencies + run: | + sudo apt-get update + sudo apt-get install -y binutils-dev build-essential cmake git \ + libssl-dev libcurl4-openssl-dev libelf-dev libdw-dev \ + libiberty-dev zlib1g-dev libstdc++-12-dev \ + python3-docutils + + # Build & install kcov (on cache miss) + - name: Build and Install kcov + if: steps.cache-kcov.outputs.cache-hit != 'true' + run: | + git clone --branch v42 --depth 1 https://github.com/SimonKagstrom/kcov.git + cd kcov + mkdir build && cd build + cmake .. + make -j$(nproc) + sudo make install + + - name: Check kcov installation + run: | + ls -l /usr/local/bin/kcov || echo "Kcov not found!" + kcov --version + + - name: Test Installer [make test/installer] + run: make test/installer + + - name: Test System [make test/system] + run: make test/system + + - name: Upload to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + # Path changed to match your Makefile's COV_ROOT (.coverage) + directory: ./.coverage diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 0000000..c0bcffc --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,114 @@ +--- +name: install + +on: + push: + workflow_dispatch: + inputs: + debug: + description: "Enable debug logging (GCRYPT_DEBUG=1)" + required: false + type: boolean + default: false + schedule: + - cron: "0 0 * * 0" # Sunday at 12 AM + +permissions: + contents: read + +jobs: + # Handles Ubuntu and macOS + Unix: + runs-on: ${{ matrix.os }} + + env: + GCRYPT_DEBUG: ${{ inputs.debug && '1' || '' }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y git python3-docutils + + - name: Dependencies (macOS) + if: runner.os == 'macOS' + run: brew install coreutils python3 git docutils + + - name: Help [make] + run: make + + - name: Test Installer + run: bash ./tests/installer-test-logic.sh + + - name: Install [make install] + run: sudo make install + + - name: Verify [make check/install] + run: make check/install + + # Handles RedHat (UBI Container) + RedHat: + runs-on: ubuntu-latest + + env: + GCRYPT_DEBUG: ${{ inputs.debug && '1' || '' }} + + container: + image: registry.access.redhat.com/ubi9/ubi:latest + + steps: + - uses: actions/checkout@v4 + + # dnf is slow in containers. We cache the dnf cache directory. + - name: Cache DNF + uses: actions/cache@v4 + with: + path: /var/cache/dnf + key: ${{ runner.os }}-ubi9-dnf-v1 + restore-keys: | + ${{ runner.os }}-ubi9-dnf- + + - name: Dependencies [redhat] + run: dnf install -y git python3-docutils make man-db + + - name: Help [make] + run: make + + - name: Test Installer + run: bash ./tests/installer-test-logic.sh + + - name: Install [make install] + run: make install # container runs as sudo + + - name: Verify [make check/install] + run: make check/install + + # Handles Termux (Android shell) + termux-android: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Fix permissions for Docker + run: chmod -R 777 . + + - name: Run tests in Termux + run: | + docker run --rm \ + -v "$PWD":/app \ + -w /app \ + termux/termux-docker:latest \ + /system/bin/sh -c "export PATH=/data/data/com.termux/files/usr/bin:\$PATH; \ + apt-get update && \ + apt-get install -y git make bash python man && \ + pip install docutils && \ + make install && \ + make check/install && \ + git-remote-gcrypt --version" diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..a347395 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,41 @@ +--- +name: lint + +"on": + push: + workflow_dispatch: + inputs: + debug: + description: "Enable debug logging (GCRYPT_DEBUG=1)" + required: false + type: boolean + default: false + schedule: + - cron: "0 0 * * 0" # Sunday at 12 AM + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install ShellCheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: Lint [make lint] + run: make lint + + - name: Check Formatting [make format] + # Requires shfmt and black. + # Assuming environment has them or we install them. + # ubuntu-latest has python3, we need shfmt. + run: | + sudo snap install shfmt + pip3 install black + make format + git diff --exit-code + + - name: Check Generation [make generate] + run: | + make generate + git diff --exit-code diff --git a/.gitignore b/.gitignore index 2395a05..9d2c592 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ /debian/files /debian/git-remote-gcrypt.substvars /debian/git-remote-gcrypt + +# Test coverage +.coverage/ +# scratch pad +.tmp/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..46b12b4 --- /dev/null +++ b/Makefile @@ -0,0 +1,256 @@ +SHELL:=/bin/bash +# .ONESHELL: +# .EXPORT_ALL_VARIABLES: +.DEFAULT_GOAL := _help +.SHELLFLAGS = -ec + +.PHONY: _help +_help: + @printf "\nUsage: make , valid commands:\n\n" + @awk 'BEGIN {FS = ":.*?##H "}; \ + /##H/ && !/@awk.*?##H/ { \ + target=$$1; doc=$$2; \ + if (length(target) > max) max = length(target); \ + targets[NR] = target; docs[NR] = doc; list[NR] = 1; \ + } \ + END { \ + for (i = 1; i <= NR; i++) { \ + if (list[i]) printf " \033[1;34m%-*s\033[0m %s\n", max, targets[i], docs[i]; \ + } \ + print ""; \ + }' $(MAKEFILE_LIST) + +.PHONY: vars +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)" +endef + +define print_warn +printf "\033[1;33m%s\033[0m\n" "$(1)" +endef + +define print_success +printf "\033[1;34m✓ %s\033[0m\n" "$(1)" +endef + + +define print_info +printf "\033[1;36m%s\033[0m\n" "$(1)" +endef + +define print_target +printf "\033[1;35m-> %s\033[0m\n" "$(1)" +endef + + +.PHONY: check/deps +check/deps: check/deps/shellcheck check/deps/kcov ##H Verify kcov & shellcheck + +.PHONY: check/deps/shellcheck +check/deps/shellcheck: + @$(call print_info, --- shellcheck version ---) + @shellcheck --version + +.PHONY: check/deps/kcov +check/deps/kcov: + @$(call print_info, --- kcov version ---) + @kcov --version + + + +LINT_LOCS_PY ?= $(shell git ls-files '*.py') +LINT_LOCS_SH ?= $(shell git ls-files '*.sh' ':!tests/system-test.sh') +FORMAT_LOCS_SHELL ?= completions/*.sh completions/bash/* completions/zsh/* completions/fish/* completions/templates/bash.in completions/templates/zsh.in completions/templates/fish.in + +.PHONY: format +format: ##H Format scripts + @$(call print_target,format) + @$(call print_info,Formatting shell scripts...) + shfmt -ci -bn -s -w $(LINT_LOCS_SH) $(FORMAT_LOCS_SHELL) + @$(call print_success,OK.) + @$(call print_info,Formatting Python scripts...) + -black $(LINT_LOCS_PY) + -isort $(LINT_LOCS_PY) + @$(call print_success,OK.) + @$(call print_info,Formatting YAML files...) + -prettier --write .github/workflows + @$(call print_success,OK.) + +.PHONY: lint +lint: ##H Run shellcheck + @$(call print_target,lint) + @$(call print_info,Running shellcheck...) + shellcheck --version + shellcheck install.sh + shellcheck -s sh -e SC3043,SC2001 git-remote-gcrypt + shellcheck tests/*.sh + @$(call print_success,OK.) + @$(call print_info,Linting Python scripts...) + -ruff check $(LINT_LOCS_PY) + @$(call print_success,OK.) + + +# --- Test Config --- +PWD := $(shell pwd) +COV_ROOT := $(PWD)/.coverage +COV_SYSTEM := $(COV_ROOT)/system +COV_INSTALL := $(COV_ROOT)/installer + +.PHONY: test/, test +test: test/ +test/: ##H Run tests (purity checks only if kcov missing) + @if command -v kcov >/dev/null 2>&1; then \ + $(MAKE) test/installer test/system test/cov; \ + else \ + $(call print_warn,kcov not found: skipping coverage/bash tests.); \ + $(MAKE) test/purity; \ + fi + +.PHONY: test/installer +test/installer: ##H Test installer logic + @rm -rf $(COV_INSTALL) + @mkdir -p $(COV_INSTALL) .tmp + @rm -f .tmp/kcov.log + @export COV_DIR=$(COV_INSTALL); \ + set -o pipefail; \ + for test_script in tests/installer-test*.sh; do \ + if [ "$$test_script" = "tests/installer-test-logic.sh" ]; then \ + kcov --bash-dont-parse-binary-dir \ + --include-pattern=install.sh \ + --exclude-path=$(PWD)/.git,$(PWD)/tests \ + $(COV_INSTALL) \ + "$$test_script" 2>&1 | (mkdir -p .tmp; tee -a .tmp/kcov.log); \ + else \ + bash "$$test_script" 2>&1 | tee -a .tmp/kcov.log; \ + fi; \ + done; \ + if grep -q 'kcov: error:' .tmp/kcov.log; then \ + echo "FAIL: kcov errors detected (see above)"; exit 1; \ + fi + + +.PHONY: test/purity +test/purity: ##H Run logic tests (with native /bin/sh) + @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: ##H Run logic tests (with bash & coverage) + @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 && $(call print_warn,Debug mode enabled); \ + export GIT_CONFIG_PARAMETERS="'gcrypt.gpg-args=--pinentry-mode loopback --no-tty'"; \ + 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)/$$(basename $$test_script) \ + ./$$test_script; \ + done; \ + kcov --merge $(COV_SYSTEM)/merged $(COV_SYSTEM)/system-test-*.sh; \ + 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 "Debug: WILDCARD=$(wildcard $(1)/*/*/cobertura.xml)" ; \ + echo "Debug: MERGED_FILTER=$(filter %/merged/kcov-merged/cobertura.xml, $(wildcard $(1)/*/*/cobertura.xml))" ; \ + 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 "Warning: No coverage report found for $(2) in $(1)") + + +.PHONY: test/cov +test/cov: ##H Show coverage gaps + $(call CHECK_COVERAGE,$(COV_SYSTEM),git-remote-gcrypt,56) || err=1; \ + $(call CHECK_COVERAGE,$(COV_INSTALL),install.sh,84) || err=1; \ + exit $$err + + + +# Version from git describe (or fallback) +__VERSION__ := $(shell git describe --tags --always --dirty 2>/dev/null || echo "@@DEV_VERSION@@") + + +.PHONY: generate +generate: ##H Autogen man docs & shell completions + @$(call print_info,Generating documentation and completions...) + ./completions/gen_docs.sh + @$(call print_success,Generated.) + + +.PHONY: install/, install +install/: install +install: ##H Install system-wide + @$(call print_target,install) + @$(call print_info,Installing git-remote-gcrypt...) + @bash ./install.sh + @$(call print_success,Installed.) + +.PHONY: install/user +install/user: ##H make install prefix=~/.local + $(MAKE) install prefix=~/.local + + +.PHONY: check/install +check/install: ##H Verify installation works + bash ./tests/verify-system-install.sh + + +.PHONY: uninstall/, uninstall +uninstall/: uninstall +uninstall: ##H Uninstall + @$(call print_target,uninstall) + @bash ./uninstall.sh + @$(call print_success,Uninstalled.) + +.PHONY: uninstall/user +uninstall/user: ##H make uninstall prefix=~/.local + $(MAKE) uninstall prefix=~/.local + + + + +.PHONY: deploy/debian +deploy/debian: ##H Build Debian package + @$(call print_target,deploy/debian) + @$(call print_info,Building Debian package...) + gbp buildpackage -uc -us + @$(call print_success,Built Debian package.) + +.PHONY: deploy/redhat +deploy/redhat: ##H Build RPM package + @$(call print_target,deploy/redhat) + @$(call print_info,Building RPM package...) + rpmbuild -bb redhat/git-remote-gcrypt.spec + @$(call print_success,Built RPM package.) + +.PHONY: clean +clean: ##H Clean up + rm -rf .coverage .build_tmp diff --git a/README.rst b/README.rst index 2847301..e9dcabd 100644 --- a/README.rst +++ b/README.rst @@ -48,6 +48,33 @@ Create an encrypted remote by pushing to it:: > To gcrypt::[...] > * [new branch] master -> master + > * [new branch] master -> master + +Command Reference +================= + +:: + + Options: + help Show this help message + version Show version information + check [URL] Check if URL is a gcrypt repository + clean [URL|REMOTE] Scan/Clean unencrypted files from remote + clean --force Actually delete files (default is scan only) + clean --init Allow cleaning uninitialized repos (requires --force) + clean --hard Override safety checks (requires --force) + stat [URL|REMOTE] Show diagnostics (file counts, tracked vs untracked) + 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 + Configuration ============= @@ -114,6 +141,14 @@ Environment variables When set (to anything other than the empty string), this environment variable forces a full repack when pushing. +*GCRYPT_TRACE* + When set (to anything other than the empty string), enables shell execution tracing (set -x) + for external commands (rsync, curl, rclone). + +*GCRYPT_DEBUG* + When set (to anything other than the empty string), enables verbose debug logging to standard error. + This includes GPG status output and resolved participant keys. + Examples ======== @@ -248,14 +283,40 @@ Each item extends until newline, and matches one of the following: Detecting gcrypt repos ====================== -To detect if a git url is a gcrypt repo, use: ``git-remote-gcrypt --check url`` -Exit status is 0 if the repo exists and can be decrypted, 1 if the repo +To detect if a git url is a gcrypt repo, use:: + + git-remote-gcrypt check url + +(Legacy syntax ``--check`` is also supported). + +Exit status is 0 if the repository exists and uses gcrypt, 1 if it uses gcrypt but could not be decrypted, and 100 if the repo is not encrypted with gcrypt (or could not be accessed). Note that this has to fetch the repo contents into the local git repository, the same as is done when using a gcrypt repo. +Cleaning gcrypt repos +===================== + +To scan for unencrypted files in a remote gcrypt repo, use:: + + git-remote-gcrypt clean [url|remote] + +.. warning:: + The clean command is unstable and subject to deprecation or renaming and should not be used in scripts. + +Supported backends for the clean command are ``rsync://``, ``rclone://``, +``sftp://``, and git-based remotes. + +If no URL or remote is specified, ``git-remote-gcrypt`` will list all +available ``gcrypt::`` remotes. + +By default, this command only performs a scan. To actually remove the +unencrypted files, you must use the ``--force`` flag:: + + git-remote-gcrypt clean url --force + Known issues ============ diff --git a/completions/bash/git-remote-gcrypt b/completions/bash/git-remote-gcrypt new file mode 100644 index 0000000..58e82c9 --- /dev/null +++ b/completions/bash/git-remote-gcrypt @@ -0,0 +1,46 @@ +# Bash completion for git-remote-gcrypt +# Install to: /etc/bash_completion.d/ or ~/.local/share/bash-completion/completions/ + +_git_remote_gcrypt() { + local cur prev opts commands + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD - 1]}" + opts="-h --help -v --version" + commands="check clean stat" + + # 1. First argument: complete commands and global options + if [[ $COMP_CWORD -eq 1 ]]; then + COMPREPLY=($(compgen -W "$commands $opts" -- "$cur")) + if [[ $cur == gcrypt::* ]]; then + COMPREPLY+=("$cur") + fi + return 0 + fi + + # 2. Handle subcommands + case "${COMP_WORDS[1]}" in + clean) + local remotes=$(git remote -v 2>/dev/null | grep 'gcrypt::' | awk '{print $1}' | sort -u || :) + COMPREPLY=($(compgen -W "--force --init --hard $remotes" -- "$cur")) + return 0 + ;; + check | stat) + local remotes=$(git remote 2>/dev/null || :) + COMPREPLY=($(compgen -W "$remotes" -- "$cur")) + return 0 + ;; + capabilities | fetch | list | push) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + return 0 + ;; + esac + + # 3. Fallback (global flags if not in a known subcommand?) + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W "$opts" -- "$cur")) + return 0 + fi +} + +complete -F _git_remote_gcrypt git-remote-gcrypt diff --git a/completions/fish/git-remote-gcrypt.fish b/completions/fish/git-remote-gcrypt.fish new file mode 100644 index 0000000..60cb262 --- /dev/null +++ b/completions/fish/git-remote-gcrypt.fish @@ -0,0 +1,18 @@ +# Fish completion for git-remote-gcrypt +# Install to: ~/.config/fish/completions/ + +complete -c git-remote-gcrypt -s h -l help -d 'Show help message' +complete -c git-remote-gcrypt -s v -l version -d 'Show version information' + +# Subcommands +complete -c git-remote-gcrypt -f -n "not __fish_seen_subcommand_from check clean stat" -a 'check' -d 'Check if URL is a gcrypt repository' +complete -c git-remote-gcrypt -f -n "not __fish_seen_subcommand_from check clean stat" -a 'clean' -d 'Scan/Clean unencrypted files from remote' +complete -c git-remote-gcrypt -f -n "not __fish_seen_subcommand_from check clean stat" -a 'stat' -d 'Show diagnostics' +complete -c git-remote-gcrypt -n "__fish_seen_subcommand_from clean" -a "(git remote -v 2>/dev/null | grep 'gcrypt::' | awk '{print \$1}' | sort -u)" -d 'Gcrypt Remote' +complete -c git-remote-gcrypt -n "__fish_seen_subcommand_from check" -a "(git remote 2>/dev/null)" -d 'Git Remote' +complete -c git-remote-gcrypt -n "__fish_seen_subcommand_from stat" -a "(git remote 2>/dev/null)" -d 'Git Remote' + +# Clean flags +complete -c git-remote-gcrypt -f -n "__fish_seen_subcommand_from clean" -l force -d ' Actually delete files (default is scan only)' +complete -c git-remote-gcrypt -f -n "__fish_seen_subcommand_from clean" -l init -d ' Allow cleaning uninitialized repos (requires --force)' +complete -c git-remote-gcrypt -f -n "__fish_seen_subcommand_from clean" -l hard -d ' Override safety checks (requires --force)' diff --git a/completions/gen_docs.sh b/completions/gen_docs.sh new file mode 100755 index 0000000..a270fbd --- /dev/null +++ b/completions/gen_docs.sh @@ -0,0 +1,169 @@ +#!/bin/bash +set -e + +# gen_docs.sh +# Generates documentation and shell completions from git-remote-gcrypt source. +# Strictly POSIX sh compliant. + +SCRIPT_KEY="HELP_TEXT" +SRC="git-remote-gcrypt" +README_TMPL="completions/templates/README.rst.in" +README_OUT="README.rst" +BASH_TMPL="completions/templates/bash.in" +BASH_OUT="completions/bash/git-remote-gcrypt" +ZSH_TMPL="completions/templates/zsh.in" +ZSH_OUT="completions/zsh/_git-remote-gcrypt" +FISH_TMPL="completions/templates/fish.in" +FISH_OUT="completions/fish/git-remote-gcrypt.fish" + +# Ensure we're in the project root +if [ ! -f "$SRC" ]; then + echo "Error: Must be run from project root" >&2 + exit 1 +fi + +# Extract HELP_TEXT variable content +# Using sed to capture lines between double quotes of HELP_TEXT="..." +# Assumes HELP_TEXT="..." is a single block. +RAW_HELP=$(sed -n "/^$SCRIPT_KEY=\"/,/\"$/p" "$SRC" | sed "s/^$SCRIPT_KEY=\"//;s/\"$//") + +# 1. Prepare {commands_help} for README (Indented for RST) +# We want the Options and Git Protocol Commands sections +COMMANDS_HELP=$(printf '%s\n' "$RAW_HELP" | sed -n '/^Options:/,$p' | sed 's/^/ /') + +# 2. Parse Commands and Flags for Completions +# Extract command names (first word after 2 spaces) +COMMANDS_LIST=$(echo "$RAW_HELP" | awk '/^ [a-z]+ / {print $1}' | grep -vE "^(help|version|capabilities|list|push|fetch)$" | sort | tr '\n' ' ' | sed 's/ $//') + +# Extract clean flags +# Text: " clean -f, --force Actually delete files..." +# We want to extract flags properly. +# Get lines, then extract words starting with - +# Stop at the first word that doesn't start with - (description start) +CLEAN_FLAGS_RAW=$(echo "$RAW_HELP" | grep "^ clean -" | awk '{ + out="" + for (i=2; i<=NF; i++) { + if ($i ~ /^-/) { + # remove comma if present + sub(",", "", $i) + out = out ? out " " $i : $i + } else { + break + } + } + print out +}') + +CLEAN_FLAGS_BASH=$(echo "$CLEAN_FLAGS_RAW" | tr '\n' ' ' | sed 's/ */ /g; s/ $//') + +# For Zsh: Generate proper spec strings +# Use while read loop to handle lines safely +CLEAN_FLAGS_ZSH=$(echo "$CLEAN_FLAGS_RAW" | while read -r line; do + [ -z "$line" ] && continue + # line is "-f --force" or "--hard" + # simple split + flags=$(echo "$line" | tr ' ' '\n') + # Build exclusion list (all flags in this group exclude each other self + + # Check if we have multiple flags (aliases) + if echo "$line" | grep -q " "; then + # "(-f --force)" + excl="($line)" + # "{-f,--force}" + fspec="{$(echo "$line" | sed 's/ /,/g')}" + else + # "" (no exclusion needed against itself strictly, or just empty for single) + # But usually clean flags are distinct. + excl="" + fspec="$line" + fi + + # Description - specific descriptions would be better, but generic for now. + # We rely on the fact that these are clean flags. + desc="[Flag]" + + # Use printf to avoid newline issues in variable + # Zsh format: '(-f --force)'{-f,--force}'[Actually delete files]' + if [ -n "$excl" ]; then + printf "'%s'%s'%s'\n" "$excl" "$fspec" "$desc" + else + printf "%s'%s'\n" "$fspec" "$desc" + fi +done | tr '\n' ' ' | sed 's/ $//') + +# For Fish +# We need to turn "-f --force" into: -s f -l force +# And "--hard" into: -l hard +CLEAN_FLAGS_FISH=$(echo "$CLEAN_FLAGS_RAW" | while read -r line; do + [ -z "$line" ] && continue + + short="" + long="" + + # Split by space + # Case 1: "-f --force" -> field1=-f, field2=--force + f1=$(echo "$line" | awk '{print $1}') + f2=$(echo "$line" | awk '{print $2}') + + # Description is looked up separately via grep because it contains spaces + # escape single quotes for Fish string + desc=$(echo "$RAW_HELP" | grep -F -- "$line" | head -n 1 | sed 's/^[[:space:]]*//' | cut -d ' ' -f 3- | sed "s/'/\\\\'/g") + + if [[ $f1 == -* ]] && [[ $f2 == --* ]]; then + short="${f1#-}" + long="${f2#--}" + elif [[ $f1 == --* ]]; then + long="${f1#--}" + else + # Starts with - (short) + short="${f1#-}" + if [ -n "$f2" ] && echo "$f2" | grep -q "^--"; then + long="${f2#--}" + fi + fi + + cmd='complete -c git-remote-gcrypt -f -n "__fish_seen_subcommand_from clean"' + [ -n "$short" ] && cmd="$cmd -s $short" + [ -n "$long" ] && cmd="$cmd -l $long" + cmd="$cmd -d '$desc'" + + printf "%s\n" "$cmd" +done) + +# 3. Generate README +echo "Generating $README_OUT..." +# Escape backslashes, forward slashes, and ampersands, then flatten newlines to \n +ESCAPED_HELP=$(printf '%s\n' "$COMMANDS_HELP" | sed 's/\\/\\\\/g; s/[\/&]/\\&/g' | awk 'NR>1{printf "\\n"} {printf "%s", $0}') +sed "s/{commands_help}/$ESCAPED_HELP/" "$README_TMPL" >"$README_OUT" + +# 4. Generate Bash +echo "Generating Bash completions..." +sed "s/{commands}/$COMMANDS_LIST/; s/{clean_flags_bash}/$CLEAN_FLAGS_BASH/" "$BASH_TMPL" >"$BASH_OUT" + +# 5. Generate Zsh +echo "Generating Zsh completions..." +# Zsh substitution is tricky with the complex string. +# We'll stick to replacing {commands} and {clean_flags_zsh} +# Need to escape special chars for sed +SAFE_CMDS=$(echo "$COMMANDS_LIST" | sed 's/ / /g') # just space separated +# For clean_flags_zsh, since it contains quotes and braces, we need care. +# We'll read the template line by line? No, sed is standard. +# We use a temp file for the replacement string to avoid sed escaping hell for large blocks? +# Or just keep it simple. +sed "s/{commands}/$COMMANDS_LIST/" "$ZSH_TMPL" \ + | sed "s|{clean_flags_zsh}|$CLEAN_FLAGS_ZSH|" >"$ZSH_OUT" + +# 6. Generate Fish +echo "Generating Fish completions..." +# Fish needs {not_sc_list} which matches {commands} (space separated) +# Use awk for safe replacement of multi-line string +CLEAN_FLAGS_FISH="$CLEAN_FLAGS_FISH" awk -v cmds="$COMMANDS_LIST" ' + BEGIN { flags=ENVIRON["CLEAN_FLAGS_FISH"] } + { + gsub(/{not_sc_list}/, cmds) + gsub(/{clean_flags_fish}/, flags) + print + } +' "$FISH_TMPL" >"$FISH_OUT" + +echo "Done." diff --git a/completions/templates/README.rst.in b/completions/templates/README.rst.in new file mode 100644 index 0000000..e270963 --- /dev/null +++ b/completions/templates/README.rst.in @@ -0,0 +1,339 @@ +================= +git-remote-gcrypt +================= + +-------------------------------------- +GNU Privacy Guard-encrypted git remote +-------------------------------------- + +:Manual section: 1 + +Description +=========== + +git-remote-gcrypt is a git remote helper to push and pull from +repositories encrypted with GnuPG, using a custom format. This remote +helper handles URIs prefixed with `gcrypt::`. + +Supported backends are `local`, `rsync://` and `sftp://`, where the +repository is stored as a set of files, or instead any `` +where gcrypt will store the same representation in a git repository, +bridged over arbitrary git transport. Prefer `local` or `rsync://` if +you can use one of those; see "Performance" below for discussion. + +There is also an experimental `rclone://` backend for early adoptors +only (you have been warned). + +The aim is to provide confidential, authenticated git storage and +collaboration using typical untrusted file hosts or services. + +Installation +............ + +* use your GNU/Linux distribution's package manager -- Debian, Ubuntu, + Fedora, Arch and some smaller distros are known to have packages + +* run the supplied ``install.sh`` script on other systems + +Quickstart +.......... + +Create an encrypted remote by pushing to it:: + + git remote add cryptremote gcrypt::rsync://example.com/repo + git push cryptremote master + > gcrypt: Setting up new repository + > gcrypt: Remote ID is :id:7VigUnLVYVtZx8oir34R + > [ more lines .. ] + > To gcrypt::[...] + > * [new branch] master -> master + + > * [new branch] master -> master + +Command Reference +================= + +:: + +{commands_help} + +Configuration +============= + +The following ``git-config(1)`` variables are supported: + +``remote..gcrypt-participants`` + .. +``gcrypt.participants`` + Space-separated list of GPG key identifiers. The remote is encrypted + to these participants and only signatures from these are accepted. + ``gpg -k`` lists all public keys you know. + + If this option is not set, we encrypt to your default key and accept + any valid signature. This behavior can also be requested explicitly + by setting participants to ``simple``. + + The ``gcrypt-participants`` setting on the remote takes precedence + over the repository variable ``gcrypt.participants``. + +``remote..gcrypt-publish-participants`` + .. +``gcrypt.publish-participants`` + By default, the gpg key ids of the participants are obscured by + encrypting using ``gpg -R``. Setting this option to ``true`` disables + that security measure. + + The problem with using ``gpg -R`` is that to decrypt, gpg tries each + available secret key in turn until it finds a usable key. + This can result in unnecessary passphrase prompts. + +``gcrypt.gpg-args`` + The contents of this setting are passed as arguments to gpg. + E.g. ``--use-agent``. + +``remote..gcrypt-signingkey`` + .. +``user.signingkey`` + (The latter from regular git configuration) The key to use for signing. + You should set ``user.signingkey`` if your default signing key is not + part of the participant list. You may use the per-remote version + to sign different remotes using different keys. + +``remote..gcrypt-rsync-put-flags`` + .. +``gcrypt.rsync-put-flags`` + Flags to be passed to ``rsync`` when uploading to a remote using the + ``rsync://`` backend. If the flags are set to a specific remote, the + global flags, if also set, will not be applied for that remote. + +``remote..gcrypt-require-explicit-force-push`` + .. +``gcrypt.require-explicit-force-push`` + A longstanding bug is that every git push effectively has a ``--force``. + + If this flag is set to ``true``, git-remote-gcrypt will refuse to push, + unless ``--force`` is passed, or refspecs are prefixed with ``+``. + + There is a potential solution here: https://bugs.debian.org/877464#32 + +Environment variables +===================== + +*GCRYPT_FULL_REPACK* + When set (to anything other than the empty string), this environment + variable forces a full repack when pushing. + +*GCRYPT_TRACE* + When set (to anything other than the empty string), enables shell execution tracing (set -x) + for external commands (rsync, curl, rclone). + +*GCRYPT_DEBUG* + When set (to anything other than the empty string), enables verbose debug logging to standard error. + This includes GPG status output and resolved participant keys. + +Examples +======== + +How to set up a remote for two participants:: + + git remote add cryptremote gcrypt::rsync://example.com/repo + git config remote.cryptremote.gcrypt-participants "KEY1 KEY2" + git push cryptremote master + +How to use a git backend:: + + # notice that the target git repo must already exist and its + # `next` branch will be overwritten! + git remote add gitcrypt gcrypt::git@example.com:repo#next + git push gitcrypt master + +The URL fragment (``#next`` here) indicates which backend branch is used. + +Notes +===== + +Collaboration + The encryption of the manifest is updated for each push to match the + participant configuration. Each pushing user must have the public + keys of all collaborators and correct participant config. + +Dependencies + ``rsync``, ``curl`` and ``rclone`` for remotes ``rsync:``, ``sftp:`` and + ``rclone:`` respectively. The main executable requires a POSIX-compliant + shell that supports ``local``. + +GNU Privacy Guard + Both GPG 1.4 and 2 are supported. You need a personal GPG key. GPG + configuration applies to algorithm choices for public-key + encryption, symmetric encryption, and signing. See ``man gpg`` for + more information. + +Remote ID + The Remote ID is not secret; it only ensures that two repositories + signed by the same user can be distinguished. You will see + a warning if the Remote ID changes, which should only happen if the + remote was re-created. + +Performance + Using an arbitrary `` or an `sftp://` URI requires + uploading the entire repository history with each push. This + means that pushes of your repository become slower over time, as + your git history becomes longer, and it can easily get to the + point that continued usage of git-remote-gcrypt is impractical. + + Thus, you should use these backends only when you know that your + repository will not ever grow very large, not just that it's not + large now. This means that these backends are inappropriate for + most repositories, and likely suitable only for unusual cases, + such as small credential stores. Even then, use `rsync://` if you + can. Note, however, that `rsync://` won't work with a repository + hosting service like Gitolite, GitHub or GitLab. + +rsync URIs + The URI format for the rsync backend is ``rsync://user@host/path``, + which translates to the rsync location ``user@host:/path``, + accessed over ssh. Note that the path is absolute, not relative to the + home directory. An earlier non-standard URI format is also supported: + ``rsync://user@host:path``, which translates to the rsync location + ``user@host:path`` + +rclone backend + In addition to adding the rclone backend as a remote with URI like + ``gcrypt::rclone://remote:subdir``, you must add the remote to the + rclone configuration too. This is typically done by executing + ``rclone config``. See rclone(1). + + The rclone backend is considered experimental and is for early + adoptors only. You have been warned. + +Repository format +................. + +| `EncSign(X):` Sign and Encrypt to GPG key holder +| `Encrypt(K,X):` Encrypt using symmetric-key algorithm +| `Hash(X):` SHA-2/256 +| +| `B:` branch list +| `L:` list of the hash (`Hi`) and key (`Ki`) for each packfile +| `R:` Remote ID +| +| To write the repository: +| +| Store each packfile `P` as `Encrypt(Ki, P)` → `P'` in filename `Hi` +| where `Ki` is a new random string and `Hash(P')` → `Hi` +| Store `EncSign(B || L || R)` in the manifest +| +| To read the repository: +| +| Get manifest, decrypt and verify using GPG keyring → `(B, L, R)` +| Warn if `R` does not match previously seen Remote ID +| for each `Hi, Ki` in `L`: +| Get file `Hi` from the server → `P'` +| Verify `Hash(P')` matches `Hi` +| Decrypt `P'` using `Ki` → `P` then open `P` with git + +Manifest file +............. + +Example manifest file (with ellipsis for brevity):: + + $ gpg -d 91bd0c092128cf2e60e1a608c31e92caf1f9c1595f83f2890ef17c0e4881aa0a + 542051c7cd152644e4995bda63cc3ddffd635958 refs/heads/next + 3c9e76484c7596eff70b21cbe58408b2774bedad refs/heads/master + pack :SHA256:f2ad50316...cd4ba67092dc4 z8YoAnFpMlW...3PkI2mND49P1qm + pack :SHA256:a6e17bb4c...426492f379584 82+k2cbiUn7...dgXfyX6wXGpvVa + keep :SHA256:f2ad50316...cd4ba67092dc4 1 + repo :id:OYiSleGirtLubEVqJpFF + +Each item extends until newline, and matches one of the following: + +`` `` + Git object id and its ref + +``pack :: `` + Packfile hash (`Hi`) and corresponding symmetric key (`Ki`). + +``keep :: `` + Packfile hash and its repack generation + +``repo `` + The remote id + +``extn ...`` + Extension field, preserved but unused. + +Detecting gcrypt repos +====================== + +To detect if a git url is a gcrypt repo, use:: + + git-remote-gcrypt check url + +(Legacy syntax ``--check`` is also supported). + +Exit status is 0 if the repository exists and uses gcrypt, 1 if it +uses gcrypt but could not be decrypted, and 100 if the repo is not +encrypted with gcrypt (or could not be accessed). + +Note that this has to fetch the repo contents into the local git +repository, the same as is done when using a gcrypt repo. + +Cleaning gcrypt repos +===================== + +To scan for unencrypted files in a remote gcrypt repo, use:: + + git-remote-gcrypt clean [url|remote] + +.. warning:: + The clean command is unstable and subject to deprecation or renaming and should not be used in scripts. + +Supported backends for the clean command are ``rsync://``, ``rclone://``, +``sftp://``, and git-based remotes. + +If no URL or remote is specified, ``git-remote-gcrypt`` will list all +available ``gcrypt::`` remotes. + +By default, this command only performs a scan. To actually remove the +unencrypted files, you must use the ``--force`` flag:: + + git-remote-gcrypt clean url --force + +Known issues +============ + +Every git push effectively has ``--force``. Be sure to pull before +pushing. + +git-remote-gcrypt can decide to repack the remote without warning, +which means that your push can suddenly take significantly longer than +you were expecting, as your whole history has to be reuploaded. +This push might fail over a poor link. + +git-remote-gcrypt might report a repository as "not found" when the +repository does in fact exist, but git-remote-gcrypt is having +authentication, port, or network connectivity issues. + +See also +======== + +git-remote-helpers(1), gpg(1) + +Credits +======= + +The original author of git-remote-gcrypt was GitHub user bluss. + +The de facto maintainer in 2013 and 2014 was Joey Hess. + +The current maintainer, since 2016, is Sean Whitton +. + +License +======= + +This document and git-remote-gcrypt are licensed under identical terms, +GPL-3 (or 2+); see the git-remote-gcrypt file. + +.. this document generates a man page with rst2man +.. vim: ft=rst tw=72 sts=4 diff --git a/completions/templates/bash.in b/completions/templates/bash.in new file mode 100644 index 0000000..08d2aa5 --- /dev/null +++ b/completions/templates/bash.in @@ -0,0 +1,46 @@ +# Bash completion for git-remote-gcrypt +# Install to: /etc/bash_completion.d/ or ~/.local/share/bash-completion/completions/ + +_git_remote_gcrypt() { + local cur prev opts commands + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD - 1]}" + opts="-h --help -v --version" + commands="{commands}" + + # 1. First argument: complete commands and global options + if [[ $COMP_CWORD -eq 1 ]]; then + COMPREPLY=($(compgen -W "$commands $opts" -- "$cur")) + if [[ $cur == gcrypt::* ]]; then + COMPREPLY+=("$cur") + fi + return 0 + fi + + # 2. Handle subcommands + case "${COMP_WORDS[1]}" in + clean) + local remotes=$(git remote -v 2>/dev/null | grep 'gcrypt::' | awk '{print $1}' | sort -u || :) + COMPREPLY=($(compgen -W "{clean_flags_bash} $remotes" -- "$cur")) + return 0 + ;; + check | stat) + local remotes=$(git remote 2>/dev/null || :) + COMPREPLY=($(compgen -W "$remotes" -- "$cur")) + return 0 + ;; + capabilities | fetch | list | push) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + return 0 + ;; + esac + + # 3. Fallback (global flags if not in a known subcommand?) + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W "$opts" -- "$cur")) + return 0 + fi +} + +complete -F _git_remote_gcrypt git-remote-gcrypt diff --git a/completions/templates/fish.in b/completions/templates/fish.in new file mode 100644 index 0000000..be8fa0a --- /dev/null +++ b/completions/templates/fish.in @@ -0,0 +1,16 @@ +# Fish completion for git-remote-gcrypt +# Install to: ~/.config/fish/completions/ + +complete -c git-remote-gcrypt -s h -l help -d 'Show help message' +complete -c git-remote-gcrypt -s v -l version -d 'Show version information' + +# Subcommands +complete -c git-remote-gcrypt -f -n "not __fish_seen_subcommand_from {not_sc_list}" -a 'check' -d 'Check if URL is a gcrypt repository' +complete -c git-remote-gcrypt -f -n "not __fish_seen_subcommand_from {not_sc_list}" -a 'clean' -d 'Scan/Clean unencrypted files from remote' +complete -c git-remote-gcrypt -f -n "not __fish_seen_subcommand_from {not_sc_list}" -a 'stat' -d 'Show diagnostics' +complete -c git-remote-gcrypt -n "__fish_seen_subcommand_from clean" -a "(git remote -v 2>/dev/null | grep 'gcrypt::' | awk '{print \$1}' | sort -u)" -d 'Gcrypt Remote' +complete -c git-remote-gcrypt -n "__fish_seen_subcommand_from check" -a "(git remote 2>/dev/null)" -d 'Git Remote' +complete -c git-remote-gcrypt -n "__fish_seen_subcommand_from stat" -a "(git remote 2>/dev/null)" -d 'Git Remote' + +# Clean flags +{clean_flags_fish} diff --git a/completions/templates/zsh.in b/completions/templates/zsh.in new file mode 100644 index 0000000..03b8977 --- /dev/null +++ b/completions/templates/zsh.in @@ -0,0 +1,31 @@ +#compdef git-remote-gcrypt +# Zsh completion for git-remote-gcrypt +# Install to: ~/.zsh/completions/ or /usr/share/zsh/site-functions/ + +_git_remote_gcrypt() { + local -a args + args=( + '(- *)'{-h,--help}'[show help message]' + '(- *)'{-v,--version}'[show version information]' + '1:command:({commands})' + '*::subcommand arguments:->args' + ) + _arguments -s -S $args + + case $line[1] in + clean) + _arguments {clean_flags_zsh} \ + '*:gcrypt URL: _alternative "remotes:gcrypt remote:($(git remote -v 2>/dev/null | grep "gcrypt::" | awk "{print \$1}" | sort -u))" "files:file:_files"' + ;; + check | stat) + _arguments \ + '*:gcrypt URL: _alternative "remotes:git remote:($(git remote 2>/dev/null))" "files:file:_files"' + ;; + *) + _arguments \ + '*:gcrypt URL:' + ;; + esac +} + +_git_remote_gcrypt "$@" diff --git a/completions/zsh/_git-remote-gcrypt b/completions/zsh/_git-remote-gcrypt new file mode 100644 index 0000000..7de0fd9 --- /dev/null +++ b/completions/zsh/_git-remote-gcrypt @@ -0,0 +1,31 @@ +#compdef git-remote-gcrypt +# Zsh completion for git-remote-gcrypt +# Install to: ~/.zsh/completions/ or /usr/share/zsh/site-functions/ + +_git_remote_gcrypt() { + local -a args + args=( + '(- *)'{-h,--help}'[show help message]' + '(- *)'{-v,--version}'[show version information]' + '1:command:(check clean stat)' + '*::subcommand arguments:->args' + ) + _arguments -s -S $args + + case $line[1] in + clean) + _arguments --force'[Flag]' --init'[Flag]' --hard'[Flag]' \ + '*:gcrypt URL: _alternative "remotes:gcrypt remote:($(git remote -v 2>/dev/null | grep "gcrypt::" | awk "{print \$1}" | sort -u))" "files:file:_files"' + ;; + check | stat) + _arguments \ + '*:gcrypt URL: _alternative "remotes:git remote:($(git remote 2>/dev/null))" "files:file:_files"' + ;; + *) + _arguments \ + '*:gcrypt URL:' + ;; + esac +} + +_git_remote_gcrypt "$@" diff --git a/git-remote-gcrypt b/git-remote-gcrypt index 7e7240f..bc9d938 100755 --- a/git-remote-gcrypt +++ b/git-remote-gcrypt @@ -30,6 +30,161 @@ Gref="refs/gcrypt/gitception$GITCEPTION" Gref_rbranch="refs/heads/master" Packkey_bytes=63 # nbr random bytes for packfile keys, any >= 256 bit is ok Hashtype=SHA256 # SHA512 SHA384 SHA256 SHA224 supported. +VERSION="@@DEV_VERSION@@" + + +# Help function +HELP_TEXT="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: + help Show this help message + version Show version information + check [URL] Check if URL is a gcrypt repository + clean [URL|REMOTE] Scan/Clean unencrypted files from remote + clean --force Actually delete files (default is scan only) + clean --init Allow cleaning uninitialized repos (requires --force) + clean --hard Override safety checks (requires --force) + stat [URL|REMOTE] Show diagnostics (file counts, tracked vs untracked) +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" + +# Help function +show_help() { + echo "$HELP_TEXT" >&2 +} + +# Parse arguments +while [ $# -gt 0 ]; do + case "$1" in + help|--help|-h) + show_help + exit 0 + ;; + version|--version|-v) + echo "git-remote-gcrypt version $VERSION" >&2 + exit 0 + ;; + check) + NAME=gcrypt-check + URL="$2" + shift + ;; + clean) + NAME=gcrypt-clean + shift + while [ $# -gt 0 ]; do + case "$1" in + --force) FORCE_CLEAN=yes ;; + --hard) HARD_FORCE=yes ;; + --repack) DO_REPACK=yes ;; + --init) FORCE_INIT=yes ;; + -*) echo "Unknown option: $1" >&2; exit 1 ;; + *) + if [ -z "$URL" ]; then + URL="$1" + else + echo "Error: Multiple URLs/remotes provided to clean" >&2 + exit 1 + fi + ;; + esac + shift + done + if [ -n "$HARD_FORCE" ] && [ -z "$FORCE_CLEAN" ]; then + echo "Error: --hard requires --force" >&2 + exit 1 + fi + break # Stop parsing outer loop + ;; + stat) + NAME=gcrypt-stat + shift + if [ $# -gt 0 ]; then + if [ -z "$URL" ]; then + URL="$1" + else + echo "Error: Multiple URLs/remotes provided to stat" >&2 + exit 1 + fi + shift + fi + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + break + ;; + esac +done + +# If NAME is not set, we might be invoked as a remote helper +if [ -z "$NAME" ]; then + # We are likely running as "git-remote-gcrypt " + # This case is handled by gcrypt_main_loop "$@" at the bottom if flags/commands were not matched + : +fi +case "${1:-}" in + capabilities) + if [ "${2:-}" = "-h" ] || [ "${2:-}" = "--help" ]; then + cat >&2 < + Invoked by git to query what operations this helper supports. +EOF + exit 0 + fi + ;; + list) + if [ "${2:-}" = "-h" ] || [ "${2:-}" = "--help" ]; then + cat >&2 < + Invoked by git to list available refs (branches/tags). +EOF + exit 0 + fi + ;; + push) + if [ "${2:-}" = "-h" ] || [ "${2:-}" = "--help" ]; then + cat >&2 <:" | 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 < " | git-remote-gcrypt + Invoked by git to fetch objects from the remote. +EOF + exit 0 + fi + ;; +esac + Manifestfile=91bd0c092128cf2e60e1a608c31e92caf1f9c1595f83f2890ef17c0e4881aa0a Hex40="[a-f0-9]" Hex40=$Hex40$Hex40$Hex40$Hex40$Hex40$Hex40$Hex40$Hex40 @@ -53,7 +208,7 @@ Recipients= # xfeed: The most basic output function puts $1 into the stdin of $2..$# xfeed() { - local input_= + local input_="" input_=$1; shift "$@" <&2; } echo_die() { echo_info "$@" ; exit 1; } +print_debug() { + if [ -n "${GCRYPT_DEBUG:-}" ]; then + echo_info "DEBUG:" "$@" + fi +} isnull() { case "$1" in "") return 0;; *) return 1;; esac; } isnonnull() { ! isnull "$1"; } @@ -77,11 +237,46 @@ pipefail() "$@" || { echo_info "'$1' failed!"; kill $$; exit 1; } } -isurl() { isnull "${2%%$1://*}"; } +isurl() { isnull "${2%%"$1"://*}"; } islocalrepo() { isnull "${1##/*}" && [ ! -e "$1/HEAD" ]; } xgrep() { command grep "$@" || : ; } + + +# Resolve URL or remote name, or list remotes if empty +resolve_url() { + local cmd="$1" + if [ -z "$URL" ]; then + local remotes + remotes=$(git remote -v | awk '{print $1 " " $2}' | sort -u || :) + echo "Usage: git-remote-gcrypt $cmd [URL|REMOTE]" >&2 + if [ -n "$remotes" ]; then + echo "No URL or remote specified. Available remotes:" >&2 + echo "$remotes" | sed 's/^/ /' >&2 + exit 0 + else + echo "Error: No remotes found and no URL/remote specified." >&2 + exit 1 + fi + fi + + # If it's not a URL, try to resolve as a remote name + if ! echo "$URL" | grep -q -E '://|::' || [ -n "${URL##*/*}" ]; then + local potential_url + potential_url=$(git config --get "remote.$URL.url" || :) + if [ -n "$potential_url" ]; then + # Don't clean non-gcrypt remotes! + if ! echo "$potential_url" | grep -q '^gcrypt::'; then + echo_die "Error: Remote '$URL' is not a gcrypt:: remote." + fi + print_debug "Resolved remote '$URL' to '$potential_url'" + URL="$potential_url" + fi + fi + URL="${URL#gcrypt::}" +} + # setvar is used for named return variables # $1 *must* be a valid variable name, $2 is any value # @@ -92,7 +287,7 @@ xgrep() { command grep "$@" || : ; } setvar() { isnull "${1##@*}" || echo_die "Missing @ for return variable: $1" - eval ${1#@}=\$2 + eval "${1#@}"=\$2 } Newline=" @@ -101,8 +296,8 @@ Newline=" # $1 is return var, $2 is value appended with newline separator append_to() { - local f_append_tmp_= - eval f_append_tmp_=\$${1#@} + local f_append_tmp_="" + eval f_append_tmp_=\$"${1#@}" isnull "$f_append_tmp_" || f_append_tmp_=$f_append_tmp_$Newline setvar "$1" "$f_append_tmp_$2" } @@ -112,14 +307,14 @@ append_to() # $2 input value pick_fields_1_2() { - local f_ret= f_one= f_two= - while read f_one f_two _ # from << here-document + local f_ret="" f_one="" f_two="" + while read -r f_one f_two _ # from << here-document do f_ret="$f_ret$f_one $f_two$Newline" done </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" || : + 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-fetch" >/dev/null; then + obj_id="$(git ls-tree "$Gref-fetch" | 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_ } @@ -188,7 +395,7 @@ update_tree() local tab_=" " # $2 is a filename from the repo format (set +e; - git ls-tree "$1" | xgrep -v -E '\b'"$2"'$'; + git -c core.quotePath=false ls-tree "$1" | awk -F'\t' -v f="$2" '$2 != f' xecho "100644 blob $3$tab_$2" ) | git mktree } @@ -197,7 +404,7 @@ update_tree() # depends on previous GET to set $Gref and depends on PUT_FINAL later gitception_put() { - local obj_id= tree_id= commit_id= + 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") && @@ -208,19 +415,49 @@ gitception_put() # depends on previous GET like put gitception_remove() { - 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) && - commit_id=$(anon_commit "$tree_id") && - git update-ref "$Gref" "$commit_id" + local tree_id="" commit_id="" temp_index="" + temp_index=$(mktemp) + + # Use temporary index to cleanly remove file (handles recursion) + ( + export GIT_INDEX_FILE="$temp_index" + git read-tree "$Gref" + if [ -n "$HARD_FORCE" ]; then + git rm --cached --ignore-unmatch -q -f "$2" + else + if ! git rm --cached --ignore-unmatch -q "$2"; then + echo_info "Error: Failed to remove '$2' because it has local modifications." + + # Hints for the user as requested + echo_info "Hints:" + echo_info " 1. To see all files with status differences: git status" + + suggest_args="--force --hard" + if [ "${FORCE_INIT:-}" = "yes" ]; then + suggest_args="--init $suggest_args" + fi + echo_info " 2. To force clean the remote via tool: git-remote-gcrypt clean $suggest_args $URL" + + exit 1 + fi + fi + tree_id=$(git write-tree) + if [ "$tree_id" != "$(git rev-parse "$Gref^{tree}")" ]; then + commit_id=$(anon_commit "$tree_id") && + git update-ref "$Gref" "$commit_id" + fi + ) + rm -f "$temp_index" } gitception_new_repo() { - local commit_id= empty_tree=4b825dc642cb6eb9a060e54bf8d69288fbee4904 + 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 || : + if gitception_get "$1" "x" 2>/dev/null >&2; then + git update-ref "$Gref" "$Gref-fetch" + fi git rev-parse -q --verify "$Gref" >/dev/null && return 0 || commit_id=$(anon_commit "$empty_tree") && git update-ref "$Gref" "$commit_id" @@ -230,15 +467,21 @@ gitception_new_repo() # Fetch repo $1, file $2, tmpfile in $3 GET() { + print_debug "GET $1 $2 $3" if isurl sftp "$1" then - (exec 0>&-; curl -s -S -k "$1/$2") > "$3" + (exec 0 "$3" elif isurl rsync "$1" then - (exec 0>&-; rsync -I -W "$(rsynclocation "$1")"/"$2" "$3" >&2) + print_debug "Calling rsync..." + ( + if [ -n "${GCRYPT_TRACE:-}" ]; then set -x; fi + exec 0&2 + ) elif isurl rclone "$1" then - (exec 0>&-; rclone copyto --error-on-no-transfer "${1#rclone://}"/"$2" "$3" >&2) + (exec 0&2) elif islocalrepo "$1" then cat "$1/$2" > "$3" @@ -250,12 +493,19 @@ GET() # Put repo $1, file $2 or fail, tmpfile in $3 PUT() { + print_debug "PUT $1 $2 $3" if isurl sftp "$1" then curl -s -S -k --ftp-create-dirs -T "$3" "$1/$2" elif isurl rsync "$1" then - rsync $Conf_rsync_put_flags -I -W "$3" "$(rsynclocation "$1")"/"$2" >&2 + print_debug "Calling rsync..." + ( + if [ -n "${GCRYPT_TRACE:-}" ]; then set -x; fi + exec 0&2 + ) elif isurl rclone "$1" then rclone copyto --error-on-no-transfer "$3" "${1#rclone://}"/"$2" >&2 @@ -281,13 +531,20 @@ PUT_FINAL() # Put directory for repo $1 PUTREPO() { + print_debug "PUTREPO $1" if isurl sftp "$1" then : elif isurl rsync "$1" then - rsync $Conf_rsync_put_flags -q -r --exclude='*' \ + print_debug "Calling rsync..." + ( + if [ -n "${GCRYPT_TRACE:-}" ]; then set -x; fi + exec 0&2 + ) elif isurl rclone "$1" then rclone mkdir "${1#rclone://}" >&2 @@ -302,30 +559,49 @@ PUTREPO() # For repo $1, delete all newline-separated files in $2 REMOVE() { - local fn_= + local fn_="" + print_debug "REMOVE $1 $2" if isurl sftp "$1" then # FIXME echo_info "sftp: Ignore remove request $1/$2" elif isurl rsync "$1" then - xfeed "$2" rsync -I -W -v -r --delete --include-from=- \ + print_debug "Calling rsync..." + ( + if [ -n "${GCRYPT_TRACE:-}" ]; then set -x; fi + # rsync needs parent directories included or it won't traverse them + echo "$2" | while IFS= read -r f; do + d=$(dirname "$f") + mkdir -p "$Localdir/$d" + done + + # rsync needs stdin for --include-from=- + # We include specific files ($2), then include ALL directories (*/), then exclude everything else (*). + rsync -I -W -v -r --delete --include-from=- --include='*/' \ --exclude='*' "$Localdir"/ "$(rsynclocation "$1")/" >&2 + ) <&2 elif islocalrepo "$1" then - for fn_ in $2; do + echo "$2" | while IFS= read -r fn_; do rm -f "$1"/"$fn_" done else - for fn_ in $2; do + # gitception handling + echo "$2" | while IFS= read -r fn_; do gitception_remove "${1#gitception://}" "$fn_" done fi } +REMOTE_REMOVE() { REMOVE "$@"; } # Alias if needed/used? No, just ensuring clean block. + + CLEAN_FINAL() { if isurl sftp "$1" || islocalrepo "$1" || isurl rsync "$1" || isurl rclone "$1" @@ -354,6 +630,7 @@ EOF # Encrypt to recipients $1 PRIVENCRYPT() { + # shellcheck disable=SC2086 set -- $1 if isnonnull "$Conf_signkey"; then set -- "$@" -u "$Conf_signkey" @@ -364,15 +641,33 @@ PRIVENCRYPT() # $1 is the match for good signature, $2 is the textual signers list PRIVDECRYPT() { - local status_= + local status_="" signer_="" exec 4>&1 && - status_=$(rungpg --status-fd 3 -q -d 3>&1 1>&4) && + status_=$(rungpg --status-fd 3 -q -d 3>&1 1>&4 || { + rc=$? + print_debug "rungpg failed with exit code $rc" + echo_info "ignoring GPG errors (likely anonymous recipient OR pinentry)." + }) && + print_debug "status_ output:" + if [ -n "${GCRYPT_DEBUG:-}" ]; then + xfeed "$status_" grep "^\[GNUPG:\] " >&2 + fi + print_debug "Checking regex: $1" xfeed "$status_" grep "^\[GNUPG:\] ENC_TO " >/dev/null && (xfeed "$status_" grep -e "$1" >/dev/null || { echo_info "Failed to verify manifest signature!" && echo_info "Only accepting signatories: ${2:-(none)}" && + echo_info "To accept any signature from your keyring, try:" && + echo_info " git config --unset gcrypt.participants" && + echo_info " # or: git config gcrypt.participants simple" && 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 @@ -383,7 +678,7 @@ genkey() gpg_hash() { - local hash_= + local hash_="" hash_=$(rungpg --with-colons --print-md "$1" | tr A-F a-f) hash_=${hash_#:*:} xecho "${hash_%:}" @@ -392,15 +687,16 @@ gpg_hash() rungpg() { if isnonnull "$Conf_gpg_args"; then - set -- "$Conf_gpg_args" "$@" + # shellcheck disable=SC2086 + set -- $Conf_gpg_args "$@" fi # 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 } @@ -421,14 +717,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=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" '.+' || @@ -470,13 +767,14 @@ read_config() r_keyfpr=${r_keyfpr%%"$Newline"*} keyid_=$(xfeed "$r_keyinfo" cut -f 5 -d :) fprid_=$(xfeed "$r_keyfpr" cut -f 10 -d :) - - isnonnull "$fprid_" && - signers_="$signers_ $keyid_" && - append_to @good_sig "^\[GNUPG:\] VALIDSIG .*$fprid_$" || { + print_debug "Resolved participant $recp_ to fpr: $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 @@ -490,6 +788,9 @@ read_config() if isnull "$Recipients" then + if [ "$NAME" = "gcrypt-stat" ] || [ "$NAME" = "gcrypt-clean" ]; then + return 0 + fi echo_info "You have not configured any keys you can encrypt to" \ "for this repository" echo_info "Use ::" @@ -498,19 +799,94 @@ read_config() fi setvar "$1" "$good_sig" setvar "$2" "$signers_" + print_debug "read_config done" +} + +early_safety_check() +{ + local check_files="" early_bad_files="" + + # EARLY SAFETY CHECK for gitception backends: + # Before GPG validation, check if the remote has unencrypted files. + if [ "$NAME" = "gcrypt-clean" ] || [ "$NAME" = "gcrypt-stat" ]; then + return 0 + fi + # For dumb backends (rsync/sftp/rclone/local), check for ANY files. + if isurl sftp "$URL" || isurl rsync "$URL" || isurl rclone "$URL" || islocalrepo "$URL"; then + local dumb_files="" + get_remote_file_list @dumb_files + if isnull "$dumb_files"; then + return 0 + fi + + early_bad_files=$(echo "$dumb_files" | grep -v -E '^[a-f0-9]{56}$|^[a-f0-9]{64}$|^[a-f0-9]{96}$|^[a-f0-9]{128}$' || :) + if isnull "$early_bad_files"; then + return 0 + fi + + + if [ "$(git config --bool remote."$NAME".gcrypt-allow-unencrypted-remote)" = "true" ] || \ + [ "$(git config --bool gcrypt.allow-unencrypted-remote)" = "true" ]; then + return 0 + fi + + 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 unexpected files: $(echo "$early_bad_files" | head -n 3 | tr '\n' ' ')" + echo_info "To see unencrypted files, use: git-remote-gcrypt clean $URL" + echo_info "To fix and remove these files, use: git-remote-gcrypt clean --force $URL" + exit 1 + fi + + 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 + return 0 + fi + + check_files=$(git ls-tree --name-only "refs/gcrypt/safety-check" 2>/dev/null || :) + git update-ref -d "refs/gcrypt/safety-check" 2>/dev/null || true + + if isnull "$check_files"; then + return 0 + fi + + 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 isnull "$early_bad_files"; then + return 0 + fi + + + if [ "$(git config --bool remote."$NAME".gcrypt-allow-unencrypted-remote)" = "true" ] || \ + [ "$(git config --bool gcrypt.allow-unencrypted-remote)" = "true" ]; then + return 0 + fi + + 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 unexpected files: $(echo "$early_bad_files" | head -n 3 | tr '\n' ' ')" + echo_info "To see unencrypted files, use: git-remote-gcrypt clean $URL" + echo_info "To fix and remove these files, use: git-remote-gcrypt clean --force $URL" + exit 1 } ensure_connected() { - local manifest_= r_repoid= r_name= url_frag= r_sigmatch= r_signers= \ - tmp_manifest= tmp_stderr= + 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 + Did_find_repo=no + print_debug "Calling read_config" read_config @r_sigmatch @r_signers + print_debug "Back from read_config" iseq "${NAME#gcrypt::}" "$URL" || r_name=$NAME @@ -541,7 +917,11 @@ ensure_connected() tmp_manifest="$Tempdir/maniF" tmp_stderr="$Tempdir/stderr" - GET "$URL" "$Manifestfile" "$tmp_manifest" 2>| "$tmp_stderr" || { + print_debug "Getting manifest from $URL file $Manifestfile" + # GET "$URL" "$Manifestfile" "$tmp_manifest" 2>| "$tmp_stderr" || { + # Debugging: don't capture stderr, let it flow to console + git update-ref -d "$Gref-fetch" || : + GET "$URL" "$Manifestfile" "$tmp_manifest" || { if ! isnull "$Repoid"; then cat >&2 "$tmp_stderr" echo_info "Repository not found: $URL" @@ -553,11 +933,18 @@ ensure_connected() fi } + # gitception: populate Gref from fetch + if git rev-parse -q --verify "$Gref-fetch" >/dev/null; then + git update-ref "$Gref" "$Gref-fetch" + fi + 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_" @@ -566,6 +953,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" @@ -592,11 +991,12 @@ ensure_connected() # $3 the key get_verify_decrypt_pack() { - local rcv_id= tmp_encrypted= + 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" } @@ -605,7 +1005,7 @@ get_verify_decrypt_pack() # $1 destdir (when repack, else "") get_pack_files() { - local pack_id= r_pack_key_line= htype_= pack_= key_= + local pack_id="" r_pack_key_line="" htype_="" pack_="" key_="" while IFS=': ' read -r _ htype_ pack_ # < (if sha-1 exists locally) - r_revlist=$(xfeed "$Refslist" cut -f 1 -d ' ' | - safe_git_rev_parse | sed -e 's/^\(.\)/^&/') - fi - - while IFS=: read -r src_ dst_ # << +src:dst - do - if [ $(echo "$src_" | cut -c1) != + ] - then - force_passed=false - fi - - src_=${src_#+} - filter_to ! @Refslist "$Hex40 $dst_" "$Refslist" - - if isnonnull "$src_" - then - append_to @r_revlist "$src_" - obj_=$(xfeed "$src_" safe_git_rev_parse) - append_to @Refslist "$obj_ $dst_" - fi - done </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 remote."$NAME".gcrypt-allow-unencrypted-remote)" = "true" ] || \ + [ "$(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 + # mark all remote refs with ^ (if sha-1 exists locally) + r_revlist=$(xfeed "$Refslist" cut -f 1 -d ' ' | + safe_git_rev_parse | sed -e 's/^\(.\)/^&/') + fi + + while IFS=: read -r src_ dst_ # << +src:dst + do + # shellcheck disable=SC2046 + if [ $(echo "$src_" | cut -c1) != + ] + then + force_passed=false + fi + + src_=${src_#+} + filter_to ! @Refslist "$Hex40 $dst_" "$Refslist" + + if isnonnull "$src_" + then + append_to @r_revlist "$src_" + obj_=$(xfeed "$src_" safe_git_rev_parse) + append_to @Refslist "$obj_ $dst_" + fi + done </dev/null || \ + git fetch --quiet "$URL" "refs/heads/main:refs/gcrypt/list-files" 2>/dev/null; then + r_files=$(git ls-tree -r --name-only "refs/gcrypt/list-files") || { + git update-ref -d "refs/gcrypt/list-files" + return 1 + } + git update-ref -d "refs/gcrypt/list-files" + else + # Could not fetch, or remote is empty. + # If checking, this might be fine, but for clean it's an issue if we expected files. + # Returning 1 is safer. + git update-ref -d "refs/gcrypt/list-files" 2>/dev/null || true + return 1 + fi + fi + setvar "$1" "$r_files" + return $err_code +} + +cmd_clean() +{ + local remote_files="" valid_files="" bad_files="" f="" + + if ! ensure_connected; then + echo_die "Could not connect to $URL." + fi + + if [ "$Did_find_repo" != "yes" ]; then + if [ "${FORCE_INIT:-}" = "yes" ]; then + echo_info "WARNING: No gcrypt manifest found, but --init specified." + echo_info "WARNING: Proceeding to clean uninitialized repository." + elif isnull "$FORCE_CLEAN"; then + echo_info "WARNING: No gcrypt manifest found." + echo_info "WARNING: Listing all files as potential garbage (dry-run)." + else + echo_info "Error: No gcrypt manifest found on remote '$URL'." + echo_info "Aborting clean to prevent accidental data loss." + echo_info "To force clean this uninitialized repository (e.g., to wipe before init):" + echo_info " git-remote-gcrypt clean --init --force $URL" + exit 1 + fi + fi + + get_remote_file_list @remote_files || echo_die "Failed to list remote files." + + 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="" + OIFS="$IFS" + IFS="$Newline" + 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 + IFS="$OIFS" + bad_files="${bad_files#"$Newline"}" + + if isnull "$bad_files"; then + echo_info "No unencrypted files found. Remote is clean." + if [ "${DO_REPACK:-}" = "yes" ]; then + echo_info "Repacking remote..." + # Prepare r_revlist for repack (all refs) + r_revlist="" + if isnonnull "$Refslist"; then + r_revlist=$(xecho "$Refslist" | cut -d' ' -f1) + fi + GCRYPT_FULL_REPACK=1 + perform_repack + PUT_FINAL "$URL" + fi + 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 isnull "$FORCE_CLEAN"; then + echo_info "NOTE: This is a scan of unencrypted files on the remote." + echo_info "To actually delete these files, use:" + if [ "$Did_find_repo" != "yes" ]; then + echo_info " git-remote-gcrypt clean --init --force $URL" + else + echo_info " git-remote-gcrypt clean --force $URL" + fi + + # If user requested repack but found bad files and no force, abort (safety first) + if [ "${DO_REPACK:-}" = "yes" ]; then + echo_info "NOTE: Repack requested but pending file deletions require --force." + fi + + CLEAN_FINAL "$URL" + git remote remove "$NAME" 2>/dev/null || true + exit 0 + fi + + echo_info "Removing files..." + + # If we are forcing clean on uninitialized repo, Gref might be missing. + # Fetch it so gitception_remove has a base. + if [ "$Did_find_repo" != "yes" ] && isnonnull "$Gref_rbranch"; then + git fetch -q -f "$URL" "$Gref_rbranch:$Gref" 2>/dev/null || : + fi + + REMOVE "$URL" "$bad_files" + + if [ "${DO_REPACK:-}" = "yes" ]; then + echo_info "Repacking remote..." + # Prepare r_revlist from all current refs for full repack + r_revlist="" + if isnonnull "$Refslist"; then + r_revlist=$(xecho "$Refslist" | cut -d' ' -f1) + fi + + # Set flag to force repack_if_needed to act + GCRYPT_FULL_REPACK=1 + perform_repack + fi + + PUT_FINAL "$URL" + CLEAN_FINAL "$URL" + git remote remove "$NAME" 2>/dev/null || true + echo_info "Done. Remote cleaned." + exit 0 +} + +cmd_stat() +{ + local remote_files="" file_count=0 tracked_count=0 untracked_count=0 valid_files="" f="" + + if ! ensure_connected; then + echo_die "Could not connect to $URL." + fi + + get_remote_file_list @remote_files || echo_die "Failed to list remote files." + file_count=$(line_count "$remote_files") + if isnull "$remote_files"; then + file_count=0 + fi + + echo "Remote: $URL" + echo "Total files: $file_count" + + if [ "$Did_find_repo" = "yes" ]; then + echo "Gcrypt repository: detected" + echo "Manifest: found" + # Build whitelist of valid gcrypt files to count tracked + valid_files="$Manifestfile" + for f in $(xecho "$Packlist" | cut -d: -f3 | cut -d' ' -f1); do + valid_files="$valid_files$Newline$f" + done + tracked_count=0 + for f in $remote_files; do + if xfeed "$valid_files" grep -qxF "$f"; then + tracked_count=$((tracked_count + 1)) + fi + done + untracked_count=$((file_count - tracked_count)) + echo "Tracked/Encrypted files: $tracked_count" + echo "Untracked/Plain files: $untracked_count" + else + echo "Gcrypt repository: not detected (no manifest)" + echo "Tracked/Encrypted files: 0" + echo "Untracked/Plain files: $file_count" + echo "" + echo "To force initiation of the repository as gcrypt (and delete all old objects):" + echo " git-remote-gcrypt clean --init --force $URL" + fi + CLEAN_FINAL "$URL" + git remote remove "$NAME" 2>/dev/null || true + exit 0 +} + +if [ "$NAME" = "gcrypt-check" ]; then + resolve_url check + echo_info "Checking remote: $URL" setup ensure_connected - git remote remove $NAME 2>/dev/null || true + CLEAN_FINAL "$URL" + git remote remove "$NAME" 2>/dev/null || true if iseq "$Did_find_repo" "no" then exit 100 fi +elif [ "$NAME" = "gcrypt-clean" ]; then + resolve_url clean + echo_info "Checking remote: $URL" + setup + cmd_clean +elif [ "$NAME" = "gcrypt-stat" ]; then + resolve_url stat + echo_info "Getting statistics for remote: $URL" + setup + cmd_stat +elif [ "$1" = --version ] || [ "$1" = -v ]; then + echo "git-remote-gcrypt version $VERSION" + exit 0 else gcrypt_main_loop "$@" + # gcrypt_main_loop "$NAME" "$URL" fi diff --git a/install.sh b/install.sh index 7fc1cfc..6599d41 100755 --- a/install.sh +++ b/install.sh @@ -1,33 +1,98 @@ #!/bin/sh - set -e -: ${prefix:=/usr/local} -: ${DESTDIR:=} +# Auto-detect Termux: if /usr/local doesn't exist but $PREFIX does (Android/Termux) +if [ -z "${prefix:-}" ]; then + if [ -d "${_USR_LOCAL:-/usr/local}" ]; then + prefix=/usr/local + elif [ -n "${PREFIX:-}" ] && [ -d "$PREFIX" ]; then + # Termux sets $PREFIX to /data/data/com.termux/files/usr + prefix="$PREFIX" + echo "Detected Termux environment, using prefix=$prefix" + else + prefix=/usr/local + fi +fi +: "${DESTDIR:=}" verbose() { echo "$@" >&2 && "$@"; } -install_v() -{ + +install_v() { # Install $1 into $2/ with mode $3 - verbose install -d "$2" && - verbose install -m "$3" "$1" "$2" + if ! verbose install -d "$2"; then + echo "Error: Failed to create directory $2" >&2 + exit 1 + fi + if ! verbose install -m "$3" "$1" "$2"; then + echo "Error: Failed to install $1 into $2" >&2 + exit 1 + fi } -install_v git-remote-gcrypt "$DESTDIR$prefix/bin" 755 +# --- VERSION DETECTION --- +: "${OS_RELEASE_FILE:=/etc/os-release}" +if [ -f "$OS_RELEASE_FILE" ]; then + # shellcheck disable=SC1091,SC1090 + . "$OS_RELEASE_FILE" + OS_IDENTIFIER=$ID # Linux +elif command -v uname >/dev/null; then + # Fallback for macOS/BSD (darwin) + OS_IDENTIFIER=$(uname -s | tr '[:upper:]' '[:lower:]') +else + OS_IDENTIFIER="unknown_OS" +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 "dev") +else + if [ ! -f debian/changelog ]; then + echo "Error: debian/changelog not found (and not a git repo)" >&2 + exit 1 + fi + VERSION=$(grep ^git-remote-gcrypt debian/changelog | head -n 1 | awk '{print $2}' | tr -d '()') +fi +VERSION="$VERSION ($OS_IDENTIFIER)" -if command -v rst2man >/dev/null -then +echo "Detected version: $VERSION" + +# Setup temporary build area +BUILD_DIR="./.build_tmp" +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" + +# --- GENERATION --- +verbose ./completions/gen_docs.sh + +# --- INSTALLATION --- +# This is where the 'Permission denied' happens if not sudo +install_v "$BUILD_DIR/git-remote-gcrypt" "$DESTDIR$prefix/bin" 755 + +if command -v rst2man >/dev/null; then rst2man='rst2man' -elif command -v rst2man.py >/dev/null # it is installed as rst2man.py on macOS -then +elif command -v rst2man.py >/dev/null; then # it is installed as rst2man.py on macOS rst2man='rst2man.py' fi -if [ -n "$rst2man" ] -then - trap 'rm -f git-remote-gcrypt.1.gz' EXIT - verbose $rst2man ./README.rst | gzip -9 > git-remote-gcrypt.1.gz +if [ -n "$rst2man" ]; then + # Update trap to clean up manpage too + trap 'rm -rf "$BUILD_DIR"; rm -f git-remote-gcrypt.1.gz' EXIT + verbose "$rst2man" ./README.rst | gzip -9 >git-remote-gcrypt.1.gz install_v git-remote-gcrypt.1.gz "$DESTDIR$prefix/share/man/man1" 644 else echo "'rst2man' not found, man page not installed" >&2 fi + +# Install shell completions +# Bash +install_v completions/bash/git-remote-gcrypt "$DESTDIR$prefix/share/bash-completion/completions" 644 +# Zsh +install_v completions/zsh/_git-remote-gcrypt "$DESTDIR$prefix/share/zsh/site-functions" 644 +# Fish +install_v completions/fish/git-remote-gcrypt.fish "$DESTDIR$prefix/share/fish/vendor_completions.d" 644 + +echo "Installation complete!" +echo "Completions installed to $DESTDIR$prefix/share/" diff --git a/tests/broken-test-gc.sh b/tests/broken-test-gc.sh new file mode 100755 index 0000000..cb0d14a --- /dev/null +++ b/tests/broken-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/coverage_report.py b/tests/coverage_report.py new file mode 100644 index 0000000..3a0234b --- /dev/null +++ b/tests/coverage_report.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +""" +Created on Wed Dec 31 08:57:33 2025 + +@author: shane +""" + +import os +import sys +import textwrap +import xml.etree.ElementTree as E + +xml_file = os.environ.get("XML_FILE") +patt = os.environ.get("PATT") + +if not xml_file: + print("Error: XML_FILE environment variable is not set.") + sys.exit(1) + +if not patt: + print("Error: PATT environment variable is not set.") + sys.exit(1) + +tree = E.parse(xml_file) +missed = [] +total_lines = 0 +missed_lines = 0 + +for c in tree.findall(".//class"): + filename = c.get("filename", "") + # Use exact filename match, not substring (avoid "uninstall.sh" matching "install.sh") + if filename == patt or filename.endswith("/" + patt): + for line in c.findall(".//line"): + total_lines += 1 + if line.get("hits") == "0": + missed.append(line.get("number")) + missed_lines += 1 + +if total_lines > 0: + COVERED = total_lines - missed_lines + pct = (COVERED / total_lines) * 100 + COLOR = "\033[32;1m" if pct > 80 else "\033[33;1m" if pct > 50 else "\033[31;1m" + 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) + # Group consecutive lines + ranges = [] + if missed: + start = int(missed[0]) + prev = start + for x in missed[1:]: + curr = int(x) + if curr == prev + 1: + prev = curr + else: + if start == prev: + ranges.append(str(start)) + else: + ranges.append(f"{start}-{prev}") + start = curr + prev = curr + # Append the last range + if start == prev: + ranges.append(str(start)) + else: + ranges.append(f"{start}-{prev}") + + print(f"\033[31;1m{len(missed)} missing lines\033[0m in {patt}:") + print( + textwrap.fill( + ", ".join(ranges), 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/installer-test-completions.sh b/tests/installer-test-completions.sh new file mode 100755 index 0000000..efdf901 --- /dev/null +++ b/tests/installer-test-completions.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# tests/test-completions.sh +# Verifies that bash completion script offers correct commands and excludes plumbing. + +# Mock commands if necessary? +# The completion script calls `git remote` etc. We can mock git. + +# Setup +TEST_DIR=$(dirname "$0") +COMP_FILE="$TEST_DIR/../completions/bash/git-remote-gcrypt" + +if [ ! -f "$COMP_FILE" ]; then + echo "FAIL: Completion file not found at $COMP_FILE" + exit 1 +fi + +# shellcheck source=/dev/null +source "$COMP_FILE" + +# Mock variables used by completion +COMP_WORDS=() +COMP_CWORD=0 +COMPREPLY=() + +# --- Mock git --- +# shellcheck disable=SC2329,SC2317 +git() { + if [[ $1 == "remote" ]]; then + echo "origin" + echo "backup" + fi +} +export -f git + +# --- Helper to run completion --- +run_completion() { + COMP_WORDS=("$@") + COMP_CWORD=$((${#COMP_WORDS[@]} - 1)) + COMPREPLY=() + _git_remote_gcrypt +} + +# --- Tests --- + +FAILURES=0 + +echo "Test 1: Top-level commands (git-remote-gcrypt [TAB])" +run_completion "git-remote-gcrypt" "" +# Expect: check clean stat -h --help -v --version +# Expect NO: capabilities list push fetch +EXPECTED="check clean stat" +FORBIDDEN="capabilities list push fetch" + +OUTPUT="${COMPREPLY[*]}" + +for cmd in $EXPECTED; do + if [[ ! $OUTPUT =~ $cmd ]]; then + echo " FAIL: Expected '$cmd' in completion output." + FAILURES=$((FAILURES + 1)) + fi +done + +for cmd in $FORBIDDEN; do + if [[ $OUTPUT =~ $cmd ]]; then + echo " FAIL: Forbidden '$cmd' found in completion output." + FAILURES=$((FAILURES + 1)) + fi +done + +if [[ $OUTPUT =~ check ]] && [[ ! $OUTPUT =~ capabilities ]]; then + echo " PASS: Top-level commands look correct." +fi + +echo "Test 2: 'stat' subcommand (git-remote-gcrypt stat [TAB])" +run_completion "git-remote-gcrypt" "stat" "" +# Should complete remotes (mocked as origin backup) +OUTPUT="${COMPREPLY[*]}" +if [[ $OUTPUT =~ "origin" ]] && [[ $OUTPUT =~ "backup" ]]; then + echo " PASS: 'stat' completes remotes." +else + echo " FAIL: 'stat' did not complete remotes. Got: $OUTPUT" + FAILURES=$((FAILURES + 1)) +fi + +echo "Test 3: 'clean' subcommand flags (git-remote-gcrypt clean [TAB])" +run_completion "git-remote-gcrypt" "clean" "" +# Should have -f --force etc. +OUTPUT="${COMPREPLY[*]}" +if [[ $OUTPUT =~ "--force" ]] && [[ $OUTPUT =~ "--init" ]]; then + echo " PASS: 'clean' completes flags." +else + echo " FAIL: 'clean' missing flags. Got: $OUTPUT" + FAILURES=$((FAILURES + 1)) +fi + +if [ "$FAILURES" -eq 0 ]; then + echo "--------------------------" + echo "All completion tests passed." + exit 0 +else + echo "--------------------------" + echo "$FAILURES completion tests FAILED." + exit 1 +fi diff --git a/tests/installer-test-logic.sh b/tests/installer-test-logic.sh new file mode 100755 index 0000000..ae3a05e --- /dev/null +++ b/tests/installer-test-logic.sh @@ -0,0 +1,265 @@ +#!/bin/bash +set -u + +# 1. Setup Sandbox +# 1. Setup Sandbox in project root to help kcov tracking +REPO_ROOT=$(pwd) +mkdir -p .tmp +SANDBOX=$(mktemp -d -p "$REPO_ROOT/.tmp" sandbox.XXXXXX) +# Use realpath for the sandbox to avoid any confusion +SANDBOX=$(realpath "$SANDBOX") +trap 'rm -rf "$SANDBOX"' EXIT + +# Helpers +print_info() { printf "\033[1;36m[TEST] %s\033[0m\n" "$1"; } +print_success() { printf "\033[1;34m[TEST] ✓ %s\033[0m\n" "$1"; } +print_err() { printf "\033[1;31m[TEST] FAIL: %s\033[0m\n" "$1"; } + +print_info "Running install logic tests in $SANDBOX..." + +# 2. Copy/Symlink artifacts +# Copy install.sh so kcov can track it correctly (symlinks confuse kcov) +ln -s "$REPO_ROOT/install.sh" "$SANDBOX/install.sh" +ln -s "$REPO_ROOT/git-remote-gcrypt" "$SANDBOX/git-remote-gcrypt" +ln -s "$REPO_ROOT/utils" "$SANDBOX/utils" +cp -r "$REPO_ROOT/completions" "$SANDBOX/completions" +# Mock gen_docs.sh to avoid kcov tracing issues with child process +echo '#!/bin/sh' >"$SANDBOX/completions/gen_docs.sh" +echo 'exit 0' >>"$SANDBOX/completions/gen_docs.sh" +chmod +x "$SANDBOX/completions/gen_docs.sh" +# Copy README as it might be edited/checked +cp "$REPO_ROOT/README.rst" "$SANDBOX/" +cp "$REPO_ROOT/completions/templates/README.rst.in" "$SANDBOX/" + +cd "$SANDBOX" || exit 2 + +# Ensure source binary has the placeholder for sed to work on +# If the local file already has a real version, inject the placeholder +if ! grep -q "@@DEV_VERSION@@" git-remote-gcrypt; then + sed -i.bak 's/^VERSION=.*/VERSION="@@DEV_VERSION@@"/' git-remote-gcrypt 2>/dev/null \ + || { sed 's/^VERSION=.*/VERSION="@@DEV_VERSION@@"/' git-remote-gcrypt >git-remote-gcrypt.tmp && mv git-remote-gcrypt.tmp git-remote-gcrypt; } +fi +chmod +x git-remote-gcrypt + +INSTALLER="./install.sh" + +assert_version() { + EXPECTED_SUBSTRING="$1" + PREFIX="$SANDBOX/usr" + export prefix="$PREFIX" + unset DESTDIR + + # Run the installer and capture output + cat .install_log 2>&1 || { + print_err "Installer failed unexpectedly. Output:" + cat .install_log + exit 1 + } + rm -f .install_log + + INSTALLED_BIN="$PREFIX/bin/git-remote-gcrypt" + chmod +x "$INSTALLED_BIN" + + OUTPUT=$("$INSTALLED_BIN" --version 2>&1 /dev/null 2>&1; then + print_err "FAILED: Installer should have exited 1 without debian/changelog" + exit 1 +else + printf " ✓ %s\n" "Installer strictly requires metadata" +fi + +# --- TEST 2: Debian-sourced Versioning --- +echo "--- Test 2: Versioning from Changelog ---" +mkdir -p debian +echo "git-remote-gcrypt (5.5.5-1) unstable; urgency=low" >debian/changelog + +# Determine the OS identifier for the test expectation +if [ -f /etc/os-release ]; then + # shellcheck source=/dev/null + source /etc/os-release + OS_IDENTIFIER="$ID" +elif command -v uname >/dev/null; then + OS_IDENTIFIER=$(uname -s | tr '[:upper:]' '[:lower:]') +else + OS_IDENTIFIER="unknown_os" +fi + +# Use the identified OS for the expected string +EXPECTED_TAG="5.5.5-1 ($OS_IDENTIFIER)" + +assert_version "$EXPECTED_TAG" + +# --- TEST 3: Prefix Support (Mac-idiomatic) --- +echo "--- Test 3: Prefix Support ---" +rm -rf "${SANDBOX:?}/usr" +export prefix="$SANDBOX/usr" +unset DESTDIR + +bash "$INSTALLER" >/dev/null 2>&1 || { + print_err "Installer FAILED" + exit 1 +} + +if [ -f "$SANDBOX/usr/bin/git-remote-gcrypt" ]; then + printf " ✓ %s\n" "Prefix honored" +else + print_err "FAILED: Binary not found in expected prefix location" + exit 1 +fi + +# --- TEST 4: DESTDIR Support (Linux-idiomatic) --- +echo "--- Test 4: DESTDIR Support ---" +rm -rf "${SANDBOX:?}/pkg_root" +export prefix="/usr" +export DESTDIR="$SANDBOX/pkg_root" + +bash "$INSTALLER" >/dev/null 2>&1 || { + print_err "Installer FAILED" + exit 1 +} + +if [ -f "$SANDBOX/pkg_root/usr/bin/git-remote-gcrypt" ]; then + printf " ✓ %s\n" "DESTDIR honored" +else + print_err "FAILED: Binary not found in DESTDIR" + exit 1 +fi + +# --- TEST 5: Permission Failure (Simulated) --- +echo "--- Test 5: Permission Failure (Simulated) ---" +# We act as root in some containers, so chmod -w won't stop writes. +# Instead, we mock 'install' to fail, ensuring error paths are hit. +SHADOW_BIN_FAIL="$SANDBOX/shadow_bin_install_fail" +mkdir -p "$SHADOW_BIN_FAIL" +echo '#!/bin/sh' >"$SHADOW_BIN_FAIL/install" +echo 'echo "Mock failure" >&2' >>"$SHADOW_BIN_FAIL/install" +echo 'exit 1' >>"$SHADOW_BIN_FAIL/install" +chmod +x "$SHADOW_BIN_FAIL/install" + +if PATH="$SHADOW_BIN_FAIL:$PATH" prefix="$SANDBOX/usr" DESTDIR="" bash "$INSTALLER" >.install_log 2>&1; then + print_err "FAILED: Installer should have failed due to install command failure" + cat .install_log + rm -rf "$SHADOW_BIN_FAIL" + exit 1 +else + printf " ✓ %s\n" "Installer failed gracefully on install error" +fi +rm -rf "$SHADOW_BIN_FAIL" + +# --- TEST 6: Missing rst2man --- +echo "--- Test 6: Missing rst2man ---" +# Shadow rst2man in PATH +SHADOW_BIN="$SANDBOX/shadow_bin" +mkdir -p "$SHADOW_BIN" +echo '#!/bin/sh' >"$SHADOW_BIN/rst2man" +echo 'exit 127' >>"$SHADOW_BIN/rst2man" +chmod +x "$SHADOW_BIN/rst2man" +ln -sf "$SHADOW_BIN/rst2man" "$SHADOW_BIN/rst2man.py" + +if PATH="$SHADOW_BIN:$PATH" prefix="$SANDBOX/usr" DESTDIR="" bash "$INSTALLER" >.install_log 2>&1; then + printf " ✓ %s\n" "Installer handled missing rst2man" +else + print_err "Installer FAILED unexpectedly with missing rst2man. Output:" + cat .install_log + exit 1 +fi + +# --- TEST 7: OS Detection Fallbacks --- +echo "--- Test 7: OS Detection Fallbacks ---" +# 7a: Hit 'uname' path by mocking absence of /etc/os-release via OS_RELEASE_FILE +if prefix="$SANDBOX/usr" DESTDIR="" OS_RELEASE_FILE="$SANDBOX/nonexistent" bash "$INSTALLER" >.install_log 2>&1; then + printf " ✓ %s\n" "OS Detection: uname path hit" +else + print_err "Installer FAILED in OS fallback (uname) path" + exit 1 +fi + +# 7b: Hit 'unknown_OS' path by mocking absence of both +# We need to shadow 'uname' too +SHADOW_BIN_OS="$SANDBOX/shadow_bin_os" +mkdir -p "$SHADOW_BIN_OS" +echo '#!/bin/sh' >"$SHADOW_BIN_OS/uname" +echo 'exit 127' >>"$SHADOW_BIN_OS/uname" +chmod +x "$SHADOW_BIN_OS/uname" + +if PATH="$SHADOW_BIN_OS:$PATH" prefix="$SANDBOX/usr" DESTDIR="" OS_RELEASE_FILE="$SANDBOX/unknown" bash "$INSTALLER" >.install_log 2>&1; then + printf " ✓ %s\n" "OS Detection: unknown_OS path hit" +else + print_err "Installer FAILED in unknown_OS fallback path" + exit 1 +fi +rm -rf "$SHADOW_BIN_OS" "$SHADOW_BIN" + +# --- TEST 8: Termux PREFIX Auto-Detection --- +echo "--- Test 8: Termux PREFIX Auto-Detection ---" +# 8a: When /usr/local doesn't exist but PREFIX is set, use PREFIX +TERMUX_PREFIX="$SANDBOX/termux_prefix" +mkdir -p "$TERMUX_PREFIX/bin" +mkdir -p "$TERMUX_PREFIX/share/bash-completion/completions" +mkdir -p "$TERMUX_PREFIX/share/zsh/site-functions" +mkdir -p "$TERMUX_PREFIX/share/fish/vendor_completions.d" +mkdir -p "$TERMUX_PREFIX/share/man/man1" + +# Unset prefix so auto-detection kicks in +unset prefix +unset DESTDIR + +# Run with PREFIX set but explicit prefix unset +# Mock /usr/local via _USR_LOCAL override +if _USR_LOCAL="/non/existent/path" PREFIX="$TERMUX_PREFIX" bash "$INSTALLER" >.install_log 2>&1; then + if [ -f "$TERMUX_PREFIX/bin/git-remote-gcrypt" ]; then + printf " ✓ %s\n" "Termux PREFIX auto-detection works" + else + # On systems with /usr/local the default is still used + if grep -q "Detected Termux" .install_log; then + print_err "FAILED: Termux detected but binary not in PREFIX" + cat .install_log + exit 1 + else + printf " ✓ %s\n" "Non-Termux: default prefix used (expected on Linux)" + fi + fi +else + print_err "Installer FAILED in Termux PREFIX test" + cat .install_log + exit 1 +fi + +# 8b: When prefix is explicitly set, it should override PREFIX +echo "--- Test 8b: Explicit prefix overrides PREFIX ---" +rm -rf "$SANDBOX/explicit_prefix" +mkdir -p "$SANDBOX/explicit_prefix" + +if PREFIX="$TERMUX_PREFIX" prefix="$SANDBOX/explicit_prefix" DESTDIR="" bash "$INSTALLER" >.install_log 2>&1; then + if [ -f "$SANDBOX/explicit_prefix/bin/git-remote-gcrypt" ]; then + printf " ✓ %s\n" "Explicit prefix overrides PREFIX" + else + print_err "FAILED: Explicit prefix not honored" + cat .install_log + exit 1 + fi +else + print_err "Installer FAILED in explicit prefix test" + cat .install_log + exit 1 +fi +rm -rf "$TERMUX_PREFIX" "$SANDBOX/explicit_prefix" + +print_success "All install logic tests passed." +[ -n "${COV_DIR:-}" ] && print_success "OK. Report: file://${COV_DIR}/index.html" + +exit 0 diff --git a/tests/manual_test_clean_local.sh b/tests/manual_test_clean_local.sh new file mode 100755 index 0000000..4328bc6 --- /dev/null +++ b/tests/manual_test_clean_local.sh @@ -0,0 +1,122 @@ +#!/bin/sh +set -e +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_SYSTEM=/dev/null + +# Setup test environment +echo "Setting up test environment..." +PROJECT_ROOT="$(pwd)" +mkdir -p .tmp +TEST_DIR="$PROJECT_ROOT/.tmp/gcrypt_test" +rm -rf "$TEST_DIR" +mkdir -p "$TEST_DIR" + +REPO_DIR="$TEST_DIR/repo" +REMOTE_DIR="$TEST_DIR/remote" + +mkdir -p "$REPO_DIR" +mkdir -p "$REMOTE_DIR" + +# Initialize repo +cd "$REPO_DIR" +git -c init.defaultBranch=master init +git config user.email "you@example.com" +git config user.name "Your Name" + +# Create a few text files +echo "content 1" >file1.txt +echo "content 2" >file2.txt +echo "content 3" >file3.txt +git add file1.txt file2.txt file3.txt +git commit -m "Initial commit with multiple files" + +# Setup gcrypt remote +GCRYPT_BIN="$PROJECT_ROOT/git-remote-gcrypt" +if [ ! -x "$GCRYPT_BIN" ]; then + echo "Error: git-remote-gcrypt binary not found at $GCRYPT_BIN" + exit 1 +fi + +# GPG Setup (embedded) +export GNUPGHOME="$TEST_DIR/gpg" +mkdir -p "$GNUPGHOME" +chmod 700 "$GNUPGHOME" + +# Wrapper to suppress warnings, handle args, and FORCE GNUPGHOME +cat <"${GNUPGHOME}/gpg" +#!/usr/bin/env bash +export GNUPGHOME="$GNUPGHOME" +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" +# VIOLATION FIX: Add wrapper to PATH so it's actually used +export PATH="${GNUPGHOME}:$PATH" + +# Generate key +echo "Generating GPG key..." +gpg --batch --passphrase "" --quick-generate-key "Test " + +# Initialize REMOTE_DIR as a bare git repo so gcrypt treats it as a git backend (gitception) +# This is required to trigger gitception_remove +git -c init.defaultBranch=master init --bare "$REMOTE_DIR" + +# Configure remote +git remote add origin "gcrypt::$REMOTE_DIR" +git config remote.origin.gcrypt-participants "test@test.com" +git config remote.origin.gcrypt-signingkey "test@test.com" + +# Configure local git for test (VIOLATION FIX: Removed --global) +git config advice.defaultBranchName false + +export PATH="$PROJECT_ROOT:$PATH" + +echo "Pushing to remote..." +# Explicitly use +HEAD:master to ensure 'force' is detected by gcrypt. +# Using HEAD:master ensures we push whatever branch 'git init' created (main/master) to 'master' on remote. +git push origin +HEAD:master + +# Create garbage on remote +cd "$TEST_DIR" +git clone "$REMOTE_DIR" raw_remote_clone +cd raw_remote_clone +git checkout master || git checkout -b master + +# Add multiple garbage files +# Add multiple garbage files +echo "garbage 1" >garbage1.txt +echo "garbage 2" >garbage2.txt +git add garbage1.txt garbage2.txt +# Configure identity for this clone to avoid global config interference +git config user.email "garbage@maker.com" +git config user.name "Garbage Maker" +git commit -m "Add garbage files" +git push origin master + +# Go back to local repo +cd "$REPO_DIR" + +# Create conflicting local files (untracked but matching name, different content) +# This simulates the "local modifications" error when `clean` tries to remove them. +echo "local conflict 1" >garbage1.txt +echo "local conflict 2" >garbage2.txt +# Add them to local index so they are 'tracked' in the worktree, potentially confusing git rm against the temp index? +# Or just ensure they exist. The user reported 'local modifications'. +git add garbage1.txt garbage2.txt + +echo "Running clean --force (expecting failure and hints)..." +echo "---------------------------------------------------" +if ! git-remote-gcrypt clean --force origin; then + echo "---------------------------------------------------" + echo "Clean failed as expected." +else + echo "Clean succeeded unexpectedly!" + exit 1 +fi diff --git a/tests/system-test-clean-command.sh b/tests/system-test-clean-command.sh new file mode 100755 index 0000000..68c712d --- /dev/null +++ b/tests/system-test-clean-command.sh @@ -0,0 +1,296 @@ +#!/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" + +# Isolate git config from user environment +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_CONFIG_GLOBAL=/dev/null +unset GIT_CONFIG_PARAMETERS + +# Suppress git advice messages +# Note: git-remote-gcrypt reads actual config files, not just CLI -c options +GIT="git -c advice.defaultBranchName=false -c commit.gpgSign=false -c init.defaultBranch=master" + +# -------------------------------------------------- +# Set up test environment +# -------------------------------------------------- +# Create temp directory +tempdir=$(mktemp -d) +trap 'rm -rf "$tempdir"' EXIT + +print_info "Setting up test environment..." + +# -------------------------------------------------- +# GPG Setup (Derived from system-test.sh) +# -------------------------------------------------- +export GNUPGHOME="${tempdir}/gpg" +mkdir "${GNUPGHOME}" + +# Wrapper to suppress obsolete warnings +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" + +# Generate key +( + gpg --batch --passphrase "" --quick-generate-key "Test " +) + +# -------------------------------------------------- +# Git Setup +# -------------------------------------------------- + +# 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" +$GIT config gpg.program "${GNUPGHOME}/gpg" +# Needed for encryption to work during setup +$GIT config gcrypt.participants "test@test.com" + +# Add multiple unencrypted files +# Add multiple unencrypted files including nested ones +echo "SECRET=abc" >"$tempdir/secret1.txt" +echo "PASSWORD=xyz" >"$tempdir/secret2.txt" +# Nested file +mkdir -p "$tempdir/subdir" +echo "NESTED=123" >"$tempdir/subdir/nested.txt" + +echo "SPACE=789" >"$tempdir/Has Space.txt" +echo "PARENS=ABC" >"$tempdir/Has (Parens).txt" + +BLOB1=$($GIT hash-object -w "$tempdir/secret1.txt") +BLOB2=$($GIT hash-object -w "$tempdir/secret2.txt") +BLOB3=$($GIT hash-object -w "$tempdir/subdir/nested.txt") +BLOB4=$($GIT hash-object -w "$tempdir/Has Space.txt") +BLOB5=$($GIT hash-object -w "$tempdir/Has (Parens).txt") + +# Create root tree using index +export GIT_INDEX_FILE=index.dirty +$GIT update-index --add --cacheinfo 100644 "$BLOB1" "secret1.txt" +$GIT update-index --add --cacheinfo 100644 "$BLOB2" "secret2.txt" +$GIT update-index --add --cacheinfo 100644 "$BLOB3" "subdir/nested.txt" +$GIT update-index --add --cacheinfo 100644 "$BLOB4" "Has Space.txt" +$GIT update-index --add --cacheinfo 100644 "$BLOB5" "Has (Parens).txt" +TREE=$($GIT write-tree) +rm index.dirty + +COMMIT=$(echo "Dirty commit with nested files" | $GIT commit-tree "$TREE") +$GIT update-ref refs/heads/master "$COMMIT" + +print_info "Created dirty remote with 4 unencrypted files" + +# Test helper +assert_grep() { + local pattern="$1" + local input="$2" + local msg="$3" + if echo "$input" | grep -q "$pattern"; then + print_success "$msg" + else + print_err "$msg - Pattern '$pattern' not found" + echo "Output: $input" + exit 1 + fi +} + +# -------------------------------------------------- +# Test 1: Usage message when no remotes found +# -------------------------------------------------- +print_info "Test 1: Usage message..." +mkdir "$tempdir/empty" && cd "$tempdir/empty" && $GIT init >/dev/null +output=$("$SCRIPT_DIR/git-remote-gcrypt" clean 2>&1 || :) +assert_grep "Usage: git-remote-gcrypt clean" "$output" "clean shows usage when no URL/remote found" + +# -------------------------------------------------- +# Test 2: Safety Check (Abort on non-gcrypt) +# -------------------------------------------------- +print_info "Test 2: Safety Check (Abort on non-gcrypt --force)..." +cd "$tempdir/remote.git" +output=$("$SCRIPT_DIR/git-remote-gcrypt" clean --force "$tempdir/remote.git" 2>&1 || :) +assert_grep "Error: No gcrypt manifest found" "$output" "clean --force aborts on non-gcrypt repo" + +if $GIT ls-tree HEAD | grep -q "secret1.txt"; then + print_success "Files preserved (Safety check passed)" +else + print_err "Files deleted despite safety check!" + exit 1 +fi + +# -------------------------------------------------- +# Test 3: Remote resolution (Abort on non-gcrypt) +# -------------------------------------------------- +print_info "Test 3: Remote resolution..." +mkdir -p "$tempdir/client" && cd "$tempdir/client" && $GIT init >/dev/null +$GIT config gpg.program "${GNUPGHOME}/gpg" +$GIT remote add origin "$tempdir/remote.git" +output=$("$SCRIPT_DIR/git-remote-gcrypt" clean origin 2>&1 || :) +assert_grep "Error: Remote 'origin' is not a gcrypt:: remote" "$output" "clean aborts on resolved non-gcrypt remote" + +# -------------------------------------------------- +# Test 4: Remote listing +# -------------------------------------------------- +print_info "Test 4: Remote listing..." +$GIT remote add gcrypt-origin "gcrypt::$tempdir/remote.git" +output=$("$SCRIPT_DIR/git-remote-gcrypt" clean 2>&1 || :) +assert_grep "Available remotes:" "$output" "clean lists remotes" +assert_grep "gcrypt-origin" "$output" "clean listed 'gcrypt-origin'" + +# -------------------------------------------------- +# Test 5: Clean Valid Gcrypt Repo +# -------------------------------------------------- +print_info "Test 5: Clean Valid Gcrypt Repo..." + +# 1. Initialize a valid gcrypt repo +mkdir "$tempdir/valid.git" && cd "$tempdir/valid.git" && $GIT init --bare >/dev/null +$GIT config user.email "test@test.com" +$GIT config user.name "Test" +cd "$tempdir/client" +$GIT config user.name "Test" +$GIT config user.email "test@test.com" +$GIT config user.signingkey "test@test.com" +# Create content to push +echo "valid content" >content.txt +$GIT add content.txt +$GIT commit -m "init valid" +# Push to intialize +set -x +$GIT push -f "gcrypt::$tempdir/valid.git" master:master || { + set +x + print_err "Git push failed" + exit 1 +} +set +x + +print_info "Initialized valid gcrypt repo" + +# 2. Inject garbage file into the remote git index/tree +cd "$tempdir/valid.git" +GREF="refs/heads/master" +if ! $GIT rev-parse --verify "$GREF" >/dev/null 2>&1; then + print_err "Gref $GREF not found in remote!" + exit 1 +fi + +GARBAGE_BLOB=$(echo "GARBAGE DATA" | $GIT hash-object -w --stdin) +CURRENT_TREE=$($GIT rev-parse "$GREF^{tree}") +export GIT_INDEX_FILE=index.garbage +$GIT read-tree "$CURRENT_TREE" +$GIT update-index --add --cacheinfo 100644 "$GARBAGE_BLOB" "garbage_file" +NEW_TREE=$($GIT write-tree) +rm index.garbage +PARENT=$($GIT rev-parse "$GREF") +NEW_COMMIT=$(echo "Inject garbage" | $GIT commit-tree "$NEW_TREE" -p "$PARENT") +$GIT update-ref "$GREF" "$NEW_COMMIT" + +# Verify injection +if ! $GIT ls-tree -r "$GREF" | grep -q "garbage_file"; then + print_err "Failed to inject garbage_file into $GREF" + exit 1 +fi +print_info "Injected garbage_file into remote $GREF" + +# 2.5 Inject a DOTFILE garbage file +DOT_GARBAGE_BLOB=$(echo "HIDDEN GARBAGE" | $GIT hash-object -w --stdin) +export GIT_INDEX_FILE=index.dotgarbage +$GIT read-tree "$NEW_TREE" +$GIT update-index --add --cacheinfo 100644 "$DOT_GARBAGE_BLOB" ".garbage (file)" +NEW_TREE_DOT=$($GIT write-tree) +rm index.dotgarbage +NEW_COMMIT_DOT=$(echo "Inject dot garbage" | $GIT commit-tree "$NEW_TREE_DOT" -p "$NEW_COMMIT") +$GIT update-ref "$GREF" "$NEW_COMMIT_DOT" + +if ! $GIT ls-tree -r "$GREF" | grep -F -q ".garbage (file)"; then + print_err "Failed to inject .garbage (file) into $GREF" + exit 1 +fi +print_info "Injected '.garbage (file)' into remote $GREF" + +# 3. Scan (expect to find garbage_file) +set -x +output=$("$SCRIPT_DIR/git-remote-gcrypt" clean "gcrypt::$tempdir/valid.git" 2>&1) +set +x +assert_grep "garbage_file" "$output" "clean identified unencrypted file in valid repo" +assert_grep "\.garbage (file)" "$output" "clean identified unencrypted DOTFILE in valid repo" +assert_grep "NOTE: This is a scan" "$output" "clean scan-only mode confirmed" + +# 4. Clean Force +"$SCRIPT_DIR/git-remote-gcrypt" clean "gcrypt::$tempdir/valid.git" --force + +# Verify garbage_file is GONE from the GREF tree +UPDATED_TREE=$($GIT rev-parse "$GREF^{tree}") +if $GIT ls-tree -r "$UPDATED_TREE" | grep -q "garbage_file"; then + print_err "Garbage file still exists in remote git tree after CLEAN FORCE!" + exit 1 +else + print_success "Garbage file removed successfully." +fi + +# -------------------------------------------------- +# Test 6: check command +# -------------------------------------------------- +print_info "Test 6: check command..." +output=$("$SCRIPT_DIR/git-remote-gcrypt" check "$tempdir/remote.git" 2>&1 || :) +assert_grep "gcrypt: Checking remote:" "$output" "check command is recognized" + +print_success "All clean/check command tests passed!" + +# -------------------------------------------------- +# Test 7: clean --init (Bypass manifest check) +# -------------------------------------------------- +print_info "Test 7: clean --init (Bypass manifest check)..." + +# Reuse the dirty remote from earlier ($tempdir/remote.git) which has secret1.txt and secret2.txt + +# 2. Standard clean should warn and list files (dry-run) +output=$("$SCRIPT_DIR/git-remote-gcrypt" clean "gcrypt::$tempdir/remote.git" 2>&1 || :) +assert_grep "WARNING: No gcrypt manifest found" "$output" "clean warns on dirty remote" +assert_grep "Listing all files as potential garbage" "$output" "clean lists files on dirty remote" + +# 2. Clean with --init should succeed (scan only) +output=$("$SCRIPT_DIR/git-remote-gcrypt" clean --init "gcrypt::$tempdir/remote.git" 2>&1 || :) +assert_grep "WARNING: No gcrypt manifest found, but --init specified" "$output" "--init warns about missing manifest" +assert_grep "Found the following files to remove" "$output" "--init scan found files" +assert_grep "secret1.txt" "$output" "--init found secret1.txt" +assert_grep "subdir/nested.txt" "$output" "--init found nested file in subdir" +assert_grep "Has Space.txt" "$output" "--init found file with spaces" +assert_grep "Has (Parens).txt" "$output" "--init found file with parens" + +# 3. Clean with --init --force should remove files +"$SCRIPT_DIR/git-remote-gcrypt" clean --init --force "gcrypt::$tempdir/remote.git" >/dev/null 2>&1 + +cd "$tempdir/remote.git" +if $GIT ls-tree HEAD | grep -q "secret1.txt"; then + print_err "--init --force FAILED to remove secret1.txt" + exit 1 +else + print_success "--init --force removed unencrypted files" +fi diff --git a/tests/system-test-clean-repack.sh b/tests/system-test-clean-repack.sh new file mode 100755 index 0000000..f4884a6 --- /dev/null +++ b/tests/system-test-clean-repack.sh @@ -0,0 +1,183 @@ +#!/bin/sh +set -e + +# Setup test environment +echo "Setting up repack test environment..." +PROJECT_ROOT="$(pwd)" +mkdir -p .tmp +TEST_DIR="$PROJECT_ROOT/.tmp/repack_test" +rm -rf "$TEST_DIR" +mkdir -p "$TEST_DIR" + +# Repo paths +REPO_DIR="$TEST_DIR/repo" +REMOTE_DIR="$TEST_DIR/remote" + +mkdir -p "$REPO_DIR" +mkdir -p "$REMOTE_DIR" + +# Tools +GCRYPT_BIN="$PROJECT_ROOT/git-remote-gcrypt" +if [ ! -x "$GCRYPT_BIN" ]; then + echo "Error: git-remote-gcrypt binary not found at $GCRYPT_BIN" + exit 1 +fi + +# GPG Setup +export GNUPGHOME="$TEST_DIR/gpg" +mkdir -p "$GNUPGHOME" +chmod 700 "$GNUPGHOME" + +cat <<'EOF' >"${GNUPGHOME}/gpg" +#!/usr/bin/env bash +export GNUPGHOME="$GNUPGHOME" +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" + +# Git config isolation +# Git config isolation (Strict: no global config) +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_CONFIG_GLOBAL=/dev/null + +echo "Generating GPG key..." +gpg --batch --passphrase "" --quick-generate-key "Test " + +# Initialize repo +cd "$REPO_DIR" +git -c init.defaultBranch=master init +git config user.email "test@test.com" +git config user.name "Test User" +git config advice.defaultBranchName false + +# Initialize local remote +git -c init.defaultBranch=master init --bare "$REMOTE_DIR" +git remote add origin "gcrypt::$REMOTE_DIR" +git config remote.origin.gcrypt-participants "test@test.com" +git config remote.origin.gcrypt-signingkey "test@test.com" +git config gpg.program "${GNUPGHOME}/gpg" +git config user.signingkey "test@test.com" + +export PATH="$PROJECT_ROOT:$PATH" + +# Create fragmentation by pushing multiple times +echo "Push 1" +echo "data 1" >file1.txt +git add file1.txt +git commit -m "Commit 1" +# Initial push needs force to initialize remote gcrypt repo +git push origin +master + +echo "Push 2" +echo "data 2" >file2.txt +git add file2.txt +git commit -m "Commit 2" +git push origin master + +echo "Push 3" +echo "data 3" >file3.txt +git add file3.txt +git commit -m "Commit 3" +git push origin master + +# Verify we have multiple pack files in remote +# Note: gcrypt stores packs in 'pack' directory if using rsync-like backend? +# For git backend (gitception), they are objects in the git repo. +# We are using local file backend? No, gcrypt::$REMOTE_DIR where REMOTE_DIR is bare git repo. +# This makes it a Git Backend (gitception). +# The packs are stored as blobs in the backend repo. +# But 'do_push' logic downloads packs using 'git rev-list'. +# The 'Packlist' manifest file lists the active packs. +# We can check the Manifest to count packs. + +# Clone the raw backend to inspect manifest +cd "$TEST_DIR" +git clone "$REMOTE_DIR" raw_backend +cd raw_backend +git checkout master +# The manifest is a file with randomized name, but we can find it encrypt/decrypt? +# No, easier: use git-remote-gcrypt to list packs via debug or inference. +# Or just trust that multiple pushes created multiple packs (as gcrypt doesn't auto-repack on push unless configured). + +# Let's count lines in Packlist from the helper's debug output? +# Or we can verify the backend git repo has multiple commits (one per push). +HEAD_SHA=$(git rev-parse HEAD) +echo "Backend SHA: $HEAD_SHA" +# Start should have 3 commits (init, push1, push2, push3) -> wait, init is implicit. +# Each push updates the manifested repo. + +# Inject garbage to verify cleanup AND repack +echo "GARBAGE" >garbage.txt +GARBAGE_BLOB=$(git hash-object -w garbage.txt) +echo "Created garbage blob: $GARBAGE_BLOB" +# Manually inject into backend (simulate inconsistency) +# But here we are simulating a gitception remote. +# To simulate "garbage" (unencrypted file), we can push one or hack the backend. +# Using 'git-remote-gcrypt' clean mechanism relies on files existing in the remote manifest (or filesystem for other backends). +# For git backend, "garbage" is a file in the remote repo's HEAD tree that isn't in the manifest/packed list. +# Let's clone backend, add file, push. +cd "$TEST_DIR/raw_backend" +echo "Garbage Data" >".garbage (file)" +git add ".garbage (file)" +git config user.email "test@test.com" +git config user.name "Test User" +git commit -m "Inject unencrypted garbage" +git push origin master +HEAD_SHA=$(git rev-parse HEAD) +echo "Backend SHA before clean: $HEAD_SHA" + +# Verify garbage exists +cd "$REPO_DIR" +# Run clean --repack --force (needed because we have garbage now) +echo "Running clean --repack --force..." +git-remote-gcrypt clean --repack --force origin + +# Verify garbage removal from backend +cd "$TEST_DIR" +rm -rf raw_backend_verify +git clone "$REMOTE_DIR" raw_backend_verify +cd raw_backend_verify + +if [ -f ".garbage (file)" ]; then + echo "Failure: .garbage (file) still exists in backend!" + exit 1 +else + echo "Success: .garbage (file) removed." +fi + +# Verify result +# Check if commit SHA changed. Repack force-pushes a new manifest state. +cd "$TEST_DIR/raw_backend_verify" +NEW_HEAD=$(git rev-parse HEAD) +echo "Old HEAD: $HEAD_SHA" +echo "New HEAD: $NEW_HEAD" + +if [ "$NEW_HEAD" != "$HEAD_SHA" ]; then + echo "Repack successful (HEAD changed)." +else + echo "Repack failed (HEAD did not change)." + exit 1 +fi + +# Verify data integrity +cd "$REPO_DIR" +# Force fresh clone to verified data +cd "$TEST_DIR" +git clone "gcrypt::$REMOTE_DIR" verified_repo +cd verified_repo +if [ -f file1.txt ] && [ -f file2.txt ] && [ -f file3.txt ]; then + echo "Data integrity verified." +else + echo "Data integrity failed!" + exit 1 +fi + +echo "Test passed." diff --git a/tests/system-test-multikey.sh b/tests/system-test-multikey.sh new file mode 100755 index 0000000..213cde6 --- /dev/null +++ b/tests/system-test-multikey.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright 2023 Cathy J. Fitzpatrick +# SPDX-License-Identifier: GPL-2.0-or-later +set -efuC -o pipefail +shopt -s inherit_errexit + +# Helpers +print_info() { printf "\033[1;36m[TEST] %s\033[0m\n" "$1"; } +print_success() { printf "\033[1;34m[TEST] ✓ %s\033[0m\n" "$1"; } +print_warn() { printf "\033[1;33m[TEST] WARNING: %s\033[0m\n" "$1"; } +print_err() { printf "\033[1;31m[TEST] FAIL: %s\033[0m\n" "$1"; } + +# Settings +num_commits=5 +files_per_commit=3 + +# Check GPG version +gpg_ver=$(gpg --version | head -n1 | awk '{print $3}') +print_info "GPG Version detected: $gpg_ver" + +# Function to check if version strictly less than +version_lt() { + if [ "$1" = "$2" ]; then + return 1 + fi + [ "$1" = "$(echo -e "$1\n$2" | sort -V | head -n1)" ] +} + +# Determine if we expect the bug (Threshold: >= 2.4.5 assumed for now) +# Ubuntu 22.04 (2.2.27) confirmed NOT to have it. +# Arch (2.4.9) definitely has it. +expect_bug=1 +if version_lt "$gpg_ver" "2.4.5"; then + print_warn "GPG version $gpg_ver is old. We do not expect the checksum bug here." + expect_bug=0 +else + print_info "GPG version $gpg_ver is modern. We expect the checksum bug." +fi + +print_info "Running multi-key clone test..." +random_source="/dev/urandom" +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" + +readonly num_commits files_per_commit random_source random_data_per_file \ + default_branch test_user_name test_user_email + +# ----------------- Helper Functions ----------------- +indent() { + sed 's/^\(.*\)$/ \1/' +} + +section_break() { + echo + printf '*%.0s' {1..70} + echo $'\n' +} + +assert() { + ( + set +e + [[ -n ${show_command:-} ]] && set -x + "${@}" + ) + local -r status=${?} + { [[ ${status} -eq 0 ]] && print_success "Verification succeeded."; } \ + || print_err "Verification failed." + return "${status}" +} + +fastfail() { + "$@" || kill -- "-$$" +} +# ---------------------------------------------------- + +umask 077 +tempdir=$(mktemp -d) +readonly tempdir +trap 'rm -Rf -- "${tempdir}"' EXIT +export HOME="${tempdir}" + +# Setup PATH to use local git-remote-gcrypt +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +repo_root="$(dirname "$SCRIPT_DIR")" +test_version=$(git describe --tags --always --dirty 2>/dev/null || echo "test") +# Escape special chars for sed (delimiter /, &, and backslash) +test_version=$(printf '%s\n' "$test_version" | sed 's:[&/\]:\\&:g') +cp "$repo_root/git-remote-gcrypt" "$tempdir/git-remote-gcrypt" +sed "s/@@DEV_VERSION@@/$test_version/" "$tempdir/git-remote-gcrypt" >"$tempdir/git-remote-gcrypt.tmp" +mv "$tempdir/git-remote-gcrypt.tmp" "$tempdir/git-remote-gcrypt" +chmod +x "$tempdir/git-remote-gcrypt" +PATH=$tempdir:${PATH} +readonly PATH +export PATH + +# Clean GIT environment +git_env=$(env | sed -n 's/^\(GIT_[^=]*\)=.*$/\1/p') +# shellcheck disable=SC2086 +IFS=$'\n' unset ${git_env} + +# GPG Setup +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" + +# Git Config +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" + +# Prepare Random Data +total_files=$((num_commits * files_per_commit)) +random_data_size=$((total_files * random_data_per_file)) +random_data_file="${tempdir}/data" +head -c "${random_data_size}" "${random_source}" >"${random_data_file}" + +### +section_break + +print_info "Step 1: Creating multiple GPG keys for participants..." +num_keys=5 # Reduced from 18 for faster CI runs +key_fps=() +( + set -x + for ((i = 0; i < num_keys; i++)); do + gpg --batch --passphrase "" --quick-generate-key \ + "${test_user_name}${i} <${test_user_email}${i}>" + done +) 2>&1 | indent + +# Capture fingerprints +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 +if [ "${#key_fps[@]}" -ne "$num_keys" ]; then + print_err "FATAL: Expected $num_keys keys, captured ${#key_fps[@]}." + print_err " Check grep/awk logic (likely capturing subkeys vs primary keys mismatch)." + exit 1 +fi +print_success "Sanity Check Passed: Captured ${#key_fps[@]} Primary Keys." + +### +section_break + +print_info "Step 2: Creating source repository..." +{ + git init -- "${tempdir}/first" + cd "${tempdir}/first" + git checkout -B "${default_branch}" + 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)) + head -c "${random_data_per_file}" >"$((file_index)).data" < \ + <(tail -c +"${random_data_index}" "${random_data_file}" || :) + done + git add . + git commit -q -m "Commit #${i}" + done + git log --format=oneline | indent +} | indent + +### +section_break + +print_info "Step 3: Creating bare remote..." +git init --bare -- "${tempdir}/second.git" | indent + +### +section_break + +print_info "Step 4: Pushing with SINGULAR participant (Key 2) to bury it..." +{ + ( + set -x + cd "${tempdir}/first" + # CRITICAL REPRO: Only encrypt to the LAST key. + # All previous keys are in the keyring but are NOT recipients. + # This forces GPG to skip the first (num_keys-1) keys. + last_key_idx=$((num_keys - 1)) + git config gcrypt.participants "${key_fps[last_key_idx]}" + git config user.signingkey "${key_fps[last_key_idx]}" + git push -f "gcrypt::${tempdir}/second.git#${default_branch}" "${default_branch}" + ) 2>&1 +} | indent + +### +section_break + +print_info "Step 5: Unhappy Path - Test clone with NO matching keys..." +{ + original_gnupghome="${GNUPGHOME}" + export GNUPGHOME="${tempdir}/gpg-empty" + mkdir "${GNUPGHOME}" + + # We expect this to FAIL + ( + set +e + if git clone -b "${default_branch}" "gcrypt::${tempdir}/second.git#${default_branch}" -- "${tempdir}/fail_test"; then + print_err "ERROR: Clone succeeded unexpectedly with empty keyring!" + exit 1 + fi + ) 2>&1 | indent + + echo "Clone failed as expected." | indent + export GNUPGHOME="${original_gnupghome}" +} + +### +section_break + +print_info "Step 6: Reproduction Step - Clone with buried key..." +{ + # Capture output to check for GPG errors + output_file="${tempdir}/clone_output" + set +e + ( + set -x + git clone -b "${default_branch}" "gcrypt::${tempdir}/second.git#${default_branch}" -- "${tempdir}/third" + ) >"${output_file}" 2>&1 + ret=$? + set -e + + cat "${output_file}" + + if grep -q "Checksum error" "${output_file}" && [ $ret -ne 0 ]; then + print_warn "WARNING: GPG failed with checksum error." + print_err "BUG REPRODUCED! Exiting due to earlier GPG failures." + exit 1 + elif grep -q "Checksum error" "${output_file}" && [ $ret -eq 0 ]; then + print_success "SUCCESS: Checksum error detected but Clone SUCCEEDED. (Fix is working!)" + elif [ $ret -eq 0 ]; then + print_warn "WARNING: Clone passed unexpectedly (Checksum error not detected). Bug not triggered." + if [ "$expect_bug" -eq 0 ]; then + print_success "SUCCESS: Old GPG version ($gpg_ver) confirmed clean. Pass." + else + print_err "FAIL: Exiting due to unexpected pass on modern GPG $gpg_ver." + exit 1 + fi + else + print_err "ERROR: Clone failed with generic error (Checksum error not detected)." + exit 1 + fi + + # Continue to verify content. + print_info "Verifying content match..." + assert diff -r --exclude ".git" -- "${tempdir}/first" "${tempdir}/third" 2>&1 | indent +} | indent + +### +section_break + +print_info "Step 7: Reproduction Step - Push with buried key..." +{ + # Capture output to check for GPG errors + output_file="${tempdir}/push_output" + set +e + ( + set -x + cd "${tempdir}/first" + # Make a change so we can push + echo "new data" >"new_file" + git add "new_file" + git commit -q -m "Commit for Step 7" + + # Set signing key for this push + last_key_idx=$((num_keys - 1)) + + # Regression Check: Ensure we didn't capture subkeys + if [ "${#key_fps[@]}" -ne "$num_keys" ]; then + print_err "FATAL: Key array corrupted! Expected $num_keys keys, found ${#key_fps[@]}." + print_err " This indicates the 'awk' capture logic has regressed (likely capturing subkeys)." + exit 1 + fi + print_success "Sanity Check (Step 7): Key count correct (${#key_fps[@]}). AWK fix confirmed active." + + # Visual Verification: Show which key we actually picked. + # If the bug were active (subkey capture), this would show 'git-remote-gcrypt8' (Key #9) + # With the fix, it must show 'git-remote-gcrypt17' (Key #18) + print_info "Selected Key Details:" + gpg --list-keys "${key_fps[last_key_idx]}" | indent + + git config gcrypt.participants "${key_fps[last_key_idx]}" + git config user.signingkey "${key_fps[last_key_idx]}" + + git push "gcrypt::${tempdir}/second.git#${default_branch}" "${default_branch}" + ) >"${output_file}" 2>&1 + ret=$? + set -e + + cat "${output_file}" + + if grep -q "Checksum error" "${output_file}" && [ $ret -ne 0 ]; then + print_warn "WARNING: GPG failed with checksum error." + print_err "BUG REPRODUCED! Exiting due to earlier GPG failures." + exit 1 + elif grep -q "Checksum error" "${output_file}" && [ $ret -eq 0 ]; then + print_success "SUCCESS: Checksum error detected (Push) but Push SUCCEEDED. (Fix is working!)" + elif [ $ret -eq 0 ]; then + print_warn "WARNING: Push passed unexpectedly (Checksum error not detected). Bug not triggered." + if [ "$expect_bug" -eq 0 ]; then + print_success "SUCCESS: Old GPG version ($gpg_ver) confirmed clean. Pass." + else + print_err "FAIL: Exiting due to unexpected pass on modern GPG $gpg_ver." + exit 1 + fi + else + print_err "ERROR: Push failed with generic error (Checksum error not detected)." + exit 1 + fi +} | indent + +if [ -n "${COV_DIR:-}" ]; then + print_success "OK. Report: file://${COV_DIR}/index.html" +fi diff --git a/tests/system-test-privacy-leaks.sh b/tests/system-test-privacy-leaks.sh new file mode 100755 index 0000000..5e0c099 --- /dev/null +++ b/tests/system-test-privacy-leaks.sh @@ -0,0 +1,167 @@ +#!/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 +export HOME="${tempdir}" + +# Ensure git-remote-gcrypt is in PATH +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +repo_root="$(dirname "$SCRIPT_DIR")" +test_version=$(git describe --tags --always --dirty 2>/dev/null || echo "test") +# Escape special chars for sed (delimiter /, &, and backslash) +test_version=$(printf '%s\n' "$test_version" | sed 's:[&/\]:\\&:g') +cp "$repo_root/git-remote-gcrypt" "$tempdir/git-remote-gcrypt" +sed "s/@@DEV_VERSION@@/$test_version/" "$tempdir/git-remote-gcrypt" >"$tempdir/git-remote-gcrypt.tmp" +mv "$tempdir/git-remote-gcrypt.tmp" "$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" +unset GIT_CONFIG_PARAMETERS +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 -f .env +git commit -m "Oops, pushed secret keys" +LEAK_SHA=$(git rev-parse master) +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 +# 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 cat-file -e "$LEAK_SHA"; then + print_warn "PRIVACY LEAK DETECTED: Leaked commit $LEAK_SHA still exists in remote objects!" + if git cat-file -p "$LEAK_SHA:.env" >/dev/null; then + print_warn "Content of .env is reachable." + print_success "Test Passed: Vulnerability successfully reproduced." + else + print_err "Commit exists but .env unreadable?" + exit 1 + fi +else + print_err "Unexpected: Leaked commit NOT found. Did gcrypt prune it?" + 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/system-test-repack.sh b/tests/system-test-repack.sh new file mode 100755 index 0000000..72f8c89 --- /dev/null +++ b/tests/system-test-repack.sh @@ -0,0 +1,195 @@ +#!/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 +export HOME="${tempdir}" + +# Set up the PATH +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +repo_root="$(dirname "$SCRIPT_DIR")" +test_version=$(git describe --tags --always --dirty 2>/dev/null || echo "test") +# Escape special chars for sed (delimiter /, &, and backslash) +test_version=$(printf '%s\n' "$test_version" | sed 's:[&/\]:\\&:g') +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" + git checkout -B "${default_branch}" + 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 + 1))" "${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-rsync-simple.sh b/tests/system-test-rsync-simple.sh new file mode 100755 index 0000000..b119e7c --- /dev/null +++ b/tests/system-test-rsync-simple.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +mkdir -p .tmp/simple_src .tmp/simple_dst/subdir +touch .tmp/simple_dst/subdir/badfile +touch .tmp/simple_dst/subdir/goodfile + +files_to_remove="subdir/badfile" +Localdir=".tmp/simple_src" + +# 1. Recreate directory structure in source +echo "$files_to_remove" | xargs -n1 dirname | sort -u | while read -r d; do + mkdir -p "$Localdir/$d" +done + +# 2. Run rsync with --include='*/' to traverse all dirs, but specific file includes +# Note: --include='*/' must come BEFORE --exclude='*' +# And we also need to include our specific files. +# Order: +# Include specific files +# Include all directories (so we traverse) +# Exclude everything else + +echo "Running rsync..." +rsync -I -W -v -r --delete --include-from=- --include='*/' --exclude='*' "$Localdir"/ .tmp/simple_dst/ </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!" diff --git a/tests/system-test.sh b/tests/system-test.sh index 74de3a5..11c763c 100755 --- a/tests/system-test.sh +++ b/tests/system-test.sh @@ -4,6 +4,15 @@ 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"; } + + + + # Unlike the main git-remote-gcrypt program, this testing script requires bash # (rather than POSIX sh) and also depends on various common system utilities # that the git-remote-gcrypt carefully avoids using (such as mktemp(1)). @@ -20,7 +29,7 @@ shopt -s inherit_errexit 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" @@ -29,6 +38,8 @@ pack_size_limit="12m" # If this variable is unset, there is no size limit. 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 system test..." + # Pipe text into this function to indent it with four spaces. This is used # to make the output of this script prettier. indent() { @@ -44,8 +55,8 @@ section_break() { assert() { (set +e; [[ -n ${show_command:-} ]] && set -x; "${@}") local -r status=${?} - { [[ ${status} -eq 0 ]] && echo "Verification succeeded."; } || \ - echo "Verification failed." + { [[ ${status} -eq 0 ]] && print_success "Verification succeeded."; } || \ + print_err "Verification failed." return "${status}" } @@ -58,10 +69,21 @@ tempdir=$(mktemp -d) readonly tempdir # shellcheck disable=SC2064 trap "rm -Rf -- '${tempdir}'" EXIT +export HOME="${tempdir}" # 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. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +repo_root="$(dirname "$SCRIPT_DIR")" +test_version=$(git describe --tags --always --dirty 2>/dev/null || echo "test") +# Escape special chars for sed (delimiter /, &, and backslash) +test_version=$(printf '%s\n' "$test_version" | sed 's:[&/\]:\\&:g') +cp "$repo_root/git-remote-gcrypt" "$tempdir/git-remote-gcrypt" +sed "s/@@DEV_VERSION@@/$test_version/" "$tempdir/git-remote-gcrypt" > "$tempdir/git-remote-gcrypt.tmp" +mv "$tempdir/git-remote-gcrypt.tmp" "$tempdir/git-remote-gcrypt" +chmod +x "$tempdir/git-remote-gcrypt" +PATH=$tempdir:${PATH} readonly PATH export PATH @@ -111,7 +133,7 @@ random_data_file="${tempdir}/data" head -c "${random_data_size}" "${random_source}" > "${random_data_file}" # Create gpg key and subkey. -echo "Step 1: Creating a new GPG key and subkey to use for testing:" +print_info "Step 1: Creating a new GPG key and subkey to use for testing:" ( set -x gpg --batch --passphrase "" --quick-generate-key \ @@ -122,10 +144,11 @@ echo "Step 1: Creating a new GPG key and subkey to use for testing:" ### section_break -echo "Step 2: Creating new repository with random data:" +print_info "Step 2: Creating new repository with random data:" { git init -- "${tempdir}/first" cd "${tempdir}/first" + git checkout -B "${default_branch}" for ((i = 0; i < num_commits; ++i)); do for ((j = 0; j < files_per_commit; ++j)); do file_index=$(( i * files_per_commit + j )) @@ -154,14 +177,14 @@ echo "Step 2: Creating new repository with random data:" ### section_break -echo "Step 3: Creating an empty bare repository to receive pushed data:" +print_info "Step 3: Creating an empty bare repository to receive pushed data:" git init --bare -- "${tempdir}/second.git" | indent ### section_break -echo "Step 4: Pushing the first repository to the second one using gitception:" +print_info "Step 4: Pushing the first repository to the second one using gitception:" { # Note that when pushing to a bare local repository, git-remote-gcrypt uses # gitception, rather than treating the remote as a local repository. @@ -197,7 +220,7 @@ echo "Step 4: Pushing the first repository to the second one using gitception:" ### section_break -echo "Step 5: Cloning the second repository using gitception:" +print_info "Step 5: Cloning the second repository using gitception:" { ( set -x @@ -221,3 +244,290 @@ echo "Step 5: Cloning the second repository using gitception:" show_command=1 assert diff -r --exclude ".git" -- \ "${tempdir}/first" "${tempdir}/third" 2>&1 | indent } | indent + + +### +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 -exec basename {} \; | 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 checkout -B "${default_branch}" + 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!" + indent < "${step9_output}" + 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 (Should Fail)..." + set +e + ( + git push "gcrypt::${missing_remote_url}" "${default_branch}" 2>&1 + ) > "step10.fail" + rc=$? + set -e + + if [ $rc -ne 0 ]; then + print_success "Push correctly failed without force." + if grep -q "Use --force to create valid new repository" "step10.fail"; then + print_success "Correct error message received." + fi + else + indent < "step10.fail" + 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 + indent < "step10.succ" + 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/verify-system-install.sh b/tests/verify-system-install.sh new file mode 100755 index 0000000..0d5e0dd --- /dev/null +++ b/tests/verify-system-install.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -u + +# 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"; } + +print_info "Verifying system install..." + +# 1. Check if the command exists in the path +if ! command -v git-remote-gcrypt >/dev/null; then + print_err "ERROR: git-remote-gcrypt is not in the PATH." + exit 1 +fi + +# 2. Run the version check (Capture stderr too!) +OUTPUT=$(git-remote-gcrypt -v 2>&1) +EXIT_CODE=$? + +if [ $EXIT_CODE -ne 0 ]; then + print_err "ERROR: Command exited with code $EXIT_CODE" + exit 1 +fi + +# 3. Verify the placeholder was replaced +if [[ $OUTPUT == *"@@DEV_VERSION@@"* ]]; then + print_err "ERROR: Version placeholder @@DEV_VERSION@@ was not replaced!" + exit 1 +fi + +# 4. Determine expected ID for comparison to actual +if [ -f /etc/os-release ]; then + # shellcheck source=/dev/null + source /etc/os-release + EXPECTED_ID=$ID +elif command -v uname >/dev/null; then + EXPECTED_ID=$(uname -s | tr '[:upper:]' '[:lower:]') +else + EXPECTED_ID="unknown_OS" +fi + +if [[ $OUTPUT != *"($EXPECTED_ID)"* ]]; then + print_err "ERROR: Distro ID '$EXPECTED_ID' missing from version string! (Got: $OUTPUT)" + exit 1 +fi + +# LEAD with the version success +printf " ✓ %s\n" "VERSION OK: $OUTPUT" + +# 5. Verify the man page +if man -w git-remote-gcrypt >/dev/null 2>&1; then + printf " ✓ %s\n" "DOCS OK: Man page is installed and indexed." +else + print_err "ERROR: Man page not found in system paths." + exit 1 +fi + +print_success "INSTALLATION VERIFIED" diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..7cc6ae5 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -e + +: "${prefix:=/usr/local}" +: "${DESTDIR:=}" + +verbose() { echo "$@" >&2 && "$@"; } + +BIN_PATH="$DESTDIR$prefix/bin/git-remote-gcrypt" +MAN_PATH="$DESTDIR$prefix/share/man/man1/git-remote-gcrypt.1.gz" + +echo "Uninstalling git-remote-gcrypt..." + +if [ -f "$BIN_PATH" ]; then + verbose rm -f "$BIN_PATH" + echo "Removed binary: $BIN_PATH" +else + echo "Binary not found: $BIN_PATH" +fi + +if [ -f "$MAN_PATH" ]; then + verbose rm -f "$MAN_PATH" + echo "Removed man page: $MAN_PATH" +else + echo "Man page not found: $MAN_PATH" +fi + +# Completions +COMP_BASH="$DESTDIR$prefix/share/bash-completion/completions/git-remote-gcrypt" +COMP_ZSH="$DESTDIR$prefix/share/zsh/site-functions/_git-remote-gcrypt" +COMP_FISH="$DESTDIR$prefix/share/fish/vendor_completions.d/git-remote-gcrypt.fish" + +for f in "$COMP_BASH" "$COMP_ZSH" "$COMP_FISH"; do + if [ -f "$f" ]; then + verbose rm -f "$f" + echo "Removed completion: $f" + fi +done + +echo "Uninstallation complete."