2026-07-05 14:32:35 +00:00
package pipeline
import (
"crypto/sha256"
"encoding/hex"
"fmt"
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
}
func MaterializeReferences ( resolved ResolvedPipeline , catalog ModuleCatalog , options ReferenceMaterializationOptions ) ( ResolvedPipeline , [ ] contracts . Warning , error ) {
out := resolved
2026-07-05 16:12:38 +00:00
out . ChunkReferences = CloneReferenceTarget ( resolved . ChunkReferences )
2026-07-05 16:35:24 +00:00
chunkReferenceSet , chunkWarnings , err := materializeReferenceTarget ( resolved . ID , resolved . ChunkReferences , catalog , options )
if err != nil {
return ResolvedPipeline { } , nil , err
}
out . ChunkReferences . ReferenceSet = chunkReferenceSet
warnings := append ( [ ] contracts . Warning ( nil ) , chunkWarnings ... )
2026-07-05 14:32:35 +00:00
if len ( resolved . ArtifactLanes ) == 0 {
2026-07-05 16:35:24 +00:00
return out , warnings , nil
2026-07-05 14:32:35 +00:00
}
out . ArtifactLanes = make ( [ ] ResolvedArtifactLane , len ( resolved . ArtifactLanes ) )
for i , lane := range resolved . ArtifactLanes {
materializedLane := lane
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-07-05 16:35:24 +00:00
extractReferenceSet , laneWarnings , err := materializeReferenceTarget ( resolved . ID , lane . ExtractReferences , catalog , options )
2026-07-05 14:32:35 +00:00
if err != nil {
return ResolvedPipeline { } , nil , err
}
2026-07-05 16:35:24 +00:00
materializedLane . ExtractReferences . ReferenceSet = extractReferenceSet
2026-07-05 14:32:35 +00:00
warnings = append ( warnings , laneWarnings ... )
2026-07-05 16:35:24 +00:00
2026-07-07 19:10:55 +00:00
mergeReferenceSet , laneWarnings , err := materializeReferenceTarget ( resolved . ID , lane . MergeReferences , catalog , options )
if err != nil {
return ResolvedPipeline { } , nil , err
}
materializedLane . MergeReferences . ReferenceSet = mergeReferenceSet
warnings = append ( warnings , laneWarnings ... )
2026-07-05 16:35:24 +00:00
normalizeReferenceSet , laneWarnings , err := materializeReferenceTarget ( resolved . ID , lane . NormalizeReferences , catalog , options )
if err != nil {
return ResolvedPipeline { } , nil , err
}
materializedLane . NormalizeReferences . ReferenceSet = normalizeReferenceSet
warnings = append ( warnings , laneWarnings ... )
out . ArtifactLanes [ i ] = materializedLane
2026-07-05 14:32:35 +00:00
}
return out , warnings , nil
}
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-05 14:32:35 +00:00
catalog ModuleCatalog ,
options ReferenceMaterializationOptions ,
) ( contracts . ReferenceSet , [ ] contracts . Warning , 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-05 16:12:38 +00:00
spec , err := referenceTargetSpec ( target , catalog )
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-07-05 14:32:35 +00:00
var warnings [ ] contracts . Warning
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
}
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
}
content , err := os . ReadFile ( path )
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
}
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 slot . MaxBytes > 0 && int64 ( len ( content ) ) > slot . MaxBytes {
2026-07-05 16:12:38 +00:00
return contracts . ReferenceSet { } , nil , fmt . Errorf ( "%s reference slot %q path %q is %d bytes, limit %d" , referenceTargetContext ( pipelineID , target ) , slotName , path , len ( content ) , slot . MaxBytes )
2026-07-05 14:32:35 +00:00
}
if len ( content ) == 0 {
warnings = append ( warnings , contracts . Warning {
2026-07-05 16:12:38 +00:00
Scope : referenceWarningScope ( pipelineID , target , slotName ) ,
2026-07-05 14:32:35 +00:00
ReasonCode : "empty_reference" ,
2026-07-05 16:12:38 +00:00
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 } ,
}
}
return set , warnings , nil
}
2026-07-05 16:12:38 +00:00
func referenceTargetSpec ( target ResolvedReferenceTarget , catalog ModuleCatalog ) ( ModuleSpec , error ) {
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 :
return registrySpec ( catalog . Mergers , target . Module )
2026-07-05 16:12:38 +00:00
case StageNormalize :
return registrySpec ( catalog . Normalizers , target . Module )
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 ... )
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 {
item . Content = append ( [ ] byte ( nil ) , item . Content ... )
items [ i ] = item
}
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
out . Bindings = append ( [ ] ReferenceBinding ( nil ) , in . Bindings ... )
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-05 14:44:44 +00:00
for _ , lane := range resolved . ArtifactLanes {
2026-07-05 16:12:38 +00:00
provenance = append ( provenance , referenceTargetProvenance ( lane . ExtractReferences ) ... )
2026-07-07 19:10:55 +00:00
provenance = append ( provenance , referenceTargetProvenance ( lane . MergeReferences ) ... )
2026-07-05 16:12:38 +00:00
provenance = append ( provenance , referenceTargetProvenance ( lane . NormalizeReferences ) ... )
}
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 {
provenance = append ( provenance , artifacts . ReferenceProvenance {
Stage : string ( target . Stage ) ,
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 ,
} )
2026-07-05 14:44:44 +00:00
}
}
return provenance
}