This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / gitutil / archive.go
5.3 kB 198 lines
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}