按 design/28 §2/§3 的「配置不进 .env」口径:读 $DATA_DIR/config/plugins/browser.json (0600 + temp+rename 范式,**不读 env、不加新键**);有头/无头、CDP 端点、profile 目录、 视口/超时/并发(1)/截图上限全部类型化 + 常量默认值;**探测优先级**(本机 Chrome/Chromium 可执行文件 → 已在跑的容器)并返回可分类的「为什么不可用」(供上层如实降级,不假装可用); profile 目录含凭据 ⇒ 0600 + 不进 git/不进上下文的注释与校验。 本片零既有文件改动(不注册工具、不碰 cmd/),接线留 B2。
143 lines
5.2 KiB
Go
143 lines
5.2 KiB
Go
package browsercfg
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// Load 从 path 读 browser 插件配置,返回**归一化且校验过**的配置。
|
||
//
|
||
// 返回 `(nil, nil)` 表示**文件不存在** —— 即「未配置」,交给探测自动判定
|
||
// (design/28 §8 B1:「无配置文件时按序探测本机 Chrome/Chromium → 已在跑的容器」)。
|
||
// 文件存在但读不动 / 是空文件 / JSON 非法 / 字段越界 ⇒ 返回非 nil 错误,调用方应如实
|
||
// 报错并**不注册工具**,绝不静默拿默认值顶替(design/28 §6 坑 5)。
|
||
//
|
||
// dataDir 只用于把空的 ProfileDir 落成默认位置(`$DATA_DIR/browser/profile`)。
|
||
func Load(path, dataDir string) (*Config, error) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, fmt.Errorf("browsercfg: 读 %s: %w", path, err)
|
||
}
|
||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||
return nil, fmt.Errorf("%w: 空文件 %s", ErrInvalidConfig, path)
|
||
}
|
||
var cfg Config
|
||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||
return nil, fmt.Errorf("%w: 解码 %s: %w", ErrInvalidConfig, path, err)
|
||
}
|
||
cfg = cfg.Normalize(dataDir)
|
||
if err := cfg.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &cfg, nil
|
||
}
|
||
|
||
// Save 把 cfg 归一化后以 **0600** 写入 path:先写 `<path>.tmp` 再 rename(照
|
||
// runtimeconfig.SavePlugins 的范式),保证并发读者不会看到半个文件。写之前先 Validate:
|
||
// 非法配置**不落盘**(宁可没有配置文件,也不留一个半对的)。
|
||
//
|
||
// 0600 的依据(design/28 §2/§4):browser.json 可能带上显式 CDP 端点与凭据化 profile 的
|
||
// 位置,属于本地运行期配置,不进 git、不进上下文。
|
||
func Save(path, dataDir string, cfg Config) error {
|
||
cfg = cfg.Normalize(dataDir)
|
||
if err := cfg.Validate(); err != nil {
|
||
return err
|
||
}
|
||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("browsercfg: 编码配置: %w", err)
|
||
}
|
||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return fmt.Errorf("browsercfg: 建配置目录 %s: %w", dir, err)
|
||
}
|
||
}
|
||
tmp := path + ".tmp"
|
||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||
return fmt.Errorf("browsercfg: 写临时文件 %s: %w", tmp, err)
|
||
}
|
||
if err := os.Rename(tmp, path); err != nil {
|
||
return fmt.Errorf("browsercfg: 重命名 %s -> %s: %w", tmp, path, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// EnsureProfileDir 以 **0700** 建好(或确认已存在)profile 目录,并在建之前核查它
|
||
// **不会进 git**(CheckProfileLocation)。0700 而非 0755:目录里的 cookie / 登录态是凭据
|
||
// (design/28 §2 三件套之 2、§4)。
|
||
func EnsureProfileDir(dir, dataDir string) error {
|
||
if strings.TrimSpace(dir) == "" {
|
||
return fmt.Errorf("%w: profile 目录为空", ErrInvalidConfig)
|
||
}
|
||
if err := CheckProfileLocation(dir, dataDir); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||
return fmt.Errorf("browsercfg: 建 profile 目录 %s: %w", dir, err)
|
||
}
|
||
// 已存在的目录 MkdirAll 不会改权限,这里补一次 Chmod,保证「长期保留的凭据目录」
|
||
// 不被更宽松的旧权限一直带着(Windows 上 Chmod 只影响只读位,属尽力而为)。
|
||
if err := os.Chmod(dir, 0o700); err != nil {
|
||
return fmt.Errorf("browsercfg: 收敛 profile 目录权限 %s: %w", dir, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CheckProfileLocation 核查 dir **不会进 git**。判定口径(务实版,见 design/13 §2.1):
|
||
// profile 目录要么落在**数据目录**内(`$DATA_DIR` 按项目约定是 gitignored 的运行期区,
|
||
// 仓库 `.gitignore` 有 `data/`),要么落在**任何 git 工作树之外**;两者都不满足 ⇒
|
||
// ErrProfileInGitTree。这样默认落点 `./data/browser/profile` 合法,而把它指到 `./configs/`
|
||
// 或仓库根这种会被 `git add` 收走的位置就会被拦下。
|
||
func CheckProfileLocation(dir, dataDir string) error {
|
||
abs, err := filepath.Abs(dir)
|
||
if err != nil {
|
||
return fmt.Errorf("browsercfg: 解析 profile 目录 %s: %w", dir, err)
|
||
}
|
||
if strings.TrimSpace(dataDir) != "" {
|
||
root, err := filepath.Abs(dataDir)
|
||
if err != nil {
|
||
return fmt.Errorf("browsercfg: 解析数据目录 %s: %w", dataDir, err)
|
||
}
|
||
if withinDir(abs, root) {
|
||
return nil
|
||
}
|
||
}
|
||
if worktree, ok := hasGitAncestor(abs); ok {
|
||
return fmt.Errorf("%w: %s 位于工作树 %s 内", ErrProfileInGitTree, abs, worktree)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// withinDir 报告 child 是否等于 parent 或位于其下(两者都会先转绝对路径再比)。
|
||
func withinDir(child, parent string) bool {
|
||
rel, err := filepath.Rel(parent, child)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if rel == "." {
|
||
return true
|
||
}
|
||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||
}
|
||
|
||
// hasGitAncestor 从 dir 起向上逐级找 `.git`(目录,或 worktree 的 `.git` 文件)。
|
||
// 命中即返回该工作树根;到文件系统根仍未命中返回 false。
|
||
func hasGitAncestor(dir string) (string, bool) {
|
||
for cur := dir; ; {
|
||
if _, err := os.Stat(filepath.Join(cur, ".git")); err == nil {
|
||
return cur, true
|
||
}
|
||
parent := filepath.Dir(cur)
|
||
if parent == cur {
|
||
return "", false
|
||
}
|
||
cur = parent
|
||
}
|
||
}
|