Constrain restore installation to session root
This commit is contained in:
@@ -62,6 +62,9 @@ Execution order and safety:
|
|||||||
- manifest replacement is validated before rename;
|
- manifest replacement is validated before rename;
|
||||||
- each committed object is verified against its declared checksum, size, and
|
- each committed object is verified against its declared checksum, size, and
|
||||||
generation before installation;
|
generation before installation;
|
||||||
|
- installation reopens the selected session root, creates only validated
|
||||||
|
missing directory components with workspace modes, and rejects symlinked or
|
||||||
|
non-regular ancestors and leaves before replacement;
|
||||||
- a committed manifest already verified during discovery is retained for the
|
- a committed manifest already verified during discovery is retained for the
|
||||||
matching restore action and revalidated before installation, avoiding a
|
matching restore action and revalidated before installation, avoiding a
|
||||||
second body transfer;
|
second body transfer;
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
|
|||||||
- campaign-aware session root is mandatory.
|
- campaign-aware session root is mandatory.
|
||||||
- manifest-driven stage state is durable across runs.
|
- manifest-driven stage state is durable across runs.
|
||||||
- cleanup guardrails prevent destructive root/out-of-scope deletion.
|
- cleanup guardrails prevent destructive root/out-of-scope deletion.
|
||||||
|
- restore installation is confined to validated relative paths beneath the
|
||||||
|
selected session root.
|
||||||
- ordinary workspace paths retain group-writable directory and file modes across
|
- ordinary workspace paths retain group-writable directory and file modes across
|
||||||
nested creation, replacement, and Notarius promotion.
|
nested creation, replacement, and Notarius promotion.
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,10 @@ Restore treats a local leaf symlink or any symlinked ancestor beneath the
|
|||||||
selected session root as a conflict, even when the linked content matches the
|
selected session root as a conflict, even when the linked content matches the
|
||||||
selected remote object. `--force` does not override that conflict.
|
selected remote object. `--force` does not override that conflict.
|
||||||
|
|
||||||
|
Before each install, restore validates the destination again beneath the
|
||||||
|
selected session root. A target changed into a symlink, directory, or other
|
||||||
|
non-regular entry is not overwritten.
|
||||||
|
|
||||||
Restore writes an execution report at `reports/restore-latest.json`.
|
Restore writes an execution report at `reports/restore-latest.json`.
|
||||||
|
|
||||||
If restore fails after beginning installation, it leaves a durable
|
If restore fails after beginning installation, it leaves a durable
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ symlink and classify its target as an already matching regular file.
|
|||||||
|
|
||||||
## Stage 6 — Install Restore Results Through The Session Root
|
## Stage 6 — Install Restore Results Through The Session Root
|
||||||
|
|
||||||
**Status: Pending**
|
**Status: Completed**
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
|
|||||||
@@ -564,6 +564,9 @@ Safe fix:
|
|||||||
- rerun with `--force` only when remote state should overwrite an eligible
|
- rerun with `--force` only when remote state should overwrite an eligible
|
||||||
regular local file; repair a symlink or other non-regular conflict first.
|
regular local file; repair a symlink or other non-regular conflict first.
|
||||||
|
|
||||||
|
If the filesystem changed after the plan was printed, rerun restore after
|
||||||
|
repairing the destination; installation performs the same safety check again.
|
||||||
|
|
||||||
Relevant reference: [Operations: Restore Workflow](./operations.md#restore-workflow).
|
Relevant reference: [Operations: Restore Workflow](./operations.md#restore-workflow).
|
||||||
|
|
||||||
## Restore current-state discovery failure
|
## Restore current-state discovery failure
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -46,6 +47,14 @@ func executeRestorePlan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if err := fileops.EnsureWorkspaceDirectory(sessionRoot); err != nil {
|
||||||
|
return nil, fmt.Errorf("create session root for restore: %w", err)
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(sessionRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open session root for restore: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = root.Close() }()
|
||||||
manifestActions := make([]RestoreAction, 0, 1)
|
manifestActions := make([]RestoreAction, 0, 1)
|
||||||
actions := make([]RestoreAction, 0, len(plan.Actions))
|
actions := make([]RestoreAction, 0, len(plan.Actions))
|
||||||
for _, action := range plan.Actions {
|
for _, action := range plan.Actions {
|
||||||
@@ -67,7 +76,7 @@ func executeRestorePlan(
|
|||||||
|
|
||||||
result := &RestoreExecutionResult{}
|
result := &RestoreExecutionResult{}
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
if err := executeRestoreDownloadAction(ctx, cfg, sessionRoot, current, action, store); err != nil {
|
if err := executeRestoreDownloadAction(ctx, cfg, sessionRoot, root, current, action, store); err != nil {
|
||||||
if report != nil {
|
if report != nil {
|
||||||
report.markFailed(action, err)
|
report.markFailed(action, err)
|
||||||
}
|
}
|
||||||
@@ -86,6 +95,7 @@ func executeRestoreDownloadAction(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
sessionRoot string,
|
sessionRoot string,
|
||||||
|
root *os.Root,
|
||||||
current *RemoteCurrentState,
|
current *RemoteCurrentState,
|
||||||
action RestoreAction,
|
action RestoreAction,
|
||||||
store storage.ObjectStore,
|
store storage.ObjectStore,
|
||||||
@@ -97,6 +107,14 @@ func executeRestoreDownloadAction(
|
|||||||
if strings.TrimSpace(action.LocalPath) != "" && filepath.Clean(action.LocalPath) != safeLocalPath {
|
if strings.TrimSpace(action.LocalPath) != "" && filepath.Clean(action.LocalPath) != safeLocalPath {
|
||||||
return fmt.Errorf("restore plan local path mismatch for %q", action.LocalRelativePath)
|
return fmt.Errorf("restore plan local path mismatch for %q", action.LocalRelativePath)
|
||||||
}
|
}
|
||||||
|
parent, leaf, err := openRestoreDestinationParent(root, action.LocalRelativePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = parent.Close() }()
|
||||||
|
if err := validateRestoreDestinationLeaf(parent, leaf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if restoreActionIsAudio(action) {
|
if restoreActionIsAudio(action) {
|
||||||
return executeRestoreAudioAction(ctx, cfg, safeLocalPath, action, store)
|
return executeRestoreAudioAction(ctx, cfg, safeLocalPath, action, store)
|
||||||
@@ -135,12 +153,18 @@ func executeRestoreDownloadAction(
|
|||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return fmt.Errorf("close restored manifest: %w", closeErr)
|
return fmt.Errorf("close restored manifest: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
if err := validateRestoreDestinationLeaf(parent, leaf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := (&manifest.LocalStore{}).Save(ctx, safeLocalPath, restored); err != nil {
|
if err := (&manifest.LocalStore{}).Save(ctx, safeLocalPath, restored); err != nil {
|
||||||
return fmt.Errorf("install rebased manifest atomically: %w", err)
|
return fmt.Errorf("install rebased manifest atomically: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateRestoreDestinationLeaf(parent, leaf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := fileops.InstallSiblingTempFile(temporaryPath, safeLocalPath, fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.InstallSiblingTempFile(temporaryPath, safeLocalPath, fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("install file atomically: %w", err)
|
return fmt.Errorf("install file atomically: %w", err)
|
||||||
}
|
}
|
||||||
@@ -148,6 +172,71 @@ func executeRestoreDownloadAction(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openRestoreDestinationParent(root *os.Root, relative string) (*os.Root, string, error) {
|
||||||
|
if root == nil {
|
||||||
|
return nil, "", 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 nil, "", fmt.Errorf("restore path %q is not a normalized relative path", relative)
|
||||||
|
}
|
||||||
|
parts := strings.Split(name, string(filepath.Separator))
|
||||||
|
current := root
|
||||||
|
for _, part := range parts[:len(parts)-1] {
|
||||||
|
info, err := current.Lstat(part)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
if err := current.Mkdir(part, fileops.WorkspaceDirectoryMode.Perm()); err != nil && !errors.Is(err, os.ErrExist) {
|
||||||
|
if current != root {
|
||||||
|
_ = current.Close()
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("create restore destination directory %q: %w", part, err)
|
||||||
|
}
|
||||||
|
info, err = current.Lstat(part)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if current != root {
|
||||||
|
_ = current.Close()
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("inspect restore destination ancestor %q: %w", part, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
if current != root {
|
||||||
|
_ = current.Close()
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("restore destination ancestor %q is not a directory", part)
|
||||||
|
}
|
||||||
|
if err := current.Chmod(part, fileops.WorkspaceDirectoryMode); err != nil {
|
||||||
|
if current != root {
|
||||||
|
_ = current.Close()
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("set restore destination directory permissions %q: %w", part, err)
|
||||||
|
}
|
||||||
|
next, err := current.OpenRoot(part)
|
||||||
|
if current != root {
|
||||||
|
_ = current.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("open restore destination ancestor %q: %w", part, err)
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return current, parts[len(parts)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRestoreDestinationLeaf(parent *os.Root, leaf string) error {
|
||||||
|
info, err := parent.Lstat(leaf)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect restore destination %q: %w", leaf, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return fmt.Errorf("restore destination %q is not an eligible regular file", leaf)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func isRestoreManifest(relativePath string) bool {
|
func isRestoreManifest(relativePath string) bool {
|
||||||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relativePath)))
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relativePath)))
|
||||||
return clean == config.PathManifestFile || clean == config.PathPreviousDirSegment+"/"+config.PathManifestFile
|
return clean == config.PathManifestFile || clean == config.PathPreviousDirSegment+"/"+config.PathManifestFile
|
||||||
|
|||||||
@@ -524,6 +524,36 @@ func TestExecuteRestorePlanPathMismatchFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteRestorePlanRejectsAncestorSymlinkCreatedAfterPlanning(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
outside := t.TempDir()
|
||||||
|
if err := os.Symlink(outside, filepath.Join(sessionRoot, "artifacts")); err != nil {
|
||||||
|
t.Skipf("Symlink() unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := newRestoreReport(current, plan, RestorePlanOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newRestoreReport() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = executeRestorePlan(context.Background(), cfg, current, plan, report, store)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not a directory") {
|
||||||
|
t.Fatalf("executeRestorePlan() error = %v, want ancestor conflict", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outside, "session_recap.md")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("outside target was changed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustReadRestoreReport(t *testing.T, path string) *RestoreReport {
|
func mustReadRestoreReport(t *testing.T, path string) *RestoreReport {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user