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}