refactor(exec,io): 文件沙箱单入口收进 io 工具、删 exec.FileSandbox(裁决 13.4.4)+ 修一条假阳性安全断言
- 删除未接线的 exec.FileSandbox(含 5 个用例):文件沙箱从此只有 plugins/tools/io/file.go 一处实现 - io 侧取更严一侧:NUL 拒绝(原 io 缺)、写前按最深存在祖先判定落点(避免越界建目录)、roots 归化到真实路径(修 roots 自身是 symlink 时的全误拒) - exec/sandbox.go 保留并注释说明:那是**终端工作目录守卫**的原语(terminal.go 在用),不是文件沙箱 - 修假阳性:原 NUL 用例断言错误文本含 "NUL",而 t.TempDir() 目录名自带测试函数名 "…RejectsNULBytePath…"、错误又内嵌整条临时路径 ⇒ 删掉安全检查用例照样绿。改为哨兵错误 ErrNullByte + errors.Is(错误文本逐字节不变),修复后同一变异必红 - 同类排查:file_test.go 三处文本断言判定无风险(含空格的串不可能出现在临时目录名里),保留
This commit is contained in:
1 parent
95f97f5df3
commit
09ee561aa7
6 files changed
+344
-222
No files matched your search
@@ -1,99 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// FileSandbox confines file read, write, and list access to a set of allowed
|
||||
// root directories (DevDocs/design/04-运行时.md §8, design/11 §5). Every path is
|
||||
// validated by [Resolve] before use, which rejects absolute paths outside the
|
||||
// roots, relative paths that escape them, and symlinks that resolve outside
|
||||
// them.
|
||||
type FileSandbox struct {
|
||||
mu sync.RWMutex
|
||||
roots []string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewFileSandbox builds a FileSandbox confined to roots. Raw roots are made
|
||||
// absolute and cleaned. The logger is required.
|
||||
func NewFileSandbox(roots []string, log *slog.Logger) (*FileSandbox, error) {
|
||||
if log == nil {
|
||||
return nil, ErrNilLogger
|
||||
}
|
||||
return &FileSandbox{
|
||||
roots: absCleaners(roots),
|
||||
log: logger.WithSource(log, "runtime/exec"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetRoots atomically replaces the allowed roots. It enables a live config
|
||||
// reload without racing in-flight Resolve calls.
|
||||
func (s *FileSandbox) SetRoots(roots []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.roots = absCleaners(roots)
|
||||
}
|
||||
|
||||
// Resolve validates path against the current allowed roots and returns its
|
||||
// absolute, symlink-resolved location, or an error if it would escape. A
|
||||
// relative path is resolved against the first allowed root.
|
||||
func (s *FileSandbox) Resolve(path string) (string, error) {
|
||||
roots := s.rootsLocked()
|
||||
if len(roots) == 0 {
|
||||
return "", ErrNoAllowedRoot
|
||||
}
|
||||
return resolveInsideRoot(path, roots)
|
||||
}
|
||||
|
||||
// ReadFile reads a file confined to the allowed roots.
|
||||
func (s *FileSandbox) ReadFile(path string) ([]byte, error) {
|
||||
abs, err := s.Resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec: read %s: %w", abs, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// WriteFile writes data to a path confined to the allowed roots.
|
||||
func (s *FileSandbox) WriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
abs, err := s.Resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(abs, data, perm); err != nil {
|
||||
return fmt.Errorf("exec: write %s: %w", abs, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the directory entries of a directory confined to the allowed
|
||||
// roots.
|
||||
func (s *FileSandbox) List(dir string) ([]os.DirEntry, error) {
|
||||
abs, err := s.Resolve(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec: list %s: %w", abs, err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *FileSandbox) rootsLocked() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
roots := make([]string, len(s.roots))
|
||||
copy(roots, s.roots)
|
||||
return roots
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newFileSandbox(t *testing.T, roots ...string) *FileSandbox {
|
||||
t.Helper()
|
||||
fs, err := NewFileSandbox(roots, discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileSandbox: %v", err)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_Allowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
realRoot := root
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
realRoot = r
|
||||
}
|
||||
fs := newFileSandbox(t, root)
|
||||
|
||||
got, err := fs.Resolve(filepath.Join(root, "a.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if want := filepath.Join(realRoot, "a.txt"); got != want {
|
||||
t.Errorf("Resolve = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_OutsideRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, t.TempDir())
|
||||
outside := filepath.Join(os.TempDir(), "cyrene-outside-file.txt")
|
||||
_, err := fs.Resolve(outside)
|
||||
if !errors.Is(err, ErrPathOutsideRoot) {
|
||||
t.Fatalf("err = %v, want ErrPathOutsideRoot", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_SymlinkEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(root, "escape")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
fs := newFileSandbox(t, root)
|
||||
|
||||
_, err := fs.Resolve(filepath.Join(link, "secret.txt"))
|
||||
if !errors.Is(err, ErrSymlinkEscape) {
|
||||
t.Fatalf("err = %v, want ErrSymlinkEscape", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_WriteReadList(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, t.TempDir())
|
||||
|
||||
if err := fs.WriteFile("a.txt", []byte("content"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
data, err := fs.ReadFile("a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != "content" {
|
||||
t.Errorf("ReadFile = %q, want content", data)
|
||||
}
|
||||
entries, err := fs.List(".")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("len(entries) = %d, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_NoRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, "")
|
||||
_, err := fs.Resolve("a.txt")
|
||||
if !errors.Is(err, ErrNoAllowedRoot) {
|
||||
t.Fatalf("err = %v, want ErrNoAllowedRoot", err)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@
|
||||
// Cyrene agent runtime (DevDocs/design/04-运行时.md §8 and design/02-会话与上下文模型.md §4).
|
||||
//
|
||||
// A Provider is the grant-based, sandboxed entry point through which the agent
|
||||
// may run code on its own host. This milestone ships two concrete pieces:
|
||||
// may run code on its own host. This milestone ships one concrete piece:
|
||||
//
|
||||
// - [TerminalExecutor], the default command provider, which runs allowlisted
|
||||
// commands inside a locked-down terminal sandbox (command allowlist, working
|
||||
// directory confinement, environment allowlist, timeout, output truncation).
|
||||
// - [FileSandbox], which confines file read/write/list access to a set of
|
||||
// allowed root directories.
|
||||
//
|
||||
// 文件读写的路径沙箱不在本包:按裁决 13.4.4(design/04 §4),文件访问的 roots
|
||||
// 收敛、symlink 逃逸与 NUL 拒绝统一由 plugins/tools/io 文件工具承载;本包内
|
||||
// sandbox.go 的收敛原语只服务于终端沙箱的工作目录 / roots 守卫。
|
||||
//
|
||||
// GUI operations and a real virtual machine are reserved for later milestones;
|
||||
// they surface only through the [Provider] interface and are not implemented
|
||||
|
||||
@@ -6,6 +6,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是终端沙箱的路径收敛原语(roots 清洗、词法包含判定、symlink 逃逸解析),
|
||||
// 由 terminal.go 用于工作目录守卫。**它不是文件沙箱**:文件读写的沙箱已按裁决
|
||||
// 13.4.4 收敛到 plugins/tools/io 文件工具(design/04 §4)。两侧保持同一收敛口径:
|
||||
// 词法 roots 包含 + 最深存在祖先的 symlink 解析 + NUL 拒绝。
|
||||
|
||||
// absCleaners returns a slice of absolute, clean paths from raw roots. Dirty or
|
||||
// empty entries are dropped; non-absolute roots are made absolute against the
|
||||
// process working directory.
|
||||
|
||||
+104
-27
@@ -20,6 +20,9 @@ var _ sdk.Tool = (*fileTool)(nil)
|
||||
// fileTool reads, writes, lists and searches files, confined to the configured
|
||||
// data directory plus any explicit whitelist. Paths are cleaned and re-checked
|
||||
// against those roots, so ".." traversal and symlink escapes are rejected.
|
||||
//
|
||||
// 本工具是文件访问沙箱的唯一实现处(裁决 13.4.4、design/04 §4):roots 收敛、
|
||||
// symlink 逃逸、NUL 字节与 ".." 穿越都在此判定,写入前先按真实落点校验父链。
|
||||
type fileTool struct {
|
||||
roots []string // absolute, cleaned allowed directories
|
||||
maxRead int64
|
||||
@@ -121,20 +124,24 @@ func (f *fileTool) read(ctx context.Context, p string) (any, error) {
|
||||
return map[string]any{"path": real, "size": len(data), "content": string(data)}, nil
|
||||
}
|
||||
|
||||
// write creates or overwrites a file, capped at maxWrite bytes and confined to
|
||||
// the allowed roots (parent dirs are created inside the root only).
|
||||
// write 创建或覆盖文件,内容上限 maxWrite 字节。流程:
|
||||
// 1. 写入前先算出目标的真实落点,确认仍在 roots 内(防 roots 内的 symlink 逃逸);
|
||||
// 2. 建目录;
|
||||
// 3. 建目录之后再核一遍父目录的真实落点——兜住建目录期间新出现的 symlink,以及
|
||||
// Windows 目录 junction 这类 filepath.EvalSymlinks 不解析的再解析点(此时解析
|
||||
// 报错即拒),保证严格度不低于收敛前的实现(裁决 13.4.4)。
|
||||
func (f *fileTool) write(ctx context.Context, p, content string) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clean, err := f.resolve(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(content)) > f.maxWrite {
|
||||
return nil, fmt.Errorf("io: file: content exceeds the %d-byte write limit", f.maxWrite)
|
||||
}
|
||||
parent := filepath.Dir(clean)
|
||||
target, err := f.resolveForCreate(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parent := filepath.Dir(target)
|
||||
if err := os.MkdirAll(parent, dirPerm); err != nil {
|
||||
return nil, fmt.Errorf("io: file: mkdir: %w", err)
|
||||
}
|
||||
@@ -142,19 +149,14 @@ func (f *fileTool) write(ctx context.Context, p, content string) (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: file: resolve parent: %w", err)
|
||||
}
|
||||
if !f.within(realParent) {
|
||||
return nil, fmt.Errorf("io: file: parent directory escapes the allowed directories")
|
||||
if !f.withinReal(realParent) {
|
||||
f.log.Warn("io file: rejected write escaping allowed directories", "path", p, "resolved", realParent)
|
||||
return nil, fmt.Errorf("io: file: %q escapes the allowed directories", p)
|
||||
}
|
||||
if info, err := os.Lstat(clean); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
real, err := filepath.EvalSymlinks(clean)
|
||||
if err != nil || !f.within(real) {
|
||||
return nil, fmt.Errorf("io: file: %q escapes the allowed directories", p)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(clean, []byte(content), filePerm); err != nil {
|
||||
if err := os.WriteFile(target, []byte(content), filePerm); err != nil {
|
||||
return nil, fmt.Errorf("io: file: write: %w", err)
|
||||
}
|
||||
return map[string]any{"path": clean, "size": len(content)}, nil
|
||||
return map[string]any{"path": target, "size": len(content)}, nil
|
||||
}
|
||||
|
||||
// list returns the entries of a directory, capped at maxList.
|
||||
@@ -246,13 +248,22 @@ func (f *fileTool) search(ctx context.Context, base, query string) (any, error)
|
||||
return map[string]any{"base": real, "query": query, "hits": hits, "count": len(hits)}, nil
|
||||
}
|
||||
|
||||
// resolve cleans p and verifies it stays within one of the allowed roots. It
|
||||
// rejects empty paths and any path that escapes via ".." or an absolute target
|
||||
// outside the roots.
|
||||
// ErrNullByte 表示路径中含 NUL 字节:在触达文件系统之前就被拒绝(收敛自原 exec 文件
|
||||
// 沙箱,裁决 13.4.4)。调用方与测试一律用 errors.Is(err, ErrNullByte) 判定——不要断言
|
||||
// 错误文本;此处只存不带来源前缀的文本,前缀由返回值处的 fmt.Errorf 补上,保证对外错误
|
||||
// 文本与收敛前逐字节一致。
|
||||
var ErrNullByte = errors.New("path contains a NUL byte")
|
||||
|
||||
// resolve 清洗路径并确认其词法上留在允许的 roots 之内:拒绝空路径与含 NUL 字节的
|
||||
// 路径(NUL 收敛自原 exec 文件沙箱,裁决 13.4.4),拒绝 ".." 穿越与越界的绝对路径;
|
||||
// 返回绝对、clean 的词法路径。
|
||||
func (f *fileTool) resolve(p string) (string, error) {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return "", fmt.Errorf("io: file: path is required")
|
||||
}
|
||||
if strings.IndexByte(p, 0) >= 0 {
|
||||
return "", fmt.Errorf("io: file: %w", ErrNullByte)
|
||||
}
|
||||
if len(f.roots) == 0 {
|
||||
return "", fmt.Errorf("io: file: no base directory configured")
|
||||
}
|
||||
@@ -267,9 +278,8 @@ func (f *fileTool) resolve(p string) (string, error) {
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// resolveExisting resolves p and verifies the symlink-resolved real path stays
|
||||
// within the roots, guarding against escaping through a symlink. It errors when
|
||||
// the target does not exist.
|
||||
// resolveExisting 解析一个必须已存在的路径:要求其 symlink 解析后的真实落点仍在
|
||||
// roots 的真实路径之内,防住经由 roots 内 symlink 的逃逸。目标不存在时报错。
|
||||
func (f *fileTool) resolveExisting(p string) (string, error) {
|
||||
clean, err := f.resolve(p)
|
||||
if err != nil {
|
||||
@@ -279,16 +289,56 @@ func (f *fileTool) resolveExisting(p string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("io: file: resolve %q: %w", p, err)
|
||||
}
|
||||
if !f.within(real) {
|
||||
if !f.withinReal(real) {
|
||||
f.log.Warn("io file: rejected path escaping allowed directories", "path", p, "resolved", real)
|
||||
return "", fmt.Errorf("io: file: %q escapes the allowed directories", p)
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
|
||||
// within reports whether p is inside (or equal to) one of the allowed roots.
|
||||
func (f *fileTool) within(p string) bool {
|
||||
for _, root := range f.roots {
|
||||
// resolveForCreate 解析一个可能尚不存在(即将创建)的写入目标:先推出最深存在祖先
|
||||
// 的真实落点,确认仍在 roots 内后返回真实路径(真实祖先 + 尚未创建的分量)。
|
||||
func (f *fileTool) resolveForCreate(p string) (string, error) {
|
||||
clean, err := f.resolve(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
real, err := resolveReal(clean)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("io: file: resolve %q: %w", p, err)
|
||||
}
|
||||
if !f.withinReal(real) {
|
||||
f.log.Warn("io file: rejected write escaping allowed directories", "path", p, "resolved", real)
|
||||
return "", fmt.Errorf("io: file: %q escapes the allowed directories", p)
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
|
||||
// within 报告 p 是否词法上位于(或等于)某个 root 之内。
|
||||
func (f *fileTool) within(p string) bool { return withinRoots(p, f.roots) }
|
||||
|
||||
// withinReal 报告真实落点 p 是否位于某个 root 的真实路径之内。
|
||||
func (f *fileTool) withinReal(p string) bool { return withinRoots(p, f.realRoots()) }
|
||||
|
||||
// realRoots 返回各 root 的真实路径:存在则解析 symlink,尚未创建则按最深存在祖先
|
||||
// 推导。把「真实落点」与 roots 放进同一(真实)路径空间比较,既拦得住 roots 内
|
||||
// symlink 的逃逸,也不会把「DataDir 自身是 symlink」的正常部署误判为逃逸。
|
||||
func (f *fileTool) realRoots() []string {
|
||||
out := make([]string, 0, len(f.roots))
|
||||
for _, r := range f.roots {
|
||||
real, err := resolveReal(r)
|
||||
if err != nil {
|
||||
real = r
|
||||
}
|
||||
out = append(out, real)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withinRoots 报告 p 是否位于(或等于)roots 中的某一个之内:按 filepath.Rel 判定,
|
||||
// 相对路径为 "." 或不以 ".." 开头即为包含。
|
||||
func withinRoots(p string, roots []string) bool {
|
||||
for _, root := range roots {
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -300,6 +350,33 @@ func (f *fileTool) within(p string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveReal 返回 p 的真实(symlink 解析后)路径:p 自身存在时直接解析;不存在时
|
||||
// 沿父级上溯到最深的存在祖先,再把尚未创建的分量拼回。这样「即将创建」的路径也能
|
||||
// 在落盘前判定真实落点,避免 MkdirAll 沿 roots 内的 symlink 逃逸。
|
||||
func resolveReal(p string) (string, error) {
|
||||
cur := filepath.Clean(p)
|
||||
remaining := ""
|
||||
for {
|
||||
if real, err := filepath.EvalSymlinks(cur); err == nil {
|
||||
if remaining == "" {
|
||||
return real, nil
|
||||
}
|
||||
return filepath.Join(real, remaining), nil
|
||||
}
|
||||
base := filepath.Base(cur)
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return "", fmt.Errorf("no existing ancestor for %s", p)
|
||||
}
|
||||
if remaining == "" {
|
||||
remaining = base
|
||||
} else {
|
||||
remaining = filepath.Join(base, remaining)
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// absoluteClean absolutizes and cleans p, returning the best-effort absolute
|
||||
// form used as an allowed root.
|
||||
func absoluteClean(p string) string {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// TestFileTool_RejectsNULBytePath 覆盖 NUL 字节拒绝(收敛自原 exec 文件沙箱,
|
||||
// 裁决 13.4.4):任何 action 的路径含 NUL 都必须在触达文件系统之前被拒,且错误须
|
||||
// 经 errors.Is 命中 ErrNullByte——不能按错误文本断言,理由见用例内注释。
|
||||
func TestFileTool_RejectsNULBytePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := fileToolForTest(t, &config.Config{DataDir: t.TempDir()})
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{"read", map[string]any{"action": "read", "path": "a\x00b.txt"}},
|
||||
{"write", map[string]any{"action": "write", "path": "a\x00b.txt", "content": "x"}},
|
||||
{"list", map[string]any{"action": "list", "path": "sub\x00dir"}},
|
||||
{"search", map[string]any{"action": "search", "path": "sub\x00dir", "query": "x"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ft.Execute(context.Background(), c.args)
|
||||
// 必须用「哨兵 + errors.Is」断言,不能按错误文本里的 "NUL" 做子串匹配:
|
||||
// t.TempDir() 的目录名带着本测试的函数名(…TestFileTool_RejectsNULBytePath…),
|
||||
// 而拒绝/失败路径的错误消息会嵌完整路径,于是文本断言必然假阳性——实测把
|
||||
// file.go 的 NUL 检查整段删掉,这四个子用例仍全部 PASS,安全门等于没被钉住。
|
||||
if err == nil || !errors.Is(err, ErrNullByte) {
|
||||
t.Fatalf("expected NUL rejection (errors.Is ErrNullByte), got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTool_RejectsTraversalOnWrite 覆盖 ".." 穿越在写入面同样被拒,
|
||||
// 且越界目标不会真的被创建。
|
||||
func TestFileTool_RejectsTraversalOnWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
ft := fileToolForTest(t, &config.Config{DataDir: root})
|
||||
const leaf = "cyrene-fsguard-traversal.txt"
|
||||
outside := filepath.Join(filepath.Dir(root), leaf)
|
||||
for _, p := range []string{"../" + leaf, "sub/../../" + leaf} {
|
||||
if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": p, "content": "x"}); err == nil {
|
||||
t.Fatalf("expected traversal rejection for %q", p)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(outside); !os.IsNotExist(err) {
|
||||
t.Fatalf("out-of-root file %q must not exist (stat err = %v)", outside, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTool_Write_CreatesNestedDirsInsideRoot 确认更严的落点校验没有破坏
|
||||
// roots 内的多级目录创建与回读。
|
||||
func TestFileTool_Write_CreatesNestedDirsInsideRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
ft := fileToolForTest(t, &config.Config{DataDir: root})
|
||||
if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "deep/a/b/c.txt", "content": "ok"}); err != nil {
|
||||
t.Fatalf("write nested: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "deep", "a", "b", "c.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read nested: %v", err)
|
||||
}
|
||||
if string(data) != "ok" {
|
||||
t.Fatalf("content = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTool_Write_CreatesMissingDataDir 覆盖 DataDir 尚未创建的场景:
|
||||
// roots 与真实落点按同一规则推导,首次写入仍应把数据目录建出来。
|
||||
func TestFileTool_Write_CreatesMissingDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := filepath.Join(t.TempDir(), "not-created-yet")
|
||||
ft := fileToolForTest(t, &config.Config{DataDir: root})
|
||||
if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "a.txt", "content": "ok"}); err != nil {
|
||||
t.Fatalf("write into missing data dir: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(data) != "ok" {
|
||||
t.Fatalf("read back: %v %q", err, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTool_Write_FollowsSymlinkInsideRoot 覆盖 roots 内的合法 symlink:
|
||||
// 指向 roots 之内时不应被当作逃逸(收敛前后行为一致)。
|
||||
func TestFileTool_Write_FollowsSymlinkInsideRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "real.txt")
|
||||
if err := os.WriteFile(target, []byte("old"), filePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(root, "alias.txt")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
ft := fileToolForTest(t, &config.Config{DataDir: root})
|
||||
if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "alias.txt", "content": "new"}); err != nil {
|
||||
t.Fatalf("write through in-root symlink: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(target); err != nil || string(data) != "new" {
|
||||
t.Fatalf("read back: %v %q", err, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTool_Write_FollowsDirSymlinkInsideRoot 覆盖 roots 内的目录 symlink 指向
|
||||
// roots 之内:落点解析后仍应放行,建目录后的父目录复核不得误拒。
|
||||
func TestFileTool_Write_FollowsDirSymlinkInsideRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
sub := filepath.Join(root, "sub")
|
||||
if err := os.MkdirAll(sub, dirPerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(sub, filepath.Join(root, "link")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
ft := fileToolForTest(t, &config.Config{DataDir: root})
|
||||
if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "link/a.txt", "content": "hi"}); err != nil {
|
||||
t.Fatalf("write through in-root dir symlink: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(sub, "a.txt")); err != nil || string(data) != "hi" {
|
||||
t.Fatalf("read back: %v %q", err, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTool_Write_RejectsSymlinkParentEscape 覆盖「即将创建」路径的 symlink 逃逸:
|
||||
// 目标父链经由 roots 内的 symlink 指向外部时,写入必须在建目录之前被拒,外部目录
|
||||
// 不得留下任何被创建出来的痕迹(旧实现先 MkdirAll 再校验,会先在外部建出目录)。
|
||||
func TestFileTool_Write_RejectsSymlinkParentEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
ft := fileToolForTest(t, &config.Config{DataDir: root})
|
||||
_, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "escape/sub/pwn.txt", "content": "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected symlink parent escape to be rejected")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "sub")); !os.IsNotExist(err) {
|
||||
t.Fatalf("rejected write must not create %q outside the root (stat err = %v)", filepath.Join(outside, "sub"), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTool_SymlinkedRootAllowed 覆盖 roots 与真实落点在同一路径空间比较:
|
||||
// DataDir 自身是 symlink 的正常部署(如 macOS /tmp → /private/tmp)不得被误判为逃逸。
|
||||
func TestFileTool_SymlinkedRootAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
realDir := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Symlink(realDir, link); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
ft := fileToolForTest(t, &config.Config{DataDir: link})
|
||||
if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "a.txt", "content": "hi"}); err != nil {
|
||||
t.Fatalf("write through symlinked root: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(realDir, "a.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(data) != "hi" {
|
||||
t.Fatalf("content = %q", data)
|
||||
}
|
||||
got, err := ft.Execute(context.Background(), map[string]any{"action": "read", "path": "a.txt"})
|
||||
if err != nil {
|
||||
t.Fatalf("read through symlinked root: %v", err)
|
||||
}
|
||||
if got.(map[string]any)["content"] != "hi" {
|
||||
t.Fatalf("content = %q", got.(map[string]any)["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithinRoots 覆盖词法包含判定(含根自身、".." 越界、同前缀兄弟目录)。
|
||||
func TestWithinRoots(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := filepath.Clean("/allow/root")
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"inside", filepath.Join(root, "a", "b.txt"), true},
|
||||
{"exact-root", root, true},
|
||||
{"parent", filepath.Dir(root), false},
|
||||
{"sibling", "/allow/other", false},
|
||||
{"sibling-prefix", "/allow/root-other", false},
|
||||
{"deep", filepath.Join(root, "x", "y", "z"), true},
|
||||
{"normalized-dotdot", filepath.Join(root, "sub", "..", "file.txt"), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := withinRoots(c.path, []string{root}); got != c.want {
|
||||
t.Errorf("withinRoots(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveReal_NonExistentNested 覆盖最深存在祖先推导:叶子与中间目录都还不存在
|
||||
// 时,真实落点 = 已存在祖先的真实路径 + 未创建分量。
|
||||
func TestResolveReal_NonExistentNested(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
realDir := dir
|
||||
if r, err := filepath.EvalSymlinks(dir); err == nil {
|
||||
realDir = r
|
||||
}
|
||||
p := filepath.Join(dir, "a", "b", "c")
|
||||
got, err := resolveReal(p)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveReal(%q) error: %v", p, err)
|
||||
}
|
||||
if want := filepath.Join(realDir, "a", "b", "c"); got != want {
|
||||
t.Errorf("resolveReal = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user