Confine publish archive reads

This commit is contained in:
2026-08-10 19:16:18 +00:00
parent 60cebf0e4b
commit 9900211fa4
18 changed files with 655 additions and 152 deletions

View File

@@ -2,10 +2,12 @@ package stage
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"io"
"os"
"path/filepath"
"sort"
@@ -16,13 +18,16 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
type publishStage struct{}
type publishUploadFile struct {
RelativePath string
LocalPath string
RelativePath string
SourceRelativePath string
Checksum string
}
var publishPrerequisiteStages = []string{
@@ -85,11 +90,11 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("publish: resolve run root: %w", err)
}
runRootInfo, err := os.Stat(runRoot)
runRootInfo, err := os.Lstat(runRoot)
if err != nil {
return nil, fmt.Errorf("publish: run root %q: %w", runRoot, err)
}
if !runRootInfo.IsDir() {
if runRootInfo.Mode()&os.ModeSymlink != 0 || !runRootInfo.IsDir() {
return nil, fmt.Errorf("publish: run root %q is not a directory", runRoot)
}
@@ -110,20 +115,45 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("publish: run id is required")
}
manifestSource, err := resolvePublishRunManifestSource(runRoot)
runManifestFile, err := fileops.OpenConfinedRegularFile(runRoot, "manifest.json")
if err != nil {
return nil, fmt.Errorf("publish: resolve run manifest source: %w", err)
return nil, fmt.Errorf("publish: open run manifest: %w", err)
}
defer runManifestFile.Close()
runManifest, err := (&manifest.LocalStore{}).LoadRunReader(ctx, runManifestFile)
if err != nil {
return nil, fmt.Errorf("publish: load run manifest: %w", err)
}
if _, err := runManifestFile.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("publish: rewind run manifest: %w", err)
}
runFiles, err := collectPublishRunFiles(runRoot, manifestSource)
runFiles, err := collectPublishRunFiles(runRoot, runManifest)
if err != nil {
return nil, fmt.Errorf("publish: collect run files: %w", err)
}
runSources, err := openPublishRunFiles(runRoot, runFiles, runManifestFile)
if err != nil {
return nil, fmt.Errorf("publish: verify run files: %w", err)
}
defer closePublishSources(runSources)
uploader, ok := env.ObjectStore.(storage.ReaderUploader)
if !ok {
return nil, fmt.Errorf("publish: object store does not support verified source uploads")
}
sessionPaths := publishSessionPaths(env, m)
previousFiles, err := collectPublishPreviousFiles(sessionPaths.PreviousDir)
previousFiles, previousManifestFile, err := collectPublishPreviousFiles(ctx, sessionPaths.PreviousDir)
if err != nil {
return nil, fmt.Errorf("publish: collect previous files: %w", err)
}
if previousManifestFile != nil {
defer previousManifestFile.Close()
}
previousSources, err := openPublishRunFiles(sessionPaths.PreviousDir, previousFiles, previousManifestFile)
if err != nil {
return nil, fmt.Errorf("publish: verify previous files: %w", err)
}
defer closePublishSources(previousSources)
runtimeCatalog, err := buildPublishRuntimeArtifactCatalog(
sessionPaths,
m,
@@ -146,9 +176,9 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("publish: resolve publish output rules: %w", err)
}
runUploaded := make([]string, 0, len(runFiles))
for _, file := range runFiles {
for index, file := range runFiles {
key := artifacts.S3RunRelativeDestinationKey(runPrefix, file.RelativePath)
if _, err := env.ObjectStore.Upload(ctx, file.LocalPath, key, storage.UploadOptions{}); err != nil {
if _, err := uploader.UploadReader(ctx, runSources[index], key, storage.UploadOptions{}); err != nil {
return nil, fmt.Errorf("publish: upload run file %q to %q: %w", file.RelativePath, key, err)
}
runUploaded = append(runUploaded, file.RelativePath)
@@ -164,9 +194,9 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}
previousUploaded := make([]string, 0, len(previousFiles))
for _, file := range previousFiles {
for index, file := range previousFiles {
key := artifacts.S3PublishedOutputKey(sessionPrefix, file.RelativePath)
if _, err := env.ObjectStore.Upload(ctx, file.LocalPath, key, storage.UploadOptions{}); err != nil {
if _, err := uploader.UploadReader(ctx, previousSources[index], key, storage.UploadOptions{}); err != nil {
return nil, fmt.Errorf("publish: upload previous file %q to %q: %w", file.RelativePath, key, err)
}
previousUploaded = append(previousUploaded, file.RelativePath)
@@ -567,131 +597,206 @@ func resolveConfiguredArtifactLocalPath(paths artifacts.SessionPaths, configured
return filepath.Join(paths.Root, rel), nil
}
func collectPublishRunFiles(runRoot, manifestPath string) ([]publishUploadFile, error) {
files := make([]publishUploadFile, 0, 64)
err := filepath.WalkDir(runRoot, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == runRoot {
func collectPublishRunFiles(runRoot string, runManifest *manifest.RunManifest) ([]publishUploadFile, error) {
if runManifest == nil {
return nil, fmt.Errorf("run manifest is required")
}
files := map[string]publishUploadFile{
"manifest.json": {RelativePath: "manifest.json", SourceRelativePath: "manifest.json"},
}
add := func(path, checksum string) error {
if strings.TrimSpace(path) == "" {
return nil
}
rel, err := filepath.Rel(runRoot, path)
rel, err := pathsafe.SlashRelativeFromRoot(runRoot, path)
if err != nil {
return fmt.Errorf("relative path from %q to %q: %w", runRoot, path, err)
return nil
}
rel = filepath.ToSlash(rel)
if publishRunPathExcluded(rel) {
if d.IsDir() {
return filepath.SkipDir
candidate := publishUploadFile{RelativePath: rel, SourceRelativePath: rel, Checksum: strings.TrimSpace(checksum)}
if prior, exists := files[rel]; exists && prior.Checksum != "" && candidate.Checksum != "" && prior.Checksum != candidate.Checksum {
return fmt.Errorf("source %q has conflicting declared checksums", rel)
}
if prior, exists := files[rel]; exists && prior.Checksum != "" {
candidate.Checksum = prior.Checksum
}
files[rel] = candidate
return nil
}
for _, record := range runManifest.Stages {
if record == nil {
continue
}
for _, output := range record.Outputs {
if err := add(output.ArchivePath, output.Checksum); err != nil {
return nil, err
}
if output.ArchivePath == "" {
if err := add(output.LocalPath, output.Checksum); err != nil {
return nil, err
}
}
return nil
}
if d.IsDir() {
return nil
for _, path := range append(append([]string(nil), record.Logs...), record.GeneratedConfigs...) {
if err := add(path, ""); err != nil {
return nil, err
}
}
files = append(files, publishUploadFile{
RelativePath: rel,
LocalPath: path,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %q: %w", runRoot, err)
}
manifestInfo, err := os.Stat(manifestPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("manifest.json not found (checked path %q)", manifestPath)
}
return nil, fmt.Errorf("stat %q: %w", manifestPath, err)
}
if manifestInfo.IsDir() {
return nil, fmt.Errorf("manifest path %q is a directory", manifestPath)
}
files = append(files, publishUploadFile{
RelativePath: "manifest.json",
LocalPath: manifestPath,
})
seen := map[string]publishUploadFile{}
out := make([]publishUploadFile, 0, len(files))
for _, file := range files {
seen[file.RelativePath] = file
out = append(out, file)
}
files = files[:0]
for _, file := range seen {
files = append(files, file)
}
sort.Slice(files, func(i, j int) bool {
return files[i].RelativePath < files[j].RelativePath
})
return files, nil
sort.Slice(out, func(i, j int) bool { return out[i].RelativePath < out[j].RelativePath })
return out, nil
}
func publishRunPathExcluded(rel string) bool {
return rel == "audio" || strings.HasPrefix(rel, "audio/") ||
rel == "extract/notarius-output" || strings.HasPrefix(rel, "extract/notarius-output/")
}
func collectPublishPreviousFiles(previousDir string) ([]publishUploadFile, error) {
func collectPublishPreviousFiles(ctx context.Context, previousDir string) ([]publishUploadFile, *os.File, error) {
previousDir = filepath.Clean(strings.TrimSpace(previousDir))
if previousDir == "" {
return nil, fmt.Errorf("previous directory is required")
if previousDir == "" || previousDir == "." {
return nil, nil, fmt.Errorf("previous directory is required")
}
info, err := os.Lstat(previousDir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil, nil
}
info, err := os.Stat(previousDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("stat %q: %w", previousDir, err)
return nil, nil, fmt.Errorf("inspect previous directory: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("previous path %q is not a directory", previousDir)
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return nil, nil, fmt.Errorf("previous directory is not a regular directory")
}
files := make([]publishUploadFile, 0, 16)
err = filepath.WalkDir(previousDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
manifestFile, err := fileops.OpenConfinedRegularFile(previousDir, "manifest.json")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil, nil
}
if d.IsDir() {
return nil, nil, fmt.Errorf("open previous manifest: %w", err)
}
previousManifest, err := (&manifest.LocalStore{}).LoadReader(ctx, manifestFile)
if err != nil {
_ = manifestFile.Close()
return nil, nil, fmt.Errorf("load previous manifest: %w", err)
}
if _, err := manifestFile.Seek(0, io.SeekStart); err != nil {
_ = manifestFile.Close()
return nil, nil, fmt.Errorf("rewind previous manifest: %w", err)
}
files := map[string]publishUploadFile{
"manifest.json": {RelativePath: filepath.ToSlash(filepath.Join(config.PathPreviousDirSegment, "manifest.json")), SourceRelativePath: "manifest.json"},
}
add := func(path, checksum string) error {
if strings.TrimSpace(path) == "" {
return nil
}
rel, err := filepath.Rel(previousDir, path)
rel, err := pathsafe.SlashRelativeFromRoot(previousDir, path)
if err != nil {
return fmt.Errorf("relative path from %q to %q: %w", previousDir, path, err)
return nil
}
rel = filepath.ToSlash(rel)
files = append(files, publishUploadFile{
RelativePath: filepath.ToSlash(filepath.Join(config.PathPreviousDirSegment, rel)),
LocalPath: path,
})
archivePath := filepath.ToSlash(filepath.Join(config.PathPreviousDirSegment, rel))
candidate := publishUploadFile{RelativePath: archivePath, SourceRelativePath: rel, Checksum: strings.TrimSpace(checksum)}
if prior, exists := files[rel]; exists && prior.Checksum != "" && candidate.Checksum != "" && prior.Checksum != candidate.Checksum {
return fmt.Errorf("source %q has conflicting declared checksums", rel)
}
if prior, exists := files[rel]; exists && prior.Checksum != "" {
candidate.Checksum = prior.Checksum
}
files[rel] = candidate
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %q: %w", previousDir, err)
}
sort.Slice(files, func(i, j int) bool {
return files[i].RelativePath < files[j].RelativePath
})
return files, nil
for _, artifact := range previousManifest.Artifacts {
if err := add(artifact.ArchivePath, artifact.Checksum); err != nil {
_ = manifestFile.Close()
return nil, nil, err
}
if artifact.ArchivePath == "" {
if err := add(artifact.LocalPath, artifact.Checksum); err != nil {
_ = manifestFile.Close()
return nil, nil, err
}
}
}
for _, record := range previousManifest.Stages {
if record == nil {
continue
}
for _, artifact := range record.Outputs {
if err := add(artifact.ArchivePath, artifact.Checksum); err != nil {
_ = manifestFile.Close()
return nil, nil, err
}
if artifact.ArchivePath == "" {
if err := add(artifact.LocalPath, artifact.Checksum); err != nil {
_ = manifestFile.Close()
return nil, nil, err
}
}
}
for _, path := range append(append([]string(nil), record.Logs...), record.GeneratedConfigs...) {
if err := add(path, ""); err != nil {
_ = manifestFile.Close()
return nil, nil, err
}
}
}
out := make([]publishUploadFile, 0, len(files))
for _, file := range files {
out = append(out, file)
}
sort.Slice(out, func(i, j int) bool { return out[i].RelativePath < out[j].RelativePath })
return out, manifestFile, nil
}
func resolvePublishRunManifestSource(runRoot string) (string, error) {
path := filepath.Join(filepath.Clean(runRoot), "manifest.json")
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("manifest.json not found in run root %q", runRoot)
func openPublishRunFiles(runRoot string, files []publishUploadFile, manifestFile *os.File) ([]*os.File, error) {
sources := make([]*os.File, 0, len(files))
for _, candidate := range files {
if manifestFile == nil {
closePublishSources(sources)
return nil, fmt.Errorf("archive manifest is required")
}
return "", fmt.Errorf("stat %q: %w", path, err)
file := manifestFile
if candidate.SourceRelativePath != "manifest.json" {
var err error
file, err = fileops.OpenConfinedRegularFile(runRoot, candidate.SourceRelativePath)
if err != nil {
closePublishSources(sources)
return nil, err
}
}
if err := verifyPublishSourceChecksum(file, candidate.RelativePath, candidate.Checksum); err != nil {
if file != manifestFile {
_ = file.Close()
}
closePublishSources(sources)
return nil, err
}
sources = append(sources, file)
}
if info.IsDir() {
return "", fmt.Errorf("manifest path %q is a directory", path)
return sources, nil
}
func verifyPublishSourceChecksum(file *os.File, relativePath, expected string) error {
if strings.TrimSpace(expected) == "" {
return nil
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("read source %q for checksum: %w", relativePath, err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("rewind source %q after checksum: %w", relativePath, err)
}
if actual := hex.EncodeToString(hash.Sum(nil)); !strings.EqualFold(actual, expected) {
return fmt.Errorf("source %q does not match its declared checksum", relativePath)
}
return nil
}
func closePublishSources(sources []*os.File) {
for _, source := range sources {
_ = source.Close()
}
return path, nil
}
func directoryExists(path string) (bool, error) {