443 lines
16 KiB
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
|
|
}
|