This repository has no description
1package gitutil
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "mime"
9 "net/http"
10 "net/url"
11 "os/exec"
12 "path"
13 "slices"
14 "strings"
15 "syscall"
16 "time"
17 "unicode"
18
19 "github.com/go-git/go-git/v5/plumbing"
20 "github.com/samber/lo"
21)
22
23type ArchiveFormat string
24
25const (
26 ArchiveTarGz ArchiveFormat = "tar.gz"
27 ArchiveZip ArchiveFormat = "zip"
28)
29
30var ArchiveFormats = []ArchiveFormat{ArchiveTarGz, ArchiveZip}
31
32func ParseArchiveFormat(raw string) (ArchiveFormat, error) {
33 if format := ArchiveFormat(raw); slices.Contains(ArchiveFormats, format) {
34 return format, nil
35 }
36 return "", fmt.Errorf("only tar.gz and zip formats are supported, got %q", raw)
37}
38
39func (f ArchiveFormat) String() string { return string(f) }
40
41func (f ArchiveFormat) contentType() string {
42 return lo.Ternary(f == ArchiveZip, "application/zip", "application/gzip")
43}
44
45var pathSeparators = strings.NewReplacer("/", "-", `\`, "-")
46
47type RepoName string
48
49type Rev string
50
51const RevHead Rev = "HEAD"
52
53func ParseRev(raw string) (Rev, error) {
54 switch {
55 case raw == "":
56 return "", fmt.Errorf("ref is empty")
57 case strings.ContainsFunc(raw, func(c rune) bool { return unicode.IsSpace(c) || unicode.IsControl(c) }):
58 return "", fmt.Errorf("ref contains whitespace or a control character: %q", raw)
59 case strings.HasPrefix(raw, "-"):
60 return "", fmt.Errorf("ref starts with a dash: %q", raw)
61 }
62 return Rev(raw), nil
63}
64
65func RevFromHash(h plumbing.Hash) Rev { return Rev(h.String()) }
66
67func (r Rev) String() string { return string(r) }
68
69func (r Rev) Slug() string { return pathSeparators.Replace(plumbing.ReferenceName(r).Short()) }
70
71func (r Rev) Or(fallback Rev) Rev { return lo.Ternary(r == "", fallback, r) }
72
73func (r Rev) OrHash(h plumbing.Hash) Rev { return r.Or(RevFromHash(h)) }
74
75type ArchivePrefix string
76
77const MaxArchivePrefixLen = 255
78
79func ParseArchivePrefix(raw string) (ArchivePrefix, error) {
80 switch {
81 case len(raw) > MaxArchivePrefixLen:
82 return "", fmt.Errorf("prefix is %d bytes, over the %d byte limit", len(raw), MaxArchivePrefixLen)
83 case strings.ContainsFunc(raw, unicode.IsControl):
84 return "", fmt.Errorf("prefix contains a control character: %q", raw)
85 case strings.Contains(raw, `\`):
86 return "", fmt.Errorf("prefix contains a backslash: %q", raw)
87 }
88 trimmed := strings.Trim(raw, "/")
89 if trimmed == "" {
90 return "", nil
91 }
92 cleaned := path.Clean(trimmed)
93 if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
94 return "", fmt.Errorf("prefix escapes the archive root: %q", raw)
95 }
96 return ArchivePrefix(cleaned), nil
97}
98
99func (p ArchivePrefix) String() string { return string(p) }
100
101func (p ArchivePrefix) OrDefault(repo RepoName, rev Rev) ArchivePrefix {
102 if p == "" {
103 return ArchivePrefix(archiveStem(repo, rev))
104 }
105 return p
106}
107
108func archiveStem(repo RepoName, rev Rev) string {
109 stem := pathSeparators.Replace(string(repo)) + "-" + rev.Slug()
110 if len(stem) <= MaxArchivePrefixLen {
111 return stem
112 }
113 return strings.ToValidUTF8(stem[:MaxArchivePrefixLen], "")
114}
115
116type ArchiveParams struct {
117 Rev Rev
118 Format ArchiveFormat
119 Prefix ArchivePrefix
120}
121
122func ParseArchiveParams(q url.Values) (ArchiveParams, error) {
123 p := ArchiveParams{Format: ArchiveTarGz}
124 var err error
125 if raw := q.Get("ref"); raw != "" {
126 if p.Rev, err = ParseRev(raw); err != nil {
127 return ArchiveParams{}, err
128 }
129 }
130 if raw := q.Get("format"); raw != "" {
131 if p.Format, err = ParseArchiveFormat(raw); err != nil {
132 return ArchiveParams{}, err
133 }
134 }
135 if p.Prefix, err = ParseArchivePrefix(q.Get("prefix")); err != nil {
136 return ArchiveParams{}, err
137 }
138 return p, nil
139}
140
141func (p ArchiveParams) WithRev(rev Rev) ArchiveParams {
142 p.Rev = rev
143 return p
144}
145
146func (p ArchiveParams) Query(repo string) url.Values {
147 return url.Values{
148 "repo": {repo},
149 "ref": {p.Rev.String()},
150 "format": {p.Format.String()},
151 "prefix": {p.Prefix.String()},
152 }
153}
154
155func (p ArchiveParams) SetHeaders(h http.Header, repo RepoName) {
156 h.Set("Content-Type", p.Format.contentType())
157 h.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
158 "filename": archiveStem(repo, p.Rev) + "." + p.Format.String(),
159 }))
160 h.Set("X-Content-Type-Options", "nosniff")
161}
162
163func ImmutableLink(target string) string { return `<` + target + `>; rel="immutable"` }
164
165func ParseImmutableLink(header string) (Rev, error) {
166 target := strings.TrimSuffix(strings.TrimPrefix(header, "<"), `>; rel="immutable"`)
167 parsed, err := url.Parse(target)
168 if err != nil {
169 return "", err
170 }
171 return ParseRev(parsed.Query().Get("ref"))
172}
173
174const archiveWaitDelay = 10 * time.Second
175
176func WriteArchive(ctx context.Context, w io.Writer, repoPath string, rev Rev, format ArchiveFormat, prefix ArchivePrefix) error {
177 args := []string{"archive", "--format=" + format.String()}
178 if prefix != "" {
179 args = append(args, "--prefix="+prefix.String()+"/")
180 }
181
182 cmd := exec.CommandContext(ctx, "git", append(args, "--", rev.String())...)
183 cmd.Dir = repoPath
184 cmd.Stdout = w
185 stderr := new(bytes.Buffer)
186 cmd.Stderr = stderr
187 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
188 cmd.WaitDelay = archiveWaitDelay
189 cmd.Cancel = func() error {
190 err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
191 return lo.Ternary(err == syscall.ESRCH, nil, err)
192 }
193
194 if err := cmd.Run(); err != nil {
195 return fmt.Errorf("%w, stderr: %s", err, stderr.String())
196 }
197 return nil
198}