forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
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.sh/tangled.sh/core/api/tangled"
12 "tangled.sh/tangled.sh/core/appview/db"
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 = []syntax.NSID{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22)
23
24func (v *Validator) ValidateLabelDefinition(label *db.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)
40 }
41
42 if label.ValueType.IsNull() && label.ValueType.IsEnumType() {
43 return fmt.Errorf("null type cannot be used in conjunction with enum type")
44 }
45
46 // validate scope (nsid format)
47 if label.Scope == "" {
48 return fmt.Errorf("scope is required")
49 }
50 if _, err := syntax.ParseNSID(string(label.Scope)); err != nil {
51 return fmt.Errorf("failed to parse scope: %w", err)
52 }
53 if !slices.Contains(validScopes, label.Scope) {
54 return fmt.Errorf("invalid scope: scope must be one of %q", validScopes)
55 }
56
57 // validate color if provided
58 if label.Color != nil {
59 color := strings.TrimSpace(*label.Color)
60 if color == "" {
61 // empty color is fine, set to nil
62 label.Color = nil
63 } else {
64 if !colorRegex.MatchString(color) {
65 return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)")
66 }
67 // expand 3-digit hex to 6-digit hex
68 if len(color) == 4 { // #ABC
69 color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3])
70 }
71 // convert to uppercase for consistency
72 color = strings.ToUpper(color)
73 label.Color = &color
74 }
75 }
76
77 return nil
78}
79
80func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
81 if labelDef == nil {
82 return fmt.Errorf("label definition is required")
83 }
84 if labelOp == nil {
85 return fmt.Errorf("label operation is required")
86 }
87
88 expectedKey := labelDef.AtUri().String()
89 if labelOp.OperandKey != expectedKey {
90 return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey)
91 }
92
93 if labelOp.Operation != db.LabelOperationAdd && labelOp.Operation != db.LabelOperationDel {
94 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation)
95 }
96
97 if labelOp.Subject == "" {
98 return fmt.Errorf("subject URI is required")
99 }
100 if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil {
101 return fmt.Errorf("invalid subject URI: %w", err)
102 }
103
104 if err := v.validateOperandValue(labelDef, labelOp); err != nil {
105 return fmt.Errorf("invalid operand value: %w", err)
106 }
107
108 // Validate performed time is not zero/invalid
109 if labelOp.PerformedAt.IsZero() {
110 return fmt.Errorf("performed_at timestamp is required")
111 }
112
113 return nil
114}
115
116func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
117 valueType := labelDef.ValueType
118
119 switch valueType.Type {
120 case db.ConcreteTypeNull:
121 // For null type, value should be empty
122 if labelOp.OperandValue != "null" {
123 return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue)
124 }
125
126 case db.ConcreteTypeString:
127 // For string type, validate enum constraints if present
128 if valueType.IsEnumType() {
129 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
130 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
131 }
132 }
133
134 switch valueType.Format {
135 case db.ValueTypeFormatDid:
136 id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue)
137 if err != nil {
138 return fmt.Errorf("failed to resolve did/handle: %w", err)
139 }
140
141 labelOp.OperandValue = id.DID.String()
142
143 case db.ValueTypeFormatAny, "":
144 default:
145 return fmt.Errorf("unsupported format constraint: %q", valueType.Format)
146 }
147
148 case db.ConcreteTypeInt:
149 if labelOp.OperandValue == "" {
150 return fmt.Errorf("integer type requires non-empty value")
151 }
152 if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil {
153 return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue)
154 }
155
156 if valueType.IsEnumType() {
157 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
158 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
159 }
160 }
161
162 case db.ConcreteTypeBool:
163 if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" {
164 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue)
165 }
166
167 // validate enum constraints if present (though uncommon for booleans)
168 if valueType.IsEnumType() {
169 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
170 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
171 }
172 }
173
174 default:
175 return fmt.Errorf("unsupported value type: %q", valueType.Type)
176 }
177
178 return nil
179}