~bigbes/sr-ht-spec

ref: e0938e942893dda1670ac125b4785c8b8941bfe7 sr-ht-spec/hooks/doc.go -rw-r--r-- 6.5 KiB
e0938e94 — Eugene Blikh refactor: make the filter-polarity trap inexpressible 27 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// Package hooks is spec.sr.ht's receive path: the git hooks a space's bare
// repository runs on every push, and the daemon-side RPC endpoint they call.
//
// # Why the hooks are service code
//
// bleve is single-writer and the running specsrht daemon holds the index open,
// so a separate hook process cannot reindex; and validation needs the same
// schema and policy logic the API uses, which must not be duplicated in a
// shell script that will drift from it. Both hooks are therefore thin shims
// that RPC into the daemon over a unix socket, calling [service.Service]
// entry points. Nothing in this package shells out to git.
//
// Daemon availability is consequently part of the push path, so the behaviour
// is stated rather than discovered: **fail closed**. If the daemon is
// unreachable, unresponsive, or answers anything this package cannot parse,
// the push is rejected. A rejected push is recoverable in one command; a
// silently unvalidated, unindexed one is a corruption discovered weeks later.
//
// # Which hook does what
//
// Three hooks are installed, not two, and the reason is a property of git that
// the design did not account for. Both facts below were verified against
// git 2.55 rather than inferred from the documentation:
//
//  1. GIT_PUSH_OPTION_COUNT / GIT_PUSH_OPTION_<n> are set for `pre-receive`
//     and `post-receive` only. githooks(5) documents them under exactly those
//     two hooks, and `update` observably runs without them. So `update` alone
//     cannot see --push-option=skip-validation.
//  2. During `pre-receive` the pushed objects are still in receive-pack's
//     quarantine directory (GIT_QUARANTINE_PATH), reachable only through the
//     hook process's own GIT_OBJECT_DIRECTORY. A separate process that opens
//     the bare repository — the daemon — cannot read them. git migrates the
//     quarantine into the real object store immediately after `pre-receive`
//     succeeds and before the first `update` runs, so `update` is the earliest
//     hook at which the daemon can read what is being pushed.
//
// Neither hook can do the whole job, so the work is split along that seam:
//
//   - pre-receive — the only place push options exist. It forwards them, with
//     the full list of proposed ref updates, to the daemon, which records them
//     for the duration of this push. It validates nothing (it cannot: the
//     objects are invisible to the daemon) but it is still a rejecting hook,
//     because a daemon that cannot be reached must stop the push here rather
//     than one hook later.
//   - update — per ref, before the ref moves, and the only hook whose refusal
//     is scoped to a single ref. This is where the refs rule and frontmatter /
//     document-id validation run, via [service.Service.ValidatePush].
//   - post-receive — after the fact, cannot reject. It tells the daemon the
//     push landed so the space can be reindexed and its index rev stamp moved.
//
// The two phases of one push are correlated by (repository, receive-pack pid):
// every hook of a single push is a direct child of one receive-pack process,
// so os.Getppid() is stable across them, and the daemon additionally requires
// the ref update `update` presents to appear in the list `pre-receive` sent.
// An `update` call with no recorded pre-receive phase is refused rather than
// assumed unskippable — that combination means either a broken install or a
// daemon restart mid-push, and both deserve a message instead of a guess.
//
// # Push options
//
// The one recognised option is `skip-validation`. It waives frontmatter and
// document-id validation and nothing else; the refs rule is never skippable.
// An unrecognised push option is a rejection, not a no-op: with exactly one
// option in the vocabulary, a silently ignored `--push-option=skip-validaton`
// would present as an inexplicable rejection of a push the human believed they
// had waived.
//
// Push options only reach a hook when the repository advertises them, so
// [Install] sets receive.advertisePushOptions on every repository it installs
// into. Without it `git push --push-option=...` fails client-side with
// "the receiving end does not support push options".
//
// # Identity
//
// The hook does not decide who is pushing; it forwards a credential and the
// daemon resolves it:
//
//	SPECSRHT_PRINCIPAL       "owner" or "agent"     (required)
//	SPECSRHT_AGENT_TOKEN     agent secret           (required when kind=agent)
//	SPECSRHT_AGENT           agent identity string  (provenance, optional)
//	SPECSRHT_AGENT_SESSION   agent session id       (provenance, optional)
//
// An agent's token is validated against the database on every push, so an
// agent credential asserts nothing by itself. `owner` is different: it is an
// assertion, trusted because sshd already authenticated the SSH key and the
// forced-command wrapper — which owns this environment — is what sets it.
// That wrapper is therefore part of the trust boundary: sshd must not
// AcceptEnv any SPECSRHT_* name, or a client could name its own principal.
// A missing or unrecognised SPECSRHT_PRINCIPAL is refused; there is no
// anonymous write path and nothing to default to.
//
// # Addressing
//
// A hook knows which repository it is running in (git chdirs into it and sets
// GIT_DIR) and nothing else, so both the socket and the space are derived from
// that one fact:
//
//   - the socket is <repos>/.specsrht/hook.sock, where <repos> is the parent of
//     the parent of the repository directory — the layout gitx.DiskPath
//     defines. It lives under `repos` rather than `cache` because `cache` is
//     documented as safe to delete at any time, and deleting the socket would
//     take the push path down until the daemon restarted. SPECSRHT_HOOK_SOCKET
//     overrides it for a non-standard deployment or a test.
//   - the space is not sent as a name at all. The hook sends the absolute
//     repository path and the daemon resolves it against its own configured
//     repos root, refusing anything that is not exactly
//     <repos>/~<owner>/<name>. A hook can therefore only ever address a
//     repository the daemon already owns.
//
// # Installation
//
// [Install] writes each hook as a symlink to the specsrht binary, which
// dispatches on the name it was invoked as ([ModeFromArgs]). There is no shell
// stub, no generated script, and nothing to regenerate when the socket path or
// the protocol changes; refreshing a repository's hooks after an upgrade is
// running [Install] again, which the daemon does for every space at startup.
package hooks