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:先写 `.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 } }