package artifacts import ( "crypto/sha256" "encoding/hex" "fmt" "io" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) // AnalyzeEvidenceState distinguishes a verified current configured artifact // from every form of unavailable evidence. type AnalyzeEvidenceState string const ( AnalyzeEvidenceCurrent AnalyzeEvidenceState = "current" AnalyzeEvidenceNonCurrent AnalyzeEvidenceState = "non_current" ) // AnalyzeEvidence is the read-only result of inspecting one configured // artifact's manifest record and durable output. type AnalyzeEvidence struct { State AnalyzeEvidenceState Reason string SourceID string Path string ProducerRunID string Contract *artifactmodel.ContractMetadata Checksum string Size int64 } // InspectAnalyzeEvidence verifies that one configured artifact has supported, // current manifest evidence for the exact canonical bytes on disk. func InspectAnalyzeEvidence( paths SessionPaths, m *manifest.Manifest, key string, configured ConfiguredArtifactDefinition, ) AnalyzeEvidence { normalizedKey := strings.TrimSpace(key) sourceID := ConfiguredArtifactSourceID(normalizedKey) nonCurrent := func(reason string) AnalyzeEvidence { return AnalyzeEvidence{State: AnalyzeEvidenceNonCurrent, Reason: reason, SourceID: sourceID} } if m == nil { return nonCurrent("analyze manifest evidence is absent; regenerate the artifact") } stageRecord := m.Stages["analyze"] if stageRecord == nil || stageRecord.Name != "analyze" { return nonCurrent("analyze manifest evidence is absent; regenerate the artifact") } if !stageRecord.HasVersionedAnalyzeState() { return nonCurrent("analyze manifest evidence is legacy or unsupported; regenerate the artifact") } record, ok := stageRecord.AnalyzeArtifacts[normalizedKey] if !ok { return nonCurrent("configured artifact has no analyze manifest record; regenerate the artifact") } if record.Status != manifest.AnalyzeArtifactCurrent { return nonCurrent(fmt.Sprintf("configured artifact manifest status is %q; regenerate the artifact", record.Status)) } if err := manifest.ValidateAnalyzeArtifactCollection( stageRecord.AnalyzeStateVersion, map[string]manifest.AnalyzeArtifactRecord{normalizedKey: record}, ); err != nil { return nonCurrent("configured artifact manifest evidence is malformed; regenerate the artifact") } configuredPath, err := pathsafe.NormalizeRelativeDestination(strings.TrimSpace(configured.OutputPath)) if err != nil { return nonCurrent("configured artifact output path is unsafe; correct the configuration") } if record.Output.LocalPath != configuredPath { return nonCurrent("configured artifact manifest path differs from current configuration; regenerate the artifact") } if record.Output.SourceID != sourceID || record.Output.Kind != "scriptorium_artifact" { return nonCurrent("configured artifact manifest identity is incompatible; regenerate the artifact") } file, err := fileops.OpenConfinedRegularFile(paths.Root, configuredPath) if err != nil { return nonCurrent("configured artifact output is missing or unsafe; regenerate the artifact") } defer file.Close() info, err := file.Stat() if err != nil || !info.Mode().IsRegular() { return nonCurrent("configured artifact output is not a safe regular file; regenerate the artifact") } hash := sha256.New() size, err := io.Copy(hash, file) if err != nil { return nonCurrent("configured artifact output could not be verified; regenerate the artifact") } if size != info.Size() || size != record.OutputSize { return nonCurrent("configured artifact output size differs from manifest evidence; regenerate the artifact") } if hex.EncodeToString(hash.Sum(nil)) != record.Output.Checksum { return nonCurrent("configured artifact output checksum differs from manifest evidence; regenerate the artifact") } return AnalyzeEvidence{ State: AnalyzeEvidenceCurrent, SourceID: sourceID, Path: filepath.Join(paths.Root, filepath.FromSlash(configuredPath)), ProducerRunID: record.ProducerRunID, Contract: cloneArtifactContract(record.Output.Contract), Checksum: record.Output.Checksum, Size: record.OutputSize, } } // HydrateAnalyzeArtifacts makes configured sources available only from // validated current manifest evidence. It never mutates manifest state. func (c *ArtifactCatalog) HydrateAnalyzeArtifacts( paths SessionPaths, m *manifest.Manifest, configured map[string]ConfiguredArtifactDefinition, ) { if c == nil || len(configured) == 0 { return } for _, entry := range c.ListConfigured() { definition, ok := configured[entry.ConfiguredKey] if !ok { continue } evidence := InspectAnalyzeEvidence(paths, m, entry.ConfiguredKey, definition) if evidence.State != AnalyzeEvidenceCurrent { continue } _ = c.markAvailableFromAnalyzeManifest( evidence.SourceID, evidence.Path, evidence.ProducerRunID, evidence.Checksum, evidence.Size, evidence.Contract, ) } }