#!/bin/sh # ---------------------------------------------------------------------- # post-receive hook to adopt push certs into 'refs/push-certs' # Collects the cert blob on push and saves it, then, if a certain number of # signed pushes have been seen, processes all the "saved" blobs in one go, # adding them to the special ref 'refs/push-certs'. This is done in a way # that allows searching for all the certs pertaining to one specific branch # (thanks to Junio Hamano for this idea plus general brainstorming). # The "collection" happens only if $GIT_PUSH_CERT_NONCE_STATUS = OK; again, # thanks to Junio for pointing this out; see [1] # # [1]: https://groups.google.com/forum/#!topic/gitolite/7cSrU6JorEY # WARNINGS: # Does not check that GIT_PUSH_CERT_STATUS = "G". If you want to check that # and FAIL the push, you'll have to write a simple pre-receive hook # (post-receive is not the place for that; see 'man githooks'). # # Gitolite users: failing the hook cannot be done as a VREF because git does # not set those environment variables in the update hook. You'll have to # write a trivial pre-receive hook and add that in. # Relevant gitolite doc links: # repo-specific environment variables # http://gitolite.com/gitolite/dev-notes.html#rsev # repo-specific hooks # http://gitolite.com/gitolite/non-core.html#rsh # http://gitolite.com/gitolite/cookbook.html#v3.6-variation-repo-specific-hooks # Environment: # GIT_PUSH_CERT_NONCE_STATUS should be "OK" (as mentioned above) # # GL_OPTIONS_GPC_PENDING (optional; defaults to 1). This is the number of # git push certs that should be waiting in order to trigger the post # processing. You can set it within gitolite like so: # # repo foo bar # or maybe just 'repo @all' # option ENV.GPC_PENDING = 5 # Setup: # Set up this code as a post-receive hook for whatever repos you need to. # Then arrange to have the environment variable GL_OPTION_GPC_PENDING set to # some number, as shown above. (This is only required if you need it to be # greater than 1.) It could of course be different for different repos. # Also see "Invocation" section below. # Invocation: # Normally via git (see 'man githooks'), once it is setup as a post-receive # hook. # # However, if you set the "pending" limit high, and want to periodically # "clean up" pending certs without necessarily waiting for the counter to # trip, do the following (untested): # # RB=$(gitolite query-rc GL_REPO_BASE) # for r in $(gitolite list-phy-repos) # do # cd $RB/$repo.git # unset GL_OPTIONS_GPC_PENDING # if it is set higher up # hooks/post-receive post_process # done # # That will take care of it. # Using without gitolite: # Just set GL_OPTIONS_GPC_PENDING within the script (maybe read it from git # config). Everything else is independent of gitolite. # ---------------------------------------------------------------------- # make it work on BSD also (but NOT YET TESTED on FreeBSD!) uname_s=`uname -s` if [ "$uname_s" = "Linux" ] then _lock() { flock "$@"; } else _lock() { lockf -k "$@"; } # I'm assuming other BSDs also have this; I only have FreeBSD. fi # ---------------------------------------------------------------------- # standard stuff die() { echo "$@" >&2; exit 1; } warn() { echo "$@" >&2; } # ---------------------------------------------------------------------- # if there are no arguments, we're running as a "post-receive" hook if [ -z "$1" ] then # ignore if it may be a replay attack [ "$GIT_PUSH_CERT_NONCE_STATUS" = "OK" ] || exit 1 # I don't think "exit 1" does anything in a post-receive anyway, so that's # just a symbolic gesture! # note the lock file used _lock .gpc.lock $0 cat_blob # if you want to initiate the post-processing ONLY from outside (for # example via cron), comment out the next line. exec $0 post_process fi # ---------------------------------------------------------------------- # the 'post_process' part; see "Invocation" section in the doc at the top if [ "$1" = "post_process" ] then # this is the same lock file as above _lock .gpc.lock $0 count_and_rotate $$ [ -d git-push-certs.$$ ] || exit 0 # but this is a different one _lock .gpc.ref.lock $0 update_ref $$ exit 0 fi # ---------------------------------------------------------------------- # other values for "$1" are internal use only if [ "$1" = "cat_blob" ] then mkdir -p git-push-certs git cat-file blob $GIT_PUSH_CERT > git-push-certs/$GIT_PUSH_CERT echo $GIT_PUSH_CERT >> git-push-certs/.blob.list fi if [ "$1" = "count_and_rotate" ] then count=$(ls git-push-certs | wc -l) if test $count -ge ${GL_OPTIONS_GPC_PENDING:-1} then # rotate the directory mv git-push-certs git-push-certs.$2 fi fi if [ "$1" = "update_ref" ] then # use a different index file for all this GIT_INDEX_FILE=push_certs_index; export GIT_INDEX_FILE # prepare the special ref to receive commits # historically this hook put the certs in a ref named refs/push-certs # however, git does *NOT* replicate single-level refs # trying to push them explicitly causes this error: # remote: error: refusing to create funny ref 'refs/push-certs' remotely # https://lore.kernel.org/git/robbat2-20211115T063838-612792475Z@orbis-terrarum.net/ # # As a good-enough solution, use the namespace of meta/ for the refs. # This is already used in other systems: # - kernel.org refs/meta/cgit # - gerrit refs/meta/config # - GitBlit reflog: refs/meta/gitblit https://www.gitblit.com/administration.html#H12 # - cc-utils refs/meta/ci # - JGit refs/meta/push-certs https://www.ibm.com/docs/en/radfws/9.6.1?topic=SSRTLW_9.6.1/org.eclipse.egit.doc/help/JGit/New_and_Noteworthy/4.1/4.1.htm # # To migrate from old to new, for each repo: # git update-ref refs/meta/push-certs refs/push-certs PUSH_CERTS_EXTRA_REFS='' PUSH_CERTS='' # These vars will be populated after checks. # others vars are temp _OLD_PUSH_CERTS=refs/push-certs _NEW_PUSH_CERTS=refs/meta/push-certs _OLD_PUSH_CERTS_EXISTS=0 _NEW_PUSH_CERTS_EXISTS=0 git show-ref --verify --quiet -- "$_OLD_PUSH_CERTS" && _OLD_PUSH_CERTS_EXISTS=1 git show-ref --verify --quiet -- "$_NEW_PUSH_CERTS" && _NEW_PUSH_CERTS_EXISTS=1 case "${_OLD_PUSH_CERTS_EXISTS}${_NEW_PUSH_CERTS_EXISTS}" in # neither or new only: # let's push to the NEW name only '00'|'01') PUSH_CERTS=$_NEW_PUSH_CERTS ;; # old-only: stick to the same, the migration is opt-in '10') PUSH_CERTS=$_OLD_PUSH_CERTS ;; # Both: Push to the old name, duplicate to the new name '11') PUSH_CERTS=$_OLD_PUSH_CERTS PUSH_CERTS_EXTRA_REFS=$_NEW_PUSH_CERTS ;; esac # cleanup vars unset _OLD_PUSH_CERTS_EXISTS _NEW_PUSH_CERTS_EXISTS _OLD_PUSH_CERTS _NEW_PUSH_CERTS if git rev-parse -q --verify $PUSH_CERTS >/dev/null then git read-tree $PUSH_CERTS else git read-tree --empty T=$(git write-tree) C=$(echo 'start' | git commit-tree $T) for _ref in $PUSH_CERTS $PUSH_CERTS_EXTRA_REFS ; do git update-ref "${_ref}" "${C}" done fi # for each cert blob... for b in `cat git-push-certs.$2/.blob.list` do cf=git-push-certs.$2/$b # it's highly unlikely that the blob got GC-ed already but write it # back anyway, just in case B=$(git hash-object -w $cf) # bit of a sanity check [ "$B" = "$b" ] || warn "this should not happen: $B is not equal to $b" # for each ref described within the cert, update the index for ref in `cat $cf | egrep '^[a-f0-9]+ [a-f0-9]+ refs/' | cut -f3 -d' '` do git update-index --add --cacheinfo 100644,$b,$ref # we're using the ref name as a "fake" filename, so people can, # for example, 'git log refs/push-certs -- refs/heads/master', to # see all the push certs pertaining to the master branch. This # idea came from Junio Hamano, the git maintainer (I certainly # don't deal with git plumbing enough to have thought of it!) done T=$(git write-tree) C=$( git commit-tree -p $PUSH_CERTS $T < $cf ) for _ref in $PUSH_CERTS $PUSH_CERTS_EXTRA_REFS ; do git update-ref "${_ref}" "${C}" done rm -f $cf done rm -f git-push-certs.$2/.blob.list rmdir git-push-certs.$2 fi