// Package taskidentity derives workload identities from tasks assigned by Gitea. package taskidentity import ( "bytes" "crypto/sha256" "errors" "fmt" "regexp" "strings" "gitea.dev/actionslib/pkg/model" runnerv1 "gitea.dev/actionslib/runner/v1" ) var safeSegment = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) var safeTaskKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]*$`) // Identity is the trusted identity context extracted from a fetched task. type Identity struct { Repository string Task string SPIFFEID string } // FromMetadata validates identity fields recovered from backend-owned state. func FromMetadata(repository, task, spiffeID, trustDomain string) (Identity, error) { parts := strings.Split(repository, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !safeTaskKey.MatchString(task) { return Identity{}, errors.New("invalid recovered repository or task identity") } expected := "spiffe://" + trustDomain + "/ci/" + strings.Join([]string{ sanitize(parts[0]), sanitize(parts[1]), task, }, "/") if spiffeID != expected { return Identity{}, fmt.Errorf("recovered SPIFFE ID %q does not match %q", spiffeID, expected) } return Identity{Repository: repository, Task: task, SPIFFEID: spiffeID}, nil } // FromTask derives the repository/task SPIFFE ID from Gitea's trusted task // context. Workflow input never supplies or overrides the resulting ID. func FromTask(task *runnerv1.Task, trustDomain string) (Identity, error) { if task == nil || task.Context == nil { return Identity{}, errors.New("task context is required") } repository := strings.TrimSpace(task.Context.GetFields()["repository"].GetStringValue()) taskName, err := workflowTaskKey(task.WorkflowPayload) if err != nil { return Identity{}, err } parts := strings.Split(repository, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return Identity{}, fmt.Errorf("invalid task repository %q", repository) } trustDomain = strings.TrimSpace(trustDomain) if trustDomain == "" || strings.ContainsAny(trustDomain, "/ ") { return Identity{}, fmt.Errorf("invalid trust domain %q", trustDomain) } path := strings.Join([]string{ sanitize(parts[0]), sanitize(parts[1]), taskName, }, "/") return Identity{ Repository: repository, Task: taskName, SPIFFEID: "spiffe://" + trustDomain + "/ci/" + path, }, nil } func workflowTaskKey(payload []byte) (string, error) { workflow, err := model.ReadWorkflow(bytes.NewReader(payload)) if err != nil { return "", fmt.Errorf("parse task workflow: %w", err) } jobIDs := workflow.GetJobIDs() if len(jobIDs) != 1 { return "", fmt.Errorf("task workflow must contain exactly one job, got %d", len(jobIDs)) } if !safeTaskKey.MatchString(jobIDs[0]) { return "", fmt.Errorf("task job key %q must match %s", jobIDs[0], safeTaskKey) } return jobIDs[0], nil } // BackoffTaskSegment deterministically converts a legacy display name into a // collision-resistant path segment. Canonical task identities do not use it. func BackoffTaskSegment(value string) string { return sanitize(value) } func sanitize(value string) string { if safeSegment.MatchString(value) { return value } slug := strings.Trim(regexp.MustCompile(`[^A-Za-z0-9._-]+`).ReplaceAllString(value, "-"), "-._") if len(slug) > 48 { slug = strings.TrimRight(slug[:48], "-._") } if slug == "" { slug = "segment" } digest := fmt.Sprintf("%x", sha256.Sum256([]byte(value)))[:12] return slug + "-" + digest }