From 10645f44a35e5ff7d104680a4b8d88873f810f26 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Sat, 24 Oct 2026 19:51:13 +0530 Subject: [PATCH] feat(plugins): add plugin dev link and validate - add `auth0 plugin dev validate` to check a plugin.json offline and list every problem with its path - add `auth0 plugin dev link` and `unlink` to run a local build as `auth0 ` in dev mode - run a linked plugin through the same proxy, scope enforcement and approval as an installed one, and ask for approval again when its permissions change - skip only the file hashes, the lockfile and the revocation list for a linked plugin, and print one line on stderr for every linked run - keep linked runs out of telemetry and mark linked plugins in `plugin list` and `plugin info` - store the links in `dev-links.json`, read only in dev mode and ignored when it is damaged - share the plugin ID check between the store and `dev validate` --- internal/cli/plugin.go | 184 ++++- internal/cli/plugin_dev.go | 482 ++++++++++++ internal/cli/plugin_dev_test.go | 948 +++++++++++++++++++++++ internal/cli/plugin_run.go | 162 +++- internal/cli/plugin_run_test.go | 594 +++++++++++++- internal/cli/plugin_telemetry.go | 8 +- internal/cli/plugin_telemetry_test.go | 65 ++ internal/cli/plugin_test.go | 269 ++++++- internal/display/plugin_dev.go | 80 ++ internal/display/plugin_dev_test.go | 92 +++ internal/display/plugins.go | 38 +- internal/display/plugins_test.go | 49 +- internal/plugins/devlink/devlink.go | 339 ++++++++ internal/plugins/devlink/devlink_test.go | 505 ++++++++++++ internal/plugins/store/store.go | 24 +- 15 files changed, 3764 insertions(+), 75 deletions(-) create mode 100644 internal/cli/plugin_dev.go create mode 100644 internal/cli/plugin_dev_test.go create mode 100644 internal/display/plugin_dev.go create mode 100644 internal/display/plugin_dev_test.go create mode 100644 internal/plugins/devlink/devlink.go create mode 100644 internal/plugins/devlink/devlink_test.go diff --git a/internal/cli/plugin.go b/internal/cli/plugin.go index 08b1700ae..1c9b680db 100644 --- a/internal/cli/plugin.go +++ b/internal/cli/plugin.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "net/http" "os" "path/filepath" @@ -23,6 +24,7 @@ import ( "github.com/auth0/auth0-cli/internal/iostream" "github.com/auth0/auth0-cli/internal/plugins" "github.com/auth0/auth0-cli/internal/plugins/approvals" + "github.com/auth0/auth0-cli/internal/plugins/devlink" "github.com/auth0/auth0-cli/internal/plugins/registry" "github.com/auth0/auth0-cli/internal/plugins/store" "github.com/auth0/auth0-cli/internal/prompt" @@ -69,6 +71,7 @@ func pluginCmd(cli *cli) *cobra.Command { cmd.AddCommand(updatePluginCmd(cli)) cmd.AddCommand(uninstallPluginCmd(cli)) cmd.AddCommand(approvePluginCmd(cli)) + cmd.AddCommand(pluginDevCmd(cli)) return cmd } @@ -183,8 +186,16 @@ func listPluginCmd(cli *cli) *cobra.Command { return err } - results := make([]display.PluginListEntry, 0, len(installed)) + // A file of links that cannot be read lists no link, and says so. + links, err := devLinks(svc.store.Dir) + if err != nil && !cli.wantsJSONError() { + cli.renderer.Warnf("No linked plugin is listed: %v", linksError(err)) + } + + results := make([]display.PluginListEntry, 0, len(installed)+len(links)) for _, plugin := range installed { + _, linked := links[plugin.ID] + results = append(results, display.PluginListEntry{ ID: plugin.ID, Name: plugins.NameOf(plugin.ID), @@ -192,9 +203,12 @@ func listPluginCmd(cli *cli) *cobra.Command { Pinned: plugin.Pinned, Previous: plugin.Previous, SizeBytes: sizes[plugin.ID], + Linked: linked, }) } + results = append(results, linkedOnlyEntries(cli, installed, links)...) + cli.renderer.PluginList(results) return nil @@ -209,6 +223,34 @@ func listPluginCmd(cli *cli) *cobra.Command { return cmd } +// linkedOnlyEntries returns the entries of the linked plugins that are not +// installed, in the order of their IDs, with the version of the manifest in the +// directory of the build. A link that cannot be used is listed without a version, +// and a warning says why. +func linkedOnlyEntries(cli *cli, installed []store.Installed, links map[string]devlink.Link) []display.PluginListEntry { + var entries []display.PluginListEntry + + for _, id := range slices.Sorted(maps.Keys(links)) { + build, err := links[id].Open(id) + if err != nil && !cli.wantsJSONError() { + cli.renderer.Warnf("%v", unusableLink(id, links[id], err)) + } + + if isInstalled(installed, id) { + continue + } + + entry := display.PluginListEntry{ID: id, Name: plugins.NameOf(id), Linked: true} + if err == nil { + entry.Version = build.Manifest.Version + } + + entries = append(entries, entry) + } + + return entries +} + func infoPluginCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "info ", @@ -231,6 +273,26 @@ func infoPluginCmd(cli *cli) *cobra.Command { return err } + // A linked plugin is described by its manifest, which needs no registry. A + // version that was asked for is a version of the registry. + if ref.Version == "" { + build, linked, err := linkedPlugin(cli, svc, ref.ID) + if err != nil { + return err + } + + if linked { + view, err := linkedPluginInfo(cli, svc, build) + if err != nil { + return err + } + + cli.renderer.PluginInfo(*view) + + return nil + } + } + index, err := loadPluginIndex(cmd.Context(), cli, svc) if err != nil { return err @@ -290,18 +352,13 @@ func buildPluginInfo(cmd *cobra.Command, cli *cli, svc *pluginService, index *re record := records[n] view := &display.PluginDetails{ - ID: plugin.ID, - Version: record.Version, - Description: plugin.Description, - Owner: plugin.Owner, - Homepage: plugin.Homepage, - Platforms: record.Platforms, - Permissions: display.PluginPermissions{ - Scopes: len(record.Permissions.Scopes), - Hosts: len(record.Permissions.Hosts), - Files: len(record.Permissions.Files), - Env: len(record.Permissions.Env), - }, + ID: plugin.ID, + Version: record.Version, + Description: plugin.Description, + Owner: plugin.Owner, + Homepage: plugin.Homepage, + Platforms: record.Platforms, + Permissions: permissionCounts(record.Permissions), PermissionsHash: record.PermissionsHash, Versions: make([]display.PluginVersion, 0, len(records)), ApprovedTenants: []display.PluginApproval{}, @@ -318,15 +375,67 @@ func buildPluginInfo(cmd *cobra.Command, cli *cli, svc *pluginService, index *re view.Deprecated = pluginDeprecation(index, plugin.ID, record.Version) - if view.Installed, err = installedPluginView(cli, svc, plugin.ID); err != nil { + if err := addInstallState(cli, svc, view); err != nil { return nil, err } - approved, err := svc.approvals.List(plugin.ID) - if err != nil { + return view, nil +} + +// linkedPluginInfo gathers everything info shows about a linked plugin, which is +// what its manifest says, whether a copy is installed too, and the approvals. +func linkedPluginInfo(cli *cli, svc *pluginService, build *devlink.Build) (*display.PluginDetails, error) { + manifest := build.Manifest + + view := &display.PluginDetails{ + ID: manifest.ID, + Version: manifest.Version, + Description: manifest.Description, + Owner: manifest.Owner, + Homepage: manifest.Homepage, + Platforms: manifest.Platforms, + Permissions: permissionCounts(manifest.Permissions()), + PermissionsHash: manifest.PermissionsHash(), + Versions: []display.PluginVersion{}, + ApprovedTenants: []display.PluginApproval{}, + Linked: true, + } + + if manifest.Engines != nil { + view.Requires = manifest.Engines.Auth0CLI + } + + if err := addInstallState(cli, svc, view); err != nil { return nil, err } + return view, nil +} + +// permissionCounts counts what a plugin asks to be allowed. +func permissionCounts(permissions plugins.Permissions) display.PluginPermissions { + return display.PluginPermissions{ + Scopes: len(permissions.Scopes), + Hosts: len(permissions.Hosts), + Files: len(permissions.Files), + Env: len(permissions.Env), + } +} + +// addInstallState adds to the view of a plugin whether a copy is installed, and +// the tenants where it is approved. +func addInstallState(cli *cli, svc *pluginService, view *display.PluginDetails) error { + var err error + + if view.Installed, err = installedPluginView(cli, svc, view.ID); err != nil { + return err + } + + approved, err := svc.approvals.List(view.ID) + if err != nil { + return err + } + for _, approval := range approved { view.ApprovedTenants = append(view.ApprovedTenants, display.PluginApproval{ Tenant: approval.Tenant, @@ -335,7 +444,7 @@ func buildPluginInfo(cmd *cobra.Command, cli *cli, svc *pluginService, index *re }) } - return view, nil + return nil } // installedPluginView describes the installed copy of a plugin, or returns nil @@ -578,8 +687,8 @@ func approvePluginCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "approve ", Args: cobra.ExactArgs(1), - Short: "Approve what an installed plugin may do on a tenant", - Long: "Show the permissions of an installed plugin and, when you agree, approve them for a tenant. " + + Short: "Approve what an installed or linked plugin may do on a tenant", + Long: "Show the permissions of an installed plugin, or of a plugin linked in dev mode, and, when you agree, approve them for a tenant. " + "A plugin runs on a tenant only after a person approved its permissions there. " + "The approval holds for later versions that ask for the same permissions.\n\n" + "This needs a terminal. It cannot run with `--no-input`, in agent mode or in a script, because only a person approves a plugin. " + @@ -602,16 +711,7 @@ func approvePluginCmd(cli *cli) *cobra.Command { return validationError{err: errPluginNoTenant, reason: "plugin_no_tenant"} } - if _, err := requireInstalled(svc.store, ref); err != nil { - return err - } - - verified, err := svc.store.Verify(ref.ID) - - if _, failed := errors.AsType[*store.VerificationError](err); failed { - return fmt.Errorf("the installed copy of %s failed verification, so it cannot be approved: %w. Reinstall it with: auth0 plugin install %s", ref.ID, err, ref.Name) - } - + verified, err := approvalSubject(cli, svc, ref) if err != nil { return err } @@ -647,6 +747,32 @@ func approvePluginCmd(cli *cli) *cobra.Command { return cmd } +// approvalSubject returns the plugin that approve shows and approves. It is the +// linked build when the plugin is linked, because that is what runs, and +// otherwise the installed copy, after it was verified. +func approvalSubject(cli *cli, svc *pluginService, ref pluginRef) (store.Verified, error) { + build, linked, err := linkedPlugin(cli, svc, ref.ID) + if err != nil { + return store.Verified{}, err + } + + if linked { + return verifiedLinked(build), nil + } + + if _, err := requireInstalled(svc.store, ref); err != nil { + return store.Verified{}, err + } + + verified, err := svc.store.Verify(ref.ID) + + if _, failed := errors.AsType[*store.VerificationError](err); failed { + return store.Verified{}, fmt.Errorf("the installed copy of %s failed verification, so it cannot be approved: %w. Reinstall it with: auth0 plugin install %s", ref.ID, err, ref.Name) + } + + return verified, err +} + // pluginsDisabledEnv turns off the plugin commands that bring plugins onto a // machine or approve them, for managed machines where plugins are not wanted. const pluginsDisabledEnv = "AUTH0_CLI_PLUGINS_DISABLED" diff --git a/internal/cli/plugin_dev.go b/internal/cli/plugin_dev.go new file mode 100644 index 000000000..bf80145d3 --- /dev/null +++ b/internal/cli/plugin_dev.go @@ -0,0 +1,482 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/devlink" + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/store" +) + +// pluginDevModeEnv turns the commands for plugin authors on, and with them the +// links that skip the integrity checks of an installed plugin. Only the exact +// value true does: this variable lets a plugin run without being verified, so +// anything else, such as 1 or True, leaves it off. +const pluginDevModeEnv = "AUTH0_CLI_PLUGIN_DEV_MODE" + +var pluginDevBin = Flag{ + Name: "Bin", + LongForm: "bin", + Help: "The executable of the plugin. By default it is the file named like the plugin in the directory of the plugin.", +} + +// pluginDevMode reports whether dev mode is on. It reads one variable and +// nothing else, so it is cheap to ask on every start of the CLI. +func pluginDevMode() bool { + return os.Getenv(pluginDevModeEnv) == "true" +} + +// requirePluginDevMode fails unless dev mode is on. +func requirePluginDevMode() error { + if pluginDevMode() { + return nil + } + + return validationError{ + err: fmt.Errorf("the commands for plugin authors are available only in dev mode. Turn it on with: %s=true", pluginDevModeEnv), + reason: "plugin_dev_mode_required", + } +} + +func pluginDevCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "dev", + Short: "Check and run a plugin you are building", + Long: "Commands for plugin authors. They are available only in dev mode, which you turn on with " + pluginDevModeEnv + "=true.\n\n" + + "A linked plugin runs as `auth0 ` from the directory of your build, through the same proxy, permissions and approval as an installed plugin. " + + "Only the checks that make no sense for a build that is not published are skipped: the hashes of its files, the lockfile and the revocation list.", + Example: ` auth0 plugin dev validate + auth0 plugin dev link ./checkmate`, + // The commands for authors show in the help only in dev mode. + Hidden: !pluginDevMode(), + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(linkPluginCmd(cli)) + cmd.AddCommand(unlinkPluginCmd(cli)) + cmd.AddCommand(validatePluginCmd(cli)) + + return cmd +} + +func linkPluginCmd(cli *cli) *cobra.Command { + var bin string + + cmd := &cobra.Command{ + Use: "link [path]", + Args: cobra.MaximumNArgs(1), + Short: "Run a local build of a plugin as an auth0 command", + Long: "Link the plugin in a directory, so that `auth0 ` runs it. The directory must hold a valid plugin.json, and the executable is the file named like the plugin in it, " + + "with .exe added on Windows, unless you give another with `--bin`. The manifest and the executable are read again on every run, so a rebuild needs no new link.\n\n" + + "A linked plugin takes the place of an installed plugin with the same name. It is held to the same rules as an installed one, and a person must approve its permissions with `auth0 plugin approve`. " + + "Linking it again replaces the link.", + Example: ` auth0 plugin dev link + auth0 plugin dev link ./checkmate + auth0 plugin dev link ./checkmate --bin ./checkmate/dist/checkmate`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requirePluginDevMode(); err != nil { + return err + } + + path := "." + if len(args) == 1 { + path = args[0] + } + + dir, err := filepath.Abs(path) + if err != nil { + return linkInvalid(err) + } + + info, err := os.Stat(dir) + if err != nil { + return linkInvalid(err) + } + + if !info.IsDir() { + return linkInvalid(fmt.Errorf("%s is not a directory. Give the directory that holds %s", dir, devlink.ManifestName)) + } + + manifest, problems, err := checkPluginManifest(cmd.Root(), dir) + if err != nil { + return linkInvalid(err) + } + + if len(problems) > 0 { + return linkProblems(filepath.Join(dir, devlink.ManifestName), problems) + } + + if !strings.HasPrefix(manifest.ID, registry.DefaultRegistryName+"/") { + return linkInvalid(fmt.Errorf("%s names the plugin %s, but the only registry this CLI knows is %q, so the ID must start with %s/", filepath.Join(dir, devlink.ManifestName), manifest.ID, registry.DefaultRegistryName, registry.DefaultRegistryName)) + } + + // The executable is the one the author gave, or the file named like the + // plugin in the directory of the build. + if bin == "" { + bin = devlink.DefaultBin(dir, manifest.Name()) + } else if bin, err = filepath.Abs(bin); err != nil { + return linkInvalid(err) + } + + if err := devlink.CheckExecutable(bin); err != nil { + return linkInvalid(fmt.Errorf("the executable of the plugin cannot be used: %w. Build it there, or give another file with --bin", err)) + } + + svc, err := cli.getPlugins() + if err != nil { + return err + } + + links := devlink.Links{Dir: svc.store.Dir} + if err := links.Add(cmd.Context(), manifest.ID, devlink.Link{Path: dir, Bin: bin}); err != nil { + return linksError(err) + } + + return sayLinked(cmd, cli, svc, manifest, display.PluginLinkResult{ + ID: manifest.ID, + Version: manifest.Version, + Path: dir, + Bin: bin, + Banner: linkedBanner(manifest.ID, manifest.Version, dir), + }) + }, + } + + pluginDevBin.RegisterString(cmd, &bin, "") + 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.MarkFlagsMutuallyExclusive("json", "json-compact") + + return cmd +} + +// sayLinked tells the person what was linked, what each run prints, and whether +// the plugin still needs an approval, and prints the result for a machine. +func sayLinked(cmd *cobra.Command, cli *cli, svc *pluginService, manifest *plugins.Manifest, result display.PluginLinkResult) error { + op := newPluginOperation(cmd, cli, svc, nil) + + // An approval that cannot be read counts as no approval: the person is asked to + // approve, and the approval itself reports a damaged file. + approved := false + if op.tenant != "" { + approved, _ = svc.approvals.Approved(op.tenant, manifest.ID, manifest.PermissionsHash()) + } + + result.ApprovalRequired = !approved + + if result.ApprovalRequired && op.tenant != "" { + result.ApproveCommand = approveCommand(manifest.ID, op.tenant) + } + + r := cli.renderer + r.Infof("Linked %s %s from %s. Run it with: auth0 %s", manifest.ID, plugins.SafeText(manifest.Version), plugins.SafeText(result.Path), manifest.Name()) + r.Infof("Every run prints this line on stderr: %s", result.Banner) + + op.sayApproval(&display.PluginChangeResult{ID: manifest.ID, ApprovalRequired: result.ApprovalRequired, ApproveCommand: result.ApproveCommand}) + r.PluginLinked(result) + + return nil +} + +// linkedBanner is the line that every run of a linked plugin prints on stderr. +func linkedBanner(id, version, dir string) string { + return fmt.Sprintf("Running the linked build of %s %s from %s. Its files are not checked against a release.", id, plugins.SafeText(version), plugins.SafeText(dir)) +} + +func unlinkPluginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "unlink ", + Args: cobra.ExactArgs(1), + Short: "Stop running a local build of a plugin", + Long: "Remove the link of a plugin, so that `auth0 ` runs the installed plugin again, or nothing when none is installed. " + + "The plugin can be given as `name` or `auth0/name`. The build itself is not touched.", + Example: ` auth0 plugin dev unlink checkmate + auth0 plugin dev unlink auth0/checkmate`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requirePluginDevMode(); err != nil { + return err + } + + ref, err := parsePluginName(args[0], "unlink") + if err != nil { + return err + } + + svc, err := cli.getPlugins() + if err != nil { + return err + } + + links := devlink.Links{Dir: svc.store.Dir} + + err = links.Remove(cmd.Context(), ref.ID) + if errors.Is(err, devlink.ErrNotLinked) { + linked, _ := links.Load() + + return notLinkedError(ref, slices.Sorted(maps.Keys(linked))) + } + + if err != nil { + return linksError(err) + } + + cli.renderer.Infof("Unlinked %s.", ref.ID) + + return nil + }, + } + + return cmd +} + +// notLinkedError says that a plugin is not linked, and which plugins are. +func notLinkedError(ref pluginRef, linked []string) error { + if len(linked) == 0 { + return validationError{ + err: fmt.Errorf("the plugin %s is not linked, and no plugin is. Link one with: auth0 plugin dev link ", ref.ID), + reason: "plugin_not_linked", + } + } + + return validationError{ + err: fmt.Errorf("the plugin %s is not linked. Linked plugins: %s", ref.ID, strings.Join(linked, ", ")), + reason: "plugin_not_linked", + } +} + +func validatePluginCmd(cli *cli) *cobra.Command { + var bin string + + cmd := &cobra.Command{ + Use: "validate [path]", + Args: cobra.MaximumNArgs(1), + Short: "Check the manifest of a plugin", + Long: "Check a plugin.json the way the registry does when a plugin is published, and print every problem with the path of its field. " + + "The path is a directory that holds plugin.json, or the file itself, and defaults to the current directory. " + + "It also checks that the name of the plugin is free of core commands. With `--bin`, it checks that the executable can be run too.\n\n" + + "This works offline. The command fails when the manifest has a problem.", + Example: ` auth0 plugin dev validate + auth0 plugin dev validate ./checkmate/plugin.json + auth0 plugin dev validate ./checkmate --bin ./checkmate/checkmate --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requirePluginDevMode(); err != nil { + return err + } + + path := "." + if len(args) == 1 { + path = args[0] + } + + _, problems, err := checkPluginManifest(cmd.Root(), path) + if err != nil { + return validationError{err: err, reason: "plugin_manifest_invalid"} + } + + if bin != "" { + if err := devlink.CheckExecutable(bin); err != nil { + problems = append(problems, display.PluginProblem{Path: "--bin", Message: err.Error()}) + } + } + + result := display.PluginValidation{Valid: len(problems) == 0, Problems: problems} + if result.Problems == nil { + result.Problems = []display.PluginProblem{} + } + + cli.renderer.PluginValidation(result) + + if !result.Valid { + return validationError{ + err: fmt.Errorf("the plugin manifest has %s", pluralize(len(problems), "problem", "problems")), + reason: "plugin_manifest_invalid", + details: problemDetails(problems), + } + } + + return nil + }, + } + + pluginDevBin.RegisterString(cmd, &bin, "") + 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.MarkFlagsMutuallyExclusive("json", "json-compact") + + return cmd +} + +// checkPluginManifest reads the plugin.json at the path, which is a directory +// that holds it or the file itself, and checks it the way it is checked when a +// plugin is published, that its ID is safe to use as a path, and that its name is +// not one of a core command. It returns the problems it found, and an error when +// the file cannot be read at all. The manifest is nil when it has problems. +func checkPluginManifest(root *cobra.Command, path string) (*plugins.Manifest, []display.PluginProblem, error) { + info, err := os.Stat(path) + if err != nil { + return nil, nil, err + } + + if info.IsDir() { + path = filepath.Join(path, devlink.ManifestName) + } + + manifest, err := devlink.ReadManifest(path) + if invalid, ok := errors.AsType[*plugins.ValidationError](err); ok { + problems := make([]display.PluginProblem, 0, len(invalid.Errors)) + for _, problem := range invalid.Errors { + problems = append(problems, splitProblem(problem)) + } + + return nil, problems, nil + } + + if err != nil { + return nil, nil, err + } + + // The ID becomes directory names of the store, so a plugin that cannot be + // installed because of its ID is refused here too. + err = store.CheckID(manifest.ID) + if err == nil { + err = checkPluginNameAvailable(root, manifest.Name()) + } + + if err != nil { + return nil, []display.PluginProblem{{Path: "id", Message: err.Error()}}, nil + } + + return manifest, nil, nil +} + +// splitProblem splits a problem of a manifest, which starts with the JSON path of +// the field and a colon, into the path and the message. A problem that has no path, +// such as a document that is not JSON, gets the root. +func splitProblem(problem string) display.PluginProblem { + path, message, found := strings.Cut(problem, ": ") + + // A path has no space unless it quotes a key. + if !found || (strings.Contains(path, " ") && !strings.Contains(path, `["`)) { + return display.PluginProblem{Path: "(root)", Message: problem} + } + + return display.PluginProblem{Path: path, Message: message} +} + +// problemDetails returns the problems as the details of the JSON error. +func problemDetails(problems []display.PluginProblem) json.RawMessage { + details, err := json.Marshal(struct { + Problems []display.PluginProblem `json:"problems"` + }{problems}) + if err != nil { + return nil + } + + return details +} + +// linkInvalid is the failure of a link that cannot be made or used. +func linkInvalid(err error) error { + return validationError{err: err, reason: "plugin_link_invalid"} +} + +// linkProblems is the failure of a link whose manifest has problems. +func linkProblems(manifestPath string, problems []display.PluginProblem) error { + lines := make([]string, 0, len(problems)) + for _, problem := range problems { + lines = append(lines, problem.Path+": "+problem.Message) + } + + return validationError{ + err: fmt.Errorf("%s is not valid: %s. Check it with: auth0 plugin dev validate %s", manifestPath, strings.Join(lines, "; "), filepath.Dir(manifestPath)), + reason: "plugin_link_invalid", + details: problemDetails(problems), + } +} + +// linksError tells a person what to do about a change of the links that failed. +func linksError(err error) error { + if errors.Is(err, devlink.ErrDamaged) { + return linkInvalid(fmt.Errorf("%w. Repair it, or delete it to start again with no links", err)) + } + + return err +} + +// devLinks returns the links of the plugins directory. Links count only in dev +// mode, so it returns none, and reads nothing, when dev mode is off. +func devLinks(dir string) (map[string]devlink.Link, error) { + if !pluginDevMode() { + return nil, nil //nolint:nilnil // With dev mode off there are no links, and no file is read. + } + + return devlink.Links{Dir: dir}.Load() +} + +// linkedPlugin returns the build of a linked plugin as it is now. It reports false +// when dev mode is off or the plugin is not linked. A file of links that cannot be +// read counts as no links, as it does when the commands are registered, and a +// warning says so. A plugin that is linked but whose link cannot be used is an +// error, and is never read as not being linked. +func linkedPlugin(cli *cli, svc *pluginService, id string) (*devlink.Build, bool, error) { + links, err := devLinks(svc.store.Dir) + if err != nil && !cli.wantsJSONError() { + cli.renderer.Warnf("No plugin is linked: %v", linksError(err)) + } + + link, found := links[id] + if !found { + return nil, false, nil + } + + build, err := link.Open(id) + if err != nil { + return nil, true, unusableLink(id, link, err) + } + + return build, true, nil +} + +// unusableLink is the failure of a run or a command that found a link it cannot +// use: it says why, and what to do about it. +func unusableLink(id string, link devlink.Link, err error) error { + why := err.Error() + if invalid, ok := errors.AsType[*plugins.ValidationError](err); ok { + why = strings.Join(invalid.Errors, "; ") + } + + // The ID is the key of a file on disk, so it is as untrusted as the path. + id = plugins.SafeText(id) + + return validationError{ + err: fmt.Errorf("the linked plugin %s cannot be used: %s. Check it with: auth0 plugin dev validate %s, or remove the link with: auth0 plugin dev unlink %s", + id, plugins.SafeText(why), plugins.SafeText(link.Path), plugins.NameOf(id)), + reason: "plugin_link_invalid", + } +} + +// verifiedLinked describes a linked build the way the checks of a run see an +// installed plugin that was verified. Nothing was verified: the hashes and the +// lockfile are skipped, and the permissions hash is that of the manifest just read. +func verifiedLinked(build *devlink.Build) store.Verified { + return store.Verified{ + ID: build.Manifest.ID, + Version: build.Manifest.Version, + Dir: build.Dir, + Bin: build.Bin, + ManifestPath: build.ManifestPath, + Manifest: build.Manifest, + PermissionsHash: build.Manifest.PermissionsHash(), + } +} diff --git a/internal/cli/plugin_dev_test.go b/internal/cli/plugin_dev_test.go new file mode 100644 index 000000000..401dd6429 --- /dev/null +++ b/internal/cli/plugin_dev_test.go @@ -0,0 +1,948 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/devlink" +) + +// stubExecutable is the executable of a build that is linked or checked but never +// run. +var stubExecutable = []byte("#!/bin/sh\n") + +// devBuild is a local build of the test plugin: a directory with its plugin.json +// and its executable. +type devBuild struct { + Dir string + Bin string + Manifest plugins.Manifest +} + +// writeDevBuild writes a build of the test plugin in a new directory. +func writeDevBuild(t *testing.T, version string, commands []plugins.Command, executable []byte) devBuild { + t.Helper() + + dir := t.TempDir() + + build := devBuild{ + Dir: dir, + Bin: devlink.DefaultBin(dir, pluginTestBin), + Manifest: fixtureManifest(plugins.CurrentPlatform(), version, commands), + } + + build.save(t) + require.NoError(t, os.WriteFile(build.Bin, executable, 0o755)) + + return build +} + +// save writes the manifest of the build. +func (b devBuild) save(t *testing.T) { + t.Helper() + + data, err := json.Marshal(b.Manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(b.Dir, devlink.ManifestName), data, 0o600)) +} + +// writeManifestText replaces the manifest of the build with the text. +func (b devBuild) writeManifestText(t *testing.T, text string) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(b.Dir, devlink.ManifestName), []byte(text), 0o600)) +} + +// newDevEnv returns an environment with dev mode on. +func newDevEnv(t *testing.T) *pluginEnv { + t.Helper() + + e := newPluginEnv(t) + t.Setenv(pluginDevModeEnv, "true") + + return e +} + +// link records the build as the link of its plugin, the way the link command does. +func (e *pluginEnv) link(build devBuild) { + e.t.Helper() + + require.NoError(e.t, devlink.Links{Dir: e.Dir}.Add(context.Background(), build.Manifest.ID, devlink.Link{Path: build.Dir, Bin: build.Bin})) +} + +// links returns the links that are recorded. +func (e *pluginEnv) links() map[string]devlink.Link { + e.t.Helper() + + links, err := devlink.Links{Dir: e.Dir}.Load() + require.NoError(e.t, err) + + return links +} + +// linksPath is where the links are kept. +func (e *pluginEnv) linksPath() string { + return filepath.Join(e.Dir, devlink.FileName) +} + +// approveBuild records the approval of a person for the permissions of the build. +func (e *pluginEnv) approveBuild(build devBuild) { + e.t.Helper() + + require.NoError(e.t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, build.Manifest.ID, build.Manifest.PermissionsHash())) +} + +// samePath fails unless two paths are the same file, whatever links their +// directories go through. +func samePath(t *testing.T, want, got string) { + t.Helper() + + resolvedWant, err := filepath.EvalSymlinks(want) + require.NoError(t, err) + + resolvedGot, err := filepath.EvalSymlinks(got) + require.NoError(t, err) + + assert.Equal(t, resolvedWant, resolvedGot) +} + +// devModeValues are values that leave dev mode off. +var devModeValues = []string{"", "1", "True", "TRUE", " true", "true ", "yes", "on", "false"} + +func TestPluginDevMode(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {"true", true}, + {"", false}, + {"1", false}, + {"True", false}, + {"TRUE", false}, + {" true", false}, + {"true ", false}, + {"yes", false}, + {"false", false}, + } + + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + t.Setenv(pluginDevModeEnv, test.value) + + assert.Equal(t, test.want, pluginDevMode()) + assert.Equal(t, test.want, !pluginDevCmd(&cli{}).Hidden, "the commands show in the help only in dev mode") + + err := requirePluginDevMode() + if test.want { + require.NoError(t, err) + + return + } + + require.Error(t, err) + assertValidationError(t, err, "plugin_dev_mode_required") + assert.Contains(t, err.Error(), pluginDevModeEnv+"=true") + }) + } +} + +func TestPluginDevCmds_NeedDevMode(t *testing.T) { + for _, value := range devModeValues { + t.Run("dev mode is "+value, func(t *testing.T) { + e := newPluginEnv(t) + t.Setenv(pluginDevModeEnv, value) + + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + commands := [][]string{ + {"plugin", "dev", "link", build.Dir}, + {"plugin", "dev", "unlink", "checkmate"}, + {"plugin", "dev", "validate", build.Dir}, + } + + for _, args := range commands { + err := e.run(args...) + + require.Error(t, err, args) + assertValidationError(t, err, "plugin_dev_mode_required") + assert.Contains(t, err.Error(), pluginDevModeEnv+"=true") + assert.Empty(t, e.stdout.String()) + } + + assert.NoDirExists(t, e.Dir, "nothing is written") + }) + } +} + +func TestLinkPluginCmd(t *testing.T) { + t.Run("links the directory, tells what every run prints, and asks for the approval", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.NoError(t, e.run("plugin", "dev", "link", build.Dir, "--tenant", pluginTestTenant)) + + assert.Equal(t, map[string]devlink.Link{pluginTestID: {Path: build.Dir, Bin: build.Bin}}, e.links()) + + out := e.stderr.String() + assert.Contains(t, out, "Linked auth0/checkmate 1.0.0 from "+build.Dir+". Run it with: auth0 checkmate") + assert.Contains(t, out, "Every run prints this line on stderr: Running the linked build of auth0/checkmate 1.0.0 from "+build.Dir+". Its files are not checked against a release.") + assert.Contains(t, out, "auth0/checkmate is not approved for "+pluginTestTenant+" yet. Approve it in a terminal with: auth0 plugin approve auth0/checkmate --tenant "+pluginTestTenant) + assert.Empty(t, e.stdout.String()) + }) + + t.Run("prints the result as JSON", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.NoError(t, e.run("plugin", "dev", "link", build.Dir, "--tenant", pluginTestTenant, "--json-compact")) + + assert.JSONEq(t, `{ + "id": "auth0/checkmate", + "version": "1.0.0", + "path": `+strconv.Quote(build.Dir)+`, + "bin": `+strconv.Quote(build.Bin)+`, + "banner": `+strconv.Quote("Running the linked build of auth0/checkmate 1.0.0 from "+build.Dir+". Its files are not checked against a release.")+`, + "approvalRequired": true, + "approveCommand": "auth0 plugin approve auth0/checkmate --tenant `+pluginTestTenant+`" + }`, e.stdout.String()) + }) + + t.Run("does not ask for an approval that is there", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + e.approveBuild(build) + + require.NoError(t, e.run("plugin", "dev", "link", build.Dir, "--tenant", pluginTestTenant, "--json")) + + object := e.jsonObject() + assert.Equal(t, false, object["approvalRequired"]) + assert.NotContains(t, object, "approveCommand") + assert.NotContains(t, e.stderr.String(), "not approved") + }) + + t.Run("asks again for permissions that changed", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + e.approveBuild(build) + + build.Manifest.Commands[0].Permissions = fixturePermissions([]string{"read:clients", "delete:clients"}) + build.save(t) + + require.NoError(t, e.run("plugin", "dev", "link", build.Dir, "--tenant", pluginTestTenant, "--json")) + + assert.Equal(t, true, e.jsonObject()["approvalRequired"]) + }) + + t.Run("says how to approve when there is no tenant", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.NoError(t, e.run("plugin", "dev", "link", build.Dir)) + + assert.Contains(t, e.stderr.String(), "auth0/checkmate is not approved on any tenant. Log in with auth0 login, then approve it in a terminal with: auth0 plugin approve auth0/checkmate --tenant ") + }) + + t.Run("takes the current directory by default", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + t.Chdir(build.Dir) + + require.NoError(t, e.run("plugin", "dev", "link")) + + link := e.links()[pluginTestID] + assert.True(t, filepath.IsAbs(link.Path)) + assert.True(t, filepath.IsAbs(link.Bin)) + samePath(t, build.Dir, link.Path) + samePath(t, build.Bin, link.Bin) + }) + + t.Run("records an absolute path for a relative one", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + t.Chdir(filepath.Dir(build.Dir)) + + require.NoError(t, e.run("plugin", "dev", "link", "."+string(filepath.Separator)+filepath.Base(build.Dir))) + + link := e.links()[pluginTestID] + assert.True(t, filepath.IsAbs(link.Path), link.Path) + samePath(t, build.Dir, link.Path) + }) + + t.Run("runs the executable that --bin names", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + // The conventional file is gone, so only the one that was named can be it. + require.NoError(t, os.Remove(build.Bin)) + require.NoError(t, os.Mkdir(filepath.Join(build.Dir, "dist"), 0o700)) + + other := filepath.Join(build.Dir, "dist", "built") + require.NoError(t, os.WriteFile(other, stubExecutable, 0o755)) + + t.Chdir(build.Dir) + + require.NoError(t, e.run("plugin", "dev", "link", "--bin", filepath.Join("dist", "built"))) + + link := e.links()[pluginTestID] + assert.True(t, filepath.IsAbs(link.Bin), link.Bin) + samePath(t, other, link.Bin) + }) + + t.Run("linking again replaces the link", func(t *testing.T) { + e := newDevEnv(t) + first := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + second := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + + require.NoError(t, e.run("plugin", "dev", "link", first.Dir)) + require.NoError(t, e.run("plugin", "dev", "link", second.Dir), "the command of the plugin that is linked does not block its own link") + + assert.Equal(t, map[string]devlink.Link{pluginTestID: {Path: second.Dir, Bin: second.Bin}}, e.links()) + assert.Contains(t, e.stderr.String(), "Linked auth0/checkmate 2.0.0 from "+second.Dir) + }) + + t.Run("takes at most one directory", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.Error(t, e.run("plugin", "dev", "link", build.Dir, build.Dir)) + assert.NoFileExists(t, e.linksPath()) + }) +} + +func TestLinkPluginCmd_Refusals(t *testing.T) { + tests := []struct { + name string + + // Prepare changes the build, and returns the arguments after the link + // command. + prepare func(t *testing.T, build devBuild) []string + want []string + }{ + { + name: "a directory that does not exist", + prepare: func(t *testing.T, build devBuild) []string { + return []string{filepath.Join(build.Dir, "missing")} + }, + want: []string{"missing"}, + }, + { + name: "a file and not a directory", + prepare: func(t *testing.T, build devBuild) []string { + return []string{filepath.Join(build.Dir, devlink.ManifestName)} + }, + want: []string{"is not a directory", devlink.ManifestName}, + }, + { + name: "a directory without a manifest", + prepare: func(t *testing.T, build devBuild) []string { + require.NoError(t, os.Remove(filepath.Join(build.Dir, devlink.ManifestName))) + + return []string{build.Dir} + }, + want: []string{devlink.ManifestName}, + }, + { + name: "a manifest that is not JSON", + prepare: func(t *testing.T, build devBuild) []string { + build.writeManifestText(t, "not json") + + return []string{build.Dir} + }, + want: []string{"is not valid", "auth0 plugin dev validate {dir}"}, + }, + { + name: "a manifest with problems", + prepare: func(t *testing.T, build devBuild) []string { + build.writeManifestText(t, `{"id":"auth0/checkmate"}`) + + return []string{build.Dir} + }, + want: []string{"is not valid", "version:", "auth0 plugin dev validate {dir}"}, + }, + { + name: "a name of a core command", + prepare: func(t *testing.T, build devBuild) []string { + build.Manifest.ID = "auth0/apps" + build.save(t) + + return []string{build.Dir} + }, + want: []string{"auth0 already has a command named \"apps\""}, + }, + { + name: "a plugin name that Windows reserves", + prepare: func(t *testing.T, build devBuild) []string { + build.Manifest.ID = "auth0/con" + build.save(t) + + return []string{build.Dir} + }, + want: []string{`"con" cannot be a directory name`}, + }, + { + name: "a registry name that Windows reserves", + prepare: func(t *testing.T, build devBuild) []string { + build.Manifest.ID = "nul/checkmate" + build.save(t) + + return []string{build.Dir} + }, + want: []string{`"nul" cannot be a directory name`}, + }, + { + name: "a registry that the CLI does not know", + prepare: func(t *testing.T, build devBuild) []string { + build.Manifest.ID = "acme/checkmate" + build.save(t) + + return []string{build.Dir} + }, + want: []string{"acme/checkmate", "must start with auth0/"}, + }, + { + name: "an executable that is not there", + prepare: func(t *testing.T, build devBuild) []string { + require.NoError(t, os.Remove(build.Bin)) + + return []string{build.Dir} + }, + want: []string{"{bin}", "Build it there, or give another file with --bin"}, + }, + { + name: "an executable that is a directory", + prepare: func(t *testing.T, build devBuild) []string { + require.NoError(t, os.Remove(build.Bin)) + require.NoError(t, os.Mkdir(build.Bin, 0o700)) + + return []string{build.Dir} + }, + want: []string{"{bin} is not a regular file"}, + }, + { + name: "a file that --bin names and is not there", + prepare: func(t *testing.T, build devBuild) []string { + return []string{build.Dir, "--bin", filepath.Join(build.Dir, "nothing")} + }, + want: []string{filepath.Join("{dir}", "nothing"), "--bin"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + args := test.prepare(t, build) + + err := e.run(append([]string{"plugin", "dev", "link"}, args...)...) + + require.Error(t, err) + assertValidationError(t, err, "plugin_link_invalid") + + for _, want := range test.want { + want = strings.ReplaceAll(want, "{dir}", build.Dir) + want = strings.ReplaceAll(want, "{bin}", build.Bin) + + assert.Contains(t, err.Error(), want) + } + + assert.NoFileExists(t, e.linksPath(), "nothing is linked") + assert.NoDirExists(t, e.Dir, "nothing is written") + assert.Empty(t, e.stdout.String()) + }) + } + + t.Run("an executable that cannot be run", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows has no executable bit") + } + + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + require.NoError(t, os.Chmod(build.Bin, 0o644)) + + err := e.run("plugin", "dev", "link", build.Dir) + + assertValidationError(t, err, "plugin_link_invalid") + assert.Contains(t, err.Error(), "chmod +x "+build.Bin) + assert.NoFileExists(t, e.linksPath()) + }) + + t.Run("a file of links that is damaged is left as it is", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.NoError(t, os.MkdirAll(e.Dir, 0o700)) + require.NoError(t, os.WriteFile(e.linksPath(), []byte("{damaged"), 0o600)) + + err := e.run("plugin", "dev", "link", build.Dir) + + require.ErrorIs(t, err, devlink.ErrDamaged) + assertValidationError(t, err, "plugin_link_invalid") + assert.Contains(t, err.Error(), e.linksPath()) + assert.Contains(t, err.Error(), "Repair it, or delete it to start again with no links") + assert.Equal(t, "{damaged", readFile(t, e.linksPath())) + }) +} + +func TestUnlinkPluginCmd(t *testing.T) { + // The other plugin is linked without a build, as unlinking does not need one. + other := devlink.Link{Path: filepath.Join(t.TempDir(), "other"), Bin: filepath.Join(t.TempDir(), "other", "other")} + + newEnv := func(t *testing.T) (*pluginEnv, devBuild) { + t.Helper() + + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + e.link(build) + require.NoError(t, devlink.Links{Dir: e.Dir}.Add(context.Background(), "auth0/other", other)) + + return e, build + } + + t.Run("by short name, and the build is left alone", func(t *testing.T) { + e, build := newEnv(t) + + require.NoError(t, e.run("plugin", "dev", "unlink", "checkmate")) + + assert.Equal(t, map[string]devlink.Link{"auth0/other": other}, e.links()) + assert.Contains(t, e.stderr.String(), "Unlinked auth0/checkmate.") + assert.FileExists(t, build.Bin) + assert.FileExists(t, filepath.Join(build.Dir, devlink.ManifestName)) + }) + + t.Run("by ID", func(t *testing.T) { + e, _ := newEnv(t) + + require.NoError(t, e.run("plugin", "dev", "unlink", "auth0/other")) + + assert.Contains(t, e.links(), pluginTestID) + assert.NotContains(t, e.links(), "auth0/other") + }) + + t.Run("the last link leaves a file with none", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + e.link(build) + + require.NoError(t, e.run("plugin", "dev", "unlink", "checkmate")) + + assert.Empty(t, e.links()) + }) + + t.Run("a plugin that is not linked names the ones that are", func(t *testing.T) { + e, _ := newEnv(t) + + err := e.run("plugin", "dev", "unlink", "nothing") + + require.EqualError(t, err, "the plugin auth0/nothing is not linked. Linked plugins: auth0/checkmate, auth0/other") + assertValidationError(t, err, "plugin_not_linked") + assert.Len(t, e.links(), 2) + }) + + t.Run("a plugin that is not linked when nothing is", func(t *testing.T) { + e := newDevEnv(t) + + err := e.run("plugin", "dev", "unlink", "checkmate") + + require.EqualError(t, err, "the plugin auth0/checkmate is not linked, and no plugin is. Link one with: auth0 plugin dev link ") + assertValidationError(t, err, "plugin_not_linked") + }) + + t.Run("takes a name and not a version", func(t *testing.T) { + e, _ := newEnv(t) + + err := e.run("plugin", "dev", "unlink", "checkmate@1.0.0") + + require.ErrorContains(t, err, "unlink takes the name of a plugin, without a version") + assert.Len(t, e.links(), 2) + }) + + t.Run("takes one plugin", func(t *testing.T) { + e, _ := newEnv(t) + + require.Error(t, e.run("plugin", "dev", "unlink")) + require.Error(t, e.run("plugin", "dev", "unlink", "checkmate", "other")) + assert.Len(t, e.links(), 2) + }) + + t.Run("a file of links that is damaged is left as it is", func(t *testing.T) { + e := newDevEnv(t) + + require.NoError(t, os.MkdirAll(e.Dir, 0o700)) + require.NoError(t, os.WriteFile(e.linksPath(), []byte("{damaged"), 0o600)) + + err := e.run("plugin", "dev", "unlink", "checkmate") + + require.ErrorIs(t, err, devlink.ErrDamaged) + assertValidationError(t, err, "plugin_link_invalid") + assert.Equal(t, "{damaged", readFile(t, e.linksPath())) + }) +} + +func TestValidatePluginCmd(t *testing.T) { + // Validating needs no registry and no session: the service of the plugins is + // not even asked for. + offline := func(e *pluginEnv) { + e.cli.plugins = func() (*pluginService, error) { return nil, errors.New("the validation must not use the registry") } + } + + t.Run("a valid manifest in a directory", func(t *testing.T) { + e := newDevEnv(t) + offline(e) + + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.NoError(t, e.run("plugin", "dev", "validate", build.Dir)) + + assert.Contains(t, e.stderr.String(), "The plugin manifest is valid.") + assert.Empty(t, e.stdout.String()) + assert.NoDirExists(t, e.Dir, "nothing is written") + }) + + t.Run("a valid manifest as JSON", func(t *testing.T) { + e := newDevEnv(t) + offline(e) + + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.NoError(t, e.run("plugin", "dev", "validate", filepath.Join(build.Dir, devlink.ManifestName), "--json-compact")) + + assert.JSONEq(t, `{"valid":true,"problems":[]}`, e.stdout.String()) + }) + + t.Run("takes the current directory by default", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + t.Chdir(build.Dir) + + require.NoError(t, e.run("plugin", "dev", "validate")) + assert.Contains(t, e.stderr.String(), "The plugin manifest is valid.") + }) + + t.Run("every problem is reported with the path of its field", func(t *testing.T) { + e := newDevEnv(t) + offline(e) + + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + build.writeManifestText(t, `{"id":"Bad","version":"1","apiVersion":1,"description":"d","owner":{"team":"t","bugs":"https://example.com"},"platforms":["linux-x64"],"commands":[{"name":"a","description":"d"}],"extra":true}`) + + err := e.run("plugin", "dev", "validate", build.Dir, "--json") + + require.EqualError(t, err, "the plugin manifest has 3 problems") + assertValidationError(t, err, "plugin_manifest_invalid") + + var got display.PluginValidation + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &got), e.stdout.String()) + + assert.False(t, got.Valid) + + paths := map[string]string{} + for _, problem := range got.Problems { + paths[problem.Path] = problem.Message + } + + assert.Contains(t, paths, "extra") + assert.Contains(t, paths, "id") + assert.Len(t, got.Problems, 3) + }) + + t.Run("the problems are a table for a person", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + build.writeManifestText(t, `{"id":"auth0/checkmate"}`) + + err := e.run("plugin", "dev", "validate", build.Dir) + + require.Error(t, err) + assertValidationError(t, err, "plugin_manifest_invalid") + assert.Contains(t, e.stdout.String(), "PATH") + assert.Contains(t, e.stdout.String(), "version") + assert.NotContains(t, e.stderr.String(), "is valid") + }) + + t.Run("a document that is not JSON has the root as its path", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + build.writeManifestText(t, "not json") + + err := e.run("plugin", "dev", "validate", build.Dir, "--json") + + require.Error(t, err) + + var got display.PluginValidation + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &got), e.stdout.String()) + + require.Len(t, got.Problems, 1) + assert.Equal(t, "(root)", got.Problems[0].Path) + }) + + t.Run("the name of a core command is a problem", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + build.Manifest.ID = "auth0/apps" + build.save(t) + + err := e.run("plugin", "dev", "validate", build.Dir, "--json") + + require.Error(t, err) + + var got display.PluginValidation + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &got), e.stdout.String()) + + require.Len(t, got.Problems, 1) + assert.Equal(t, "id", got.Problems[0].Path) + assert.Contains(t, got.Problems[0].Message, `auth0 already has a command named "apps"`) + }) + + t.Run("an ID that is not safe to use as a path is a problem", func(t *testing.T) { + for id, part := range map[string]string{"auth0/con": "con", "auth0/nul": "nul", "auth0/com1": "com1", "nul/checkmate": "nul"} { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + build.Manifest.ID = id + build.save(t) + + err := e.run("plugin", "dev", "validate", build.Dir, "--json") + + require.Error(t, err, id) + + var got display.PluginValidation + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &got), e.stdout.String()) + + require.Len(t, got.Problems, 1, id) + assert.Equal(t, "id", got.Problems[0].Path, id) + assert.Contains(t, got.Problems[0].Message, `"`+part+`" cannot be a directory name`, id) + } + }) + + t.Run("a file or directory that cannot be read has no result", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + for _, path := range []string{filepath.Join(build.Dir, "missing"), t.TempDir()} { + err := e.run("plugin", "dev", "validate", path) + + require.Error(t, err, path) + assertValidationError(t, err, "plugin_manifest_invalid") + assert.Empty(t, e.stdout.String()) + } + }) + + t.Run("checks the executable with --bin", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.NoError(t, e.run("plugin", "dev", "validate", build.Dir, "--bin", build.Bin)) + + missing := filepath.Join(build.Dir, "missing") + err := e.run("plugin", "dev", "validate", build.Dir, "--bin", missing, "--json-compact") + + require.EqualError(t, err, "the plugin manifest has 1 problem") + assertValidationError(t, err, "plugin_manifest_invalid") + + var got display.PluginValidation + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &got), e.stdout.String()) + + require.Len(t, got.Problems, 1) + assert.Equal(t, "--bin", got.Problems[0].Path) + assert.Contains(t, got.Problems[0].Message, missing) + }) + + t.Run("takes one output format", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + require.Error(t, e.run("plugin", "dev", "validate", build.Dir, "--json", "--json-compact")) + assert.Empty(t, e.stdout.String()) + }) +} + +func TestSplitProblem(t *testing.T) { + tests := []struct { + name string + problem string + want display.PluginProblem + }{ + {"a field", "id: required field is missing", display.PluginProblem{Path: "id", Message: "required field is missing"}}, + {"a field of a command", "commands[0].name: must not be empty", display.PluginProblem{Path: "commands[0].name", Message: "must not be empty"}}, + {"a quoted key with a space", `permissions["a b"]: unknown`, display.PluginProblem{Path: `permissions["a b"]`, Message: "unknown"}}, + {"a message with a colon", "id: must match: a-z", display.PluginProblem{Path: "id", Message: "must match: a-z"}}, + {"no path", "the manifest is not valid JSON", display.PluginProblem{Path: "(root)", Message: "the manifest is not valid JSON"}}, + {"words before the colon", "the manifest is not valid JSON: unexpected end", display.PluginProblem{Path: "(root)", Message: "the manifest is not valid JSON: unexpected end"}}, + {"nothing", "", display.PluginProblem{Path: "(root)", Message: ""}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, splitProblem(test.problem)) + }) + } +} + +func TestDevLinks(t *testing.T) { + t.Run("reads nothing when dev mode is off", func(t *testing.T) { + dir := t.TempDir() + + // A damaged file would be an error if it were read. + require.NoError(t, os.WriteFile(filepath.Join(dir, devlink.FileName), []byte("{damaged"), 0o600)) + + for _, value := range devModeValues { + t.Setenv(pluginDevModeEnv, value) + + links, err := devLinks(dir) + + require.NoError(t, err, value) + assert.Nil(t, links, value) + } + }) + + t.Run("reads the links in dev mode", func(t *testing.T) { + t.Setenv(pluginDevModeEnv, "true") + + dir := t.TempDir() + link := devlink.Link{Path: filepath.Join(dir, "build"), Bin: filepath.Join(dir, "build", "checkmate")} + require.NoError(t, devlink.Links{Dir: dir}.Add(context.Background(), pluginTestID, link)) + + links, err := devLinks(dir) + + require.NoError(t, err) + assert.Equal(t, map[string]devlink.Link{pluginTestID: link}, links) + + require.NoError(t, os.WriteFile(filepath.Join(dir, devlink.FileName), []byte("{damaged"), 0o600)) + + _, err = devLinks(dir) + require.ErrorIs(t, err, devlink.ErrDamaged) + }) +} + +func TestLinkedPlugin(t *testing.T) { + t.Run("a plugin that is linked is not linked when dev mode is off", func(t *testing.T) { + e := newPluginEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + e.link(build) + + for _, value := range devModeValues { + t.Setenv(pluginDevModeEnv, value) + + got, linked, err := linkedPlugin(e.cli, e.svc, pluginTestID) + + require.NoError(t, err, value) + assert.False(t, linked, value) + assert.Nil(t, got, value) + } + }) + + t.Run("the build as it is on disk now", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + e.link(build) + + got, linked, err := linkedPlugin(e.cli, e.svc, pluginTestID) + + require.NoError(t, err) + require.True(t, linked) + assert.Equal(t, "1.0.0", got.Manifest.Version) + + build.Manifest.Version = "1.1.0" + build.save(t) + + got, _, err = linkedPlugin(e.cli, e.svc, pluginTestID) + + require.NoError(t, err) + assert.Equal(t, "1.1.0", got.Manifest.Version, "an edit shows without linking again") + + _, linked, err = linkedPlugin(e.cli, e.svc, "auth0/other") + require.NoError(t, err) + assert.False(t, linked) + }) + + t.Run("a link that cannot be used is an error and not a plugin that is not linked", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + e.link(build) + require.NoError(t, os.Remove(build.Bin)) + + _, linked, err := linkedPlugin(e.cli, e.svc, pluginTestID) + + require.Error(t, err) + assert.True(t, linked) + assertValidationError(t, err, "plugin_link_invalid") + assert.Contains(t, err.Error(), "the linked plugin auth0/checkmate cannot be used") + assert.Contains(t, err.Error(), build.Bin) + assert.Contains(t, err.Error(), "auth0 plugin dev validate "+build.Dir) + assert.Contains(t, err.Error(), "auth0 plugin dev unlink checkmate") + }) + + t.Run("a file of links that is damaged is a warning and no link", func(t *testing.T) { + e := newDevEnv(t) + e.cli.renderer = &display.Renderer{MessageWriter: e.stderr, ResultWriter: e.stdout} + + require.NoError(t, os.MkdirAll(e.Dir, 0o700)) + require.NoError(t, os.WriteFile(e.linksPath(), []byte("{damaged"), 0o600)) + + _, linked, err := linkedPlugin(e.cli, e.svc, pluginTestID) + + require.NoError(t, err) + assert.False(t, linked) + assert.Contains(t, e.stderr.String(), "No plugin is linked") + assert.Contains(t, e.stderr.String(), e.linksPath()) + }) +} + +func TestUnusableLink(t *testing.T) { + tests := []struct { + name string + id string + path string + want []string + }{ + {"plain text", "auth0/checkmate", "/work/checkmate", []string{"the linked plugin auth0/checkmate cannot be used: it broke.", "validate /work/checkmate,", "unlink checkmate"}}, + {"a control character in the path", "auth0/checkmate", "/work/\x1b[31mevil", []string{"validate /work/\ufffd[31mevil,"}}, + {"a control character in the ID", "auth0/check\x1bmate", "/work/checkmate", []string{"plugin auth0/check\ufffdmate cannot", "unlink check\ufffdmate"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := unusableLink(test.id, devlink.Link{Path: test.path, Bin: test.path}, errors.New("it broke")) + + assertValidationError(t, err, "plugin_link_invalid") + assert.NotContains(t, err.Error(), "\x1b") + + for _, want := range test.want { + assert.Contains(t, err.Error(), want) + } + }) + } +} + +func TestVerifiedLinked(t *testing.T) { + build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + + opened, err := devlink.Link{Path: build.Dir, Bin: build.Bin}.Open(pluginTestID) + require.NoError(t, err) + + verified := verifiedLinked(opened) + + assert.Equal(t, pluginTestID, verified.ID) + assert.Equal(t, "1.0.0", verified.Version) + assert.Equal(t, build.Dir, verified.Dir) + assert.Equal(t, build.Bin, verified.Bin) + assert.Equal(t, filepath.Join(build.Dir, devlink.ManifestName), verified.ManifestPath) + assert.Equal(t, build.Manifest.PermissionsHash(), verified.PermissionsHash, "the hash is that of the manifest just read") +} diff --git a/internal/cli/plugin_run.go b/internal/cli/plugin_run.go index 4c88b5ca4..c99a35bf3 100644 --- a/internal/cli/plugin_run.go +++ b/internal/cli/plugin_run.go @@ -23,6 +23,7 @@ import ( "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/devlink" "github.com/auth0/auth0-cli/internal/plugins/proxy" "github.com/auth0/auth0-cli/internal/plugins/registry" "github.com/auth0/auth0-cli/internal/plugins/runner" @@ -73,6 +74,10 @@ var pluginOutputFlags = [...]string{"json", "json-compact", "csv"} // of the CLI must keep working. Whether a plugin may run is decided when it is // run, see pluginCommand.run. // +// In dev mode, and only then, the plugins that an author linked are added first, +// and take the place of an installed plugin with the same name. Outside dev mode +// the file of the links is not read. +// // 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 @@ -87,8 +92,16 @@ func registerPluginCommands(rootCmd *cobra.Command, cli *cli, version string, tr return } - manifests, err := installedStore.Manifests() - if err != nil || len(manifests) == 0 { + var manifests map[string]*plugins.Manifest + + if installed, err := installedStore.Manifests(); err == nil { + manifests = installed + } + + // A file of links that cannot be read registers no link. + links, _ := devLinks(installedStore.Dir) + + if len(manifests) == 0 && len(links) == 0 { return } @@ -105,6 +118,20 @@ func registerPluginCommands(rootCmd *cobra.Command, cli *cli, version string, tr } } + // The linked plugins come first, so that the check for a name in use below + // leaves an installed plugin with the name of a linked one out. + for _, id := range slices.Sorted(maps.Keys(links)) { + name := plugins.NameOf(id) + + if core[name] || slices.ContainsFunc(rootCmd.Commands(), func(c *cobra.Command) bool { return c.Name() == name }) { + continue + } + + rootCmd.AddCommand(newLinkedCommand(cli, rootCmd, id, links[id])) + + registered = true + } + for _, id := range slices.Sorted(maps.Keys(manifests)) { name := plugins.NameOf(id) @@ -136,6 +163,39 @@ func registerPluginCommands(rootCmd *cobra.Command, cli *cli, version string, tr } } +// newLinkedCommand returns the command of a linked plugin, built from the +// manifest in the directory of the build as it is now. A link whose manifest or +// executable cannot be used is not registered as a plugin: its command only says +// why when it is run, and never runs the build or an installed plugin of the +// same name. +func newLinkedCommand(cli *cli, rootCmd *cobra.Command, id string, link devlink.Link) *cobra.Command { + build, err := link.Open(id) + + var command *cobra.Command + + if err != nil { + p := &pluginCommand{cli: cli, id: id, link: &link} + + command = &cobra.Command{ + Use: plugins.NameOf(id), + Short: "(Linked, cannot be used) " + plugins.SafeText(link.Path), + Args: cobra.ArbitraryArgs, + DisableFlagParsing: true, + Annotations: pluginAnnotations(id, "", nil), + PersistentPreRunE: p.preRun, + RunE: func(*cobra.Command, []string) error { return unusableLink(id, link, err) }, + } + + addPluginFlags(command, cli, rootCmd, plugins.Command{}) + } else { + command = buildPluginCommand(rootCmd, &pluginCommand{cli: cli, id: id, version: build.Manifest.Version, link: &link}, build.Manifest) + } + + command.GroupID = pluginGroupID + + return command +} + // groupPluginCommands shows the commands of the plugins in the help of the root // under their own heading. The root help lists the commands under one heading, so // the core commands, which are the ones that are not a plugin command, get a group @@ -220,13 +280,19 @@ func (c *cli) warnShadowedPlugin(cmd *cobra.Command) { 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. +// pluginCommand is an installed plugin as the command tree sees it, or a plugin +// that an author linked from a local build. type pluginCommand struct { cli *cli id string version string + // Link is set for a linked plugin, and is nil for an installed one. It is the + // only thing that makes a run skip the checks of the files of an installed + // plugin, see verify. + link *devlink.Link + // 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 @@ -240,7 +306,13 @@ type pluginCommand struct { // 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} + return buildPluginCommand(rootCmd, &pluginCommand{cli: cli, id: id, version: manifest.Version}, manifest) +} + +// buildPluginCommand is newPluginCommand for a plugin command that is already +// made, which tells whether the plugin is installed or linked. +func buildPluginCommand(rootCmd *cobra.Command, p *pluginCommand, manifest *plugins.Manifest) *cobra.Command { + cli, id := p.cli, p.id rootSpec := manifest.RootCommand() @@ -568,7 +640,7 @@ func ownedFlags(root *cobra.Command) []runner.OwnedFlag { func (p *pluginCommand) preRun(cmd *cobra.Command, args []string) error { // From here on the command is a plugin run, even when it fails before the // plugin is known, so that it never sends the event of a core command. - p.cli.pluginRun = &pluginRunIdentity{id: p.id} + p.cli.pluginRun = &pluginRunIdentity{id: p.id, linked: p.link != nil} scanned, err := runner.Scan(args, ownedFlags(cmd.Root())) if err != nil { @@ -641,8 +713,11 @@ func (p *pluginCommand) namespace(cmd *cobra.Command) error { // 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. +// 2. The installed files are the ones that were installed (Store.Verify). For a +// linked plugin there is nothing to compare with, so its manifest and +// executable are read and checked as they are, and it needs dev mode. +// 3. The registry index is current enough, and does not revoke this version. A +// linked plugin is not in the index, so it cannot be revoked. // 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. @@ -655,7 +730,9 @@ func (p *pluginCommand) namespace(cmd *cobra.Command) error { // 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. +// to log in. For a linked plugin that second check reads the manifest again and +// compares its permissions with the ones that were approved, so that a build that +// is edited in between cannot run with more than the person approved. func (p *pluginCommand) run(cmd *cobra.Command, spec plugins.Command, lead []string) error { if p.scanned.Help { return cmd.Help() @@ -692,8 +769,13 @@ func (p *pluginCommand) run(cmd *cobra.Command, spec plugins.Command, lead []str return err } - // The plugin and its version are known now. A run that got this far is counted. - c.pluginRun = &pluginRunIdentity{id: p.id, version: verified.Version} + // The plugin and its version are known now. A run that got this far is counted, + // unless the plugin is linked, see trackPluginRun. + c.pluginRun = &pluginRunIdentity{id: p.id, version: verified.Version, linked: p.link != nil} + + if p.link != nil && !c.wantsJSONError() { + c.renderer.Warnf("%s", linkedBanner(p.id, verified.Version, verified.Dir)) + } command, ok := commandOf(verified.Manifest, spec.Name) if !ok { @@ -708,12 +790,8 @@ func (p *pluginCommand) run(cmd *cobra.Command, spec plugins.Command, lead []str 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.checkRevoked(index, verified.Version); err != nil { + return err } if err := p.checkCall(cmd, svc, command); err != nil { @@ -832,13 +910,22 @@ func (p *pluginCommand) run(cmd *cobra.Command, spec plugins.Command, lead []str return p.failure(err, summary) } - p.hintUpdate(svc, index, again.Version) + // The hint compares the version that is installed with the registry. A linked + // build is not installed. + if p.link == nil { + p.hintUpdate(svc, index, again.Version) + } return nil } -// verify checks the installed files of the plugin against the lockfile. +// verify checks the installed files of the plugin against the lockfile. For a +// linked plugin it reads the build instead, see verifyLinked. func (p *pluginCommand) verify(svc *pluginService) (store.Verified, error) { + if p.link != nil { + return p.verifyLinked() + } + verified, err := svc.store.Verify(p.id) if err == nil { return verified, nil @@ -851,6 +938,26 @@ func (p *pluginCommand) verify(svc *pluginService) (store.Verified, error) { return store.Verified{}, err } +// verifyLinked is verify for a linked plugin, and the one place where a plugin +// runs without Store.Verify. It needs dev mode. The manifest is read and validated +// as it is on disk now, it must be for this plugin, and the executable must exist +// and be a regular file that can be run. Nothing is hashed, because a build +// changes all the time, and nothing is compared with a lockfile. The permissions +// hash is that of the manifest just read, which the caller compares with the +// approval, and again after the session is open. +func (p *pluginCommand) verifyLinked() (store.Verified, error) { + if err := requirePluginDevMode(); err != nil { + return store.Verified{}, err + } + + build, err := p.link.Open(p.id) + if err != nil { + return store.Verified{}, unusableLink(p.id, *p.link, err) + } + + return verifiedLinked(build), nil +} + // 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 }) @@ -898,6 +1005,25 @@ func (p *pluginCommand) loadIndex(ctx context.Context, svc *pluginService, versi return index, nil } +// checkRevoked fails when the registry revoked this version of the plugin. A +// linked build is not in the index, so there is nothing to revoke. +func (p *pluginCommand) checkRevoked(index *registry.Index, version string) error { + if p.link != nil { + return nil + } + + revocation, revoked := index.Revoked(p.id, version) + if !revoked { + return nil + } + + return validationError{ + err: fmt.Errorf("%s %s was revoked: %s. Run: auth0 plugin update %s", + plugins.NameOf(p.id), version, strings.TrimRight(plugins.SafeText(revocation.Reason), ". "), plugins.NameOf(p.id)), + reason: "plugin_revoked", + } +} + // 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. diff --git a/internal/cli/plugin_run_test.go b/internal/cli/plugin_run_test.go index 677e6c743..3beed79c3 100644 --- a/internal/cli/plugin_run_test.go +++ b/internal/cli/plugin_run_test.go @@ -35,6 +35,7 @@ import ( "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/devlink" "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" @@ -346,21 +347,26 @@ func newRunEnv(t *testing.T, commands []plugins.Command, change ...func(*registr 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{ +// fixtureManifest returns the manifest of a version of the fixture plugin. +func fixtureManifest(platform plugins.Platform, version string, commands []plugins.Command) plugins.Manifest { + return 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}, + Platforms: []plugins.Platform{platform}, Commands: commands, } +} + +// 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 := fixtureManifest(r.platform, version, commands) record := registrytest.NewVersion(version) record.Platforms = manifest.Platforms @@ -2965,3 +2971,575 @@ func TestPluginRun_DeniedCallsInTheEnvelope(t *testing.T) { } }) } + +// linkFixture links a build of the fixture plugin whose executable is the test +// binary, with dev mode on. +func (r *runEnv) linkFixture(version string, commands []plugins.Command) devBuild { + r.t.Helper() + + r.t.Setenv(pluginDevModeEnv, "true") + + binary, err := fixtureBinary() + require.NoError(r.t, err) + + build := writeDevBuild(r.t, version, commands, binary) + r.link(build) + + return build +} + +func TestPluginRun_Linked(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, reason string) { + t.Helper() + + require.Error(t, err) + assertValidationError(t, err, reason) + assert.False(t, r.started(), "the plugin must not have run") + assert.Empty(t, r.tenant.seen()) + } + + // An approved linked build. + newLinked := func(t *testing.T) (*runEnv, devBuild) { + t.Helper() + + r := newRunEnv(t, fixtureCommands()) + build := r.linkFixture("2.0.0", fixtureCommands()) + r.approveBuild(build) + + return r, build + } + + t.Run("runs through the proxy with the scopes of its command", func(t *testing.T) { + r, build := newLinked(t) + + require.NoError(t, r.exec("checkmate", "--mode=call", "--path=/api/v2/clients")) + + assert.Contains(t, r.stdout.String(), "status=200") + assert.NotContains(t, r.stdout.String(), "shh", "the secret field is removed") + require.Len(t, r.tenant.seen(), 1) + assert.Equal(t, "Bearer "+r.Token, r.tenant.seen()[0].authorization) + assert.Equal(t, 1, strings.Count(r.stderr.String(), linkedBanner(pluginTestID, "2.0.0", build.Dir)), "one banner for one run") + + require.NoError(t, r.exec("checkmate", "--mode=call", "--path=/api/v2/users")) + + assert.Contains(t, r.stdout.String(), "status=403", "the root command cannot read users") + assert.Contains(t, r.stderr.String(), "API call was denied") + assert.Len(t, r.tenant.seen(), 1, "a denied call never reaches the tenant") + + require.NoError(t, r.exec("checkmate", "scan", "--mode=call", "--path=/api/v2/users")) + + assert.Contains(t, r.stdout.String(), "status=200", "scan has the scopes of its own command") + }) + + t.Run("takes the place of the installed plugin of the same name", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + build := r.linkFixture("2.0.0", fixtureCommands()) + r.approveBuild(build) + + require.NoError(t, r.exec("checkmate")) + assert.Contains(t, r.stderr.String(), linkedBanner(pluginTestID, "2.0.0", build.Dir), "the linked build ran") + + require.NoError(t, devlink.Links{Dir: r.Dir}.Remove(context.Background(), pluginTestID)) + require.NoError(t, r.exec("checkmate")) + + assert.NotContains(t, r.stderr.String(), "linked build", "the installed plugin runs again") + assert.True(t, r.started()) + }) + + t.Run("runs although the installed copy fails verification", func(t *testing.T) { + r, build := newLinked(t) + + verified, err := r.svc.store.Verify(pluginTestID) + require.NoError(t, err) + + tamper(t, verified.Bin) + + require.NoError(t, r.exec("checkmate")) + assert.Contains(t, r.stderr.String(), linkedBanner(pluginTestID, "2.0.0", build.Dir)) + assert.True(t, r.started()) + }) + + t.Run("reads the build as it is on every run", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("a running executable cannot be replaced") + } + + r, build := newLinked(t) + + require.NoError(t, r.exec("checkmate")) + + // A rebuild: a new executable in the place of the old one, and an edit of the + // manifest that changes nothing that was approved. + binary, err := fixtureBinary() + require.NoError(t, err) + + staged := build.Bin + ".new" + require.NoError(t, os.WriteFile(staged, binary, 0o755)) + require.NoError(t, os.Rename(staged, build.Bin)) + + build.Manifest.Version = "2.1.0" + build.Manifest.Description = "Checks the tenant again" + build.save(t) + + require.NoError(t, r.exec("checkmate")) + + assert.Contains(t, r.stderr.String(), linkedBanner(pluginTestID, "2.1.0", build.Dir), "no new link and no new approval") + assert.True(t, r.started()) + }) + + t.Run("still needs the approval of a person", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + // The approval of the installed copy does not count for a build. + r.linkFixture("2.0.0", []plugins.Command{{Name: pluginTestBin, Description: "Check a tenant", Permissions: fixturePermissions([]string{"read:clients", "read:users"})}}) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "plugin_not_approved") + assert.Contains(t, err.Error(), "auth0 plugin approve auth0/checkmate --tenant "+pluginTestTenant) + assert.Zero(t, r.logins, "an unapproved build never starts a login") + }) + + t.Run("needs a new approval when the permissions changed", func(t *testing.T) { + r, build := newLinked(t) + + require.NoError(t, r.exec("checkmate")) + + build.Manifest.Commands[0].Permissions = fixturePermissions([]string{"read:clients", "delete:clients"}) + build.save(t) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "plugin_not_approved") + assert.Zero(t, r.logins) + + r.approveBuild(build) + require.NoError(t, r.exec("checkmate")) + assert.True(t, r.started()) + }) + + t.Run("fails when the manifest changed while the session was opened", func(t *testing.T) { + r, build := newLinked(t) + + login := r.svc.login + r.svc.login = func(ctx context.Context, c *cli) error { + if err := login(ctx, c); err != nil { + return err + } + + build.Manifest.Commands[0].Permissions = fixturePermissions([]string{"read:clients", "delete:clients"}) + build.save(t) + + return nil + } + + err := r.exec("checkmate") + + require.Error(t, err) + assert.Equal(t, "plugin_changed", errorReason(err)) + assert.Equal(t, 1, r.logins, "the build is read again after the session is open") + assert.False(t, r.started()) + assert.Empty(t, r.tenant.seen()) + }) + + t.Run("fails when the executable went away while the session was opened", func(t *testing.T) { + r, build := newLinked(t) + + login := r.svc.login + r.svc.login = func(ctx context.Context, c *cli) error { + if err := login(ctx, c); err != nil { + return err + } + + return os.Remove(build.Bin) + } + + err := r.exec("checkmate") + + require.Error(t, err) + assertValidationError(t, err, "plugin_link_invalid") + assert.False(t, r.started()) + }) + + t.Run("keeps the index of the registry", 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)) + }) + build := r.linkFixture("2.0.0", fixtureCommands()) + r.approveBuild(build) + + r.Now = r.Now.Add(2 * time.Hour) + r.server.Fail("/index.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) + }) + + t.Run("keeps the rules of the tenant", func(t *testing.T) { + r, _ := newLinked(t) + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, "fixed.us.auth0.com") + t.Setenv(envAPIToken, "env-token") + + err := r.exec("checkmate", "--tenant", pluginTestTenant) + + require.Error(t, err) + assert.Equal(t, "env_auth_tenant_conflict", errorReason(err)) + assert.False(t, r.started()) + assert.Zero(t, r.logins) + }) + + t.Run("keeps the tenant of the session", func(t *testing.T) { + r, _ := newLinked(t) + r.Domain = "other.us.auth0.com" + + err := r.exec("checkmate") + + require.Error(t, err) + assert.Equal(t, "plugin_tenant_mismatch", errorReason(err)) + assert.False(t, r.started()) + }) + + t.Run("passes only the environment the command declared", func(t *testing.T) { + r, _ := newLinked(t) + + t.Setenv("AUTH0_CLIENT_SECRET", "the-secret-of-the-cli") + t.Setenv(pluginFixtureEnv, "declared-value") + t.Setenv("UNDECLARED_VARIABLE", "undeclared") + + require.NoError(t, r.exec("checkmate", "scan")) + + env := r.report(t).Env + + assert.Equal(t, "declared-value", env[pluginFixtureEnv]) + assert.NotContains(t, env, "AUTH0_CLIENT_SECRET") + assert.NotContains(t, env, "UNDECLARED_VARIABLE") + assert.NotContains(t, env, pluginDevModeEnv) + }) + + t.Run("stops a command that runs out of time", func(t *testing.T) { + commands := fixtureCommands() + commands[1].Timeout = "1s" + + r := newRunEnv(t, commands) + build := r.linkFixture("2.0.0", commands) + r.approveBuild(build) + + err := r.exec("checkmate", "scan", "--mode=sleep", "--marker="+filepath.Join(t.TempDir(), "pid")) + + require.Error(t, err) + assert.Equal(t, "plugin_timeout", errorReason(err)) + }) + + t.Run("refuses an output the command does not support", func(t *testing.T) { + r, _ := newLinked(t) + + err := r.exec("checkmate", "scan", "--json") + + require.Error(t, err) + assert.Equal(t, "plugin_output_unsupported", errorReason(err)) + assert.False(t, r.started()) + assert.Empty(t, r.tenant.seen()) + }) + + t.Run("prints no banner in machine output", func(t *testing.T) { + for _, flag := range []string{"--json", "--json-compact", "--agent-mode"} { + r, _ := newLinked(t) + + require.NoError(t, r.exec("checkmate", flag, "--mode=call", "--path=/api/v2/clients"), flag) + + assert.Contains(t, r.stdout.String(), "status=200", flag) + assert.Empty(t, r.stderr.String(), flag) + } + }) + + t.Run("is not revoked by the registry", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + build := r.linkFixture("1.0.0", fixtureCommands()) + r.approveBuild(build) + r.approve() + + r.republish(r.fixtureIndex(r.record).Revoke(pluginTestID, "1.0.0", "it leaks tokens.")) + + require.NoError(t, r.exec("checkmate")) + assert.True(t, r.started()) + + // The installed copy of the same version is revoked, so the revocation is read. + require.NoError(t, devlink.Links{Dir: r.Dir}.Remove(context.Background(), pluginTestID)) + + err := r.exec("checkmate") + + assertRefused(t, r, err, "plugin_revoked") + }) + + t.Run("does not hint at an update of the installed copy", func(t *testing.T) { + r := newHintEnv(t) + build := r.linkFixture("1.0.0", fixtureCommands()) + r.approveBuild(build) + + require.NoError(t, r.exec("checkmate")) + + assert.NotContains(t, r.stderr.String(), hintLine) + assert.NoFileExists(t, hintPath(r)) + + require.NoError(t, devlink.Links{Dir: r.Dir}.Remove(context.Background(), pluginTestID)) + require.NoError(t, r.exec("checkmate")) + + assert.Contains(t, r.stderr.String(), hintLine, "the installed copy still gets it") + }) +} + +func TestPluginRun_LinkedNeedsDevMode(t *testing.T) { + for _, value := range devModeValues { + t.Run("the links are ignored with dev mode "+value, func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + build := r.linkFixture("2.0.0", fixtureCommands()) + r.approveBuild(build) + + t.Setenv(pluginDevModeEnv, value) + + require.NoError(t, r.exec("checkmate")) + + assert.NotContains(t, r.stderr.String(), "linked build", "the installed plugin ran") + assert.True(t, r.started()) + + // A tampered copy is refused, and the link does not stand in for it. + verified, err := r.svc.store.Verify(pluginTestID) + require.NoError(t, err) + + tamper(t, verified.Bin) + + err = r.exec("checkmate") + + require.Error(t, err) + assert.Equal(t, "plugin_verification_failed", errorReason(err)) + assert.False(t, r.started()) + }) + } + + t.Run("a run does not start when dev mode went off after the commands were made", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + build := r.linkFixture("2.0.0", fixtureCommands()) + r.approveBuild(build) + + 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) + + // The command of the linked build is in the tree. + found, _, err := root.Find([]string{pluginTestBin}) + require.NoError(t, err) + require.NotEqual(t, root, found) + + t.Setenv(pluginDevModeEnv, "") + + root.SetArgs([]string{"checkmate"}) + + err = root.ExecuteContext(context.Background()) + + require.Error(t, err) + assertValidationError(t, err, "plugin_dev_mode_required") + assert.False(t, r.started()) + assert.Zero(t, r.logins) + assert.Empty(t, r.tenant.seen()) + }) + + t.Run("verify of a linked plugin needs dev mode", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + build := r.linkFixture("2.0.0", fixtureCommands()) + + link := devlink.Link{Path: build.Dir, Bin: build.Bin} + linked := &pluginCommand{cli: r.cli, id: pluginTestID, link: &link} + + verified, err := linked.verify(r.svc) + + require.NoError(t, err) + assert.Equal(t, "2.0.0", verified.Version) + + t.Setenv(pluginDevModeEnv, "") + + _, err = linked.verify(r.svc) + + require.Error(t, err) + assertValidationError(t, err, "plugin_dev_mode_required") + }) +} + +func TestRegisterPluginCommands_Linked(t *testing.T) { + t.Run("a linked plugin comes first, and takes the name of an installed one", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + build := r.linkFixture("2.0.0", fixtureCommands()) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + registerPluginCommands(root, r.cli, "dev", nil) + + require.Len(t, root.Commands(), 1, "one command for the name") + + var info struct { + Version string `json:"version"` + } + + require.NoError(t, json.Unmarshal([]byte(root.Commands()[0].Annotations[pluginInfoAnnotation]), &info)) + assert.Equal(t, build.Manifest.Version, info.Version, "the command is the one of the linked build") + }) + + t.Run("a linked plugin with no installed plugin", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, e.cli) + registerPluginCommands(root, e.cli, "dev", nil) + + require.Len(t, root.Commands(), 1) + assert.Equal(t, pluginTestBin, root.Commands()[0].Name()) + }) + + t.Run("a name of a core command is not registered", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + + root := &cobra.Command{Use: "auth0"} + root.AddCommand(&cobra.Command{Use: pluginTestBin}) + + registerPluginCommands(root, e.cli, "dev", nil) + + assert.Len(t, root.Commands(), 1) + }) + + t.Run("nothing is registered or read when dev mode is off", func(t *testing.T) { + for _, value := range devModeValues { + e := newPluginEnv(t) + t.Setenv(pluginDevModeEnv, value) + + // A file that would be an error if it were read. + require.NoError(t, os.MkdirAll(e.Dir, 0o700)) + require.NoError(t, os.WriteFile(e.linksPath(), []byte("{damaged"), 0o600)) + + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + require.NoError(t, os.WriteFile(e.linksPath(), []byte(`{"formatVersion":1,"links":{"auth0/checkmate":{"path":`+strconv.Quote(build.Dir)+`,"bin":`+strconv.Quote(build.Bin)+`}}}`), 0o600)) + + root := &cobra.Command{Use: "auth0"} + + assert.NotPanics(t, func() { registerPluginCommands(root, e.cli, "dev", []*cobra.Command{nil}) }) + assert.Empty(t, root.Commands(), value) + } + }) + + t.Run("a file of links that is damaged registers no link and breaks nothing", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + t.Setenv(pluginDevModeEnv, "true") + + require.NoError(t, os.WriteFile(filepath.Join(r.Dir, devlink.FileName), []byte("{damaged"), 0o600)) + + root := &cobra.Command{Use: "auth0"} + addPersistentFlags(root, r.cli) + + assert.NotPanics(t, func() { registerPluginCommands(root, r.cli, "dev", nil) }) + + require.Len(t, root.Commands(), 1, "the installed plugin is registered as it is without links") + + require.NoError(t, r.exec("apps", "--help")) + assert.Empty(t, r.stderr.String()) + + require.NoError(t, r.exec("checkmate")) + assert.NotContains(t, r.stderr.String(), "linked build") + assert.True(t, r.started()) + }) + + t.Run("a damaged file with nothing installed registers nothing", func(t *testing.T) { + e := newDevEnv(t) + + require.NoError(t, os.MkdirAll(e.Dir, 0o700)) + require.NoError(t, os.WriteFile(e.linksPath(), []byte("{damaged"), 0o600)) + + root := &cobra.Command{Use: "auth0"} + + assert.NotPanics(t, func() { registerPluginCommands(root, e.cli, "dev", []*cobra.Command{nil}) }) + assert.Empty(t, root.Commands()) + }) +} + +func TestPluginRun_LinkedThatCannotBeUsed(t *testing.T) { + tests := []struct { + name string + damage func(t *testing.T, build devBuild) + want []string + }{ + { + name: "the executable is gone", + damage: func(t *testing.T, build devBuild) { require.NoError(t, os.Remove(build.Bin)) }, + want: []string{"the linked plugin auth0/checkmate cannot be used", "{bin}"}, + }, + { + name: "the manifest is gone", + damage: func(t *testing.T, build devBuild) { + require.NoError(t, os.Remove(filepath.Join(build.Dir, devlink.ManifestName))) + }, + want: []string{"the linked plugin auth0/checkmate cannot be used", devlink.ManifestName}, + }, + { + name: "the manifest has a problem", + damage: func(t *testing.T, build devBuild) { build.writeManifestText(t, `{"id":"auth0/checkmate"}`) }, + want: []string{"cannot be used", "version:"}, + }, + { + name: "the manifest is for another plugin", + damage: func(t *testing.T, build devBuild) { + build.Manifest.ID = "auth0/other" + build.save(t) + }, + want: []string{"is for the plugin auth0/other, not auth0/checkmate"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + build := r.linkFixture("2.0.0", fixtureCommands()) + r.approveBuild(build) + + test.damage(t, build) + + for _, args := range [][]string{{"checkmate"}, {"checkmate", "scan"}, {"checkmate", "--agent-mode"}} { + err := r.exec(args...) + + require.Error(t, err, args) + assertValidationError(t, err, "plugin_link_invalid") + + for _, want := range test.want { + assert.Contains(t, err.Error(), strings.ReplaceAll(want, "{bin}", build.Bin)) + } + + assert.Contains(t, err.Error(), "auth0 plugin dev validate "+build.Dir) + assert.Contains(t, err.Error(), "auth0 plugin dev unlink checkmate") + assert.False(t, r.started(), "neither the build nor the installed plugin runs") + assert.Empty(t, r.tenant.seen()) + assert.Zero(t, r.logins) + } + }) + } +} diff --git a/internal/cli/plugin_telemetry.go b/internal/cli/plugin_telemetry.go index 70b780f55..1e113fae3 100644 --- a/internal/cli/plugin_telemetry.go +++ b/internal/cli/plugin_telemetry.go @@ -15,6 +15,10 @@ import ( type pluginRunIdentity struct { id string version string + + // Linked is true for a plugin that an author runs from a local build. Its + // name can be private and unreleased, so its runs are never counted. + linked bool } // trackablePlugin reports whether events may name the plugin. Only the plugins of @@ -26,10 +30,10 @@ func trackablePlugin(id string) bool { // trackPluginRun sends the event of a plugin run: the plugin, its version and how // the run ended. Nothing else about the run is sent. A run that stopped before the -// plugin was verified sends nothing. +// plugin was verified sends nothing, and neither does a run of a linked plugin. func trackPluginRun(c *cli, executionErr error) { run := c.pluginRun - if run == nil || run.version == "" { + if run == nil || run.version == "" || run.linked { return } diff --git a/internal/cli/plugin_telemetry_test.go b/internal/cli/plugin_telemetry_test.go index a1b967649..3ed9448b3 100644 --- a/internal/cli/plugin_telemetry_test.go +++ b/internal/cli/plugin_telemetry_test.go @@ -24,6 +24,7 @@ import ( "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/devlink" "github.com/auth0/auth0-cli/internal/plugins/store" ) @@ -843,3 +844,67 @@ func appendToFile(path, text string) error { return file.Close() } + +func TestPluginRunTelemetry_LinkedRunsAreNotTracked(t *testing.T) { + tests := []struct { + name string + unapproved bool + args []string + }{ + {name: "a run that succeeds", args: []string{"checkmate"}}, + {name: "a plugin that exits with a code", args: []string{"checkmate", "--mode=exit", "--code=7"}}, + {name: "not approved", unapproved: true, args: []string{"checkmate"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + recorder := newEventRecorder(t, r.cli) + + build := r.linkFixture("2.0.0", fixtureCommands()) + if !test.unapproved { + r.approveBuild(build) + } + + events, _ := r.tracked(t, recorder, test.args...) + + assert.Empty(t, events, "a build that is not published sends nothing: %v", eventNames(events)) + assert.Empty(t, recorder.raw()) + }) + } + + t.Run("a link that cannot be used", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + recorder := newEventRecorder(t, r.cli) + + build := r.linkFixture("2.0.0", fixtureCommands()) + require.NoError(t, os.Remove(build.Bin)) + + events, err := r.tracked(t, recorder, "checkmate") + + require.Error(t, err) + assert.Empty(t, events) + }) + + t.Run("the installed plugin is still tracked once the link is gone", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + recorder := newEventRecorder(t, r.cli) + r.approve() + + build := r.linkFixture("2.0.0", fixtureCommands()) + r.approveBuild(build) + + events, err := r.tracked(t, recorder, "checkmate") + require.NoError(t, err) + require.Empty(t, events) + + require.NoError(t, devlink.Links{Dir: r.Dir}.Remove(context.Background(), pluginTestID)) + + events, err = r.tracked(t, recorder, "checkmate") + require.NoError(t, err) + require.Len(t, events, 1) + + assert.Equal(t, "CLI - Plugin - Run", events[0].Event) + assert.Equal(t, "1.0.0", events[0].Properties["plugin_version"]) + }) +} diff --git a/internal/cli/plugin_test.go b/internal/cli/plugin_test.go index 7820a0ceb..57e101c1d 100644 --- a/internal/cli/plugin_test.go +++ b/internal/cli/plugin_test.go @@ -415,7 +415,7 @@ func TestPluginCmd(t *testing.T) { } slices.Sort(names) - assert.Equal(t, []string{"approve", "info", "install", "list", "search", "uninstall", "update"}, names) + assert.Equal(t, []string{"approve", "dev", "info", "install", "list", "search", "uninstall", "update"}, names) } func TestSearchPluginCmd(t *testing.T) { @@ -702,7 +702,8 @@ func TestListPluginCmd(t *testing.T) { assert.Equal(t, "1.0.0", listed[0]["previous"]) assert.Equal(t, true, listed[0]["pinned"]) assert.Greater(t, listed[0]["sizeBytes"], float64(0)) - assert.Len(t, listed[0], 6) + assert.Equal(t, false, listed[0]["linked"]) + assert.Len(t, listed[0], 7) }) t.Run("prints CSV", func(t *testing.T) { @@ -768,7 +769,8 @@ func TestInfoPluginCmd(t *testing.T) { "versions": [{"version": "2.0.0", "latest": true, "revoked": false}, {"version": "1.0.0", "latest": false, "revoked": false}], "installed": null, "deprecated": null, - "approvedTenants": [] + "approvedTenants": [], + "linked": false }`, e.stdout.String()) }) @@ -2862,3 +2864,264 @@ func TestPluginCommands_EnvAuthModeFixesTheTenant(t *testing.T) { }) } } + +func TestListPluginCmd_Linked(t *testing.T) { + t.Run("marks an installed plugin that is linked", func(t *testing.T) { + e := newDevEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + e.link(writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable)) + + require.NoError(t, e.run("plugin", "list")) + + assert.Regexp(t, `ID\s+VERSION\s+PINNED\s+PREVIOUS\s+SIZE\s+LINKED`, e.stdout.String()) + assert.Regexp(t, `auth0/checkmate\s+1\.0\.0\s+yes\s+.*\s+yes\s*\n`, e.stdout.String()) + + require.NoError(t, e.run("plugin", "list", "--json-compact")) + + var listed []map[string]any + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &listed)) + require.Len(t, listed, 1) + assert.Equal(t, true, listed[0]["linked"]) + assert.Equal(t, "1.0.0", listed[0]["version"], "the installed version, as the entry is the installed plugin") + }) + + t.Run("lists a linked plugin that is not installed, with the version of its manifest", func(t *testing.T) { + e := newDevEnv(t) + e.link(writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable)) + + require.NoError(t, e.run("plugin", "list", "--json-compact")) + + assert.JSONEq(t, `[{"id":"auth0/checkmate","name":"checkmate","version":"2.0.0","pinned":false,"previous":"","sizeBytes":0,"linked":true}]`, e.stdout.String()) + + require.NoError(t, e.run("plugin", "list")) + assert.Regexp(t, `auth0/checkmate\s+2\.0\.0\s+no\s+.*yes\s*\n`, e.stdout.String()) + assert.Empty(t, e.stderr.String()) + }) + + t.Run("lists a link that cannot be used without a version, and says why", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + require.NoError(t, os.Remove(build.Bin)) + + require.NoError(t, e.run("plugin", "list", "--json-compact")) + + assert.JSONEq(t, `[{"id":"auth0/checkmate","name":"checkmate","version":"","pinned":false,"previous":"","sizeBytes":0,"linked":true}]`, e.stdout.String()) + assert.Empty(t, e.stderr.String(), "JSON output carries no warning") + + require.NoError(t, e.run("plugin", "list")) + assert.Contains(t, e.stderr.String(), "the linked plugin auth0/checkmate cannot be used") + assert.Contains(t, e.stderr.String(), build.Bin) + }) + + t.Run("lists the installed plugins when the file of links is damaged", func(t *testing.T) { + e := newDevEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + require.NoError(t, os.WriteFile(e.linksPath(), []byte("{damaged"), 0o600)) + + require.NoError(t, e.run("plugin", "list")) + + assert.Contains(t, e.stdout.String(), "auth0/checkmate") + assert.NotContains(t, e.stdout.String(), "LINKED") + assert.Contains(t, e.stderr.String(), "No linked plugin is listed") + assert.Contains(t, e.stderr.String(), e.linksPath()) + + require.NoError(t, e.run("plugin", "list", "--json-compact")) + assert.Empty(t, e.stderr.String(), "JSON output carries no warning") + assert.Contains(t, e.stdout.String(), `"linked":false`) + }) + + t.Run("shows no link and reads no file when dev mode is off", func(t *testing.T) { + for _, value := range devModeValues { + e := newPluginEnv(t) + t.Setenv(pluginDevModeEnv, value) + + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + + e.link(writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable)) + require.NoError(t, os.WriteFile(e.linksPath(), []byte("{damaged"), 0o600)) + + require.NoError(t, e.run("plugin", "list"), value) + + assert.NotContains(t, e.stdout.String(), "LINKED", value) + assert.Empty(t, e.stderr.String(), value) + } + }) +} + +func TestInfoPluginCmd_Linked(t *testing.T) { + t.Run("describes the manifest of the build, without the registry", func(t *testing.T) { + // Nothing is published, so a request to the registry would fail. + e := newDevEnv(t) + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + e.approveBuild(build) + + require.NoError(t, e.run("plugin", "info", "checkmate", "--json-compact")) + + assert.JSONEq(t, `{ + "id": "auth0/checkmate", + "version": "2.0.0", + "description": "Checks the tenant", + "owner": {"team": "Test team", "bugs": "https://example.com/bugs"}, + "homepage": "", + "requires": "", + "platforms": ["`+string(e.platform)+`"], + "permissions": {"scopes": 2, "hosts": 0, "files": 0, "env": 1}, + "permissionsHash": "`+build.Manifest.PermissionsHash()+`", + "versions": [], + "installed": null, + "deprecated": null, + "approvedTenants": [{"tenant": "`+pluginTestTenant+`", "permissionsHash": "`+build.Manifest.PermissionsHash()+`", "approvedAt": "`+e.Now.UTC().Format(time.RFC3339)+`"}], + "linked": true + }`, e.stdout.String()) + + require.NoError(t, e.run("plugin", "info", "auth0/checkmate")) + assert.Regexp(t, `LINKED\s+yes, a local build runs in place of any installed copy`, e.stdout.String()) + assert.Regexp(t, `VERSION\s+2\.0\.0`, e.stdout.String()) + }) + + t.Run("shows an installed copy next to the build", func(t *testing.T) { + e := newDevEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + e.link(writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable)) + + require.NoError(t, e.run("plugin", "info", "checkmate")) + + assert.Regexp(t, `VERSION\s+2\.0\.0`, e.stdout.String()) + assert.Regexp(t, `INSTALLED\s+1\.0\.0`, e.stdout.String()) + }) + + t.Run("a version that was asked for is a version of the registry", func(t *testing.T) { + e := newDevEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.link(writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable)) + + require.NoError(t, e.run("plugin", "info", "checkmate@1.0.0", "--json-compact")) + + object := e.jsonObject() + assert.Equal(t, "1.0.0", object["version"]) + assert.Equal(t, false, object["linked"]) + }) + + t.Run("a link that cannot be used is an error", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + require.NoError(t, os.Remove(build.Bin)) + + err := e.run("plugin", "info", "checkmate") + + require.Error(t, err) + assertValidationError(t, err, "plugin_link_invalid") + assert.Contains(t, err.Error(), build.Bin) + }) + + t.Run("goes to the registry when dev mode is off", func(t *testing.T) { + for _, value := range devModeValues { + e := newPluginEnv(t) + t.Setenv(pluginDevModeEnv, value) + + e.publish(e.index(e.release("1.0.0"))) + e.link(writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable)) + + require.NoError(t, e.run("plugin", "info", "checkmate", "--json-compact"), value) + + object := e.jsonObject() + assert.Equal(t, "1.0.0", object["version"], value) + assert.Equal(t, false, object["linked"], value) + } + }) +} + +func TestApprovePluginCmd_Linked(t *testing.T) { + t.Run("approves the permissions of the build, which need not be installed", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant)) + + assert.Contains(t, e.stderr.String(), "auth0/checkmate 2.0.0 Checks the tenant") + assert.Contains(t, e.stderr.String(), "Approved auth0/checkmate 2.0.0 for "+pluginTestTenant+".") + assert.Equal(t, map[string]string{pluginTestTenant: build.Manifest.PermissionsHash()}, e.approvedHashes(pluginTestID)) + }) + + t.Run("approves the build and not an installed copy that fails verification", func(t *testing.T) { + e := newDevEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + require.NoError(t, os.WriteFile(filepath.Join(e.Dir, "store", "auth0", "checkmate", "1.0.0", registrytest.BinName(e.platform, pluginTestBin)), []byte("tampered"), 0o700)) + + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant)) + + assert.Equal(t, map[string]string{pluginTestTenant: build.Manifest.PermissionsHash()}, e.approvedHashes(pluginTestID)) + }) + + t.Run("approves the permissions as they are in the manifest now", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + + build.Manifest.Commands[0].Permissions = fixturePermissions([]string{"read:clients", "delete:clients"}) + build.save(t) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant)) + + assert.Contains(t, e.stderr.String(), "delete") + assert.Equal(t, map[string]string{pluginTestTenant: build.Manifest.PermissionsHash()}, e.approvedHashes(pluginTestID)) + }) + + t.Run("is still a decision of a person", func(t *testing.T) { + e := newDevEnv(t) + e.link(writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable)) + e.answer("y\n") + e.Terminal = false + + err := e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant) + + require.Error(t, err) + assert.Contains(t, err.Error(), "only a person can approve a plugin") + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + + t.Run("a link that cannot be used is not approved", func(t *testing.T) { + e := newDevEnv(t) + build := writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable) + e.link(build) + require.NoError(t, os.Remove(build.Bin)) + e.answer("y\n") + + err := e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant) + + require.Error(t, err) + assertValidationError(t, err, "plugin_link_invalid") + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + + t.Run("a link counts only in dev mode", func(t *testing.T) { + for _, value := range devModeValues { + e := newPluginEnv(t) + t.Setenv(pluginDevModeEnv, value) + + e.link(writeDevBuild(t, "2.0.0", fixtureCommands(), stubExecutable)) + e.answer("y\n") + + err := e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant) + + require.Error(t, err, value) + assertValidationError(t, err, "plugin_not_installed") + assert.Empty(t, e.approvedHashes(pluginTestID), value) + } + }) +} diff --git a/internal/display/plugin_dev.go b/internal/display/plugin_dev.go new file mode 100644 index 000000000..52129ae65 --- /dev/null +++ b/internal/display/plugin_dev.go @@ -0,0 +1,80 @@ +package display + +import ( + "github.com/auth0/auth0-cli/internal/plugins" +) + +// PluginProblem is one thing wrong with a plugin manifest: the JSON path of the +// field, and what is wrong with it. +type PluginProblem struct { + Path string `json:"path"` + Message string `json:"message"` +} + +// PluginValidation is the result of checking a plugin manifest. Problems is empty, +// and not null, when the manifest is valid. +type PluginValidation struct { + Valid bool `json:"valid"` + Problems []PluginProblem `json:"problems"` +} + +// PluginLinkResult is the result of linking a local build of a plugin. Banner is +// the line that every run of the linked plugin prints on stderr. ApproveCommand is +// only set when ApprovalRequired is true and there is a tenant. +type PluginLinkResult struct { + ID string `json:"id"` + Version string `json:"version"` + Path string `json:"path"` + Bin string `json:"bin"` + Banner string `json:"banner"` + ApprovalRequired bool `json:"approvalRequired"` + ApproveCommand string `json:"approveCommand,omitempty"` +} + +type pluginProblemView struct { + PluginProblem +} + +func (v *pluginProblemView) AsTableHeader() []string { + return []string{"Path", "Problem"} +} + +func (v *pluginProblemView) AsTableRow() []string { + return []string{plugins.SafeText(v.Path), plugins.SafeText(v.Message)} +} + +func (v *pluginProblemView) Object() any { + return &v.PluginProblem +} + +// PluginValidation prints the result of checking a manifest. A person gets a +// table of the problems, or a line when there are none, and JSON gets the result +// as it is. +func (r *Renderer) PluginValidation(result PluginValidation) { + switch { + case r.Format == OutputFormatJSON: + r.JSONResult(result) + case r.Format == OutputFormatJSONCompact: + r.JSONCompactResult(result) + case result.Valid: + r.Infof("The plugin manifest is valid.") + default: + problems := make([]View, 0, len(result.Problems)) + for _, problem := range result.Problems { + problems = append(problems, &pluginProblemView{problem}) + } + + r.Results(problems) + } +} + +// PluginLinked prints the result of linking a plugin as JSON. In the other formats +// it prints nothing, because the command already told the person what it did. +func (r *Renderer) PluginLinked(result PluginLinkResult) { + switch r.Format { + case OutputFormatJSON: + r.JSONResult(result) + case OutputFormatJSONCompact: + r.JSONCompactResult(result) + } +} diff --git a/internal/display/plugin_dev_test.go b/internal/display/plugin_dev_test.go new file mode 100644 index 000000000..eb389971b --- /dev/null +++ b/internal/display/plugin_dev_test.go @@ -0,0 +1,92 @@ +package display + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRenderer_PluginValidation(t *testing.T) { + invalid := PluginValidation{Problems: []PluginProblem{ + {Path: "commands[0].name", Message: "does not match the pattern"}, + {Path: "id", Message: "is required"}, + }} + hostile := PluginValidation{Problems: []PluginProblem{{Path: "id", Message: hostilePluginText}}} + valid := PluginValidation{Valid: true, Problems: []PluginProblem{}} + + tests := []struct { + name string + format OutputFormat + result PluginValidation + wantStdout string + wantStderr string + }{ + { + name: "JSON has the result", + format: OutputFormatJSONCompact, + result: invalid, + wantStdout: `{"valid":false,"problems":[{"path":"commands[0].name","message":"does not match the pattern"},{"path":"id","message":"is required"}]}`, + }, + { + name: "JSON has an empty list, not null, when the manifest is valid", + format: OutputFormatJSONCompact, + result: valid, + wantStdout: `{"valid":true,"problems":[]}`, + }, + { + name: "a person is told when the manifest is valid", + result: valid, + wantStderr: "The plugin manifest is valid.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r, stdout, stderr := pluginRenderer(test.format) + r.PluginValidation(test.result) + + if test.wantStdout != "" { + assert.JSONEq(t, test.wantStdout, stdout.String()) + } else { + assert.Empty(t, stdout.String()) + } + + assert.Contains(t, stderr.String(), test.wantStderr) + }) + } + + t.Run("a person gets the path and the problem, as plain text", func(t *testing.T) { + r, stdout, _ := pluginRenderer("") + r.PluginValidation(invalid) + + assert.Contains(t, stdout.String(), "commands[0].name") + assert.Contains(t, stdout.String(), "does not match the pattern") + + r, stdout, _ = pluginRenderer("") + r.PluginValidation(hostile) + + assert.Contains(t, stdout.String(), cleanPluginText) + assert.NotContains(t, stdout.String(), "\u202e") + }) +} + +func TestRenderer_PluginLinked(t *testing.T) { + result := PluginLinkResult{ + ID: "auth0/checkmate", Version: "1.0.0", Path: "/work/checkmate", Bin: "/work/checkmate/checkmate", Banner: "Running the linked build.", + ApprovalRequired: true, ApproveCommand: "auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com", + } + + r, stdout, _ := pluginRenderer(OutputFormatJSONCompact) + r.PluginLinked(result) + assert.JSONEq(t, `{"id":"auth0/checkmate","version":"1.0.0","path":"/work/checkmate","bin":"/work/checkmate/checkmate","banner":"Running the linked build.", + "approvalRequired":true,"approveCommand":"auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com"}`, stdout.String()) + + // A person gets the lines that the command printed, not a table or a CSV file. + for _, format := range []OutputFormat{"", OutputFormatCSV} { + r, stdout, stderr := pluginRenderer(format) + r.PluginLinked(result) + + assert.Empty(t, stdout.String(), format) + assert.Empty(t, stderr.String(), format) + } +} diff --git a/internal/display/plugins.go b/internal/display/plugins.go index c4836a584..b66a252ec 100644 --- a/internal/display/plugins.go +++ b/internal/display/plugins.go @@ -2,6 +2,7 @@ package display import ( "fmt" + "slices" "strings" "time" @@ -40,7 +41,9 @@ type PluginSearchResult struct { Deprecated *PluginDeprecation `json:"deprecated"` } -// PluginListEntry is one installed plugin. +// PluginListEntry is one installed plugin, or a plugin that is only linked from a +// local build. Linked is true when a local build runs in place of the installed +// copy, or when there is no installed copy. type PluginListEntry struct { ID string `json:"id"` Name string `json:"name"` @@ -48,6 +51,7 @@ type PluginListEntry struct { Pinned bool `json:"pinned"` Previous string `json:"previous"` SizeBytes int64 `json:"sizeBytes"` + Linked bool `json:"linked"` } // PluginChangeResult is the result of an install or an update of one plugin. @@ -122,6 +126,10 @@ type PluginDetails struct { Installed *PluginInstalled `json:"installed"` Deprecated *PluginDeprecation `json:"deprecated"` ApprovedTenants []PluginApproval `json:"approvedTenants"` + + // Linked is true when a local build of the plugin runs, whatever else is + // installed. + Linked bool `json:"linked"` } type pluginSearchView struct { @@ -147,14 +155,30 @@ func (v *pluginSearchView) Object() any { type pluginListView struct { PluginListEntry + + // WithLinked adds the column that marks the linked plugins. Without a linked + // plugin in the list the column would only ever say no. + withLinked bool } func (v *pluginListView) AsTableHeader() []string { - return []string{"ID", "Version", "Pinned", "Previous", "Size"} + header := []string{"ID", "Version", "Pinned", "Previous", "Size"} + + if v.withLinked { + header = append(header, "Linked") + } + + return header } func (v *pluginListView) AsTableRow() []string { - return []string{plugins.SafeText(v.ID), plugins.SafeText(v.Version), yesNo(v.Pinned), plugins.SafeText(v.Previous), formatSize(v.SizeBytes)} + row := []string{plugins.SafeText(v.ID), plugins.SafeText(v.Version), yesNo(v.Pinned), plugins.SafeText(v.Previous), formatSize(v.SizeBytes)} + + if v.withLinked { + row = append(row, yesNo(v.Linked)) + } + + return row } func (v *pluginListView) Object() any { @@ -272,6 +296,10 @@ func (v *pluginInfoView) KeyValues() [][]string { rows = append(rows, []string{"APPROVED TENANTS", approved}) + if v.Linked { + rows = append(rows, []string{"LINKED", "yes, a local build runs in place of any installed copy"}) + } + // Everything the registry supplied is printed as plain text. The ID row is // already plain, and its color is not text. for _, row := range rows[1:] { @@ -307,9 +335,11 @@ func (r *Renderer) PluginList(installed []PluginListEntry) { return } + withLinked := slices.ContainsFunc(installed, func(plugin PluginListEntry) bool { return plugin.Linked }) + results := make([]View, 0, len(installed)) for _, plugin := range installed { - results = append(results, &pluginListView{plugin}) + results = append(results, &pluginListView{PluginListEntry: plugin, withLinked: withLinked}) } r.Results(results) diff --git a/internal/display/plugins_test.go b/internal/display/plugins_test.go index 7c6b642df..b0ba3470c 100644 --- a/internal/display/plugins_test.go +++ b/internal/display/plugins_test.go @@ -3,6 +3,7 @@ package display import ( "bytes" "encoding/json" + "slices" "testing" "time" @@ -142,24 +143,30 @@ func TestPluginListView(t *testing.T) { name: "empty", entry: PluginListEntry{}, wantRow: []string{"", "", "no", "", "0 B"}, - wantJSON: `{"id":"","name":"","version":"","pinned":false,"previous":"","sizeBytes":0}`, + wantJSON: `{"id":"","name":"","version":"","pinned":false,"previous":"","sizeBytes":0,"linked":false}`, }, { name: "pinned with a previous version", entry: PluginListEntry{ID: "auth0/checkmate", Name: "checkmate", Version: "2.0.0", Pinned: true, Previous: "1.0.0", SizeBytes: 90_500_000}, wantRow: []string{"auth0/checkmate", "2.0.0", "yes", "1.0.0", "90.5 MB"}, - wantJSON: `{"id":"auth0/checkmate","name":"checkmate","version":"2.0.0","pinned":true,"previous":"1.0.0","sizeBytes":90500000}`, + wantJSON: `{"id":"auth0/checkmate","name":"checkmate","version":"2.0.0","pinned":true,"previous":"1.0.0","sizeBytes":90500000,"linked":false}`, }, { name: "hostile text is cleaned in the row", entry: PluginListEntry{ID: "auth0/" + hostilePluginText, Version: hostilePluginText, Previous: hostilePluginText, SizeBytes: 1536}, wantRow: []string{"auth0/" + cleanPluginText, cleanPluginText, "no", cleanPluginText, "1.5 kB"}, }, + { + name: "a linked plugin", + entry: PluginListEntry{ID: "auth0/checkmate", Name: "checkmate", Version: "2.0.0", Linked: true}, + wantRow: []string{"auth0/checkmate", "2.0.0", "no", "", "0 B"}, + wantJSON: `{"id":"auth0/checkmate","name":"checkmate","version":"2.0.0","pinned":false,"previous":"","sizeBytes":0,"linked":true}`, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - view := &pluginListView{test.entry} + view := &pluginListView{PluginListEntry: test.entry} assert.Equal(t, []string{"ID", "Version", "Pinned", "Previous", "Size"}, view.AsTableHeader()) assert.Equal(t, test.wantRow, view.AsTableRow()) @@ -175,6 +182,13 @@ func TestPluginListView(t *testing.T) { } } +func TestPluginListView_LinkedColumn(t *testing.T) { + view := &pluginListView{PluginListEntry: PluginListEntry{ID: "auth0/checkmate", Version: "2.0.0", Linked: true}, withLinked: true} + + assert.Equal(t, []string{"ID", "Version", "Pinned", "Previous", "Size", "Linked"}, view.AsTableHeader()) + assert.Equal(t, []string{"auth0/checkmate", "2.0.0", "no", "", "0 B", "yes"}, view.AsTableRow()) +} + func TestPluginChangeView(t *testing.T) { tests := []struct { name string @@ -262,6 +276,26 @@ func TestPluginInfoView(t *testing.T) { {"APPROVED TENANTS", "acme.us.auth0.com, other.us.auth0.com (different permissions)"}, }, }, + { + name: "a linked plugin", + mutate: func(d *PluginDetails) { d.Linked = true }, + want: [][]string{ + {"ID", ansi.Faint("auth0/checkmate")}, + {"VERSION", "2.0.0"}, + {"DESCRIPTION", "Audits a tenant"}, + {"OWNER", "Tools https://example.com/bugs"}, + {"HOMEPAGE", "https://example.com/checkmate"}, + {"REQUIRES", "auth0-cli >=1.38.0"}, + {"PLATFORMS", "darwin-arm64, linux-x64"}, + {"PERMISSIONS", "2 scopes, 1 host, 0 file paths, 0 environment variables"}, + {"PERMISSIONS HASH", "sha256:aaa"}, + {"VERSIONS", "2.0.0 (latest), 1.1.0 (revoked), 1.0.0"}, + {"INSTALLED", "1.0.0"}, + {"DEPRECATED", "Unmaintained (use auth0/other instead)"}, + {"APPROVED TENANTS", "acme.us.auth0.com, other.us.auth0.com (different permissions)"}, + {"LINKED", "yes, a local build runs in place of any installed copy"}, + }, + }, { name: "nothing is known", mutate: func(d *PluginDetails) { *d = PluginDetails{} }, @@ -363,7 +397,7 @@ func TestPluginInfoView(t *testing.T) { view := &pluginInfoView{PluginDetails{Versions: []PluginVersion{}, ApprovedTenants: []PluginApproval{}}} assert.JSONEq(t, `{"id":"","version":"","description":"","owner":{"team":"","bugs":""},"homepage":"","requires":"","platforms":null, - "permissions":{"scopes":0,"hosts":0,"files":0,"env":0},"permissionsHash":"","versions":[],"installed":null,"deprecated":null,"approvedTenants":[]}`, jsonOf(t, view)) + "permissions":{"scopes":0,"hosts":0,"files":0,"env":0},"permissionsHash":"","versions":[],"installed":null,"deprecated":null,"approvedTenants":[],"linked":false}`, jsonOf(t, view)) }) t.Run("the JSON object of an approval has its time", func(t *testing.T) { @@ -420,6 +454,13 @@ func TestRenderer_PluginList(t *testing.T) { assert.Equal(t, "ID,Version,Pinned,Previous,Size\nauth0/checkmate,2.0.0,yes,1.0.0,1.5 kB\n", stdout.String()) }) + t.Run("has the column of linked plugins only when a plugin is linked", func(t *testing.T) { + r, stdout, _ := pluginRenderer(OutputFormatCSV) + r.PluginList(append(slices.Clone(installed), PluginListEntry{ID: "auth0/mine", Version: "0.1.0", Linked: true})) + + assert.Equal(t, "ID,Version,Pinned,Previous,Size,Linked\nauth0/checkmate,2.0.0,yes,1.0.0,1.5 kB,no\nauth0/mine,0.1.0,no,,0 B,yes\n", stdout.String()) + }) + t.Run("says when nothing is installed", func(t *testing.T) { r, stdout, stderr := pluginRenderer("") r.PluginList(nil) diff --git a/internal/plugins/devlink/devlink.go b/internal/plugins/devlink/devlink.go new file mode 100644 index 000000000..de651f0e6 --- /dev/null +++ b/internal/plugins/devlink/devlink.go @@ -0,0 +1,339 @@ +// Package devlink records the local builds of plugins that a plugin author runs +// as auth0 without publishing them, and reads such a build from disk. +// +// The links are kept in dev-links.json in the plugins directory, next to the +// lockfile. A link names the directory of the build and its executable, both as +// absolute paths. Nothing here decides whether a linked plugin may run: that is +// the job of the caller, which must also keep linked plugins out of reach unless +// dev mode is on. +package devlink + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/filelock" +) + +const ( + // FileName is the name of the file that holds the links, in the plugins + // directory. + FileName = "dev-links.json" + + // ManifestName is the name of the manifest in the directory of a build. + ManifestName = "plugin.json" + + // The lock that serializes changes to the links. It is not the lock of the + // store, so that a long download never holds up a link. + lockName = ".dev-links.lock" + + // The only format of the file this CLI reads. + formatVersion = 1 + + // The largest file, manifest or dev-links.json, that is read, in bytes. + maxFileBytes = 1 << 20 + + dirPerm os.FileMode = 0o700 +) + +var ( + // ErrDamaged means dev-links.json cannot be trusted. The file is left alone + // and never replaced automatically. + ErrDamaged = errors.New("the dev links file is damaged") + + // Another auth0 process held the lock of the links for too long. + errBusy = errors.New("another auth0 process is changing the dev links, try again in a moment") + + // ErrNotLinked means no plugin with the ID is linked. + ErrNotLinked = errors.New("the plugin is not linked") +) + +// The function that moves the new file into place, which tests replace to make +// it fail. +var renameFile = plugins.Rename + +// Link is one linked build: the directory that holds its plugin.json and the +// executable to run. Both are absolute paths. +type Link struct { + Path string `json:"path"` + Bin string `json:"bin"` +} + +// Build is a linked plugin as it is on disk now. +type Build struct { + // Dir is the directory of the build, ManifestPath its plugin.json and Bin its + // executable. + Dir string + ManifestPath string + Bin string + + // Manifest is the plugin.json as it was read and validated just now. + Manifest *plugins.Manifest +} + +// document is the content of dev-links.json. +type document struct { + FormatVersion int `json:"formatVersion"` + Links map[string]Link `json:"links"` +} + +// Links is the set of links kept in one plugins directory. +type Links struct { + // Dir is the plugins directory. + Dir string +} + +func (l Links) path() string { + return filepath.Join(l.Dir, FileName) +} + +// Load returns the links by plugin ID. A missing file is no links. A file that +// cannot be read, is too large, has a duplicate key, a field this CLI does not +// know, a format version it does not read or a path that is not absolute is an +// error that wraps ErrDamaged. +// +// It takes no lock, because the file is always replaced by a rename. +func (l Links) Load() (map[string]Link, error) { + path := l.path() + + data, err := readRegularFile(path) + if errors.Is(err, os.ErrNotExist) { + return map[string]Link{}, nil + } + + if err != nil { + return nil, fmt.Errorf("%w (%w)", ErrDamaged, err) + } + + doc, err := decode(data) + if err != nil { + return nil, fmt.Errorf("%w (%s: %w)", ErrDamaged, path, err) + } + + return doc.Links, nil +} + +// Add links the plugin, replacing the link it had. +func (l Links) Add(ctx context.Context, id string, link Link) error { + if id == "" || !filepath.IsAbs(link.Path) || !filepath.IsAbs(link.Bin) { + return fmt.Errorf("a link needs a plugin ID and absolute paths, got %q, %q and %q", id, link.Path, link.Bin) + } + + return l.change(ctx, func(links map[string]Link) error { + links[id] = link + + return nil + }) +} + +// Remove drops the link of the plugin. It fails with ErrNotLinked when the +// plugin is not linked. +func (l Links) Remove(ctx context.Context, id string) error { + return l.change(ctx, func(links map[string]Link) error { + if _, found := links[id]; !found { + return fmt.Errorf("%w: %s", ErrNotLinked, id) + } + + delete(links, id) + + return nil + }) +} + +// change reads the links under the lock, applies the change and writes them +// back, so that two processes never lose each other's change. +func (l Links) change(ctx context.Context, apply func(links map[string]Link) error) error { + if err := os.MkdirAll(l.Dir, dirPerm); err != nil { + return fmt.Errorf("failed to create the plugins directory: %w", err) + } + + if err := os.Chmod(l.Dir, dirPerm); err != nil { + return fmt.Errorf("failed to restrict the plugins directory: %w", err) + } + + release, err := filelock.Acquire(ctx, filepath.Join(l.Dir, lockName), errBusy) + if err != nil { + return err + } + defer release() + + links, err := l.Load() + if err != nil { + return err + } + + if err := apply(links); err != nil { + return err + } + + return l.write(links) +} + +// write replaces dev-links.json through a temporary file that is synced and then +// renamed, so that readers see either the old file or the new one. The caller +// holds the lock. +func (l Links) write(links map[string]Link) error { + data, err := json.MarshalIndent(document{FormatVersion: formatVersion, Links: links}, "", " ") + if err != nil { + return fmt.Errorf("failed to encode the dev links: %w", err) + } + + temp, err := plugins.StageTempFile(l.Dir, FileName, append(data, '\n')) + if err != nil { + return fmt.Errorf("failed to write %s: %w", l.path(), err) + } + + if err := renameFile(temp, l.path()); err != nil { + _ = os.Remove(temp) + + return fmt.Errorf("failed to write %s: %w", l.path(), err) + } + + plugins.SyncDir(l.Dir) + + return nil +} + +// decode reads and checks the content of dev-links.json. +func decode(data []byte) (*document, error) { + var doc document + + if err := plugins.UnmarshalStrict(data, &doc); err != nil { + return nil, err + } + + // Decoding ignores a field it does not know, and matches the name of a field + // without regard to case, so that "Links" would be read as "links". The file + // is accepted only if writing what was read gives the same document back. + canonical, err := json.Marshal(doc) + if err != nil { + return nil, err + } + + var got, want any + + if err := json.Unmarshal(data, &got); err != nil { + return nil, err + } + + if err := json.Unmarshal(canonical, &want); err != nil { + return nil, err + } + + if !reflect.DeepEqual(got, want) { + return nil, errors.New("it has a field this CLI does not know, a field that is not written in the exact case, or a field that is missing") + } + + if doc.FormatVersion != formatVersion { + return nil, fmt.Errorf("format version %d is not supported, this CLI reads version %d", doc.FormatVersion, formatVersion) + } + + if doc.Links == nil { + doc.Links = map[string]Link{} + } + + for id, link := range doc.Links { + if id == "" || !filepath.IsAbs(link.Path) || !filepath.IsAbs(link.Bin) { + return nil, fmt.Errorf("links[%q]: the plugin ID is empty or a path is not absolute", id) + } + } + + return &doc, nil +} + +// Open reads the build of the link as it is on disk now: the manifest is read +// and validated again, it must be for the plugin with the ID, and the executable +// must be usable. Nothing is cached, so an edit shows at the next call. +func (l Link) Open(id string) (*Build, error) { + manifestPath := filepath.Join(l.Path, ManifestName) + + manifest, err := ReadManifest(manifestPath) + if err != nil { + return nil, err + } + + if manifest.ID != id { + return nil, fmt.Errorf("%s is for the plugin %s, not %s", manifestPath, manifest.ID, id) + } + + if err := CheckExecutable(l.Bin); err != nil { + return nil, err + } + + return &Build{Dir: l.Path, ManifestPath: manifestPath, Bin: l.Bin, Manifest: manifest}, nil +} + +// ReadManifest reads and validates the plugin.json at the path, as the manifest +// of a plugin that is being published is. A manifest with problems is a +// *plugins.ValidationError. +func ReadManifest(path string) (*plugins.Manifest, error) { + data, err := readRegularFile(path) + if err != nil { + return nil, err + } + + return plugins.ParseManifest(data) +} + +// DefaultBin returns where the executable of the plugin with the name is by +// convention: a file of that name in the directory of the build, with .exe added +// on Windows. +func DefaultBin(dir, name string) string { + if runtime.GOOS == "windows" { + name += ".exe" + } + + return filepath.Join(dir, name) +} + +// CheckExecutable fails unless the path is a regular file once links are +// followed, and on systems that have an executable bit, one that can be run. +func CheckExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", path) + } + + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("%s is not executable, make it executable with: chmod +x %s", path, path) + } + + return nil +} + +// readRegularFile reads a file of at most maxFileBytes that is a regular file +// once links are followed. +func readRegularFile(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, err + } + + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", path) + } + + data, err := plugins.ReadCapped(file, maxFileBytes) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + + return data, nil +} diff --git a/internal/plugins/devlink/devlink_test.go b/internal/plugins/devlink/devlink_test.go new file mode 100644 index 000000000..8add2fa13 --- /dev/null +++ b/internal/plugins/devlink/devlink_test.go @@ -0,0 +1,505 @@ +package devlink + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins" +) + +const testID = "auth0/checkmate" + +// newLinks returns the links of a plugins directory that does not exist yet. +func newLinks(t *testing.T) Links { + t.Helper() + + return Links{Dir: filepath.Join(t.TempDir(), "plugins")} +} + +// abs returns an absolute path for a name, the way the commands make one. +func abs(t *testing.T, name string) string { + t.Helper() + + return filepath.Join(t.TempDir(), name) +} + +func (l Links) read(t *testing.T) string { + t.Helper() + + data, err := os.ReadFile(l.path()) + require.NoError(t, err) + + return string(data) +} + +func (l Links) writeRaw(t *testing.T, content string) { + t.Helper() + + require.NoError(t, os.MkdirAll(l.Dir, 0o700)) + require.NoError(t, os.WriteFile(l.path(), []byte(content), 0o600)) +} + +// build writes a build of the plugin in a directory: its manifest and an +// executable, and returns the link to it. +func build(t *testing.T, version string) Link { + t.Helper() + + dir := t.TempDir() + writeManifest(t, dir, version) + + bin := DefaultBin(dir, "checkmate") + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755)) + + return Link{Path: dir, Bin: bin} +} + +func writeManifest(t *testing.T, dir, version string) { + t.Helper() + + data, err := json.Marshal(plugins.Manifest{ + ID: testID, + Version: version, + APIVersion: 1, + Description: "Checks the tenant", + Owner: plugins.Owner{Team: "Test team", Bugs: "https://example.com/bugs"}, + Platforms: []plugins.Platform{plugins.PlatformLinuxX64}, + Commands: []plugins.Command{{Name: "checkmate", Description: "Check a tenant"}}, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, ManifestName), data, 0o600)) +} + +func TestLinks_LoadIsRefusedWhenTheFileIsNotTrustworthy(t *testing.T) { + path := filepath.Join(os.TempDir(), "a", "plugin") + if runtime.GOOS == "windows" { + path = `C:\a\plugin` + } + + link := `{"path":` + strconv.Quote(path) + `,"bin":` + strconv.Quote(path) + `}` + + tests := []struct { + name string + content string + }{ + {"not JSON", `{"formatVersion":`}, + {"empty", ``}, + {"a duplicate key", `{"formatVersion":1,"links":{},"links":{}}`}, + {"a duplicate key in a link", `{"formatVersion":1,"links":{"auth0/a":{"path":` + strconv.Quote(path) + `,"path":` + strconv.Quote(path) + `,"bin":` + strconv.Quote(path) + `}}}`}, + {"a key in another case", `{"formatVersion":1,"links":{},"Links":{"auth0/a":` + link + `}}`}, + {"a name of a field in another case", `{"formatVersion":1,"Links":{"auth0/a":` + link + `}}`}, + {"a field it does not know", `{"formatVersion":1,"links":{},"extra":true}`}, + {"a field of a link it does not know", `{"formatVersion":1,"links":{"auth0/a":{"path":` + strconv.Quote(path) + `,"bin":` + strconv.Quote(path) + `,"extra":1}}}`}, + {"no links field", `{"formatVersion":1}`}, + {"no version", `{"links":{}}`}, + {"a version it does not read", `{"formatVersion":2,"links":{}}`}, + {"a link without bin", `{"formatVersion":1,"links":{"auth0/a":{"path":` + strconv.Quote(path) + `}}}`}, + {"a path that is not absolute", `{"formatVersion":1,"links":{"auth0/a":{"path":"relative/dir","bin":` + strconv.Quote(path) + `}}}`}, + {"a bin that is not absolute", `{"formatVersion":1,"links":{"auth0/a":{"path":` + strconv.Quote(path) + `,"bin":"checkmate"}}}`}, + {"an empty plugin ID", `{"formatVersion":1,"links":{"":` + link + `}}`}, + {"a file larger than allowed", `{"formatVersion":1,"links":{},"pad":"` + strings.Repeat("x", maxFileBytes) + `"}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + links := newLinks(t) + links.writeRaw(t, test.content) + + _, err := links.Load() + require.Error(t, err) + assert.ErrorIs(t, err, ErrDamaged) + assert.Contains(t, err.Error(), links.path(), "the error names the file") + + // A damaged file is never replaced. + require.Error(t, links.Add(context.Background(), testID, Link{Path: abs(t, "a"), Bin: abs(t, "b")})) + assert.Equal(t, test.content, links.read(t)) + }) + } + + t.Run("a directory", func(t *testing.T) { + links := newLinks(t) + require.NoError(t, os.MkdirAll(links.path(), 0o700)) + + _, err := links.Load() + assert.ErrorIs(t, err, ErrDamaged) + }) +} + +func TestLinks_LoadOfAValidFile(t *testing.T) { + t.Run("a missing file is no links, and nothing is created", func(t *testing.T) { + links := newLinks(t) + + got, err := links.Load() + require.NoError(t, err) + assert.Empty(t, got) + assert.NoDirExists(t, links.Dir) + }) + + t.Run("an empty list of links", func(t *testing.T) { + links := newLinks(t) + links.writeRaw(t, `{"formatVersion":1,"links":{}}`) + + got, err := links.Load() + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("a null list of links", func(t *testing.T) { + links := newLinks(t) + links.writeRaw(t, `{"formatVersion":1,"links":null}`) + + got, err := links.Load() + require.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got) + }) +} + +func TestLinks_AddRemove(t *testing.T) { + ctx := context.Background() + links := newLinks(t) + + first := Link{Path: abs(t, "one"), Bin: abs(t, "one/checkmate")} + second := Link{Path: abs(t, "two"), Bin: abs(t, "two/checkmate")} + other := Link{Path: abs(t, "three"), Bin: abs(t, "three/other")} + + require.NoError(t, links.Add(ctx, testID, first)) + require.NoError(t, links.Add(ctx, "auth0/other", other)) + + got, err := links.Load() + require.NoError(t, err) + assert.Equal(t, map[string]Link{testID: first, "auth0/other": other}, got) + + t.Run("linking again replaces the link of that ID only", func(t *testing.T) { + require.NoError(t, links.Add(ctx, testID, second)) + + all, err := links.Load() + require.NoError(t, err) + assert.Equal(t, map[string]Link{testID: second, "auth0/other": other}, all) + }) + + t.Run("the file is private, and is valid JSON with the version", func(t *testing.T) { + var document map[string]any + require.NoError(t, json.Unmarshal([]byte(links.read(t)), &document)) + assert.EqualValues(t, 1, document["formatVersion"]) + + if runtime.GOOS != "windows" { + info, err := os.Stat(links.path()) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + info, err = os.Stat(links.Dir) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + } + }) + + t.Run("removing one leaves the other", func(t *testing.T) { + require.NoError(t, links.Remove(ctx, testID)) + + all, err := links.Load() + require.NoError(t, err) + assert.Equal(t, map[string]Link{"auth0/other": other}, all) + }) + + t.Run("an unknown plugin is not linked, and the file is untouched", func(t *testing.T) { + before := links.read(t) + + err := links.Remove(ctx, testID) + require.ErrorIs(t, err, ErrNotLinked) + assert.Contains(t, err.Error(), testID) + assert.Equal(t, before, links.read(t)) + }) + + t.Run("removing the last link leaves a valid empty file", func(t *testing.T) { + require.NoError(t, links.Remove(ctx, "auth0/other")) + + got, err := links.Load() + require.NoError(t, err) + assert.Empty(t, got) + assert.JSONEq(t, `{"formatVersion":1,"links":{}}`, links.read(t)) + }) + + t.Run("no temporary file is left behind", func(t *testing.T) { + entries, err := os.ReadDir(links.Dir) + require.NoError(t, err) + + for _, entry := range entries { + assert.Contains(t, []string{FileName, lockName}, entry.Name()) + } + }) +} + +func TestLinks_ConcurrentChangesAreNotLost(t *testing.T) { + links := newLinks(t) + base := t.TempDir() + + var group sync.WaitGroup + + errs := make(chan error, 20) + + for n := range 20 { + group.Add(1) + + go func() { + defer group.Done() + + name := "plugin" + strconv.Itoa(n) + errs <- links.Add(context.Background(), "auth0/"+name, Link{Path: filepath.Join(base, name), Bin: filepath.Join(base, name, name)}) + }() + } + + group.Wait() + close(errs) + + for err := range errs { + require.NoError(t, err) + } + + got, err := links.Load() + require.NoError(t, err) + assert.Len(t, got, 20, "every change is kept") +} + +func TestLinks_AddRefusesWhatIsNotAbsolute(t *testing.T) { + tests := []struct { + name string + id string + link Link + }{ + {"a relative path", testID, Link{Path: "dir", Bin: abs(t, "bin")}}, + {"a relative bin", testID, Link{Path: abs(t, "dir"), Bin: "bin"}}, + {"no ID", "", Link{Path: abs(t, "dir"), Bin: abs(t, "bin")}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + links := newLinks(t) + + require.Error(t, links.Add(context.Background(), test.id, test.link)) + assert.NoDirExists(t, links.Dir) + }) + } +} + +func TestLinks_AFailedWriteLeavesTheOldFile(t *testing.T) { + ctx := context.Background() + links := newLinks(t) + first := Link{Path: abs(t, "one"), Bin: abs(t, "one/checkmate")} + + require.NoError(t, links.Add(ctx, testID, first)) + + before := links.read(t) + failure := errors.New("disk is read only") + + original := renameFile + renameFile = func(string, string) error { return failure } + + t.Cleanup(func() { renameFile = original }) + + err := links.Add(ctx, "auth0/other", Link{Path: abs(t, "two"), Bin: abs(t, "two/other")}) + require.ErrorIs(t, err, failure) + assert.Contains(t, err.Error(), links.path()) + + err = links.Remove(ctx, testID) + require.ErrorIs(t, err, failure) + + assert.Equal(t, before, links.read(t), "readers still see the old file") + + entries, err := os.ReadDir(links.Dir) + require.NoError(t, err) + + for _, entry := range entries { + assert.Contains(t, []string{FileName, lockName}, entry.Name(), "no staged file is left behind") + } +} + +func TestLink_Open(t *testing.T) { + t.Run("reads the build as it is on disk now", func(t *testing.T) { + link := build(t, "1.0.0") + + opened, err := link.Open(testID) + require.NoError(t, err) + assert.Equal(t, link.Path, opened.Dir) + assert.Equal(t, link.Bin, opened.Bin) + assert.Equal(t, filepath.Join(link.Path, ManifestName), opened.ManifestPath) + assert.Equal(t, "1.0.0", opened.Manifest.Version) + + writeManifest(t, link.Path, "1.1.0") + + opened, err = link.Open(testID) + require.NoError(t, err) + assert.Equal(t, "1.1.0", opened.Manifest.Version, "an edit shows without linking again") + }) + + tests := []struct { + name string + change func(t *testing.T, link Link) + id string + wantErr string + }{ + { + name: "the manifest has a problem", + change: func(t *testing.T, link Link) { writeManifestText(t, link, `{"id":"auth0/checkmate"}`) }, + wantErr: "required field is missing", + }, + { + name: "the manifest is not JSON", + change: func(t *testing.T, link Link) { writeManifestText(t, link, `nope`) }, + wantErr: "not valid JSON", + }, + { + name: "the manifest is gone", + change: func(t *testing.T, link Link) { require.NoError(t, os.Remove(filepath.Join(link.Path, ManifestName))) }, + wantErr: ManifestName, + }, + { + name: "the manifest is for another plugin", + id: "auth0/other", + wantErr: "is for the plugin auth0/checkmate, not auth0/other", + }, + { + name: "the manifest is a directory", + change: func(t *testing.T, link Link) { replaceWithDirectory(t, filepath.Join(link.Path, ManifestName)) }, + wantErr: "not a regular file", + }, + { + name: "the manifest is larger than allowed", + change: func(t *testing.T, link Link) { writeManifestText(t, link, strings.Repeat(" ", maxFileBytes+1)) }, + wantErr: "larger than the allowed size", + }, + { + name: "the executable is gone", + change: func(t *testing.T, link Link) { require.NoError(t, os.Remove(link.Bin)) }, + wantErr: "{bin}", + }, + { + name: "the executable is a directory", + change: func(t *testing.T, link Link) { replaceWithDirectory(t, link.Bin) }, + wantErr: "is not a regular file", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + link := build(t, "1.0.0") + + if test.change != nil { + test.change(t, link) + } + + id := testID + if test.id != "" { + id = test.id + } + + _, err := link.Open(id) + require.Error(t, err) + + assert.Contains(t, err.Error(), strings.ReplaceAll(test.wantErr, "{bin}", link.Bin)) + }) + } +} + +func writeManifestText(t *testing.T, link Link, text string) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(link.Path, ManifestName), []byte(text), 0o600)) +} + +func replaceWithDirectory(t *testing.T, path string) { + t.Helper() + + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Mkdir(path, 0o700)) +} + +func TestReadManifest_ReportsEveryProblemOfAnInvalidManifest(t *testing.T) { + link := build(t, "1.0.0") + writeManifestText(t, link, `{"id":"Bad","version":"1","apiVersion":1,"description":"d","owner":{"team":"t","bugs":"https://example.com"},"platforms":["linux-x64"],"commands":[{"name":"a","description":"d"}],"extra":true}`) + + _, err := ReadManifest(filepath.Join(link.Path, ManifestName)) + + invalid, ok := errors.AsType[*plugins.ValidationError](err) + require.True(t, ok, "a manifest with problems is a validation error, got %v", err) + assert.Contains(t, strings.Join(invalid.Errors, "\n"), "extra: unknown field") + assert.Contains(t, strings.Join(invalid.Errors, "\n"), "id:") +} + +func TestCheckExecutable(t *testing.T) { + dir := t.TempDir() + + regular := filepath.Join(dir, "regular") + require.NoError(t, os.WriteFile(regular, []byte("x"), 0o755)) + + t.Run("a regular file", func(t *testing.T) { + require.NoError(t, CheckExecutable(regular)) + }) + + t.Run("a missing file names the path", func(t *testing.T) { + err := CheckExecutable(filepath.Join(dir, "missing")) + require.ErrorIs(t, err, os.ErrNotExist) + assert.Contains(t, err.Error(), filepath.Join(dir, "missing")) + }) + + t.Run("a directory", func(t *testing.T) { + err := CheckExecutable(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not a regular file") + }) + + if runtime.GOOS == "windows" { + return + } + + t.Run("a file without the executable bit", func(t *testing.T) { + plain := filepath.Join(dir, "plain") + require.NoError(t, os.WriteFile(plain, []byte("x"), 0o644)) + + err := CheckExecutable(plain) + require.Error(t, err) + assert.Contains(t, err.Error(), "chmod +x "+plain) + }) + + t.Run("a link to a regular file is followed", func(t *testing.T) { + link := filepath.Join(dir, "link") + require.NoError(t, os.Symlink(regular, link)) + + require.NoError(t, CheckExecutable(link)) + }) + + t.Run("a link to a directory is refused", func(t *testing.T) { + link := filepath.Join(dir, "dirlink") + require.NoError(t, os.Symlink(dir, link)) + + require.Error(t, CheckExecutable(link)) + }) + + t.Run("a link to nothing is refused", func(t *testing.T) { + link := filepath.Join(dir, "dangling") + require.NoError(t, os.Symlink(filepath.Join(dir, "gone"), link)) + + require.ErrorIs(t, CheckExecutable(link), os.ErrNotExist) + }) +} + +func TestDefaultBin(t *testing.T) { + dir := filepath.Join(t.TempDir(), "build") + + want := filepath.Join(dir, "checkmate") + if runtime.GOOS == "windows" { + want += ".exe" + } + + assert.Equal(t, want, DefaultBin(dir, "checkmate")) +} diff --git a/internal/plugins/store/store.go b/internal/plugins/store/store.go index 61890872c..a904402ad 100644 --- a/internal/plugins/store/store.go +++ b/internal/plugins/store/store.go @@ -165,21 +165,31 @@ var ( currentPlatform = plugins.CurrentPlatform ) -// pluginPath returns where the directory of a plugin is, or an error when the -// ID is not safe to use as a path. Both parts of the ID become directory names, -// so neither may be a name that Windows reserves, even on a system where that -// would work: a plugin that installs here can then be installed everywhere. -func (s *Store) pluginPath(id string) (string, error) { +// CheckID returns an error unless id is a plugin ID, registry/name, that is safe +// to use as a path. Both parts of the ID become directory names, so neither may +// be a name that Windows reserves, even on a system where that would work: a +// plugin that installs here can then be installed everywhere. +func CheckID(id string) error { if !idPattern.MatchString(id) { - return "", fmt.Errorf("%q is not a plugin ID in the form registry/name", id) + return fmt.Errorf("%q is not a plugin ID in the form registry/name", id) } for _, part := range strings.Split(id, "/") { if reason := badFileName(part); reason != "" { - return "", fmt.Errorf("%q is not a plugin ID in the form registry/name: %q cannot be a directory name because %s", id, part, reason) + return fmt.Errorf("%q is not a plugin ID in the form registry/name: %q cannot be a directory name because %s", id, part, reason) } } + return nil +} + +// pluginPath returns where the directory of a plugin is, or an error when the +// ID is not safe to use as a path. +func (s *Store) pluginPath(id string) (string, error) { + if err := CheckID(id); err != nil { + return "", err + } + return filepath.Join(s.Dir, storeDirName, filepath.FromSlash(id)), nil }