diff --git a/docs/06-tasks.md b/docs/06-tasks.md index a9076cd..a79a034 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -8,7 +8,7 @@ | --- | --- | --- | --- | | T-001 | 建立 Go module、命令入口、日志和测试基座 | - | TODO | | T-002 | 固化 BrowserDefinition、LaunchSpec、错误和事件合约 | T-001 | DONE | -| T-003 | 实现可测试的参数构造和 Chrome/Edge 发现 | T-002 | TODO | +| T-003 | 实现可测试的参数构造和 Chrome/Edge 发现 | T-002 | DONE | ## Phase 1:进程核心 diff --git a/docs/api.md b/docs/api.md index 11708bf..9e811e2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,6 +20,8 @@ type BrowserManager interface { `Stop(force=false)` 只执行优雅关闭;强制关闭必须由明确用户动作或 CLI `--force` 触发。 +参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。 + ## CLI 目标形状 ```text diff --git a/docs/current-state.md b/docs/current-state.md index e25f30e..4dfbfe4 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,10 +4,10 @@ - 日期:2026-07-22 - 阶段:文档与交互原型准备 -- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座和浏览器 domain/application 合约,T-001/T-002 已完成 +- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约和 Chrome/Edge 参数/发现模块,T-001 至 T-003 已完成 - UI:尚未实现;P0 页面需要先制作 HTML 原型 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;下一个任务为 T-003 +- blocker:无;下一个任务为 T-101 ## 当前目录 diff --git a/docs/tasks/T-003.md b/docs/tasks/T-003.md new file mode 100644 index 0000000..143bcdd --- /dev/null +++ b/docs/tasks/T-003.md @@ -0,0 +1,36 @@ +--- +id: T-003 +title: 实现 Chrome/Edge 参数构造与可执行文件发现 +phase: 0 +deps: [T-002] +status: DONE +created: 2026-07-22 +owner: codex +--- + +## 需求与背景 + +不同浏览器的 executable 路径不同,但 Chromium 启动参数需要保持统一、可测试且不允许用户额外参数覆盖实例身份边界。 + +## 方案与边界 + +- `internal/platform/browser` 提供 Chrome/Edge executable discovery。 +- `BuildArgs` 只生成参数数组,不创建 shell 命令字符串。 +- user data dir、profile、proxy 和 remote debugging 相关参数由工具控制;额外参数只能是非空 `--flag`,不能覆盖受控项。 +- 本任务不启动真实进程,不实现 Windows API 进程树。 + +## 验收要点 + +- Chrome 和 Edge 标准安装路径自动发现测试通过。 +- 配置路径优先于自动发现,非法路径被拒绝。 +- 代理拒绝 userinfo、非法 scheme、非法端口和路径参数。 +- 额外参数不能覆盖 user data dir、profile、代理或 remote debugging 参数。 +- `go test ./...`、`go vet ./...` 通过。 + +## 执行记录 + +- 状态:DONE +- 变更:新增浏览器发现器、参数构造器、代理校验、额外参数边界和注入式测试。 +- 验证:`gofmt`、`go test ./...`、`go vet ./...` 均通过。 +- 阻塞:无。 +- 残余风险:标准安装路径和 Windows 文件权限需在真实 Chrome/Edge smoke 中继续验证。 diff --git a/internal/platform/browser/launch.go b/internal/platform/browser/launch.go new file mode 100644 index 0000000..6ee176a --- /dev/null +++ b/internal/platform/browser/launch.go @@ -0,0 +1,154 @@ +package browser + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "chub/internal/domain" +) + +var ( + ErrExecutableNotFound = errors.New("browser executable was not found") + ErrUnsafeArgument = errors.New("browser argument is controlled or unsafe") +) + +type Discoverer struct { + getenv func(string) string + stat func(string) (fs.FileInfo, error) +} + +func NewDiscoverer() *Discoverer { return &Discoverer{getenv: os.Getenv, stat: os.Stat} } + +func (d *Discoverer) Resolve(ctx context.Context, kind domain.BrowserKind, configured string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + if d == nil || d.getenv == nil || d.stat == nil || !kind.Valid() { + return "", ErrExecutableNotFound + } + if configured = strings.TrimSpace(configured); configured != "" { + if path, ok := d.executable(configured); ok { + return path, nil + } + return "", fmt.Errorf("%w: configured path is unavailable", ErrExecutableNotFound) + } + seen := make(map[string]struct{}) + for _, candidate := range d.candidates(kind) { + cleaned := filepath.Clean(candidate) + key := strings.ToLower(cleaned) + if candidate == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + if path, ok := d.executable(cleaned); ok { + return path, nil + } + } + return "", ErrExecutableNotFound +} + +func (d *Discoverer) candidates(kind domain.BrowserKind) []string { + var suffixes []string + switch kind { + case domain.BrowserChrome: + suffixes = []string{`Google\Chrome\Application\chrome.exe`} + case domain.BrowserEdge: + suffixes = []string{`Microsoft\Edge\Application\msedge.exe`} + } + var candidates []string + for _, root := range []string{"PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"} { + for _, suffix := range suffixes { + candidates = append(candidates, filepath.Join(d.getenv(root), suffix)) + } + } + return candidates +} + +func (d *Discoverer) executable(path string) (string, bool) { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "." || !filepath.IsAbs(cleaned) { + return "", false + } + info, err := d.stat(cleaned) + if err != nil || info.IsDir() || !info.Mode().IsRegular() { + return "", false + } + return cleaned, true +} + +func BuildArgs(spec domain.LaunchSpec) ([]string, error) { + normalized, err := spec.Normalize() + if err != nil { + return nil, err + } + proxy, err := normalizeProxy(normalized.ProxyServer) + if err != nil { + return nil, err + } + args := []string{ + "--user-data-dir=" + normalized.UserDataDir, + "--no-first-run", + "--disable-default-apps", + } + if normalized.ProfileDirectory != "" { + args = append(args, "--profile-directory="+normalized.ProfileDirectory) + } + if normalized.Headless { + args = append(args, "--headless=new") + } + if proxy != "" { + args = append(args, "--proxy-server="+proxy) + } + for _, extra := range normalized.ExtraArgs { + if err := validateExtraArg(extra); err != nil { + return nil, err + } + args = append(args, extra) + } + return append(args, normalized.TargetURL), nil +} + +func normalizeProxy(value string) (string, error) { + proxy := strings.TrimSpace(value) + if proxy == "" { + return "", nil + } + parsed, err := url.Parse(proxy) + if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("%w: proxy must be scheme://host:port without credentials", domain.ErrInvalidLaunchSpec) + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" && scheme != "socks4" && scheme != "socks5" { + return "", fmt.Errorf("%w: unsupported proxy scheme", domain.ErrInvalidLaunchSpec) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65535 { + return "", fmt.Errorf("%w: proxy port out of range", domain.ErrInvalidLaunchSpec) + } + return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil +} + +func validateExtraArg(value string) error { + arg := strings.TrimSpace(value) + if arg == "" || !strings.HasPrefix(arg, "--") { + return fmt.Errorf("%w: extra arguments must be non-empty flags", ErrUnsafeArgument) + } + name := strings.ToLower(strings.SplitN(arg[2:], "=", 2)[0]) + for _, controlled := range []string{"user-data-dir", "profile-directory", "proxy-server", "remote-debugging-address", "remote-debugging-port"} { + if name == controlled { + return fmt.Errorf("%w: --%s is controlled by LaunchSpec", ErrUnsafeArgument, controlled) + } + } + return nil +} diff --git a/internal/platform/browser/launch_test.go b/internal/platform/browser/launch_test.go new file mode 100644 index 0000000..810063c --- /dev/null +++ b/internal/platform/browser/launch_test.go @@ -0,0 +1,90 @@ +package browser + +import ( + "context" + "errors" + "io/fs" + "path/filepath" + "reflect" + "testing" + "time" + + "chub/internal/domain" +) + +func TestBuildArgsUsesControlledArgumentsAndRejectsOverrides(t *testing.T) { + profile := filepath.Join(t.TempDir(), "profile one") + args, err := BuildArgs(domain.LaunchSpec{ + Kind: domain.BrowserChrome, + UserDataDir: profile, + ProfileDirectory: "Default", + TargetURL: "https://example.com/?a=1&b=2", + ProxyServer: "HTTPS://Proxy.Local:8443", + Headless: true, + ExtraArgs: []string{"--window-size=1280,900"}, + }) + if err != nil { + t.Fatalf("BuildArgs() error = %v", err) + } + want := []string{"--user-data-dir=" + filepath.Clean(profile), "--no-first-run", "--disable-default-apps", "--profile-directory=Default", "--headless=new", "--proxy-server=https://proxy.local:8443", "--window-size=1280,900", "https://example.com/?a=1&b=2"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("args = %#v, want %#v", args, want) + } + for _, unsafe := range []string{"--user-data-dir=C:\\other", "--remote-debugging-port=9222", "not-a-flag"} { + _, err := BuildArgs(domain.LaunchSpec{Kind: domain.BrowserEdge, UserDataDir: profile, TargetURL: "https://example.com", ExtraArgs: []string{unsafe}}) + if !errors.Is(err, ErrUnsafeArgument) { + t.Errorf("extra arg %q error = %v", unsafe, err) + } + } +} + +func TestBuildArgsRejectsProxyCredentialsAndUnsupportedValues(t *testing.T) { + base := domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: filepath.Join(t.TempDir(), "profile"), TargetURL: "https://example.com"} + for _, proxy := range []string{"http://user@127.0.0.1:8080", "ftp://127.0.0.1:21", "http://127.0.0.1:70000", "http://127.0.0.1:8080/path"} { + base.ProxyServer = proxy + if _, err := BuildArgs(base); !errors.Is(err, domain.ErrInvalidLaunchSpec) { + t.Errorf("proxy %q error = %v", proxy, err) + } + } +} + +func TestDiscovererResolvesConfiguredAndStandardPaths(t *testing.T) { + root := t.TempDir() + chrome := filepath.Join(root, `Google\Chrome\Application\chrome.exe`) + edge := filepath.Join(root, `Microsoft\Edge\Application\msedge.exe`) + files := map[string]fs.FileInfo{chrome: fakeFileInfo{}, edge: fakeFileInfo{}} + discoverer := &Discoverer{getenv: func(key string) string { + if key == "PROGRAMFILES" { + return root + } + return "" + }, stat: func(path string) (fs.FileInfo, error) { + info, ok := files[path] + if !ok { + return nil, fs.ErrNotExist + } + return info, nil + }} + for _, test := range []struct { + kind domain.BrowserKind + want string + }{{domain.BrowserChrome, chrome}, {domain.BrowserEdge, edge}} { + got, err := discoverer.Resolve(context.Background(), test.kind, "") + if err != nil || got != test.want { + t.Errorf("Resolve(%s) = %q, %v; want %q", test.kind, got, err, test.want) + } + } + got, err := discoverer.Resolve(context.Background(), domain.BrowserChrome, chrome) + if err != nil || got != chrome { + t.Fatalf("configured Resolve() = %q, %v", got, err) + } +} + +type fakeFileInfo struct{} + +func (fakeFileInfo) Name() string { return "browser.exe" } +func (fakeFileInfo) Size() int64 { return 1 } +func (fakeFileInfo) Mode() fs.FileMode { return 0o755 } +func (fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (fakeFileInfo) IsDir() bool { return false } +func (fakeFileInfo) Sys() any { return nil }