Files
narratio/internal/app/restore_manifest_paths.go

217 lines
7.2 KiB
Go

package app
import (
"context"
"fmt"
"io"
"path"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func prepareRestoredManifest(
ctx context.Context,
cfg *config.Config,
current *RemoteCurrentState,
source io.Reader,
destinationRoot string,
requireCurrentIdentity bool,
) (*manifest.Manifest, error) {
manifestStore := &manifest.LocalStore{}
m, err := manifestStore.LoadReader(ctx, source)
if err != nil {
return nil, fmt.Errorf("validate manifest decode: %w", err)
}
if requireCurrentIdentity {
if err := validateRestoredManifestIdentity(cfg, current, m); err != nil {
return nil, err
}
}
if err := rebaseRestoredManifestPaths(cfg, m, destinationRoot); err != nil {
return nil, err
}
return m, nil
}
func validateRestoredManifestIdentity(cfg *config.Config, current *RemoteCurrentState, m *manifest.Manifest) error {
if cfg == nil || cfg.Session == nil {
return fmt.Errorf("resolved session config is required")
}
requestedSession := strings.TrimSpace(cfg.Session.SessionID)
requestedCampaign := strings.TrimSpace(cfg.Session.Campaign)
manifestSession := strings.TrimSpace(m.SessionID)
manifestCampaign := strings.TrimSpace(m.Campaign)
if manifestSession != requestedSession {
return fmt.Errorf("manifest session_id %q does not match requested session_id %q", manifestSession, requestedSession)
}
if manifestCampaign == "" {
return fmt.Errorf("manifest campaign is required")
}
if manifestCampaign != requestedCampaign {
return fmt.Errorf("manifest campaign %q does not match requested campaign %q", manifestCampaign, requestedCampaign)
}
if current != nil {
if expected := strings.TrimSpace(current.SessionID); expected != "" && manifestSession != expected {
return fmt.Errorf("manifest session_id %q does not match discovered session_id %q", manifestSession, expected)
}
if expected := strings.TrimSpace(current.Campaign); expected != "" && manifestCampaign != expected {
return fmt.Errorf("manifest campaign %q does not match discovered campaign %q", manifestCampaign, expected)
}
if expected := strings.TrimSpace(current.RunID); expected != "" && strings.TrimSpace(m.RunID) != expected {
return fmt.Errorf("manifest run_id %q does not match discovered run_id %q", strings.TrimSpace(m.RunID), expected)
}
}
return nil
}
func rebaseRestoredManifestPaths(cfg *config.Config, m *manifest.Manifest, destinationRoot string) error {
if cfg == nil || cfg.Pipeline == nil || m == nil {
return fmt.Errorf("resolved config and manifest are required")
}
destinationRoot = filepath.Clean(strings.TrimSpace(destinationRoot))
if destinationRoot == "" || destinationRoot == "." {
return fmt.Errorf("restored manifest destination root is required")
}
producerRoot, hasProducerRoot := restoredManifestSessionRoot(m)
rebase := func(field, value string) (string, error) {
return rebaseRestoredLocalReference(field, value, producerRoot, hasProducerRoot, destinationRoot)
}
for i := range m.Inputs {
value, err := rebase("inputs.path", m.Inputs[i].Path)
if err != nil {
return err
}
m.Inputs[i].Path = value
// Spool and cache locations are host-local implementation details. They
// are deliberately not authoritative after a restore.
m.Inputs[i].SpoolPath = ""
m.Inputs[i].CachePath = ""
}
for i := range m.Artifacts {
value, err := rebase("artifacts.local_path", m.Artifacts[i].LocalPath)
if err != nil {
return err
}
m.Artifacts[i].LocalPath = value
}
for stageName, record := range m.Stages {
if record == nil {
continue
}
for i := range record.Outputs {
value, err := rebase("stages."+stageName+".outputs.local_path", record.Outputs[i].LocalPath)
if err != nil {
return err
}
record.Outputs[i].LocalPath = value
}
// Logs and generated configuration files are invocation-local diagnostics,
// not restored artifacts. Dropping them prevents a producer-machine path
// from becoming a usable local reference.
record.Logs = nil
record.GeneratedConfigs = nil
}
if runID := strings.TrimSpace(m.RunID); runID != "" {
m.LocalWorkDir = filepath.Join(destinationRoot, config.PathRunsDirSegment, runID)
} else {
m.LocalWorkDir = destinationRoot
}
m.LocalSpoolDir = ""
// A post-publish cleanup record is authority to delete producer-local
// directories. It must never cross a restore boundary.
m.PostPublishCleanup = nil
return nil
}
func restoredManifestSessionRoot(m *manifest.Manifest) (string, bool) {
if m == nil {
return "", false
}
runRoot := portableCleanPath(m.LocalWorkDir)
runID := strings.TrimSpace(m.RunID)
if !portableAbsolutePath(runRoot) || runID == "" || path.Base(runRoot) != runID {
return "", false
}
runsDir := path.Dir(runRoot)
if path.Base(runsDir) != config.PathRunsDirSegment {
return "", false
}
return path.Dir(runsDir), true
}
func rebaseRestoredLocalReference(field, value, producerRoot string, hasProducerRoot bool, destinationRoot string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
if portableDrivePath(value) && !portableAbsolutePath(value) {
return "", fmt.Errorf("%s has an unsafe relative path %q", field, value)
}
if portableAbsolutePath(value) {
if !hasProducerRoot {
return "", fmt.Errorf("%s has an absolute path without a producer session root", field)
}
relative, ok := portableRelativeWithinRoot(producerRoot, value)
if !ok || relative == "" {
return "", fmt.Errorf("%s absolute path is outside the producer session root", field)
}
resolved, err := joinWithinSessionRoot(destinationRoot, relative)
if err != nil {
return "", fmt.Errorf("%s: %w", field, err)
}
return resolved, nil
}
resolved, err := joinWithinSessionRoot(destinationRoot, strings.ReplaceAll(value, "\\", "/"))
if err != nil {
return "", fmt.Errorf("%s has an unsafe relative path: %w", field, err)
}
return resolved, nil
}
func portableRelativeWithinRoot(root, candidate string) (string, bool) {
root = strings.TrimSuffix(portableCleanPath(root), "/")
candidate = portableCleanPath(candidate)
if root == "" || candidate == "" {
return "", false
}
compareRoot, compareCandidate := root, candidate
if portableDrivePath(root) || portableDrivePath(candidate) || strings.HasPrefix(root, "//") || strings.HasPrefix(candidate, "//") {
compareRoot = strings.ToLower(compareRoot)
compareCandidate = strings.ToLower(compareCandidate)
}
if compareCandidate == compareRoot {
return "", true
}
if !strings.HasPrefix(compareCandidate, compareRoot+"/") {
return "", false
}
return strings.TrimPrefix(candidate, root+"/"), true
}
func portableCleanPath(value string) string {
value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if value == "" {
return ""
}
return path.Clean(value)
}
func portableAbsolutePath(value string) bool {
value = strings.TrimSpace(value)
return strings.HasPrefix(value, "/") || strings.HasPrefix(value, "\\") || (len(value) >= 3 && isASCIIAlpha(value[0]) && value[1] == ':' && (value[2] == '/' || value[2] == '\\'))
}
func portableDrivePath(value string) bool {
value = strings.TrimSpace(value)
return len(value) >= 2 && isASCIIAlpha(value[0]) && value[1] == ':'
}
func isASCIIAlpha(value byte) bool {
return (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z')
}