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,7 @@ package app
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
@@ -267,6 +268,71 @@ func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) {
}
}
func TestRestorePlanSymlinksRemainNonRegularConflicts(t *testing.T) {
for _, tt := range []struct {
name string
setup func(t *testing.T, sessionRoot string)
}{
{
name: "leaf",
setup: func(t *testing.T, sessionRoot string) {
t.Helper()
outside := filepath.Join(t.TempDir(), "recap.md")
mustWriteTestFile(t, outside, "remote-content\n")
if err := os.MkdirAll(filepath.Join(sessionRoot, "artifacts"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.Symlink(outside, filepath.Join(sessionRoot, "artifacts", "session_recap.md")); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
},
},
{
name: "ancestor",
setup: func(t *testing.T, sessionRoot string) {
t.Helper()
outside := t.TempDir()
mustWriteTestFile(t, filepath.Join(outside, "session_recap.md"), "remote-content\n")
if err := os.Symlink(outside, filepath.Join(sessionRoot, "artifacts")); err != nil {
t.Skipf("Symlink() unavailable: %v", err)
}
},
},
} {
t.Run(tt.name, func(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"))
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
tt.setup(t, sessionRoot)
for _, options := range []RestorePlanOptions{{DryRun: true}, {Force: true}} {
plan, err := buildRestorePlan(context.Background(), cfg, current, store, options)
if err != nil {
t.Fatalf("buildRestorePlan(%+v) error = %v", options, err)
}
action := restoreActionForPath(t, plan, "artifacts/session_recap.md")
if action.Kind != RestoreActionConflict || action.ConflictKind != RestoreConflictNonRegular {
t.Fatalf("action = %#v, want non-regular conflict", action)
}
}
})
}
}
func restoreActionForPath(t *testing.T, plan *RestorePlan, path string) RestoreAction {
t.Helper()
for _, action := range plan.Actions {
if action.LocalRelativePath == path {
return action
}
}
t.Fatalf("restore action for %q not found", path)
return RestoreAction{}
}
func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)