1package validator
2
3import (
4 "context"
5 "fmt"
6 "regexp"
7 "strings"
8
9 "github.com/bluesky-social/indigo/atproto/syntax"
10 "golang.org/x/exp/slices"
11 "tangled.org/core/api/tangled"
12 "tangled.org/core/appview/models"
13)
14
15var (
16 // Label name should be alphanumeric with hyphens/underscores, but not start/end with them
17 labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`)
18 // Color should be a valid hex color
19 colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
20 // You can only label issues and pulls presently
21 validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22)
23
24func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error {
25 if label.Name == "" {
26 return fmt.Errorf("label name is empty")
27 }
28 if len(label.Name) > 40 {
29 return fmt.Errorf("label name too long (max 40 graphemes)")
30 }
31 if len(label.Name) < 1 {
32 return fmt.Errorf("label name too short (min 1 grapheme)")
33 }
34 if !labelNameRegex.MatchString(label.Name) {
35 return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)")
36 }
37
38 if !label.ValueType.IsConcreteType() {
39 return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type)
40 }
41
42 // null type checks: cannot be enums, multiple or explicit format
43 if label.ValueType.IsNull() && label.ValueType.IsEnum() {
44 return fmt.Errorf("null type cannot be used in conjunction with enum type")
45 }
46 if label.ValueType.IsNull() && label.Multiple {
47 return fmt.Errorf("null type labels cannot be multiple")
48 }
49 if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() {
50 return fmt.Errorf("format cannot be used in conjunction with null type")
51 }
52
53 // format checks: cannot be used with enum, or integers
54 if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() {
55 return fmt.Errorf("enum types cannot be used in conjunction with format specification")
56 }
57
58 if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() {
59 return fmt.Errorf("format specifications are only permitted on string types")
60 }
61
62 // validate scope (nsid format)
63 if label.Scope == nil {
64 return fmt.Errorf("scope is required")
65 }
66 for _, s := range label.Scope {
67 if _, err := syntax.ParseNSID(s); err != nil {
68 return fmt.Errorf("failed to parse scope: %w", err)
69 }
70 if !slices.Contains(validScopes, s) {
71 return fmt.Errorf("invalid scope: scope must be present in %q", validScopes)
72 }
73 }
74
75 // validate color if provided
76 if label.Color != nil {
77 color := strings.TrimSpace(*label.Color)
78 if color == "" {
79 // empty color is fine, set to nil
80 label.Color = nil
81 } else {
82 if !colorRegex.MatchString(color) {
83 return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)")
84 }
85 // expand 3-digit hex to 6-digit hex
86 if len(color) == 4 { // #ABC
87 color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3])
88 }
89 // convert to uppercase for consistency
90 color = strings.ToUpper(color)
91 label.Color = &color
92 }
93 }
94
95 return nil
96}
97
98func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
99 if labelDef == nil {
100 return fmt.Errorf("label definition is required")
101 }
102 if repo == nil {
103 return fmt.Errorf("repo is required")
104 }
105 if labelOp == nil {
106 return fmt.Errorf("label operation is required")
107 }
108
109 // validate permissions: only collaborators can apply labels currently
110 //
111 // TODO: introduce a repo:triage permission
112 ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
113 if err != nil {
114 return fmt.Errorf("failed to enforce permissions: %w", err)
115 }
116 if !ok {
117 return fmt.Errorf("unauhtorized label operation")
118 }
119
120 expectedKey := labelDef.AtUri().String()
121 if labelOp.OperandKey != expectedKey {
122 return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey)
123 }
124
125 if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel {
126 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation)
127 }
128
129 if labelOp.Subject == "" {
130 return fmt.Errorf("subject URI is required")
131 }
132 if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil {
133 return fmt.Errorf("invalid subject URI: %w", err)
134 }
135
136 if err := v.validateOperandValue(labelDef, labelOp); err != nil {
137 return fmt.Errorf("invalid operand value: %w", err)
138 }
139
140 // Validate performed time is not zero/invalid
141 if labelOp.PerformedAt.IsZero() {
142 return fmt.Errorf("performed_at timestamp is required")
143 }
144
145 return nil
146}
147
148func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
149 valueType := labelDef.ValueType
150
151 // this is permitted, it "unsets" a label
152 if labelOp.OperandValue == "" {
153 labelOp.Operation = models.LabelOperationDel
154 return nil
155 }
156
157 switch valueType.Type {
158 case models.ConcreteTypeNull:
159 // For null type, value should be empty
160 if labelOp.OperandValue != "null" {
161 return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue)
162 }
163
164 case models.ConcreteTypeString:
165 // For string type, validate enum constraints if present
166 if valueType.IsEnum() {
167 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
168 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
169 }
170 }
171
172 switch valueType.Format {
173 case models.ValueTypeFormatDid:
174 id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue)
175 if err != nil {
176 return fmt.Errorf("failed to resolve did/handle: %w", err)
177 }
178
179 labelOp.OperandValue = id.DID.String()
180
181 case models.ValueTypeFormatAny, "":
182 default:
183 return fmt.Errorf("unsupported format constraint: %q", valueType.Format)
184 }
185
186 case models.ConcreteTypeInt:
187 if labelOp.OperandValue == "" {
188 return fmt.Errorf("integer type requires non-empty value")
189 }
190 if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil {
191 return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue)
192 }
193
194 if valueType.IsEnum() {
195 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
196 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
197 }
198 }
199
200 case models.ConcreteTypeBool:
201 if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" {
202 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue)
203 }
204
205 // validate enum constraints if present (though uncommon for booleans)
206 if valueType.IsEnum() {
207 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
208 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
209 }
210 }
211
212 default:
213 return fmt.Errorf("unsupported value type: %q", valueType.Type)
214 }
215
216 return nil
217}