feat(browser): 浏览器配置与探测库(internal/capability/browsercfg,纯库零接线)

按 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。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:34 +08:00
1 parent b7fb4dd1c7
commit 29181245ce
9 files changed
+1398

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
package browsercfg
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
)
// ---- 默认值与硬上限(design/28 §3;一切具名,本包不存在裸数字)----
//
// 口径(design/28 §8 B1):`.env` 已冻结为「最小化功能可跑」的引导面 ⇒ B1 的一切可调项
// **只**落在 `$DATA_DIR/config/plugins/browser.json` + 插件面板上,代码只提供默认值与
// 自动探测。因此下面的常量是「配置文件缺失 / 字段 ≤0」时的回落值。
const (
// DefaultHeadless 默认档 = **有头**(design/28 §2:「默认档 = 有头 + 持久化 profile」)。
// 有头的目的是过风控;无头只在「对反爬不敏感 / 要省资源」时由面板显式降级。
DefaultHeadless = false
// DefaultViewportWidth / DefaultViewportHeight 是视口默认 1280×800:桌面常见分辨率的
// 下界,且与 design/28 §2「指纹一致性」对齐 —— 刻意避开 800×600 那种一眼自动化的尺寸。
DefaultViewportWidth = 1280
DefaultViewportHeight = 800
// MaxViewportWidth / MaxViewportHeight 是视口硬上限 7680×4320(8K,design/28 §3
// 「视口尺寸上限」)。超过即判配置非法:不设上限等于允许一次渲染把整机内存交出去。
MaxViewportWidth = 7680
MaxViewportHeight = 4320
// DefaultTimeoutSeconds 是单次浏览器动作超时 30s:够一次冷启动导航 + 渲染完成。
// 它是**正常路径**的预算,不是重试预算(design/28 §3)。
DefaultTimeoutSeconds = 30
// DefaultIdleTimeoutSeconds 是空闲回收 300s(design/28 §3「空闲 N 秒回收」的常量):
// 5 分钟无动作即回收会话,避免一个 profile 被长期占着。
DefaultIdleTimeoutSeconds = 300
// DefaultConcurrency 并发上限恒为 1,**不是可调项**:同一 user-data-dir 起两个实例
// 会互锁(design/28 §3、§6 坑 2),且 profile 含凭据,串行最省心。Normalize 把任何
// >1 的取值夹回 1,而不是照单收下。
DefaultConcurrency = 1
// DefaultScreenshotMaxBytes 是单张截图字节上限 8 MiB:1280×800 的 PNG/JPEG 远小于它,
// 而 vision 管线也只该吃这么大的图(design/28 §3「超限如实截断/缩放并标注」)。
DefaultScreenshotMaxBytes = 8 << 20
// MaxScreenshotMaxBytes 是单张截图字节上限的**硬上限** 32 MiB(design/28 §3
// 「单张字节上限」):在默认 8 MiB 之上给面板留「显式放大」的余量,但绝不让单张截图无界。
MaxScreenshotMaxBytes = 32 << 20
// DefaultCDPEndpoint 是「已在跑的容器」的候选端点:CDP 默认调试端口 9222 绑回环。
// 它只是**出站探测目标**,不新开我们的监听端口(AGENTS §9 红线、design/28 §3)。
DefaultCDPEndpoint = "http://127.0.0.1:9222"
)
// Config 是 browser 插件的落盘配置(`$DATA_DIR/config/plugins/browser.json`,
// design/28 §1 M2、§8 B1)。零值经 Normalize 即为「全默认」,所以 `Config{}` 是安全的。
type Config struct {
// Headless 为 true 用无头;默认 false(有头,见 DefaultHeadless)。
// 用 plain bool 而不是 *bool:默认值本身就是 false,「缺失」与「显式 false」同义,无歧义。
Headless bool `json:"headless"`
// Executable 显式指定本机 Chrome/Chromium 可执行文件;空 ⇒ 交给自动探测。
Executable string `json:"executable,omitempty"`
// CDPEndpoint 显式指定已在跑的 CDP 端点(如容器)。空 ⇒ 交给自动探测;**显式给出时
// 它取代自动探测**:连不上就如实报 cdp_unreachable,不偷偷回退本机(design/28 §6 坑 5)。
CDPEndpoint string `json:"cdpEndpoint,omitempty"`
// ProfileDir 是持久化 profile(user-data-dir)目录,**含 cookie / 登录态等凭据**
// (design/28 §2 三件套之 2):空 ⇒ 默认 `$DATA_DIR/browser/profile`。使用前须过
// CheckProfileLocation(不得进 git 工作树),目录 0700、内容绝不进上下文。
ProfileDir string `json:"profileDir,omitempty"`
// ViewportWidth / ViewportHeight 是视口尺寸;≤0 ⇒ 用默认(见 DefaultViewport*),
// 超过上限 ⇒ Validate 判非法(见 MaxViewport*)。
ViewportWidth int `json:"viewportWidth"`
ViewportHeight int `json:"viewportHeight"`
// TimeoutSeconds 是单次动作超时;≤0 ⇒ 用默认(见 DefaultTimeoutSeconds)。
TimeoutSeconds int `json:"timeoutSeconds"`
// IdleTimeoutSeconds 是空闲回收秒数;≤0 ⇒ 用默认(见 DefaultIdleTimeoutSeconds)。
IdleTimeoutSeconds int `json:"idleTimeoutSeconds"`
// Concurrency 是并发上限;≤0 ⇒ 用默认、>1 ⇒ 夹回 1(见 DefaultConcurrency:profile 锁)。
Concurrency int `json:"concurrency"`
// ScreenshotMaxBytes 是单张截图字节上限;≤0 ⇒ 用默认、超过硬上限 ⇒ Validate 判非法。
ScreenshotMaxBytes int `json:"screenshotMaxBytes"`
}
// Defaults 返回一组全默认的配置(ProfileDir 落在 dataDir 下)。
func Defaults(dataDir string) Config {
return Config{}.Normalize(dataDir)
}
// Normalize 用常量默认补足「≤0 / 缺失」的字段,并把并发夹到 1。它**只填不改**:
// 越界的值原样保留,交给 Validate 拒绝 —— 悄悄改小会让面板显示的与生效的不是一回事。
func (c Config) Normalize(dataDir string) Config {
if c.ViewportWidth <= 0 {
c.ViewportWidth = DefaultViewportWidth
}
if c.ViewportHeight <= 0 {
c.ViewportHeight = DefaultViewportHeight
}
if c.TimeoutSeconds <= 0 {
c.TimeoutSeconds = DefaultTimeoutSeconds
}
if c.IdleTimeoutSeconds <= 0 {
c.IdleTimeoutSeconds = DefaultIdleTimeoutSeconds
}
if c.Concurrency <= 0 {
c.Concurrency = DefaultConcurrency
}
if c.Concurrency > DefaultConcurrency {
c.Concurrency = DefaultConcurrency
}
if c.ScreenshotMaxBytes <= 0 {
c.ScreenshotMaxBytes = DefaultScreenshotMaxBytes
}
if strings.TrimSpace(c.ProfileDir) == "" {
c.ProfileDir = DefaultProfileDir(dataDir)
}
return c
}
// Validate 检查配置是否在硬上限内(design/28 §3)。它判的是**原始值**(未 Normalize 也可用):
// 负值/零按「用默认」放行(≤0 的语义由 Normalize 承担),只有**越上限**与**端点非法**算错。
// 错误一律 %w 包住 ErrInvalidConfig;配置错误里不含任何凭据(本类型的字段就没有凭据)。
func (c Config) Validate() error {
if c.ViewportWidth > MaxViewportWidth {
return fmt.Errorf("%w: viewportWidth=%d 超过上限 %d", ErrInvalidConfig, c.ViewportWidth, MaxViewportWidth)
}
if c.ViewportHeight > MaxViewportHeight {
return fmt.Errorf("%w: viewportHeight=%d 超过上限 %d", ErrInvalidConfig, c.ViewportHeight, MaxViewportHeight)
}
if c.ScreenshotMaxBytes > MaxScreenshotMaxBytes {
return fmt.Errorf("%w: screenshotMaxBytes=%d 超过上限 %d", ErrInvalidConfig, c.ScreenshotMaxBytes, MaxScreenshotMaxBytes)
}
if raw := strings.TrimSpace(c.CDPEndpoint); raw != "" && !isHTTPAbsolute(raw) {
return fmt.Errorf("%w: cdpEndpoint %q 不是可用的 http(s) 绝对地址", ErrInvalidConfig, raw)
}
return nil
}
// Timeout 返回单次动作超时;调用前应已 Normalize(否则可能为 0)。
func (c Config) Timeout() time.Duration {
return time.Duration(c.TimeoutSeconds) * time.Second
}
// IdleTimeout 返回空闲回收时长;调用前应已 Normalize(否则可能为 0)。
func (c Config) IdleTimeout() time.Duration {
return time.Duration(c.IdleTimeoutSeconds) * time.Second
}
// isHTTPAbsolute 报告 raw 是否为带主机的 http(s) 绝对地址(照 config.ResolveEndpoint 口径:
// `ftp://x`、`127.0.0.1:9222` 这类一律不算)。
func isHTTPAbsolute(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
// DefaultConfigPath 返回 browser 插件配置文件的落点:
// `$DATA_DIR/config/plugins/browser.json`(design/13 §2.1「一个插件一个文件,文件名 = 插件 id」
// + design/28 §1 M2)。
func DefaultConfigPath(dataDir string) string {
return filepath.Join(dataDir, "config", "plugins", "browser.json")
}
// DefaultProfileDir 返回持久化 profile 目录的默认落点:`$DATA_DIR/browser/profile`。
// **它含凭据**:0700、不进 git、内容不进上下文(见 EnsureProfileDir / CheckProfileLocation)。
func DefaultProfileDir(dataDir string) string {
return filepath.Join(dataDir, "browser", "profile")
}
// DefaultShotsDir 返回截图落盘目录的默认落点(design/28 §4:「截图存
// `$DATA_DIR/browser/shots/`,0600」)。本片不落盘,只给 B3 一个一致的默认位置。
func DefaultShotsDir(dataDir string) string {
return filepath.Join(dataDir, "browser", "shots")
}
@@ -0,0 +1,194 @@
package browsercfg
import (
"errors"
"path/filepath"
"testing"
"time"
)
// TestConfigNormalizeFillsDefaults 钉住「零值 + Normalize = 全默认」(design/28 §8 B1:
// 代码只提供默认值)。Config 全字段可比较,故整struct 比对。
func TestConfigNormalizeFillsDefaults(t *testing.T) {
got := Config{}.Normalize("/data")
want := Config{
Headless: DefaultHeadless,
ProfileDir: filepath.Join("/data", "browser", "profile"),
ViewportWidth: DefaultViewportWidth,
ViewportHeight: DefaultViewportHeight,
TimeoutSeconds: DefaultTimeoutSeconds,
IdleTimeoutSeconds: DefaultIdleTimeoutSeconds,
Concurrency: DefaultConcurrency,
ScreenshotMaxBytes: DefaultScreenshotMaxBytes,
}
if got != want {
t.Fatalf("Normalize 默认值回落不符\n got=%+v\nwant=%+v", got, want)
}
if got.Headless != false {
t.Fatalf("默认档必须是有头(design/28 §2),got headless=%v", got.Headless)
}
}
// TestConfigNormalizeKeepsExplicit 钉住「显式值不被默认覆盖」。
func TestConfigNormalizeKeepsExplicit(t *testing.T) {
in := Config{
Headless: true,
Executable: "/opt/chrome",
CDPEndpoint: "http://127.0.0.1:9333",
ProfileDir: "/tmp/p",
ViewportWidth: 1920,
ViewportHeight: 1080,
TimeoutSeconds: 5,
IdleTimeoutSeconds: 60,
Concurrency: 1,
ScreenshotMaxBytes: 1 << 20,
}
if got := in.Normalize("/data"); got != in {
t.Fatalf("Normalize 改动了显式值\n got=%+v\nwant=%+v", got, in)
}
}
// TestConfigNormalizeNonPositiveUsesDefault 钉住「≤0 = 用默认」(含负数)。
func TestConfigNormalizeNonPositiveUsesDefault(t *testing.T) {
in := Config{
ViewportWidth: -1,
ViewportHeight: 0,
TimeoutSeconds: -30,
IdleTimeoutSeconds: 0,
Concurrency: -2,
ScreenshotMaxBytes: -5,
}
got := in.Normalize("/data")
if got.ViewportWidth != DefaultViewportWidth || got.ViewportHeight != DefaultViewportHeight {
t.Fatalf("视口未回落默认:%d×%d", got.ViewportWidth, got.ViewportHeight)
}
if got.TimeoutSeconds != DefaultTimeoutSeconds || got.IdleTimeoutSeconds != DefaultIdleTimeoutSeconds {
t.Fatalf("超时未回落默认:timeout=%d idle=%d", got.TimeoutSeconds, got.IdleTimeoutSeconds)
}
if got.Concurrency != DefaultConcurrency {
t.Fatalf("并发未回落默认:%d", got.Concurrency)
}
if got.ScreenshotMaxBytes != DefaultScreenshotMaxBytes {
t.Fatalf("截图字节上限未回落默认:%d", got.ScreenshotMaxBytes)
}
}
// TestConfigNormalizeClampsConcurrency 钉住并发上限恒为 1(design/28 §3、§6 坑 2:profile 锁)。
func TestConfigNormalizeClampsConcurrency(t *testing.T) {
for _, in := range []int{2, 8, 100} {
if got := (Config{Concurrency: in}).Normalize("/data").Concurrency; got != DefaultConcurrency {
t.Fatalf("Concurrency=%d 未被夹到 %d,got=%d", in, DefaultConcurrency, got)
}
}
}
// TestDefaultsMatchesZeroNormalize 钉住 Defaults 与「零值 Normalize」是同一件事。
func TestDefaultsMatchesZeroNormalize(t *testing.T) {
if got, want := Defaults("/d"), (Config{}).Normalize("/d"); got != want {
t.Fatalf("Defaults 与 Normalize 口径不一致\n got=%+v\nwant=%+v", got, want)
}
}
// TestConfigValidateAcceptsDefaults 钉住「全默认配置合法」——Validate 判的是原始值,
// 零值代表「用默认」,不该被判非法。
func TestConfigValidateAcceptsDefaults(t *testing.T) {
if err := (Config{}).Validate(); err != nil {
t.Fatalf("零值配置应合法(= 全默认),got err=%v", err)
}
}
// TestConfigValidateRejectsOverCaps 钉住硬上限(design/28 §3:视口尺寸上限 + 单张字节上限)。
func TestConfigValidateRejectsOverCaps(t *testing.T) {
cases := []struct {
name string
cfg Config
}{
{"视口宽超上限", Config{ViewportWidth: MaxViewportWidth + 1}},
{"视口高超上限", Config{ViewportHeight: MaxViewportHeight + 1}},
{"截图字节超上限", Config{ScreenshotMaxBytes: MaxScreenshotMaxBytes + 1}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.cfg.Validate()
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("应返回 ErrInvalidConfig,got=%v", err)
}
})
}
}
// TestConfigValidateAcceptsAtCaps 钉住边界值(正好等于上限)合法,防止把 > 写成 >=。
func TestConfigValidateAcceptsAtCaps(t *testing.T) {
cfg := Config{
ViewportWidth: MaxViewportWidth,
ViewportHeight: MaxViewportHeight,
ScreenshotMaxBytes: MaxScreenshotMaxBytes,
}
if err := cfg.Validate(); err != nil {
t.Fatalf("边界值应合法,got err=%v", err)
}
}
// TestConfigValidateEndpoint 是 cdpEndpoint 合法性的表驱动:非 http(s) 绝对地址一律非法
// (照 config.ResolveEndpoint 口径),合法与空(=交给探测)放行。
func TestConfigValidateEndpoint(t *testing.T) {
cases := []struct {
endpoint string
wantErr bool
}{
{"", false},
{" ", false},
{"http://127.0.0.1:9222", false},
{"https://browser.internal:9222", false},
{"ftp://127.0.0.1:9222", true},
{"127.0.0.1:9222", true},
{"ws://127.0.0.1:9222", true},
{"http://", true},
}
for _, tc := range cases {
err := (Config{CDPEndpoint: tc.endpoint}).Validate()
if tc.wantErr && !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("endpoint=%q 应判非法,got err=%v", tc.endpoint, err)
}
if !tc.wantErr && err != nil {
t.Fatalf("endpoint=%q 应合法,got err=%v", tc.endpoint, err)
}
}
}
// TestConfigTimeoutAccessors 钉住秒数字段到 time.Duration 的换算。
func TestConfigTimeoutAccessors(t *testing.T) {
cfg := Config{TimeoutSeconds: 12, IdleTimeoutSeconds: 90}
if got := cfg.Timeout(); got != 12*time.Second {
t.Fatalf("Timeout()=%v,want %v", got, 12*time.Second)
}
if got := cfg.IdleTimeout(); got != 90*time.Second {
t.Fatalf("IdleTimeout()=%v,want %v", got, 90*time.Second)
}
}
// TestDefaultPaths 钉住三个落点(design/13 §2.1 + design/28 §1 M2/§4)。
func TestDefaultPaths(t *testing.T) {
data := filepath.Join("root", "data")
if got, want := DefaultConfigPath(data), filepath.Join(data, "config", "plugins", "browser.json"); got != want {
t.Fatalf("DefaultConfigPath=%q,want %q", got, want)
}
if got, want := DefaultProfileDir(data), filepath.Join(data, "browser", "profile"); got != want {
t.Fatalf("DefaultProfileDir=%q,want %q", got, want)
}
if got, want := DefaultShotsDir(data), filepath.Join(data, "browser", "shots"); got != want {
t.Fatalf("DefaultShotsDir=%q,want %q", got, want)
}
}
// TestReasonMessage 钉住分类原因码都有中文文案(上层直接写日志,避免两处措辞漂移)。
func TestReasonMessage(t *testing.T) {
for _, r := range []Reason{ReasonUsable, ReasonExecutableMissing, ReasonCDPUnreachable, ReasonNoSource} {
if r.Message() == "" {
t.Fatalf("原因码 %q 缺中文文案", r)
}
}
if got := Reason("unknown").Message(); got != "" {
t.Fatalf("未知原因码应返回空串,got=%q", got)
}
}
+22
View File
@@ -0,0 +1,22 @@
// Package browsercfg 只做两件事:**浏览器能力的配置**与**可用性判定**。它是
// design/28(浏览器与页面截图)B1 的产物,配置源是
// `$DATA_DIR/config/plugins/browser.json`(design/28 §1 M2、§8 B1、design/13 §2.1
// 的插件配置落点),并按「本机 Chrome/Chromium 可执行文件 → 已在跑的容器(CDP 端点)」
// 的优先级探测,给出**可证伪的可用性结论**和「为什么不可用」的**分类原因**。
//
// 它是**纯库**:不注册任何工具、不建长连接、不接线(工具面在 B2,闭环在 B3)。
// 除 CheckCDP 会发一次出站 HTTP GET 外,本包不启动进程、不开监听端口。
//
// 红线(design/28 §3/§4,AGENTS §9/§10):
//
// - **不进 `.env`**:一切可调项只从 `browser.json` 读。本包不读任何**配置来源**的
// 环境变量,也不新增任何 env 键;唯一的 getenv 用途是从 `%ProgramFiles%` /
// `%LOCALAPPDATA%` 定位本机 Chrome 安装目录(与配置无关的系统环境)。
// - **不新开监听端口**:我们是 CDP 的**出站客户端**,探测只连调用方给的地址
// (默认 `127.0.0.1:9222`)。
// - **不假装可用**:探测不到就返回 `Usable=false` + 分类原因,由上层**如实降级**
// (照 web-search `off` 的口径:不注册工具,而不是注册一把抡起来必报错的锤子)。
// - **profile 目录含凭据**(cookie / 登录态)⇒ 目录 0700、文件 0600、**不得位于
// git 工作树内**(见 CheckProfileLocation)、内容**绝不进上下文/长期记忆**
// (design/28 §2 三件套之 2、§4)。截图落盘同属敏感面,默认不进长期记忆。
package browsercfg
+54
View File
@@ -0,0 +1,54 @@
package browsercfg
import "errors"
// 哨兵错误。AGENTS §10 只放行 `var ErrXxx` 作为包级变量,故原因码与文案用常量。
var (
// ErrInvalidConfig 表示 browser.json 解码失败、是空文件,或字段越界:
// 视口/截图字节超过硬上限、cdpEndpoint 不是 http(s) 绝对地址。
ErrInvalidConfig = errors.New("browsercfg: 配置非法")
// ErrProfileInGitTree 表示 profile 目录会落在 git 工作树内 —— profile 含凭据
// (cookie / 登录态),被 `git add` 收走就是把主人的登录态提交出去(design/28 §4)。
ErrProfileInGitTree = errors.New("browsercfg: profile 目录不得位于 git 工作树内")
)
// Reason 是可用性判定「为什么不可用」的**分类原因码**。上层据它如实降级与记日志,
// 不许把它含糊成「探测失败」四个字(design/28 §6 坑 5:失败要如实报)。
type Reason string
// 分类原因码。取值稳定、可断言;人类可读文案见 Message()。
const (
// ReasonUsable 表示探测到可用来源(Usable=true 时使用)。
ReasonUsable Reason = "usable"
// ReasonExecutableMissing 表示配置显式给出的浏览器可执行文件不存在或不可执行。
ReasonExecutableMissing Reason = "executable_missing"
// ReasonCDPUnreachable 表示 CDP 端点不可达 —— 配置里显式给定的那个,或兜底候选。
ReasonCDPUnreachable Reason = "cdp_unreachable"
// ReasonNoSource 表示本机既无 Chrome/Chromium 可执行文件、也没有在跑的 CDP 端点。
ReasonNoSource Reason = "no_source"
)
// 分类原因的中文文案:上层直接写进日志/面板,避免两处各写一份措辞
// (照 config.WebSearchReason* 的既有口径)。
const (
messageUsable = "可用"
messageExecutableMissing = "指定的浏览器可执行文件不存在或不可执行"
messageCDPUnreachable = "CDP 端点不可达"
messageNoSource = "本机未发现 Chrome/Chromium,也没有在跑的 CDP 端点"
)
// Message 返回原因码对应的中文文案;未知码返回空串(调用方回落到 Detail)。
func (r Reason) Message() string {
switch r {
case ReasonUsable:
return messageUsable
case ReasonExecutableMissing:
return messageExecutableMissing
case ReasonCDPUnreachable:
return messageCDPUnreachable
case ReasonNoSource:
return messageNoSource
default:
return ""
}
}
@@ -0,0 +1,36 @@
package browsercfg
import (
"context"
"errors"
"io/fs"
"os"
"time"
)
// testFileInfo 是最小的 fs.FileInfo 实现,让单测能伪造 Stat 的返回,从而不依赖真实装没装浏览器。
type testFileInfo struct {
mode fs.FileMode
dir bool
}
func (f testFileInfo) Name() string { return "browser" }
func (f testFileInfo) Size() int64 { return 0 }
func (f testFileInfo) Mode() fs.FileMode { return f.mode }
func (f testFileInfo) ModTime() time.Time { return time.Time{} }
func (f testFileInfo) IsDir() bool { return f.dir }
func (f testFileInfo) Sys() any { return nil }
// executableFileInfo 造一个带执行位的普通文件信息(非 Windows 平台才算「可执行」)。
func executableFileInfo() fs.FileInfo { return testFileInfo{mode: 0o755} }
// noBrowserProber 返回一个「本机没有浏览器、也没有 CDP 端点」的 Prober(全阴性基线)。
func noBrowserProber() *Prober {
return &Prober{
LookPath: func(string) (string, error) { return "", errors.New("not found") },
Stat: func(string) (os.FileInfo, error) { return nil, os.ErrNotExist },
Getenv: func(string) string { return "" },
GOOS: "linux",
CheckCDP: func(context.Context, string) error { return errors.New("connection refused") },
}
}
+275
View File
@@ -0,0 +1,275 @@
package browsercfg
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// probeHTTPTimeout 是 CDP 存活探测的单次超时(2s)。探测问的是「有没有在听」,不是
// 「页面能不能加载」,所以给得短:连不上就是连不上,不该拖住启动路径。
const probeHTTPTimeout = 2 * time.Second
// Source 标识浏览器从哪来(探测优先级见 design/28 §8 B1)。
type Source string
// 来源取值。SourceNone 用于不可用(Usable=false)的结论。
const (
// SourceNone 表示没有可用来源。
SourceNone Source = ""
// SourceLocalChrome 表示本机 Chrome/Chromium 可执行文件(默认有头)。
SourceLocalChrome Source = "local-chrome"
// SourceCDP 表示一个已在响应 CDP 的端点(容器,或本机手动起的实例)。
SourceCDP Source = "cdp"
)
// Availability 是可用性判定的结论。**不可用也是结论**:Usable=false 时 Reason 说明分类
// 原因、Detail 给出可排查的细节,上层据此如实降级(不注册工具)与记日志。
type Availability struct {
// Usable 为 true 表示探测到可用来源。
Usable bool
// Source 是选定的来源;不可用时为 SourceNone。
Source Source
// Executable 是选定的本机可执行文件(Source=SourceLocalChrome 时有值)。
Executable string
// Endpoint 是选定的 CDP 端点(Source=SourceCDP 时有值)。
Endpoint string
// Headless 是本次判定采用的档位(来自配置,默认有头)。
Headless bool
// Reason 是分类原因码(可用时恒为 ReasonUsable)。
Reason Reason
// Detail 是可排查的补充说明(含具体路径/端点/错误),进日志与面板,**不进提示词**。
Detail string
}
// Prober 执行可用性探测。字段可注入,使单测无需真的装浏览器 / 起容器;零接线:除
// CheckCDP 会发一次出站 HTTP GET 外,它不建立任何长连接、不启动任何进程。
type Prober struct {
// LookPath 在 PATH 上查找可执行文件(默认 exec.LookPath)。
LookPath func(string) (string, error)
// Stat 检查候选绝对路径(默认 os.Stat)。
Stat func(string) (os.FileInfo, error)
// Getenv 读系统环境(默认 os.Getenv),只用于定位 Windows 的安装目录。
Getenv func(string) string
// GOOS 决定候选路径表(默认 runtime.GOOS)。
GOOS string
// CheckCDP 探测一个 CDP 端点,返回 nil 表示可达(默认对 <endpoint>/json/version 发 GET)。
CheckCDP func(ctx context.Context, endpoint string) error
}
// NewProber 返回接好真实现的 Prober。
func NewProber() *Prober { return (&Prober{}).withDefaults() }
// withDefaults 给零值字段补上真实现,使 `&Prober{}` 也安全(照 transcode.Limits 的口径:
// 零值 = 全默认)。测试只覆写关心的那几个字段即可。
func (p *Prober) withDefaults() *Prober {
if p.LookPath == nil {
p.LookPath = exec.LookPath
}
if p.Stat == nil {
p.Stat = os.Stat
}
if p.Getenv == nil {
p.Getenv = os.Getenv
}
if p.GOOS == "" {
p.GOOS = runtime.GOOS
}
if p.CheckCDP == nil {
p.CheckCDP = checkCDP
}
return p
}
// Resolve 判定浏览器是否可用并给出分类原因。探测优先级(design/28 §8 B1):
//
// 1. 配置**显式**给出的来源(executable / cdpEndpoint)—— 面板是权威:连不上就如实报
// 对应原因,**不偷偷回退**到自动探测(design/28 §6 坑 5:失败要如实报);
// 2. 无显式来源 ⇒ 本机 Chrome/Chromium 可执行文件;
// 3. 仍无 ⇒ 已在跑的 CDP 端点(容器,或本机手动实例);
// 4. 都没有 ⇒ Usable=false、Reason=no_source —— 上层据此**不注册工具**,不假装可用。
//
// cfg 为 nil 表示没有配置文件(Load 返回 nil, nil),直接走 2→3→4。p 为 nil 时用真实现。
func Resolve(ctx context.Context, cfg *Config, p *Prober) Availability {
if p == nil {
p = &Prober{}
}
p = p.withDefaults()
headless := cfg != nil && cfg.Headless
if cfg != nil {
if exe := strings.TrimSpace(cfg.Executable); exe != "" {
return resolveConfiguredExecutable(p, exe, headless)
}
if endpoint := strings.TrimSpace(cfg.CDPEndpoint); endpoint != "" {
return resolveConfiguredEndpoint(ctx, p, endpoint, headless)
}
}
return resolveByProbe(ctx, p, headless)
}
// resolveConfiguredExecutable 处理配置显式给的浏览器可执行文件:不存在即
// Reason=executable_missing(不做「换一个」的猜测——面板写错了就该让人看见)。
func resolveConfiguredExecutable(p *Prober, exe string, headless bool) Availability {
if !p.isExecutable(exe) {
return Availability{
Headless: headless,
Reason: ReasonExecutableMissing,
Detail: fmt.Sprintf("配置的 executable 不存在或不可执行:%s", exe),
}
}
return Availability{
Usable: true,
Source: SourceLocalChrome,
Executable: exe,
Headless: headless,
Reason: ReasonUsable,
Detail: "配置指定的本机浏览器:" + exe,
}
}
// resolveConfiguredEndpoint 处理配置显式给的 CDP 端点:连不上即 Reason=cdp_unreachable,
// 绝不悄悄改用本机浏览器(否则「我配的容器没生效」会变成静默的语义漂移)。
func resolveConfiguredEndpoint(ctx context.Context, p *Prober, endpoint string, headless bool) Availability {
if err := p.CheckCDP(ctx, endpoint); err != nil {
return Availability{
Headless: headless,
Reason: ReasonCDPUnreachable,
Detail: fmt.Sprintf("配置的 cdpEndpoint 不可达:%s(%v)", endpoint, err),
}
}
return Availability{
Usable: true,
Source: SourceCDP,
Endpoint: endpoint,
Headless: headless,
Reason: ReasonUsable,
Detail: "配置指定的 CDP 端点:" + endpoint,
}
}
// resolveByProbe 按优先级自动探测:本机可执行文件 → 已在跑的 CDP 端点 → 都没有。
func resolveByProbe(ctx context.Context, p *Prober, headless bool) Availability {
if exe, ok := p.FindLocalBrowser(); ok {
return Availability{
Usable: true,
Source: SourceLocalChrome,
Executable: exe,
Headless: headless,
Reason: ReasonUsable,
Detail: "自动探测到的本机浏览器:" + exe,
}
}
if err := p.CheckCDP(ctx, DefaultCDPEndpoint); err == nil {
return Availability{
Usable: true,
Source: SourceCDP,
Endpoint: DefaultCDPEndpoint,
Headless: headless,
Reason: ReasonUsable,
Detail: "自动探测到在跑的 CDP 端点:" + DefaultCDPEndpoint,
}
}
return Availability{
Headless: headless,
Reason: ReasonNoSource,
Detail: fmt.Sprintf("本机未发现 Chrome/Chromium,%s 也没有在跑的 CDP 端点", DefaultCDPEndpoint),
}
}
// FindLocalBrowser 按「PATH 上的常见命令名 → 各平台固定安装位置」查找本机 Chrome/Chromium,
// 返回第一个可执行文件。找不到返回 ("", false)。
func (p *Prober) FindLocalBrowser() (string, bool) {
for _, name := range lookPathNames(p.GOOS) {
if path, err := p.LookPath(name); err == nil && strings.TrimSpace(path) != "" {
return path, true
}
}
for _, path := range absoluteCandidates(p.GOOS, p.Getenv) {
if p.isExecutable(path) {
return path, true
}
}
return "", false
}
// isExecutable 判定 path 是否是一个可执行文件:存在、不是目录,且在非 Windows 平台上有
// 执行位(Windows 不看执行位)。
func (p *Prober) isExecutable(path string) bool {
info, err := p.Stat(path)
if err != nil || info == nil || info.IsDir() {
return false
}
if p.GOOS == "windows" {
return true
}
return info.Mode().Perm()&0o111 != 0
}
// checkCDP 对 `<endpoint>/json/version` 发一次出站 GET:CDP 端点活着就会回版本 JSON。
// 它是**出站**请求,只连调用方给的地址(默认 127.0.0.1),不新开任何监听端口(AGENTS §9)。
func checkCDP(ctx context.Context, endpoint string) error {
base := strings.TrimRight(strings.TrimSpace(endpoint), "/")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil)
if err != nil {
return fmt.Errorf("browsercfg: 构造探测请求: %w", err)
}
client := &http.Client{Timeout: probeHTTPTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("browsercfg: 探测 %s: %w", endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("browsercfg: 探测 %s: HTTP %d", endpoint, resp.StatusCode)
}
return nil
}
// lookPathNames 是各平台 PATH 上常见的浏览器命令名。
func lookPathNames(goos string) []string {
switch goos {
case "windows":
return []string{"chrome.exe", "chrome", "chromium.exe", "chromium"}
case "darwin":
return []string{"google-chrome", "chromium", "chrome"}
default:
return []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"}
}
}
// absoluteCandidates 是各平台**不在 PATH 上**的固定安装位置。
func absoluteCandidates(goos string, getenv func(string) string) []string {
switch goos {
case "windows":
var out []string
for _, key := range []string{"ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"} {
if base := strings.TrimSpace(getenv(key)); base != "" {
out = append(out,
filepath.Join(base, "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(base, "Chromium", "Application", "chrome.exe"),
)
}
}
return out
case "darwin":
return []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
}
default:
return []string{
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/chrome",
}
}
}
@@ -0,0 +1,281 @@
package browsercfg
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// TestResolveNoSource 钉住「都没有 ⇒ 不假装可用」:本机无浏览器、无 CDP 端点 ⇒
// Usable=false、Reason=no_source(design/28 §8 B1、§7 验收判据 1)。
func TestResolveNoSource(t *testing.T) {
got := Resolve(context.Background(), nil, noBrowserProber())
if got.Usable {
t.Fatalf("应判不可用,got=%+v", got)
}
if got.Reason != ReasonNoSource {
t.Fatalf("Reason=%q,want %q", got.Reason, ReasonNoSource)
}
if got.Source != SourceNone {
t.Fatalf("不可用时应无来源,got=%q", got.Source)
}
}
// TestResolveLocalBrowserFromPath 钉住优先级第 1 档:PATH 上的 Chrome/Chromium 命中即用本机。
func TestResolveLocalBrowserFromPath(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(name string) (string, error) {
if name == "google-chrome" {
return "/usr/bin/google-chrome", nil
}
return "", errors.New("not found")
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Source != SourceLocalChrome || got.Executable != "/usr/bin/google-chrome" {
t.Fatalf("应命中本机浏览器,got=%+v", got)
}
if got.Reason != ReasonUsable {
t.Fatalf("可用时 Reason 应为 usable,got=%q", got.Reason)
}
}
// TestResolveLocalBrowserFromAbsolutePath 钉住 PATH 未命中时会把固定安装位置当候选。
func TestResolveLocalBrowserFromAbsolutePath(t *testing.T) {
p := noBrowserProber()
p.Stat = func(path string) (os.FileInfo, error) {
if path == "/usr/bin/chromium" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Executable != "/usr/bin/chromium" {
t.Fatalf("应命中固定安装位置 /usr/bin/chromium,got=%+v", got)
}
}
// TestResolveLocalBrowserSkipsDirectory 钉住目录不算可执行文件。
func TestResolveLocalBrowserSkipsDirectory(t *testing.T) {
p := noBrowserProber()
p.Stat = func(string) (os.FileInfo, error) { return testFileInfo{dir: true, mode: 0o755}, nil }
if got := Resolve(context.Background(), nil, p); got.Usable {
t.Fatalf("目录不该被当浏览器,got=%+v", got)
}
}
// TestResolveLocalBrowserNeedsExecBit 钉住非 Windows 上无执行位不算可执行(Windows 跳过)。
func TestResolveLocalBrowserNeedsExecBit(t *testing.T) {
p := noBrowserProber()
p.Stat = func(string) (os.FileInfo, error) { return testFileInfo{mode: 0o644}, nil }
if got := Resolve(context.Background(), nil, p); got.Usable {
t.Fatalf("无执行位不该算可执行,got=%+v", got)
}
p.GOOS = "windows"
p.Getenv = func(key string) string {
if key == "ProgramFiles" {
return `C:\PF`
}
return ""
}
if got := Resolve(context.Background(), nil, p); !got.Usable {
t.Fatalf("Windows 不看执行位,应判可用,got=%+v", got)
}
}
// TestResolveCDPWhenNoLocalBrowser 钉住优先级第 2 档:本机没有时看已在跑的 CDP 端点。
func TestResolveCDPWhenNoLocalBrowser(t *testing.T) {
p := noBrowserProber()
p.CheckCDP = func(_ context.Context, endpoint string) error {
if endpoint == DefaultCDPEndpoint {
return nil
}
return errors.New("connection refused")
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Source != SourceCDP || got.Endpoint != DefaultCDPEndpoint {
t.Fatalf("应命中 CDP 端点,got=%+v", got)
}
}
// TestResolveLocalPreferredOverCDP 钉住优先级顺序(design/28 §8 B1:本机 → 容器)。
func TestResolveLocalPreferredOverCDP(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
p.CheckCDP = func(context.Context, string) error { return nil }
got := Resolve(context.Background(), nil, p)
if got.Source != SourceLocalChrome {
t.Fatalf("本机应优先于 CDP,got=%+v", got)
}
}
// TestResolveConfiguredExecutable 钉住面板显式指定的可执行文件:存在即可用、缺失即分类报错。
func TestResolveConfiguredExecutable(t *testing.T) {
ok := noBrowserProber()
ok.Stat = func(path string) (os.FileInfo, error) {
if path == "/opt/chrome" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
got := Resolve(context.Background(), &Config{Executable: "/opt/chrome"}, ok)
if !got.Usable || got.Source != SourceLocalChrome || got.Executable != "/opt/chrome" {
t.Fatalf("配置的可执行文件应被采纳,got=%+v", got)
}
got = Resolve(context.Background(), &Config{Executable: "/opt/missing"}, noBrowserProber())
if got.Usable || got.Reason != ReasonExecutableMissing {
t.Fatalf("缺失应报 %q,got=%+v", ReasonExecutableMissing, got)
}
}
// TestResolveConfiguredEndpoint 钉住面板显式指定的 CDP 端点:可达即可用、不可达分类报错。
func TestResolveConfiguredEndpoint(t *testing.T) {
p := noBrowserProber()
p.CheckCDP = func(_ context.Context, endpoint string) error {
if endpoint == "http://127.0.0.1:9333" {
return nil
}
return errors.New("connection refused")
}
got := Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9333"}, p)
if !got.Usable || got.Source != SourceCDP || got.Endpoint != "http://127.0.0.1:9333" {
t.Fatalf("配置的 CDP 端点应被采纳,got=%+v", got)
}
got = Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9"}, p)
if got.Usable || got.Reason != ReasonCDPUnreachable {
t.Fatalf("不可达应报 %q,got=%+v", ReasonCDPUnreachable, got)
}
}
// TestResolveConfiguredEndpointDoesNotFallBack 是「不许静默回退」的守门测试:面板配了端点、
// 端点连不上时,**即使本机明明有浏览器**也必须如实报 cdp_unreachable,不许改走本机。
func TestResolveConfiguredEndpointDoesNotFallBack(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
got := Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9"}, p)
if got.Usable || got.Source != SourceNone || got.Reason != ReasonCDPUnreachable {
t.Fatalf("配了端点并连不上时必须如实报错、不得回退本机,got=%+v", got)
}
}
// TestResolveConfiguredExecutableWinsOverEndpoint 钉住显式来源的优先序:executable 先于
// cdpEndpoint(同一个「本机 → 容器」的口径),命中 executable 时不该再去探测端点。
func TestResolveConfiguredExecutableWinsOverEndpoint(t *testing.T) {
p := noBrowserProber()
p.Stat = func(path string) (os.FileInfo, error) {
if path == "/opt/chrome" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
probedEndpoint := false
p.CheckCDP = func(context.Context, string) error { probedEndpoint = true; return nil }
got := Resolve(context.Background(), &Config{Executable: "/opt/chrome", CDPEndpoint: "http://127.0.0.1:9333"}, p)
if got.Source != SourceLocalChrome {
t.Fatalf("executable 应优先,got=%+v", got)
}
if probedEndpoint {
t.Fatal("命中 executable 时不该再探测 CDP 端点")
}
}
// TestResolveHeadlessFromConfig 钉住档位来自配置(默认有头,显式 true 才无头)。
func TestResolveHeadlessFromConfig(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
if got := Resolve(context.Background(), &Config{}, p); got.Headless {
t.Fatalf("默认档应为有头,got=%+v", got)
}
if got := Resolve(context.Background(), &Config{Headless: true}, p); !got.Headless {
t.Fatalf("显式无头应被采纳,got=%+v", got)
}
if got := Resolve(context.Background(), nil, p); got.Headless {
t.Fatalf("无配置时应为有头默认,got=%+v", got)
}
}
// TestProberWithDefaults 钉住零值 Prober 也被补上真实现(零值 = 全默认)。
func TestProberWithDefaults(t *testing.T) {
p := (&Prober{}).withDefaults()
if p.LookPath == nil || p.Stat == nil || p.Getenv == nil || p.CheckCDP == nil || p.GOOS == "" {
t.Fatalf("withDefaults 未补齐字段:%+v", p)
}
}
// TestNewProber 钉住 NewProber 返回的也是补齐过的实现(生产路径取它)。
func TestNewProber(t *testing.T) {
p := NewProber()
if p.LookPath == nil || p.Stat == nil || p.Getenv == nil || p.CheckCDP == nil || p.GOOS == "" {
t.Fatalf("NewProber 未补齐字段:%+v", p)
}
}
// TestCheckCDP 用本机 httptest 服务钉住默认探测实现(生产路径,不能不测):
// `/json/version` 返回 2xx 即可达;非 2xx、连不上都算不可达;尾部斜杠容错。
func TestCheckCDP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/json/version" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"Browser":"Chrome/120"}`))
}))
defer srv.Close()
if err := checkCDP(context.Background(), srv.URL); err != nil {
t.Fatalf("可达端点应返回 nil,got=%v", err)
}
if err := checkCDP(context.Background(), srv.URL+"/"); err != nil {
t.Fatalf("尾部斜杠应被容错,got=%v", err)
}
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer bad.Close()
if err := checkCDP(context.Background(), bad.URL); err == nil {
t.Fatal("非 2xx 应判不可达")
}
if err := checkCDP(context.Background(), "http://127.0.0.1:1"); err == nil {
t.Fatal("连不上应报错")
}
}
// TestCandidateTables 钉住三平台的候选表都非空(避免某个平台分支写成空表 ⇒ 该平台永远
// 探测不到浏览器),以及 Windows 候选完全由安装目录环境变量驱动。
func TestCandidateTables(t *testing.T) {
for _, goos := range []string{"windows", "darwin", "linux"} {
if len(lookPathNames(goos)) == 0 {
t.Fatalf("%s 的 PATH 候选为空", goos)
}
}
env := map[string]string{"ProgramFiles": `C:\PF`, "LOCALAPPDATA": `C:\LA`}
getenv := func(k string) string { return env[k] }
for _, goos := range []string{"windows", "darwin", "linux"} {
if got := absoluteCandidates(goos, getenv); len(got) == 0 {
t.Fatalf("%s 的绝对路径候选为空", goos)
}
}
if got := absoluteCandidates("windows", func(string) string { return "" }); len(got) != 0 {
t.Fatalf("无安装目录环境变量时 windows 候选应为空,got=%v", got)
}
}
// TestResolveNilProberDoesNotPanic 钉住 p=nil 不 panic(不连真实端点:用一个必失败的
// 上下文超时让它立刻返回)。
func TestResolveNilProberDoesNotPanic(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
defer func() {
if r := recover(); r != nil {
t.Fatalf("Resolve(nil prober) panic: %v", r)
}
}()
_ = Resolve(ctx, nil, nil)
}
+142
View File
@@ -0,0 +1,142 @@
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
}
}
@@ -0,0 +1,209 @@
package browsercfg
import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"
)
// TestLoadMissingFileReturnsNil 钉住「文件不存在 = 未配置」不是错误(design/28 §8 B1:
// 无配置文件时按序探测)。
func TestLoadMissingFileReturnsNil(t *testing.T) {
path := filepath.Join(t.TempDir(), "browser.json")
cfg, err := Load(path, t.TempDir())
if err != nil {
t.Fatalf("缺文件不该报错,got err=%v", err)
}
if cfg != nil {
t.Fatalf("缺文件应返回 nil 配置,got=%+v", cfg)
}
}
// TestLoadRejectsBadFiles 钉住「空文件 / JSON 非法 / 字段越界」都如实报错,不静默顶替。
func TestLoadRejectsBadFiles(t *testing.T) {
dir := t.TempDir()
cases := []struct {
name string
body string
}{
{"空文件", " \n"},
{"JSON 非法", "{not json"},
{"视口越上限", `{"viewportWidth": 999999}`},
{"截图字节越上限", `{"screenshotMaxBytes": 999999999}`},
{"端点非法", `{"cdpEndpoint": "ftp://127.0.0.1:9222"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(dir, tc.name+".json")
if err := os.WriteFile(path, []byte(tc.body), 0o600); err != nil {
t.Fatalf("准备文件失败: %v", err)
}
cfg, err := Load(path, dir)
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("应返回 ErrInvalidConfig,got cfg=%+v err=%v", cfg, err)
}
if cfg != nil {
t.Fatalf("非法配置不该返回配置,got=%+v", cfg)
}
})
}
}
// TestLoadNormalizesAndValidates 钉住 Load 返回的是**归一化且校验过**的配置:缺省字段被
// 默认补齐,ProfileDir 落到 dataDir 下。
func TestLoadNormalizesAndValidates(t *testing.T) {
data := t.TempDir()
path := DefaultConfigPath(data)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("建目录失败: %v", err)
}
if err := os.WriteFile(path, []byte(`{"headless": true}`), 0o600); err != nil {
t.Fatalf("写文件失败: %v", err)
}
cfg, err := Load(path, data)
if err != nil {
t.Fatalf("合法配置不该报错,got err=%v", err)
}
if cfg == nil {
t.Fatal("合法配置不该返回 nil")
}
want := Config{
Headless: true,
ProfileDir: DefaultProfileDir(data),
ViewportWidth: DefaultViewportWidth,
ViewportHeight: DefaultViewportHeight,
TimeoutSeconds: DefaultTimeoutSeconds,
IdleTimeoutSeconds: DefaultIdleTimeoutSeconds,
Concurrency: DefaultConcurrency,
ScreenshotMaxBytes: DefaultScreenshotMaxBytes,
}
if *cfg != want {
t.Fatalf("Load 未归一化\n got=%+v\nwant=%+v", *cfg, want)
}
}
// TestSaveRoundTrip 钉住写入后可原样读回(归一化后的形态)。
func TestSaveRoundTrip(t *testing.T) {
data := t.TempDir()
path := DefaultConfigPath(data)
in := Config{Headless: true, ViewportWidth: 1920, CDPEndpoint: "http://127.0.0.1:9222"}
if err := Save(path, data, in); err != nil {
t.Fatalf("Save 失败: %v", err)
}
got, err := Load(path, data)
if err != nil {
t.Fatalf("Load 失败: %v", err)
}
if got == nil || *got != in.Normalize(data) {
t.Fatalf("往返不一致\n got=%+v\nwant=%+v", got, in.Normalize(data))
}
}
// TestSaveFileMode0600 钉住配置文件是 0600(design/28 §4;Windows 不看权限位,跳过)。
func TestSaveFileMode0600(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows 不体现 POSIX 权限位")
}
data := t.TempDir()
path := DefaultConfigPath(data)
if err := Save(path, data, Config{}); err != nil {
t.Fatalf("Save 失败: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat 失败: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("配置权限 = %o,want 600", perm)
}
}
// TestSaveRejectsInvalidWithoutWriting 钉住非法配置不落盘(宁可没有配置文件)。
func TestSaveRejectsInvalidWithoutWriting(t *testing.T) {
data := t.TempDir()
path := DefaultConfigPath(data)
err := Save(path, data, Config{CDPEndpoint: "not-a-url"})
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("应返回 ErrInvalidConfig,got=%v", err)
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("非法配置不该落盘,Stat err=%v", statErr)
}
}
// TestSaveLeavesNoTempFile 钉住 temp+rename 收尾干净(不留 .tmp 残留)。
func TestSaveLeavesNoTempFile(t *testing.T) {
data := t.TempDir()
path := DefaultConfigPath(data)
if err := Save(path, data, Config{}); err != nil {
t.Fatalf("Save 失败: %v", err)
}
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("不该留临时文件,Stat err=%v", err)
}
}
// TestEnsureProfileDirCreates 钉住 profile 目录按 0700 建好(Windows 不看权限位)。
func TestEnsureProfileDirCreates(t *testing.T) {
data := filepath.Join(t.TempDir(), "data")
dir := DefaultProfileDir(data)
if err := EnsureProfileDir(dir, data); err != nil {
t.Fatalf("EnsureProfileDir 失败: %v", err)
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
t.Fatalf("profile 目录未建好: info=%v err=%v", info, err)
}
if runtime.GOOS != "windows" {
if perm := info.Mode().Perm(); perm != 0o700 {
t.Fatalf("profile 权限 = %o,want 700", perm)
}
}
}
// TestEnsureProfileDirRejectsEmpty 钉住空目录被拒。
func TestEnsureProfileDirRejectsEmpty(t *testing.T) {
if err := EnsureProfileDir(" ", "/data"); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("空目录应返回 ErrInvalidConfig,got=%v", err)
}
}
// TestCheckProfileLocationAllowsDataDirInWorktree 钉住默认落点合法:数据目录按项目约定是
// gitignored 的运行期区,即使它落在 git 工作树内也放行。
func TestCheckProfileLocationAllowsDataDirInWorktree(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatalf("造假工作树失败: %v", err)
}
data := filepath.Join(root, "data")
if err := CheckProfileLocation(DefaultProfileDir(data), data); err != nil {
t.Fatalf("数据目录内的 profile 应合法,got err=%v", err)
}
}
// TestCheckProfileLocationRejectsWorktreePath 钉住「会进 git」的落点被拦下(design/28 §4)。
func TestCheckProfileLocationRejectsWorktreePath(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatalf("造假工作树失败: %v", err)
}
dir := filepath.Join(root, "configs", "profile")
err := CheckProfileLocation(dir, filepath.Join(root, "data"))
if !errors.Is(err, ErrProfileInGitTree) {
t.Fatalf("工作树内、且不在数据目录下的 profile 应被拒,got err=%v", err)
}
}
// TestCheckProfileLocationAllowsOutsideWorktree 钉住工作树之外的目录合法(临时目录自身若
// 恰好在某个工作树内则跳过,保持测试与机器无关)。
func TestCheckProfileLocationAllowsOutsideWorktree(t *testing.T) {
dir := t.TempDir()
if worktree, ok := hasGitAncestor(dir); ok {
t.Skipf("临时目录位于工作树 %s 内,跳过", worktree)
}
if err := CheckProfileLocation(dir, ""); err != nil {
t.Fatalf("工作树之外的 profile 应合法,got err=%v", err)
}
}