2026-07-05 14:32:35 +00:00
package pipeline
import (
"crypto/sha256"
"encoding/hex"
2026-08-09 00:33:33 +00:00
"errors"
2026-07-05 14:32:35 +00:00
"fmt"
2026-08-09 00:33:33 +00:00
"io"
"math"
2026-07-05 10:53:16 -05:00
"mime"
2026-07-05 14:32:35 +00:00
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
2026-07-05 14:44:44 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
2026-07-05 14:32:35 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
const (
referenceOriginFile = "file"
2026-07-05 10:53:16 -05:00
referenceMediaType = "text/plain"
unknownMediaType = "application/octet-stream"
2026-07-05 14:32:35 +00:00
)
type ReferenceMaterializationOptions struct {
ConfigPath string
WorkingDir string
}
2026-08-27 16:40:17 +00:00
func MaterializeReferences ( resolved ResolvedPipeline , catalog ModuleCatalog , options ReferenceMaterializationOptions ) ( ResolvedPipeline , [ ] contracts . ProducerDiagnostic , error ) {
2026-07-05 14:32:35 +00:00
out := resolved
2026-07-05 16:12:38 +00:00
out . ChunkReferences = CloneReferenceTarget ( resolved . ChunkReferences )
2026-08-27 16:40:17 +00:00
chunkReferenceSet , chunkDiagnostics , err := materializeReferenceTarget ( resolved . ID , resolved . ChunkReferences , "" , catalog , options )
2026-07-05 16:35:24 +00:00
if err != nil {
return ResolvedPipeline { } , nil , err
}
out . ChunkReferences . ReferenceSet = chunkReferenceSet
2026-08-27 16:40:17 +00:00
diagnostics := contracts . CloneProducerDiagnostics ( chunkDiagnostics )
2026-07-21 21:05:28 +00:00
if len ( resolved . Steps ) == 0 {
2026-08-27 16:40:17 +00:00
return out , diagnostics , nil
2026-07-05 14:32:35 +00:00
}
2026-08-27 16:40:17 +00:00
materializeLane := func ( lane ResolvedArtifactLane ) ( ResolvedArtifactLane , [ ] contracts . ProducerDiagnostic , error ) {
2026-07-05 14:32:35 +00:00
materializedLane := lane
2026-08-27 16:40:17 +00:00
var allDiagnostics [ ] contracts . ProducerDiagnostic
2026-07-05 16:12:38 +00:00
materializedLane . ExtractReferences = CloneReferenceTarget ( lane . ExtractReferences )
2026-07-07 19:10:55 +00:00
materializedLane . MergeReferences = CloneReferenceTarget ( lane . MergeReferences )
2026-07-05 16:12:38 +00:00
materializedLane . NormalizeReferences = CloneReferenceTarget ( lane . NormalizeReferences )
2026-08-27 16:40:17 +00:00
extractReferenceSet , laneDiagnostics , err := materializeReferenceTarget ( resolved . ID , lane . ExtractReferences , lane . ArtifactKind , catalog , options )
2026-07-05 14:32:35 +00:00
if err != nil {
2026-07-21 21:05:28 +00:00
return ResolvedArtifactLane { } , nil , err
2026-07-05 14:32:35 +00:00
}
2026-07-05 16:35:24 +00:00
materializedLane . ExtractReferences . ReferenceSet = extractReferenceSet
2026-08-27 16:40:17 +00:00
allDiagnostics = append ( allDiagnostics , laneDiagnostics ... )
2026-07-05 16:35:24 +00:00
2026-08-27 16:40:17 +00:00
mergeReferenceSet , laneDiagnostics , err := materializeReferenceTarget ( resolved . ID , lane . MergeReferences , lane . ArtifactKind , catalog , options )
2026-07-07 19:10:55 +00:00
if err != nil {
2026-07-21 21:05:28 +00:00
return ResolvedArtifactLane { } , nil , err
2026-07-07 19:10:55 +00:00
}
materializedLane . MergeReferences . ReferenceSet = mergeReferenceSet
2026-08-27 16:40:17 +00:00
allDiagnostics = append ( allDiagnostics , laneDiagnostics ... )
2026-07-07 19:10:55 +00:00
2026-08-27 16:40:17 +00:00
normalizeReferenceSet , laneDiagnostics , err := materializeReferenceTarget ( resolved . ID , lane . NormalizeReferences , lane . ArtifactKind , catalog , options )
2026-07-05 16:35:24 +00:00
if err != nil {
2026-07-21 21:05:28 +00:00
return ResolvedArtifactLane { } , nil , err
2026-07-05 16:35:24 +00:00
}
materializedLane . NormalizeReferences . ReferenceSet = normalizeReferenceSet
2026-08-27 16:40:17 +00:00
allDiagnostics = append ( allDiagnostics , laneDiagnostics ... )
return materializedLane , allDiagnostics , nil
2026-07-21 21:05:28 +00:00
}
if len ( resolved . Steps ) > 0 {
out . Steps = make ( [ ] ResolvedPipelineStep , len ( resolved . Steps ) )
for i , step := range resolved . Steps {
out . Steps [ i ] . ID = step . ID
out . Steps [ i ] . ArtifactLanes = make ( [ ] ResolvedArtifactLane , len ( step . ArtifactLanes ) )
for j , lane := range step . ArtifactLanes {
2026-08-27 16:40:17 +00:00
materializedLane , laneDiagnostics , err := materializeLane ( lane )
2026-07-21 21:05:28 +00:00
if err != nil {
return ResolvedPipeline { } , nil , err
}
out . Steps [ i ] . ArtifactLanes [ j ] = materializedLane
2026-08-27 16:40:17 +00:00
diagnostics = append ( diagnostics , laneDiagnostics ... )
2026-07-21 21:05:28 +00:00
}
}
2026-07-05 14:32:35 +00:00
}
2026-08-27 16:40:17 +00:00
return out , diagnostics , nil
2026-08-27 15:33:13 +00:00
}
2026-07-05 16:12:38 +00:00
func materializeReferenceTarget (
2026-07-05 14:32:35 +00:00
pipelineID string ,
2026-07-05 16:12:38 +00:00
target ResolvedReferenceTarget ,
2026-07-17 13:53:55 +00:00
artifactKind contracts . ArtifactKind ,
2026-07-05 14:32:35 +00:00
catalog ModuleCatalog ,
options ReferenceMaterializationOptions ,
2026-08-27 16:40:17 +00:00
) ( contracts . ReferenceSet , [ ] contracts . ProducerDiagnostic , error ) {
2026-07-05 16:12:38 +00:00
if len ( target . Bindings ) == 0 {
2026-07-05 14:32:35 +00:00
return contracts . ReferenceSet { } , nil , nil
}
2026-07-17 13:53:55 +00:00
spec , err := referenceTargetSpec ( target , artifactKind , catalog )
2026-07-05 16:12:38 +00:00
if err != nil {
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s: %w" , referenceTargetContext ( pipelineID , target ) , err )
2026-07-05 14:32:35 +00:00
}
slotByName := make ( map [ string ] contracts . ReferenceSlot , len ( spec . ReferenceSlots ) )
for _ , slot := range spec . ReferenceSlots {
slotByName [ slot . Name ] = slot
}
2026-07-05 16:12:38 +00:00
set := contracts . ReferenceSet { Slots : make ( map [ string ] contracts . ResolvedReferenceSlot , len ( target . Bindings ) ) }
2026-08-27 16:40:17 +00:00
var diagnostics [ ] contracts . ProducerDiagnostic
2026-07-05 16:12:38 +00:00
for _ , binding := range target . Bindings {
2026-07-05 14:32:35 +00:00
slotName := strings . TrimSpace ( binding . SlotName )
slot , ok := slotByName [ slotName ]
if ! ok {
2026-07-05 16:12:38 +00:00
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s reference slot %q is not declared by %s module %q" , referenceTargetContext ( pipelineID , target ) , slotName , target . Stage , target . Module )
2026-07-05 14:32:35 +00:00
}
2026-07-21 21:05:28 +00:00
if binding . Artifact != nil {
2026-07-21 21:39:23 +00:00
// Generated selectors are resolved at the step handoff. Keep the
// declared slot and its constraints, but do not materialize bytes yet.
set . Slots [ slotName ] = contracts . ResolvedReferenceSlot { Slot : cloneReferenceSlot ( slot ) }
2026-07-21 21:05:28 +00:00
continue
}
2026-07-05 14:32:35 +00:00
path , err := referencePath ( binding , options )
if err != nil {
2026-07-05 16:12:38 +00:00
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s reference slot %q path %q: %w" , referenceTargetContext ( pipelineID , target ) , slotName , binding . Source , err )
2026-07-05 14:32:35 +00:00
}
2026-08-09 00:33:33 +00:00
file , err := os . Open ( path )
2026-07-05 14:32:35 +00:00
if err != nil {
2026-07-05 16:12:38 +00:00
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s reference slot %q read %q: %w" , referenceTargetContext ( pipelineID , target ) , slotName , path , err )
2026-07-05 14:32:35 +00:00
}
2026-08-09 00:33:33 +00:00
regularFileSize := int64 ( 0 )
if info , statErr := file . Stat ( ) ; statErr == nil && info . Mode ( ) . IsRegular ( ) {
regularFileSize = info . Size ( )
}
content , err := readReferenceContent ( file , slot . MaxBytes )
closeErr := file . Close ( )
if err == nil && closeErr != nil {
err = closeErr
}
if err != nil {
var sizeErr * referenceSizeLimitError
if errors . As ( err , & sizeErr ) {
sizeBytes := sizeErr . SizeBytes
if regularFileSize > sizeBytes {
sizeBytes = regularFileSize
}
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s reference slot %q path %q is %d bytes, limit %d" , referenceTargetContext ( pipelineID , target ) , slotName , path , sizeBytes , slot . MaxBytes )
}
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s reference slot %q read %q: %w" , referenceTargetContext ( pipelineID , target ) , slotName , path , err )
}
2026-07-05 14:32:35 +00:00
if ! utf8 . Valid ( content ) {
2026-07-05 16:12:38 +00:00
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s reference slot %q path %q must be UTF-8 text" , referenceTargetContext ( pipelineID , target ) , slotName , path )
2026-07-05 14:32:35 +00:00
}
2026-07-05 10:53:16 -05:00
mediaType := referenceMediaTypeForPath ( path )
if ! referenceMediaTypeAccepted ( mediaType , slot . AcceptedMediaTypes ) {
2026-07-05 16:12:38 +00:00
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s reference slot %q path %q media type %q is not accepted" , referenceTargetContext ( pipelineID , target ) , slotName , path , mediaType )
2026-07-05 10:53:16 -05:00
}
2026-07-05 14:32:35 +00:00
if len ( content ) == 0 {
2026-08-27 16:40:17 +00:00
diagnostics = append ( diagnostics , contracts . ProducerDiagnostic { Disposition : contracts . DiagnosticDispositionWarning , Category : contracts . DiagnosticCategoryConfiguration , ReasonCode : "empty_reference" , OccurrenceCount : 1 , Samples : [ ] contracts . DiagnosticSample { { Scope : referenceWarningScope ( pipelineID , target , slotName ) , Message : fmt . Sprintf ( "reference slot %q for %s is bound to an empty file" , slotName , referenceTargetLabel ( target ) ) } } } )
2026-07-05 14:32:35 +00:00
}
item := contracts . ReferenceItem {
SlotName : slotName ,
2026-07-05 10:53:16 -05:00
MediaType : mediaType ,
2026-07-05 14:32:35 +00:00
Content : append ( [ ] byte ( nil ) , content ... ) ,
Digest : referenceDigest ( content ) ,
Origin : contracts . ReferenceOrigin { Type : referenceOriginFile , URI : fileURI ( path ) } ,
SizeBytes : int64 ( len ( content ) ) ,
BindingSource : strings . TrimSpace ( binding . BindingSource ) ,
}
set . Slots [ slotName ] = contracts . ResolvedReferenceSlot {
Slot : cloneReferenceSlot ( slot ) ,
Items : [ ] contracts . ReferenceItem { item } ,
}
}
2026-08-27 16:40:17 +00:00
return set , diagnostics , nil
2026-07-05 14:32:35 +00:00
}
2026-08-09 00:33:33 +00:00
type referenceSizeLimitError struct {
SizeBytes int64
MaxBytes int64
}
func ( err * referenceSizeLimitError ) Error ( ) string {
return fmt . Sprintf ( "reference is %d bytes, limit %d" , err . SizeBytes , err . MaxBytes )
}
func readReferenceContent ( reader io . Reader , maxBytes int64 ) ( [ ] byte , error ) {
if maxBytes <= 0 {
return io . ReadAll ( reader )
}
if maxBytes == math . MaxInt64 {
return nil , fmt . Errorf ( "maximum reference size %d cannot be safely bounded" , maxBytes )
}
content , err := io . ReadAll ( io . LimitReader ( reader , maxBytes + 1 ) )
if err != nil {
return nil , err
}
if int64 ( len ( content ) ) > maxBytes {
return nil , & referenceSizeLimitError { SizeBytes : int64 ( len ( content ) ) , MaxBytes : maxBytes }
}
return content , nil
}
2026-07-17 13:53:55 +00:00
func referenceTargetSpec ( target ResolvedReferenceTarget , artifactKind contracts . ArtifactKind , catalog ModuleCatalog ) ( ModuleSpec , error ) {
2026-07-05 16:12:38 +00:00
switch target . Stage {
case StageChunk :
return registrySpec ( catalog . Chunkers , target . Module )
case StageExtract :
return registrySpec ( catalog . Extractors , target . Module )
2026-07-07 19:10:55 +00:00
case StageMerge :
2026-07-17 13:53:55 +00:00
if catalog . Mergers == nil {
return ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , target . Module )
}
if spec , ok := catalog . Mergers . SpecForArtifact ( target . Module , artifactKind ) ; ok {
return spec , nil
}
return ModuleSpec { } , missingArtifactVariantError ( "merger" , target . Module , artifactKind , catalog . Mergers . registeredKinds ( target . Module ) )
2026-07-05 16:12:38 +00:00
case StageNormalize :
2026-07-17 13:53:55 +00:00
if catalog . Normalizers == nil {
return ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , target . Module )
}
if spec , ok := catalog . Normalizers . SpecForArtifact ( target . Module , artifactKind ) ; ok {
return spec , nil
}
return ModuleSpec { } , missingArtifactVariantError ( "normalizer" , target . Module , artifactKind , catalog . Normalizers . registeredKinds ( target . Module ) )
2026-07-05 16:12:38 +00:00
default :
return ModuleSpec { } , fmt . Errorf ( "reference target stage %q is not supported" , target . Stage )
}
}
func referenceTargetContext ( pipelineID string , target ResolvedReferenceTarget ) string {
if target . LaneID != "" {
return fmt . Sprintf ( "pipeline %q lane %q %s module %q" , pipelineID , target . LaneID , target . Stage , target . Module )
}
return fmt . Sprintf ( "pipeline %q %s module %q" , pipelineID , target . Stage , target . Module )
}
func referenceTargetLabel ( target ResolvedReferenceTarget ) string {
if target . LaneID != "" {
return fmt . Sprintf ( "lane %q %s target" , target . LaneID , target . Stage )
}
return fmt . Sprintf ( "%s target" , target . Stage )
}
func referenceWarningScope ( pipelineID string , target ResolvedReferenceTarget , slotName string ) string {
if target . LaneID != "" {
return fmt . Sprintf ( "pipeline.%s.lane.%s.%s.reference.%s" , pipelineID , target . LaneID , target . Stage , slotName )
}
return fmt . Sprintf ( "pipeline.%s.%s.reference.%s" , pipelineID , target . Stage , slotName )
}
2026-07-05 10:53:16 -05:00
func referenceMediaTypeForPath ( path string ) string {
extension := strings . ToLower ( filepath . Ext ( path ) )
mediaType := mime . TypeByExtension ( extension )
if strings . TrimSpace ( mediaType ) == "" {
if extension == ".md" || extension == ".markdown" {
return "text/markdown"
}
2026-07-05 18:48:45 -05:00
if extension == ".yaml" || extension == ".yml" {
return "application/yaml"
}
2026-07-05 10:53:16 -05:00
return unknownMediaType
}
return canonicalMediaType ( mediaType )
}
func referenceMediaTypeAccepted ( mediaType string , accepted [ ] string ) bool {
if len ( accepted ) == 0 {
return true
}
mediaType = canonicalMediaType ( mediaType )
for _ , value := range accepted {
if strings . EqualFold ( mediaType , canonicalMediaType ( value ) ) {
return true
}
}
return false
}
func canonicalMediaType ( mediaType string ) string {
trimmed := strings . TrimSpace ( mediaType )
if trimmed == "" {
return ""
}
parsed , _ , err := mime . ParseMediaType ( trimmed )
if err != nil {
return strings . ToLower ( trimmed )
}
return strings . ToLower ( parsed )
}
2026-07-05 14:32:35 +00:00
func referencePath ( binding ReferenceBinding , options ReferenceMaterializationOptions ) ( string , error ) {
source := strings . TrimSpace ( binding . Source )
if source == "" {
return "" , fmt . Errorf ( "must not be empty" )
}
if filepath . IsAbs ( source ) {
return filepath . Clean ( source ) , nil
}
base := strings . TrimSpace ( options . WorkingDir )
if strings . TrimSpace ( binding . BindingSource ) == contracts . ReferenceBindingSourceConfig {
base = filepath . Dir ( strings . TrimSpace ( options . ConfigPath ) )
}
if base == "" {
var err error
base , err = os . Getwd ( )
if err != nil {
return "" , fmt . Errorf ( "resolve working directory: %w" , err )
}
}
return filepath . Clean ( filepath . Join ( base , source ) ) , nil
}
func referenceDigest ( content [ ] byte ) string {
sum := sha256 . Sum256 ( content )
return "sha256:" + hex . EncodeToString ( sum [ : ] )
}
func fileURI ( path string ) string {
absolute , err := filepath . Abs ( path )
if err != nil {
absolute = path
}
absolute = filepath . ToSlash ( filepath . Clean ( absolute ) )
if strings . HasPrefix ( absolute , "/" ) {
return "file://" + ( & url . URL { Path : absolute } ) . EscapedPath ( )
}
return "file:///" + ( & url . URL { Path : absolute } ) . EscapedPath ( )
}
func cloneReferenceSlot ( slot contracts . ReferenceSlot ) contracts . ReferenceSlot {
slot . AcceptedMediaTypes = append ( [ ] string ( nil ) , slot . AcceptedMediaTypes ... )
2026-07-21 21:05:28 +00:00
slot . AcceptedArtifactKinds = append ( [ ] contracts . ArtifactKind ( nil ) , slot . AcceptedArtifactKinds ... )
2026-07-05 14:32:35 +00:00
return slot
}
func CloneReferenceSet ( in contracts . ReferenceSet ) contracts . ReferenceSet {
if len ( in . Slots ) == 0 {
return contracts . ReferenceSet { }
}
out := contracts . ReferenceSet { Slots : make ( map [ string ] contracts . ResolvedReferenceSlot , len ( in . Slots ) ) }
keys := make ( [ ] string , 0 , len ( in . Slots ) )
for key := range in . Slots {
keys = append ( keys , key )
}
sort . Strings ( keys )
for _ , key := range keys {
slot := in . Slots [ key ]
slot . Slot = cloneReferenceSlot ( slot . Slot )
if len ( slot . Items ) > 0 {
items := make ( [ ] contracts . ReferenceItem , len ( slot . Items ) )
for i , item := range slot . Items {
2026-07-21 21:39:23 +00:00
items [ i ] = contracts . CloneReferenceItem ( item )
2026-07-05 14:32:35 +00:00
}
slot . Items = items
}
out . Slots [ key ] = slot
}
return out
}
2026-07-05 14:44:44 +00:00
2026-07-05 16:12:38 +00:00
func CloneReferenceTarget ( in ResolvedReferenceTarget ) ResolvedReferenceTarget {
out := in
2026-07-21 21:05:28 +00:00
if len ( in . Bindings ) > 0 {
out . Bindings = make ( [ ] ReferenceBinding , len ( in . Bindings ) )
for i , binding := range in . Bindings {
out . Bindings [ i ] = binding
if binding . Artifact != nil {
artifact := * binding . Artifact
out . Bindings [ i ] . Artifact = & artifact
}
}
}
2026-07-05 16:12:38 +00:00
out . ReferenceSet = CloneReferenceSet ( in . ReferenceSet )
return out
}
2026-07-05 14:44:44 +00:00
func ReferenceProvenance ( resolved ResolvedPipeline ) [ ] artifacts . ReferenceProvenance {
provenance := [ ] artifacts . ReferenceProvenance { }
2026-07-05 16:12:38 +00:00
provenance = append ( provenance , referenceTargetProvenance ( resolved . ChunkReferences ) ... )
2026-07-21 21:05:28 +00:00
if len ( resolved . Steps ) > 0 {
for _ , step := range resolved . Steps {
for _ , lane := range step . ArtifactLanes {
provenance = append ( provenance , referenceTargetProvenance ( lane . ExtractReferences ) ... )
provenance = append ( provenance , referenceTargetProvenance ( lane . MergeReferences ) ... )
provenance = append ( provenance , referenceTargetProvenance ( lane . NormalizeReferences ) ... )
}
}
2026-07-05 16:12:38 +00:00
}
return provenance
}
func referenceTargetProvenance ( target ResolvedReferenceTarget ) [ ] artifacts . ReferenceProvenance {
if len ( target . ReferenceSet . Slots ) == 0 {
return nil
}
provenance := [ ] artifacts . ReferenceProvenance { }
slotNames := make ( [ ] string , 0 , len ( target . ReferenceSet . Slots ) )
for slotName := range target . ReferenceSet . Slots {
slotNames = append ( slotNames , slotName )
}
sort . Strings ( slotNames )
for _ , slotName := range slotNames {
slot := target . ReferenceSet . Slots [ slotName ]
for _ , item := range slot . Items {
2026-07-21 21:39:23 +00:00
schemaDigest := ""
if item . ArtifactSchema . ID != "" || item . ArtifactSchema . Name != "" || item . ArtifactSchema . Version != "" || len ( item . ArtifactSchema . JSONSchema ) > 0 {
schemaDigest = contracts . DigestArtifactSchema ( item . ArtifactSchema )
}
2026-07-05 16:12:38 +00:00
provenance = append ( provenance , artifacts . ReferenceProvenance {
2026-07-21 21:39:23 +00:00
Stage : string ( target . Stage ) ,
StepID : target . StepID ,
LaneID : target . LaneID ,
SlotName : item . SlotName ,
OriginType : item . Origin . Type ,
OriginURI : item . Origin . URI ,
Digest : item . Digest ,
MediaType : item . MediaType ,
SizeBytes : item . SizeBytes ,
BindingSource : item . BindingSource ,
ArtifactKind : string ( item . ArtifactKind ) ,
SchemaID : item . ArtifactSchema . ID ,
SchemaName : item . ArtifactSchema . Name ,
SchemaVersion : item . ArtifactSchema . Version ,
SchemaDigest : schemaDigest ,
ProducerPipeline : item . Producer . PipelineID ,
ProducerStep : item . Producer . StepID ,
ProducerLane : item . Producer . LaneID ,
ProducerModule : item . Producer . ModuleKey ,
2026-07-05 16:12:38 +00:00
} )
2026-07-05 14:44:44 +00:00
}
}
return provenance
}