forked from tangled.org/core
this repo has no description
at master 6.9 kB view raw
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}