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
}