94 lines
2.7 KiB
Go
94 lines
2.7 KiB
Go
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|