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 "~", 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: /~/. 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 }