feat: add browser argument builder and discovery
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user