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.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, labelOp *models.LabelOp) error { 99 if labelDef == nil { 100 return fmt.Errorf("label definition is required") 101 } 102 if labelOp == nil { 103 return fmt.Errorf("label operation is required") 104 } 105 106 expectedKey := labelDef.AtUri().String() 107 if labelOp.OperandKey != expectedKey { 108 return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 109 } 110 111 if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 112 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 113 } 114 115 if labelOp.Subject == "" { 116 return fmt.Errorf("subject URI is required") 117 } 118 if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { 119 return fmt.Errorf("invalid subject URI: %w", err) 120 } 121 122 if err := v.validateOperandValue(labelDef, labelOp); err != nil { 123 return fmt.Errorf("invalid operand value: %w", err) 124 } 125 126 // Validate performed time is not zero/invalid 127 if labelOp.PerformedAt.IsZero() { 128 return fmt.Errorf("performed_at timestamp is required") 129 } 130 131 return nil 132} 133 134func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 135 valueType := labelDef.ValueType 136 137 // this is permitted, it "unsets" a label 138 if labelOp.OperandValue == "" { 139 labelOp.Operation = models.LabelOperationDel 140 return nil 141 } 142 143 switch valueType.Type { 144 case models.ConcreteTypeNull: 145 // For null type, value should be empty 146 if labelOp.OperandValue != "null" { 147 return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 148 } 149 150 case models.ConcreteTypeString: 151 // For string type, validate enum constraints if present 152 if valueType.IsEnum() { 153 if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 154 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 155 } 156 } 157 158 switch valueType.Format { 159 case models.ValueTypeFormatDid: 160 id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 161 if err != nil { 162 return fmt.Errorf("failed to resolve did/handle: %w", err) 163 } 164 165 labelOp.OperandValue = id.DID.String() 166 167 case models.ValueTypeFormatAny, "": 168 default: 169 return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 170 } 171 172 case models.ConcreteTypeInt: 173 if labelOp.OperandValue == "" { 174 return fmt.Errorf("integer type requires non-empty value") 175 } 176 if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { 177 return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) 178 } 179 180 if valueType.IsEnum() { 181 if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 182 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 183 } 184 } 185 186 case models.ConcreteTypeBool: 187 if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 188 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 189 } 190 191 // validate enum constraints if present (though uncommon for booleans) 192 if valueType.IsEnum() { 193 if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 194 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 195 } 196 } 197 198 default: 199 return fmt.Errorf("unsupported value type: %q", valueType.Type) 200 } 201 202 return nil 203}