Protect restore planning from symlinks
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user