Protect restore planning from symlinks

This commit is contained in:
2026-09-02 00:51:19 +00:00
parent 1e418d6929
commit 45b27ad562
6 changed files with 201 additions and 23 deletions

View File

@@ -2,6 +2,8 @@ package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -14,6 +16,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"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/pathsafe"
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
)
@@ -80,12 +83,21 @@ func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCu
return nil, fmt.Errorf("remote object store is required")
}
sessionPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
actions, err := buildCurrentRestoreActions(ctx, current, store, sessionPaths, opts)
if err := fileops.EnsureWorkspaceDirectory(sessionPaths.Root); err != nil {
return nil, fmt.Errorf("create session root for restore planning: %w", err)
}
sessionRoot, err := os.OpenRoot(sessionPaths.Root)
if err != nil {
return nil, fmt.Errorf("open session root for restore planning: %w", err)
}
defer func() { _ = sessionRoot.Close() }()
actions, err := buildCurrentRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts)
if err != nil {
return nil, err
}
previousActions, err := buildPreviousCacheRestoreActions(ctx, cfg, sessionPaths, store, opts.Force)
previousActions, err := buildPreviousCacheRestoreActions(ctx, cfg, sessionPaths, sessionRoot, store, opts.Force)
if err != nil {
return nil, err
}
@@ -119,12 +131,13 @@ func buildCurrentRestoreActions(
current *RemoteCurrentState,
store storage.ObjectStore,
sessionPaths artifacts.SessionPaths,
sessionRoot *os.Root,
opts RestorePlanOptions,
) ([]RestoreAction, error) {
if current != nil && current.Commit != nil {
return buildCommittedRestoreActions(ctx, current, store, sessionPaths, opts)
return buildCommittedRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts)
}
return buildLegacyRestoreActions(ctx, current, store, sessionPaths, opts)
return buildLegacyRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts)
}
func buildCommittedRestoreActions(
@@ -132,6 +145,7 @@ func buildCommittedRestoreActions(
current *RemoteCurrentState,
store storage.ObjectStore,
sessionPaths artifacts.SessionPaths,
sessionRoot *os.Root,
opts RestorePlanOptions,
) ([]RestoreAction, error) {
if current == nil || current.Commit == nil {
@@ -156,7 +170,7 @@ func buildCommittedRestoreActions(
}
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{
Key: artifact.DestinationKey, Size: artifact.Size, ETag: artifact.Generation,
}, artifact.SHA256, rel, localPath, opts.Force)
}, artifact.SHA256, rel, localPath, sessionRoot, opts.Force)
if err != nil {
return nil, fmt.Errorf("classify committed object %q: %w", artifact.DestinationKey, err)
}
@@ -173,6 +187,7 @@ func buildLegacyRestoreActions(
current *RemoteCurrentState,
store storage.ObjectStore,
sessionPaths artifacts.SessionPaths,
sessionRoot *os.Root,
opts RestorePlanOptions,
) ([]RestoreAction, error) {
prefix := normalizeRemoteKey(current.SessionPrefix)
@@ -212,7 +227,7 @@ func buildLegacyRestoreActions(
if err != nil {
return nil, fmt.Errorf("map remote key %q: %w", key, err)
}
action, err := classifyRestoreAction(ctx, store, object, "", rel, localPath, opts.Force)
action, err := classifyRestoreAction(ctx, store, object, "", rel, localPath, sessionRoot, opts.Force)
if err != nil {
return nil, fmt.Errorf("classify remote key %q: %w", key, err)
}
@@ -337,6 +352,7 @@ func buildPreviousCacheRestoreActions(
ctx context.Context,
cfg *config.Config,
sessionPaths artifacts.SessionPaths,
sessionRoot *os.Root,
store storage.ObjectStore,
force bool,
) ([]RestoreAction, error) {
@@ -359,7 +375,7 @@ func buildPreviousCacheRestoreActions(
for _, record := range plan.Records {
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{
Key: record.RemoteKey, Size: record.Size, ETag: record.Generation,
}, record.SHA256, record.LocalRelativePath, record.LocalPath, force)
}, record.SHA256, record.LocalRelativePath, record.LocalPath, sessionRoot, force)
if err != nil {
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
}
@@ -376,6 +392,7 @@ func classifyRestoreAction(
expectedSHA256 string,
localRelPath string,
localPath string,
sessionRoot *os.Root,
force bool,
) (RestoreAction, error) {
action := RestoreAction{
@@ -390,33 +407,37 @@ func classifyRestoreAction(
action.Generation = strings.TrimSpace(object.ETag)
}
info, err := os.Stat(localPath)
target, err := inspectRestoreTarget(sessionRoot, localRelPath)
if err != nil {
if os.IsNotExist(err) {
action.Kind = RestoreActionDownload
action.Reason = "local file missing"
return action, nil
}
return RestoreAction{}, fmt.Errorf("stat local file: %w", err)
return RestoreAction{}, err
}
if target.kind == restoreTargetMissing {
action.Kind = RestoreActionDownload
action.Reason = "local file missing"
return action, nil
}
action.ExistsLocal = true
if info.IsDir() {
if target.kind == restoreTargetDirectory {
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictDirectory
action.Reason = "local path is a directory"
return action, nil
}
if !info.Mode().IsRegular() {
if target.kind == restoreTargetNonRegular {
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictNonRegular
action.Reason = "local path is not a regular file"
return action, nil
}
defer func() { _ = target.file.Close() }()
info, err := target.file.Stat()
if err != nil {
return RestoreAction{}, fmt.Errorf("stat opened local file: %w", err)
}
if action.SHA256 != "" {
localDigest, err := artifacts.SHA256File(localPath)
localDigest, err := sha256OpenedFile(target.file)
if err != nil {
return RestoreAction{}, fmt.Errorf("checksum local file: %w", err)
}
@@ -464,7 +485,7 @@ func classifyRestoreAction(
return action, nil
}
localDigest, err := artifacts.SHA256File(localPath)
localDigest, err := sha256OpenedFile(target.file)
if err != nil {
return RestoreAction{}, fmt.Errorf("checksum local file: %w", err)
}
@@ -499,6 +520,86 @@ func classifyRestoreAction(
return action, nil
}
type restoreTargetKind uint8
const (
restoreTargetMissing restoreTargetKind = iota
restoreTargetRegular
restoreTargetDirectory
restoreTargetNonRegular
)
type restoreTarget struct {
kind restoreTargetKind
file *os.File
}
func inspectRestoreTarget(root *os.Root, relative string) (restoreTarget, error) {
if root == nil {
return restoreTarget{}, fmt.Errorf("restore session root is required")
}
name := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relative)))
if name == "." || name == ".." || filepath.IsAbs(name) || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
return restoreTarget{}, fmt.Errorf("restore path %q is not a normalized relative path", relative)
}
parts := strings.Split(name, string(filepath.Separator))
current := root
var opened []*os.Root
defer func() {
for _, directory := range opened {
_ = directory.Close()
}
}()
for _, part := range parts[:len(parts)-1] {
info, err := current.Lstat(part)
if errors.Is(err, os.ErrNotExist) {
return restoreTarget{kind: restoreTargetMissing}, nil
}
if err != nil {
return restoreTarget{}, fmt.Errorf("inspect restore ancestor %q: %w", part, err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return restoreTarget{kind: restoreTargetNonRegular}, nil
}
next, err := current.OpenRoot(part)
if err != nil {
return restoreTarget{}, fmt.Errorf("open restore ancestor %q: %w", part, err)
}
opened = append(opened, next)
current = next
}
leaf := parts[len(parts)-1]
info, err := current.Lstat(leaf)
if errors.Is(err, os.ErrNotExist) {
return restoreTarget{kind: restoreTargetMissing}, nil
}
if err != nil {
return restoreTarget{}, fmt.Errorf("inspect restore target %q: %w", relative, err)
}
if info.IsDir() {
return restoreTarget{kind: restoreTargetDirectory}, nil
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return restoreTarget{kind: restoreTargetNonRegular}, nil
}
file, err := current.Open(leaf)
if err != nil {
return restoreTarget{}, fmt.Errorf("open restore target %q: %w", relative, err)
}
return restoreTarget{kind: restoreTargetRegular, file: file}, nil
}
func sha256OpenedFile(file *os.File) (string, error) {
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func restoreActionIsAudio(action RestoreAction) bool {
return restoreRelativePathIsAudio(action.LocalRelativePath)
}