1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "fmt"
7 "mime"
8 "strings"
9 "time"
10 "unicode"
11 "unicode/utf8"
12
13 "golang.org/x/text/unicode/norm"
14)
15
16// Release binds release notes and assets to a repository tag.
17type Release struct {
18 ID int64
19 Repo RepoName
20 Tag, Title, Notes string
21 Draft, Prerelease, MakeLatest bool
22 // TargetCommitish is the commit at publication, empty for an unresolved draft.
23 TargetCommitish string
24 Author string
25 CreatedAt, PublishedAt, UpdatedAt time.Time
26 Version uint64
27}
28
29// ReleaseAssetState distinguishes reserved uploads from visible assets.
30type ReleaseAssetState string
31
32const (
33 ReleaseAssetUploading ReleaseAssetState = "uploading"
34 ReleaseAssetUploaded ReleaseAssetState = "uploaded"
35)
36
37// ReleaseAsset is metadata only; its contents live in the blob store.
38type ReleaseAsset struct {
39 ID, ReleaseID int64
40 Repo RepoName
41 Name, Label, ContentType string
42 Size int64
43 Digest string
44 State ReleaseAssetState
45 Uploader string
46 // Charged counts bytes reserved against repository quota.
47 Charged int64
48 // MultipartID identifies an unfinished upload for cleanup.
49 MultipartID string
50 CreatedAt, UpdatedAt time.Time
51 Version uint64
52}
53
54// BlobKey contains only a globally allocated identifier.
55func (a ReleaseAsset) BlobKey() string { return fmt.Sprintf("release/%020d", a.ID) }
56
57const (
58 ReleaseMaxTitleLen = 256
59 ReleaseMaxNotesLen = 125_000
60 ReleaseAssetMaxNameLen = 255
61 ReleaseAssetMaxLabelLen = 255
62 ReleaseAssetPendingMax = 24 * time.Hour
63 DefaultReleaseAssetMax = 2 << 30
64 DefaultReleaseAssetsMax = 1000
65 DefaultReleaseQuota = 20 << 30
66)
67
68// ParseReleaseTag validates a short tag using Git reference-name rules.
69func ParseReleaseTag(s string) (string, error) {
70 if s == "" {
71 return "", fmt.Errorf("%w: empty tag name", ErrInvalid)
72 }
73 if strings.HasPrefix(s, "refs/") {
74 return "", fmt.Errorf("%w: %q: give the short tag name", ErrInvalid, s)
75 }
76 if reason := refNameProblem(s); reason != "" {
77 return "", fmt.Errorf("%w: %q: %s", ErrInvalid, s, reason)
78 }
79 return s, nil
80}
81
82func cleanReleaseText(s string, limit int, multiline bool) (string, error) {
83 if !utf8.ValidString(s) {
84 return "", fmt.Errorf("%w: invalid UTF-8", ErrInvalid)
85 }
86 s = norm.NFC.String(s)
87 s = strings.Map(func(r rune) rune {
88 if multiline && (r == '\n' || r == '\t' || r == '\r') {
89 return r
90 }
91 if unicode.IsControl(r) || (!multiline && isBidiControl(r)) {
92 return -1
93 }
94 return r
95 }, s)
96 if utf8.RuneCountInString(s) > limit {
97 return "", fmt.Errorf("%w: text exceeds %d characters", ErrInvalid, limit)
98 }
99 return s, nil
100}
101
102// ParseReleaseTitle normalizes and cleans an optional title.
103func ParseReleaseTitle(s string) (string, error) {
104 return cleanReleaseText(s, ReleaseMaxTitleLen, false)
105}
106
107// ParseReleaseNotes validates and cleans multiline notes.
108func ParseReleaseNotes(s string) (string, error) {
109 return cleanReleaseText(s, ReleaseMaxNotesLen, true)
110}
111
112// ParseReleaseAssetLabel normalizes and cleans an optional label.
113func ParseReleaseAssetLabel(s string) (string, error) {
114 return cleanReleaseText(s, ReleaseAssetMaxLabelLen, false)
115}
116
117// ParseReleaseAssetName validates a single filename without rewriting it.
118func ParseReleaseAssetName(s string) (string, error) {
119 if !utf8.ValidString(s) || s == "" || s == "." || s == ".." || utf8.RuneCountInString(s) > ReleaseAssetMaxNameLen || strings.ContainsAny(s, "/\\") {
120 return "", fmt.Errorf("%w: invalid asset name", ErrInvalid)
121 }
122 for _, r := range s {
123 if unicode.IsControl(r) {
124 return "", fmt.Errorf("%w: control character in asset name", ErrInvalid)
125 }
126 }
127 return s, nil
128}
129
130// ParseReleaseContentType validates a printable ASCII media type.
131func ParseReleaseContentType(s string) (string, error) {
132 if s == "" {
133 return "application/octet-stream", nil
134 }
135 if len(s) > 255 {
136 return "", fmt.Errorf("%w: media type too long", ErrInvalid)
137 }
138 for _, r := range s {
139 if r < 32 || r > 126 {
140 return "", fmt.Errorf("%w: invalid media type", ErrInvalid)
141 }
142 }
143 if _, _, err := mime.ParseMediaType(s); err != nil {
144 return "", fmt.Errorf("%w: media type: %v", ErrInvalid, err)
145 }
146 return s, nil
147}
148
149// Published reports whether the release is public to repository readers.
150func (r Release) Published() bool { return !r.Draft }
151
152// LatestRelease selects a pin first, then the newest stable published release.
153func LatestRelease(rs []Release) (Release, bool) {
154 var best Release
155 ok := false
156 for _, r := range rs {
157 if r.Draft || r.Prerelease {
158 continue
159 }
160 if !ok || (r.MakeLatest && !best.MakeLatest) || (r.MakeLatest == best.MakeLatest && (r.CreatedAt.After(best.CreatedAt) || (r.CreatedAt.Equal(best.CreatedAt) && r.ID > best.ID))) {
161 best, ok = r, true
162 }
163 }
164 return best, ok
165}