Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218package validator
import ( "context" "fmt" "regexp" "slices" "strings"
"github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" "tangled.org/core/appview/models")
var ( // Label name should be alphanumeric with hyphens/underscores, but not start/end with them labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) // Color should be a valid hex color colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) // You can only label issues and pulls presently validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID})
func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { if label.Name == "" { return fmt.Errorf("label name is empty") } if len(label.Name) > 40 { return fmt.Errorf("label name too long (max 40 graphemes)") } if len(label.Name) < 1 { return fmt.Errorf("label name too short (min 1 grapheme)") } if !labelNameRegex.MatchString(label.Name) { return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") }
if !label.ValueType.IsConcreteType() { return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) }
// null type checks: cannot be enums, multiple or explicit format if label.ValueType.IsNull() && label.ValueType.IsEnum() { return fmt.Errorf("null type cannot be used in conjunction with enum type") } if label.ValueType.IsNull() && label.Multiple { return fmt.Errorf("null type labels cannot be multiple") } if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { return fmt.Errorf("format cannot be used in conjunction with null type") }
// format checks: cannot be used with enum, or integers if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { return fmt.Errorf("enum types cannot be used in conjunction with format specification") }
if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { return fmt.Errorf("format specifications are only permitted on string types") }
// validate scope (nsid format) if label.Scope == nil { return fmt.Errorf("scope is required") } for _, s := range label.Scope { if _, err := syntax.ParseNSID(s); err != nil { return fmt.Errorf("failed to parse scope: %w", err) } if !slices.Contains(validScopes, s) { return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) } }
// validate color if provided if label.Color != nil { color := strings.TrimSpace(*label.Color) if color == "" { // empty color is fine, set to nil label.Color = nil } else { if !colorRegex.MatchString(color) { return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") } // expand 3-digit hex to 6-digit hex if len(color) == 4 { // #ABC color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) } // convert to uppercase for consistency color = strings.ToUpper(color) label.Color = &color } }
return nil}
func (v *Validator) ValidateLabelOp(ctx context.Context, labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { if labelDef == nil { return fmt.Errorf("label definition is required") } if repo == nil { return fmt.Errorf("repo is required") } if labelOp == nil { return fmt.Errorf("label operation is required") }
expectedKey := labelDef.AtUri().String() if labelOp.OperandKey != expectedKey { return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) }
if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) }
if labelOp.Subject == "" { return fmt.Errorf("subject URI is required") } if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { return fmt.Errorf("invalid subject URI: %w", err) }
if err := v.validateOperandValue(labelDef, labelOp); err != nil { return fmt.Errorf("invalid operand value: %w", err) }
// Validate performed time is not zero/invalid if labelOp.PerformedAt.IsZero() { return fmt.Errorf("performed_at timestamp is required") }
// validate permissions: only collaborators can apply labels currently // // TODO: introduce a repo:triage permission ok, err := v.acl.HasRepoPermissionErr(ctx, repo, labelOp.Did, "repo:push") if err != nil { return err } if !ok { return fmt.Errorf("unauthorized label operation") }
return nil}
func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { valueType := labelDef.ValueType
// this is permitted, it "unsets" a label if labelOp.OperandValue == "" { labelOp.Operation = models.LabelOperationDel return nil }
switch valueType.Type { case models.ConcreteTypeNull: // For null type, value should be empty if labelOp.OperandValue != "null" { return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) }
case models.ConcreteTypeString: // For string type, validate enum constraints if present if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) } }
switch valueType.Format { case models.ValueTypeFormatDid: id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) if err != nil { return fmt.Errorf("failed to resolve did/handle: %w", err) }
labelOp.OperandValue = id.DID.String()
case models.ValueTypeFormatAny, "": default: return fmt.Errorf("unsupported format constraint: %q", valueType.Format) }
case models.ConcreteTypeInt: if labelOp.OperandValue == "" { return fmt.Errorf("integer type requires non-empty value") } if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) }
if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) } }
case models.ConcreteTypeBool: if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) }
// validate enum constraints if present (though uncommon for booleans) if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) } }
default: return fmt.Errorf("unsupported value type: %q", valueType.Type) }
return nil}