package checkpoint import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "path/filepath" "sort" "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) const digestPrefixLength = 16 type Fingerprint struct { Name string `json:"name"` Value string `json:"value"` } type IdentityInput struct { Pipeline pipeline.ResolvedPipeline InputKey, RawInputDigest, SourceDigest string SelectedLanes []string RuntimeOverrides []Fingerprint References []artifacts.ReferenceProvenance ProvenanceFingerprints []Fingerprint } type Identity struct { Digest string `json:"digest"` PipelineID string `json:"pipeline_id"` PipelineDigest string `json:"pipeline_digest"` InputKey string `json:"input_key"` RawInputDigest string `json:"raw_input_digest,omitempty"` SourceDigest string `json:"source_digest,omitempty"` SelectedLanes []string `json:"selected_lanes,omitempty"` RuntimeOverrides []Fingerprint `json:"runtime_overrides,omitempty"` ReferenceDigests []Fingerprint `json:"reference_digests,omitempty"` ProvenanceFingerprints []Fingerprint `json:"provenance_fingerprints,omitempty"` } func NewIdentity(input IdentityInput) (Identity, error) { pipelineID, pipelineDigest, inputKey := strings.TrimSpace(input.Pipeline.ID), strings.TrimSpace(input.Pipeline.Digest), strings.TrimSpace(input.InputKey) if pipelineID == "" { return Identity{}, fmt.Errorf("checkpoint identity pipeline id must not be empty") } if pipelineDigest == "" { return Identity{}, fmt.Errorf("checkpoint identity pipeline digest must not be empty") } if inputKey == "" { inputKey = strings.TrimSpace(input.Pipeline.Input.Module) } if inputKey == "" { return Identity{}, fmt.Errorf("checkpoint identity input key must not be empty") } if strings.TrimSpace(input.RawInputDigest) == "" && strings.TrimSpace(input.SourceDigest) == "" { return Identity{}, fmt.Errorf("checkpoint identity raw input digest or source digest must be set") } v := Identity{PipelineID: pipelineID, PipelineDigest: pipelineDigest, InputKey: inputKey, RawInputDigest: strings.TrimSpace(input.RawInputDigest), SourceDigest: strings.TrimSpace(input.SourceDigest), SelectedLanes: normalizedLanes(input.SelectedLanes, input.Pipeline.ArtifactLanes), RuntimeOverrides: normalizeIdentityFingerprints(input.RuntimeOverrides), ReferenceDigests: referenceFingerprints(input.References), ProvenanceFingerprints: normalizeIdentityFingerprints(input.ProvenanceFingerprints)} data, err := json.Marshal(Identity{PipelineID: v.PipelineID, PipelineDigest: v.PipelineDigest, InputKey: v.InputKey, RawInputDigest: v.RawInputDigest, SourceDigest: v.SourceDigest, SelectedLanes: v.SelectedLanes, RuntimeOverrides: v.RuntimeOverrides, ReferenceDigests: v.ReferenceDigests, ProvenanceFingerprints: v.ProvenanceFingerprints}) if err != nil { return Identity{}, fmt.Errorf("marshal checkpoint identity: %w", err) } sum := sha256.Sum256(data) v.Digest = "sha256:" + hex.EncodeToString(sum[:]) return v, nil } func (i Identity) RelativePath() (string, error) { p, err := safeComponent(i.PipelineID) if err != nil { return "", fmt.Errorf("checkpoint identity pipeline id: %w", err) } k, err := safeComponent(i.InputKey) if err != nil { return "", fmt.Errorf("checkpoint identity input key: %w", err) } s := digestPrefix(i.SourceDigest) if s == "" { s = digestPrefix(i.RawInputDigest) } d := digestPrefix(i.PipelineDigest) x := digestPrefix(i.Digest) if s == "" || d == "" || x == "" { return "", fmt.Errorf("checkpoint identity digest prefix must not be empty") } s, err = safeComponent(s) if err != nil { return "", fmt.Errorf("checkpoint identity source digest: %w", err) } d, err = safeComponent(d) if err != nil { return "", fmt.Errorf("checkpoint identity pipeline digest: %w", err) } x, err = safeComponent(x) if err != nil { return "", fmt.Errorf("checkpoint identity digest: %w", err) } return filepath.ToSlash(filepath.Join(p, k+"-"+s, d, x)), nil } func normalizedLanes(selected []string, resolved []pipeline.ResolvedArtifactLane) []string { if len(selected) == 0 { for _, lane := range resolved { selected = append(selected, lane.ID) } } return normalizeStrings(selected) } func normalizeIdentityFingerprints(values []Fingerprint) []Fingerprint { by := map[string]string{} for _, v := range values { if n, x := strings.TrimSpace(v.Name), strings.TrimSpace(v.Value); n != "" && x != "" { by[n] = x } } names := make([]string, 0, len(by)) for n := range by { names = append(names, n) } sort.Strings(names) out := make([]Fingerprint, 0, len(names)) for _, n := range names { out = append(out, Fingerprint{Name: n, Value: by[n]}) } if len(out) == 0 { return nil } return out } func referenceFingerprints(refs []artifacts.ReferenceProvenance) []Fingerprint { var values []Fingerprint for _, r := range refs { if d := strings.TrimSpace(r.Digest); d != "" { values = append(values, Fingerprint{Name: strings.Join([]string{strings.TrimSpace(r.Stage), strings.TrimSpace(r.LaneID), strings.TrimSpace(r.SlotName), strings.TrimSpace(r.OriginURI)}, ":"), Value: d}) } } return normalizeIdentityFingerprints(values) } func normalizeStrings(values []string) []string { seen := map[string]struct{}{} for _, v := range values { if v = strings.TrimSpace(v); v != "" { seen[v] = struct{}{} } } out := make([]string, 0, len(seen)) for v := range seen { out = append(out, v) } sort.Strings(out) if len(out) == 0 { return nil } return out } func digestPrefix(v string) string { v = strings.TrimSpace(v) if n := strings.Index(v, ":"); n >= 0 { v = v[n+1:] } if len(v) > digestPrefixLength { return v[:digestPrefixLength] } return v } func safeComponent(v string) (string, error) { v = strings.TrimSpace(v) if v == "" { return "", fmt.Errorf("must not be empty") } var b strings.Builder for _, r := range v { if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { b.WriteRune(r) } else { b.WriteString(fmt.Sprintf("~%x", r)) } } out := b.String() if out == "." || out == ".." || strings.Contains(out, "..") || strings.ContainsAny(out, `/\\`) { return "", fmt.Errorf("%q is not filesystem safe", v) } return out, nil }