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 }