diff --git a/internal/cli/auth_env.go b/internal/cli/auth_env.go index 9068173f5..ec002c091 100644 --- a/internal/cli/auth_env.go +++ b/internal/cli/auth_env.go @@ -178,6 +178,8 @@ func (c *cli) setupWithEnvAuthentication(ctx context.Context) error { c.api = auth0.NewAPI(api) c.apiv3 = auth0.NewAPIV3(apiv3) + c.accessToken = accessToken + c.sessionDomain = domain return nil } diff --git a/internal/cli/auth_env_test.go b/internal/cli/auth_env_test.go index d6e46562c..bb7f74a8c 100644 --- a/internal/cli/auth_env_test.go +++ b/internal/cli/auth_env_test.go @@ -5,8 +5,11 @@ import ( "errors" "fmt" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gokeyring "github.com/zalando/go-keyring" "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/display" @@ -194,6 +197,38 @@ func TestSetupWithEnvAuthenticationNoSavedConfig(t *testing.T) { assert.Equal(t, "tenant.us.auth0.com", c.tenant) assert.NotNil(t, c.api) assert.NotNil(t, c.apiv3) + assert.Equal(t, "direct-token", c.accessToken, "a plugin run hands this token to its proxy") + assert.Equal(t, "tenant.us.auth0.com", c.sessionDomain) +} + +func TestSetupWithAuthenticationKeepsTheSession(t *testing.T) { + // A saved tenant with a token that has not expired is used as it is: nothing + // is refreshed and no login starts. The mock keyring and the temporary home + // keep the test away from the real keychain and config. + gokeyring.MockInit() + t.Setenv("HOME", t.TempDir()) + + for _, name := range []string{authModeEnvVar, envDomain, envAPIToken, envClientID, envClientSecret} { + t.Setenv(name, "") + } + + const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6OTY4MTgzMzQ2MH0.DsEpQkL0MIWcGJOIfEY8vr3MVS_E0GYsachNLQwBu5Q" + + saveTenant(t, config.Tenant{ + Domain: "saved.us.auth0.com", + AccessToken: token, + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "client", + }) + + c := &cli{renderer: &display.Renderer{}} + + require.NoError(t, c.setupWithAuthentication(context.Background())) + + assert.Equal(t, token, c.accessToken, "a plugin run hands this token to its proxy") + assert.Equal(t, "saved.us.auth0.com", c.sessionDomain) + assert.NotNil(t, c.api) + assert.NotNil(t, c.apiv3) } func TestResolveInstallIDForTrackingSkipsDiskInEnvMode(t *testing.T) { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 82cfe2e7e..849091364 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -62,6 +62,17 @@ type cli struct { detectedAgent string executedCommandPath string + // The access token and tenant domain of the session that + // setupWithAuthentication opened, kept in memory only, so that a plugin run + // can hand the token to its proxy without reading the keyring again. They are + // empty until a session was set up. The token must never be printed. + accessToken string + sessionDomain string + + // The names of the installed plugins that a core command shadows, with the + // plugin ID, see registerPluginCommands. + shadowedPlugins map[string]string + Config config.Config } @@ -225,6 +236,8 @@ func (c *cli) setupWithAuthentication(ctx context.Context) error { c.api = auth0.NewAPI(api) c.apiv3 = auth0.NewAPIV3(apiv3) + c.accessToken = accessToken + c.sessionDomain = tenant.Domain return nil } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 9f48b4f6d..c06733eeb 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -380,6 +380,12 @@ func renderNamespaceJSONHelp(root *cobra.Command, args []string) bool { return false } + // A plugin command does not parse its flags, so a JSON flag on it is for the + // plugin to run with, not a request for help. + if target.Annotations[pluginCommandAnnotation] == "true" { + return false + } + // Cobra adds the --help/-h flag lazily during execution; add it now so a // `--help --json` combination parses instead of tripping on "unknown flag: --help". target.InitDefaultHelpFlag() diff --git a/internal/cli/doc-gen.go b/internal/cli/doc-gen.go index 2bac2a788..0f8f2432f 100644 --- a/internal/cli/doc-gen.go +++ b/internal/cli/doc-gen.go @@ -21,6 +21,11 @@ const ( // them. skipDocsAnnotation = "skipDocs" + // The annotation with this key, set to "true" on a command, keeps the root + // pre-run hook from authenticating for the command. The command does its own + // checks first and logs in only when it needs a session. + skipAuthAnnotation = "skipAuth" + associatedCommandsFragment = `{{range .AssociatedCommands}}- [{{.CommandPath}}]({{ link .CommandPath }}) - {{.Short}} {{end}}` diff --git a/internal/cli/exit.go b/internal/cli/exit.go index db6d58b04..f9464f794 100644 --- a/internal/cli/exit.go +++ b/internal/cli/exit.go @@ -94,6 +94,40 @@ func (e validationError) Unwrap() error { return e.err } func (e validationError) ErrorDetails() json.RawMessage { return e.details } +// pluginError is a failure of a plugin run: the plugin exited with a non-zero +// code, ran out of time, was interrupted, or could not be started. It classifies +// as "plugin" in the JSON error envelope and carries which plugin it was and the +// exit code of the plugin in the envelope's "details", so a script can tell the +// failure of a plugin from a failure of the CLI. The process itself exits with the +// generic code, see exitCodeForError. +type pluginError struct { + err error + reason string + pluginID string + version string + + // ExitCode is the code the plugin exited with, or zero when it did not exit by + // itself. + exitCode int +} + +func (e pluginError) Error() string { return e.err.Error() } + +func (e pluginError) Unwrap() error { return e.err } + +func (e pluginError) ErrorDetails() json.RawMessage { + details, err := json.Marshal(struct { + PluginID string `json:"pluginId"` + Version string `json:"version"` + ExitCode int `json:"exitCode,omitempty"` + }{e.pluginID, e.version, e.exitCode}) + if err != nil { + return nil + } + + return details +} + // errorClass returns a stable, machine-readable classification for an error. It // is the single source of truth shared by exit-code mapping, the JSON error // envelope, and analytics tracking. @@ -117,6 +151,11 @@ func errorClass(err error) string { return "validation" } + var pluginErr pluginError + if errors.As(err, &pluginErr) { + return "plugin" + } + if errors.Is(err, config.ErrInvalidToken) || errors.Is(err, config.ErrMalformedToken) || errors.Is(err, config.ErrStoredTokenUnavailable) || @@ -201,6 +240,11 @@ func errorReason(err error) string { return "local_validation" } + var pluginErr pluginError + if errors.As(err, &pluginErr) { + return pluginErr.reason + } + if status, ok := managementHTTPStatus(err); ok { return reasonForHTTPStatus(status) } diff --git a/internal/cli/exit_test.go b/internal/cli/exit_test.go index 06d81ca8b..bad5417fb 100644 --- a/internal/cli/exit_test.go +++ b/internal/cli/exit_test.go @@ -44,6 +44,8 @@ func TestErrorClass(t *testing.T) { {name: "wrapped auth error", err: authError{err: fmt.Errorf("wrap: %w", errors.New("token expired"))}, expected: "auth"}, {name: "local validation error", err: validationError{err: errors.New("schema validation failed")}, expected: "validation"}, {name: "wrapped validation error", err: fmt.Errorf("wrap: %w", validationError{err: errors.New("bad json")}), expected: "validation"}, + {name: "plugin error", err: pluginError{err: errors.New("exited"), reason: "plugin_exit"}, expected: "plugin"}, + {name: "wrapped plugin error", err: fmt.Errorf("wrap: %w", pluginError{err: errors.New("slow"), reason: "plugin_timeout"}), expected: "plugin"}, {name: "missing scopes", err: config.ErrTokenMissingRequiredScopes{MissingScopes: []string{"read:users"}}, expected: "auth"}, {name: "401 unauthorized", err: fakeManagementError{status: 401}, expected: "auth"}, {name: "403 forbidden", err: fakeManagementError{status: 403}, expected: "auth"}, @@ -109,6 +111,9 @@ func TestErrorReason(t *testing.T) { {name: "malformed token sentinel", err: config.ErrMalformedToken, expected: "token_malformed"}, {name: "missing scopes", err: config.ErrTokenMissingRequiredScopes{MissingScopes: []string{"read:users"}}, expected: "missing_scopes"}, {name: "validation default", err: validationError{err: errors.New("bad json")}, expected: "local_validation"}, + {name: "plugin exited", err: pluginError{err: errors.New("exited"), reason: "plugin_exit"}, expected: "plugin_exit"}, + {name: "plugin timed out", err: pluginError{err: errors.New("slow"), reason: "plugin_timeout"}, expected: "plugin_timeout"}, + {name: "plugin interrupted", err: pluginError{err: errors.New("stopped"), reason: "plugin_interrupted"}, expected: "plugin_interrupted"}, {name: "401 unauthorized", err: fakeManagementError{status: 401}, expected: "unauthorized"}, {name: "403 forbidden", err: fakeManagementError{status: 403}, expected: "forbidden"}, {name: "400 invalid request", err: fakeManagementError{status: 400}, expected: "invalid_request"}, @@ -145,6 +150,7 @@ func TestExitCodeForError(t *testing.T) { {name: "auth error", err: config.ErrInvalidToken, expected: exitGeneric}, {name: "server validation error", err: fakeManagementError{status: 400}, expected: exitGeneric}, {name: "local validation error", err: validationError{err: errors.New("bad payload")}, expected: exitGeneric}, + {name: "plugin error", err: pluginError{err: errors.New("exited"), reason: "plugin_exit", exitCode: 7}, expected: exitGeneric}, {name: "not found error", err: fakeManagementError{status: 404}, expected: exitGeneric}, {name: "rate limit error", err: fakeManagementError{status: 429}, expected: exitGeneric}, {name: "api error", err: fakeManagementError{status: 500}, expected: exitGeneric}, @@ -169,6 +175,10 @@ func TestAgentModeHelpMatchesExitCodes(t *testing.T) { assert.Contains(t, agentModeHelp, "exits 1", "help should state the coarse failure exit code") assert.Contains(t, agentModeHelp, `"code" field`, "help should point the failure class at the JSON envelope") + + for _, class := range []string{"usage", "auth", "validation", "not_found", "conflict", "rate_limit", "api", "plugin"} { + assert.Contains(t, agentModeHelp, class, "help should list every failure class of the envelope") + } } func TestBuildErrorEnvelope(t *testing.T) { @@ -227,6 +237,17 @@ func TestBuildErrorEnvelope(t *testing.T) { assert.JSONEq(t, `{"suggestions":["apps","apis"]}`, string(envelope.Error.Details)) }) + t.Run("carries the plugin and its exit code as details", func(t *testing.T) { + envelope := buildErrorEnvelope(pluginError{ + err: errors.New("checkmate 1.0.0 exited with code 7"), reason: "plugin_exit", + pluginID: "auth0/checkmate", version: "1.0.0", exitCode: 7, + }) + + assert.Equal(t, "plugin", envelope.Error.Code) + assert.Equal(t, "plugin_exit", envelope.Error.Reason) + assert.JSONEq(t, `{"pluginId":"auth0/checkmate","version":"1.0.0","exitCode":7}`, string(envelope.Error.Details)) + }) + t.Run("omits details when the unknown command has no suggestions", func(t *testing.T) { envelope := buildErrorEnvelope(unknownCommandError{token: "zzz", parent: "auth0"}) diff --git a/internal/cli/interrupt.go b/internal/cli/interrupt.go new file mode 100644 index 000000000..46537d72b --- /dev/null +++ b/internal/cli/interrupt.go @@ -0,0 +1,79 @@ +package cli + +import ( + "context" + "sync" +) + +// interruptGuard lets a command that started other processes stop them before +// the CLI exits on Ctrl-C. Without a holder the interrupt ends the process at +// once, as it always did. While there is one, the interrupt only cancels the +// context, the holder cleans up and returns, and Execute then exits with the code +// of an interrupt. +type interruptGuard struct { + mu sync.Mutex + holders int + interrupted bool +} + +type interruptGuardKey struct{} + +// withInterruptGuard returns a context that carries the guard. +func withInterruptGuard(ctx context.Context, guard *interruptGuard) context.Context { + return context.WithValue(ctx, interruptGuardKey{}, guard) +} + +// holdInterrupt makes an interrupt wait for the caller, until the function it +// returns is called. A context without a guard, as in tests, has nothing to hold. +func holdInterrupt(ctx context.Context) (release func()) { + guard, ok := ctx.Value(interruptGuardKey{}).(*interruptGuard) + if !ok { + return func() {} + } + + guard.mu.Lock() + guard.holders++ + guard.mu.Unlock() + + var once sync.Once + + return func() { + once.Do(func() { + guard.mu.Lock() + guard.holders-- + guard.mu.Unlock() + }) + } +} + +// interrupt reports whether a holder will deal with the interrupt. When it +// returns false, nothing is running that needs to be stopped and the caller ends +// the process. +func (g *interruptGuard) interrupt() bool { + g.mu.Lock() + defer g.mu.Unlock() + + if g.holders == 0 { + return false + } + + g.interrupted = true + + return true +} + +// wasInterrupted reports whether an interrupt arrived while a holder ran. +func (g *interruptGuard) wasInterrupted() bool { + g.mu.Lock() + defer g.mu.Unlock() + + return g.interrupted +} + +// interrupted reports whether an interrupt arrived while a command held the +// guard of the context. +func interrupted(ctx context.Context) bool { + guard, ok := ctx.Value(interruptGuardKey{}).(*interruptGuard) + + return ok && guard.wasInterrupted() +} diff --git a/internal/cli/plugin.go b/internal/cli/plugin.go index ba0663694..5c5568b89 100644 --- a/internal/cli/plugin.go +++ b/internal/cli/plugin.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "path/filepath" "regexp" @@ -1245,6 +1246,16 @@ type pluginService struct { // Confirmer asks the person a yes or no question. When it is nil, the prompt // of the CLI is used. Tests set it for the same reason. confirmer func(message string) bool + + // Login opens the session that a plugin runs with. When it is nil, the + // authentication of the CLI is used. Tests set it, because they have no tenant + // to log in to. + login func(ctx context.Context, c *cli) error + + // Transport carries the calls that the proxy of a plugin makes to the tenant. + // When it is nil, the default transport is used. Tests set it, because they + // have no tenant. + transport http.RoundTripper } // canPrompt reports whether a person can answer at the terminal of the command. @@ -1457,6 +1468,12 @@ func checkPluginNameAvailable(root *cobra.Command, name string) error { reserved := name == "help" || name == "completion" for _, command := range root.Commands() { + // The commands of installed plugins do not count: a plugin can be installed + // again, and an update replaces it. + if command.Annotations[pluginCommandAnnotation] == "true" { + continue + } + if command.Name() == name || slices.Contains(command.Aliases, name) { reserved = true } diff --git a/internal/cli/plugin_fixture_exit_test.go b/internal/cli/plugin_fixture_exit_test.go new file mode 100644 index 000000000..609d5d855 --- /dev/null +++ b/internal/cli/plugin_fixture_exit_test.go @@ -0,0 +1,11 @@ +package cli + +import "syscall" + +// exitFixture ends the process of the fixture plugin without the exit hook of +// os.Exit. In a test binary built with coverage that hook warns on stderr that +// GOCOVERDIR is not set, because the fixture runs with a minimal environment, and +// the warning would end up in the output that the tests compare. +func exitFixture(code int) { + syscall.Exit(code) +} diff --git a/internal/cli/plugin_run.go b/internal/cli/plugin_run.go new file mode 100644 index 000000000..d48fe642a --- /dev/null +++ b/internal/cli/plugin_run.go @@ -0,0 +1,1007 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "maps" + "os" + "os/signal" + "runtime" + "slices" + "strings" + "sync" + "syscall" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/buildinfo" + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/approvals" + "github.com/auth0/auth0-cli/internal/plugins/proxy" + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/runner" + "github.com/auth0/auth0-cli/internal/plugins/store" +) + +const ( + // PluginRootAnnotation marks the command of a plugin whose manifest has a root + // command, so that its usage shows that the plugin can run without a + // subcommand. + pluginRootAnnotation = "pluginRoot" + + // PluginCommandAnnotation marks the commands that installed plugins add. + pluginCommandAnnotation = "plugin" + + // PluginRefreshTimeout bounds the refresh of a stale registry index before a + // plugin runs, so that a slow network does not hold up a plugin that may run + // from the saved index. + pluginRefreshTimeout = 10 * time.Second +) + +// pluginOutputFlags are the output flags of the commands. A plugin command can +// support them next to the persistent flags of the root command. +var pluginOutputFlags = [...]string{"json", "json-compact", "csv"} + +// registerPluginCommands adds the commands of the installed plugins to the root, +// as auth0 and auth0 . A CLI build whose version does +// not have the plugin feature adds none. +// +// It runs on every start of the CLI, so it is cheap and never fails: it reads the +// lockfile once and the small manifest of each plugin, checked against the hash in +// the lockfile, and neither hashes the executables nor reaches the network. It +// returns at once when no plugin is installed. A lockfile or manifest that cannot +// be read registers nothing for that plugin, silently, because every other command +// of the CLI must keep working. Whether a plugin may run is decided when it is +// run, see pluginCommand.run. +// +// The core commands win over the plugins. A plugin that has the name of a core +// command or alias is not registered, and the core command says so when it runs, +// see warnShadowedPlugin. The trailing commands are the core commands that are +// added to the root after the plugins. +func registerPluginCommands(rootCmd *cobra.Command, cli *cli, version string, trailing []*cobra.Command) { + if !plugins.FeatureEnabled(version) { + return + } + + installedStore := cli.pluginStore() + if installedStore == nil { + return + } + + manifests, err := installedStore.Manifests() + if err != nil || len(manifests) == 0 { + return + } + + cli.shadowedPlugins = nil + + core := map[string]bool{"help": true, "completion": true} + + for _, command := range slices.Concat(rootCmd.Commands(), trailing) { + core[command.Name()] = true + + for _, alias := range command.Aliases { + core[alias] = true + } + } + + for _, id := range slices.Sorted(maps.Keys(manifests)) { + name := plugins.NameOf(id) + + if core[name] { + if cli.shadowedPlugins == nil { + cli.shadowedPlugins = map[string]string{} + } + + cli.shadowedPlugins[name] = id + + continue + } + + // Two registries could have a plugin of the same name. The first one stays. + if slices.ContainsFunc(rootCmd.Commands(), func(c *cobra.Command) bool { return c.Name() == name }) { + continue + } + + rootCmd.AddCommand(newPluginCommand(cli, rootCmd, id, manifests[id])) + } +} + +// pluginStore returns the store of the installed plugins, for registering their +// commands. It reads no configuration: the plugins directory is a path next to +// the config file, whether or not the config is read, as it is not in env auth +// mode. +func (c *cli) pluginStore() *store.Store { + if c.plugins != nil { + svc, err := c.plugins() + if err != nil { + return nil + } + + return svc.store + } + + dir := c.Config.PluginsDir() + + return &store.Store{Dir: dir, CLIVersion: buildinfo.Version, Approvals: &approvals.Approvals{Dir: dir}} +} + +// warnShadowedPlugin warns when the command that runs is a core command that an +// installed plugin has the name of, and says how to remove the plugin. It is +// silent in agent mode and for every other command. The name that counts is the +// one that was typed, so an alias of a core command is covered. +func (c *cli) warnShadowedPlugin(cmd *cobra.Command) { + if len(c.shadowedPlugins) == 0 { + return + } + + top := cmd + for top.HasParent() && top.Parent() != top.Root() { + top = top.Parent() + } + + typed := top.CalledAs() + + id, shadowed := c.shadowedPlugins[typed] + if !shadowed { + return + } + + c.renderer.Warnf("The installed plugin %s has the name of the core command %q, so the core command runs and the plugin is not available. Remove the plugin with: auth0 plugin uninstall %s", id, typed, typed) +} + +// pluginCommand is an installed plugin as the command tree sees it. +type pluginCommand struct { + cli *cli + + id string + version string + + // Scanned is what the pre-run hook made of the arguments of the command that + // runs, for the run that follows it. + scanned runner.Scanned +} + +// newPluginCommand returns the command of a plugin, with a subcommand for each +// command of the manifest that is not the root command. +// +// Cobra does not read the flags of these commands. Everything after the name of +// the command goes to the pre-run hook, which takes out the flags of the CLI and +// leaves the rest for the plugin. The flags that the manifest declares are +// registered for help and completion only, and the CLI never checks them. +func newPluginCommand(cli *cli, rootCmd *cobra.Command, id string, manifest *plugins.Manifest) *cobra.Command { + p := &pluginCommand{cli: cli, id: id, version: manifest.Version} + + rootSpec := manifest.RootCommand() + + top := &cobra.Command{ + Use: plugins.NameOf(id), + Short: plugins.SafeText(manifest.Description), + Args: cobra.ArbitraryArgs, + DisableFlagParsing: true, + Annotations: pluginAnnotations(), + PersistentPreRunE: p.preRun, + } + + top.SetUsageTemplate(pluginUsageTemplate()) + + if rootSpec != nil { + top.Short = plugins.SafeText(rootSpec.Description) + top.Annotations[pluginRootAnnotation] = "true" + top.RunE = func(cmd *cobra.Command, _ []string) error { return p.run(cmd, *rootSpec, nil) } + + addPluginFlags(top, cli, rootCmd, *rootSpec) + } else { + top.RunE = func(cmd *cobra.Command, _ []string) error { return p.namespace(cmd) } + + addPluginFlags(top, cli, rootCmd, plugins.Command{}) + } + + top.Long = pluginLong(id, manifest, rootSpec) + + for _, spec := range manifest.Commands { + if rootSpec != nil && spec.Name == rootSpec.Name { + continue + } + + sub := &cobra.Command{ + Use: spec.Name, + Short: plugins.SafeText(spec.Description), + Long: pluginLong(id, manifest, &spec), + Args: cobra.ArbitraryArgs, + DisableFlagParsing: true, + Annotations: pluginAnnotations(), + RunE: func(cmd *cobra.Command, _ []string) error { return p.run(cmd, spec, []string{spec.Name}) }, + } + + addPluginFlags(sub, cli, rootCmd, spec) + top.AddCommand(sub) + } + + return top +} + +// pluginAnnotations returns the annotations of a plugin command: it is a plugin +// command, it stays out of the generated docs while the feature is gated, and the +// root pre-run hook does not log in for it. +func pluginAnnotations() map[string]string { + return map[string]string{ + pluginCommandAnnotation: "true", + skipDocsAnnotation: "true", + skipAuthAnnotation: "true", + } +} + +// addPluginFlags registers the flags of a command of the manifest, and the output +// flags, on the cobra command. They show in the help and in completion and are +// used for nothing else. +// +// The output flags that the command does not support are hidden. They are +// registered all the same, so that the pre-run hook sets every output flag the +// same way and the run can then refuse the ones that are not supported. +func addPluginFlags(cmd *cobra.Command, cli *cli, rootCmd *cobra.Command, spec plugins.Command) { + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + + output := plugins.Output{} + if spec.Output != nil { + output = *spec.Output + } + + for name, supported := range map[string]bool{"json": output.JSON, "json-compact": output.JSON, "csv": output.CSV} { + if !supported { + _ = cmd.Flags().MarkHidden(name) + } + } + + for _, flag := range spec.Flags { + // The manifest cannot declare a flag of the CLI. This keeps a manifest that + // slipped through from making pflag panic when the CLI starts. + if cmd.Flags().Lookup(flag.Name) != nil || rootCmd.PersistentFlags().Lookup(flag.Name) != nil { + continue + } + + short := flag.Shorthand + if len(short) != 1 || short == "h" || cmd.Flags().ShorthandLookup(short) != nil || rootCmd.PersistentFlags().ShorthandLookup(short) != nil { + short = "" + } + + description := plugins.SafeText(flag.Description) + + switch flag.Type { + case plugins.FlagTypeBool: + cmd.Flags().BoolP(flag.Name, short, false, description) + case plugins.FlagTypeInt: + cmd.Flags().IntP(flag.Name, short, 0, description) + case plugins.FlagTypeFloat: + cmd.Flags().Float64P(flag.Name, short, 0, description) + case plugins.FlagTypeStringArray: + cmd.Flags().StringArrayP(flag.Name, short, nil, description) + default: + cmd.Flags().StringP(flag.Name, short, "", description) + } + } +} + +// pluginUsageTemplate is the usage of a plugin command. The usage template of the +// root assumes core commands, which have an operation after the resource. +func pluginUsageTemplate() string { + return fmt.Sprintf(`%s{{if .HasAvailableSubCommands}}{{if eq (index .Annotations "pluginRoot") "true"}} + {{.UseLine}}{{end}} + {{.CommandPath}} [flags]{{else}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + +%s{{range .Commands}}{{if .IsAvailableCommand}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +%s +{{WrappedLocalFlagUsages . | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +%s +{{WrappedInheritedFlagUsages . | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} --help" for more information about a command.{{end}} +`, + ansi.Bold("Usage:"), + ansi.Bold("Available Commands:"), + ansi.Bold("Flags:"), + ansi.Bold("Global Flags:"), + ) +} + +// pluginLong returns the long help of a command of a plugin, from its manifest: +// what it does, what it may do, how long it may run, and how its arguments are +// read. The command is nil for the top command of a plugin that has no root +// command, which only groups the others. +func pluginLong(id string, manifest *plugins.Manifest, command *plugins.Command) string { + var text strings.Builder + + if command == nil { + text.WriteString(plugins.SafeText(manifest.Description)) + text.WriteString("\n\nThis plugin has no command of its own. Run one of the commands below.") + } else { + text.WriteString(plugins.SafeText(command.Description)) + text.WriteString("\n\n") + text.WriteString(pluginFacts(command)) + } + + fmt.Fprintf(&text, "\n\nThis command comes from the plugin %s %s. It runs only on a tenant where you approved the permissions of the plugin: see them and approve them with `auth0 plugin approve %s`.", id, manifest.Version, plugins.NameOf(id)) + + text.WriteString("\n\nThe CLI reads its own flags (--tenant, --debug, --no-input, --no-color, --agent-mode, --json, --json-compact, --csv and --help) wherever they are, " + + "and passes every other argument to the plugin as it was typed, without checking the flags of the plugin. " + + "Everything after a bare `--` goes to the plugin untouched. " + + "Type the name of a subcommand first, directly after the name of the plugin. When a flag comes first, the CLI may not find the subcommand. A call of the command that has the name of the plugin with the name of a subcommand among its arguments is then refused, and so is any call of a plugin that has no such command.") + + return text.String() +} + +// pluginFacts describes what a command of a plugin may do, in lines. +func pluginFacts(command *plugins.Command) string { + scopes := "none, so it cannot call the Management API" + + if command.Permissions != nil && len(command.Permissions.Scopes) > 0 { + scopes = plugins.SafeText(strings.Join(plugins.NormalizePermissions(*command.Permissions).Scopes, ", ")) + } + + timeout := "the default of the registry" + if command.Timeout != "" { + timeout = plugins.SafeText(command.Timeout) + } + + lines := []string{"Management API scopes: " + scopes, "Timeout: " + timeout} + + if command.Interactive { + lines = append(lines, "Interactive: it needs a terminal, and does not run with --no-input or in agent mode.") + } + + if output := command.Output; output != nil { + var supported []string + + if output.JSON { + supported = append(supported, "--json", "--json-compact") + } + + if output.CSV { + supported = append(supported, "--csv") + } + + if output.AgentMode { + supported = append(supported, "agent mode") + } + + if len(supported) > 0 { + lines = append(lines, "Output: "+strings.Join(supported, ", ")) + } + } + + return strings.Join(lines, "\n") +} + +// ownedFlags returns the flags that the CLI keeps for itself and takes out of +// the arguments of a plugin: the persistent flags of the root, which are a bool +// or a string, and the output flags. The list is read from the root command, so +// it follows the flags the CLI has. +func ownedFlags(root *cobra.Command) []runner.OwnedFlag { + var owned []runner.OwnedFlag + + root.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + if kind := flag.Value.Type(); kind == "bool" || kind == "string" { + owned = append(owned, runner.OwnedFlag{Name: flag.Name, Short: flag.Shorthand, Bool: kind == "bool"}) + } + }) + + for _, name := range pluginOutputFlags { + owned = append(owned, runner.OwnedFlag{Name: name, Bool: true}) + } + + return owned +} + +// preRun is the pre-run hook of every command of a plugin. Cobra did not read +// the flags, so it takes the flags of the CLI out of the arguments and applies +// them as cobra would have, and then runs the hook of the root, which does not +// log in for these commands. +func (p *pluginCommand) preRun(cmd *cobra.Command, args []string) error { + scanned, err := runner.Scan(args, ownedFlags(cmd.Root())) + if err != nil { + return usageError{err: err} + } + + p.scanned = scanned + + for name, value := range scanned.Values { + flags := cmd.Flags() + if cmd.Root().PersistentFlags().Lookup(name) != nil { + flags = cmd.Root().PersistentFlags() + } + + if err := flags.Set(name, value); err != nil { + return usageError{err: err} + } + } + + _, p.cli.tenantExplicit = scanned.Values["tenant"] + + return cmd.Root().PersistentPreRunE(cmd, args) +} + +// checkSubcommandOrder refuses a call of the root command of a plugin that has +// the name of one of its subcommands among its arguments before a bare double +// dash. Cobra looks for the subcommand after the flags it knows. An unknown flag +// takes the next word as its value, so in "--flag scan" the word "scan" is not +// found as a subcommand and the root command runs with it, and a plugin could +// then run its scan command with the scopes of the root command. The call is +// refused, as it is not clear which command was meant. +func (p *pluginCommand) checkSubcommandOrder(cmd *cobra.Command) error { + for _, token := range p.scanned.Rest { + if token == "--" { + return nil + } + + if !slices.ContainsFunc(cmd.Commands(), func(sub *cobra.Command) bool { return sub.Name() == token }) { + continue + } + + return usageError{ + err: fmt.Errorf("%q is a command of %s, but it comes after a flag, so it cannot be told from the value of that flag. "+ + "Type the command first (%s %s ...), write the value of the flag as --flag=value, or put the argument after --", + token, cmd.CommandPath(), cmd.CommandPath(), token), + reason: "plugin_ambiguous_subcommand", + } + } + + return nil +} + +// namespace is what the top command of a plugin without a root command does: it +// shows its help, or rejects an argument the way a group of core commands does. +func (p *pluginCommand) namespace(cmd *cobra.Command) error { + if p.scanned.Help || len(p.scanned.Rest) == 0 { + return cmd.Help() + } + + token := p.scanned.Rest[0] + + if strings.HasPrefix(token, "-") { + return usageError{err: fmt.Errorf("unknown flag %q for %q", token, cmd.CommandPath())} + } + + return unknownCommandError{token: token, parent: cmd.CommandPath(), suggestions: commandSuggestions(cmd, p.scanned.Rest)} +} + +// run runs a command of the plugin. The checks come first, and every one of them +// fails closed: the plugin is run only when everything holds, in this order. +// +// 1. Plugins are not turned off on this machine. +// 2. The installed files are the ones that were installed (Store.Verify). +// 3. The registry index is current enough, and does not revoke this version. +// 4. The command can run the way it was called: a terminal when it is +// interactive, and the output it was asked for. +// 5. The tenant approved the permissions of the plugin. +// 6. Only then a session is opened, which can log in or refresh a token, and it +// must be for the tenant that approved. +// +// The plugin gets the scopes of the command that was called, not of the whole +// plugin. Verify and the start of the process cannot be one step: the files are +// hashed, and the runner then starts what is at the path. A process of the same +// user that can write there in between could swap the file, which the +// permissions that Verify checks make hard but do not rule out. Verify is +// repeated after the session is open, as that can take as long as a person needs +// to log in. +func (p *pluginCommand) run(cmd *cobra.Command, spec plugins.Command, lead []string) error { + if p.scanned.Help { + return cmd.Help() + } + + if lead == nil { + if err := p.checkSubcommandOrder(cmd); err != nil { + return err + } + } + + c := p.cli + ctx := cmd.Context() + + if err := checkPluginsEnabled(cmd); err != nil { + return err + } + + if err := checkPluginTenantFlag(cmd, c); err != nil { + return err + } + + svc, err := c.getPlugins() + if err != nil { + return err + } + + if err := svc.usePins(); err != nil { + return err + } + + verified, err := p.verify(svc) + if err != nil { + return err + } + + command, ok := commandOf(verified.Manifest, spec.Name) + if !ok { + return validationError{ + err: fmt.Errorf("%s %s has no command %q any more. Run it again", p.id, verified.Version, spec.Name), + reason: "plugin_command_missing", + } + } + + index, err := p.loadIndex(ctx, svc, verified.Version) + if err != nil { + return err + } + + if revocation, revoked := index.Revoked(p.id, verified.Version); revoked { + return validationError{ + err: fmt.Errorf("%s %s was revoked: %s. Run: auth0 plugin update %s", + plugins.NameOf(p.id), verified.Version, strings.TrimRight(plugins.SafeText(revocation.Reason), ". "), plugins.NameOf(p.id)), + reason: "plugin_revoked", + } + } + + if err := p.checkCall(cmd, svc, command); err != nil { + return err + } + + tenant := pluginTenant(c) + + if err := p.checkApproval(svc, tenant, verified); err != nil { + return err + } + + // The timeout is read again from the second check, below. Reading it here + // refuses a command whose timeout is not usable before anyone logs in. + if _, err := pluginTimeout(command, index); err != nil { + return err + } + + login := svc.login + if login == nil { + login = func(ctx context.Context, c *cli) error { return c.setupWithAuthentication(ctx) } + } + + if err := login(ctx, c); err != nil { + return err + } + + if !strings.EqualFold(c.sessionDomain, tenant) { + return authError{ + err: fmt.Errorf("the session is for %s, but %s is approved for %s. Use --tenant %s to run it there, or approve it for %s with: %s", c.sessionDomain, plugins.NameOf(p.id), tenant, tenant, c.sessionDomain, approveCommand(p.id, c.sessionDomain)), + reason: "plugin_tenant_mismatch", + } + } + + session, err := c.newPluginSession() + if err != nil { + return err + } + + again, err := p.verify(svc) + if err != nil { + return err + } + + if again.Version != verified.Version || again.PermissionsHash != verified.PermissionsHash || + again.Bin != verified.Bin || again.Dir != verified.Dir || again.ManifestPath != verified.ManifestPath { + return validationError{err: fmt.Errorf("%s changed while the session was opened. Run it again", p.id), reason: "plugin_changed"} + } + + // What runs is what the second check found, so the command is taken from its + // manifest and not from the first one. + command, ok = commandOf(again.Manifest, spec.Name) + if !ok { + return validationError{ + err: fmt.Errorf("%s %s has no command %q any more. Run it again", p.id, again.Version, spec.Name), + reason: "plugin_command_missing", + } + } + + if err := p.checkCall(cmd, svc, command); err != nil { + return err + } + + timeout, err := pluginTimeout(command, index) + if err != nil { + return err + } + + policy, err := proxy.NewPolicy(index) + if err != nil { + return err + } + + // From here on an interrupt stops the plugin and the proxy before the CLI exits. + release := holdInterrupt(ctx) + defer release() + + // SIGHUP, which a terminal sends when it is closed, stops a plugin that runs in + // its own process group, as it would not get the signal from the terminal. + // A second Ctrl-C does not wait for this: the CLI exits at once, and a plugin + // that runs in its own process group can be left running. + runCtx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGHUP) + defer stop() + + var scopes []string + if command.Permissions != nil { + scopes = command.Permissions.Scopes + } + + gate, err := proxy.Start(runCtx, proxy.Config{PluginID: p.id, Domain: tenant, Tokens: session, Policy: policy, Scopes: scopes, Transport: svc.transport}) + if err != nil { + return err + } + + defer gate.Close() + + err = runner.Run(runCtx, runner.Spec{ + Bin: again.Bin, + Args: slices.Concat(lead, p.scanned.Rest), + Env: environment(cmd, command, tenant, gate.Env()), + Timeout: timeout, + Stdin: cmd.InOrStdin(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + Interactive: command.Interactive, + }) + + if summary := gate.Summary(); summary != "" { + c.renderer.Warnf("%s", summary) + } + + if err != nil { + return p.failure(err) + } + + return nil +} + +// verify checks the installed files of the plugin against the lockfile. +func (p *pluginCommand) verify(svc *pluginService) (store.Verified, error) { + verified, err := svc.store.Verify(p.id) + if err == nil { + return verified, nil + } + + if _, failed := errors.AsType[*store.VerificationError](err); failed { + return store.Verified{}, validationError{err: err, reason: "plugin_verification_failed"} + } + + return store.Verified{}, err +} + +// commandOf returns the command of the manifest with the given name. +func commandOf(manifest *plugins.Manifest, name string) (plugins.Command, bool) { + n := slices.IndexFunc(manifest.Commands, func(c plugins.Command) bool { return c.Name == name }) + if n < 0 { + return plugins.Command{}, false + } + + return manifest.Commands[n], true +} + +// loadIndex returns the registry index that the plugin runs against. A plugin +// runs only with an index that was verified and has not expired, and whose age is +// within the grace of its policy. An index that is due for a refresh is +// refreshed for a few seconds, and when that fails the saved copy is used with a +// warning, as long as it is still good enough. +func (p *pluginCommand) loadIndex(ctx context.Context, svc *pluginService, version string) (*registry.Index, error) { + unusable := func(why error) error { + return validationError{ + err: fmt.Errorf("%s %s was not run, because the registry index cannot be trusted: %w. Reconnect and run: auth0 plugin search", plugins.NameOf(p.id), version, why), + reason: "plugin_registry_unavailable", + } + } + + index, status, err := svc.client.Load(ctx) + + if err == nil && status.Stale { + fresh, cancel := context.WithTimeout(ctx, pluginRefreshTimeout) + index, status, err = svc.client.EnsureFresh(fresh) + + cancel() + } + + if err != nil { + return nil, unusable(err) + } + + if !status.Runnable { + return nil, unusable(errors.New(strings.TrimRight(status.Warning, ". "))) + } + + if status.Warning != "" && !p.cli.wantsJSONError() { + p.cli.renderer.Warnf("%s", status.Warning) + } + + return index, nil +} + +// checkCall refuses a command that cannot run the way it was called: an +// interactive command needs a person at a terminal, and the output flags and +// agent mode need the matching declaration in the manifest. +func (p *pluginCommand) checkCall(cmd *cobra.Command, svc *pluginService, command plugins.Command) error { + c := p.cli + name := cmd.CommandPath() + + if command.Interactive && (c.agentMode || c.noInput || !svc.canPrompt(cmd)) { + return usageError{ + err: fmt.Errorf("%s is interactive, so it needs a terminal. It cannot run in agent mode, with --no-input, or when input or output is not a terminal", name), + reason: "plugin_interactive", + } + } + + output := plugins.Output{} + if command.Output != nil { + output = *command.Output + } + + unsupported := func(flag, declaration string) error { + return usageError{ + err: fmt.Errorf("%s does not support %s, because its manifest does not declare %s", name, flag, declaration), + reason: "plugin_output_unsupported", + } + } + + for _, flag := range []struct { + name string + declaration string + supported bool + }{ + {"json", "output.json", output.JSON}, + {"json-compact", "output.json", output.JSON}, + {"csv", "output.csv", output.CSV}, + } { + if p.scanned.Values[flag.name] == "true" && !flag.supported { + return unsupported("--"+flag.name, flag.declaration) + } + } + + if c.agentMode && !output.AgentMode { + return unsupported("agent mode (turn it off with --agent-mode=false)", "output.agentMode") + } + + return nil +} + +// checkApproval fails unless the permissions of the plugin are approved for the +// tenant, by a recorded approval or by a pin. A damaged approvals file is an +// error and is never read as no approvals. +func (p *pluginCommand) checkApproval(svc *pluginService, tenant string, verified store.Verified) error { + if tenant == "" { + return validationError{err: errPluginNoTenant, reason: "plugin_no_tenant"} + } + + err := svc.approvals.Check(tenant, p.id, verified.PermissionsHash) + + if _, notApproved := errors.AsType[*approvals.NotApprovedError](err); notApproved { + return validationError{err: err, reason: "plugin_not_approved"} + } + + if errors.Is(err, approvals.ErrDamaged) { + return validationError{err: err, reason: "plugin_approvals_damaged"} + } + + if err != nil { + return validationError{err: err, reason: "plugin_approval_check"} + } + + return nil +} + +// pluginTimeout returns how long the command may run: its own timeout, or the +// default of the registry. A value that is not a timeout is an error rather than +// no limit. +func pluginTimeout(command plugins.Command, index *registry.Index) (time.Duration, error) { + value := command.Timeout + if value == "" { + value = index.Policy.WithDefaults().DefaultTimeout + } + + if err := plugins.CheckTimeout(value); err != nil { + return 0, validationError{err: fmt.Errorf("the timeout of %s is not usable: %w", command.Name, err), reason: "plugin_timeout_invalid"} + } + + timeout, _ := time.ParseDuration(value) + + return timeout, nil +} + +// environment returns the environment of the plugin, and nothing else is +// inherited: a short allowlist of the environment of the user, the variables +// that the command declared, the proxy, and the CLI-owned flags. The variables of +// the CLI itself, which start with AUTH0_, are never passed on, even when the +// command declared them. The loopback addresses are always exempt from the proxy +// of the user, so a plugin behind a corporate proxy still reaches the proxy of the +// CLI directly. The environment of the proxy of the CLI is proxyEnv. +func environment(cmd *cobra.Command, command plugins.Command, tenant string, proxyEnv []string) []string { + var declared []string + if command.Permissions != nil { + for _, variable := range command.Permissions.Env { + declared = append(declared, variable.Name) + } + } + + owned := map[string]string{} + + for _, flag := range ownedFlags(cmd.Root()) { + if found := cmd.Flags().Lookup(flag.Name); found != nil { + owned[flag.Name] = found.Value.String() + } + } + + owned["tenant"] = tenant + + return runner.Env(runtime.GOOS, runner.EnvSources{ + Base: runner.BaseEnv(os.Getenv, runtime.GOOS), + Declared: runner.DeclaredEnv(declared, os.Getenv), + Proxy: proxyEnv, + Plugin: runner.PluginVars(owned), + ExemptLoopback: true, + }) +} + +// failure turns how the plugin ended into the error of the command. The plugin +// did not end well: err is not nil. +func (p *pluginCommand) failure(err error) error { + name := plugins.NameOf(p.id) + failed := pluginError{err: err, pluginID: p.id, version: p.version} + + if exit, ok := errors.AsType[*runner.ExitError](err); ok { + failed.reason, failed.exitCode = "plugin_exit", exit.Code + failed.err = fmt.Errorf("%s %s exited with code %d", name, p.version, exit.Code) + + return failed + } + + if timeout, ok := errors.AsType[*runner.TimeoutError](err); ok { + failed.reason = "plugin_timeout" + failed.err = fmt.Errorf("%s %s did not finish within %s and was stopped", name, p.version, timeout.After) + + return failed + } + + if _, ok := errors.AsType[*runner.InterruptedError](err); ok { + failed.reason = "plugin_interrupted" + failed.err = fmt.Errorf("%s %s was interrupted", name, p.version) + + return failed + } + + failed.reason = "plugin_start_failed" + failed.err = fmt.Errorf("%s %s could not be run: %w", name, p.version, err) + + return failed +} + +// pluginSession is the session of a plugin run as the proxy sees it. It holds the +// access token that setupWithAuthentication resolved, and can renew it where the +// session allows. The token is never printed or logged. +type pluginSession struct { + mu sync.RWMutex + token string + + // Renew gets a new token, or is nil when the session cannot be renewed + // without the user. + renew func(ctx context.Context) (string, error) + + // Renewing lets one renewal run at a time, and lets a caller give up on + // waiting for it when its context ends. + renewing chan struct{} +} + +// newPluginSession returns the session that setupWithAuthentication opened. A +// session logged in with a user, and a token given in AUTH0_API_TOKEN, cannot be +// renewed silently. A client credentials login, saved or from the environment, +// can. +func (c *cli) newPluginSession() (*pluginSession, error) { + if c.accessToken == "" { + return nil, authError{err: errors.New("there is no session to run the plugin with. Run: auth0 login"), reason: "plugin_session_unavailable"} + } + + session := &pluginSession{token: c.accessToken, renewing: make(chan struct{}, 1)} + + domain := c.sessionDomain + + if envAuthEnabled(os.Getenv) { + apiToken := strings.TrimSpace(os.Getenv(envAPIToken)) + clientID := strings.TrimSpace(os.Getenv(envClientID)) + clientSecret := strings.TrimSpace(os.Getenv(envClientSecret)) + + if apiToken == "" && clientID != "" && clientSecret != "" { + session.renew = func(ctx context.Context) (string, error) { + return c.resolveEnvAccessToken(ctx, domain, "", clientID, clientSecret) + } + } + + return session, nil + } + + tenant, err := c.Config.GetTenant(domain) + if err != nil { + return nil, authError{err: err, reason: "plugin_session_unavailable"} + } + + if tenant.IsAuthenticatedWithClientCredentials() { + session.renew = c.savedCredentialsRenewal(tenant, func(ctx context.Context, tenant *config.Tenant) error { + return tenant.RegenerateAccessToken(ctx) + }) + } + + return session, nil +} + +// savedCredentialsRenewal returns how to renew the token of a tenant that is +// logged in with client credentials: it gets a new token with the saved secret +// and saves it, as setupWithAuthentication does when the token has expired. +func (c *cli) savedCredentialsRenewal(tenant config.Tenant, regenerate func(ctx context.Context, tenant *config.Tenant) error) func(ctx context.Context) (string, error) { + return func(ctx context.Context) (string, error) { + if err := regenerate(ctx, &tenant); err != nil { + return "", authError{err: fmt.Errorf("failed to fetch access token using client credentials: %w", err), reason: "credentials_refresh_failed"} + } + + if err := c.Config.AddTenant(tenant); err != nil { + return "", configPersistError(err) + } + + token := tenant.GetAccessToken() + if token == "" { + return "", authError{err: errors.New("the new access token could not be read back"), reason: "stored_token_unavailable"} + } + + return token, nil + } +} + +// Token returns the current access token. +func (s *pluginSession) Token(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + s.mu.RLock() + defer s.mu.RUnlock() + + return s.token, nil +} + +// Refresh gets a new access token after the current one was rejected. It returns +// proxy.ErrReloginRequired for a session that only the user can renew. +func (s *pluginSession) Refresh(ctx context.Context) (string, error) { + if s.renew == nil { + return "", proxy.ErrReloginRequired + } + + select { + case s.renewing <- struct{}{}: + defer func() { <-s.renewing }() + case <-ctx.Done(): + return "", ctx.Err() + } + + if err := ctx.Err(); err != nil { + return "", err + } + + token, err := s.renew(ctx) + if err != nil { + return "", err + } + + s.mu.Lock() + s.token = token + s.mu.Unlock() + + return token, nil +} diff --git a/internal/cli/plugin_run_test.go b/internal/cli/plugin_run_test.go new file mode 100644 index 000000000..e12d69b5c --- /dev/null +++ b/internal/cli/plugin_run_test.go @@ -0,0 +1,2457 @@ +package cli + +import ( + "archive/zip" + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gokeyring "github.com/zalando/go-keyring" + + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/keyring" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/approvals" + "github.com/auth0/auth0-cli/internal/plugins/proxy" + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/registry/registrytest" + "github.com/auth0/auth0-cli/internal/plugins/runner" +) + +// pluginFixtureEnv is the variable that the scan command of the fixture plugin +// declares in its permissions. +const pluginFixtureEnv = "FIXTURE_DECLARED" + +// TestMain lets the test binary act as the plugin. A copy of the binary that is +// installed as checkmate runs the fixture instead of the tests, so the commands +// start a real process and no shell script is needed on any system. +func TestMain(m *testing.M) { + if code, ok := runPluginFixture(os.Args); ok { + exitFixture(code) + } + + os.Exit(m.Run()) +} + +// fixtureReportDoc is what the fixture plugin prints in its report mode. +type fixtureReportDoc struct { + Args []string `json:"args"` + Env map[string]string `json:"env"` +} + +// runPluginFixture runs the fixture plugin when the binary was started under the +// name of the plugin. Its mode comes from --mode=, and the other options from +// --code=, --path= and --marker=. Everything else is only reported, which is how +// a test sees the arguments that the CLI passed on. +func runPluginFixture(args []string) (code int, ok bool) { + if strings.TrimSuffix(filepath.Base(args[0]), ".exe") != pluginTestBin { + return 0, false + } + + options := map[string]string{} + + for _, arg := range args[1:] { + if arg == "--" { + break + } + + if name, value, found := strings.Cut(arg, "="); found && strings.HasPrefix(name, "--") { + options[name] = value + } + } + + switch options["--mode"] { + case "exit": + code, _ := strconv.Atoi(options["--code"]) + + return code, true + case "sleep": + _ = os.WriteFile(options["--marker"], []byte(strconv.Itoa(os.Getpid())), 0o600) + + time.Sleep(time.Minute) + + return 0, true + case "call": + return fixtureCall(options["--path"]), true + default: + return fixtureReport(args[1:]), true + } +} + +// fixtureReport prints the arguments and the environment of the plugin as JSON on +// stdout, and a line on stderr. +func fixtureReport(args []string) int { + env := map[string]string{} + + for _, entry := range os.Environ() { + name, value, _ := strings.Cut(entry, "=") + env[name] = value + } + + data, err := json.Marshal(fixtureReportDoc{Args: append([]string{}, args...), Env: env}) + if err != nil { + return 2 + } + + fmt.Fprintln(os.Stdout, string(data)) + fmt.Fprintln(os.Stderr, "fixture stderr") + + return 0 +} + +// fixtureCall makes a call to the Management API the way a plugin does, through +// the proxy with the secret of the run, and prints the status and the body. +func fixtureCall(path string) int { + certificate, err := os.ReadFile(os.Getenv("AUTH0_PLUGIN_CA_CERT")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + + return 2 + } + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(certificate) + + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}} + + request, err := http.NewRequest(http.MethodGet, "https://"+os.Getenv("AUTH0_PLUGIN_DOMAIN")+path, nil) + if err != nil { + return 2 + } + + request.Header.Set("Authorization", "Bearer "+os.Getenv("AUTH0_PLUGIN_MANAGEMENT_TOKEN")) + + response, err := client.Do(request) + if err != nil { + fmt.Fprintln(os.Stderr, err) + + return 2 + } + + defer response.Body.Close() + + body, _ := io.ReadAll(response.Body) + + fmt.Fprintf(os.Stdout, "status=%d\n%s\n", response.StatusCode, body) + + return 0 +} + +// fixtureBinary returns the test binary, which is the executable of every +// fixture plugin. +var fixtureBinary = sync.OnceValues(func() ([]byte, error) { + path, err := os.Executable() + if err != nil { + return nil, err + } + + return os.ReadFile(path) +}) + +// fixtureArchive returns a zip archive with the test binary under the given name +// and the manifest. The binary is stored without compression, because it is large +// and compressing it would make every test slow. +func fixtureArchive(t *testing.T, name string, manifest []byte) []byte { + t.Helper() + + binary, err := fixtureBinary() + require.NoError(t, err) + + var buffer bytes.Buffer + + buffer.Grow(len(binary) + len(manifest) + 4096) + + archive := zip.NewWriter(&buffer) + + for _, entry := range []struct { + name string + body []byte + }{{name, binary}, {"plugin.json", manifest}} { + header := &zip.FileHeader{Name: entry.name, Method: zip.Store} + header.SetMode(0o755) + + writer, err := archive.CreateHeader(header) + require.NoError(t, err) + + _, err = writer.Write(entry.body) + require.NoError(t, err) + } + + require.NoError(t, archive.Close()) + + return buffer.Bytes() +} + +// fixturePermissions returns permissions with the given scopes, and the +// environment variables that the command may read. +func fixturePermissions(scopes []string, env ...string) *plugins.Permissions { + permissions := &plugins.Permissions{ + Scopes: scopes, + Hosts: []plugins.HostPermission{}, + Files: []plugins.FilePermission{}, + Env: []plugins.EnvPermission{}, + } + + for _, name := range env { + permissions.Env = append(permissions.Env, plugins.EnvPermission{Name: name, Reason: "Read a setting"}) + } + + return permissions +} + +// fixtureCommands returns the commands of the fixture plugin: a root command +// that can read clients, scan that can read users and read one variable of the +// environment, and report that supports JSON output only. +func fixtureCommands() []plugins.Command { + return []plugins.Command{ + { + Name: pluginTestBin, + Description: "Check a tenant", + Flags: []plugins.Flag{{Name: "validators", Shorthand: "v", Type: plugins.FlagTypeString, Description: "The validators to run"}}, + Output: &plugins.Output{JSON: true, CSV: true, AgentMode: true}, + Permissions: fixturePermissions([]string{"read:clients"}), + }, + { + Name: "scan", + Description: "Scan the users", + Flags: []plugins.Flag{{Name: "depth", Type: plugins.FlagTypeInt, Description: "How deep to scan"}}, + Timeout: "30s", + Permissions: fixturePermissions([]string{"read:users"}, pluginFixtureEnv), + }, + { + Name: "report", + Description: "Write a report", + Output: &plugins.Output{JSON: true}, + }, + } +} + +// fakeTenant stands in for the Management API. It answers every call with a +// list of clients that includes a secret, and remembers what it was asked. +type fakeTenant struct { + mu sync.Mutex + requests []fakeTenantRequest +} + +type fakeTenantRequest struct { + path string + authorization string +} + +func (f *fakeTenant) RoundTrip(request *http.Request) (*http.Response, error) { + f.mu.Lock() + f.requests = append(f.requests, fakeTenantRequest{path: request.URL.Path, authorization: request.Header.Get("Authorization")}) + f.mu.Unlock() + + body := `{"clients":[{"name":"app","client_secret":"shh"}]}` + + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + Request: request, + }, nil +} + +func (f *fakeTenant) seen() []fakeTenantRequest { + f.mu.Lock() + defer f.mu.Unlock() + + return slices.Clone(f.requests) +} + +// runEnv is a pluginEnv with the fixture plugin installed and a session that +// needs no tenant: the login of the plugin run is replaced, and so is the tenant +// the proxy calls. +type runEnv struct { + *pluginEnv + + record registry.VersionRecord + tenant *fakeTenant + + // Logins counts the sessions that runs opened since the last run started, and + // LoginErr, when it is set, is what opening one returns. + logins int + loginErr error + + // Token and Domain are the access token and the tenant of the session that a + // login opens. + Token string + Domain string +} + +// newRunEnv returns an environment with version 1.0.0 of the fixture plugin +// installed, which is not approved yet. The changes are applied to the index +// before it is published. +func newRunEnv(t *testing.T, commands []plugins.Command, change ...func(*registrytest.IndexBuilder)) *runEnv { + t.Helper() + + e := newPluginEnv(t) + r := &runEnv{pluginEnv: e, tenant: &fakeTenant{}, Token: "session-token-1", Domain: pluginTestTenant} + + // The session is the one of a person who logged in with a browser, which is + // saved in the config file of the home directory of the test. + saveTenant(t, config.Tenant{Domain: pluginTestTenant}) + + r.record = r.releaseFixture("1.0.0", commands) + r.publishFixture(change, r.record) + e.installPlugin(pluginTestID, "1.0.0") + + e.cli.Config.DefaultTenant = pluginTestTenant + e.svc.transport = r.tenant + e.svc.login = func(_ context.Context, c *cli) error { + r.logins++ + + if r.loginErr != nil { + return r.loginErr + } + + c.accessToken = r.Token + c.sessionDomain = r.Domain + + return nil + } + + return r +} + +// releaseFixture builds a version of the fixture plugin with the given commands +// and serves its archive. The record has the permissions and commands of the +// manifest, as the registry requires. +func (r *runEnv) releaseFixture(version string, commands []plugins.Command) registry.VersionRecord { + r.t.Helper() + + manifest := plugins.Manifest{ + ID: pluginTestID, + Version: version, + APIVersion: 1, + Description: "Checks the tenant", + Owner: plugins.Owner{Team: "Test team", Bugs: "https://example.com/bugs"}, + Platforms: []plugins.Platform{r.platform}, + Commands: commands, + } + + record := registrytest.NewVersion(version) + record.Platforms = manifest.Platforms + record.Permissions = manifest.Permissions() + record.Commands = manifest.ConsentCommands() + record = registrytest.Seal(record) + + data, err := json.Marshal(manifest) + require.NoError(r.t, err) + + archive := fixtureArchive(r.t, pluginTestBin, data) + path := "/auth0/auth0-cli-plugins/releases/download/v" + version + "/" + pluginTestBin + ".zip" + + record.Artifacts = []registry.Artifact{ + registrytest.NewArtifact(r.platform, "https://github.com"+path, registry.ArtifactFormatZip, pluginTestBin, archive), + } + + r.server.Set(path, archive) + + return record +} + +// fixtureIndex returns the builder of an index with the records, a scope table +// for two calls and a secret field. +func (r *runEnv) fixtureIndex(records ...registry.VersionRecord) *registrytest.IndexBuilder { + return r.index(records...). + SetScopeTable(registry.ScopeTable{FormatVersion: 1, Rules: []registry.ScopeRule{ + {Method: "GET", Path: "/api/v2/clients", AnyOf: []string{"read:clients"}}, + {Method: "GET", Path: "/api/v2/users", AnyOf: []string{"read:users"}}, + }}). + SetSecretFields(registry.SecretFields{FormatVersion: 1, Names: []string{"client_secret"}}) +} + +func (r *runEnv) publishFixture(change []func(*registrytest.IndexBuilder), records ...registry.VersionRecord) { + r.t.Helper() + + builder := r.fixtureIndex(records...) + + for _, apply := range change { + apply(builder) + } + + r.publish(builder) +} + +// approve approves the installed version of the plugin for the tenant of the +// environment, the way a person does at a terminal. +func (r *runEnv) approve() { + r.t.Helper() + + require.NoError(r.t, r.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, r.record.PermissionsHash)) +} + +// exec runs a command line on a new command tree and returns its error. The +// output of the plugin goes to the stdout buffer and its errors to stderr. +func (r *runEnv) exec(args ...string) error { + return r.execContext(context.Background(), args...) +} + +func (r *runEnv) execContext(ctx context.Context, args ...string) error { + r.stdout.Reset() + r.stderr.Reset() + + r.logins = 0 + r.cli.renderer = &display.Renderer{MessageWriter: r.stderr, ResultWriter: r.stdout} + + root := buildRootCmd(r.cli) + addPersistentFlags(root, r.cli) + addSubCommands(root, r.cli) + enforceUnknownSubcommand(root) + root.SetOut(r.stdout) + root.SetErr(r.stderr) + root.SetArgs(args) + + return root.ExecuteContext(ctx) +} + +// report returns what the fixture plugin printed in its report mode. +func (r *runEnv) report(t *testing.T) fixtureReportDoc { + t.Helper() + + var report fixtureReportDoc + + require.NoError(t, json.Unmarshal(r.stdout.Bytes(), &report), r.stdout.String()) + + return report +} + +// started reports whether the fixture plugin ran in the last run. +func (r *runEnv) started() bool { + return strings.Contains(r.stderr.String(), "fixture stderr") || strings.Contains(r.stdout.String(), `"args"`) +} + +// reasonOf returns the class and the reason that the error has in the JSON error +// envelope. +func reasonOf(err error) (class, reason string) { + return errorClass(err), errorReason(err) +} + +// tamper appends to the executable of a plugin, so that it no longer matches the +// hash that was recorded when it was installed. +func tamper(t *testing.T, bin string) { + t.Helper() + + file, err := os.OpenFile(bin, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + + _, err = file.WriteString("tampered") + require.NoError(t, err) + require.NoError(t, file.Close()) +} + +// waitForMarker waits for the fixture plugin to write its process ID and returns +// it. +func waitForMarker(t *testing.T, path string) int { + t.Helper() + + var pid int + + require.Eventually(t, func() bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + + pid, err = strconv.Atoi(string(data)) + + return err == nil + }, 30*time.Second, 20*time.Millisecond) + + return pid +} + +// processAlive reports whether a process with the ID exists. +func processAlive(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + return process.Signal(syscall.Signal(0)) == nil +} + +func TestRegisterPluginCommands_Gate(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + + tests := []struct { + version string + want bool + }{ + {"dev", true}, + {"1.39.0", false}, + } + + for _, test := range tests { + t.Run(test.version, func(t *testing.T) { + root := &cobra.Command{Use: "auth0"} + registerPluginCommands(root, e.cli, test.version, nil) + + found, _, err := root.Find([]string{pluginTestBin}) + require.NoError(t, err) + assert.Equal(t, test.want, found != root) + assert.Equal(t, test.want, len(root.Commands()) == 1) + }) + } +} + +func TestRegisterPluginCommands_Shape(t *testing.T) { + t.Run("a plugin with a root command and subcommands", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + registerPluginCommands(root, r.cli, "dev", nil) + + top, _, err := root.Find([]string{pluginTestBin}) + require.NoError(t, err) + + assert.Equal(t, pluginTestBin, top.Name()) + assert.Equal(t, "Check a tenant", top.Short) + assert.True(t, top.DisableFlagParsing) + assert.True(t, top.Runnable()) + assert.Equal(t, map[string]string{ + pluginCommandAnnotation: "true", + pluginRootAnnotation: "true", + skipDocsAnnotation: "true", + skipAuthAnnotation: "true", + }, top.Annotations) + + // The root command of the manifest is the top command and not a subcommand. + var names []string + for _, sub := range top.Commands() { + names = append(names, sub.Name()) + + assert.True(t, sub.DisableFlagParsing, sub.Name()) + assert.Equal(t, "true", sub.Annotations[skipAuthAnnotation], sub.Name()) + assert.Equal(t, "true", sub.Annotations[skipDocsAnnotation], sub.Name()) + assert.NotContains(t, sub.Annotations, pluginRootAnnotation, sub.Name()) + } + + assert.ElementsMatch(t, []string{"scan", "report"}, names) + + // The flags are there for help and completion. + validators := top.Flags().Lookup("validators") + require.NotNil(t, validators) + assert.Equal(t, "v", validators.Shorthand) + assert.Equal(t, "The validators to run", validators.Usage) + + scan, _, err := root.Find([]string{pluginTestBin, "scan"}) + require.NoError(t, err) + assert.NotNil(t, scan.Flags().Lookup("depth")) + assert.Nil(t, scan.Flags().Lookup("validators")) + + // Only the output flags that the command supports show in the help. + assert.False(t, top.Flags().Lookup("json").Hidden) + assert.False(t, top.Flags().Lookup("csv").Hidden) + assert.True(t, scan.Flags().Lookup("json").Hidden) + + report, _, err := root.Find([]string{pluginTestBin, "report"}) + require.NoError(t, err) + assert.False(t, report.Flags().Lookup("json").Hidden) + assert.True(t, report.Flags().Lookup("csv").Hidden) + }) + + t.Run("a plugin without a root command is a namespace", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()[1:]) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + registerPluginCommands(root, r.cli, "dev", nil) + + top, _, err := root.Find([]string{pluginTestBin}) + require.NoError(t, err) + + assert.Equal(t, pluginTestBin, top.Name()) + assert.Equal(t, "Checks the tenant", top.Short) + assert.NotContains(t, top.Annotations, pluginRootAnnotation) + assert.Equal(t, "true", top.Annotations[skipAuthAnnotation]) + assert.Len(t, top.Commands(), 2) + assert.Nil(t, top.Flags().Lookup("validators")) + }) + + t.Run("a manifest cannot make the flags collide", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + + manifest := &plugins.Manifest{ID: pluginTestID, Version: "1.0.0", Description: "x", Commands: []plugins.Command{{ + Name: pluginTestBin, + Flags: []plugins.Flag{ + {Name: "tenant", Type: plugins.FlagTypeString}, + {Name: "json", Type: plugins.FlagTypeBool}, + {Name: "mine", Shorthand: "h", Type: plugins.FlagTypeBool}, + {Name: "twice", Shorthand: "t", Type: plugins.FlagTypeBool}, + {Name: "again", Shorthand: "t", Type: plugins.FlagTypeBool}, + {Name: "twice", Type: plugins.FlagTypeBool}, + {Name: "count", Type: plugins.FlagTypeInt}, + {Name: "ratio", Type: plugins.FlagTypeFloat}, + {Name: "items", Type: plugins.FlagTypeStringArray}, + }, + }}} + + assert.NotPanics(t, func() { root.AddCommand(newPluginCommand(r.cli, root, pluginTestID, manifest)) }) + + top, _, err := root.Find([]string{pluginTestBin}) + require.NoError(t, err) + + assert.Nil(t, top.Flags().Lookup("tenant"), "a persistent flag of the CLI is not shadowed") + assert.Equal(t, "Output in json format.", top.Flags().Lookup("json").Usage) + assert.Equal(t, "", top.Flags().Lookup("mine").Shorthand) + assert.Equal(t, "t", top.Flags().Lookup("twice").Shorthand) + assert.Equal(t, "", top.Flags().Lookup("again").Shorthand) + assert.Equal(t, "int", top.Flags().Lookup("count").Value.Type()) + assert.Equal(t, "float64", top.Flags().Lookup("ratio").Value.Type()) + assert.Equal(t, "stringArray", top.Flags().Lookup("items").Value.Type()) + }) +} + +func TestRegisterPluginCommands_NeverBreaksTheCLI(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + verified, err := r.svc.store.Verify(pluginTestID) + require.NoError(t, err) + + tests := []struct { + name string + damage func(t *testing.T) + }{ + {"a lockfile that is not JSON", func(t *testing.T) { + require.NoError(t, os.WriteFile(filepath.Join(r.Dir, "plugins.lock.json"), []byte("{not json"), 0o600)) + }}, + {"a lockfile that is a directory", func(t *testing.T) { + path := filepath.Join(r.Dir, "plugins.lock.json") + + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Mkdir(path, 0o700)) + }}, + {"a manifest that no longer matches the lockfile", func(t *testing.T) { + require.NoError(t, os.WriteFile(verified.ManifestPath, []byte(`{"id":"auth0/checkmate"}`), 0o600)) + }}, + {"a manifest that is missing", func(t *testing.T) { + require.NoError(t, os.Remove(verified.ManifestPath)) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // A fresh copy of the broken state each time is not needed: each case only + // has to leave the others working, and they all break the same plugin. + test.damage(t) + + root := &cobra.Command{Use: "auth0"} + + assert.NotPanics(t, func() { registerPluginCommands(root, r.cli, "dev", nil) }) + assert.Empty(t, root.Commands(), "nothing is registered for a plugin that cannot be read") + + // The rest of the CLI works and says nothing about the plugin. + require.NoError(t, r.exec("apps", "--help")) + assert.Empty(t, r.stderr.String()) + assert.NotContains(t, r.stdout.String(), pluginTestBin) + assert.Zero(t, r.logins) + + require.NoError(t, r.exec("apps")) + assert.Empty(t, r.stderr.String()) + }) + } +} + +// A nil trailing command panics when the names of the core commands are +// collected, so a panic in this test shows that the registration did not return +// before that. +func TestRegisterPluginCommands_ReturnsEarlyWithoutPlugins(t *testing.T) { + trailing := []*cobra.Command{nil} + + t.Run("nothing is installed", func(t *testing.T) { + e := newPluginEnv(t) + + root := &cobra.Command{Use: "auth0"} + + assert.NotPanics(t, func() { registerPluginCommands(root, e.cli, "dev", trailing) }) + assert.Empty(t, root.Commands()) + assert.Empty(t, e.cli.shadowedPlugins) + assert.NoDirExists(t, e.Dir, "nothing is created to look for plugins") + }) + + t.Run("no manifest can be read", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + verified, err := r.svc.store.Verify(pluginTestID) + require.NoError(t, err) + require.NoError(t, os.Remove(verified.ManifestPath)) + + root := &cobra.Command{Use: "auth0"} + + assert.NotPanics(t, func() { registerPluginCommands(root, r.cli, "dev", trailing) }) + assert.Empty(t, root.Commands()) + }) +} + +// TestPluginCommand_Routing records how cobra finds the command of a plugin when +// the flags are not parsed. The routing is cobra's and depends on which flags it +// knows, so these are the behaviours that the help text warns about. +func TestPluginCommand_Routing(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + registerPluginCommands(root, r.cli, "dev", nil) + + tests := []struct { + name string + args []string + want string + rest []string + }{ + {"the subcommand first", []string{"checkmate", "scan", "--x"}, "auth0 checkmate scan", []string{"--x"}}, + {"a flag of the root command with its value", []string{"checkmate", "--validators", "a"}, "auth0 checkmate", []string{"--validators", "a"}}, + {"a flag with its value before the subcommand", []string{"checkmate", "--validators", "a", "scan"}, "auth0 checkmate scan", []string{"--validators", "a"}}, + {"a flag with an equals sign before the subcommand", []string{"checkmate", "--validators=a", "scan"}, "auth0 checkmate scan", []string{"--validators=a"}}, + {"a bare double dash", []string{"checkmate", "--", "scan"}, "auth0 checkmate", []string{"--", "scan"}}, + {"an unknown flag swallows the next word", []string{"checkmate", "--x", "scan"}, "auth0 checkmate", []string{"--x", "scan"}}, + {"an unknown short flag swallows the next word", []string{"checkmate", "-z", "scan"}, "auth0 checkmate", []string{"-z", "scan"}}, + {"a flag of the CLI before the subcommand", []string{"checkmate", "--tenant", "t", "scan"}, "auth0 checkmate scan", []string{"--tenant", "t"}}, + {"a boolean flag of the CLI before the subcommand", []string{"checkmate", "--json", "scan"}, "auth0 checkmate scan", []string{"--json"}}, + {"an unknown word", []string{"checkmate", "bogus"}, "auth0 checkmate", []string{"bogus"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + found, rest, err := root.Find(test.args) + + require.NoError(t, err) + assert.Equal(t, test.want, found.CommandPath()) + assert.Equal(t, test.rest, rest) + }) + } + + t.Run("the help says that the subcommand comes first", func(t *testing.T) { + top, _, err := root.Find([]string{"checkmate"}) + require.NoError(t, err) + + assert.Contains(t, top.Long, "Type the name of a subcommand first, directly after the name of the plugin") + }) +} + +func TestPluginCommand_Help(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + t.Run("the root command, without an approval or a login", func(t *testing.T) { + require.NoError(t, r.exec("checkmate", "--help")) + + help := r.stdout.String() + + assert.Contains(t, help, "Check a tenant") + assert.Contains(t, help, "Management API scopes: read:clients") + assert.Contains(t, help, "Timeout: the default of the registry") + assert.Contains(t, help, "Output: --json, --json-compact, --csv, agent mode") + assert.Contains(t, help, "comes from the plugin auth0/checkmate 1.0.0") + assert.Contains(t, help, "auth0 plugin approve checkmate") + assert.Contains(t, help, "auth0 checkmate [flags]") + assert.Contains(t, help, "scan") + assert.Contains(t, help, "Scan the users") + assert.Contains(t, help, "--validators string") + assert.Contains(t, help, "The validators to run") + assert.Contains(t, help, "--json") + assert.NotContains(t, help, "fixture") + assert.Zero(t, r.logins) + assert.False(t, r.started()) + }) + + t.Run("a subcommand", func(t *testing.T) { + for _, args := range [][]string{ + {"checkmate", "scan", "--help"}, + {"checkmate", "scan", "-h"}, + {"checkmate", "scan", "--depth", "3", "--help"}, + } { + require.NoError(t, r.exec(args...), args) + + help := r.stdout.String() + + assert.Contains(t, help, "Scan the users", args) + assert.Contains(t, help, "Management API scopes: read:users", args) + assert.Contains(t, help, "Timeout: 30s", args) + assert.Contains(t, help, "--depth int", args) + assert.NotContains(t, help, "--json ", args, "the output flag is hidden when the command does not declare it") + assert.Zero(t, r.logins) + assert.False(t, r.started()) + } + }) + + t.Run("after a double dash the flag is the plugin's", func(t *testing.T) { + r.approve() + + require.NoError(t, r.exec("checkmate", "--", "--help")) + + assert.Equal(t, []string{"--", "--help"}, r.report(t).Args) + }) + + t.Run("through the help command", func(t *testing.T) { + require.NoError(t, r.exec("help", "checkmate")) + + assert.Contains(t, r.stdout.String(), "Check a tenant") + }) + + t.Run("a plugin without a root command", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()[1:]) + + for _, args := range [][]string{{"checkmate"}, {"checkmate", "--help"}, {"checkmate", "--debug"}} { + require.NoError(t, r.exec(args...), args) + + help := r.stdout.String() + + assert.Contains(t, help, "This plugin has no command of its own", args) + assert.Contains(t, help, "scan", args) + assert.NotContains(t, help, "auth0 checkmate [flags]", args) + assert.Zero(t, r.logins) + assert.False(t, r.started()) + } + + err := r.exec("checkmate", "bogus") + require.Error(t, err) + assert.Equal(t, "usage", errorClass(err)) + assert.Contains(t, err.Error(), "bogus") + + err = r.exec("checkmate", "--bogus") + require.Error(t, err) + assert.Equal(t, "usage", errorClass(err)) + assert.Zero(t, r.logins) + }) +} + +func TestPluginRun_Arguments(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + other := "other.us.auth0.com" + + tests := []struct { + name string + args []string + want []string + env map[string]string + }{ + { + name: "no arguments", + args: []string{"checkmate"}, + want: []string{}, + env: map[string]string{ + "AUTH0_PLUGIN_TENANT": pluginTestTenant, "AUTH0_PLUGIN_DEBUG": "false", "AUTH0_PLUGIN_NO_INPUT": "false", + "AUTH0_PLUGIN_NO_COLOR": "false", "AUTH0_PLUGIN_AGENT_MODE": "false", "AUTH0_PLUGIN_JSON": "false", + "AUTH0_PLUGIN_JSON_COMPACT": "false", "AUTH0_PLUGIN_CSV": "false", + }, + }, + { + name: "the flags of the plugin reach it untouched", + args: []string{"checkmate", "--validators", "a", "-v=b", "--unknown", "--mode=report", "plain"}, + want: []string{"--validators", "a", "-v=b", "--unknown", "--mode=report", "plain"}, + }, + { + name: "the flags of the CLI are taken out wherever they are", + args: []string{"checkmate", "--debug", "--validators", "a", "--tenant", pluginTestTenant, "--json", "last"}, + want: []string{"--validators", "a", "last"}, + env: map[string]string{"AUTH0_PLUGIN_DEBUG": "true", "AUTH0_PLUGIN_JSON": "true", "AUTH0_PLUGIN_TENANT": pluginTestTenant}, + }, + { + name: "a flag of the CLI with an equals sign", + args: []string{"checkmate", "--tenant=" + pluginTestTenant, "--no-input=true", "--no-color"}, + want: []string{}, + env: map[string]string{"AUTH0_PLUGIN_NO_INPUT": "true", "AUTH0_PLUGIN_NO_COLOR": "true"}, + }, + { + name: "everything after a double dash is the plugin's", + args: []string{"checkmate", "--debug", "--", "--tenant", other, "--debug", "--json", "-h", "--agent-mode"}, + want: []string{"--", "--tenant", other, "--debug", "--json", "-h", "--agent-mode"}, + env: map[string]string{"AUTH0_PLUGIN_DEBUG": "true", "AUTH0_PLUGIN_TENANT": pluginTestTenant, "AUTH0_PLUGIN_JSON": "false", "AUTH0_PLUGIN_AGENT_MODE": "false"}, + }, + { + name: "the subcommand name goes first to the plugin", + args: []string{"checkmate", "scan", "--depth", "3", "--debug", "-x"}, + want: []string{"scan", "--depth", "3", "-x"}, + env: map[string]string{"AUTH0_PLUGIN_DEBUG": "true"}, + }, + { + name: "a double dash after the subcommand", + args: []string{"checkmate", "scan", "--", "--tenant", "x"}, + want: []string{"scan", "--", "--tenant", "x"}, + env: map[string]string{"AUTH0_PLUGIN_TENANT": pluginTestTenant}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NoError(t, r.exec(test.args...)) + + report := r.report(t) + + assert.Equal(t, test.want, report.Args) + + for name, value := range test.env { + assert.Equal(t, value, report.Env[name], name) + } + + assert.Equal(t, 1, r.logins) + }) + } + + t.Run("every flag of the CLI becomes a variable of the plugin", func(t *testing.T) { + require.NoError(t, r.exec("checkmate", "--tenant", pluginTestTenant, "--debug", "--no-input", "--no-color", "--agent-mode", "--json", "--json-compact", "--csv")) + + assert.Equal(t, map[string]string{ + "AUTH0_PLUGIN_TENANT": pluginTestTenant, "AUTH0_PLUGIN_DEBUG": "true", "AUTH0_PLUGIN_NO_INPUT": "true", + "AUTH0_PLUGIN_NO_COLOR": "true", "AUTH0_PLUGIN_AGENT_MODE": "true", "AUTH0_PLUGIN_JSON": "true", + "AUTH0_PLUGIN_JSON_COMPACT": "true", "AUTH0_PLUGIN_CSV": "true", + }, pluginVariables(r.report(t).Env, "AUTH0_PLUGIN_TENANT", "AUTH0_PLUGIN_DEBUG", "AUTH0_PLUGIN_NO_INPUT", "AUTH0_PLUGIN_NO_COLOR", + "AUTH0_PLUGIN_AGENT_MODE", "AUTH0_PLUGIN_JSON", "AUTH0_PLUGIN_JSON_COMPACT", "AUTH0_PLUGIN_CSV")) + }) + + t.Run("agent mode switches the defaults", func(t *testing.T) { + require.NoError(t, r.exec("checkmate", "--agent-mode")) + + env := r.report(t).Env + + assert.Equal(t, "true", env["AUTH0_PLUGIN_AGENT_MODE"]) + assert.Equal(t, "true", env["AUTH0_PLUGIN_NO_INPUT"]) + assert.Equal(t, "true", env["AUTH0_PLUGIN_NO_COLOR"]) + assert.Equal(t, "true", env["AUTH0_PLUGIN_JSON"]) + }) + + t.Run("an owned flag without its value is a usage error", func(t *testing.T) { + err := r.exec("checkmate", "--tenant") + + require.Error(t, err) + assert.Equal(t, "usage", errorClass(err)) + assert.Zero(t, r.logins) + assert.False(t, r.started()) + }) +} + +// pluginVariables returns the named variables of the environment of the plugin. +func pluginVariables(env map[string]string, names ...string) map[string]string { + found := map[string]string{} + + for _, name := range names { + if value, ok := env[name]; ok { + found[name] = value + } + } + + return found +} + +func TestPluginRun_PassesOutputThrough(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + require.NoError(t, r.exec("checkmate")) + + assert.Contains(t, r.stdout.String(), `"args"`) + assert.Equal(t, "fixture stderr\n", r.stderr.String(), "the plugin's own stderr and nothing from the CLI") +} + +func TestPluginRun_Environment(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + t.Setenv("AUTH0_CLIENT_SECRET", "client-secret-value") + t.Setenv("AUTH0_API_TOKEN", "api-token-value") + t.Setenv("AUTH0_CLIENT_ID", "client-id-value") + t.Setenv("AUTH0_CLI_ANALYTICS", "false") + t.Setenv("AWS_PROFILE", "production") + t.Setenv("FIXTURE_UNDECLARED", "undeclared-value") + t.Setenv(pluginFixtureEnv, "declared-value") + t.Setenv("LANG", "en_US.UTF-8") + + allowed := func(name string) bool { + switch name { + case "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", + "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", pluginFixtureEnv: + return true + } + + return strings.HasPrefix(name, "AUTH0_PLUGIN_") + } + + check := func(t *testing.T, env map[string]string) { + t.Helper() + + var unexpected []string + + for name, value := range env { + if !allowed(name) { + unexpected = append(unexpected, name) + } + + for _, secret := range []string{"client-secret-value", "api-token-value", "client-id-value", r.Token} { + assert.NotContains(t, value, secret, "the value of %s", name) + } + } + + assert.Empty(t, unexpected, "variables that no rule lets through") + + assert.NotEmpty(t, env["AUTH0_PLUGIN_DOMAIN"]) + assert.NotEmpty(t, env["AUTH0_PLUGIN_MANAGEMENT_TOKEN"], "the secret of the proxy is there, the token is not") + assert.NotEmpty(t, env["AUTH0_PLUGIN_CA_CERT"]) + assert.Equal(t, os.Getenv("HOME"), env["HOME"]) + assert.Equal(t, "en_US.UTF-8", env["LANG"]) + } + + t.Run("the root command", func(t *testing.T) { + require.NoError(t, r.exec("checkmate")) + + env := r.report(t).Env + + check(t, env) + assert.NotContains(t, env, pluginFixtureEnv, "a variable that another command declared") + }) + + t.Run("a command that declares a variable", func(t *testing.T) { + require.NoError(t, r.exec("checkmate", "scan")) + + env := r.report(t).Env + + check(t, env) + assert.Equal(t, "declared-value", env[pluginFixtureEnv]) + }) + + t.Run("a declared variable that is not set", func(t *testing.T) { + t.Setenv(pluginFixtureEnv, "") + + require.NoError(t, r.exec("checkmate", "scan")) + + assert.NotContains(t, r.report(t).Env, pluginFixtureEnv) + }) +} + +func TestPluginRun_OnlyTheInvokedCommandScopes(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + tests := []struct { + name string + args []string + path string + want int + }{ + {"the root command reads clients", []string{"checkmate", "--mode=call", "--path=/api/v2/clients"}, "/api/v2/clients", http.StatusOK}, + {"the root command cannot read users", []string{"checkmate", "--mode=call", "--path=/api/v2/users"}, "/api/v2/users", http.StatusForbidden}, + {"scan reads users", []string{"checkmate", "scan", "--mode=call", "--path=/api/v2/users"}, "/api/v2/users", http.StatusOK}, + {"scan cannot read clients", []string{"checkmate", "scan", "--mode=call", "--path=/api/v2/clients"}, "/api/v2/clients", http.StatusForbidden}, + {"report has no scopes", []string{"checkmate", "report", "--mode=call", "--path=/api/v2/clients"}, "/api/v2/clients", http.StatusForbidden}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + before := len(r.tenant.seen()) + + require.NoError(t, r.exec(test.args...)) + + assert.Contains(t, r.stdout.String(), "status="+strconv.Itoa(test.want)) + + seen := r.tenant.seen()[before:] + + if test.want == http.StatusOK { + require.Len(t, seen, 1) + assert.Equal(t, test.path, seen[0].path) + assert.Equal(t, "Bearer "+r.Token, seen[0].authorization, "the tenant gets the token of the session") + assert.NotContains(t, r.stdout.String(), "shh", "the secret field is removed") + assert.Contains(t, r.stdout.String(), `"name":"app"`) + + return + } + + assert.Empty(t, seen, "a denied call never reaches the tenant") + assert.Contains(t, r.stderr.String(), "API call was denied", "the summary of the denied calls") + }) + } + + t.Run("the summary is not printed in agent mode", func(t *testing.T) { + require.NoError(t, r.exec("checkmate", "--agent-mode", "--mode=call", "--path=/api/v2/users")) + + assert.Contains(t, r.stdout.String(), "status=403") + assert.Empty(t, r.stderr.String()) + }) +} + +func TestPluginRun_Refusals(t *testing.T) { + // A refusal never starts the plugin, so it never starts the proxy either. + assertRefused := func(t *testing.T, r *runEnv, err error, class, reason string) { + t.Helper() + + require.Error(t, err) + + gotClass, gotReason := reasonOf(err) + + assert.Equal(t, class, gotClass, err.Error()) + assert.Equal(t, reason, gotReason, err.Error()) + assert.False(t, r.started(), "the plugin must not have run") + assert.Empty(t, r.tenant.seen()) + } + + t.Run("not approved", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "validation", "plugin_not_approved") + assert.Contains(t, err.Error(), "auth0 plugin approve auth0/checkmate --tenant "+pluginTestTenant) + assert.Zero(t, r.logins, "an unapproved plugin never starts a login") + + err = r.exec("checkmate", "scan") + assertRefused(t, r, err, "validation", "plugin_not_approved") + assert.Zero(t, r.logins) + }) + + t.Run("approved for another tenant", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + require.NoError(t, r.svc.approvals.Approve(context.Background(), "other.us.auth0.com", pluginTestID, r.record.PermissionsHash)) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "validation", "plugin_not_approved") + assert.Zero(t, r.logins) + }) + + t.Run("the permissions changed since the approval", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + require.NoError(t, r.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, "sha256:"+strings.Repeat("a", 64))) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "validation", "plugin_not_approved") + assert.Contains(t, err.Error(), "changed what it asks for") + assert.Zero(t, r.logins) + }) + + t.Run("a damaged approvals file", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + require.NoError(t, os.WriteFile(filepath.Join(r.Dir, "approvals.json"), []byte("{broken"), 0o600)) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "validation", "plugin_approvals_damaged") + assert.Zero(t, r.logins) + }) + + t.Run("no tenant", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + r.cli.Config.DefaultTenant = "" + + // The config file would give the CLI a default tenant when it is read. + require.NoError(t, os.Remove(filepath.Join(os.Getenv("HOME"), ".config", "auth0", "config.json"))) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "validation", "plugin_no_tenant") + assert.Zero(t, r.logins) + }) + + t.Run("plugins are turned off", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + t.Setenv(pluginsDisabledEnv, "true") + + err := r.exec("checkmate") + + require.Error(t, err) + assert.Contains(t, err.Error(), "turned off") + assert.False(t, r.started()) + assert.Zero(t, r.logins) + + // Only running is refused. The help still reads. + require.NoError(t, r.exec("checkmate", "--help")) + assert.Contains(t, r.stdout.String(), "Check a tenant") + + t.Setenv(pluginsDisabledEnv, "perhaps") + err = r.exec("checkmate") + require.Error(t, err) + assert.Contains(t, err.Error(), "not true or false") + assert.False(t, r.started()) + }) + + t.Run("a bad pin", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + t.Setenv(approvals.PinsEnv, "not-a-pin") + + err := r.exec("checkmate") + + require.Error(t, err) + assert.ErrorIs(t, err, approvals.ErrBadPins) + assert.False(t, r.started()) + assert.Zero(t, r.logins) + }) + + t.Run("a binary that changed", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + verified, err := r.svc.store.Verify(pluginTestID) + require.NoError(t, err) + + tamper(t, verified.Bin) + + err = r.exec("checkmate") + + assertRefused(t, r, err, "validation", "plugin_verification_failed") + assert.Zero(t, r.logins, "a plugin that failed verification never starts a login") + + // It still shows in the help, which runs nothing. + require.NoError(t, r.exec("checkmate", "--help")) + }) + + t.Run("a binary that changed while the session was opened", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + verified, err := r.svc.store.Verify(pluginTestID) + require.NoError(t, err) + + login := r.svc.login + r.svc.login = func(ctx context.Context, c *cli) error { + if err := login(ctx, c); err != nil { + return err + } + + tamper(t, verified.Bin) + + return nil + } + + err = r.exec("checkmate") + + assertRefused(t, r, err, "validation", "plugin_verification_failed") + assert.Equal(t, 1, r.logins, "the files are checked again after the session is open") + }) + + t.Run("a tenant flag that conflicts with the environment", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, "fixed.us.auth0.com") + t.Setenv(envAPIToken, "env-token") + + err := r.exec("checkmate", "--tenant", pluginTestTenant) + + assertRefused(t, r, err, "auth", "env_auth_tenant_conflict") + assert.Zero(t, r.logins) + }) + + t.Run("revoked", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + r.republish(r.fixtureIndex(r.record).Revoke(pluginTestID, "1.0.0", "it leaks tokens.")) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "validation", "plugin_revoked") + assert.Equal(t, "checkmate 1.0.0 was revoked: it leaks tokens. Run: auth0 plugin update checkmate", err.Error()) + assert.Zero(t, r.logins, "a revoked plugin never starts a login") + }) + + t.Run("another version is revoked", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + r.republish(r.fixtureIndex(r.record).Revoke(pluginTestID, "0.9.0", "old")) + + require.NoError(t, r.exec("checkmate")) + assert.True(t, r.started()) + }) + + t.Run("the tenant of the session is not the one that approved", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + r.Domain = "other.us.auth0.com" + + err := r.exec("checkmate") + + assertRefused(t, r, err, "auth", "plugin_tenant_mismatch") + assert.Equal(t, 1, r.logins) + assert.Contains(t, err.Error(), "other.us.auth0.com") + assert.Contains(t, err.Error(), "auth0 plugin approve auth0/checkmate --tenant other.us.auth0.com") + }) + + t.Run("the tenant is compared without regard to case", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + r.Domain = strings.ToUpper(pluginTestTenant) + saveTenant(t, config.Tenant{Domain: r.Domain}) + + require.NoError(t, r.exec("checkmate")) + assert.True(t, r.started()) + }) + + t.Run("the login fails", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + r.loginErr = authError{err: errors.New("session expired"), reason: "session_expired"} + + err := r.exec("checkmate") + + assertRefused(t, r, err, "auth", "session_expired") + assert.Equal(t, 1, r.logins) + }) + + t.Run("a command the installed manifest no longer has", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + top := newPluginCommand(r.cli, &cobra.Command{Use: "auth0"}, pluginTestID, &plugins.Manifest{ID: pluginTestID, Version: "1.0.0", Commands: fixtureCommands()}) + p := &pluginCommand{cli: r.cli, id: pluginTestID, version: "1.0.0"} + + err := p.run(top, plugins.Command{Name: "gone"}, []string{"gone"}) + + assertRefused(t, r, err, "validation", "plugin_command_missing") + }) +} + +func TestPluginRun_Pins(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + t.Run("agent mode without an approval or a pin", func(t *testing.T) { + err := r.exec("checkmate", "--agent-mode") + + require.Error(t, err) + assert.Equal(t, "plugin_not_approved", errorReason(err)) + assert.False(t, r.started()) + assert.Zero(t, r.logins) + }) + + t.Run("agent mode with a pin", func(t *testing.T) { + t.Setenv(approvals.PinsEnv, pluginTestID+"@"+r.record.PermissionsHash) + + require.NoError(t, r.exec("checkmate", "--agent-mode")) + + assert.Equal(t, "true", r.report(t).Env["AUTH0_PLUGIN_AGENT_MODE"]) + assert.Equal(t, 1, r.logins) + assert.Empty(t, r.approvedHashes(pluginTestID), "a pin is never written down") + }) + + t.Run("a pin for other permissions", func(t *testing.T) { + t.Setenv(approvals.PinsEnv, pluginTestID+"@sha256:"+strings.Repeat("b", 64)) + + err := r.exec("checkmate", "--agent-mode") + + require.Error(t, err) + assert.Equal(t, "plugin_not_approved", errorReason(err)) + assert.False(t, r.started()) + }) +} + +func TestPluginRun_Index(t *testing.T) { + const week = 7 * 24 * time.Hour + + t.Run("a stale index that cannot be refreshed runs with a warning", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + r.Now = r.Now.Add(week) + r.server.Fail("/timestamp.json", http.StatusServiceUnavailable) + + require.NoError(t, r.exec("checkmate")) + + assert.True(t, r.started()) + assert.Contains(t, r.stderr.String(), pluginStaleWarning) + }) + + t.Run("the warning is left out of machine output", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + r.Now = r.Now.Add(week) + r.server.Fail("/timestamp.json", http.StatusServiceUnavailable) + + require.NoError(t, r.exec("checkmate", "--agent-mode")) + + assert.NotContains(t, r.stderr.String(), pluginStaleWarning) + }) + + t.Run("a stale index that can be refreshed is refreshed", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + r.Now = r.Now.Add(week) + + before := r.server.Requests("/timestamp.json") + + require.NoError(t, r.exec("checkmate")) + + assert.Greater(t, r.server.Requests("/timestamp.json"), before) + assert.NotContains(t, r.stderr.String(), pluginStaleWarning) + }) + + t.Run("a fresh index is not fetched again", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + before := r.server.Requests("/timestamp.json") + + require.NoError(t, r.exec("checkmate")) + + assert.Equal(t, before, r.server.Requests("/timestamp.json")) + }) + + t.Run("an expired index", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands(), func(b *registrytest.IndexBuilder) { + b.SetExpires(time.Date(2026, time.October, 1, 13, 0, 0, 0, time.UTC)) + }) + r.approve() + + r.Now = r.Now.Add(2 * time.Hour) + r.server.Fail("/timestamp.json", http.StatusServiceUnavailable) + + err := r.exec("checkmate") + + require.Error(t, err) + assert.Equal(t, "plugin_registry_unavailable", errorReason(err)) + assert.ErrorIs(t, err, registry.ErrExpired) + assert.False(t, r.started()) + assert.Zero(t, r.logins) + }) + + t.Run("an index that was not refreshed within the grace period", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands(), func(b *registrytest.IndexBuilder) { + b.SetExpires(time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC)) + }) + r.approve() + + r.Now = r.Now.Add(40 * 24 * time.Hour) + r.server.Fail("/timestamp.json", http.StatusServiceUnavailable) + + err := r.exec("checkmate") + + require.Error(t, err) + assert.Equal(t, "plugin_registry_unavailable", errorReason(err)) + assert.False(t, r.started()) + assert.Zero(t, r.logins) + }) +} + +func TestPluginRun_InteractiveAndOutput(t *testing.T) { + t.Run("an interactive command", func(t *testing.T) { + commands := fixtureCommands() + commands[0].Interactive = true + + r := newRunEnv(t, commands) + r.approve() + + tests := []struct { + name string + terminal bool + args []string + refused bool + }{ + {"in agent mode at a terminal", true, []string{"checkmate", "--agent-mode"}, true}, + {"with --no-input at a terminal", true, []string{"checkmate", "--no-input"}, true}, + {"with --no-input=true at a terminal", true, []string{"checkmate", "--no-input=true"}, true}, + {"without a terminal", false, []string{"checkmate"}, true}, + {"at a terminal", true, []string{"checkmate"}, false}, + {"at a terminal with --no-input=false", true, []string{"checkmate", "--no-input=false"}, false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r.Terminal = test.terminal + + err := r.exec(test.args...) + + if !test.refused { + require.NoError(t, err) + assert.True(t, r.started()) + + return + } + + require.Error(t, err) + + class, reason := reasonOf(err) + assert.Equal(t, "usage", class) + assert.Equal(t, "plugin_interactive", reason) + assert.Contains(t, err.Error(), "auth0 checkmate is interactive") + assert.False(t, r.started()) + assert.Zero(t, r.logins, "an interactive command that cannot run never starts a login") + }) + } + + t.Run("a command that is not interactive runs without a terminal", func(t *testing.T) { + r.Terminal = false + + require.NoError(t, r.exec("checkmate", "scan")) + assert.True(t, r.started()) + }) + }) + + t.Run("the output flags need the declaration", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + tests := []struct { + args []string + refused string + }{ + {[]string{"checkmate", "--json"}, ""}, + {[]string{"checkmate", "--json-compact"}, ""}, + {[]string{"checkmate", "--csv"}, ""}, + {[]string{"checkmate", "--agent-mode"}, ""}, + {[]string{"checkmate", "--json=false"}, ""}, + {[]string{"checkmate", "report", "--json"}, ""}, + {[]string{"checkmate", "report", "--json-compact"}, ""}, + {[]string{"checkmate", "report", "--csv"}, "output.csv"}, + {[]string{"checkmate", "report", "--agent-mode"}, "output.agentMode"}, + {[]string{"checkmate", "scan", "--json"}, "output.json"}, + {[]string{"checkmate", "scan", "--json-compact"}, "output.json"}, + {[]string{"checkmate", "scan", "--csv"}, "output.csv"}, + {[]string{"checkmate", "scan", "--agent-mode"}, "output.agentMode"}, + {[]string{"checkmate", "scan", "--agent-mode=false"}, ""}, + {[]string{"checkmate", "scan", "--json=false"}, ""}, + {[]string{"checkmate", "scan", "--", "--json"}, ""}, + } + + for _, test := range tests { + t.Run(strings.Join(test.args, " "), func(t *testing.T) { + err := r.exec(test.args...) + + if test.refused == "" { + require.NoError(t, err) + assert.True(t, r.started()) + + return + } + + require.Error(t, err) + + class, reason := reasonOf(err) + assert.Equal(t, "usage", class) + assert.Equal(t, "plugin_output_unsupported", reason) + assert.Contains(t, err.Error(), "does not declare "+test.refused) + assert.False(t, r.started()) + assert.Zero(t, r.logins) + }) + } + }) +} + +func TestPluginRun_Results(t *testing.T) { + t.Run("a plugin that exits with a code", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + err := r.exec("checkmate", "--mode=exit", "--code=7") + + require.Error(t, err) + + class, reason := reasonOf(err) + assert.Equal(t, "plugin", class) + assert.Equal(t, "plugin_exit", reason) + assert.Equal(t, exitGeneric, exitCodeForError(err), "the process exits with the generic code") + assert.Equal(t, "checkmate 1.0.0 exited with code 7", err.Error()) + + envelope := buildErrorEnvelope(err) + + assert.Equal(t, "plugin", envelope.Error.Code) + assert.Equal(t, "plugin_exit", envelope.Error.Reason) + assert.JSONEq(t, `{"pluginId":"auth0/checkmate","version":"1.0.0","exitCode":7}`, string(envelope.Error.Details)) + }) + + t.Run("a plugin that runs out of time", func(t *testing.T) { + commands := fixtureCommands() + commands[1].Timeout = "4s" + + r := newRunEnv(t, commands) + r.approve() + + marker := filepath.Join(t.TempDir(), "pid") + started := time.Now() + + err := r.exec("checkmate", "scan", "--mode=sleep", "--marker="+marker) + + require.Error(t, err) + + class, reason := reasonOf(err) + assert.Equal(t, "plugin", class) + assert.Equal(t, "plugin_timeout", reason) + assert.Equal(t, "checkmate 1.0.0 did not finish within 4s and was stopped", err.Error()) + assert.Less(t, time.Since(started), 30*time.Second) + assert.JSONEq(t, `{"pluginId":"auth0/checkmate","version":"1.0.0"}`, string(buildErrorEnvelope(err).Error.Details)) + + if runtime.GOOS != "windows" { + assert.False(t, processAlive(waitForMarker(t, marker)), "the plugin must be gone") + } + }) + + t.Run("the timeout comes from the registry when the command has none", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands(), func(b *registrytest.IndexBuilder) { + b.SetPolicy(registry.Policy{DefaultTimeout: "4s"}) + }) + r.approve() + + marker := filepath.Join(t.TempDir(), "pid") + + err := r.exec("checkmate", "--mode=sleep", "--marker="+marker) + + require.Error(t, err) + assert.Equal(t, "plugin_timeout", errorReason(err)) + assert.Contains(t, err.Error(), "did not finish within 4s") + }) + + t.Run("the proxy is closed after the run", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + require.NoError(t, r.exec("checkmate")) + + address := r.report(t).Env["AUTH0_PLUGIN_DOMAIN"] + require.NotEmpty(t, address) + + _, err := (&http.Client{Timeout: 2 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}).Get("https://" + address + "/") + assert.Error(t, err, "nothing listens once the plugin has ended") + + certificate := r.report(t).Env["AUTH0_PLUGIN_CA_CERT"] + _, statErr := os.Stat(certificate) + assert.True(t, os.IsNotExist(statErr), "the certificate of the run is removed") + }) +} + +func TestPluginRun_Interrupt(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the process tree is stopped differently on Windows") + } + + r := newRunEnv(t, fixtureCommands()) + r.approve() + + guard := &interruptGuard{} + ctx, cancel := context.WithCancel(withInterruptGuard(context.Background(), guard)) + + defer cancel() + + marker := filepath.Join(t.TempDir(), "pid") + done := make(chan error, 1) + + go func() { done <- r.execContext(ctx, "checkmate", "--mode=sleep", "--marker="+marker) }() + + pid := waitForMarker(t, marker) + + // The interrupt waits for the run, which stops the plugin before Execute exits. + require.True(t, guard.interrupt()) + cancel() + + err := <-done + + require.Error(t, err) + + class, reason := reasonOf(err) + assert.Equal(t, "plugin", class) + assert.Equal(t, "plugin_interrupted", reason) + assert.Equal(t, "checkmate 1.0.0 was interrupted", err.Error()) + assert.True(t, interrupted(ctx), "Execute exits with the code of an interrupt") + assert.False(t, processAlive(pid), "the plugin must be gone") +} + +func TestPluginRun_Hangup(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the process tree is stopped differently on Windows") + } + + r := newRunEnv(t, fixtureCommands()) + r.approve() + + marker := filepath.Join(t.TempDir(), "pid") + done := make(chan error, 1) + + go func() { done <- r.exec("checkmate", "--mode=sleep", "--marker="+marker) }() + + pid := waitForMarker(t, marker) + + // A terminal that is closed sends SIGHUP to the CLI. The run has taken the + // signal by now, as the plugin only starts after that. + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGHUP)) + + err := <-done + + require.Error(t, err) + + class, reason := reasonOf(err) + assert.Equal(t, "plugin", class) + assert.Equal(t, "plugin_interrupted", reason) + assert.False(t, processAlive(pid), "the plugin must be gone") +} + +func TestPluginRun_AmbiguousSubcommand(t *testing.T) { + // The root command declares the boolean flag mode, so that cobra does not take + // the word after it as its value. + commands := fixtureCommands() + commands[0].Flags = append(commands[0].Flags, plugins.Flag{Name: "mode", Type: plugins.FlagTypeBool, Description: "A switch"}) + + r := newRunEnv(t, commands) + r.approve() + + refused := []struct { + name string + args []string + }{ + {"an unknown flag takes the subcommand as its value", []string{"checkmate", "--unknownbool", "scan"}}, + {"a boolean flag followed by words", []string{"checkmate", "--mode", "call", "scan"}}, + {"the word in the middle", []string{"checkmate", "--unknownbool", "scan", "--depth", "3"}}, + {"the word after a flag of the CLI", []string{"checkmate", "--debug", "--unknownbool", "scan"}}, + } + + for _, test := range refused { + t.Run("refuses "+test.name, func(t *testing.T) { + err := r.exec(test.args...) + + require.Error(t, err) + + class, reason := reasonOf(err) + assert.Equal(t, "usage", class) + assert.Equal(t, "plugin_ambiguous_subcommand", reason) + assert.Contains(t, err.Error(), `"scan" is a command of auth0 checkmate`) + assert.Contains(t, err.Error(), "auth0 checkmate scan ...") + assert.Contains(t, err.Error(), "--flag=value") + assert.Contains(t, err.Error(), "after --") + assert.Zero(t, r.logins, "nothing starts before the call is refused") + assert.False(t, r.started()) + assert.Empty(t, r.tenant.seen()) + }) + } + + allowed := []struct { + name string + args []string + want []string + }{ + {"the subcommand first", []string{"checkmate", "scan"}, []string{"scan"}}, + {"a flag with an equals sign before the subcommand", []string{"checkmate", "--validators=a", "scan"}, []string{"scan", "--validators=a"}}, + {"a flag and its value before the subcommand", []string{"checkmate", "--validators", "a", "scan"}, []string{"scan", "--validators", "a"}}, + {"a double dash before the word", []string{"checkmate", "--", "scan"}, []string{"--", "scan"}}, + {"the word again after the subcommand", []string{"checkmate", "scan", "--unknownbool", "scan"}, []string{"scan", "--unknownbool", "scan"}}, + {"a value that is not a subcommand", []string{"checkmate", "--unknownbool", "other"}, []string{"--unknownbool", "other"}}, + } + + for _, test := range allowed { + t.Run("allows "+test.name, func(t *testing.T) { + require.NoError(t, r.exec(test.args...)) + + assert.Equal(t, test.want, r.report(t).Args) + assert.Equal(t, 1, r.logins) + }) + } + + t.Run("help is not refused", func(t *testing.T) { + require.NoError(t, r.exec("checkmate", "--unknownbool", "scan", "--help")) + + assert.Zero(t, r.logins) + assert.False(t, r.started()) + }) + + t.Run("a plugin without subcommands has nothing to mix up", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()[:1]) + r.approve() + + require.NoError(t, r.exec("checkmate", "--unknownbool", "scan")) + + assert.Equal(t, []string{"--unknownbool", "scan"}, r.report(t).Args) + assert.Equal(t, 1, r.logins) + }) +} + +func TestPluginRun_NoLoginBeforeApproval(t *testing.T) { + // The login of the CLI is the real one, in the mode that needs neither the + // keychain nor the network, so the root hook is the only thing that could start + // it before the plugin was checked. + gokeyring.MockInit() + + r := newRunEnv(t, fixtureCommands()) + r.svc.login = nil + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, pluginTestTenant) + t.Setenv(envAPIToken, "offline-token") + t.Setenv(envClientID, "") + t.Setenv(envClientSecret, "") + t.Setenv("BROWSER", "true") + + // Without the env mode a login could fall through to the saved one, which + // opens a browser. Nothing below may run unless the mode is on. + require.True(t, envAuthEnabled(os.Getenv), "the test must stay in the offline mode") + + noSession := func(t *testing.T) { + t.Helper() + + assert.Empty(t, r.cli.accessToken, "no login may have started") + assert.Empty(t, r.cli.sessionDomain, "no login may have started") + assert.False(t, r.started()) + } + + reset := func() { + r.cli.accessToken, r.cli.sessionDomain = "", "" + } + + t.Run("not approved", func(t *testing.T) { + err := r.exec("checkmate", "scan") + + require.Error(t, err) + assert.Equal(t, "plugin_not_approved", errorReason(err)) + noSession(t) + }) + + t.Run("an approved run opens the session", func(t *testing.T) { + reset() + r.approve() + + require.NoError(t, r.exec("checkmate", "scan")) + + assert.Equal(t, "offline-token", r.cli.accessToken) + assert.Equal(t, pluginTestTenant, r.cli.sessionDomain) + assert.True(t, r.started()) + }) + + t.Run("help", func(t *testing.T) { + reset() + + require.NoError(t, r.exec("checkmate", "scan", "--help")) + noSession(t) + }) +} + +func TestPluginRun_ChangedDuringLogin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the store is copied with cp") + } + + r := newRunEnv(t, fixtureCommands()) + r.approve() + + // While the person logs in, the plugin is replaced by an exact copy of the + // same version in another place. The version and the permissions are the same, + // but what would run is not what was checked. + login := r.svc.login + r.svc.login = func(ctx context.Context, c *cli) error { + if err := login(ctx, c); err != nil { + return err + } + + moved := filepath.Join(t.TempDir(), "plugins") + + if output, err := exec.Command("cp", "-Rp", r.Dir, moved).CombinedOutput(); err != nil { + return fmt.Errorf("%w: %s", err, output) + } + + r.svc.store.Dir = moved + + return nil + } + + err := r.exec("checkmate") + + require.Error(t, err) + assert.Equal(t, "plugin_changed", errorReason(err)) + assert.Equal(t, 1, r.logins) + assert.False(t, r.started()) +} + +func TestPluginEnvironment_KeepsTheCredentialsOfTheCLI(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + names := []string{ + "AUTH0_CLIENT_SECRET", "AUTH0_API_TOKEN", "AUTH0_CLIENT_ID", "AUTH0_DOMAIN", + "AUTH0_CLI_X", "auth0_client_secret", + } + + // The manifest is built in memory, as the validator of the manifest would not + // let it through. + command := plugins.Command{Name: pluginTestBin, Permissions: fixturePermissions(nil, append([]string{"AWS_PROFILE"}, names...)...)} + manifest := &plugins.Manifest{ID: pluginTestID, Version: "1.0.0", Commands: []plugins.Command{command}} + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + + top := newPluginCommand(r.cli, root, pluginTestID, manifest) + root.AddCommand(top) + + for _, name := range names { + t.Setenv(name, "secret-of-"+name) + } + + t.Setenv("AWS_PROFILE", "production") + + env := environment(top, command, pluginTestTenant, []string{"AUTH0_PLUGIN_DOMAIN=127.0.0.1:1"}) + + assert.Contains(t, env, "AWS_PROFILE=production", "a variable that is not the CLI's still passes when declared") + + for _, entry := range env { + name, value, _ := strings.Cut(entry, "=") + + assert.False(t, strings.HasPrefix(strings.ToUpper(name), "AUTH0_") && !strings.HasPrefix(name, "AUTH0_PLUGIN_"), "%s must not reach the plugin", name) + assert.NotContains(t, value, "secret-of-", name) + } +} + +func TestPluginEnvironment_LoopbackIsNotProxied(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + t.Setenv("NO_PROXY", "internal.example.com") + t.Setenv("no_proxy", "") + t.Setenv("HTTPS_PROXY", "http://proxy.example.com:3128") + + require.NoError(t, r.exec("checkmate")) + + env := r.report(t).Env + + assert.Equal(t, "internal.example.com,127.0.0.1,localhost,::1", env["NO_PROXY"]) + assert.Equal(t, "127.0.0.1,localhost,::1", env["no_proxy"]) + assert.Equal(t, "http://proxy.example.com:3128", env["HTTPS_PROXY"], "the proxy of the user is kept") +} + +func TestRenderNamespaceJSONHelp_LeavesPluginCommandsAlone(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + registerPluginCommands(root, r.cli, "dev", nil) + + // The plugin has subcommands, so it looks like a namespace. A JSON flag on it + // is for the plugin, and must not turn the run into a help request. + for _, args := range [][]string{ + {"checkmate", "--json"}, + {"checkmate", "--json-compact"}, + {"checkmate", "--validators", "a", "--json"}, + } { + assert.False(t, renderNamespaceJSONHelp(root, args), args) + } +} + +func TestPluginHelp_UsesSafeText(t *testing.T) { + manifest := &plugins.Manifest{ID: pluginTestID, Version: "1.0.0", Description: "bad\x1b[2Jtext", Commands: []plugins.Command{ + {Name: pluginTestBin, Description: "root\u202ecommand", Flags: []plugins.Flag{{Name: "f", Type: plugins.FlagTypeBool, Description: "flag\x07"}}}, + }} + + root := &cobra.Command{Use: "auth0"} + top := newPluginCommand(&cli{}, root, pluginTestID, manifest) + + for _, text := range []string{top.Short, top.Long, top.Flags().Lookup("f").Usage} { + for _, r := range text { + assert.False(t, r < ' ' && r != '\n', "control character %q in %q", r, text) + assert.NotEqual(t, '\u202e', r) + } + } +} + +func TestCheckPluginNameAvailable_IgnoresPluginCommands(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + root.AddCommand(&cobra.Command{Use: "apps", Aliases: []string{"clients"}}) + registerPluginCommands(root, r.cli, "dev", nil) + + require.NoError(t, checkPluginNameAvailable(root, pluginTestBin), "an installed plugin can be installed again") + + err := checkPluginNameAvailable(root, "apps") + require.Error(t, err) + assert.Equal(t, "plugin_name_reserved", errorReason(err)) + + require.Error(t, checkPluginNameAvailable(root, "clients")) +} + +func TestPluginReinstall(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + // The command tree has the plugin when `plugin install` for it runs. + r.Terminal = false + + require.NoError(t, r.exec("plugin", "install", "checkmate")) + assert.Contains(t, r.stderr.String(), "checkmate") +} + +func TestShadowedPlugin(t *testing.T) { + e := newPluginEnv(t) + + apps := e.releaseAs("auth0/apps", e.platform, "1.0.0") + clients := e.releaseAs("auth0/clients", e.platform, "1.0.0") + fine := e.releaseAs("auth0/pluginless", e.platform, "1.0.0") + + e.publish(registrytest.NewIndexBuilder(). + AddPlugin(registrytest.NewPlugin("auth0/apps", apps)). + AddPlugin(registrytest.NewPlugin("auth0/clients", clients)). + AddPlugin(registrytest.NewPlugin("auth0/pluginless", fine))) + + e.installPlugin("auth0/apps", "1.0.0") + e.installPlugin("auth0/clients", "1.0.0") + e.installPlugin("auth0/pluginless", "1.0.0") + + t.Run("the core command is the one registered", func(t *testing.T) { + root := buildRootCmd(e.cli) + addPersistentFlags(root, e.cli) + addSubCommands(root, e.cli) + + for _, name := range []string{"apps", "clients"} { + found, _, err := root.Find([]string{name}) + require.NoError(t, err) + + assert.NotContains(t, found.Annotations, pluginCommandAnnotation, name) + assert.Equal(t, "apps", found.Name(), name) + } + + found, _, err := root.Find([]string{"pluginless"}) + require.NoError(t, err) + assert.Equal(t, "true", found.Annotations[pluginCommandAnnotation]) + }) + + t.Run("the warning names the plugin and how to remove it", func(t *testing.T) { + require.NoError(t, e.run("apps")) + + assert.Contains(t, e.stderr.String(), `The installed plugin auth0/apps has the name of the core command "apps"`) + assert.Contains(t, e.stderr.String(), "so the core command runs and the plugin is not available") + assert.Contains(t, e.stderr.String(), "Remove the plugin with: auth0 plugin uninstall apps") + assert.Equal(t, 1, strings.Count(e.stderr.String(), "The installed plugin")) + }) + + t.Run("an alias of a core command counts", func(t *testing.T) { + require.NoError(t, e.run("clients")) + + assert.Contains(t, e.stderr.String(), `The installed plugin auth0/clients has the name of the core command "clients"`) + assert.Contains(t, e.stderr.String(), "auth0 plugin uninstall clients") + assert.NotContains(t, e.stderr.String(), "auth0/apps") + }) + + t.Run("no warning for another command", func(t *testing.T) { + require.NoError(t, e.run("tenants")) + + assert.NotContains(t, e.stderr.String(), "The installed plugin") + }) + + t.Run("no warning in agent mode", func(t *testing.T) { + require.NoError(t, e.run("--agent-mode", "apps")) + + assert.Empty(t, e.stderr.String()) + }) +} + +func TestPluginReservedFlagsMatchTheCLI(t *testing.T) { + root := buildRootCmd(&cli{}) + addPersistentFlags(root, &cli{}) + + // Every flag the CLI owns, as the pre-scan sees them, plus the help flag. + names := []string{"help"} + shorthands := []string{"h"} + + for _, flag := range ownedFlags(root) { + names = append(names, flag.Name) + + if flag.Short != "" { + shorthands = append(shorthands, flag.Short) + } + } + + root.InitDefaultHelpFlag() + root.Flags().VisitAll(func(flag *pflag.Flag) { names = append(names, flag.Name) }) + root.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + names = append(names, flag.Name) + + if flag.Shorthand != "" { + shorthands = append(shorthands, flag.Shorthand) + } + }) + + for _, name := range pluginOutputFlags { + names = append(names, name) + } + + manifest := func(flag string) []byte { + return fmt.Appendf(nil, `{"id":"auth0/checkmate","version":"1.0.0","apiVersion":1,"description":"x", + "owner":{"team":"t","bugs":"https://example.com"},"platforms":["darwin-arm64"], + "commands":[{"name":"checkmate","description":"x","interactive":false,"flags":[%s]}]}`, flag) + } + + slices.Sort(names) + names = slices.Compact(names) + + for _, name := range names { + t.Run("the flag "+name, func(t *testing.T) { + _, err := plugins.ParseManifest(manifest(fmt.Sprintf(`{"name":%q,"type":"string","description":"x"}`, name))) + + require.Error(t, err, "a plugin must not be able to declare the flag %s, which the CLI owns", name) + assert.Contains(t, err.Error(), "collides with a flag owned by the CLI") + }) + } + + slices.Sort(shorthands) + shorthands = slices.Compact(shorthands) + + for _, shorthand := range shorthands { + t.Run("the shorthand "+shorthand, func(t *testing.T) { + _, err := plugins.ParseManifest(manifest(fmt.Sprintf(`{"name":"mine","shorthand":%q,"type":"string","description":"x"}`, shorthand))) + + require.Error(t, err, "a plugin must not be able to declare the shorthand %s, which the CLI owns", shorthand) + assert.Contains(t, err.Error(), "collides with a shorthand owned by the CLI") + }) + } + + t.Run("a flag of the plugin is fine", func(t *testing.T) { + _, err := plugins.ParseManifest(manifest(`{"name":"mine","shorthand":"m","type":"string","description":"x"}`)) + + require.NoError(t, err) + }) +} + +func TestOwnedFlags(t *testing.T) { + root := buildRootCmd(&cli{}) + addPersistentFlags(root, &cli{}) + + owned := map[string]bool{} + + for _, flag := range ownedFlags(root) { + owned[flag.Name] = flag.Bool + } + + assert.Equal(t, map[string]bool{ + "tenant": false, "debug": true, "no-input": true, "no-color": true, "agent-mode": true, + "json": true, "json-compact": true, "csv": true, + }, owned) +} + +func TestPluginError(t *testing.T) { + err := pluginError{err: errors.New("it failed"), reason: "plugin_exit", pluginID: pluginTestID, version: "1.0.0", exitCode: 3} + + assert.Equal(t, "it failed", err.Error()) + assert.Equal(t, "plugin", errorClass(err)) + assert.Equal(t, "plugin_exit", errorReason(err)) + assert.Equal(t, exitGeneric, exitCodeForError(err)) + assert.JSONEq(t, `{"pluginId":"auth0/checkmate","version":"1.0.0","exitCode":3}`, string(err.ErrorDetails())) + + wrapped := fmt.Errorf("wrap: %w", err) + + assert.Equal(t, "plugin", errorClass(wrapped)) + assert.Equal(t, "plugin_exit", errorReason(wrapped)) + + timeout := pluginError{err: errors.New("slow"), reason: "plugin_timeout", pluginID: pluginTestID, version: "1.0.0"} + + assert.JSONEq(t, `{"pluginId":"auth0/checkmate","version":"1.0.0"}`, string(timeout.ErrorDetails()), "no exit code when the plugin did not exit") +} + +func TestPluginFailure(t *testing.T) { + p := &pluginCommand{id: pluginTestID, version: "1.0.0"} + + tests := []struct { + name string + err error + reason string + code int + }{ + {"an exit", &runner.ExitError{Code: 9}, "plugin_exit", 9}, + {"a timeout", &runner.TimeoutError{After: time.Second}, "plugin_timeout", 0}, + {"an interruption", &runner.InterruptedError{}, "plugin_interrupted", 0}, + {"a plugin that did not start", errors.New("no such file"), "plugin_start_failed", 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := p.failure(test.err) + + var failed pluginError + + require.ErrorAs(t, err, &failed) + assert.Equal(t, test.reason, failed.reason) + assert.Equal(t, test.code, failed.exitCode) + assert.Equal(t, pluginTestID, failed.pluginID) + assert.Equal(t, "1.0.0", failed.version) + }) + } +} + +// saveTenant writes a tenant to the config file in the home directory of the test +// through a config of its own, because a config remembers that its file was +// missing when it first looked. +func saveTenant(t *testing.T, tenant config.Tenant) { + t.Helper() + + var saved config.Config + + require.NoError(t, saved.AddTenant(tenant)) +} + +func TestPluginSession(t *testing.T) { + setup := func(t *testing.T) *cli { + t.Helper() + + gokeyring.MockInit() + t.Setenv("HOME", t.TempDir()) + + for _, name := range []string{authModeEnvVar, envDomain, envAPIToken, envClientID, envClientSecret} { + t.Setenv(name, "") + } + + return &cli{renderer: &display.Renderer{MessageWriter: io.Discard, ResultWriter: io.Discard}} + } + + t.Run("there is no session", func(t *testing.T) { + c := setup(t) + + _, err := c.newPluginSession() + + require.Error(t, err) + assert.Equal(t, "plugin_session_unavailable", errorReason(err)) + }) + + t.Run("a token from the environment cannot be renewed", func(t *testing.T) { + c := setup(t) + c.accessToken, c.sessionDomain = "env-token", pluginTestTenant + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envAPIToken, "env-token") + + session, err := c.newPluginSession() + require.NoError(t, err) + + token, err := session.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "env-token", token) + + _, err = session.Refresh(context.Background()) + assert.ErrorIs(t, err, proxy.ErrReloginRequired) + }) + + t.Run("client credentials from the environment can be renewed", func(t *testing.T) { + c := setup(t) + c.accessToken, c.sessionDomain = "first", pluginTestTenant + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envClientID, "id") + t.Setenv(envClientSecret, "secret") + + session, err := c.newPluginSession() + require.NoError(t, err) + + require.NotNil(t, session.renew) + + session.renew = func(context.Context) (string, error) { return "second", nil } + + token, err := session.Refresh(context.Background()) + require.NoError(t, err) + assert.Equal(t, "second", token) + + token, err = session.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "second", token) + }) + + t.Run("a user who logged in with a device code cannot be renewed", func(t *testing.T) { + c := setup(t) + c.accessToken, c.sessionDomain = "user-token", pluginTestTenant + saveTenant(t, config.Tenant{Domain: pluginTestTenant}) + + session, err := c.newPluginSession() + require.NoError(t, err) + + token, err := session.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "user-token", token) + + _, err = session.Refresh(context.Background()) + assert.ErrorIs(t, err, proxy.ErrReloginRequired) + }) + + t.Run("saved client credentials can be renewed and the new token is saved", func(t *testing.T) { + c := setup(t) + c.accessToken, c.sessionDomain = "old-token", pluginTestTenant + saveTenant(t, config.Tenant{Domain: pluginTestTenant, ClientID: "client"}) + + session, err := c.newPluginSession() + require.NoError(t, err) + require.NotNil(t, session.renew) + + tenant, err := c.Config.GetTenant(pluginTestTenant) + require.NoError(t, err) + + session.renew = c.savedCredentialsRenewal(tenant, func(_ context.Context, tenant *config.Tenant) error { + tenant.ExpiresAt = time.Now().Add(time.Hour) + + return keyring.StoreAccessToken(tenant.Domain, "new-token") + }) + + token, err := session.Refresh(context.Background()) + require.NoError(t, err) + assert.Equal(t, "new-token", token) + + token, err = session.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "new-token", token) + + saved, err := c.Config.GetTenant(pluginTestTenant) + require.NoError(t, err) + assert.True(t, saved.ExpiresAt.After(time.Now()), "the expiry of the new token is saved") + }) + + t.Run("a renewal that fails is an auth error and keeps the old token", func(t *testing.T) { + c := setup(t) + c.accessToken, c.sessionDomain = "old-token", pluginTestTenant + saveTenant(t, config.Tenant{Domain: pluginTestTenant, ClientID: "client"}) + + session, err := c.newPluginSession() + require.NoError(t, err) + + tenant, err := c.Config.GetTenant(pluginTestTenant) + require.NoError(t, err) + + session.renew = c.savedCredentialsRenewal(tenant, func(context.Context, *config.Tenant) error { return errors.New("rotated") }) + + _, err = session.Refresh(context.Background()) + + require.Error(t, err) + assert.Equal(t, "credentials_refresh_failed", errorReason(err)) + assert.NotContains(t, err.Error(), "old-token") + + token, err := session.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "old-token", token) + }) + + t.Run("a session of a tenant that is not saved", func(t *testing.T) { + c := setup(t) + c.accessToken, c.sessionDomain = "token", pluginTestTenant + + _, err := c.newPluginSession() + + require.Error(t, err) + assert.Equal(t, "plugin_session_unavailable", errorReason(err)) + }) + + t.Run("the context is honoured", func(t *testing.T) { + c := setup(t) + c.accessToken, c.sessionDomain = "token", pluginTestTenant + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envClientID, "id") + t.Setenv(envClientSecret, "secret") + + session, err := c.newPluginSession() + require.NoError(t, err) + + called := false + session.renew = func(context.Context) (string, error) { called = true; return "new", nil } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = session.Token(ctx) + assert.ErrorIs(t, err, context.Canceled) + + _, err = session.Refresh(ctx) + assert.ErrorIs(t, err, context.Canceled) + assert.False(t, called, "a renewal is not started for a caller that is gone") + }) + + t.Run("one renewal at a time, and a waiting caller can give up", func(t *testing.T) { + c := setup(t) + c.accessToken, c.sessionDomain = "token", pluginTestTenant + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envClientID, "id") + t.Setenv(envClientSecret, "secret") + + session, err := c.newPluginSession() + require.NoError(t, err) + + started := make(chan struct{}) + release := make(chan struct{}) + + session.renew = func(context.Context) (string, error) { + close(started) + <-release + + return "new", nil + } + + first := make(chan error, 1) + + go func() { + _, err := session.Refresh(context.Background()) + first <- err + }() + + <-started + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err = session.Refresh(ctx) + assert.ErrorIs(t, err, context.DeadlineExceeded) + + close(release) + require.NoError(t, <-first) + }) +} + +func TestInterruptGuard(t *testing.T) { + t.Run("without a holder the interrupt ends the process", func(t *testing.T) { + guard := &interruptGuard{} + + assert.False(t, guard.interrupt()) + assert.False(t, guard.wasInterrupted()) + }) + + t.Run("a holder takes the interrupt", func(t *testing.T) { + guard := &interruptGuard{} + ctx := withInterruptGuard(context.Background(), guard) + + release := holdInterrupt(ctx) + + assert.True(t, guard.interrupt()) + assert.True(t, guard.wasInterrupted()) + assert.True(t, interrupted(ctx)) + + release() + release() + + assert.False(t, guard.interrupt(), "nothing is held any more") + }) + + t.Run("a context without a guard has nothing to hold", func(t *testing.T) { + release := holdInterrupt(context.Background()) + + assert.NotPanics(t, release) + assert.False(t, interrupted(context.Background())) + }) +} diff --git a/internal/cli/plugin_test.go b/internal/cli/plugin_test.go index e7f9ab827..8b33d984b 100644 --- a/internal/cli/plugin_test.go +++ b/internal/cli/plugin_test.go @@ -92,6 +92,9 @@ func newPluginEnv(t *testing.T) *pluginEnv { t.Setenv(name, "") } + // A person who runs the tests inside an agent client would turn agent mode on. + t.Setenv(agentModeEnvVar, "false") + platform := plugins.CurrentPlatform() if platform == "" { t.Skip("this machine is not one of the plugin platforms") diff --git a/internal/cli/root.go b/internal/cli/root.go index 90655e546..08a6a4fa0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -72,7 +72,7 @@ In agent mode the CLI: approves it in a browser. • Exits 0 on success and 130 when interrupted; every other failure exits 1, so scripts that treat any non-zero exit as failure keep working. The specific - failure class (usage, auth, validation, not_found, conflict, rate_limit, api) is carried + failure class (usage, auth, validation, not_found, conflict, rate_limit, api, plugin) is carried by the JSON error envelope's "code" field, not by the exit code.` const panicMessage = ` @@ -183,6 +183,12 @@ func Execute() { cancelCtx := contextWithCancel() err := classifyRequiredFlagError(rootCmd.ExecuteContext(cancelCtx)) + + // A command that held the interrupt has stopped what it started by now. + if interrupted(cancelCtx) { + os.Exit(exitInterrupted) // nolint:gocritic + } + if err == nil && cli.helpErr != nil { err = cli.helpErr } @@ -259,11 +265,13 @@ func buildRootCmd(cli *cli) *cobra.Command { ansi.Initialize(cli.noColor) prepareInteractivity(cmd) cli.configureRenderer() + cli.warnShadowedPlugin(cmd) // Namespace commands (e.g. `auth0 actions`) never call the API // themselves; they only print help or reject an unknown - // subcommand, so they must not force authentication. - if cmd.HasSubCommands() || !commandRequiresAuthentication(cmd.CommandPath()) { + // subcommand, so they must not force authentication. A plugin command + // logs in itself, once it knows that the plugin may run. + if cmd.HasSubCommands() || !commandRequiresAuthentication(cmd.CommandPath()) || cmd.Annotations[skipAuthAnnotation] == "true" { return nil } @@ -538,8 +546,13 @@ func applyAgentModeDefaults(cli *cli, cmd *cobra.Command) { const agentModeEnvVar = "AUTH0_AGENT_MODE" // resolveAgentMode reports agent mode: an explicit --agent-mode flag wins, then AUTH0_AGENT_MODE (true/false), then a detected agent client. +// The arguments after a bare "--" are not read, as they belong to a plugin. func resolveAgentMode(agentClient string, args []string) bool { for _, arg := range args { + if arg == "--" { + break + } + if arg == "--agent-mode" { return true } @@ -635,27 +648,40 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(experimentationCmd(cli)) registerPluginCmd(rootCmd, cli, buildinfo.Version) - rootCmd.AddCommand(docsCmd(cli)) - rootCmd.AddCommand(commandsCmd(cli)) - rootCmd.AddCommand(agentCmd(cli)) + // The commands below are added after the installed plugins, so the plugins + // are given them to find out which names a core command owns. + trailing := []*cobra.Command{docsCmd(cli), commandsCmd(cli), agentCmd(cli)} // Keep completion at the bottom. - rootCmd.AddCommand(completionCmd(cli)) + trailing = append(trailing, completionCmd(cli)) + + registerPluginCommands(rootCmd, cli, buildinfo.Version, trailing) + rootCmd.AddCommand(trailing...) } func contextWithCancel() context.Context { ctx, cancel := context.WithCancel(context.Background()) + guard := &interruptGuard{} ch := make(chan os.Signal, 1) signal.Notify(ch, os.Interrupt) go func() { <-ch + + // A command that started other processes, such as a plugin, stops them when + // the context ends, and Execute exits afterwards. A second interrupt does not + // wait for that. + if guard.interrupt() { + cancel() + <-ch + } + defer cancel() os.Exit(exitInterrupted) }() - return ctx + return withInterruptGuard(ctx, guard) } func overrideHelpAndVersionFlagText(cmd *cobra.Command) { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 3ae072947..e50685b02 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -484,6 +484,10 @@ func TestResolveAgentMode(t *testing.T) { {name: "detected agent when no flag or env", detected: "claude-code", expected: true}, {name: "human is not agent mode", detected: "human", expected: false}, {name: "unknown is not agent mode", detected: "unknown", expected: false}, + {name: "a flag after a double dash is not read", args: []string{"checkmate", "scan", "--", "--agent-mode"}, detected: "human", expected: false}, + {name: "a flag with a value after a double dash is not read", args: []string{"checkmate", "--", "--agent-mode=true"}, detected: "human", expected: false}, + {name: "a flag before a double dash is read", args: []string{"checkmate", "--agent-mode", "--", "scan"}, detected: "human", expected: true}, + {name: "after a double dash the env still decides", args: []string{"checkmate", "--", "--agent-mode"}, env: "true", detected: "human", expected: true}, } for _, test := range tests { diff --git a/internal/plugins/manifest.go b/internal/plugins/manifest.go index 41ea08cdc..0337fbbe7 100644 --- a/internal/plugins/manifest.go +++ b/internal/plugins/manifest.go @@ -148,8 +148,9 @@ const ( // reservedFlagNames lists the flags owned by the CLI itself. A plugin command // cannot declare a flag with one of these names. // -// TODO: Read this list from the root command's persistent flags once the -// dynamic plugin commands land (DXCDT-2605). +// The list is static because the manifest validator cannot see the commands of +// the CLI. A test in internal/cli fails when a persistent flag of the root +// command, an output flag or help is missing from it. var reservedFlagNames = []string{ "tenant", "json", @@ -165,19 +166,19 @@ var reservedFlagNames = []string{ // reservedFlagShorthands lists the single letter flags owned by the CLI // itself. A plugin command cannot declare a flag with one of these shorthands. // The CLI's persistent flags have no shorthand, so the only reserved one comes -// from cobra's built-in help flag. -// -// TODO: Read this list from the root command's persistent flags once the -// dynamic plugin commands land (DXCDT-2605). +// from cobra's built-in help flag. A test in internal/cli fails when a flag of +// the CLI gets another shorthand. var reservedFlagShorthands = []string{ "h", } // reservedEnvPrefixes lists the environment variable prefixes that the CLI -// owns, so a plugin cannot ask for variables that start with them. +// owns, so a plugin cannot ask for variables that start with them. AUTH0_ covers +// the credentials of the CLI (AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, +// AUTH0_API_TOKEN), the settings it reads (AUTH0_CLI_) and the variables it sets +// for plugins (AUTH0_PLUGIN_). The comparison ignores case. var reservedEnvPrefixes = []string{ - "AUTH0_PLUGIN_", - "AUTH0_CLI_", + "AUTH0_", } // Manifest is the parsed content of a plugin.json file. @@ -393,7 +394,7 @@ func (m *Manifest) semanticErrors() []string { for j, env := range command.Permissions.Env { reserved := slices.ContainsFunc(reservedEnvPrefixes, func(prefix string) bool { - return strings.HasPrefix(env.Name, prefix) + return strings.HasPrefix(strings.ToUpper(env.Name), prefix) }) if reserved { found.Addf("%s.name: %q uses a prefix reserved by the CLI (%s)", diff --git a/internal/plugins/manifest_test.go b/internal/plugins/manifest_test.go index 9540f78c2..319e97038 100644 --- a/internal/plugins/manifest_test.go +++ b/internal/plugins/manifest_test.go @@ -613,6 +613,40 @@ func TestParseManifest_ReservedFlagNames(t *testing.T) { } } +func TestParseManifest_ReservedEnvNames(t *testing.T) { + names := []string{ + "AUTH0_CLIENT_SECRET", "AUTH0_API_TOKEN", "AUTH0_CLIENT_ID", "AUTH0_DOMAIN", + "AUTH0_CLI_X", "AUTH0_PLUGIN_X", "AUTH0_ANYTHING", "auth0_client_secret", "Auth0_Domain", + } + + for _, name := range names { + t.Run("rejects "+name, func(t *testing.T) { + _, err := ParseManifest(mutateManifest(t, setValue(name, "commands", 0, "permissions", "env", 0, "name"))) + + requireValidationError(t, err, "commands[0].permissions.env[0].name:", name) + }) + + // The schema only accepts upper case names, so the check of the reserved + // prefixes on a manifest that skipped the schema is tried on its own. + t.Run("the check alone rejects "+name, func(t *testing.T) { + manifest := parseMutated(t, func(map[string]any) {}) + manifest.Commands[0].Permissions.Env[0].Name = name + + problems := manifest.semanticErrors() + + require.Len(t, problems, 1) + assert.Contains(t, problems[0], "commands[0].permissions.env[0].name:") + assert.Contains(t, problems[0], "reserved by the CLI") + }) + } + + t.Run("accepts a name that only looks like it", func(t *testing.T) { + manifest := parseMutated(t, setValue("AUTH0X_NAME", "commands", 0, "permissions", "env", 0, "name")) + + assert.Equal(t, "AUTH0X_NAME", manifest.Commands[0].Permissions.Env[0].Name) + }) +} + func TestParseManifest_MalformedJSON(t *testing.T) { tests := []struct { name string diff --git a/internal/plugins/runner/env.go b/internal/plugins/runner/env.go new file mode 100644 index 000000000..1186bc8c3 --- /dev/null +++ b/internal/plugins/runner/env.go @@ -0,0 +1,221 @@ +package runner + +import ( + "slices" + "strings" +) + +// pluginPrefix starts the name of every variable that the CLI sets for the +// plugin, and that nothing else may set. +const pluginPrefix = "AUTH0_PLUGIN_" + +// reservedPrefix starts the name of every variable that belongs to the CLI: its +// credentials, its settings and the variables it sets for the plugin. A command +// can never be given one of them from the user's environment, whatever it +// declared. +const reservedPrefix = "AUTH0_" + +// baseNames are the variables of the user's environment that every plugin gets, +// when they are set. Nothing else is passed on unless the command declared it. +var baseNames = []string{ + "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "no_proxy", +} + +// windowsNames are added to the base list on Windows, which a plugin needs to +// find its way around the system. They are untested until Windows support lands. +var windowsNames = []string{ + "SystemRoot", "TEMP", "TMP", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PATHEXT", "ComSpec", +} + +// BaseEnv returns the allowlisted variables that lookup has a value for, as +// NAME=value entries. A variable that is unset or empty is left out. The names +// that only exist on Windows are used only when goos is "windows". +func BaseEnv(lookup func(string) string, goos string) []string { + names := baseNames + if goos == "windows" { + names = slices.Concat(baseNames, windowsNames) + } + + return entries(names, lookup) +} + +// DeclaredEnv returns the variables that a command declared and that lookup has a +// value for, as NAME=value entries. A variable that is unset or empty is left +// out, and so is a name that cannot be the name of a variable. A name that starts +// with AUTH0_, in any case, is left out too, so that the credentials of the CLI +// never reach a plugin even from a manifest that asks for them. +func DeclaredEnv(names []string, lookup func(string) string) []string { + allowed := make([]string, 0, len(names)) + + for _, name := range names { + if !isReserved(name) { + allowed = append(allowed, name) + } + } + + return entries(allowed, lookup) +} + +// isReserved reports whether a name starts with AUTH0_, in any case. +func isReserved(name string) bool { + return strings.HasPrefix(strings.ToUpper(name), reservedPrefix) +} + +// entries returns NAME=value for each name that has a value. +func entries(names []string, lookup func(string) string) []string { + var result []string + + for _, name := range names { + if name == "" || strings.Contains(name, "=") { + continue + } + + if value := lookup(name); value != "" { + result = append(result, name+"="+value) + } + } + + return result +} + +// PluginVars turns the values of the CLI-owned flags into the variables the +// plugin reads. The name of a flag becomes AUTH0_PLUGIN_ followed by the name in +// upper case with every dash replaced by an underscore, so json-compact becomes +// AUTH0_PLUGIN_JSON_COMPACT. The result is sorted, so that it is the same on +// every run. +func PluginVars(owned map[string]string) []string { + result := make([]string, 0, len(owned)) + + for name, value := range owned { + result = append(result, pluginPrefix+strings.ToUpper(strings.ReplaceAll(name, "-", "_"))+"="+value) + } + + slices.Sort(result) + + return result +} + +// EnvSources are the places the environment of a plugin is made from. +type EnvSources struct { + // Base is the allowlisted part of the user's environment. + Base []string + + // Declared is the part of the user's environment that the command declared. + Declared []string + + // Proxy is the environment of the loopback proxy. + Proxy []string + + // Plugin is the variables made from the CLI-owned flags. + Plugin []string + + // ExemptLoopback makes NO_PROXY and no_proxy name the loopback addresses, so + // that a plugin that runs behind a proxy of the user does not send its calls to + // the proxy of the CLI through that proxy. + ExemptLoopback bool +} + +// Env combines the sources into one environment with each name once, sorted by +// name. The variables of the proxy and of the CLI-owned flags always win: a +// variable from Base or Declared that has the same name is dropped, and so is any +// one from those two that starts with AUTH0_, in any case, because those names +// belong to the CLI and its credentials. Manifest validation already keeps a +// command from declaring such names, and this is the second line of defence. +// +// When ExemptLoopback is set, the loopback addresses are added to the value of +// NO_PROXY and no_proxy, after a comma, when they are not in it already, and the +// variables are set when the environment has none. On Windows, where names are +// not case sensitive, there is one variable. +// +// On Windows names are compared without regard to case, as the system does. An +// entry without a name or without an equals sign is dropped. +func Env(goos string, sources EnvSources) []string { + key := func(name string) string { + if goos == "windows" { + return strings.ToUpper(name) + } + + return name + } + + merged := map[string]string{} + + add := func(list []string, trusted bool) { + for _, entry := range list { + name, _, found := strings.Cut(entry, "=") + if !found || name == "" { + continue + } + + if !trusted && isReserved(name) { + continue + } + + merged[key(name)] = entry + } + } + + add(sources.Base, false) + add(sources.Declared, false) + add(sources.Proxy, true) + add(sources.Plugin, true) + + if sources.ExemptLoopback { + names := []string{"NO_PROXY", "no_proxy"} + if goos == "windows" { + names = names[:1] + } + + for _, name := range names { + if entry, found := merged[key(name)]; found { + current, value, _ := strings.Cut(entry, "=") + merged[key(name)] = current + "=" + withLoopback(value) + } else { + merged[key(name)] = name + "=" + withLoopback("") + } + } + } + + result := make([]string, 0, len(merged)) + for _, entry := range merged { + result = append(result, entry) + } + + slices.Sort(result) + + return result +} + +// loopbackHosts are the addresses of the proxy of the CLI, which a plugin must +// reach directly. +var loopbackHosts = []string{"127.0.0.1", "localhost", "::1"} + +// withLoopback adds the loopback addresses that a comma separated list of hosts +// does not have, after the ones it has. +func withLoopback(list string) string { + var have []string + + for _, host := range strings.Split(list, ",") { + if host = strings.TrimSpace(host); host != "" { + have = append(have, strings.ToLower(host)) + } + } + + result := strings.TrimRight(strings.TrimSpace(list), ", ") + + for _, host := range loopbackHosts { + if slices.Contains(have, host) { + continue + } + + if result != "" { + result += "," + } + + result += host + } + + return result +} diff --git a/internal/plugins/runner/env_test.go b/internal/plugins/runner/env_test.go new file mode 100644 index 000000000..ca6c1996a --- /dev/null +++ b/internal/plugins/runner/env_test.go @@ -0,0 +1,350 @@ +package runner + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// lookupIn returns a lookup function over a fixed environment. +func lookupIn(env map[string]string) func(string) string { + return func(name string) string { return env[name] } +} + +// userEnv is an environment with everything on the allowlist, and with variables +// that a plugin must never see. +func userEnv() map[string]string { + env := map[string]string{ + "AUTH0_CLIENT_SECRET": "secret", + "AUTH0_API_TOKEN": "token", + "AUTH0_CLIENT_ID": "id", + "AUTH0_DOMAIN": "acme.auth0.com", + "auth0_client_secret": "secret", + "Auth0_Api_Token": "token", + "AUTH0_CLI_X": "x", + "AWS_PROFILE": "prod", + "SSH_AUTH_SOCK": "/tmp/agent", + "AUTH0_PLUGIN_TENANT": "forged", + } + + for _, name := range append(append([]string{}, baseNames...), windowsNames...) { + env[name] = "value-of-" + name + } + + return env +} + +func TestBaseEnv(t *testing.T) { + t.Parallel() + + common := []string{ + "PATH=value-of-PATH", "HOME=value-of-HOME", "LANG=value-of-LANG", "LC_ALL=value-of-LC_ALL", + "LC_CTYPE=value-of-LC_CTYPE", "TERM=value-of-TERM", "TMPDIR=value-of-TMPDIR", + "HTTP_PROXY=value-of-HTTP_PROXY", "HTTPS_PROXY=value-of-HTTPS_PROXY", "NO_PROXY=value-of-NO_PROXY", + "http_proxy=value-of-http_proxy", "https_proxy=value-of-https_proxy", "no_proxy=value-of-no_proxy", + } + + windows := []string{ + "SystemRoot=value-of-SystemRoot", "TEMP=value-of-TEMP", "TMP=value-of-TMP", + "USERPROFILE=value-of-USERPROFILE", "APPDATA=value-of-APPDATA", "LOCALAPPDATA=value-of-LOCALAPPDATA", + "PATHEXT=value-of-PATHEXT", "ComSpec=value-of-ComSpec", + } + + tests := []struct { + goos string + want []string + }{ + {"linux", common}, + {"darwin", common}, + {"windows", append(append([]string{}, common...), windows...)}, + } + + for _, test := range tests { + t.Run(test.goos, func(t *testing.T) { + t.Parallel() + + got := BaseEnv(lookupIn(userEnv()), test.goos) + + assert.ElementsMatch(t, test.want, got) + + for _, entry := range got { + assert.NotContains(t, entry, "AUTH0_", "nothing of the CLI may reach the plugin") + assert.NotContains(t, entry, "AWS_PROFILE") + assert.NotContains(t, entry, "SSH_AUTH_SOCK") + } + }) + } + + t.Run("leaves out what is not set", func(t *testing.T) { + t.Parallel() + + got := BaseEnv(lookupIn(map[string]string{"PATH": "/bin", "HOME": "", "AUTH0_CLIENT_SECRET": "s"}), "linux") + + assert.Equal(t, []string{"PATH=/bin"}, got) + }) + + t.Run("is empty for an empty environment", func(t *testing.T) { + t.Parallel() + + assert.Empty(t, BaseEnv(lookupIn(nil), "windows")) + }) +} + +func TestDeclaredEnv(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + names []string + want []string + }{ + {"names that are set", []string{"AWS_PROFILE", "SSH_AUTH_SOCK"}, []string{"AWS_PROFILE=prod", "SSH_AUTH_SOCK=/tmp/agent"}}, + {"names that are not set are left out", []string{"AWS_PROFILE", "MISSING"}, []string{"AWS_PROFILE=prod"}}, + {"nothing declared", nil, nil}, + {"names that cannot be variables are left out", []string{"", "A=B", "AWS_PROFILE"}, []string{"AWS_PROFILE=prod"}}, + {"only what is declared passes", []string{"AWS_PROFILE"}, []string{"AWS_PROFILE=prod"}}, + {"AUTH0_CLIENT_SECRET is left out", []string{"AUTH0_CLIENT_SECRET", "AWS_PROFILE"}, []string{"AWS_PROFILE=prod"}}, + {"AUTH0_API_TOKEN is left out", []string{"AUTH0_API_TOKEN"}, nil}, + {"AUTH0_CLIENT_ID is left out", []string{"AUTH0_CLIENT_ID"}, nil}, + {"AUTH0_DOMAIN is left out", []string{"AUTH0_DOMAIN"}, nil}, + {"AUTH0_CLI_X is left out", []string{"AUTH0_CLI_X"}, nil}, + {"AUTH0_PLUGIN_TENANT is left out", []string{"AUTH0_PLUGIN_TENANT"}, nil}, + {"the prefix is read without regard to case", []string{"auth0_client_secret", "Auth0_Api_Token"}, nil}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := DeclaredEnv(test.names, lookupIn(userEnv())) + + assert.Equal(t, test.want, got) + }) + } +} + +func TestPluginVars(t *testing.T) { + t.Parallel() + + t.Run("maps every owned flag", func(t *testing.T) { + t.Parallel() + + got := PluginVars(map[string]string{ + "tenant": "acme", + "debug": "false", + "no-input": "true", + "no-color": "false", + "agent-mode": "true", + "json": "true", + "json-compact": "false", + "csv": "false", + }) + + assert.Equal(t, []string{ + "AUTH0_PLUGIN_AGENT_MODE=true", + "AUTH0_PLUGIN_CSV=false", + "AUTH0_PLUGIN_DEBUG=false", + "AUTH0_PLUGIN_JSON=true", + "AUTH0_PLUGIN_JSON_COMPACT=false", + "AUTH0_PLUGIN_NO_COLOR=false", + "AUTH0_PLUGIN_NO_INPUT=true", + "AUTH0_PLUGIN_TENANT=acme", + }, got) + }) + + t.Run("keeps a value as it is", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"AUTH0_PLUGIN_TENANT=a b=c"}, PluginVars(map[string]string{"tenant": "a b=c"})) + }) + + t.Run("is empty without flags", func(t *testing.T) { + t.Parallel() + + assert.Empty(t, PluginVars(nil)) + }) +} + +func TestEnv(t *testing.T) { + t.Parallel() + + t.Run("combines the sources once per name and sorts them", func(t *testing.T) { + t.Parallel() + + got := Env("linux", EnvSources{ + Base: []string{"PATH=/bin", "HOME=/home/u"}, + Declared: []string{"AWS_PROFILE=prod"}, + Proxy: []string{"AUTH0_PLUGIN_DOMAIN=127.0.0.1:1"}, + Plugin: []string{"AUTH0_PLUGIN_TENANT=acme"}, + }) + + assert.Equal(t, []string{ + "AUTH0_PLUGIN_DOMAIN=127.0.0.1:1", + "AUTH0_PLUGIN_TENANT=acme", + "AWS_PROFILE=prod", + "HOME=/home/u", + "PATH=/bin", + }, got) + }) + + t.Run("a name that is declared and also in the base has one entry", func(t *testing.T) { + t.Parallel() + + got := Env("linux", EnvSources{Base: []string{"PATH=/bin"}, Declared: []string{"PATH=/other"}}) + + assert.Equal(t, []string{"PATH=/other"}, got) + }) + + t.Run("the variables of the CLI always win", func(t *testing.T) { + t.Parallel() + + got := Env("linux", EnvSources{ + Base: []string{"SSL_CERT_FILE=/base", "AUTH0_PLUGIN_TENANT=base"}, + Declared: []string{"SSL_CERT_FILE=/declared", "AUTH0_PLUGIN_TENANT=declared", "AUTH0_PLUGIN_MANAGEMENT_TOKEN=declared", "NODE_EXTRA_CA_CERTS=/declared"}, + Proxy: []string{"AUTH0_PLUGIN_MANAGEMENT_TOKEN=proxy", "SSL_CERT_FILE=/proxy", "NODE_EXTRA_CA_CERTS=/proxy"}, + Plugin: []string{"AUTH0_PLUGIN_TENANT=flag"}, + }) + + assert.Equal(t, []string{ + "AUTH0_PLUGIN_MANAGEMENT_TOKEN=proxy", + "AUTH0_PLUGIN_TENANT=flag", + "NODE_EXTRA_CA_CERTS=/proxy", + "SSL_CERT_FILE=/proxy", + }, got) + }) + + t.Run("a declared name with the prefix of the CLI is dropped even when the CLI sets nothing by that name", func(t *testing.T) { + t.Parallel() + + got := Env("linux", EnvSources{ + Base: []string{"auth0_plugin_x=base"}, + Declared: []string{"AUTH0_PLUGIN_JSON=forged", "AUTH0_PLUGIN_ANYTHING=forged", "KEPT=yes"}, + }) + + assert.Equal(t, []string{"KEPT=yes"}, got) + }) + + t.Run("a declared name that belongs to the CLI is dropped, whatever its case", func(t *testing.T) { + t.Parallel() + + got := Env("linux", EnvSources{ + Base: []string{"auth0_client_id=base"}, + Declared: []string{ + "AUTH0_CLIENT_SECRET=s", "AUTH0_API_TOKEN=t", "AUTH0_CLIENT_ID=i", "AUTH0_DOMAIN=d", + "AUTH0_CLI_X=x", "auth0_client_secret=s", "KEPT=yes", + }, + }) + + assert.Equal(t, []string{"KEPT=yes"}, got) + }) + + t.Run("names are compared without regard to case only on Windows", func(t *testing.T) { + t.Parallel() + + sources := EnvSources{Base: []string{"Path=/base"}, Declared: []string{"PATH=/declared"}} + + assert.Equal(t, []string{"PATH=/declared"}, Env("windows", sources)) + assert.Equal(t, []string{"PATH=/declared", "Path=/base"}, Env("linux", sources)) + }) + + t.Run("drops entries that have no name or no equals sign", func(t *testing.T) { + t.Parallel() + + got := Env("linux", EnvSources{Base: []string{"NOEQUALS", "=x", "", "OK=1"}, Plugin: []string{"BROKEN"}}) + + assert.Equal(t, []string{"OK=1"}, got) + }) + + t.Run("keeps an empty value and a value with an equals sign", func(t *testing.T) { + t.Parallel() + + got := Env("linux", EnvSources{Declared: []string{"EMPTY=", "A=b=c"}}) + + assert.Equal(t, []string{"A=b=c", "EMPTY="}, got) + }) + + t.Run("is empty without sources", func(t *testing.T) { + t.Parallel() + + assert.Empty(t, Env("linux", EnvSources{})) + }) +} + +func TestEnvExemptLoopback(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + goos string + sources EnvSources + want []string + }{ + { + name: "sets both variables when the user has none", + goos: "linux", + sources: EnvSources{Base: []string{"PATH=/bin"}}, + want: []string{"NO_PROXY=127.0.0.1,localhost,::1", "PATH=/bin", "no_proxy=127.0.0.1,localhost,::1"}, + }, + { + name: "adds to the value of the user", + goos: "linux", + sources: EnvSources{Base: []string{"NO_PROXY=internal.example.com,10.0.0.0/8", "no_proxy=other.example.com"}}, + want: []string{"NO_PROXY=internal.example.com,10.0.0.0/8,127.0.0.1,localhost,::1", "no_proxy=other.example.com,127.0.0.1,localhost,::1"}, + }, + { + name: "adds only what is missing, in any case, once", + goos: "linux", + sources: EnvSources{Base: []string{"NO_PROXY=LocalHost, ::1 ,a.com", "no_proxy=127.0.0.1,localhost,::1"}}, + want: []string{"NO_PROXY=LocalHost, ::1 ,a.com,127.0.0.1", "no_proxy=127.0.0.1,localhost,::1"}, + }, + { + name: "sets only the variable that is missing", + goos: "linux", + sources: EnvSources{Base: []string{"NO_PROXY=a.com"}}, + want: []string{"NO_PROXY=a.com,127.0.0.1,localhost,::1", "no_proxy=127.0.0.1,localhost,::1"}, + }, + { + name: "does not double a trailing comma or an empty value", + goos: "linux", + sources: EnvSources{Base: []string{"NO_PROXY=a.com,", "no_proxy="}}, + want: []string{"NO_PROXY=a.com,127.0.0.1,localhost,::1", "no_proxy=127.0.0.1,localhost,::1"}, + }, + { + name: "has one variable on Windows, whatever its case", + goos: "windows", + sources: EnvSources{Base: []string{"No_Proxy=a.com"}}, + want: []string{"No_Proxy=a.com,127.0.0.1,localhost,::1"}, + }, + { + name: "sets one variable on Windows when the user has none", + goos: "windows", + want: []string{"NO_PROXY=127.0.0.1,localhost,::1"}, + }, + { + name: "keeps one entry per name when several sources have it, in the sorted result", + goos: "linux", + sources: EnvSources{ + Base: []string{"NO_PROXY=base.example.com", "PATH=/bin"}, + Declared: []string{"NO_PROXY=declared.example.com", "ZED=1"}, + Plugin: []string{"AUTH0_PLUGIN_TENANT=acme"}, + }, + want: []string{ + "AUTH0_PLUGIN_TENANT=acme", + "NO_PROXY=declared.example.com,127.0.0.1,localhost,::1", + "PATH=/bin", + "ZED=1", + "no_proxy=127.0.0.1,localhost,::1", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + test.sources.ExemptLoopback = true + + assert.Equal(t, test.want, Env(test.goos, test.sources)) + }) + } +} diff --git a/internal/plugins/runner/exit_test.go b/internal/plugins/runner/exit_test.go new file mode 100644 index 000000000..b0c15962b --- /dev/null +++ b/internal/plugins/runner/exit_test.go @@ -0,0 +1,11 @@ +package runner + +import "syscall" + +// exitHelper ends a helper process without the exit hook of os.Exit. In a test +// binary built with coverage that hook warns on stderr that GOCOVERDIR is not set, +// because a helper runs with a minimal environment, and the warning would end up +// in the output that the tests compare. +func exitHelper(code int) { + syscall.Exit(code) +} diff --git a/internal/plugins/runner/proc_unix.go b/internal/plugins/runner/proc_unix.go new file mode 100644 index 000000000..43152bf5f --- /dev/null +++ b/internal/plugins/runner/proc_unix.go @@ -0,0 +1,66 @@ +//go:build !windows + +package runner + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +// prepare puts a plugin that is not interactive in a process group of its own, +// which is what lets the runner stop everything it starts. An interactive plugin +// stays in the group of the CLI, because a background group stops on its first +// read from the terminal. +func prepare(cmd *exec.Cmd, interactive bool) { + if !interactive { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } +} + +// terminate asks the plugin, and its group when it has one, to stop. +func terminate(cmd *exec.Cmd, interactive bool) { + send(cmd, interactive, syscall.SIGTERM) +} + +// kill ends the plugin, and its group when it has one, without a chance to clean +// up. +func kill(cmd *exec.Cmd, interactive bool) { + send(cmd, interactive, syscall.SIGKILL) +} + +// send delivers a signal to the group of the plugin, or to the plugin alone when +// it is interactive. A process that is already gone is not an error. +func send(cmd *exec.Cmd, interactive bool, signal syscall.Signal) { + if interactive { + _ = cmd.Process.Signal(signal) + + return + } + + _ = syscall.Kill(-cmd.Process.Pid, signal) +} + +// lingering reports whether the group of the plugin still has a process in it, +// after the plugin itself has gone. An interactive plugin has no group of its +// own, so nothing is left to wait for. +func lingering(cmd *exec.Cmd, interactive bool) bool { + if interactive { + return false + } + + err := syscall.Kill(-cmd.Process.Pid, 0) + + return err == nil || errors.Is(err, syscall.EPERM) +} + +// exitCode returns the exit code of a finished process, or 128 plus the number +// of the signal that ended it. +func exitCode(state *os.ProcessState) int { + if status, ok := state.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + + return state.ExitCode() +} diff --git a/internal/plugins/runner/proc_unix_test.go b/internal/plugins/runner/proc_unix_test.go new file mode 100644 index 000000000..0e9561ed2 --- /dev/null +++ b/internal/plugins/runner/proc_unix_test.go @@ -0,0 +1,113 @@ +//go:build !windows + +package runner + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + helperModes["groups"] = func([]string) { + fmt.Fprintf(os.Stdout, "%d %d", os.Getpid(), syscall.Getpgrp()) + } + helperModes["kill-self"] = func([]string) { + _ = syscall.Kill(os.Getpid(), syscall.SIGKILL) + + time.Sleep(time.Minute) + } +} + +func TestRun_ProcessGroup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + interactive bool + ownGroup bool + }{ + {"a command that is not interactive has a group of its own", false, true}, + {"an interactive command stays in the group of the CLI", true, false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + + spec := helper("groups") + spec.Stdout = &stdout + spec.Interactive = test.interactive + + require.NoError(t, Run(t.Context(), spec)) + + var pid, group int + + _, err := fmt.Sscanf(strings.TrimSpace(stdout.String()), "%d %d", &pid, &group) + require.NoError(t, err) + + if test.ownGroup { + assert.Equal(t, pid, group) + } else { + assert.Equal(t, syscall.Getpgrp(), group) + } + }) + } +} + +func TestRun_ASignalIsReportedAs128PlusTheSignal(t *testing.T) { + t.Parallel() + + err := Run(t.Context(), helper("kill-self")) + + var exit *ExitError + require.ErrorAs(t, err, &exit) + assert.Equal(t, 128+int(syscall.SIGKILL), exit.Code) +} + +func TestRun_StoppingAnInteractiveCommandLeavesWhatItStarted(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + go func() { + waitFor(t, filepath.Join(dir, "ready")) + cancel() + }() + + spec := helper("spawn", dir) + spec.Interactive = true + + // The grandchild is not ours to leave behind once the test is done. + t.Cleanup(func() { + if data, err := os.ReadFile(filepath.Join(dir, "ready")); err == nil { + if pid, err := strconv.Atoi(string(data)); err == nil { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } + }) + + var interrupted *InterruptedError + require.ErrorAs(t, Run(ctx, spec), &interrupted) + + // Only the plugin is signalled, never the group of the CLI, so what the plugin + // started keeps running. + before := beats(t, dir) + + assert.Eventually(t, func() bool { return beats(t, dir) != before }, 5*time.Second, 20*time.Millisecond) +} diff --git a/internal/plugins/runner/proc_windows.go b/internal/plugins/runner/proc_windows.go new file mode 100644 index 000000000..016664c3a --- /dev/null +++ b/internal/plugins/runner/proc_windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package runner + +import ( + "os" + "os/exec" +) + +// TODO(DXCDT-2610): Job Objects. Until then a plugin on Windows runs as a plain +// child: a timeout or Ctrl-C ends the plugin process only, and anything it +// started can outlive it. + +// prepare does nothing on Windows. +func prepare(*exec.Cmd, bool) {} + +// terminate ends the plugin, because Windows has no signal that asks a process to +// stop. +func terminate(cmd *exec.Cmd, _ bool) { + _ = cmd.Process.Kill() +} + +// kill ends the plugin. +func kill(cmd *exec.Cmd, _ bool) { + _ = cmd.Process.Kill() +} + +// lingering is always false on Windows, where only the plugin itself is tracked. +func lingering(*exec.Cmd, bool) bool { + return false +} + +// exitCode returns the exit code of a finished process. +func exitCode(state *os.ProcessState) int { + return state.ExitCode() +} diff --git a/internal/plugins/runner/runner.go b/internal/plugins/runner/runner.go new file mode 100644 index 000000000..6fc74afe8 --- /dev/null +++ b/internal/plugins/runner/runner.go @@ -0,0 +1,229 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "golang.org/x/term" +) + +// grace is how long a plugin gets between the request to stop and the kill. +// Tests make it shorter. +var grace = 5 * time.Second + +// pollInterval is how often the runner looks whether the process tree is gone +// during the grace period. +const pollInterval = 10 * time.Millisecond + +// isTerminal reports whether r is a terminal. +func isTerminal(r io.Reader) bool { + file, ok := r.(*os.File) + + return ok && term.IsTerminal(int(file.Fd())) +} + +// Spec describes one run of a plugin. +type Spec struct { + // Bin is the path of the executable. It must contain a path separator, so the + // plugin is never looked up on PATH. + Bin string + Args []string + + // Env is exactly the environment of the plugin. Nothing is inherited from the + // CLI, and an empty list means an empty environment. The operating system may + // still add the few variables it needs to start a process at all. + Env []string + + // Timeout stops the plugin when it has run this long. Zero means no limit. + Timeout time.Duration + + // Stdin, Stdout and Stderr are passed to the plugin. A *os.File is handed over + // as it is, so the plugin gets the descriptor itself, and anything else is + // copied through a pipe. A nil value is the null device. + // + // Unless Interactive is set, stdin that is a terminal is not passed on and the + // plugin gets the null device instead: a plugin in its own process group would + // otherwise stop on its first read from the terminal. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + + // Interactive is true for a command that talks to the user. It stays in the + // process group of the CLI, so the terminal keeps working, and on a timeout + // only the plugin process itself is stopped. + Interactive bool + + // The field terminal replaces the check whether Stdin is a terminal, for tests. + terminal func(io.Reader) bool +} + +// ExitError means the plugin ran and exited with a non-zero code. A plugin that +// a signal ended has the code 128 plus the number of the signal. +type ExitError struct { + Code int +} + +// Error returns the message of the error. +func (e *ExitError) Error() string { + return fmt.Sprintf("the plugin exited with code %d", e.Code) +} + +// TimeoutError means the plugin was stopped because it ran longer than its +// timeout. +type TimeoutError struct { + After time.Duration +} + +// Error returns the message of the error. +func (e *TimeoutError) Error() string { + return fmt.Sprintf("the plugin did not finish within %s and was stopped", e.After) +} + +// InterruptedError means the plugin was stopped because the context ended, for +// example after Ctrl-C. +type InterruptedError struct{} + +// Error returns the message of the error. +func (e *InterruptedError) Error() string { + return "the plugin was interrupted" +} + +// Run starts the plugin and waits for it. It returns nil when the plugin exits +// with code 0, an *ExitError for any other exit, a *TimeoutError when the +// timeout ended it, an *InterruptedError when ctx ended it, and another error +// when the plugin could not be started. +// +// When the timeout or ctx ends the run, the plugin and every process it started +// are asked to stop, and killed if they are still there after the grace period. +// On Unix a plugin that is not interactive is in a process group of its own, so +// that covers the whole tree. The kill goes to the group by its number, so a +// process that left the group is not reached, and in the instant between the +// plugin ending and the group being checked the number could in theory be reused. +func Run(ctx context.Context, spec Spec) error { + if spec.Bin == "" || filepath.Base(spec.Bin) == spec.Bin { + return fmt.Errorf("the plugin executable %q is not a path", spec.Bin) + } + + if ctx.Err() != nil { + return &InterruptedError{} + } + + cmd := exec.Command(spec.Bin, spec.Args...) + cmd.Env = spec.Env + cmd.Stdout = spec.Stdout + cmd.Stderr = spec.Stderr + cmd.WaitDelay = grace + + // A nil Env would make the command inherit the environment of the CLI. + if cmd.Env == nil { + cmd.Env = []string{} + } + + atTerminal := spec.terminal + if atTerminal == nil { + atTerminal = isTerminal + } + + if spec.Stdin != nil && (spec.Interactive || !atTerminal(spec.Stdin)) { + cmd.Stdin = spec.Stdin + } + + prepare(cmd, spec.Interactive) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("could not start the plugin: %w", err) + } + + // The result of Wait is stored before done is closed, so it is read after done. + var waitErr error + + done := make(chan struct{}) + + go func() { + waitErr = cmd.Wait() + + close(done) + }() + + var expired <-chan time.Time + + if spec.Timeout > 0 { + timer := time.NewTimer(spec.Timeout) + defer timer.Stop() + + expired = timer.C + } + + select { + case <-done: + return result(waitErr) + case <-expired: + stop(cmd, spec.Interactive, done) + + return &TimeoutError{After: spec.Timeout} + case <-ctx.Done(): + stop(cmd, spec.Interactive, done) + + return &InterruptedError{} + } +} + +// result turns what Wait returned into the error of Run. +func result(err error) error { + var exit *exec.ExitError + + switch { + case err == nil: + return nil + case errors.As(err, &exit): + return &ExitError{Code: exitCode(exit.ProcessState)} + case errors.Is(err, exec.ErrWaitDelay): + // Wait reports this only for a plugin that exited with code 0, and something + // it started kept its output open. + return nil + default: + return err + } +} + +// stop asks the plugin to stop, and kills it when it and what it started are not +// gone when the grace period ends. It returns once the plugin has been waited +// for. +func stop(cmd *exec.Cmd, interactive bool, done <-chan struct{}) { + terminate(cmd, interactive) + + deadline := time.NewTimer(grace) + defer deadline.Stop() + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + exited := false + + for { + if exited && !lingering(cmd, interactive) { + return + } + + select { + case <-done: + exited = true + done = nil + case <-deadline.C: + kill(cmd, interactive) + + if !exited { + <-done + } + + return + case <-ticker.C: + } + } +} diff --git a/internal/plugins/runner/runner_test.go b/internal/plugins/runner/runner_test.go new file mode 100644 index 000000000..3c3cd0e9f --- /dev/null +++ b/internal/plugins/runner/runner_test.go @@ -0,0 +1,475 @@ +package runner + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helperSentinel as the first argument also makes the test binary a helper +// process, for runs with an empty environment. +const helperSentinel = "--helper" + +// helperModes are what the test binary can do when it runs as a plugin. Other +// test files add the ones that only work on some systems. +var helperModes = map[string]func(args []string){ + "exit": func(args []string) { + code, _ := strconv.Atoi(args[0]) + exitHelper(code) + }, + "echo": func([]string) { + fmt.Fprintln(os.Stdout, "to stdout") + fmt.Fprintln(os.Stderr, "to stderr") + }, + "env": func([]string) { + for _, entry := range os.Environ() { + fmt.Fprintln(os.Stdout, entry) + } + }, + "stdin": func([]string) { + data, _ := io.ReadAll(os.Stdin) + fmt.Fprintf(os.Stdout, "stdin=%d", len(data)) + }, + "sleep": func([]string) { + time.Sleep(time.Minute) + }, + "pause": func([]string) { + time.Sleep(time.Second) + }, + + // Beat [ignore-term] writes the first beat to /beat and its process + // number to /ready, then rewrites /beat every few milliseconds with + // a counter. The first beat comes before ready, so a test that stops the + // process as soon as it is ready always finds a beat. It ends by itself after a + // while, in case a test leaves it behind. + "beat": func(args []string) { + if len(args) > 1 && args[1] == "ignore-term" { + signal.Ignore(syscall.SIGTERM) + } + + _ = os.WriteFile(filepath.Join(args[0], "beat"), []byte("0"), 0o600) + _ = os.WriteFile(filepath.Join(args[0], "ready"), []byte(strconv.Itoa(os.Getpid())), 0o600) + + for i := 0; i < 1000; i++ { + if err := os.WriteFile(filepath.Join(args[0], "beat"), []byte(strconv.Itoa(i)), 0o600); err != nil { + return + } + + time.Sleep(20 * time.Millisecond) + } + }, + + // Spawn [ignore-term] starts a grandchild that beats, and waits to be + // stopped itself. + "spawn": func(args []string) { + child := exec.Command(executable(), append([]string{"beat"}, args...)...) + child.Env = []string{"GO_WANT_HELPER=1"} + + if err := child.Start(); err != nil { + fmt.Fprintln(os.Stderr, err) + exitHelper(3) + } + + time.Sleep(time.Minute) + }, + + // Daemon exits at once and leaves a process behind that keeps its output + // open. + "daemon": func([]string) { + child := exec.Command(executable(), "pause") + child.Env = []string{"GO_WANT_HELPER=1"} + child.Stdout = os.Stdout + child.Stderr = os.Stderr + + if err := child.Start(); err != nil { + exitHelper(3) + } + }, +} + +func executable() string { + path, err := os.Executable() + if err != nil { + panic(err) + } + + return path +} + +func TestMain(m *testing.M) { + args := os.Args[1:] + + if len(args) > 0 && args[0] == helperSentinel { + args = args[1:] + } else if os.Getenv("GO_WANT_HELPER") != "1" { + grace = 300 * time.Millisecond + + os.Exit(m.Run()) + } + + if mode, ok := helperModes[args[0]]; ok { + mode(args[1:]) + } + + exitHelper(0) +} + +// helper returns a spec that runs a mode of the test binary with a minimal +// environment. +func helper(args ...string) Spec { + return Spec{Bin: executable(), Args: args, Env: []string{"GO_WANT_HELPER=1"}} +} + +// waitFor waits until the file exists. +func waitFor(t *testing.T, path string) { + t.Helper() + + require.Eventually(t, func() bool { + _, err := os.Stat(path) + + return err == nil + }, 30*time.Second, 5*time.Millisecond, "%s never appeared", path) +} + +// beats returns what the process that beats last wrote. +func beats(t *testing.T, dir string) string { + t.Helper() + + data, err := os.ReadFile(filepath.Join(dir, "beat")) + require.NoError(t, err) + + return string(data) +} + +// requireStopped requires that the process that beats in dir has stopped +// beating, and was beating before. +func requireStopped(t *testing.T, dir string) { + t.Helper() + + before := beats(t, dir) + + time.Sleep(300 * time.Millisecond) + + assert.Equal(t, before, beats(t, dir), "the process is still running") +} + +func TestRun_Results(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want int + }{ + {"exit 0 is success", []string{"exit", "0"}, 0}, + {"exit 7 is an exit error", []string{"exit", "7"}, 7}, + {"exit 1 is an exit error", []string{"exit", "1"}, 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := Run(t.Context(), helper(test.args...)) + + if test.want == 0 { + require.NoError(t, err) + + return + } + + var exit *ExitError + require.ErrorAs(t, err, &exit) + assert.Equal(t, test.want, exit.Code) + }) + } +} + +func TestRun_PassesOutputThrough(t *testing.T) { + t.Parallel() + + stdout, err := os.Create(filepath.Join(t.TempDir(), "stdout")) + require.NoError(t, err) + + defer stdout.Close() + + var stderr bytes.Buffer + + spec := helper("echo") + spec.Stdout = stdout + spec.Stderr = &stderr + + require.NoError(t, Run(t.Context(), spec)) + + written, err := os.ReadFile(stdout.Name()) + require.NoError(t, err) + + assert.Equal(t, "to stdout", strings.TrimSpace(string(written))) + assert.Equal(t, "to stderr", strings.TrimSpace(stderr.String())) +} + +func TestRun_UsesOnlyTheEnvironmentOfTheSpec(t *testing.T) { + t.Setenv("AUTH0_CLIENT_SECRET", "leaked") + t.Setenv("AWS_PROFILE", "leaked") + + tests := []struct { + name string + spec Spec + want []string + }{ + {"a list", Spec{Bin: executable(), Args: []string{"env"}, Env: []string{"GO_WANT_HELPER=1", "KEPT=yes"}}, []string{"GO_WANT_HELPER=1", "KEPT=yes"}}, + {"an empty list", Spec{Bin: executable(), Args: []string{helperSentinel, "env"}, Env: []string{}}, nil}, + {"no list", Spec{Bin: executable(), Args: []string{helperSentinel, "env"}}, nil}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout bytes.Buffer + + spec := test.spec + spec.Stdout = &stdout + + require.NoError(t, Run(t.Context(), spec)) + + got := strings.Fields(stdout.String()) + + assert.NotContains(t, stdout.String(), "leaked") + + for _, entry := range test.want { + assert.Contains(t, got, entry) + } + + // The system may add variables it needs to start a process, but none + // of the CLI. + assert.LessOrEqual(t, len(got), len(test.want)+4, "unexpected environment: %v", got) + }) + } +} + +func TestRun_Stdin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + terminal bool + interactive bool + want string + }{ + {"is passed on when it is not a terminal", false, false, "stdin=5"}, + {"is closed for a command that is not interactive when it is a terminal", true, false, "stdin=0"}, + {"is passed on to an interactive command when it is a terminal", true, true, "stdin=5"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + + spec := helper("stdin") + spec.Stdin = strings.NewReader("hello") + spec.Stdout = &stdout + spec.Interactive = test.interactive + spec.terminal = func(io.Reader) bool { return test.terminal } + + require.NoError(t, Run(t.Context(), spec)) + assert.Equal(t, test.want, stdout.String()) + }) + } + + t.Run("is the null device when there is none", func(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + + spec := helper("stdin") + spec.Stdout = &stdout + + require.NoError(t, Run(t.Context(), spec)) + assert.Equal(t, "stdin=0", stdout.String()) + }) +} + +func TestIsTerminal(t *testing.T) { + t.Parallel() + + assert.False(t, isTerminal(strings.NewReader("")), "a reader is not a terminal") + + file, err := os.Create(filepath.Join(t.TempDir(), "file")) + require.NoError(t, err) + + defer file.Close() + + assert.False(t, isTerminal(file), "a file is not a terminal") +} + +func TestRun_DoesNotStart(t *testing.T) { + t.Parallel() + + t.Run("a missing executable", func(t *testing.T) { + t.Parallel() + + spec := helper("exit", "0") + spec.Bin = filepath.Join(t.TempDir(), "missing") + + err := Run(t.Context(), spec) + + require.ErrorIs(t, err, fs.ErrNotExist) + + var exit *ExitError + assert.False(t, errors.As(err, &exit)) + }) + + t.Run("a name that would be looked up on PATH", func(t *testing.T) { + t.Parallel() + + spec := helper("exit", "0") + spec.Bin = filepath.Base(spec.Bin) + + assert.ErrorContains(t, Run(t.Context(), spec), "is not a path") + }) + + t.Run("an empty name", func(t *testing.T) { + t.Parallel() + + assert.ErrorContains(t, Run(t.Context(), Spec{}), "is not a path") + }) + + t.Run("a context that has ended", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + // The executable does not exist: the context is looked at first. + spec := helper("exit", "0") + spec.Bin = filepath.Join(t.TempDir(), "missing") + + var interrupted *InterruptedError + assert.ErrorAs(t, Run(ctx, spec), &interrupted) + }) +} + +func TestRun_Timeout(t *testing.T) { + t.Parallel() + + spec := helper("sleep") + spec.Timeout = 50 * time.Millisecond + + err := Run(t.Context(), spec) + + var timeout *TimeoutError + require.ErrorAs(t, err, &timeout) + assert.Equal(t, 50*time.Millisecond, timeout.After) +} + +func TestRun_TimeoutStopsTheWholeTree(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("the process tree is only stopped on Unix until DXCDT-2610") + } + + dir := t.TempDir() + + // The limit is long because a process can take a second to start when the + // tests run with the race detector. + spec := helper("spawn", dir) + spec.Timeout = 3 * time.Second + + var timeout *TimeoutError + require.ErrorAs(t, Run(t.Context(), spec), &timeout) + require.FileExists(t, filepath.Join(dir, "ready"), "the grandchild did not start before the timeout") + + requireStopped(t, dir) +} + +func TestRun_CancelStopsTheWholeTree(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("the process tree is only stopped on Unix until DXCDT-2610") + } + + dir := t.TempDir() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + go func() { + waitFor(t, filepath.Join(dir, "ready")) + cancel() + }() + + var interrupted *InterruptedError + require.ErrorAs(t, Run(ctx, helper("spawn", dir)), &interrupted) + + requireStopped(t, dir) +} + +func TestRun_KillsWhatIgnoresTheRequestToStop(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("a request to stop is a kill on Windows") + } + + // Both start a process that beats and ignores the request to stop. The first + // is the plugin itself, the second one it started. + for name, args := range map[string][]string{ + "the plugin": {"beat"}, + "a process the plugin started": {"spawn"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + var cancelled time.Time + + go func() { + waitFor(t, filepath.Join(dir, "ready")) + + cancelled = time.Now() + + cancel() + }() + + var interrupted *InterruptedError + require.ErrorAs(t, Run(ctx, helper(append(args, dir, "ignore-term")...)), &interrupted) + + assert.GreaterOrEqual(t, time.Since(cancelled), grace, "it was killed before the grace period ended") + requireStopped(t, dir) + }) + } +} + +func TestRun_SucceedsWhenAProcessKeepsTheOutputOpen(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + + spec := helper("daemon") + spec.Stdout = &stdout + + assert.NoError(t, Run(t.Context(), spec)) +} diff --git a/internal/plugins/runner/scan.go b/internal/plugins/runner/scan.go new file mode 100644 index 000000000..583012dc2 --- /dev/null +++ b/internal/plugins/runner/scan.go @@ -0,0 +1,169 @@ +// Package runner starts an installed plugin: it separates the flags the CLI owns +// from the arguments of the plugin, builds the small environment the plugin +// gets, and runs the executable with a timeout, stopping the whole process tree +// when the time is up or the caller gives up. +package runner + +import ( + "fmt" + "strconv" + "strings" +) + +// OwnedFlag is a flag that belongs to the CLI and is removed from the arguments +// before they reach the plugin. +type OwnedFlag struct { + // Name is the long name without the leading dashes, for example "tenant". + Name string + + // Short is the one letter name without the leading dash, or empty. + Short string + + // Bool is true for a flag that takes no value, or the optional "=true" or + // "=false". A bool flag never takes the next argument as its value. + Bool bool +} + +// Scanned is the result of Scan. +type Scanned struct { + // Values holds the value of each owned flag that was found, by flag name. The + // value of a bool flag is "true" or "false". A flag that was repeated holds the + // last value. + Values map[string]string + + // Rest is every argument that was not removed, in the original order. + Rest []string + + // Help is true when -h or --help was found. + Help bool +} + +// helpName is the long name of the help flag, which Scan always owns. +const helpName = "help" + +// Scan removes the owned flags from args, and leaves everything else untouched. +// Unknown flags, and the arguments that follow them, stay in Rest in their +// original order. It never reads a flag that belongs to the plugin. +// +// The accepted forms are --name, --name=value, --name value for a flag that takes +// a value, -x, -x=value and -x value, and for a bool flag --name or --name=true +// and --name=false. The next argument is taken as the value of a flag that takes +// one even when it starts with a dash, as cobra does, except for a bare "--". A +// short flag is only recognized on its own, so a cluster such as -ab or a value +// glued to the flag such as -xvalue stays in Rest. +// +// A bare "--" stops the scan: it and every argument after it stay in Rest +// unchanged, so the plugin sees them exactly as typed. -h and --help are always +// owned and set Help. +// +// A flag that takes a value but has none, and a bool flag whose value is not a +// bool, are errors. +func Scan(args []string, owned []OwnedFlag) (Scanned, error) { + result := Scanned{Values: map[string]string{}, Rest: make([]string, 0, len(args))} + + for i := 0; i < len(args); i++ { + arg := args[i] + + if arg == "--" { + result.Rest = append(result.Rest, args[i:]...) + + break + } + + name, value, hasValue, ok := split(arg) + if !ok { + result.Rest = append(result.Rest, arg) + + continue + } + + flag, isHelp, found := lookup(owned, name, strings.HasPrefix(arg, "--")) + if !found { + result.Rest = append(result.Rest, arg) + + continue + } + + if flag.Bool || isHelp { + parsed := "true" + + if hasValue { + b, err := strconv.ParseBool(value) + if err != nil { + return Scanned{}, fmt.Errorf("invalid value %q for %s: use true or false", value, dashed(arg)) + } + + parsed = strconv.FormatBool(b) + } + + if isHelp { + result.Help = parsed == "true" + } else { + result.Values[flag.Name] = parsed + } + + continue + } + + if !hasValue { + if i+1 >= len(args) || args[i+1] == "--" { + return Scanned{}, fmt.Errorf("the flag %s needs a value", dashed(arg)) + } + + i++ + value = args[i] + } + + result.Values[flag.Name] = value + } + + return result, nil +} + +// split breaks an argument that looks like a flag into its name and optional +// value, and reports false for anything else, such as a value, a lone dash or +// "--". +func split(arg string) (name, value string, hasValue, ok bool) { + switch { + case strings.HasPrefix(arg, "--"): + arg = arg[2:] + case strings.HasPrefix(arg, "-"): + arg = arg[1:] + default: + return "", "", false, false + } + + if arg == "" { + return "", "", false, false + } + + name, value, hasValue = strings.Cut(arg, "=") + + return name, value, hasValue, name != "" +} + +// lookup finds the owned flag that a name refers to. The help flag is built in. +func lookup(owned []OwnedFlag, name string, long bool) (flag OwnedFlag, isHelp, found bool) { + if (long && name == helpName) || (!long && name == "h") { + return OwnedFlag{}, true, true + } + + for _, candidate := range owned { + if long && candidate.Name == name { + return candidate, false, true + } + + if !long && candidate.Short != "" && candidate.Short == name { + return candidate, false, true + } + } + + return OwnedFlag{}, false, false +} + +// dashed returns the flag of an argument as it was typed, without a value. +func dashed(arg string) string { + name, _, _ := strings.Cut(arg, "=") + + return name +} diff --git a/internal/plugins/runner/scan_test.go b/internal/plugins/runner/scan_test.go new file mode 100644 index 000000000..a385548cc --- /dev/null +++ b/internal/plugins/runner/scan_test.go @@ -0,0 +1,119 @@ +package runner + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// cliFlags are the flags the CLI owns, as the pre-scan sees them. +var cliFlags = []OwnedFlag{ + {Name: "tenant", Short: "t"}, + {Name: "debug", Bool: true}, + {Name: "no-input", Bool: true}, + {Name: "json", Bool: true}, + {Name: "json-compact", Bool: true}, + {Name: "csv", Bool: true}, +} + +func TestScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + values map[string]string + rest []string + help bool + }{ + {"no arguments", nil, map[string]string{}, []string{}, false}, + {"nothing owned", []string{"scan", "--validators", "a"}, map[string]string{}, []string{"scan", "--validators", "a"}, false}, + {"a long flag with a separate value", []string{"--tenant", "acme", "scan"}, map[string]string{"tenant": "acme"}, []string{"scan"}, false}, + {"a long flag with an equals value", []string{"--tenant=acme", "scan"}, map[string]string{"tenant": "acme"}, []string{"scan"}, false}, + {"a short flag", []string{"-t", "acme", "scan"}, map[string]string{"tenant": "acme"}, []string{"scan"}, false}, + {"a short flag with an equals value", []string{"-t=acme"}, map[string]string{"tenant": "acme"}, []string{}, false}, + {"a value that starts with a dash", []string{"--tenant", "-x"}, map[string]string{"tenant": "-x"}, []string{}, false}, + {"an empty value after an equals sign", []string{"--tenant="}, map[string]string{"tenant": ""}, []string{}, false}, + {"an empty value as a separate argument", []string{"--tenant", "", "scan"}, map[string]string{"tenant": ""}, []string{"scan"}, false}, + {"a bool flag", []string{"--json", "scan"}, map[string]string{"json": "true"}, []string{"scan"}, false}, + {"a bool flag never takes the next argument", []string{"--debug", "false", "scan"}, map[string]string{"debug": "true"}, []string{"false", "scan"}, false}, + {"a bool flag set to true", []string{"--debug=true"}, map[string]string{"debug": "true"}, []string{}, false}, + {"a bool flag set to false", []string{"--debug=false"}, map[string]string{"debug": "false"}, []string{}, false}, + {"a bool value in another spelling", []string{"--debug=1", "--csv=F"}, map[string]string{"debug": "true", "csv": "false"}, []string{}, false}, + {"a flag that repeats takes the last value", []string{"--tenant", "a", "--tenant=b", "--json", "--json=false"}, map[string]string{"tenant": "b", "json": "false"}, []string{}, false}, + {"flags between the arguments of the plugin", []string{"scan", "--tenant", "acme", "--validators", "a", "--json", "b"}, map[string]string{"tenant": "acme", "json": "true"}, []string{"scan", "--validators", "a", "b"}, false}, + {"an unknown flag and its value are untouched", []string{"--unknown", "--tenant", "acme", "value"}, map[string]string{"tenant": "acme"}, []string{"--unknown", "value"}, false}, + {"a flag of the plugin that has an owned name as a prefix", []string{"--tenants", "a", "--json-compacts", "--debugger"}, map[string]string{}, []string{"--tenants", "a", "--json-compacts", "--debugger"}, false}, + {"a short flag that is not owned", []string{"-v", "-x=1"}, map[string]string{}, []string{"-v", "-x=1"}, false}, + {"a short flag in a cluster or with a glued value", []string{"-tacme", "-vt"}, map[string]string{}, []string{"-tacme", "-vt"}, false}, + {"a lone dash", []string{"-", "scan"}, map[string]string{}, []string{"-", "scan"}, false}, + {"a lone equals form", []string{"--=x", "-=x"}, map[string]string{}, []string{"--=x", "-=x"}, false}, + {"a long flag with one dash is not the long flag", []string{"-tenant", "acme"}, map[string]string{}, []string{"-tenant", "acme"}, false}, + {"a short name with two dashes is not the short flag", []string{"--t", "acme"}, map[string]string{}, []string{"--t", "acme"}, false}, + {"a double dash stops the scan and is kept", []string{"scan", "--", "--tenant", "acme"}, map[string]string{}, []string{"scan", "--", "--tenant", "acme"}, false}, + {"flags before a double dash still count", []string{"--tenant", "acme", "--", "--json", "-h"}, map[string]string{"tenant": "acme"}, []string{"--", "--json", "-h"}, false}, + {"a second double dash is kept as is", []string{"--", "--", "x"}, map[string]string{}, []string{"--", "--", "x"}, false}, + {"a flag that looks owned after a double dash", []string{"--", "--debug=false", "-t", "x"}, map[string]string{}, []string{"--", "--debug=false", "-t", "x"}, false}, + {"a short help flag", []string{"-h"}, map[string]string{}, []string{}, true}, + {"a long help flag", []string{"scan", "--help"}, map[string]string{}, []string{"scan"}, true}, + {"help set to false", []string{"--help=false"}, map[string]string{}, []string{}, false}, + {"help after a double dash is the plugin's", []string{"--", "-h", "--help"}, map[string]string{}, []string{"--", "-h", "--help"}, false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + original := append([]string(nil), test.args...) + + scanned, err := Scan(test.args, cliFlags) + + require.NoError(t, err) + assert.Equal(t, test.values, scanned.Values) + assert.Equal(t, test.rest, scanned.Rest) + assert.Equal(t, test.help, scanned.Help) + assert.Equal(t, original, append([]string(nil), test.args...), "the arguments were changed") + }) + } +} + +func TestScan_Errors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want string + }{ + {"a flag with no value at the end", []string{"scan", "--tenant"}, "the flag --tenant needs a value"}, + {"a short flag with no value at the end", []string{"-t"}, "the flag -t needs a value"}, + {"a flag whose value would be the double dash", []string{"--tenant", "--", "x"}, "the flag --tenant needs a value"}, + {"a bool flag with a value that is not a bool", []string{"--json=maybe"}, `invalid value "maybe" for --json`}, + {"a bool flag with an empty value", []string{"--debug="}, `invalid value "" for --debug`}, + {"help with a value that is not a bool", []string{"--help=please"}, `invalid value "please" for --help`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + scanned, err := Scan(test.args, cliFlags) + + require.Error(t, err) + assert.Contains(t, err.Error(), test.want) + assert.Empty(t, scanned.Rest) + }) + } +} + +func TestScan_TheHelpFlagBelongsToTheCLI(t *testing.T) { + t.Parallel() + + // An owned flag cannot take over -h or --help. + scanned, err := Scan([]string{"-h", "--help"}, []OwnedFlag{{Name: "help", Short: "h"}, {Name: "other", Short: "h"}}) + + require.NoError(t, err) + assert.True(t, scanned.Help) + assert.Empty(t, scanned.Values) +} diff --git a/internal/plugins/store/verify.go b/internal/plugins/store/verify.go index 29a387345..79c6b414c 100644 --- a/internal/plugins/store/verify.go +++ b/internal/plugins/store/verify.go @@ -143,6 +143,28 @@ func (s *Store) readManifest(id string, e *entry, version string) (*plugins.Mani return manifest, nil } +// Manifest returns the parsed plugin.json of the active version of a plugin, +// after checking it against the hash that the lockfile recorded at install time. +// +// It is for help and for registering commands only, and its result must never be +// used to decide whether a plugin may run: that is Verify. Manifest does not hash +// the executable, does not look at the other entries of the version directory, +// and does not check who can write to the directories above it, so a plugin whose +// files were replaced can still be listed here. +func (s *Store) Manifest(id string) (*plugins.Manifest, error) { + e, err := s.lookup(id) + if err != nil { + return nil, err + } + + manifest, err := s.readManifest(id, e, e.Active) + if err != nil { + return nil, &VerificationError{ID: id, Reason: err.Error()} + } + + return manifest, nil +} + // verifyVersion is Verify for one version of an entry that came out of a // validated lockfile. func (s *Store) verifyVersion(id string, e *entry, version string) (Verified, error) { diff --git a/internal/plugins/store/verify_test.go b/internal/plugins/store/verify_test.go index 1b97d202c..e002e6c7f 100644 --- a/internal/plugins/store/verify_test.go +++ b/internal/plugins/store/verify_test.go @@ -252,3 +252,138 @@ func TestVerify(t *testing.T) { assert.Equal(t, "2.0.0", verified.Version) }) } + +func TestManifest(t *testing.T) { + install := func(t *testing.T) *fixture { + t.Helper() + + f := newFixture(t) + f.install(f.index(f.release("1.0.0")), "1.0.0") + + return f + } + + t.Run("returns the manifest of the active version", func(t *testing.T) { + f := install(t) + + manifest, err := f.store.Manifest(testID) + + require.NoError(t, err) + require.NotNil(t, manifest) + assert.Equal(t, testID, manifest.ID) + assert.Equal(t, "1.0.0", manifest.Version) + }) + + t.Run("reads the active version and not another one", func(t *testing.T) { + f := newFixture(t) + index := f.index(f.release("1.0.0"), f.release("2.0.0")) + f.install(index, "1.0.0") + f.install(index, "2.0.0") + + manifest, err := f.store.Manifest(testID) + + require.NoError(t, err) + assert.Equal(t, "2.0.0", manifest.Version) + }) + + t.Run("does not hash the executable", func(t *testing.T) { + f := install(t) + + require.NoError(t, os.WriteFile(filepath.Join(f.versionDir("1.0.0"), testBin), []byte("tampered"), 0o755)) + + _, verifyErr := f.store.Verify(testID) + manifest, err := f.store.Manifest(testID) + + require.Error(t, verifyErr) + require.NoError(t, err) + assert.Equal(t, testID, manifest.ID) + }) + + t.Run("fails for a plugin that is not installed", func(t *testing.T) { + f := install(t) + + _, err := f.store.Manifest("auth0/other") + + var notInstalled *store.NotInstalledError + require.ErrorAs(t, err, ¬Installed) + }) + + t.Run("fails for an ID that is not a path-safe plugin ID", func(t *testing.T) { + f := install(t) + + _, err := f.store.Manifest("../auth0/checkmate") + + assert.Error(t, err) + }) + + t.Run("fails for a damaged lockfile", func(t *testing.T) { + f := install(t) + + require.NoError(t, os.WriteFile(f.lockfilePath(), []byte("{"), 0o600)) + + _, err := f.store.Manifest(testID) + + assert.ErrorIs(t, err, store.ErrLockfileDamaged) + }) + + refused := []struct { + name string + change func(t *testing.T, f *fixture) + want string + }{ + {"a manifest that was modified after it was recorded", func(t *testing.T, f *fixture) { + path := filepath.Join(f.versionDir("1.0.0"), "plugin.json") + content, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(content, ' '), 0o600)) + }, "the hash of plugin.json changed"}, + {"a missing manifest", func(t *testing.T, f *fixture) { + require.NoError(t, os.Remove(filepath.Join(f.versionDir("1.0.0"), "plugin.json"))) + }, "plugin.json"}, + {"a manifest that is not valid, even when its hash was recorded", func(t *testing.T, f *fixture) { + f.rewriteManifest("1.0.0", func(m map[string]any) { delete(m, "description") }) + }, "is not valid"}, + {"a manifest that is for another version, even when its hash was recorded", func(t *testing.T, f *fixture) { + f.rewriteManifest("1.0.0", func(m map[string]any) { m["version"] = "9.9.9" }) + }, "is for"}, + {"a manifest that is for another plugin, even when its hash was recorded", func(t *testing.T, f *fixture) { + f.rewriteManifest("1.0.0", func(m map[string]any) { m["id"] = "auth0/other" }) + }, "is for"}, + } + + for _, test := range refused { + t.Run("refuses "+test.name, func(t *testing.T) { + f := install(t) + + test.change(t, f) + + manifest, err := f.store.Manifest(testID) + + var verification *store.VerificationError + require.ErrorAs(t, err, &verification) + assert.Contains(t, err.Error(), test.want) + assert.Nil(t, manifest) + }) + } + + t.Run("refuses a manifest that was swapped for a link", func(t *testing.T) { + skipOnWindows(t) + + f := install(t) + + path := filepath.Join(f.versionDir("1.0.0"), "plugin.json") + content, err := os.ReadFile(path) + require.NoError(t, err) + + elsewhere := filepath.Join(t.TempDir(), "plugin.json") + require.NoError(t, os.WriteFile(elsewhere, content, 0o600)) + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Symlink(elsewhere, path)) + + _, err = f.store.Manifest(testID) + + var verification *store.VerificationError + require.ErrorAs(t, err, &verification) + assert.Contains(t, err.Error(), "is not a regular file") + }) +}