diff --git a/pkg/assume/assume.go b/pkg/assume/assume.go index 75f5e23e..9fbdb5f6 100644 --- a/pkg/assume/assume.go +++ b/pkg/assume/assume.go @@ -36,19 +36,6 @@ import ( "gopkg.in/ini.v1" ) -// Launchers give a command that we need to run in order to launch a browser, such as -// 'open ' or 'firefox --new-tab "}) -type Launcher interface { - LaunchCommand(url string, profile string) ([]string, error) - // UseForkProcess returns true if the launcher implementation should call - // the forkprocess library. - // - // For launchers that use 'open' commands, this should be false, - // as the forkprocess library causes the following error to appear: - // fork/exec open: no such file or directory - UseForkProcess() bool -} type execConfig struct { Cmd string Args []string @@ -363,34 +350,8 @@ func AssumeCommand(c *cli.Context) error { return errors.New("default browser not configured. run `granted browser set` to configure") } - var l Launcher - switch cfg.DefaultBrowser { - case browser.ChromeKey, browser.BraveKey, browser.EdgeKey, browser.ChromiumKey, browser.VivaldiKey: - l = launcher.ChromeProfile{ - BrowserType: cfg.DefaultBrowser, - ExecutablePath: browserPath, - } - case browser.FirefoxKey, browser.WaterfoxKey: - l = launcher.Firefox{ - ExecutablePath: browserPath, - } - case browser.SafariKey: - l = launcher.Safari{} - case browser.ArcKey: - l = launcher.Arc{} - case browser.ZenKey: - l = launcher.Zen{ - ExecutablePath: browserPath, - } - case browser.FirefoxDevEditionKey: - l = launcher.FirefoxDevEdition{ - ExecutablePath: browserPath, - } - case browser.FirefoxNightlyKey: - l = launcher.FirefoxNightly{ - ExecutablePath: browserPath, - } - case browser.CustomKey: + var l launcher.Launcher + if cfg.DefaultBrowser == browser.CustomKey { l, err = launcher.CustomFromLaunchTemplate(cfg.AWSConsoleBrowserLaunchTemplate, c.StringSlice("browser-launch-template-arg")) if err == launcher.ErrLaunchTemplateNotConfigured { return errors.New("error configuring custom browser, ensure that [AWSConsoleBrowserLaunchTemplate] is specified in your Granted config file") @@ -398,8 +359,13 @@ func AssumeCommand(c *cli.Context) error { if err != nil { return err } - default: - l = launcher.Open{} + } else { + l, err = launcher.ForBrowser(cfg.DefaultBrowser, browserPath, containerProfile) + if errors.Is(err, launcher.ErrUnsupportedBrowser) { + l = launcher.Open{} + } else if err != nil { + return err + } } printFlagUsage(con.Region, con.Service) diff --git a/pkg/browser/detect.go b/pkg/browser/detect.go index edd7d3e9..1f279be1 100644 --- a/pkg/browser/detect.go +++ b/pkg/browser/detect.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "runtime" "strings" @@ -99,7 +100,19 @@ func Find() (string, error) { return outcome, nil } +// GetBrowserKey identifies a browser from a display name, from a vendor +// identifier as returned by Find (a bundle ID on macOS, a .desktop file on +// Linux, a registry ProgId on Windows), or from the path to its executable. +// It returns StdoutKey for anything it does not recognise. +// +// Only the last path component is considered. Matching is by unanchored +// substring, which is what lets one implementation handle every identifier +// format, but the directory components of a path are chosen by the user and +// routinely contain browser names. Matching those would let a home directory +// decide which browser gets launched. func GetBrowserKey(b string) string { + b = filepath.Base(b) + if strings.Contains(strings.ToLower(b), "chrome") { return ChromeKey } diff --git a/pkg/browser/detect_test.go b/pkg/browser/detect_test.go new file mode 100644 index 00000000..c2ef1525 --- /dev/null +++ b/pkg/browser/detect_test.go @@ -0,0 +1,67 @@ +package browser + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetBrowserKey(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + // Display names. + {"display_name", "Chrome", ChromeKey}, + {"display_name_multiword", "Firefox Developer Edition", FirefoxDevEditionKey}, + {"display_name_stdout", "Stdout", StdoutKey}, + {"display_name_custom", "Custom", CustomKey}, + + // Vendor identifiers, as returned by Find. + {"macos_bundle_id", "com.google.chrome", ChromeKey}, + {"macos_bundle_id_safari", "com.apple.Safari", SafariKey}, + {"linux_desktop_file", "firefox.desktop\n", FirefoxKey}, + {"windows_progid", "ChromeHTML", ChromeKey}, + {"windows_progid_edge", "MSEdgeHTM", EdgeKey}, + + // Executable paths. + {"path_safari", "/Applications/Safari.app/Contents/MacOS/Safari", SafariKey}, + {"path_arc", "/Applications/Arc.app/Contents/MacOS/Arc", ArcKey}, + {"path_chrome_mac", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ChromeKey}, + {"path_firefox_linux", "/usr/bin/firefox", FirefoxKey}, + {"path_waterfox", "/usr/bin/waterfox", WaterfoxKey}, + {"path_zen_linux", "/usr/bin/zen-browser", ZenKey}, + {"path_edge_windows", `\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, EdgeKey}, + + // Installed outside the default location. + {"path_homebrew", "/opt/homebrew/bin/firefox", FirefoxKey}, + {"path_snap", "/snap/bin/chromium", ChromiumKey}, + + {"unknown", "/opt/some-browser/bin/browser", StdoutKey}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, GetBrowserKey(tt.input)) + }) + } +} + +// Directory components are user-chosen and must not select a browser. +func TestGetBrowserKey_IgnoresDirectoryComponents(t *testing.T) { + paths := []string{ + "/Users/marcus/bin/browser", // contains "arc" + "/Users/zenon/bin/browser", // contains "zen" + "/opt/edge-cases/bin/browser", // contains "edge" + "/opt/research/bin/browser", // contains "arc" + "/home/safarista/bin/browser", // contains "safari" + "/srv/chrome-testing/browser", // contains "chrome" + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + assert.Equal(t, StdoutKey, GetBrowserKey(path), "directory components must not select a browser") + }) + } +} diff --git a/pkg/granted/console.go b/pkg/granted/console.go index 38b3c6f2..b463d29d 100644 --- a/pkg/granted/console.go +++ b/pkg/granted/console.go @@ -8,7 +8,6 @@ import ( "github.com/common-fate/clio" "github.com/common-fate/clio/clierr" - "github.com/fwdcloudsec/granted/pkg/assume" "github.com/fwdcloudsec/granted/pkg/browser" "github.com/fwdcloudsec/granted/pkg/cfaws" "github.com/fwdcloudsec/granted/pkg/config" @@ -66,44 +65,13 @@ var ConsoleCommand = cli.Command{ return nil } - var l assume.Launcher + var l launcher.Launcher if cfg.CustomBrowserPath == "" && cfg.DefaultBrowser != "" { l = launcher.Open{} } else if cfg.CustomBrowserPath == "" && cfg.AWSConsoleBrowserLaunchTemplate == nil { return errors.New("default browser not configured. run `granted browser set` to configure") } else { - switch cfg.DefaultBrowser { - case browser.ChromeKey: - l = launcher.ChromeProfile{ - ExecutablePath: cfg.CustomBrowserPath, - } - case browser.BraveKey: - l = launcher.ChromeProfile{ - ExecutablePath: cfg.CustomBrowserPath, - } - case browser.EdgeKey: - l = launcher.ChromeProfile{ - ExecutablePath: cfg.CustomBrowserPath, - } - case browser.ChromiumKey: - l = launcher.ChromeProfile{ - ExecutablePath: cfg.CustomBrowserPath, - } - case browser.VivaldiKey: - l = launcher.ChromeProfile{ - ExecutablePath: cfg.CustomBrowserPath, - } - case browser.FirefoxKey: - l = launcher.Firefox{ - ExecutablePath: cfg.CustomBrowserPath, - } - case browser.ZenKey: - l = launcher.Zen{ - ExecutablePath: cfg.CustomBrowserPath, - } - case browser.SafariKey: - l = launcher.Safari{} - case browser.CustomKey: + if cfg.DefaultBrowser == browser.CustomKey { l, err = launcher.CustomFromLaunchTemplate(cfg.AWSConsoleBrowserLaunchTemplate, c.StringSlice("browser-launch-template-arg")) if err == launcher.ErrLaunchTemplateNotConfigured { return errors.New("error configuring custom browser, ensure that [AWSConsoleBrowserLaunchTemplate] is specified in your Granted config file") @@ -111,8 +79,13 @@ var ConsoleCommand = cli.Command{ if err != nil { return err } - default: - l = launcher.Open{} + } else { + l, err = launcher.ForBrowser(cfg.DefaultBrowser, cfg.CustomBrowserPath, con.Profile) + if errors.Is(err, launcher.ErrUnsupportedBrowser) { + l = launcher.Open{} + } else if err != nil { + return err + } } } // now build the actual command to run - e.g. 'firefox --new-tab ' diff --git a/pkg/idclogin/browser.go b/pkg/idclogin/browser.go index e81e3e06..16a4c3c8 100644 --- a/pkg/idclogin/browser.go +++ b/pkg/idclogin/browser.go @@ -8,19 +8,17 @@ import ( "github.com/common-fate/clio" "github.com/common-fate/clio/clierr" + "github.com/fwdcloudsec/granted/pkg/browser" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/forkprocess" "github.com/fwdcloudsec/granted/pkg/launcher" - "github.com/pkg/browser" + defaultbrowser "github.com/pkg/browser" ) // openBrowser opens the given URL in the user's configured browser, // respecting Granted's custom browser settings. If the browser fails to open, // it returns an error. func openBrowser(url string, browserProfile string) error { - // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - // The browser path comes from the user's local Granted configuration file, - // not from untrusted input. config, err := grantedConfig.Load() if err != nil { return err @@ -31,10 +29,10 @@ func openBrowser(url string, browserProfile string) error { } if config.CustomSSOBrowserPath != "" { - return openWithCustomPath(config.CustomSSOBrowserPath, url) + return openWithCustomPath(config.CustomSSOBrowserPath, url, browserProfile) } - return browser.OpenURL(url) + return defaultbrowser.OpenURL(url) } func openWithLaunchTemplate(config *grantedConfig.Config, url string, browserProfile string) error { @@ -46,6 +44,41 @@ func openWithLaunchTemplate(config *grantedConfig.Config, url string, browserPro return err } + return launch(l, url, browserProfile) +} + +// openWithCustomPath launches the browser configured as CustomSSOBrowserPath. +// +// Browsers that Granted recognises are launched through their own Launcher, +// because not all of them accept a URL as a bare argument. Safari in particular +// resolves one as a file path relative to its sandbox container, which silently +// opens the wrong page. Anything Granted does not recognise keeps being executed +// directly, which is the behaviour those configurations already have. +func openWithCustomPath(browserPath, url, browserProfile string) error { + l, err := launcherForPath(browserPath, browserProfile) + if err != nil { + return err + } + return launch(l, url, browserProfile) +} + +func launcherForPath(browserPath, browserProfile string) (launcher.Launcher, error) { + key := browser.GetBrowserKey(browserPath) + + l, err := launcher.ForBrowser(key, browserPath, browserProfile) + if errors.Is(err, launcher.ErrUnsupportedBrowser) { + clio.Debugf("no known browser at %s, launching it directly", browserPath) + return launcher.Direct{ExecutablePath: browserPath}, nil + } + if err != nil { + return nil, err + } + return l, nil +} + +// launch runs a Launcher's command, detaching the browser from Granted so that +// it outlives the CLI process. +func launch(l launcher.Launcher, url, browserProfile string) error { args, err := l.LaunchCommand(url, browserProfile) if err != nil { return fmt.Errorf("error building browser launch command: %w", err) @@ -62,22 +95,15 @@ func openWithLaunchTemplate(config *grantedConfig.Config, url string, browserPro clio.Debugf("running command without forkprocess: %s", args) // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + // The browser command is built from the user's local Granted configuration + // file, not from untrusted input. cmd := exec.Command(args[0], args[1:]...) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr - return cmd.Start() -} - -func openWithCustomPath(browserPath, url string) error { - // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - cmd := exec.Command(browserPath, url) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return err } - _ = cmd.Process.Release() - return nil + return cmd.Process.Release() } // OpenBrowserWithFallbackMessage opens the browser and logs a helpful message if it fails. diff --git a/pkg/idclogin/browser_test.go b/pkg/idclogin/browser_test.go new file mode 100644 index 00000000..f2e36011 --- /dev/null +++ b/pkg/idclogin/browser_test.go @@ -0,0 +1,55 @@ +package idclogin + +import ( + "testing" + + "github.com/fwdcloudsec/granted/pkg/browser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLauncherForPath(t *testing.T) { + const url = "https://example.awsapps.com/start/#/device?user_code=ABCD-EFGH" + + // 'open' on macOS, 'xdg-open' on Linux. + open := browser.OpenCommand() + + tests := []struct { + name string + path string + profile string + want []string + }{ + { + name: "safari_is_launched_with_open", + path: "/Applications/Safari.app/Contents/MacOS/Safari", + want: []string{open, "-a", "Safari", url}, + }, + { + name: "firefox_keeps_its_own_launcher", + path: "/usr/bin/firefox", + want: []string{"/usr/bin/firefox", "--new-tab", url}, + }, + { + name: "unknown_browser_is_executed_directly", + path: "/opt/some-browser/bin/browser", + want: []string{"/opt/some-browser/bin/browser", url}, + }, + { + name: "chrome_without_sso_browser_profile", + path: "/usr/bin/google-chrome", + want: []string{"/usr/bin/google-chrome", "--no-first-run", "--no-default-browser-check", url}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l, err := launcherForPath(tt.path, tt.profile) + require.NoError(t, err) + + got, err := l.LaunchCommand(url, tt.profile) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/launcher/chrome_profile.go b/pkg/launcher/chrome_profile.go index 0925b024..306d87f3 100644 --- a/pkg/launcher/chrome_profile.go +++ b/pkg/launcher/chrome_profile.go @@ -20,6 +20,19 @@ type ChromeProfile struct { } func (l ChromeProfile) LaunchCommand(url string, profile string) ([]string, error) { + // With no profile to isolate the session into there is nothing to look up, + // and an empty --profile-directory would change which profile the browser + // opens rather than leaving it alone. The SSO login flow and 'granted + // console' both launch without a profile. + if profile == "" { + return []string{ + l.ExecutablePath, + "--no-first-run", + "--no-default-browser-check", + url, + }, nil + } + // Chrome profiles can't contain slashes profileName := strings.ReplaceAll(profile, "/", "-") profileDir := findBrowserProfile(profileName, l.BrowserType) diff --git a/pkg/launcher/chrome_profile_test.go b/pkg/launcher/chrome_profile_test.go new file mode 100644 index 00000000..4eac7135 --- /dev/null +++ b/pkg/launcher/chrome_profile_test.go @@ -0,0 +1,137 @@ +package launcher + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + + "github.com/fwdcloudsec/granted/pkg/browser" +) + +// writeLocalState points Chrome profile lookup at a temporary home directory. +func writeLocalState(t *testing.T, infoCache map[string]any) { + t.Helper() + + var relative string + switch runtime.GOOS { + case "darwin": + relative = ChromePathMac + case "linux": + relative = ChromePathLinux + case "windows": + relative = ChromePathWindows + default: + t.Skipf("unsupported OS %s", runtime.GOOS) + } + + home := t.TempDir() + // os.UserHomeDir reads %USERPROFILE% on Windows, $HOME elsewhere. + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + stateFile := filepath.Join(home, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(stateFile), 0700); err != nil { + t.Fatalf("creating Local State directory: %v", err) + } + + contents := map[string]any{ + "profile": map[string]any{"info_cache": infoCache}, + } + data, err := json.Marshal(contents) + if err != nil { + t.Fatalf("marshalling Local State: %v", err) + } + if err := os.WriteFile(stateFile, data, 0600); err != nil { + t.Fatalf("writing Local State: %v", err) + } +} + +func TestChromeProfile_LaunchCommand_ResolvesProfileDirectory(t *testing.T) { + writeLocalState(t, map[string]any{ + "Default": map[string]any{"name": "Person 1"}, + "Profile 3": map[string]any{"name": "my-aws-profile"}, + }) + + l := ChromeProfile{BrowserType: browser.ChromeKey, ExecutablePath: "/usr/bin/google-chrome"} + + got, err := l.LaunchCommand("https://commonfate.io", "my-aws-profile") + if err != nil { + t.Fatalf("LaunchCommand() unexpected error = %v", err) + } + + want := []string{ + "/usr/bin/google-chrome", + "--profile-directory=Profile 3", + "--no-first-run", + "--no-default-browser-check", + "https://commonfate.io", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("LaunchCommand() = %v, want %v", got, want) + } +} + +func TestChromeProfile_LaunchCommand_UnknownProfileFallsBack(t *testing.T) { + writeLocalState(t, map[string]any{ + "Default": map[string]any{"name": "Person 1"}, + }) + + l := ChromeProfile{BrowserType: browser.ChromeKey, ExecutablePath: "/usr/bin/google-chrome"} + + got, err := l.LaunchCommand("https://commonfate.io", "never-seen-before") + if err != nil { + t.Fatalf("LaunchCommand() unexpected error = %v", err) + } + + want := []string{ + "/usr/bin/google-chrome", + "--profile-directory=never-seen-before", + "--no-first-run", + "--no-default-browser-check", + "https://commonfate.io", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("LaunchCommand() = %v, want %v", got, want) + } +} + +// Chrome profile names cannot contain slashes. +func TestChromeProfile_LaunchCommand_ReplacesSlashesInProfileName(t *testing.T) { + writeLocalState(t, map[string]any{ + "Profile 7": map[string]any{"name": "sso-my-role"}, + }) + + l := ChromeProfile{BrowserType: browser.ChromeKey, ExecutablePath: "/usr/bin/google-chrome"} + + got, err := l.LaunchCommand("https://commonfate.io", "sso/my-role") + if err != nil { + t.Fatalf("LaunchCommand() unexpected error = %v", err) + } + + if got[1] != "--profile-directory=Profile 7" { + t.Errorf("LaunchCommand() profile directory = %q, want %q", got[1], "--profile-directory=Profile 7") + } +} + +// An empty --profile-directory would change which profile Chrome opens. +func TestChromeProfile_LaunchCommand_NoProfile(t *testing.T) { + l := ChromeProfile{BrowserType: browser.ChromeKey, ExecutablePath: "/usr/bin/google-chrome"} + + got, err := l.LaunchCommand("https://commonfate.io", "") + if err != nil { + t.Fatalf("LaunchCommand() unexpected error = %v", err) + } + + want := []string{ + "/usr/bin/google-chrome", + "--no-first-run", + "--no-default-browser-check", + "https://commonfate.io", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("LaunchCommand() = %v, want %v", got, want) + } +} diff --git a/pkg/launcher/direct.go b/pkg/launcher/direct.go new file mode 100644 index 00000000..f6f4f8d7 --- /dev/null +++ b/pkg/launcher/direct.go @@ -0,0 +1,18 @@ +package launcher + +// Direct launches a browser by executing it with the URL as its only argument. +// It is the fallback for browsers that Granted has no specific handling for. +// +// Note that this does not work for every browser: Safari resolves a bare +// argument as a file path rather than a URL, which is why it has its own +// launcher. +type Direct struct { + // ExecutablePath is the path to the browser binary on the system. + ExecutablePath string +} + +func (l Direct) LaunchCommand(url string, profile string) ([]string, error) { + return []string{l.ExecutablePath, url}, nil +} + +func (l Direct) UseForkProcess() bool { return false } diff --git a/pkg/launcher/launcher.go b/pkg/launcher/launcher.go new file mode 100644 index 00000000..33f5b601 --- /dev/null +++ b/pkg/launcher/launcher.go @@ -0,0 +1,55 @@ +package launcher + +import ( + "errors" + + "github.com/fwdcloudsec/granted/pkg/browser" +) + +// Launchers give a command that we need to run in order to launch a browser, such as +// 'open ' or 'firefox --new-tab '. The returned command is a string slice, +// with each element being an argument. (e.g. []string{"firefox", "--new-tab", ""}) +type Launcher interface { + LaunchCommand(url string, profile string) ([]string, error) + // UseForkProcess returns true if the launcher implementation should call + // the forkprocess library. + // + // For launchers that use 'open' commands, this should be false, + // as the forkprocess library causes the following error to appear: + // fork/exec open: no such file or directory + UseForkProcess() bool +} + +// ErrUnsupportedBrowser is returned by ForBrowser for keys it does not handle, +// including CustomKey. Callers decide the fallback: the console commands use +// 'open', and the SSO login flow executes the configured path directly. +var ErrUnsupportedBrowser = errors.New("unsupported browser") + +// ForBrowser returns the Launcher to use for a browser key. path is the browser +// executable and profile is the browser profile to isolate the session into. +// +// CustomKey is deliberately not handled here, because both console call sites +// resolve it through CustomFromLaunchTemplate, which needs the configured +// launch template and its arguments. +func ForBrowser(key, path, profile string) (Launcher, error) { + switch key { + case browser.ChromeKey, browser.BraveKey, browser.EdgeKey, browser.ChromiumKey, browser.VivaldiKey: + // ChromeProfile handles an empty profile by launching without one. The SSO + // login flow has no profile unless --sso-browser-profile was given. + return ChromeProfile{BrowserType: key, ExecutablePath: path}, nil + case browser.FirefoxKey, browser.WaterfoxKey: + return Firefox{ExecutablePath: path}, nil + case browser.FirefoxDevEditionKey: + return FirefoxDevEdition{ExecutablePath: path}, nil + case browser.FirefoxNightlyKey: + return FirefoxNightly{ExecutablePath: path}, nil + case browser.ZenKey: + return Zen{ExecutablePath: path}, nil + case browser.SafariKey: + return Safari{}, nil + case browser.ArcKey: + return Arc{}, nil + default: + return nil, ErrUnsupportedBrowser + } +} diff --git a/pkg/launcher/launcher_test.go b/pkg/launcher/launcher_test.go new file mode 100644 index 00000000..b0d6c04b --- /dev/null +++ b/pkg/launcher/launcher_test.go @@ -0,0 +1,153 @@ +package launcher + +import ( + "errors" + "reflect" + "testing" + + "github.com/fwdcloudsec/granted/pkg/browser" +) + +func TestForBrowser(t *testing.T) { + const url = "https://commonfate.io" + + // 'open' on macOS, 'xdg-open' on Linux. + open := browser.OpenCommand() + + tests := []struct { + name string + key string + path string + profile string + want []string + wantErr error + }{ + { + // Safari resolves a bare argument as a file path, not a URL. + name: "safari_uses_open", + key: browser.SafariKey, + path: "/Applications/Safari.app/Contents/MacOS/Safari", + want: []string{open, "-a", "Safari", url}, + }, + { + name: "arc_uses_open", + key: browser.ArcKey, + path: "/Applications/Arc.app/Contents/MacOS/Arc", + want: []string{open, "-a", "Arc", url}, + }, + { + name: "firefox", + key: browser.FirefoxKey, + path: "/usr/bin/firefox", + want: []string{"/usr/bin/firefox", "--new-tab", url}, + }, + { + name: "waterfox_uses_firefox_launcher", + key: browser.WaterfoxKey, + path: "/usr/bin/waterfox", + want: []string{"/usr/bin/waterfox", "--new-tab", url}, + }, + { + name: "firefox_dev_edition", + key: browser.FirefoxDevEditionKey, + path: "/usr/bin/firefox-developer", + want: []string{"/usr/bin/firefox-developer", "--new-tab", url}, + }, + { + name: "firefox_nightly", + key: browser.FirefoxNightlyKey, + path: "/usr/bin/firefox-nightly", + want: []string{"/usr/bin/firefox-nightly", "--new-tab", url}, + }, + { + name: "zen", + key: browser.ZenKey, + path: "/usr/bin/zen-browser", + want: []string{"/usr/bin/zen-browser", "--new-tab", url}, + }, + { + name: "chrome_without_profile_omits_profile_directory", + key: browser.ChromeKey, + path: "/usr/bin/google-chrome", + want: []string{"/usr/bin/google-chrome", "--no-first-run", "--no-default-browser-check", url}, + }, + { + name: "vivaldi_without_profile", + key: browser.VivaldiKey, + path: "/usr/bin/vivaldi", + want: []string{"/usr/bin/vivaldi", "--no-first-run", "--no-default-browser-check", url}, + }, + { + // CustomKey needs the launch template, which ForBrowser has no access to. + name: "custom_is_left_to_the_caller", + key: browser.CustomKey, + path: "/usr/bin/whatever", + wantErr: ErrUnsupportedBrowser, + }, + { + name: "stdout_is_unsupported", + key: browser.StdoutKey, + wantErr: ErrUnsupportedBrowser, + }, + { + name: "unknown_key_is_unsupported", + key: "NOT_A_BROWSER", + path: "/usr/bin/whatever", + wantErr: ErrUnsupportedBrowser, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l, err := ForBrowser(tt.key, tt.path, tt.profile) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ForBrowser() error = %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ForBrowser() unexpected error = %v", err) + } + + got, err := l.LaunchCommand(url, tt.profile) + if err != nil { + t.Fatalf("LaunchCommand() unexpected error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("LaunchCommand() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDirect_LaunchCommand(t *testing.T) { + l := Direct{ExecutablePath: "/opt/some-browser/bin/browser"} + + got, err := l.LaunchCommand("https://commonfate.io", "ignored-profile") + if err != nil { + t.Fatalf("LaunchCommand() unexpected error = %v", err) + } + + want := []string{"/opt/some-browser/bin/browser", "https://commonfate.io"} + if !reflect.DeepEqual(got, want) { + t.Errorf("LaunchCommand() = %v, want %v", got, want) + } + + if l.UseForkProcess() { + t.Error("Direct.UseForkProcess() = true, want false") + } +} + +// forkprocess cannot run 'open'. +func TestForBrowser_OpenLaunchersDoNotForkProcess(t *testing.T) { + for _, key := range []string{browser.SafariKey, browser.ArcKey} { + l, err := ForBrowser(key, "", "") + if err != nil { + t.Fatalf("ForBrowser(%s) unexpected error = %v", key, err) + } + if l.UseForkProcess() { + t.Errorf("ForBrowser(%s).UseForkProcess() = true, want false", key) + } + } +}