package gitutil
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"slices"
"strings"
"unicode"
"github.com/go-git/go-git/v5/plumbing"
"github.com/samber/lo"
)
type ArchiveFormat string
const (
ArchiveTarGz ArchiveFormat = "tar.gz"
ArchiveZip ArchiveFormat = "zip"
)
var ArchiveFormats = []ArchiveFormat{ArchiveTarGz, ArchiveZip}
func ParseArchiveFormat(raw string) (ArchiveFormat, error) {
if format := ArchiveFormat(raw); slices.Contains(ArchiveFormats, format) {
return format, nil
}
return "", fmt.Errorf("only tar.gz and zip formats are supported, got %q", raw)
}
func (f ArchiveFormat) String() string { return string(f) }
func (f ArchiveFormat) contentType() string {
return lo.Ternary(f == ArchiveZip, "application/zip", "application/gzip")
}
var pathSeparators = strings.NewReplacer("/", "-", `\`, "-")
type RepoName string
type Rev string
const RevHead Rev = "HEAD"
func ParseRev(raw string) (Rev, error) {
switch {
case raw == "":
return "", fmt.Errorf("ref is empty")
case strings.ContainsFunc(raw, func(c rune) bool { return unicode.IsSpace(c) || unicode.IsControl(c) }):
return "", fmt.Errorf("ref contains whitespace or a control character: %q", raw)
case strings.HasPrefix(raw, "-"):
return "", fmt.Errorf("ref starts with a dash: %q", raw)
}
return Rev(raw), nil
}
func RevFromHash(h plumbing.Hash) Rev { return Rev(h.String()) }
const sha1HexLen, sha256HexLen, hexDigits = 40, 64, "0123456789abcdef"
func (r Rev) IsObjectID() bool {
return slices.Contains([]int{sha1HexLen, sha256HexLen}, len(r)) &&
strings.TrimLeft(string(r), hexDigits) == ""
}
func (r Rev) String() string { return string(r) }
func (r Rev) Slug() string { return pathSeparators.Replace(plumbing.ReferenceName(r).Short()) }
func (r Rev) Or(fallback Rev) Rev { return lo.Ternary(r == "", fallback, r) }
type ArchivePrefix string
const MaxArchivePrefixLen = 255
func ParseArchivePrefix(raw string) (ArchivePrefix, error) {
switch {
case strings.ContainsFunc(raw, unicode.IsControl):
return "", fmt.Errorf("prefix contains a control character: %q", raw)
case strings.Contains(raw, `\`):
return "", fmt.Errorf("prefix contains a backslash: %q", raw)
}
components := lo.Filter(strings.Split(raw, "/"), func(component string, _ int) bool {
return component != "" && component != "."
})
if slices.Contains(components, "..") {
return "", fmt.Errorf("prefix escapes the archive root: %q", raw)
}
prefix := ArchivePrefix(strings.Join(components, "/"))
if len(prefix) > MaxArchivePrefixLen {
return "", fmt.Errorf("prefix is %d bytes, over the %d byte limit", len(prefix), MaxArchivePrefixLen)
}
return prefix, nil
}
func (p ArchivePrefix) String() string { return string(p) }
func archiveStem(repo RepoName, rev Rev) ArchivePrefix {
stem := strings.ToValidUTF8(pathSeparators.Replace(string(repo))+"-"+rev.Slug(), "�")
return ArchivePrefix(strings.ToValidUTF8(stem[:min(len(stem), MaxArchivePrefixLen)], ""))
}
type ArchiveParams struct {
Rev Rev
Format ArchiveFormat
Prefix ArchivePrefix
}
func ParseArchiveParams(q url.Values) (ArchiveParams, error) {
p := ArchiveParams{Format: ArchiveTarGz}
var err error
if raw := q.Get("ref"); raw != "" {
if p.Rev, err = ParseRev(raw); err != nil {
return ArchiveParams{}, err
}
}
if raw := q.Get("format"); raw != "" {
if p.Format, err = ParseArchiveFormat(raw); err != nil {
return ArchiveParams{}, err
}
}
if p.Prefix, err = ParseArchivePrefix(q.Get("prefix")); err != nil {
return ArchiveParams{}, err
}
return p, nil
}
func (p ArchiveParams) WithRev(rev Rev) ArchiveParams {
p.Rev = rev
return p
}
type ServedArchive struct {
rev Rev
format ArchiveFormat
prefix ArchivePrefix
}
func (p ArchiveParams) Serve(repo RepoName) ServedArchive {
return ServedArchive{p.Rev, p.Format, cmp.Or(p.Prefix, archiveStem(repo, p.Rev))}
}
func (a ServedArchive) WithRev(rev Rev) ServedArchive {
a.rev = rev
return a
}
func (a ServedArchive) Query(repo string) url.Values {
return url.Values{
"repo": {repo},
"ref": {a.rev.String()},
"format": {a.format.String()},
"prefix": {a.prefix.String()},
}
}
func (a ServedArchive) SuffixURL() string {
return fmt.Sprintf("%s.%s?%s", url.PathEscape(a.rev.String()), a.format,
url.Values{"prefix": {a.prefix.String()}}.Encode())
}
func (a ServedArchive) SetHeaders(h http.Header) {
h.Set("Content-Type", a.format.contentType())
h.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
"filename": pathSeparators.Replace(a.prefix.String()) + "." + a.format.String(),
}))
h.Set("X-Content-Type-Options", "nosniff")
}
const archiveETagDomain = "gitutil.archive.v1"
type RepoIdentity string
func (a ServedArchive) ETag(repo RepoIdentity) string {
digest := sha256.Sum256([]byte(strings.Join(
[]string{archiveETagDomain, string(repo), a.rev.String(), a.format.String(), a.prefix.String()}, "\x00",
)))
return `"` + hex.EncodeToString(digest[:]) + `"`
}
func (a ServedArchive) ServeNotModified(w http.ResponseWriter, r *http.Request, repo RepoIdentity) bool {
etag := a.ETag(repo)
w.Header().Set("Etag", etag)
w.Header().Set("Cache-Control", "no-cache")
if !ETagMatches(r.Header, etag) {
return false
}
w.WriteHeader(http.StatusNotModified)
return true
}
func ETagMatches(h http.Header, etag string) bool {
offered := strings.Split(strings.Join(h.Values("If-None-Match"), ","), ",")
return lo.SomeBy(offered, func(candidate string) bool {
trimmed := strings.TrimSpace(candidate)
return trimmed == "*" || strings.TrimPrefix(trimmed, "W/") == etag
})
}
func ForwardHeaders(dst, src http.Header, keys ...string) {
lo.ForEach(keys, func(key string, _ int) {
if values := src.Values(key); len(values) > 0 {
dst[http.CanonicalHeaderKey(key)] = slices.Clone(values)
}
})
}
func ImmutableLink(target string) string { return `<` + target + `>; rel="immutable"` }
func ParseImmutableLink(header string) (Rev, error) {
target := strings.TrimSuffix(strings.TrimPrefix(header, "<"), `>; rel="immutable"`)
parsed, err := url.Parse(target)
if err != nil {
return "", err
}
return ParseRev(parsed.Query().Get("ref"))
}
type ResponseBody struct {
inner http.ResponseWriter
started bool
}
func NewResponseBody(w http.ResponseWriter) *ResponseBody { return &ResponseBody{inner: w} }
func (b *ResponseBody) Write(p []byte) (int, error) {
n, err := b.inner.Write(p)
b.started = b.started || n > 0
return n, err
}
func (b *ResponseBody) Fail() {
if b.started {
panic(http.ErrAbortHandler)
}
header := b.inner.Header()
lo.ForEach([]string{"Content-Length", "Content-Type", "Content-Disposition", "Etag", "Link"},
func(key string, _ int) { header.Del(key) })
header.Set("Cache-Control", "no-store")
b.inner.WriteHeader(http.StatusInternalServerError)
}
func WriteArchive(ctx context.Context, w io.Writer, repoPath string, archive ServedArchive) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cmd := Command(ctx, "archive",
"--format="+archive.format.String(),
"--prefix="+archive.prefix.String()+"/",
"--", archive.rev.String())
cmd.Dir = repoPath
stderr := new(bytes.Buffer)
cmd.Stderr = stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
if _, err := io.Copy(w, stdout); err != nil {
cancel()
_ = cmd.Wait()
return fmt.Errorf("writing the archive body: %w", err)
}
if err := cmd.Wait(); err != nil {
return fmt.Errorf("%w, stderr: %s", err, stderr.String())
}
return nil
}