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" ArtifactProvenancePreviousCacheFilesystem = "current_session.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.. func IsConfiguredArtifactSource(source string) bool { _, ok := artifactpolicy.ParseConfiguredSource(source) return ok } // ConfiguredArtifactName extracts from narratio.artifact.. func ConfiguredArtifactName(source string) (string, bool) { return artifactpolicy.ParseConfiguredSource(source) } // ExtractionArtifactSourceID returns narratio.extraction. for a configured key. func ExtractionArtifactSourceID(key string) string { return artifactpolicy.ExtractionSourceID(key) } // IsExtractionArtifactSource reports whether source is narratio.extraction.. func IsExtractionArtifactSource(source string) bool { _, ok := artifactpolicy.ParseExtractionSource(source) return ok } // ExtractionArtifactName extracts from narratio.extraction.. func ExtractionArtifactName(source string) (string, bool) { return artifactpolicy.ParseExtractionSource(source) } // IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.. func IsPreviousSessionArtifactSource(source string) bool { _, ok := artifactpolicy.ParsePreviousSessionSource(source) return ok } // PreviousSessionArtifactName extracts from narratio.previous_session.artifact.. 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) } configuredSourceID := ConfiguredArtifactSourceID(artifactName) entry, ok := catalog.Lookup(configuredSourceID) if !ok { return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source) } candidates := previousSessionCacheCandidatePaths(paths, entry.CanonicalRelPath) if len(candidates) == 0 { return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source} } manifestInputPaths := manifestInputPathSet(paths, m) fallback := "" for _, candidate := range candidates { exists, isDir, statErr := pathExists(candidate) if statErr != nil { return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate, statErr) } if !exists || isDir { continue } if err := validateResolvedContent(candidate, contentText); err != nil { return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", source, err) } if _, ok := manifestInputPaths[candidate]; ok { return ResolvedSessionArtifact{ ID: source, Path: candidate, ProducerStage: "prepare", OutputKind: "previous_session_cache", Provenance: ArtifactProvenancePreviousCacheManifestInput, }, nil } if fallback == "" { fallback = candidate } } if fallback != "" { return ResolvedSessionArtifact{ ID: source, Path: fallback, ProducerStage: "prepare", OutputKind: "previous_session_cache", Provenance: ArtifactProvenancePreviousCacheFilesystem, }, nil } return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source} } 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 previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath string) []string { trimmed := strings.TrimSpace(canonicalRelPath) if trimmed == "" { return nil } normalized := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed))) if normalized == "." || normalized == "" || normalized == ".." || strings.HasPrefix(normalized, "../") || strings.HasPrefix(normalized, "/") { return nil } relCandidates := []string{normalized} const artifactsPrefix = "artifacts/" if strings.HasPrefix(normalized, artifactsPrefix) && len(normalized) > len(artifactsPrefix) { relCandidates = append(relCandidates, strings.TrimPrefix(normalized, artifactsPrefix)) } out := make([]string, 0, len(relCandidates)) seen := map[string]struct{}{} for _, rel := range relCandidates { abs, err := SessionPreviousArtifactPath(paths, rel) if err != nil { continue } abs = filepath.Clean(abs) if _, ok := seen[abs]; ok { continue } seen[abs] = struct{}{} out = append(out, abs) } return out } func manifestInputPathSet(paths SessionPaths, m *manifest.Manifest) map[string]struct{} { if m == nil || len(m.Inputs) == 0 { return nil } out := make(map[string]struct{}, len(m.Inputs)) for _, in := range m.Inputs { resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, in.Path)) if strings.TrimSpace(resolved) == "" { continue } out[resolved] = struct{}{} } return out } func pathExists(path string) (exists bool, isDir bool, err error) { info, err := os.Stat(path) if err == nil { return true, info.IsDir(), nil } if errors.Is(err, os.ErrNotExist) { return false, false, nil } return false, false, err } 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 }