package exec import ( "fmt" "path/filepath" "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. func absCleaners(raw []string) []string { out := make([]string, 0, len(raw)) for _, r := range raw { r = strings.TrimSpace(r) if r == "" { continue } if !filepath.IsAbs(r) { r = filepath.Join(".", r) } out = append(out, filepath.Clean(r)) } return out } // withinAnyRoot reports whether path is lexically confined to one of roots. // Containment is judged by filepath.Rel: the path is inside a root when the // relative path is "." or does not begin with "..". func withinAnyRoot(path string, roots []string) bool { for _, root := range roots { rel, err := filepath.Rel(root, path) if err != nil { continue } if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) { return true } } return false } // resolveReal returns the real (symlink-free) path for p, resolving the deepest // existing ancestor when p itself does not exist (for example a file about to be // created). This prevents a symlink inside a root from pointing at a location // outside it. 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 } } // resolveInsideRoot validates a caller-supplied path and returns its absolute, // symlink-resolved path, guaranteeing that both the lexical path and its real // target stay inside one of roots. A relative path is resolved against // roots[0]. The returned path is absolute and clean. func resolveInsideRoot(candidate string, roots []string) (string, error) { if candidate == "" { return "", ErrEmptyPath } if strings.IndexByte(candidate, 0) >= 0 { return "", ErrNullByte } var abs string if filepath.IsAbs(candidate) { abs = filepath.Clean(candidate) } else { if len(roots) == 0 { return "", ErrNoAllowedRoot } abs = filepath.Join(roots[0], candidate) } if !withinAnyRoot(abs, roots) { return "", fmt.Errorf("%w: %s", ErrPathOutsideRoot, candidate) } real, err := resolveReal(abs) if err != nil { return "", fmt.Errorf("%w: %s: %w", ErrPathOutsideRoot, candidate, err) } if !withinAnyRoot(real, roots) { return "", fmt.Errorf("%w: %s (real target %s)", ErrSymlinkEscape, candidate, real) } return real, nil }