package hooks import ( "errors" "fmt" "os" "path/filepath" "github.com/go-git/go-git/v5" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" ) const ( // hooksDirMode and the hook symlinks themselves are owned by the service // user; every repository under the repos root is. hooksDirMode = 0o755 // installSuffix names the temporary link Install renames into place, so a // refresh is atomic and a push arriving mid-upgrade sees either the old // hook or the new one, never a missing one. installSuffix = ".specsrht-new" ) // InstallOptions configures Install. type InstallOptions struct { // Binary is the absolute path of the specsrht binary every hook symlinks // to. The daemon passes os.Executable(). Binary string } // InstallSpace installs the receive hooks into a space's repository. // // The path comes from gitx.DiskPath rather than from a second copy of the // layout rule, for the same reason the server re-derives it there: one source // of truth for where a space lives. func InstallSpace(reposRoot string, ref core.SpaceRef, opts InstallOptions) error { if err := core.ValidateOwner(ref.Owner); err != nil { return fmt.Errorf("hooks: install into %s: %w", ref, err) } if err := core.ValidateSpaceName(ref.Name); err != nil { return fmt.Errorf("hooks: install into %s: %w", ref, err) } return Install(gitx.DiskPath(reposRoot, ref), opts) } // Install writes — or refreshes — the receive hooks in a bare repository. // // Each hook is a symlink to the specsrht binary, which dispatches on the name // git invoked it as. Nothing is generated, so there is no stale script to find // after an upgrade: reinstalling is idempotent, and the daemon does it for // every space at startup. // // It also sets receive.advertisePushOptions. Without it git refuses // `--push-option=...` client-side with "the receiving end does not support // push options", and the documented escape hatch would not exist. // // Any file already occupying a hook's name is replaced. A repository under the // service's repos root has no hooks but ours, and silently leaving somebody // else's `update` in place would mean pushes that are never validated — the // exact failure this whole path exists to prevent. func Install(repoDir string, opts InstallOptions) error { if opts.Binary == "" { return errors.New("hooks: no binary to install hooks from") } if !filepath.IsAbs(opts.Binary) { return fmt.Errorf("hooks: %q is not an absolute path; git runs a hook with the "+ "repository as its working directory, so a relative target would not resolve", opts.Binary) } info, err := os.Stat(opts.Binary) if err != nil { return fmt.Errorf("hooks: %s: %w", opts.Binary, err) } if info.IsDir() || info.Mode().Perm()&0o111 == 0 { return fmt.Errorf("hooks: %s is not an executable file", opts.Binary) } if err := checkBareRepo(repoDir); err != nil { return err } dir := filepath.Join(repoDir, "hooks") if err := os.MkdirAll(dir, hooksDirMode); err != nil { return fmt.Errorf("hooks: create %s: %w", dir, err) } for _, mode := range Modes() { if err := linkHook(dir, string(mode), opts.Binary); err != nil { return err } } return advertisePushOptions(repoDir) } // checkBareRepo refuses to scatter symlinks into a directory that is not one of // our bare repositories. A wrong path here would install hooks nothing runs and // report success. func checkBareRepo(repoDir string) error { if repoDir == "" { return errors.New("hooks: no repository directory") } if !filepath.IsAbs(repoDir) { return fmt.Errorf("hooks: repository path %q is not absolute", repoDir) } for _, name := range []string{"HEAD", "objects", "refs"} { if _, err := os.Stat(filepath.Join(repoDir, name)); err != nil { return fmt.Errorf("hooks: %s does not look like a bare repository (%s): %w", repoDir, name, err) } } return nil } // linkHook points one hook at the binary, atomically. func linkHook(dir, name, binary string) error { final := filepath.Join(dir, name) tmp := final + installSuffix if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("hooks: clear %s: %w", tmp, err) } if err := os.Symlink(binary, tmp); err != nil { return fmt.Errorf("hooks: link %s -> %s: %w", tmp, binary, err) } if err := os.Rename(tmp, final); err != nil { _ = os.Remove(tmp) return fmt.Errorf("hooks: install %s: %w", final, err) } return nil } // advertisePushOptions turns on receive.advertisePushOptions. // // go-git writes the config file rather than this package shelling out to `git // config`: library code must not depend on a git binary being on the daemon's // PATH, and this runs on the daemon's side of the socket. func advertisePushOptions(repoDir string) error { repo, err := git.PlainOpen(repoDir) if err != nil { return fmt.Errorf("hooks: open %s: %w", repoDir, err) } cfg, err := repo.Config() if err != nil { return fmt.Errorf("hooks: read the config of %s: %w", repoDir, err) } section := cfg.Raw.Section("receive") if section.Option("advertisePushOptions") == "true" { return nil } section.SetOption("advertisePushOptions", "true") if err := repo.SetConfig(cfg); err != nil { return fmt.Errorf("hooks: enable push options on %s: %w", repoDir, err) } return nil }