refactor(plugins): use plugin metadata protocol

Plugins are now required to implement a --metadata flag
that returns a JSON representation of the plugin's usage.
For added security, the user now must use the plugin enable
subcommand, before the plugin is recognized by glab.

TODO: implement plugin parsing from plugin (metadata) directory
TODO: implement checksum verification of the plugin a la direnv
TODO: implement symlink verification - no symlinks allowed for plugins!
This commit is contained in:
Oscar Tovar 2024-05-05 04:03:21 -04:00
parent a4558a3499
commit bd6a14d344
No known key found for this signature in database
GPG Key ID: 1A48F2EC434FBC5C
5 changed files with 280 additions and 26 deletions

View File

@ -20,7 +20,6 @@ import (
"gitlab.com/gitlab-org/cli/commands/alias/expand"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/commands/help"
"gitlab.com/gitlab-org/cli/commands/plugin"
"gitlab.com/gitlab-org/cli/commands/update"
"gitlab.com/gitlab-org/cli/internal/config"
"gitlab.com/gitlab-org/cli/internal/run"
@ -84,19 +83,19 @@ func main() {
rootCmd := commands.NewCmdRoot(cmdFactory, version, buildDate)
plugins, err := plugin.List()
io := cmdFactory.IO
if err != nil {
io.Logf("%s failed to load plugins successfully", io.Color().DotWarnIcon())
}
rootCmd.AddGroup(&cobra.Group{
ID: "Plugins",
})
for _, plugin := range plugins {
rootCmd.AddCommand(plugin.NewCommand(cmdFactory))
}
// plugins, err := plugin.List()
// io := cmdFactory.IO
// if err != nil {
// io.Logf("%s failed to load plugins successfully", io.Color().DotWarnIcon())
// }
//
// rootCmd.AddGroup(&cobra.Group{
// ID: "Plugins",
// })
//
// for _, plugin := range plugins {
// rootCmd.AddCommand(plugin.NewCommand(cmdFactory))
// }
// Set Debug mode from config if not previously set by debugMode
if !debug {

236
commands/plugin/command.go Normal file
View File

@ -0,0 +1,236 @@
package plugin
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/spf13/cobra"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/internal/config"
"gitlab.com/gitlab-org/cli/pkg/tableprinter"
)
func Command(f *cmdutils.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "plugin",
Short: "Manage glab plugins",
Long: "Extend the functionality of glab with plugins",
}
cmd.AddCommand(listCmd(f))
cmd.AddCommand(enableCmd(f))
cmd.AddCommand(disableCmd(f))
return cmd
}
func listCmd(f *cmdutils.Factory) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List enabled plugins",
RunE: func(cmd *cobra.Command, args []string) error {
p := tableprinter.NewTablePrinter()
p.MaxColWidth = 48
p.Wrap = true
pluginDir := config.PluginDir()
matches, err := filepath.Glob(filepath.Join(pluginDir, "*.json"))
if err != nil {
return fmt.Errorf("reading plugins from plugin directory: %w", err)
}
p.AddRow("Name", "Version", "Description", "Checksum")
var data PluginMetadata
for _, match := range matches {
f, err := os.Open(match)
if err != nil {
return fmt.Errorf("opening plugin metadata file %s: %w", match, err)
}
if err := json.NewDecoder(f).Decode(&data); err != nil {
return fmt.Errorf("reading plugin metadata: %w", err)
}
p.AddRow(data.Name, data.Version, data.Description, data.Checksum)
if err := f.Close(); err != nil {
return fmt.Errorf("closing plugin metadata file %s: %w", match, err)
}
}
fmt.Fprintf(f.IO.StdOut, "\n%s\n", p)
return nil
},
}
}
func enableCmd(f *cmdutils.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "enable [plugin]",
Short: "Enables a glab plugin",
Long: "Plugin must start with 'glab-', include only alphanumeric and '-' characters, and be in your PATH",
Example: "glab plugin enable ping",
RunE: func(cmd *cobra.Command, args []string) error {
plugin := Plugin(args[0])
return enable(cmd.Parent(), plugin)
},
Args: cobra.ExactArgs(1),
}
return cmd
}
type PluginMetadata struct {
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Checksum string `json:"checksum,omitempty"`
}
func enable(rootCmd *cobra.Command, plugin Plugin) error {
// --------------------------------------------------------------------
// BEFORE MERGING THE FOLLOWING MUST BE DONE!!
// --------------------------------------------------------------------
//
// 1. Plugins cannot override a core command! ✅
// 2. Plugins must not symlink! =) We don't want someone enabling
// glab-kube only to trigger some other malware.
// 3. We need to establish a known checksum upon installation.
// If the user updates the binary, then we disable the plugin
// and ask the user to re-enable because it's been updated.
// This is quite strict, but given the implications, it's
// probably for the best. If this is too annoying, then
// a compromise could be made where we allow-list plugins, or
// change this so that we fully manage them (download and install).
// This would allow use to enforce things like upstream
// checksum or signature verification. This is really complex
// for an initial iteration which is why the first one only
// prompts for re-enablement.
//
// --------------------------------------------------------------------
// --------------------------------------------------------------------
// full-name vs pretty-name
// --------------------------------------------------------------------
// A plugin has two name variants - the full name and the pretty name.
// The full name includes the "glab-" prefix that's required for the
// plugin, and the pretty name has the prefix omitted. Using "glab-ping"
// an example, we'd get the folowing:
//
// FullName: glab-ping
// PrettyName : ping
//
// --------------------------------------------------------------------
// TODO: debug why it always returns
if plugin.PrettyName() == rootCmd.Name() {
return fmt.Errorf("%q cannot be used because command collides with root command", plugin.FullName())
}
for _, subCmd := range rootCmd.Commands() {
if plugin.PrettyName() == subCmd.Name() {
return fmt.Errorf("%q cannot be used because it collides with existing subcommand %q", plugin.FullName(), subCmd.Name())
}
}
// Plugins are expected to implement a --metadata flag that emits
// the plugin metadata stored in the local plugin registry..
pluginMetaCmd := exec.Command(plugin.FullName(), "--metadata")
pluginMetaCmd.WaitDelay = 750 * time.Millisecond
stdout, err := pluginMetaCmd.StdoutPipe()
if err != nil {
return fmt.Errorf("attaching to plugin stdout %s: %w", plugin, err)
}
err = pluginMetaCmd.Start()
if err != nil {
return fmt.Errorf("gathering plugin metadata: %w", err)
}
var data PluginMetadata
err = json.NewDecoder(stdout).Decode(&data)
if err != nil {
return fmt.Errorf("parsing plugin metadata: %w", err)
}
pluginDir := config.PluginDir()
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
return fmt.Errorf("creating plugin directory: %w", err)
}
pluginMetadataPath := filepath.Join(pluginDir, plugin.PrettyName()+".json")
// Ensure that we record the checksum
pluginChecksum, err := checksum(plugin.FullName())
if err != nil {
return fmt.Errorf("generating %s plugin checksum: %w", plugin, err)
}
data.Checksum = pluginChecksum
// Strictly use the pretty name as the plugin name
// in case the plugin returns a different name
// - intentionally or not.
data.Name = plugin.PrettyName()
pluginMetadataFile, err := os.Create(pluginMetadataPath)
if err != nil {
return fmt.Errorf("creating %s plugin metadata in %s: %w", plugin, pluginMetadataPath, err)
}
enc := json.NewEncoder(pluginMetadataFile)
enc.SetIndent("", " ")
err = enc.Encode(&data)
if err != nil {
return fmt.Errorf("saving %s plugin metadata to %s", plugin, pluginMetadataPath)
}
err = pluginMetadataFile.Close()
if err != nil {
return fmt.Errorf("closing %s plugin metadata file: %w", plugin, err)
}
err = pluginMetaCmd.Wait()
if err != nil {
return fmt.Errorf("waiting on %s plugin to close: %w", plugin, err)
}
return nil
}
func checksum(plugin string) (string, error) {
h := sha256.New()
absPluginPath, err := exec.LookPath(plugin)
if err != nil {
return "", err
}
pluginFile, err := os.Open(absPluginPath)
if err != nil {
return "", err
}
_, err = io.Copy(h, pluginFile)
if err != nil {
return "", nil
}
err = pluginFile.Close()
if err != nil {
return "", err
}
var b []byte
b = h.Sum(b)
return "sha256:" + hex.EncodeToString(b), nil
}
func disableCmd(f *cmdutils.Factory) *cobra.Command {
return &cobra.Command{
Use: "disable [plugin]",
Short: "Disables a glab plugin",
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("work in progress")
},
Args: cobra.ExactArgs(1),
}
}

View File

@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/spf13/cobra"
@ -18,14 +19,13 @@ import (
const numSearchers = 4
const glabPrefix = "glab-"
const windowsPrefix = "windows/"
type Plugin string
func (p Plugin) NewCommand(f *cmdutils.Factory) *cobra.Command {
return &cobra.Command{
Use: p.Name(),
Short: fmt.Sprintf("The %s plugin", p.Name()),
Use: p.PrettyName(),
Short: fmt.Sprintf("The %s plugin", p.PrettyName()),
Hidden: false,
GroupID: "Plugins",
DisableFlagParsing: true,
@ -35,21 +35,31 @@ func (p Plugin) NewCommand(f *cmdutils.Factory) *cobra.Command {
}
}
func (p Plugin) Name() string {
elems := strings.Split(p.String(), "-")
return elems[len(elems)-1]
// FullName is the name of the plugin including the `glab-` prefix.
func (p Plugin) FullName() string {
return glabPrefix + string(p)
}
// PrettyName is the name of the plugin without the `glab-` prefix.
func (p Plugin) PrettyName() string {
return strings.TrimPrefix(string(p), glabPrefix)
}
// CommandPath returns the args that make up the path to the plugin command.
func (p Plugin) CommandPath() []string {
args := strings.Split(string(p), "-")
path := []string{"glab"}
path = append(path, args...)
return path
}
// String returns the full name of the plugin.
func (p Plugin) String() string {
return p.bin()
}
func (p Plugin) bin() string {
return string(p)
return p.FullName()
}
func (p Plugin) run(io *iostreams.IOStreams, args ...string) error {
bin, err := exec.LookPath(p.bin())
bin, err := exec.LookPath(p.FullName())
if err != nil {
return fmt.Errorf("resolving %s full path: %w", p, err)
}
@ -107,6 +117,8 @@ func List() ([]Plugin, error) {
return nil, err
}
// Ensure that they're return in alphanumerical order.
slices.Sort(plugins)
return plugins, nil
}

View File

@ -23,6 +23,7 @@ import (
jobCmd "gitlab.com/gitlab-org/cli/commands/job"
labelCmd "gitlab.com/gitlab-org/cli/commands/label"
mrCmd "gitlab.com/gitlab-org/cli/commands/mr"
"gitlab.com/gitlab-org/cli/commands/plugin"
projectCmd "gitlab.com/gitlab-org/cli/commands/project"
releaseCmd "gitlab.com/gitlab-org/cli/commands/release"
scheduleCmd "gitlab.com/gitlab-org/cli/commands/schedule"
@ -132,6 +133,7 @@ func NewCmdRoot(f *cmdutils.Factory, version, buildDate string) *cobra.Command {
rootCmd.AddCommand(scheduleCmd.NewCmdSchedule(f))
rootCmd.AddCommand(snippetCmd.NewCmdSnippet(f))
rootCmd.AddCommand(askCmd.NewCmd(f))
rootCmd.AddCommand(plugin.Command(f))
rootCmd.Flags().BoolP("version", "v", false, "show glab version information")
return rootCmd

View File

@ -35,6 +35,11 @@ func ConfigDir() string {
return filepath.Join(usrConfigHome, "glab-cli")
}
// PluginDir returns the plugin directory
func PluginDir() string {
return filepath.Join(ConfigDir(), "/plugins")
}
// ConfigFile returns the config file path
func ConfigFile() string {
return path.Join(ConfigDir(), "config.yml")