Require manifest-backed local artifacts

This commit is contained in:
2026-09-02 14:09:32 +00:00
parent 62b1f9b0ae
commit 57719b7d81
28 changed files with 392 additions and 179 deletions

View File

@@ -1,6 +1,7 @@
package artifacts
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -12,6 +13,7 @@ import (
"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.
@@ -178,8 +180,8 @@ func PreviousSessionArtifactName(source string) (string, bool) {
return artifactpolicy.ParsePreviousSessionSource(source)
}
// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path.
// Resolution order is manifest producer outputs first, then canonical session path fallback.
// 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 {
@@ -187,43 +189,21 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str
}
spec := artifactRegistry[id]
for _, candidate := range manifestArtifactCandidates(paths, m, spec) {
exists, isDir, statErr := pathExists(candidate.Path)
if statErr != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate.Path, statErr)
}
if !exists || isDir {
continue
}
resolved := candidate
resolved.ID = spec.ID
resolved.ProducerStage = spec.ProducerStage
resolved.OutputKind = spec.OutputKind
if err := validateResolvedContent(resolved.Path, spec.ContentKind); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", resolved.ID, err)
}
return resolved, nil
candidate, err := manifestArtifactCandidate(paths, m, spec)
if err != nil {
return ResolvedSessionArtifact{}, err
}
fallbackPath := filepath.Join(paths.Root, filepath.FromSlash(spec.CanonicalRelPath))
exists, isDir, statErr := pathExists(fallbackPath)
if statErr != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", fallbackPath, statErr)
if err := validateResolvedContent(candidate.Path, spec.ContentKind); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err)
}
if exists && !isDir {
if err := validateResolvedContent(fallbackPath, spec.ContentKind); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err)
}
return ResolvedSessionArtifact{
ID: spec.ID,
Path: filepath.Clean(fallbackPath),
ProducerStage: spec.ProducerStage,
OutputKind: spec.OutputKind,
Provenance: "fallback.canonical_path",
}, nil
actualChecksum, err := SHA256File(candidate.Path)
if err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("checksum %q: %w", spec.ID, err)
}
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
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
@@ -334,52 +314,74 @@ func ResolvePreviousSessionArtifactWithCatalog(
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
}
func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact {
if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" {
return 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 {
return nil
if sr == nil || sr.Name != spec.ProducerStage || sr.Status != manifest.StatusSucceeded {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
}
candidates := make([]ResolvedSessionArtifact, 0, len(sr.Outputs))
var selected manifest.ArtifactRecord
count := 0
for _, out := range sr.Outputs {
if strings.TrimSpace(out.Kind) != spec.OutputKind {
continue
if strings.TrimSpace(out.Kind) == spec.OutputKind && strings.TrimSpace(out.SourceID) == spec.ID {
selected, count = out, count+1
}
if strings.TrimSpace(out.LocalPath) == "" {
continue
}
resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, out.LocalPath))
if resolved == "" {
continue
}
candidates = append(candidates, ResolvedSessionArtifact{
Path: resolved,
ProducerRunID: strings.TrimSpace(out.ProducerRunID),
Provenance: "manifest." + spec.ProducerStage + ".outputs",
})
}
return dedupeResolvedArtifacts(candidates)
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 dedupeResolvedArtifacts(values []ResolvedSessionArtifact) []ResolvedSessionArtifact {
seen := map[string]struct{}{}
out := make([]ResolvedSessionArtifact, 0, len(values))
for _, value := range values {
key := filepath.Clean(strings.TrimSpace(value.Path))
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
value.Path = key
out = append(out, value)
func requireSafeSessionRegularFile(root, path string) error {
relative, err := pathsafe.SlashRelativeFromRoot(root, path)
if err != nil {
return err
}
return out
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 {