1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "errors"
7 "fmt"
8 "strings"
9 "unicode"
10 "unicode/utf8"
11
12 "golang.org/x/text/unicode/norm"
13)
14
15// Limits of the plain text of a merge request (ADR 0017), in characters.
16const (
17 // MaxTitleLen is the longest merge request title.
18 MaxTitleLen = 120
19 // MaxTextLen is the longest description or comment.
20 MaxTextLen = 8000
21)
22
23// ErrInvalidText means a text breaks the rules of ParseMRTitle or
24// ParsePlainText.
25var ErrInvalidText = errors.New("invalid text")
26
27// TextError says why a text is invalid. It matches ErrInvalidText.
28type TextError struct {
29 // Reason completes the sentence "the text ...", for example "is not valid
30 // UTF-8".
31 Reason string
32}
33
34// Error implements error.
35func (e *TextError) Error() string { return "text " + e.Reason }
36
37// Unwrap makes errors.Is(err, ErrInvalidText) true.
38func (e *TextError) Unwrap() error { return ErrInvalidText }
39
40// MRTitle is a validated merge request title: one line of plain text, at most
41// MaxTitleLen characters. Only ParseMRTitle builds a non-zero value.
42type MRTitle struct{ s string }
43
44// ParseMRTitle validates s: not blank, valid UTF-8, one line, at most
45// MaxTitleLen characters, no control character and no bidirectional override
46// or isolate character.
47func ParseMRTitle(s string) (MRTitle, error) {
48 if reason := textProblem(s, false, MaxTitleLen); reason != "" {
49 return MRTitle{}, &TextError{Reason: reason}
50 }
51 if isBlank(s) {
52 return MRTitle{}, &TextError{Reason: "is empty"}
53 }
54 return MRTitle{s: s}, nil
55}
56
57// String returns the title.
58func (t MRTitle) String() string { return t.s }
59
60// IsZero reports whether t is the zero value.
61func (t MRTitle) IsZero() bool { return t.s == "" }
62
63// PlainText is a validated multi-line plain text, such as a description or a
64// comment: valid UTF-8, at most MaxTextLen characters, line breaks allowed,
65// no other control character, no U+2028 or U+2029, no bidirectional mark,
66// override or isolate character.
67// The zero value is the empty text.
68type PlainText struct{ s string }
69
70// ParsePlainText validates s. The empty text is valid: a caller that needs
71// text checks IsBlank.
72func ParsePlainText(s string) (PlainText, error) {
73 if reason := textProblem(s, true, MaxTextLen); reason != "" {
74 return PlainText{}, &TextError{Reason: reason}
75 }
76 return PlainText{s: s}, nil
77}
78
79// String returns the text.
80func (t PlainText) String() string { return t.s }
81
82// IsBlank reports whether the text is empty or only white space.
83func (t PlainText) IsBlank() bool { return isBlank(t.s) }
84
85func isBlank(s string) bool {
86 for _, r := range s {
87 if !unicode.IsSpace(r) {
88 return false
89 }
90 }
91 return true
92}
93
94// textProblem returns why s breaks the rules, or "".
95func textProblem(s string, multiline bool, maxLen int) string {
96 if !utf8.ValidString(s) {
97 return "is not valid UTF-8"
98 }
99 if n := utf8.RuneCountInString(s); n > maxLen {
100 return fmt.Sprintf("is longer than %d characters (%d)", maxLen, n)
101 }
102 for _, r := range s {
103 switch {
104 case r == '\n':
105 if !multiline {
106 return "must be one line"
107 }
108 case unicode.IsControl(r):
109 return fmt.Sprintf("contains the control character U+%04X", r)
110 case r == '\u2028' || r == '\u2029':
111 return fmt.Sprintf("contains the line or paragraph separator U+%04X", r)
112 case isBidiControl(r):
113 return fmt.Sprintf("contains the bidirectional override or isolate character U+%04X", r)
114 }
115 }
116 return ""
117}
118
119// isBidiControl reports whether r is a bidirectional mark, embedding, override
120// or isolate character (U+200E, U+200F, U+061C, U+202A to U+202E, U+2066 to
121// U+2069), which can make text read differently from how it is stored.
122func isBidiControl(r rune) bool {
123 return r == 0x200e || r == 0x200f || r == 0x061c ||
124 (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069)
125}
126
127// FieldViolation names a request field that breaks a rule.
128type FieldViolation struct {
129 // Field is the name of the field in the API contract, for example
130 // "title".
131 Field string
132 // Reason says what is wrong with it.
133 Reason string
134}
135
136// InvalidFieldsError is a request rejected whole because of its fields. It
137// matches ErrInvalid.
138type InvalidFieldsError struct {
139 Fields []FieldViolation
140}
141
142// Error implements error.
143func (e *InvalidFieldsError) Error() string {
144 msg := "invalid argument"
145 for i, f := range e.Fields {
146 sep := "; "
147 if i == 0 {
148 sep = ": "
149 }
150 msg += sep + f.Field + " " + f.Reason
151 }
152 return msg
153}
154
155// Unwrap makes errors.Is(err, ErrInvalid) true.
156func (e *InvalidFieldsError) Unwrap() error { return ErrInvalid }
157
158// Violations collects the violations of a request.
159type Violations struct{ list []FieldViolation }
160
161// Add records that field breaks a rule.
162func (v *Violations) Add(field, reason string) {
163 v.list = append(v.list, FieldViolation{Field: field, Reason: reason})
164}
165
166// AddErr records an error of a parser for field: the reason of a *TextError,
167// else the text of the error.
168func (v *Violations) AddErr(field string, err error) {
169 var te *TextError
170 if errors.As(err, &te) {
171 v.Add(field, te.Reason)
172 return
173 }
174 v.Add(field, err.Error())
175}
176
177// Err returns nil without violations, else an *InvalidFieldsError.
178func (v *Violations) Err() error {
179 if len(v.list) == 0 {
180 return nil
181 }
182 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
183}
184
185// Limits of the text of an issue, in code points.
186const (
187 // MaxIssueTitleLen is the longest issue title.
188 MaxIssueTitleLen = 120
189 // MaxIssueBodyLen is the longest issue body or issue comment.
190 MaxIssueBodyLen = 10000
191 // MaxAuthorLabelLen is the longest author label.
192 MaxAuthorLabelLen = 40
193)
194
195// IssueTitle is a validated issue title: one line, 1 to MaxIssueTitleLen code
196// points, normalized. Only ParseIssueTitle builds a non-zero value.
197type IssueTitle struct{ s string }
198
199// String returns the title.
200func (t IssueTitle) String() string { return t.s }
201
202// IsZero reports whether t is the zero value.
203func (t IssueTitle) IsZero() bool { return t.s == "" }
204
205// IssueBody is a validated issue body or issue comment: at most
206// MaxIssueBodyLen code points, normalized, line breaks and tabs allowed. The
207// zero value is the empty text.
208type IssueBody struct{ s string }
209
210// String returns the text.
211func (b IssueBody) String() string { return b.s }
212
213// IsBlank reports whether the text is empty or only white space.
214func (b IssueBody) IsBlank() bool { return isBlank(b.s) }
215
216// ParseIssueTitle cleans s (see cleanText) and validates it: one line, not
217// blank, at most MaxIssueTitleLen code points. A line break is refused, not
218// removed, so that two lines are never glued into one.
219func ParseIssueTitle(s string) (IssueTitle, error) {
220 if !utf8.ValidString(s) {
221 return IssueTitle{}, &TextError{Reason: "is not valid UTF-8"}
222 }
223 if strings.ContainsAny(s, "\r\n\u2028\u2029") {
224 return IssueTitle{}, &TextError{Reason: "must be one line"}
225 }
226 c := strings.TrimSpace(cleanText(s, false))
227 if c == "" {
228 return IssueTitle{}, &TextError{Reason: "is empty"}
229 }
230 if n := utf8.RuneCountInString(c); n > MaxIssueTitleLen {
231 return IssueTitle{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueTitleLen, n)}
232 }
233 return IssueTitle{s: c}, nil
234}
235
236// ParseIssueBody cleans s (see cleanText, line breaks and tabs are kept) and
237// validates it: at most MaxIssueBodyLen code points. The empty text is valid.
238func ParseIssueBody(s string) (IssueBody, error) {
239 if !utf8.ValidString(s) {
240 return IssueBody{}, &TextError{Reason: "is not valid UTF-8"}
241 }
242 c := cleanText(s, true)
243 if n := utf8.RuneCountInString(c); n > MaxIssueBodyLen {
244 return IssueBody{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueBodyLen, n)}
245 }
246 return IssueBody{s: c}, nil
247}
248
249// ParseAuthorLabel cleans s like a title (one line) and validates it: at most
250// MaxAuthorLabelLen code points. The empty label is valid.
251func ParseAuthorLabel(s string) (string, error) {
252 if !utf8.ValidString(s) {
253 return "", &TextError{Reason: "is not valid UTF-8"}
254 }
255 if strings.ContainsAny(s, "\r\n\u2028\u2029") {
256 return "", &TextError{Reason: "must be one line"}
257 }
258 c := strings.TrimSpace(cleanText(s, false))
259 if n := utf8.RuneCountInString(c); n > MaxAuthorLabelLen {
260 return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxAuthorLabelLen, n)}
261 }
262 return c, nil
263}
264
265// cleanText normalizes s to NFC and removes the control characters (C0, C1 and
266// DEL; with multiline, line feeds and tabs stay) and the bidirectional marks,
267// embeddings, overrides and isolates. s must be valid UTF-8.
268func cleanText(s string, multiline bool) string {
269 s = norm.NFC.String(s)
270 if multiline {
271 s = strings.ReplaceAll(s, "\r\n", "\n")
272 }
273 return strings.Map(func(r rune) rune {
274 switch {
275 case multiline && (r == '\n' || r == '\t'):
276 return r
277 case unicode.IsControl(r), isBidiControl(r), r == '\u2028' || r == '\u2029':
278 return -1
279 }
280 return r
281 }, s)
282}