coverage~bigbes/sr-ht-spec3cb1c03dhooks/env.go

Coverage
96.6% 56/58 statements
Δ
Blob
0110100
Uncovered L50L208-L209
1 package hooks
2
3 import (
4 "fmt"
5 "path/filepath"
6 "strconv"
7 "strings"
8 )
9
10 // The environment the forced-command wrapper sets. See the package
11 // documentation for the trust boundary: `owner` is an assertion the wrapper
12 // makes after sshd authenticated an SSH key, so sshd must not AcceptEnv any of
13 // these names.
14 const (
15 // EnvPrincipal is "owner" or "agent". There is no third value and no
16 // default: a push with no principal has nobody to authorize it.
17 EnvPrincipal = "SPECSRHT_PRINCIPAL"
18
19 // EnvAgentToken is the agent's secret, required when EnvPrincipal is
20 // "agent". It is forwarded to the daemon, which validates it; it is never
21 // logged and never appears in a message sent back to the client.
22 EnvAgentToken = "SPECSRHT_AGENT_TOKEN"
23
24 // EnvAgent and EnvAgentSession are the agent's provenance fields.
25 EnvAgent = "SPECSRHT_AGENT"
26 EnvAgentSession = "SPECSRHT_AGENT_SESSION"
27
28 // EnvSocket overrides the derived socket path. It exists for a deployment
29 // whose repos root is not where the daemon's socket lives, and for tests.
30 EnvSocket = "SPECSRHT_HOOK_SOCKET"
31 )
32
33 // The environment git sets. Push options reach `pre-receive` and
34 // `post-receive` only; `update` runs without them, which is why pre-receive
35 // exists at all in this package.
36 const (
37 envPushOptionCount = "GIT_PUSH_OPTION_COUNT"
38 envPushOptionPrefix = "GIT_PUSH_OPTION_"
39 envGitDir = "GIT_DIR"
40 )
41
42 // OptionSkipValidation waives frontmatter and document-id validation. It does
43 // not and cannot waive the refs rule: the escape hatch exists so a hook bug or
44 // a bad schema can never lock the owner out of their own repository, not so an
45 // agent can reach the approved branch.
46 const OptionSkipValidation = "skip-validation"
47
48 // KnownOptions is every push option this service understands. An option
49 // outside this set is a rejection; see the package documentation.
50 0 func KnownOptions() []string { return []string{OptionSkipValidation} }
51
52 // socketDir is the directory under the repos root that holds the hook socket,
53 // and hookSocketName the socket in it. The repos root is the right home for it
54 // because it is the one configured directory that is not documented as safe to
55 // delete — `cache` is — and because every repository whose hooks need to find
56 // it is already underneath it.
57 const (
58 socketDir = ".specsrht"
59 hookSocketName = "hook.sock"
60 )
61
62 // Lookup is os.LookupEnv, injectable so the environment protocol can be tested
63 // without mutating the process environment.
64 type Lookup func(string) (string, bool)
65
66 // SocketPath is the daemon's hook socket for a given repos root.
67 //
68 // ".specsrht" cannot collide with a space: every real entry under the repos
69 // root is "~<owner>", and core.ValidateOwner does not admit a name starting
70 // with a dot.
71 71 func SocketPath(reposRoot string) string {
72 71 return filepath.Join(reposRoot, socketDir, hookSocketName)
73 71 }
74
75 // SocketForRepo is the socket a hook running in repoDir should call, derived
76 // from the layout gitx.DiskPath defines: <repos>/~<owner>/<name>.
77 33 func SocketForRepo(repoDir string) string {
78 33 return SocketPath(filepath.Dir(filepath.Dir(filepath.Clean(repoDir))))
79 33 }
80
81 // ResolveSocket picks the socket a hook will call: the explicit override if
82 // one is set, otherwise the path derived from the repository's location.
83 36 func ResolveSocket(env Lookup, repoDir string) string {
84 36 if v, ok := env(EnvSocket); ok {
85 5 if v = strings.TrimSpace(v); v != "" {
86 4 return v
87 4 }
88 }
89 32 return SocketForRepo(repoDir)
90 }
91
92 // PushOptions reads the push options git passed to this hook.
93 //
94 // A nil result means the push-options phase was not negotiated — the client did
95 // not ask for it, or the repository does not advertise it — which is distinct
96 // from a client that asked and sent none. Neither carries an option, so no
97 // caller has to tell them apart, but an inconsistent environment does not
98 // silently become either: a count that will not parse, or a count larger than
99 // the variables actually present, is an error and the push is rejected.
100 23 func PushOptions(env Lookup) ([]string, error) {
101 23 raw, ok := env(envPushOptionCount)
102 23 if !ok {
103 4 return nil, nil
104 4 }
105 19 n, err := strconv.Atoi(strings.TrimSpace(raw))
106 19 if err != nil {
107 1 return nil, fmt.Errorf("%s=%q is not a number: %w", envPushOptionCount, raw, err)
108 1 }
109 18 if n < 0 {
110 1 return nil, fmt.Errorf("%s=%d is negative", envPushOptionCount, n)
111 1 }
112 17 opts := make([]string, 0, n)
113 17 for i := range n {
114 10 name := envPushOptionPrefix + strconv.Itoa(i)
115 10 v, ok := env(name)
116 10 if !ok {
117 1 return nil, fmt.Errorf("%s=%d but %s is not set", envPushOptionCount, n, name)
118 1 }
119 9 opts = append(opts, v)
120 }
121 16 return opts, nil
122 }
123
124 // SkipValidation reports whether the push asked to waive content validation.
125 // The comparison is exact: an option is a token git passes through verbatim,
126 // and accepting "skip-validation=yes" or "Skip-Validation" would be inventing
127 // a grammar the daemon and the documentation do not share.
128 28 func SkipValidation(opts []string) bool {
129 28 for _, o := range opts {
130 12 if o == OptionSkipValidation {
131 7 return true
132 7 }
133 }
134 21 return false
135 }
136
137 // UnknownOptions returns the push options this service does not understand.
138 24 func UnknownOptions(opts []string) []string {
139 24 var unknown []string
140 24 for _, o := range opts {
141 11 if o != OptionSkipValidation {
142 4 unknown = append(unknown, o)
143 4 }
144 }
145 24 return unknown
146 }
147
148 // CredentialFromEnv reads who the forced-command wrapper says is pushing.
149 //
150 // An absent or unrecognised principal is an error, not an anonymous
151 // credential: there is no unauthenticated write path, so the only thing an
152 // anonymous request could produce is a refusal one round trip later with a
153 // worse message. The wording names the wrapper, because that is what is
154 // actually broken when this fires.
155 40 func CredentialFromEnv(env Lookup) (Credential, error) {
156 40 raw, _ := env(EnvPrincipal)
157 40 switch kind := PrincipalKind(strings.TrimSpace(raw)); kind {
158 27 case PrincipalOwner:
159 27 return Credential{Kind: PrincipalOwner}, nil
160 9 case PrincipalAgent:
161 9 token, _ := env(EnvAgentToken)
162 9 if strings.TrimSpace(token) == "" {
163 1 return Credential{}, fmt.Errorf("%s=%s but %s is empty; an agent must present its token",
164 1 EnvPrincipal, PrincipalAgent, EnvAgentToken)
165 1 }
166 8 agent, _ := env(EnvAgent)
167 8 session, _ := env(EnvAgentSession)
168 8 return Credential{
169 8 Kind: PrincipalAgent,
170 8 Token: strings.TrimSpace(token),
171 8 Agent: strings.TrimSpace(agent),
172 8 Session: strings.TrimSpace(session),
173 8 }, nil
174 3 case "":
175 3 return Credential{}, fmt.Errorf("%s is not set; the forced-command wrapper must export it as %q or %q",
176 3 EnvPrincipal, PrincipalOwner, PrincipalAgent)
177 1 default:
178 1 return Credential{}, fmt.Errorf("%s=%q is not a principal; want %q or %q",
179 1 EnvPrincipal, kind, PrincipalOwner, PrincipalAgent)
180 }
181 }
182
183 // RepoDir resolves the bare repository the hook is running in.
184 //
185 // git chdirs into the repository and sets GIT_DIR (observably to "."), so
186 // either source alone would do; both are used because GIT_DIR is the one git
187 // documents and the working directory is the one that is always right.
188 // Symlinks are resolved here so the path the daemon receives can be compared
189 // against its own repos root by string equality.
190 39 func RepoDir(env Lookup, getwd func() (string, error), evalSymlinks func(string) (string, error)) (string, error) {
191 39 wd, err := getwd()
192 39 if err != nil {
193 1 return "", fmt.Errorf("locate the repository: %w", err)
194 1 }
195 38 dir := wd
196 38 if v, ok := env(envGitDir); ok && strings.TrimSpace(v) != "" {
197 37 dir = strings.TrimSpace(v)
198 37 if !filepath.IsAbs(dir) {
199 35 dir = filepath.Join(wd, dir)
200 35 }
201 }
202 38 resolved, err := evalSymlinks(dir)
203 38 if err != nil {
204 1 return "", fmt.Errorf("resolve the repository path %q: %w", dir, err)
205 1 }
206 37 abs, err := filepath.Abs(resolved)
207 37 if err != nil {
208 0 return "", fmt.Errorf("resolve the repository path %q: %w", resolved, err)
209 0 }
210 37 return abs, nil
211 }