drone/git/api/tag.go

401 lines
11 KiB
Go

// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
"bytes"
"context"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/harness/gitness/errors"
"github.com/harness/gitness/git/command"
"github.com/harness/gitness/git/sha"
)
const (
pgpSignatureBeginToken = "\n-----BEGIN PGP SIGNATURE-----\n" //#nosec G101
pgpSignatureEndToken = "\n-----END PGP SIGNATURE-----" //#nosec G101
)
type Tag struct {
Sha sha.SHA
Name string
TargetSha sha.SHA
TargetType GitObjectType
Title string
Message string
Tagger Signature
Signature *CommitGPGSignature
}
type CreateTagOptions struct {
// Message is the optional message the tag will be created with - if the message is empty
// the tag will be lightweight, otherwise it'll be annotated.
Message string
// Tagger is the information used in case the tag is annotated (Message is provided).
Tagger Signature
}
// TagPrefix tags prefix path on the repository.
const TagPrefix = "refs/tags/"
// GetAnnotatedTag returns the tag for a specific tag sha.
func (g *Git) GetAnnotatedTag(
ctx context.Context,
repoPath string,
rev string,
) (*Tag, error) {
if repoPath == "" {
return nil, ErrRepositoryPathEmpty
}
tags, err := getAnnotatedTags(ctx, repoPath, []string{rev})
if err != nil || len(tags) == 0 {
return nil, processGitErrorf(err, "failed to get annotated tag with sha '%s'", rev)
}
return &tags[0], nil
}
// GetAnnotatedTags returns the tags for a specific list of tag sha.
func (g *Git) GetAnnotatedTags(
ctx context.Context,
repoPath string,
revs []string,
) ([]Tag, error) {
if repoPath == "" {
return nil, ErrRepositoryPathEmpty
}
return getAnnotatedTags(ctx, repoPath, revs)
}
// CreateTag creates the tag pointing at the provided SHA (could be any type, e.g. commit, tag, blob, ...)
func (g *Git) CreateTag(
ctx context.Context,
repoPath string,
name string,
targetSHA sha.SHA,
opts *CreateTagOptions,
) error {
if repoPath == "" {
return ErrRepositoryPathEmpty
}
cmd := command.New("tag")
if opts != nil && opts.Message != "" {
cmd.Add(command.WithFlag("-m", opts.Message))
cmd.Add(
command.WithCommitterAndDate(
opts.Tagger.Identity.Name,
opts.Tagger.Identity.Email,
opts.Tagger.When,
),
)
}
cmd.Add(command.WithArg(name, targetSHA.String()))
err := cmd.Run(ctx, command.WithDir(repoPath))
if err != nil {
return processGitErrorf(err, "Service failed to create a tag")
}
return nil
}
// getAnnotatedTag is a custom implementation to retrieve an annotated tag from a sha.
func getAnnotatedTags(
ctx context.Context,
repoPath string,
revs []string,
) ([]Tag, error) {
if repoPath == "" {
return nil, ErrRepositoryPathEmpty
}
// The tag is an annotated tag with a message.
writer, reader, cancel := CatFileBatch(ctx, repoPath, nil)
defer func() {
cancel()
_ = writer.Close()
}()
tags := make([]Tag, len(revs))
for i, rev := range revs {
line := rev + "\n"
if _, err := writer.Write([]byte(line)); err != nil {
return nil, err
}
output, err := ReadBatchHeaderLine(reader)
if err != nil {
if errors.Is(err, io.EOF) || errors.IsNotFound(err) {
return nil, fmt.Errorf("tag with sha %s does not exist", rev)
}
return nil, err
}
if output.Type != string(GitObjectTypeTag) {
return nil, fmt.Errorf("git object is of type '%s', expected tag",
output.Type)
}
// read the remaining rawData
rawData, err := io.ReadAll(io.LimitReader(reader, output.Size))
if err != nil {
return nil, err
}
_, err = reader.Discard(1)
if err != nil {
return nil, err
}
tag, err := parseTagDataFromCatFile(rawData)
if err != nil {
return nil, fmt.Errorf("failed to parse tag '%s': %w", rev, err)
}
// fill in the sha
tag.Sha = output.SHA
tags[i] = tag
}
return tags, nil
}
// parseTagDataFromCatFile parses a tag from a cat-file output.
func parseTagDataFromCatFile(data []byte) (tag Tag, err error) {
// parse object Id
object, p, err := parseCatFileLine(data, 0, "object")
if err != nil {
return tag, err
}
tag.TargetSha = sha.Must(object)
// parse object type
rawType, p, err := parseCatFileLine(data, p, "type")
if err != nil {
return tag, err
}
tag.TargetType, err = ParseGitObjectType(rawType)
if err != nil {
return tag, err
}
// parse tag name
tag.Name, p, err = parseCatFileLine(data, p, "tag")
if err != nil {
return tag, err
}
// parse tagger
rawTaggerInfo, p, err := parseCatFileLine(data, p, "tagger")
if err != nil {
return tag, err
}
tag.Tagger, err = parseSignatureFromCatFileLine(rawTaggerInfo)
if err != nil {
return tag, err
}
// remainder is message and gpg (remove leading and tailing new lines)
message := string(bytes.Trim(data[p:], "\n"))
// handle gpg signature
pgpEnd := strings.Index(message, pgpSignatureEndToken)
if pgpEnd > -1 {
messageStart := pgpEnd + len(pgpSignatureEndToken)
// for now we just remove the signature (and trim any separating new lines)
// TODO: add support for GPG signature of tags
message = strings.TrimLeft(message[messageStart:], "\n")
}
tag.Message = message
// get title from message
tag.Title = message
titleEnd := strings.IndexByte(message, '\n')
if titleEnd > -1 {
tag.Title = message[:titleEnd]
}
return tag, nil
}
func parseCatFileLine(data []byte, start int, header string) (string, int, error) {
// for simplicity only look at data from start onwards
data = data[start:]
lenHeader := len(header)
lenData := len(data)
if lenData < lenHeader {
return "", 0, fmt.Errorf("expected '%s' but line only contains '%s'", header, string(data))
}
if string(data[:lenHeader]) != header {
return "", 0, fmt.Errorf("expected '%s' but started with '%s'", header, string(data[:lenHeader]))
}
// get end of line and start of next line (used externally, transpose with provided start index)
lineEnd := bytes.IndexByte(data, '\n')
externalNextLine := start + lineEnd + 1
if lineEnd == -1 {
lineEnd = lenData
externalNextLine = start + lenData
}
// if there's no data, return an error (have to consider for ' ')
if lineEnd <= lenHeader+1 {
return "", 0, fmt.Errorf("no data for line of type '%s'", header)
}
return string(data[lenHeader+1 : lineEnd]), externalNextLine, nil
}
// defaultGitTimeLayout is the (default) time format printed by git.
const defaultGitTimeLayout = "Mon Jan _2 15:04:05 2006 -0700"
// parseSignatureFromCatFileLine parses the signature from a cat-file output.
// This is used for commit / tag outputs. Input will be similar to (without 'author 'prefix):
// - author Max Mustermann <mm@gitness.io> 1666401234 -0700
// - author Max Mustermann <mm@gitness.io> Tue Oct 18 05:13:26 2022 +0530.
func parseSignatureFromCatFileLine(line string) (Signature, error) {
sig := Signature{}
emailStart := strings.LastIndexByte(line, '<')
emailEnd := strings.LastIndexByte(line, '>')
if emailStart == -1 || emailEnd == -1 || emailEnd < emailStart {
return Signature{}, fmt.Errorf("signature is missing email ('%s')", line)
}
// name requires that there is at least one char followed by a space (so emailStart >= 2)
if emailStart < 2 {
return Signature{}, fmt.Errorf("signature is missing name ('%s')", line)
}
sig.Identity.Name = line[:emailStart-1]
sig.Identity.Email = line[emailStart+1 : emailEnd]
timeStart := emailEnd + 2
if timeStart >= len(line) {
return Signature{}, fmt.Errorf("signature is missing time ('%s')", line)
}
// Check if time format is written date time format (e.g Thu, 07 Apr 2005 22:13:13 +0200)
// we can check that by ensuring that the date time part starts with a non-digit character.
if line[timeStart] > '9' {
var err error
sig.When, err = time.Parse(defaultGitTimeLayout, line[timeStart:])
if err != nil {
return Signature{}, fmt.Errorf("failed to time.parse signature time ('%s'): %w", line, err)
}
return sig, nil
}
// Otherwise we have to manually parse unix time and time zone
endOfUnixTime := timeStart + strings.IndexByte(line[timeStart:], ' ')
if endOfUnixTime <= timeStart {
return Signature{}, fmt.Errorf("signature is missing unix time ('%s')", line)
}
unixSeconds, err := strconv.ParseInt(line[timeStart:endOfUnixTime], 10, 64)
if err != nil {
return Signature{}, fmt.Errorf("failed to parse unix time ('%s'): %w", line, err)
}
// parse time zone
startOfTimeZone := endOfUnixTime + 1 // +1 for space
endOfTimeZone := startOfTimeZone + 5 // +5 for '+0700'
if startOfTimeZone >= len(line) || endOfTimeZone > len(line) {
return Signature{}, fmt.Errorf("signature is missing time zone ('%s')", line)
}
// get and disect timezone, e.g. '+0700'
rawTimeZone := line[startOfTimeZone:endOfTimeZone]
rawTimeZoneH := rawTimeZone[1:3] // gets +[07]00
rawTimeZoneMin := rawTimeZone[3:] // gets +07[00]
timeZoneH, err := strconv.ParseInt(rawTimeZoneH, 10, 64)
if err != nil {
return Signature{}, fmt.Errorf("failed to parse hours of time zone ('%s'): %w", line, err)
}
timeZoneMin, err := strconv.ParseInt(rawTimeZoneMin, 10, 64)
if err != nil {
return Signature{}, fmt.Errorf("failed to parse minutes of time zone ('%s'): %w", line, err)
}
timeZoneOffsetInSec := int(timeZoneH*60+timeZoneMin) * 60
if rawTimeZone[0] == '-' {
timeZoneOffsetInSec *= -1
}
timeZone := time.FixedZone("", timeZoneOffsetInSec)
// create final time using unix and timezone translation
sig.When = time.Unix(unixSeconds, 0).In(timeZone)
return sig, nil
}
// Parse commit information from the (uncompressed) raw
// data from the commit object.
// \n\n separate headers from message.
func parseTagData(data []byte) (*Tag, error) {
tag := &Tag{
Tagger: Signature{},
}
// we now have the contents of the commit object. Let's investigate...
nextLine := 0
l:
for {
eol := bytes.IndexByte(data[nextLine:], '\n')
switch {
case eol > 0:
line := data[nextLine : nextLine+eol]
spacePos := bytes.IndexByte(line, ' ')
refType := line[:spacePos]
switch string(refType) {
case "object":
tag.TargetSha = sha.Must(string(line[spacePos+1:]))
case "type":
// A commit can have one or more parents
tag.TargetType = GitObjectType(line[spacePos+1:])
case "tagger":
sig, err := NewSignatureFromCommitLine(line[spacePos+1:])
if err != nil {
return nil, fmt.Errorf("failed to parse tagger signature: %w", err)
}
tag.Tagger = sig
}
nextLine += eol + 1
case eol == 0:
tag.Message = string(data[nextLine+1:])
break l
default:
break l
}
}
idx := strings.LastIndex(tag.Message, pgpSignatureBeginToken)
if idx > 0 {
endSigIdx := strings.Index(tag.Message[idx:], pgpSignatureEndToken)
if endSigIdx > 0 {
tag.Signature = &CommitGPGSignature{
Signature: tag.Message[idx+1 : idx+endSigIdx+len(pgpSignatureEndToken)],
Payload: string(data[:bytes.LastIndex(data, []byte(pgpSignatureBeginToken))+1]),
}
tag.Message = tag.Message[:idx+1]
}
}
return tag, nil
}