// Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package cmd import ( "bufio" "bytes" "context" "fmt" "io" "os" "strconv" "strings" "time" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "github.com/urfave/cli/v2" ) const ( hookBatchSize = 30 ) var ( // CmdHook represents the available hooks sub-command. CmdHook = &cli.Command{ Name: "hook", Usage: "(internal) Should only be called by Git", Description: "Delegate commands to corresponding Git hooks", Before: PrepareConsoleLoggerLevel(log.FATAL), Subcommands: []*cli.Command{ subcmdHookPreReceive, subcmdHookUpdate, subcmdHookPostReceive, subcmdHookProcReceive, }, } subcmdHookPreReceive = &cli.Command{ Name: "pre-receive", Usage: "Delegate pre-receive Git hook", Description: "This command should only be called by Git", Action: runHookPreReceive, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "debug", }, }, } subcmdHookUpdate = &cli.Command{ Name: "update", Usage: "Delegate update Git hook", Description: "This command should only be called by Git", Action: runHookUpdate, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "debug", }, }, } subcmdHookPostReceive = &cli.Command{ Name: "post-receive", Usage: "Delegate post-receive Git hook", Description: "This command should only be called by Git", Action: runHookPostReceive, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "debug", }, }, } // Note: new hook since git 2.29 subcmdHookProcReceive = &cli.Command{ Name: "proc-receive", Usage: "Delegate proc-receive Git hook", Description: "This command should only be called by Git", Action: runHookProcReceive, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "debug", }, }, } ) type delayWriter struct { internal io.Writer buf *bytes.Buffer timer *time.Timer } func newDelayWriter(internal io.Writer, delay time.Duration) *delayWriter { timer := time.NewTimer(delay) return &delayWriter{ internal: internal, buf: &bytes.Buffer{}, timer: timer, } } func (d *delayWriter) Write(p []byte) (n int, err error) { if d.buf != nil { select { case <-d.timer.C: _, err := d.internal.Write(d.buf.Bytes()) if err != nil { return 0, err } d.buf = nil return d.internal.Write(p) default: return d.buf.Write(p) } } return d.internal.Write(p) } func (d *delayWriter) WriteString(s string) (n int, err error) { if d.buf != nil { select { case <-d.timer.C: _, err := d.internal.Write(d.buf.Bytes()) if err != nil { return 0, err } d.buf = nil return d.internal.Write([]byte(s)) default: return d.buf.WriteString(s) } } return d.internal.Write([]byte(s)) } func (d *delayWriter) Close() error { if d.timer.Stop() { d.buf = nil } if d.buf == nil { return nil } _, err := d.internal.Write(d.buf.Bytes()) d.buf = nil return err } type nilWriter struct{} func (n *nilWriter) Write(p []byte) (int, error) { return len(p), nil } func (n *nilWriter) WriteString(s string) (int, error) { return len(s), nil } func runHookPreReceive(c *cli.Context) error { if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { return nil } ctx, cancel := installSignals() defer cancel() setup(ctx, c.Bool("debug")) if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 { if setting.OnlyAllowPushIfGiteaEnvironmentSet { return fail(ctx, `Rejecting changes as Forgejo environment not set. If you are pushing over SSH you must push with a key managed by Forgejo or set your environment appropriately.`, "") } return nil } // the environment is set by serv command isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) username := os.Getenv(repo_module.EnvRepoUsername) reponame := os.Getenv(repo_module.EnvRepoName) userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64) deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64) actionPerm, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionPerm), 10, 64) hookOptions := private.HookOptions{ UserID: userID, GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories), GitObjectDirectory: os.Getenv(private.GitObjectDirectory), GitQuarantinePath: os.Getenv(private.GitQuarantinePath), GitPushOptions: pushOptions(), PullRequestID: prID, DeployKeyID: deployKeyID, ActionPerm: int(actionPerm), } scanner := bufio.NewScanner(os.Stdin) oldCommitIDs := make([]string, hookBatchSize) newCommitIDs := make([]string, hookBatchSize) refFullNames := make([]git.RefName, hookBatchSize) count := 0 total := 0 lastline := 0 var out io.Writer out = &nilWriter{} if setting.Git.VerbosePush { if setting.Git.VerbosePushDelay > 0 { dWriter := newDelayWriter(os.Stdout, setting.Git.VerbosePushDelay) defer dWriter.Close() out = dWriter } else { out = os.Stdout } } supportProcReceive := false if git.CheckGitVersionAtLeast("2.29") == nil { supportProcReceive = true } for scanner.Scan() { // TODO: support news feeds for wiki if isWiki { continue } fields := bytes.Fields(scanner.Bytes()) if len(fields) != 3 { continue } oldCommitID := string(fields[0]) newCommitID := string(fields[1]) refFullName := git.RefName(fields[2]) total++ lastline++ // If the ref is a branch or tag, check if it's protected // if supportProcReceive all ref should be checked because // permission check was delayed if supportProcReceive || refFullName.IsBranch() || refFullName.IsTag() { oldCommitIDs[count] = oldCommitID newCommitIDs[count] = newCommitID refFullNames[count] = refFullName count++ fmt.Fprintf(out, "*") if count >= hookBatchSize { fmt.Fprintf(out, " Checking %d references\n", count) hookOptions.OldCommitIDs = oldCommitIDs hookOptions.NewCommitIDs = newCommitIDs hookOptions.RefFullNames = refFullNames extra := private.HookPreReceive(ctx, username, reponame, hookOptions) if extra.HasError() { return fail(ctx, extra.UserMsg, "HookPreReceive(batch) failed: %v", extra.Error) } count = 0 lastline = 0 } } else { fmt.Fprintf(out, ".") } if lastline >= hookBatchSize { fmt.Fprintf(out, "\n") lastline = 0 } } if count > 0 { hookOptions.OldCommitIDs = oldCommitIDs[:count] hookOptions.NewCommitIDs = newCommitIDs[:count] hookOptions.RefFullNames = refFullNames[:count] fmt.Fprintf(out, " Checking %d references\n", count) extra := private.HookPreReceive(ctx, username, reponame, hookOptions) if extra.HasError() { return fail(ctx, extra.UserMsg, "HookPreReceive(last) failed: %v", extra.Error) } } else if lastline > 0 { fmt.Fprintf(out, "\n") } fmt.Fprintf(out, "Checked %d references in total\n", total) return nil } // runHookUpdate process the update hook: https://git-scm.com/docs/githooks#update func runHookUpdate(c *cli.Context) error { // Now if we're an internal don't do anything else if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { return nil } ctx, cancel := installSignals() defer cancel() if c.NArg() != 3 { return nil } args := c.Args().Slice() // The arguments given to the hook are in order: reference name, old commit ID and new commit ID. refFullName := git.RefName(args[0]) newCommitID := args[2] // Only process pull references. if !refFullName.IsPull() { return nil } // Empty new commit ID means deletion. if git.IsEmptyCommitID(newCommitID, nil) { return fail(ctx, fmt.Sprintf("The deletion of %s is skipped as it's an internal reference.", refFullName), "") } return nil } func runHookPostReceive(c *cli.Context) error { ctx, cancel := installSignals() defer cancel() setup(ctx, c.Bool("debug")) // First of all run update-server-info no matter what if _, _, err := git.NewCommand(ctx, "update-server-info").RunStdString(nil); err != nil { return fmt.Errorf("Failed to call 'git update-server-info': %w", err) } // Now if we're an internal don't do anything else if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { return nil } if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 { if setting.OnlyAllowPushIfGiteaEnvironmentSet { return fail(ctx, `Rejecting changes as Forgejo environment not set. If you are pushing over SSH you must push with a key managed by Forgejo or set your environment appropriately.`, "") } return nil } var out io.Writer out = &nilWriter{} if setting.Git.VerbosePush { if setting.Git.VerbosePushDelay > 0 { dWriter := newDelayWriter(os.Stdout, setting.Git.VerbosePushDelay) defer dWriter.Close() out = dWriter } else { out = os.Stdout } } // the environment is set by serv command repoUser := os.Getenv(repo_module.EnvRepoUsername) isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) repoName := os.Getenv(repo_module.EnvRepoName) pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) pusherName := os.Getenv(repo_module.EnvPusherName) hookOptions := private.HookOptions{ UserName: pusherName, UserID: pusherID, GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories), GitObjectDirectory: os.Getenv(private.GitObjectDirectory), GitQuarantinePath: os.Getenv(private.GitQuarantinePath), GitPushOptions: pushOptions(), } oldCommitIDs := make([]string, hookBatchSize) newCommitIDs := make([]string, hookBatchSize) refFullNames := make([]git.RefName, hookBatchSize) count := 0 total := 0 wasEmpty := false masterPushed := false results := make([]private.HookPostReceiveBranchResult, 0) scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { // TODO: support news feeds for wiki if isWiki { continue } fields := bytes.Fields(scanner.Bytes()) if len(fields) != 3 { continue } fmt.Fprintf(out, ".") oldCommitIDs[count] = string(fields[0]) newCommitIDs[count] = string(fields[1]) refFullNames[count] = git.RefName(fields[2]) if refFullNames[count] == git.BranchPrefix+"master" && !git.IsEmptyCommitID(newCommitIDs[count], nil) && count == total { masterPushed = true } count++ total++ if count >= hookBatchSize { fmt.Fprintf(out, " Processing %d references\n", count) hookOptions.OldCommitIDs = oldCommitIDs hookOptions.NewCommitIDs = newCommitIDs hookOptions.RefFullNames = refFullNames resp, extra := private.HookPostReceive(ctx, repoUser, repoName, hookOptions) if extra.HasError() { hookPrintResults(results) return fail(ctx, extra.UserMsg, "HookPostReceive failed: %v", extra.Error) } wasEmpty = wasEmpty || resp.RepoWasEmpty results = append(results, resp.Results...) count = 0 } } if count == 0 { if wasEmpty && masterPushed { // We need to tell the repo to reset the default branch to master extra := private.SetDefaultBranch(ctx, repoUser, repoName, "master") if extra.HasError() { return fail(ctx, extra.UserMsg, "SetDefaultBranch failed: %v", extra.Error) } } fmt.Fprintf(out, "Processed %d references in total\n", total) hookPrintResults(results) return nil } hookOptions.OldCommitIDs = oldCommitIDs[:count] hookOptions.NewCommitIDs = newCommitIDs[:count] hookOptions.RefFullNames = refFullNames[:count] fmt.Fprintf(out, " Processing %d references\n", count) resp, extra := private.HookPostReceive(ctx, repoUser, repoName, hookOptions) if resp == nil { hookPrintResults(results) return fail(ctx, extra.UserMsg, "HookPostReceive failed: %v", extra.Error) } wasEmpty = wasEmpty || resp.RepoWasEmpty results = append(results, resp.Results...) fmt.Fprintf(out, "Processed %d references in total\n", total) if wasEmpty && masterPushed { // We need to tell the repo to reset the default branch to master extra := private.SetDefaultBranch(ctx, repoUser, repoName, "master") if extra.HasError() { return fail(ctx, extra.UserMsg, "SetDefaultBranch failed: %v", extra.Error) } } hookPrintResults(results) return nil } func hookPrintResults(results []private.HookPostReceiveBranchResult) { for _, res := range results { if !res.Message { continue } fmt.Fprintln(os.Stderr, "") if res.Create { fmt.Fprintf(os.Stderr, "Create a new pull request for '%s':\n", res.Branch) fmt.Fprintf(os.Stderr, " %s\n", res.URL) } else { fmt.Fprint(os.Stderr, "Visit the existing pull request:\n") fmt.Fprintf(os.Stderr, " %s\n", res.URL) } fmt.Fprintln(os.Stderr, "") os.Stderr.Sync() } } func pushOptions() map[string]string { opts := make(map[string]string) if pushCount, err := strconv.Atoi(os.Getenv(private.GitPushOptionCount)); err == nil { for idx := 0; idx < pushCount; idx++ { opt := os.Getenv(fmt.Sprintf("GIT_PUSH_OPTION_%d", idx)) key, value, found := strings.Cut(opt, "=") if !found { value = "true" } opts[key] = value } } return opts } func runHookProcReceive(c *cli.Context) error { ctx, cancel := installSignals() defer cancel() setup(ctx, c.Bool("debug")) if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 { if setting.OnlyAllowPushIfGiteaEnvironmentSet { return fail(ctx, `Rejecting changes as Forgejo environment not set. If you are pushing over SSH you must push with a key managed by Forgejo or set your environment appropriately.`, "") } return nil } if git.CheckGitVersionAtLeast("2.29") != nil { return fail(ctx, "No proc-receive support", "current git version doesn't support proc-receive.") } reader := bufio.NewReader(os.Stdin) repoUser := os.Getenv(repo_module.EnvRepoUsername) repoName := os.Getenv(repo_module.EnvRepoName) pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) pusherName := os.Getenv(repo_module.EnvPusherName) // 1. Version and features negotiation. // S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n) // S: flush-pkt // H: PKT-LINE(version=1\0push-options...) // H: flush-pkt rs, err := readPktLine(ctx, reader, pktLineTypeData) if err != nil { return err } const VersionHead string = "version=1" var ( hasPushOptions bool response = []byte(VersionHead) requestOptions []string ) index := bytes.IndexByte(rs.Data, byte(0)) if index >= len(rs.Data) { return fail(ctx, "Protocol: format error", "pkt-line: format error "+fmt.Sprint(rs.Data)) } if index < 0 { if len(rs.Data) == 10 && rs.Data[9] == '\n' { index = 9 } else { return fail(ctx, "Protocol: format error", "pkt-line: format error "+fmt.Sprint(rs.Data)) } } if string(rs.Data[0:index]) != VersionHead { return fail(ctx, "Protocol: version error", "Received unsupported version: %s", string(rs.Data[0:index])) } requestOptions = strings.Split(string(rs.Data[index+1:]), " ") for _, option := range requestOptions { if strings.HasPrefix(option, "push-options") { response = append(response, byte(0)) response = append(response, []byte("push-options")...) hasPushOptions = true } } response = append(response, '\n') _, err = readPktLine(ctx, reader, pktLineTypeFlush) if err != nil { return err } err = writeDataPktLine(ctx, os.Stdout, response) if err != nil { return err } err = writeFlushPktLine(ctx, os.Stdout) if err != nil { return err } // 2. receive commands from server. // S: PKT-LINE( ) // S: ... ... // S: flush-pkt // # [receive push-options] // S: PKT-LINE(push-option) // S: ... ... // S: flush-pkt hookOptions := private.HookOptions{ UserName: pusherName, UserID: pusherID, } hookOptions.OldCommitIDs = make([]string, 0, hookBatchSize) hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize) hookOptions.RefFullNames = make([]git.RefName, 0, hookBatchSize) for { // note: pktLineTypeUnknow means pktLineTypeFlush and pktLineTypeData all allowed rs, err = readPktLine(ctx, reader, pktLineTypeUnknown) if err != nil { return err } if rs.Type == pktLineTypeFlush { break } t := strings.SplitN(string(rs.Data), " ", 3) if len(t) != 3 { continue } hookOptions.OldCommitIDs = append(hookOptions.OldCommitIDs, t[0]) hookOptions.NewCommitIDs = append(hookOptions.NewCommitIDs, t[1]) hookOptions.RefFullNames = append(hookOptions.RefFullNames, git.RefName(t[2])) } hookOptions.GitPushOptions = make(map[string]string) if hasPushOptions { for { rs, err = readPktLine(ctx, reader, pktLineTypeUnknown) if err != nil { return err } if rs.Type == pktLineTypeFlush { break } key, value, found := strings.Cut(string(rs.Data), "=") if !found { value = "true" } hookOptions.GitPushOptions[key] = value } } // 3. run hook resp, extra := private.HookProcReceive(ctx, repoUser, repoName, hookOptions) if extra.HasError() { return fail(ctx, extra.UserMsg, "HookProcReceive failed: %v", extra.Error) } // 4. response result to service // # a. OK, but has an alternate reference. The alternate reference name // # and other status can be given in option directives. // H: PKT-LINE(ok ) // H: PKT-LINE(option refname ) // H: PKT-LINE(option old-oid ) // H: PKT-LINE(option new-oid ) // H: PKT-LINE(option forced-update) // H: ... ... // H: flush-pkt // # b. NO, I reject it. // H: PKT-LINE(ng ) // # c. Fall through, let 'receive-pack' to execute it. // H: PKT-LINE(ok ) // H: PKT-LINE(option fall-through) for _, rs := range resp.Results { if len(rs.Err) > 0 { err = writeDataPktLine(ctx, os.Stdout, []byte("ng "+rs.OriginalRef.String()+" "+rs.Err)) if err != nil { return err } continue } if rs.IsNotMatched { err = writeDataPktLine(ctx, os.Stdout, []byte("ok "+rs.OriginalRef.String())) if err != nil { return err } err = writeDataPktLine(ctx, os.Stdout, []byte("option fall-through")) if err != nil { return err } continue } err = writeDataPktLine(ctx, os.Stdout, []byte("ok "+rs.OriginalRef)) if err != nil { return err } err = writeDataPktLine(ctx, os.Stdout, []byte("option refname "+rs.Ref)) if err != nil { return err } if !git.IsEmptyCommitID(rs.OldOID, nil) { err = writeDataPktLine(ctx, os.Stdout, []byte("option old-oid "+rs.OldOID)) if err != nil { return err } } err = writeDataPktLine(ctx, os.Stdout, []byte("option new-oid "+rs.NewOID)) if err != nil { return err } if rs.IsForcePush { err = writeDataPktLine(ctx, os.Stdout, []byte("option forced-update")) if err != nil { return err } } } err = writeFlushPktLine(ctx, os.Stdout) return err } // git PKT-Line api // pktLineType message type of pkt-line type pktLineType int64 const ( // Unknown type pktLineTypeUnknown pktLineType = 0 // flush-pkt "0000" pktLineTypeFlush pktLineType = iota // data line pktLineTypeData ) // gitPktLine pkt-line api type gitPktLine struct { Type pktLineType Length uint64 Data []byte } // Reads an Pkt-Line from `in`. If requestType is not unknown, it will a func readPktLine(ctx context.Context, in *bufio.Reader, requestType pktLineType) (*gitPktLine, error) { // Read length prefix lengthBytes := make([]byte, 4) if n, err := in.Read(lengthBytes); n != 4 || err != nil { return nil, fail(ctx, "Protocol: stdin error", "Pkt-Line: read stdin failed : %v", err) } var err error r := &gitPktLine{} r.Length, err = strconv.ParseUint(string(lengthBytes), 16, 32) if err != nil { return nil, fail(ctx, "Protocol: format parse error", "Pkt-Line format is wrong :%v", err) } if r.Length == 0 { if requestType == pktLineTypeData { return nil, fail(ctx, "Protocol: format data error", "Pkt-Line format is wrong") } r.Type = pktLineTypeFlush return r, nil } if r.Length <= 4 || r.Length > 65520 || requestType == pktLineTypeFlush { return nil, fail(ctx, "Protocol: format length error", "Pkt-Line format is wrong") } r.Data = make([]byte, r.Length-4) if n, err := io.ReadFull(in, r.Data); uint64(n) != r.Length-4 || err != nil { return nil, fail(ctx, "Protocol: stdin error", "Pkt-Line: read stdin failed : %v", err) } r.Type = pktLineTypeData return r, nil } func writeFlushPktLine(ctx context.Context, out io.Writer) error { l, err := out.Write([]byte("0000")) if err != nil || l != 4 { return fail(ctx, "Protocol: write error", "Pkt-Line response failed: %v", err) } return nil } // Write an Pkt-Line based on `data` to `out` according to the specifcation. // https://git-scm.com/docs/protocol-common func writeDataPktLine(ctx context.Context, out io.Writer, data []byte) error { // Implementations SHOULD NOT send an empty pkt-line ("0004"). if len(data) == 0 { return fail(ctx, "Protocol: write error", "Not allowed to write empty Pkt-Line") } length := uint64(len(data) + 4) // The maximum length of a pkt-line’s data component is 65516 bytes. // Implementations MUST NOT send pkt-line whose length exceeds 65520 (65516 bytes of payload + 4 bytes of length data). if length > 65520 { return fail(ctx, "Protocol: write error", "Pkt-Line exceeds maximum of 65520 bytes") } lr, err := fmt.Fprintf(out, "%04x", length) if err != nil || lr != 4 { return fail(ctx, "Protocol: write error", "Pkt-Line response failed: %v", err) } lr, err = out.Write(data) if err != nil || int(length-4) != lr { return fail(ctx, "Protocol: write error", "Pkt-Line response failed: %v", err) } return nil }