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') }