| // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: | |
| //go:build go1.26 | |
| package manager | |
| import ( | |
| "context" | |
| "encoding" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "os" | |
| "os/exec" | |
| "path/filepath" | |
| "strconv" | |
| "strings" | |
| "github.com/docker/cli/cli-plugins/hooks" | |
| "github.com/docker/cli/cli-plugins/metadata" | |
| "github.com/spf13/cobra" | |
| ) | |
| // Plugin represents a potential plugin with all it's metadata. | |
| type Plugin struct { | |
| metadata.Metadata | |
| Name string `json:",omitempty"` | |
| Path string `json:",omitempty"` | |
| // Err is non-nil if the plugin failed one of the candidate tests. | |
| Err error `json:",omitempty"` | |
| // ShadowedPaths contains the paths of any other plugins which this plugin takes precedence over. | |
| ShadowedPaths []string `json:",omitempty"` | |
| } | |
| // MarshalJSON implements [json.Marshaler] to handle marshaling the | |
| // [Plugin.Err] field (Go doesn't marshal errors by default). | |
| func (p *Plugin) MarshalJSON() ([]byte, error) { | |
| type Alias Plugin // avoid recursion | |
| cp := *p // shallow copy to avoid mutating original | |
| if cp.Err != nil { | |
| if _, ok := cp.Err.(encoding.TextMarshaler); !ok { | |
| cp.Err = &pluginError{cp.Err} | |
| } | |
| } | |
| return json.Marshal((*Alias)(&cp)) | |
| } | |
| // pluginCandidate represents a possible plugin candidate, for mocking purposes. | |
| type pluginCandidate interface { | |
| Path() string | |
| Metadata() ([]byte, error) | |
| } | |
| // newPlugin determines if the given candidate is valid and returns a | |
| // Plugin. If the candidate fails one of the tests then `Plugin.Err` | |
| // is set, and is always a `pluginError`, but the `Plugin` is still | |
| // returned with no error. An error is only returned due to a | |
| // non-recoverable error. | |
| func newPlugin(c pluginCandidate, cmds []*cobra.Command) (Plugin, error) { | |
| path := c.Path() | |
| if path == "" { | |
| return Plugin{}, errors.New("plugin candidate path cannot be empty") | |
| } | |
| // The candidate listing process should have skipped anything | |
| // which would fail here, so there are all real errors. | |
| fullname := filepath.Base(path) | |
| if fullname == "." { | |
| return Plugin{}, fmt.Errorf("unable to determine basename of plugin candidate %q", path) | |
| } | |
| var err error | |
| if fullname, err = trimExeSuffix(fullname); err != nil { | |
| return Plugin{}, fmt.Errorf("plugin candidate %q: %w", path, err) | |
| } | |
| if !strings.HasPrefix(fullname, metadata.NamePrefix) { | |
| return Plugin{}, fmt.Errorf("plugin candidate %q: does not have %q prefix", path, metadata.NamePrefix) | |
| } | |
| p := Plugin{ | |
| Name: strings.TrimPrefix(fullname, metadata.NamePrefix), | |
| Path: path, | |
| } | |
| // Now apply the candidate tests, so these update p.Err. | |
| if !isValidPluginName(p.Name) { | |
| p.Err = newPluginError("plugin candidate %q did not match %q", p.Name, pluginNameFormat) | |
| return p, nil | |
| } | |
| for _, cmd := range cmds { | |
| // Ignore conflicts with commands which are | |
| // just plugin stubs (i.e. from a previous | |
| // call to AddPluginCommandStubs). | |
| if IsPluginCommand(cmd) { | |
| continue | |
| } | |
| if cmd.Name() == p.Name { | |
| p.Err = newPluginError("plugin %q duplicates builtin command", p.Name) | |
| return p, nil | |
| } | |
| if cmd.HasAlias(p.Name) { | |
| p.Err = newPluginError("plugin %q duplicates an alias of builtin command %q", p.Name, cmd.Name()) | |
| return p, nil | |
| } | |
| } | |
| // We are supposed to check for relevant execute permissions here. Instead we rely on an attempt to execute. | |
| meta, err := c.Metadata() | |
| if err != nil { | |
| p.Err = wrapAsPluginError(err, "failed to fetch metadata") | |
| return p, nil | |
| } | |
| if err := json.Unmarshal(meta, &p.Metadata); err != nil { | |
| p.Err = wrapAsPluginError(err, "invalid metadata") | |
| return p, nil | |
| } | |
| if err := validateSchemaVersion(p.Metadata.SchemaVersion); err != nil { | |
| p.Err = &pluginError{cause: err} | |
| return p, nil | |
| } | |
| if p.Metadata.Vendor == "" { | |
| p.Err = newPluginError("plugin metadata does not define a vendor") | |
| return p, nil | |
| } | |
| return p, nil | |
| } | |
| // validateSchemaVersion validates if the plugin's schemaVersion is supported. | |
| // | |
| // The current schema-version is "0.1.0", but we don't want to break compatibility | |
| // until v2.0.0 of the schema version. Check for the major version to be < 2.0.0. | |
| // | |
| // Note that CLI versions before 28.4.1 may not support these versions as they were | |
| // hard-coded to only accept "0.1.0". | |
| func validateSchemaVersion(version string) error { | |
| if version == "0.1.0" { | |
| return nil | |
| } | |
| if version == "" { | |
| return errors.New("plugin SchemaVersion version cannot be empty") | |
| } | |
| major, _, ok := strings.Cut(version, ".") | |
| majorVersion, err := strconv.Atoi(major) | |
| if !ok || err != nil { | |
| return fmt.Errorf("plugin SchemaVersion %q has wrong format: must be <major>.<minor>.<patch>", version) | |
| } | |
| if majorVersion > 1 { | |
| return fmt.Errorf("plugin SchemaVersion %q is not supported: must be lower than 2.0.0", version) | |
| } | |
| return nil | |
| } | |
| // RunHook executes the plugin's hooks command | |
| // and returns its unprocessed output. | |
| func (p *Plugin) RunHook(ctx context.Context, hookData hooks.Request) ([]byte, error) { | |
| hDataBytes, err := json.Marshal(hookData) | |
| if err != nil { | |
| return nil, wrapAsPluginError(err, "failed to marshall hook data") | |
| } | |
| pCmd := exec.CommandContext(ctx, p.Path, p.Name, metadata.HookSubcommandName, string(hDataBytes)) // #nosec G204 -- ignore "Subprocess launched with a potential tainted input or cmd arguments" | |
| pCmd.Env = os.Environ() | |
| pCmd.Env = append(pCmd.Env, metadata.ReexecEnvvar+"="+os.Args[0]) | |
| out, err := pCmd.Output() | |
| if err != nil { | |
| if _, ok := errors.AsType[*exec.ExitError](err); ok { | |
| return nil, wrapAsPluginError(err, "plugin hook subcommand exited unsuccessfully") | |
| } | |
| return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand: "+pCmd.String()) | |
| } | |
| return out, nil | |
| } | |
| // pluginNameFormat is used as part of errors for invalid plugin-names. | |
| // We should consider making this less technical ("must start with "a-z", | |
| // and only consist of lowercase alphanumeric characters"). | |
| const pluginNameFormat = `^[a-z][a-z0-9]*$` | |
| func isValidPluginName(s string) bool { | |
| if len(s) == 0 { | |
| return false | |
| } | |
| // first character must be a-z | |
| if c := s[0]; c < 'a' || c > 'z' { | |
| return false | |
| } | |
| // followed by a-z or 0-9 | |
| for i := 1; i < len(s); i++ { | |
| c := s[i] | |
| if (c < 'a' || c > 'z') && (c < '0' || c > '9') { | |
| return false | |
| } | |
| } | |
| return true | |
| } |