~bigbes/sr-ht-spec

ref: 5a10600a93d35781d59b978d6b1c0f850ff43292 sr-ht-spec/hooks/env.go -rw-r--r-- 7.6 KiB
5a10600a — Eugene Blikh feat: service.Archive — one accessor, one tree walk, one link graph 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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
package hooks

import (
	"fmt"
	"path/filepath"
	"strconv"
	"strings"
)

// The environment the forced-command wrapper sets. See the package
// documentation for the trust boundary: `owner` is an assertion the wrapper
// makes after sshd authenticated an SSH key, so sshd must not AcceptEnv any of
// these names.
const (
	// EnvPrincipal is "owner" or "agent". There is no third value and no
	// default: a push with no principal has nobody to authorize it.
	EnvPrincipal = "SPECSRHT_PRINCIPAL"

	// EnvAgentToken is the agent's secret, required when EnvPrincipal is
	// "agent". It is forwarded to the daemon, which validates it; it is never
	// logged and never appears in a message sent back to the client.
	EnvAgentToken = "SPECSRHT_AGENT_TOKEN"

	// EnvAgent and EnvAgentSession are the agent's provenance fields.
	EnvAgent        = "SPECSRHT_AGENT"
	EnvAgentSession = "SPECSRHT_AGENT_SESSION"

	// EnvSocket overrides the derived socket path. It exists for a deployment
	// whose repos root is not where the daemon's socket lives, and for tests.
	EnvSocket = "SPECSRHT_HOOK_SOCKET"
)

// The environment git sets. Push options reach `pre-receive` and
// `post-receive` only; `update` runs without them, which is why pre-receive
// exists at all in this package.
const (
	envPushOptionCount  = "GIT_PUSH_OPTION_COUNT"
	envPushOptionPrefix = "GIT_PUSH_OPTION_"
	envGitDir           = "GIT_DIR"
)

// OptionSkipValidation waives frontmatter and document-id validation. It does
// not and cannot waive the refs rule: the escape hatch exists so a hook bug or
// a bad schema can never lock the owner out of their own repository, not so an
// agent can reach the approved branch.
const OptionSkipValidation = "skip-validation"

// KnownOptions is every push option this service understands. An option
// outside this set is a rejection; see the package documentation.
func KnownOptions() []string { return []string{OptionSkipValidation} }

// socketDir is the directory under the repos root that holds the hook socket,
// and hookSocketName the socket in it. The repos root is the right home for it
// because it is the one configured directory that is not documented as safe to
// delete — `cache` is — and because every repository whose hooks need to find
// it is already underneath it.
const (
	socketDir      = ".specsrht"
	hookSocketName = "hook.sock"
)

// Lookup is os.LookupEnv, injectable so the environment protocol can be tested
// without mutating the process environment.
type Lookup func(string) (string, bool)

// SocketPath is the daemon's hook socket for a given repos root.
//
// ".specsrht" cannot collide with a space: every real entry under the repos
// root is "~<owner>", and core.ValidateOwner does not admit a name starting
// with a dot.
func SocketPath(reposRoot string) string {
	return filepath.Join(reposRoot, socketDir, hookSocketName)
}

// SocketForRepo is the socket a hook running in repoDir should call, derived
// from the layout gitx.DiskPath defines: <repos>/~<owner>/<name>.
func SocketForRepo(repoDir string) string {
	return SocketPath(filepath.Dir(filepath.Dir(filepath.Clean(repoDir))))
}

// ResolveSocket picks the socket a hook will call: the explicit override if
// one is set, otherwise the path derived from the repository's location.
func ResolveSocket(env Lookup, repoDir string) string {
	if v, ok := env(EnvSocket); ok {
		if v = strings.TrimSpace(v); v != "" {
			return v
		}
	}
	return SocketForRepo(repoDir)
}

// PushOptions reads the push options git passed to this hook.
//
// A nil result means the push-options phase was not negotiated — the client did
// not ask for it, or the repository does not advertise it — which is distinct
// from a client that asked and sent none. Neither carries an option, so no
// caller has to tell them apart, but an inconsistent environment does not
// silently become either: a count that will not parse, or a count larger than
// the variables actually present, is an error and the push is rejected.
func PushOptions(env Lookup) ([]string, error) {
	raw, ok := env(envPushOptionCount)
	if !ok {
		return nil, nil
	}
	n, err := strconv.Atoi(strings.TrimSpace(raw))
	if err != nil {
		return nil, fmt.Errorf("%s=%q is not a number: %w", envPushOptionCount, raw, err)
	}
	if n < 0 {
		return nil, fmt.Errorf("%s=%d is negative", envPushOptionCount, n)
	}
	opts := make([]string, 0, n)
	for i := range n {
		name := envPushOptionPrefix + strconv.Itoa(i)
		v, ok := env(name)
		if !ok {
			return nil, fmt.Errorf("%s=%d but %s is not set", envPushOptionCount, n, name)
		}
		opts = append(opts, v)
	}
	return opts, nil
}

// SkipValidation reports whether the push asked to waive content validation.
// The comparison is exact: an option is a token git passes through verbatim,
// and accepting "skip-validation=yes" or "Skip-Validation" would be inventing
// a grammar the daemon and the documentation do not share.
func SkipValidation(opts []string) bool {
	for _, o := range opts {
		if o == OptionSkipValidation {
			return true
		}
	}
	return false
}

// UnknownOptions returns the push options this service does not understand.
func UnknownOptions(opts []string) []string {
	var unknown []string
	for _, o := range opts {
		if o != OptionSkipValidation {
			unknown = append(unknown, o)
		}
	}
	return unknown
}

// CredentialFromEnv reads who the forced-command wrapper says is pushing.
//
// An absent or unrecognised principal is an error, not an anonymous
// credential: there is no unauthenticated write path, so the only thing an
// anonymous request could produce is a refusal one round trip later with a
// worse message. The wording names the wrapper, because that is what is
// actually broken when this fires.
func CredentialFromEnv(env Lookup) (Credential, error) {
	raw, _ := env(EnvPrincipal)
	switch kind := PrincipalKind(strings.TrimSpace(raw)); kind {
	case PrincipalOwner:
		return Credential{Kind: PrincipalOwner}, nil
	case PrincipalAgent:
		token, _ := env(EnvAgentToken)
		if strings.TrimSpace(token) == "" {
			return Credential{}, fmt.Errorf("%s=%s but %s is empty; an agent must present its token",
				EnvPrincipal, PrincipalAgent, EnvAgentToken)
		}
		agent, _ := env(EnvAgent)
		session, _ := env(EnvAgentSession)
		return Credential{
			Kind:    PrincipalAgent,
			Token:   strings.TrimSpace(token),
			Agent:   strings.TrimSpace(agent),
			Session: strings.TrimSpace(session),
		}, nil
	case "":
		return Credential{}, fmt.Errorf("%s is not set; the forced-command wrapper must export it as %q or %q",
			EnvPrincipal, PrincipalOwner, PrincipalAgent)
	default:
		return Credential{}, fmt.Errorf("%s=%q is not a principal; want %q or %q",
			EnvPrincipal, kind, PrincipalOwner, PrincipalAgent)
	}
}

// RepoDir resolves the bare repository the hook is running in.
//
// git chdirs into the repository and sets GIT_DIR (observably to "."), so
// either source alone would do; both are used because GIT_DIR is the one git
// documents and the working directory is the one that is always right.
// Symlinks are resolved here so the path the daemon receives can be compared
// against its own repos root by string equality.
func RepoDir(env Lookup, getwd func() (string, error), evalSymlinks func(string) (string, error)) (string, error) {
	wd, err := getwd()
	if err != nil {
		return "", fmt.Errorf("locate the repository: %w", err)
	}
	dir := wd
	if v, ok := env(envGitDir); ok && strings.TrimSpace(v) != "" {
		dir = strings.TrimSpace(v)
		if !filepath.IsAbs(dir) {
			dir = filepath.Join(wd, dir)
		}
	}
	resolved, err := evalSymlinks(dir)
	if err != nil {
		return "", fmt.Errorf("resolve the repository path %q: %w", dir, err)
	}
	abs, err := filepath.Abs(resolved)
	if err != nil {
		return "", fmt.Errorf("resolve the repository path %q: %w", resolved, err)
	}
	return abs, nil
}