Files
narratio/internal/artifacts/artifact_resolver.go

443 lines
16 KiB
Go

package artifacts
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// MaxResolvedArtifactBytes bounds externally generated artifact content before validation.
const MaxResolvedArtifactBytes int64 = 64 * 1024 * 1024
const (
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown
ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown
ArtifactBoundsSession = "narratio.bounds.session"
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
)
const (
TranscriptPathBase = artifactmodel.TranscriptPathBase
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown
TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown
)
const (
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown
TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown
)
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
type artifactContentKind string
const (
contentTranscriptJSON artifactContentKind = "transcript_json"
contentJSON artifactContentKind = "json"
contentText artifactContentKind = "text"
)
type artifactSpec struct {
ID string
CanonicalRelPath string
ProducerStage string
OutputKind string
ContentKind artifactContentKind
}
var artifactRegistry = buildArtifactRegistry()
func buildArtifactRegistry() map[string]artifactSpec {
registry := map[string]artifactSpec{}
for _, transcript := range RuntimeTranscriptArtifacts() {
registry[transcript.SourceID] = artifactSpec{
ID: transcript.SourceID,
CanonicalRelPath: transcript.CanonicalRelPath,
ProducerStage: transcript.ProducerStage,
OutputKind: transcript.OutputKind,
ContentKind: transcriptContentKind(transcript),
}
}
registry[ArtifactBoundsSession] = artifactSpec{
ID: ArtifactBoundsSession,
CanonicalRelPath: "artifacts/session_bounds.json",
ProducerStage: "trim",
OutputKind: "session_bounds",
ContentKind: contentJSON,
}
return registry
}
func transcriptContentKind(transcript TranscriptArtifactSpec) artifactContentKind {
switch transcript.SourceID {
case ArtifactTranscriptFinalMarkdown, ArtifactTranscriptFinalTrimmedMarkdown:
return contentText
default:
return contentTranscriptJSON
}
}
// ResolvedSessionArtifact describes one session-level artifact lookup result.
type ResolvedSessionArtifact struct {
ID string
Path string
ProducerStage string
OutputKind string
ProducerRunID string
Provenance string
Contract *artifactmodel.ContractMetadata
Checksum string
Size int64
}
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
type SessionArtifactNotFoundError struct {
ArtifactID string
}
func (e *SessionArtifactNotFoundError) Error() string {
return fmt.Sprintf("%s: %q", ErrSessionArtifactNotFound, e.ArtifactID)
}
func (e *SessionArtifactNotFoundError) Unwrap() error {
return ErrSessionArtifactNotFound
}
// NormalizeSessionArtifactSource validates canonical artifact IDs.
func NormalizeSessionArtifactSource(source string) (string, error) {
classified, err := artifactpolicy.ClassifySource(source)
if err != nil {
return "", fmt.Errorf("unsupported artifact source %q", source)
}
if classified.Kind != artifactpolicy.SourceKindBuiltIn {
return "", fmt.Errorf("unsupported artifact source %q", source)
}
if _, ok := artifactRegistry[classified.ID]; !ok {
return "", fmt.Errorf("unsupported artifact source %q", source)
}
return classified.ID, nil
}
// IsConfiguredArtifactSource returns true when source is narratio.artifact.<name>.
func IsConfiguredArtifactSource(source string) bool {
_, ok := artifactpolicy.ParseConfiguredSource(source)
return ok
}
// ConfiguredArtifactName extracts <name> from narratio.artifact.<name>.
func ConfiguredArtifactName(source string) (string, bool) {
return artifactpolicy.ParseConfiguredSource(source)
}
// ExtractionArtifactSourceID returns narratio.extraction.<name> for a configured key.
func ExtractionArtifactSourceID(key string) string {
return artifactpolicy.ExtractionSourceID(key)
}
// IsExtractionArtifactSource reports whether source is narratio.extraction.<name>.
func IsExtractionArtifactSource(source string) bool {
_, ok := artifactpolicy.ParseExtractionSource(source)
return ok
}
// ExtractionArtifactName extracts <name> from narratio.extraction.<name>.
func ExtractionArtifactName(source string) (string, bool) {
return artifactpolicy.ParseExtractionSource(source)
}
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
func IsPreviousSessionArtifactSource(source string) bool {
_, ok := artifactpolicy.ParsePreviousSessionSource(source)
return ok
}
// PreviousSessionArtifactName extracts <name> from narratio.previous_session.artifact.<name>.
func PreviousSessionArtifactName(source string) (string, bool) {
return artifactpolicy.ParsePreviousSessionSource(source)
}
// ResolveSessionArtifact resolves a symbolic source from its unique current
// producer output declaration in the session manifest.
func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) {
id, err := NormalizeSessionArtifactSource(source)
if err != nil {
return ResolvedSessionArtifact{}, err
}
spec := artifactRegistry[id]
candidate, err := manifestArtifactCandidate(paths, m, spec)
if err != nil {
return ResolvedSessionArtifact{}, err
}
if err := validateResolvedContent(candidate.Path, spec.ContentKind); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err)
}
actualChecksum, err := SHA256File(candidate.Path)
if err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("checksum %q: %w", spec.ID, err)
}
if actualChecksum != candidate.Checksum {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: checksum mismatch", spec.ID)
}
return candidate, nil
}
// ResolveSessionArtifactWithCatalog resolves built-in sources using existing rules and resolves
// configured artifact and extraction sources through runtime catalog availability.
func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest, source string, catalog *ArtifactCatalog) (ResolvedSessionArtifact, error) {
normalized := strings.TrimSpace(source)
if IsPreviousSessionArtifactSource(normalized) {
return ResolvePreviousSessionArtifactWithCatalog(paths, m, normalized, catalog)
}
configuredSource := IsConfiguredArtifactSource(normalized)
extractionSource := IsExtractionArtifactSource(normalized)
if !configuredSource && !extractionSource {
return ResolveSessionArtifact(paths, m, normalized)
}
if catalog == nil {
return ResolvedSessionArtifact{}, fmt.Errorf("catalog-backed artifact source %q requires runtime artifact catalog", source)
}
entry, ok := catalog.Lookup(normalized)
if !ok {
return ResolvedSessionArtifact{}, fmt.Errorf("unsupported artifact source %q", source)
}
if !entry.Available {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: normalized}
}
contentKind := contentText
if extractionSource {
contentKind = contentJSON
}
if err := validateResolvedContent(entry.Path, contentKind); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", normalized, err)
}
return ResolvedSessionArtifact{
ID: normalized,
Path: filepath.Clean(entry.Path),
ProducerStage: entry.ProducerStage,
OutputKind: entry.OutputKind,
ProducerRunID: entry.ProducerRunID,
Provenance: entry.Provenance,
Contract: cloneArtifactContract(entry.Contract),
Checksum: entry.Checksum,
Size: entry.Size,
}, nil
}
// ResolvePreviousSessionArtifactWithCatalog resolves one canonical previous-session source id
// to the prepared current-session previous-cache path.
func ResolvePreviousSessionArtifactWithCatalog(
paths SessionPaths,
m *manifest.Manifest,
source string,
catalog *ArtifactCatalog,
) (ResolvedSessionArtifact, error) {
artifactName, ok := PreviousSessionArtifactName(source)
if !ok {
return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source)
}
if catalog == nil {
return ResolvedSessionArtifact{}, fmt.Errorf("previous-session artifact source %q requires runtime artifact catalog", source)
}
if _, ok := catalog.Lookup(ConfiguredArtifactSourceID(artifactName)); !ok {
return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source)
}
if m == nil {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
}
var selected manifest.InputRecord
count := 0
for _, input := range m.Inputs {
if input.Kind != "previous_artifact" || input.Source != source {
continue
}
candidate := filepath.Clean(ResolveSessionLocalPathForRead(paths, input.Path))
if strings.TrimSpace(candidate) == "" {
continue
}
if _, err := pathsafe.SlashRelativeFromRoot(paths.Root, candidate); err != nil {
continue
}
selected, count = input, count+1
}
if count != 1 {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
}
path := filepath.Clean(ResolveSessionLocalPathForRead(paths, selected.Path))
if err := requireSafeSessionRegularFile(paths.Root, path); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: unsafe previous-session artifact: %w", source, err)
}
if err := validateResolvedContent(path, contentText); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", source, err)
}
checksum := strings.TrimSpace(selected.Checksum)
if len(checksum) != 64 || strings.ToLower(checksum) != checksum {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: previous-session artifact checksum is invalid", source)
}
actualChecksum, err := SHA256File(path)
if err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("checksum %q: %w", source, err)
}
if actualChecksum != checksum {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: checksum mismatch", source)
}
return ResolvedSessionArtifact{ID: source, Path: path, ProducerStage: "prepare", OutputKind: "previous_session_cache", Provenance: ArtifactProvenancePreviousCacheManifestInput, Checksum: checksum}, nil
}
func manifestArtifactCandidate(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) (ResolvedSessionArtifact, error) {
if m == nil || spec.ProducerStage == "" || spec.OutputKind == "" {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
}
sr := m.Stages[spec.ProducerStage]
if sr == nil || sr.Name != spec.ProducerStage || sr.Status != manifest.StatusSucceeded {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
}
var selected manifest.ArtifactRecord
count := 0
for _, out := range sr.Outputs {
if strings.TrimSpace(out.Kind) == spec.OutputKind && strings.TrimSpace(out.SourceID) == spec.ID {
selected, count = out, count+1
}
}
if count == 0 {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
}
if count != 1 {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q is ambiguous", spec.ProducerStage, spec.ID)
}
if err := ValidateRunIdentity(strings.TrimSpace(selected.ProducerRunID)); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid producer run ID: %w", spec.ProducerStage, spec.ID, err)
}
checksum := strings.TrimSpace(selected.Checksum)
if len(checksum) != 64 || strings.ToLower(checksum) != checksum {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid checksum", spec.ProducerStage, spec.ID)
}
if _, err := hex.DecodeString(checksum); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid checksum", spec.ProducerStage, spec.ID)
}
resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, selected.LocalPath))
if resolved == "" {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has an empty path", spec.ProducerStage, spec.ID)
}
if _, err := pathsafe.SlashRelativeFromRoot(paths.Root, resolved); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has unsafe path: %w", spec.ProducerStage, spec.ID, err)
}
if err := requireSafeSessionRegularFile(paths.Root, resolved); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has unsafe path: %w", spec.ProducerStage, spec.ID, err)
}
return ResolvedSessionArtifact{ID: spec.ID, Path: resolved, ProducerStage: spec.ProducerStage, OutputKind: spec.OutputKind, ProducerRunID: selected.ProducerRunID, Provenance: "manifest." + spec.ProducerStage + ".outputs", Checksum: checksum}, nil
}
func requireSafeSessionRegularFile(root, path string) error {
relative, err := pathsafe.SlashRelativeFromRoot(root, path)
if err != nil {
return err
}
current := filepath.Clean(root)
for _, component := range strings.Split(relative, "/") {
current = filepath.Join(current, filepath.FromSlash(component))
info, err := os.Lstat(current)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("path contains symlink %q", current)
}
}
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("path is not a regular file")
}
return nil
}
func validateResolvedContent(path string, kind artifactContentKind) error {
switch kind {
case contentTranscriptJSON:
return validateTranscriptSegmentsJSON(path)
case contentJSON:
return validateJSONContent(path)
case contentText:
return validateNonEmptyContent(path)
default:
return fmt.Errorf("unsupported content kind %q", kind)
}
}
func validateTranscriptSegmentsJSON(path string) error {
data, err := readResolvedArtifact(path, "transcript JSON")
if err != nil {
return err
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return fmt.Errorf("decode json: %w", err)
}
segments, ok := payload["segments"]
if !ok {
return fmt.Errorf("top-level segments is required")
}
if _, ok := segments.([]any); !ok {
return fmt.Errorf("top-level segments must be an array")
}
return nil
}
func validateJSONContent(path string) error {
data, err := readResolvedArtifact(path, "JSON artifact")
if err != nil {
return err
}
var payload any
if err := json.Unmarshal(data, &payload); err != nil {
return fmt.Errorf("decode json: %w", err)
}
return nil
}
func validateNonEmptyContent(path string) error {
data, err := readResolvedArtifact(path, "text artifact")
if err != nil {
return err
}
if len(data) == 0 {
return fmt.Errorf("file is empty")
}
return nil
}
func readResolvedArtifact(path, category string) ([]byte, error) {
data, err := fileops.ReadFileBounded(path, MaxResolvedArtifactBytes)
if err != nil {
return nil, fmt.Errorf("resolved %s exceeds or cannot be read within %d-byte limit: %w", category, MaxResolvedArtifactBytes, err)
}
return data, nil
}