package acp_test import ( "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/acp" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) func TestResolveSafePath_InsideWorktree(t *testing.T) { t.Parallel() tmp := t.TempDir() target := filepath.Join(tmp, "sub", "a.txt") require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) require.NoError(t, os.WriteFile(target, []byte("x"), 0o644)) got, err := acp.ResolveSafePath(tmp, target) require.NoError(t, err) // EvalSymlinks may resolve macOS / temp paths via /private prefix; on // Windows the path may be returned as-is. Compare via filepath.Clean. assert.Equal(t, filepath.Clean(target), got) got, err = acp.ResolveSafePath(tmp, "sub/a.txt") require.NoError(t, err) assert.Equal(t, filepath.Clean(target), got) } func TestResolveSafePath_AbsoluteOutside(t *testing.T) { t.Parallel() tmp := t.TempDir() // On Windows /etc/passwd is interpreted relative to the current drive; // use a guaranteed-outside path. outsideAbs := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc", "passwd") _, err := acp.ResolveSafePath(tmp, outsideAbs) ae, ok := errs.As(err) require.True(t, ok) assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code) } func TestResolveSafePath_RelativeWithDotDot(t *testing.T) { t.Parallel() tmp := t.TempDir() _, err := acp.ResolveSafePath(tmp, "../etc/passwd") ae, ok := errs.As(err) require.True(t, ok) assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code) } func TestResolveSafePath_PrefixSiblingTrap(t *testing.T) { t.Parallel() tmp := t.TempDir() sibling := tmp + "_sibling" require.NoError(t, os.MkdirAll(sibling, 0o755)) t.Cleanup(func() { _ = os.RemoveAll(sibling) }) _, err := acp.ResolveSafePath(tmp, sibling+string(filepath.Separator)+"file") ae, ok := errs.As(err) require.True(t, ok) assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code) } func TestResolveSafePath_SymlinkEscape(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink test requires admin on Windows") } t.Parallel() tmp := t.TempDir() outside := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(outside, "secret"), []byte("s"), 0o644)) link := filepath.Join(tmp, "link-to-secret") require.NoError(t, os.Symlink(filepath.Join(outside, "secret"), link)) _, err := acp.ResolveSafePath(tmp, link) ae, ok := errs.As(err) require.True(t, ok) assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code) } func TestResolveSafePath_NonexistentInsideOK(t *testing.T) { t.Parallel() tmp := t.TempDir() want := filepath.Join(tmp, "newfile.txt") got, err := acp.ResolveSafePath(tmp, want) require.NoError(t, err) assert.Equal(t, want, got) // 多级不存在的中间目录(祖先均为 cwd 内真实目录)同样合法,不能误杀 want = filepath.Join(tmp, "newdir", "sub", "newfile.txt") got, err = acp.ResolveSafePath(tmp, want) require.NoError(t, err) assert.Equal(t, want, got) } // 父目录是指向外部的 symlink 且叶子文件不存在 → 必须被拒, // 否则 fs/write_text_file 会跟随 symlink 把新建文件写出 worktree。 func TestResolveSafePath_SymlinkDirEscapeNonexistentLeaf(t *testing.T) { t.Parallel() tmp := t.TempDir() outside := t.TempDir() link := filepath.Join(tmp, "link-dir") if err := os.Symlink(outside, link); err != nil { t.Skipf("symlink not supported on this platform: %v", err) } // 叶子不存在 _, err := acp.ResolveSafePath(tmp, filepath.Join(link, "newfile.txt")) ae, ok := errs.As(err) require.True(t, ok) assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code) // 叶子与中间目录都不存在(symlink 是更远的祖先)同样被拒 _, err = acp.ResolveSafePath(tmp, filepath.Join(link, "sub", "newfile.txt")) ae, ok = errs.As(err) require.True(t, ok) assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code) } func TestResolveSafePath_RelativeCwdRejected(t *testing.T) { t.Parallel() _, err := acp.ResolveSafePath("relative-cwd", "x") ae, ok := errs.As(err) require.True(t, ok) assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code) }