chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package platformhost is the bootstrap-time orchestrator that turns the
|
||||
// global plugin registry (extension/platform.RegisteredPlugins) into:
|
||||
//
|
||||
// - a populated internal/hook.Registry (Observer / Wrapper / Lifecycle)
|
||||
// - a list of cmdpolicy.PluginRule contributions (one per plugin that
|
||||
// called r.Restrict)
|
||||
//
|
||||
// Two key invariants:
|
||||
//
|
||||
// - **Atomic install.** A plugin's Install() runs against a staging
|
||||
// Registrar; only when Install returns nil AND validateSelf passes
|
||||
// does the host commit the staged hooks/rule. Partial install never
|
||||
// reaches the live Registry, so a half-loaded plugin cannot leave
|
||||
// stale Observer / Wrap entries behind.
|
||||
//
|
||||
// - **FailurePolicy honoured.** Each plugin declares FailOpen or
|
||||
// FailClosed. FailOpen plugins are skipped on error (warning to
|
||||
// stderr); FailClosed plugins abort the whole bootstrap. The
|
||||
// framework also enforces the Restricts↔FailClosed consistency
|
||||
// contract (a Restricts=true plugin with FailOpen would be a
|
||||
// silent security hole and is rejected during install).
|
||||
//
|
||||
// The host returns:
|
||||
//
|
||||
// - a *hook.Registry ready to install on the command tree
|
||||
// - a []cmdpolicy.PluginRule for the pruning resolver
|
||||
// - an error when a FailClosed plugin failed
|
||||
package internalplatform
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internalplatform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// PluginInstallError is the typed install-time failure. ReasonCode comes
|
||||
// from the closed enum in the design doc (section 5.3 reason_code
|
||||
// table). Cause carries the underlying error, if any, so consumers can
|
||||
// errors.As to inspect it.
|
||||
type PluginInstallError struct {
|
||||
PluginName string
|
||||
ReasonCode string
|
||||
Reason string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *PluginInstallError) Error() string {
|
||||
prefix := fmt.Sprintf("plugin %q (%s)", e.PluginName, e.ReasonCode)
|
||||
if e.Reason != "" {
|
||||
prefix += ": " + e.Reason
|
||||
}
|
||||
if e.Cause != nil {
|
||||
prefix += ": " + e.Cause.Error()
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
func (e *PluginInstallError) Unwrap() error { return e.Cause }
|
||||
|
||||
// ReasonCodes for PluginInstallError. The closed enum is referenced by
|
||||
// the design doc's hard-constraint #15 (reason_code enum closure) and
|
||||
// drives the JSON envelope's error.detail.reason_code field.
|
||||
const (
|
||||
ReasonInvalidPluginName = "invalid_plugin_name"
|
||||
ReasonPluginNamePanic = "plugin_name_panic"
|
||||
ReasonInvalidHookName = "invalid_hook_name"
|
||||
ReasonDuplicateHookName = "duplicate_hook_name"
|
||||
ReasonInvalidHookRegister = "invalid_hook_registration"
|
||||
ReasonInvalidRule = "invalid_rule"
|
||||
ReasonRestrictsMismatch = "restricts_mismatch"
|
||||
ReasonCapabilityUnmet = "capability_unmet"
|
||||
ReasonCapabilitiesPanic = "capabilities_panic"
|
||||
// ReasonInvalidCapability flags a plugin authoring error in
|
||||
// Capabilities() output -- e.g. a syntactically malformed
|
||||
// RequiredCLIVersion string. This is distinct from
|
||||
// ReasonCapabilityUnmet (legitimate version mismatch): an authoring
|
||||
// bug must NOT be hidden by FailurePolicy=FailOpen, so this code is
|
||||
// classified as untrusted-config and aborts unconditionally.
|
||||
ReasonInvalidCapability = "invalid_capability"
|
||||
ReasonInstallFailed = "install_failed"
|
||||
ReasonInstallPanic = "install_panic"
|
||||
ReasonDuplicatePluginName = "duplicate_plugin_name"
|
||||
ReasonMultipleRestricts = "multiple_restrict_plugins"
|
||||
)
|
||||
@@ -0,0 +1,344 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internalplatform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
)
|
||||
|
||||
// PluginInfo is the metadata of a successfully-installed plugin,
|
||||
// captured at install time so diagnostic commands (config plugins show)
|
||||
// can enumerate plugins without re-calling potentially panic-prone
|
||||
// plugin methods at display time.
|
||||
type PluginInfo struct {
|
||||
Name string
|
||||
Version string
|
||||
Capabilities platform.Capabilities
|
||||
}
|
||||
|
||||
// InstallResult is the output of InstallAll. Registry is ready for
|
||||
// hook.Install; PluginRules feeds into cmdpolicy.Resolve as the
|
||||
// "plugin contribution" half of the resolver input. Plugins lists
|
||||
// every plugin that committed successfully (FailOpen-skipped plugins
|
||||
// are absent), for downstream diagnostics.
|
||||
type InstallResult struct {
|
||||
Registry *hook.Registry
|
||||
PluginRules []cmdpolicy.PluginRule
|
||||
Plugins []PluginInfo
|
||||
}
|
||||
|
||||
// InstallAll runs every registered plugin through the staging
|
||||
// Registrar, validates, and commits the survivors. FailOpen plugins
|
||||
// that fail are skipped with a warning; the first FailClosed failure
|
||||
// stops the loop and returns the error.
|
||||
//
|
||||
// Plugins are processed in registration order so the result is
|
||||
// deterministic.
|
||||
//
|
||||
// errOut receives warnings about FailOpen plugin skips. nil errOut
|
||||
// means warnings are dropped (useful in tests).
|
||||
func InstallAll(plugins []platform.Plugin, errOut io.Writer) (*InstallResult, error) {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
result := &InstallResult{
|
||||
Registry: hook.NewRegistry(),
|
||||
}
|
||||
|
||||
// Detect duplicate Plugin.Name. We do this up-front so the error
|
||||
// surfaces before any Install runs; design hard-constraint #7
|
||||
// treats this as configuration error (fail-closed regardless of
|
||||
// individual FailurePolicy).
|
||||
if err := detectDuplicateNames(plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range plugins {
|
||||
name, nameErr := safeCallName(p)
|
||||
if nameErr != nil {
|
||||
// Fail-closed on bad Name: we don't know the plugin's
|
||||
// FailurePolicy yet (it's behind Capabilities, and we
|
||||
// cannot trust Capabilities() before Name() succeeds).
|
||||
return nil, nameErr
|
||||
}
|
||||
if err := installOne(name, p, result); err != nil {
|
||||
// Some errors must abort regardless of FailurePolicy
|
||||
// because they imply the plugin's FailurePolicy itself
|
||||
// cannot be trusted (e.g. the consistency check between
|
||||
// Restricts and FailClosed failed).
|
||||
if isUntrustedConfigError(err) {
|
||||
return nil, err
|
||||
}
|
||||
policy := readFailurePolicy(p)
|
||||
switch policy {
|
||||
case platform.FailClosed:
|
||||
return nil, err
|
||||
default:
|
||||
fmt.Fprintf(errOut, "warning: plugin %q skipped: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isUntrustedConfigError flags errors where the plugin's declared
|
||||
// FailurePolicy is itself part of the misconfiguration. For these the
|
||||
// host MUST abort unconditionally; honouring an FailOpen declaration on
|
||||
// a misconfigured Restricts plugin would defeat the whole point of the
|
||||
// consistency check.
|
||||
func isUntrustedConfigError(err error) bool {
|
||||
var pi *PluginInstallError
|
||||
if !errors.As(err, &pi) {
|
||||
return false
|
||||
}
|
||||
return pi.ReasonCode == ReasonRestrictsMismatch ||
|
||||
pi.ReasonCode == ReasonInvalidPluginName ||
|
||||
pi.ReasonCode == ReasonPluginNamePanic ||
|
||||
pi.ReasonCode == ReasonDuplicatePluginName ||
|
||||
pi.ReasonCode == ReasonInvalidCapability
|
||||
}
|
||||
|
||||
// installOne handles a single plugin: build a staging Registrar, call
|
||||
// Install, run validateSelf, and on success commit to the live
|
||||
// Registry / PluginRules. Any error means staged data is discarded.
|
||||
func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
||||
caps, capsErr := safeCallCapabilities(p)
|
||||
if capsErr != nil {
|
||||
return capsErr
|
||||
}
|
||||
|
||||
// FailurePolicy is a closed enum. An out-of-range value almost
|
||||
// always means the plugin author shipped FailurePolicy(2)/etc. by
|
||||
// mistake, and the host's switch on caps.FailurePolicy below would
|
||||
// silently treat the unknown value as FailOpen — defeating the
|
||||
// security boundary the policy was meant to express. Reject up
|
||||
// front with ReasonInvalidCapability (classified as
|
||||
// untrusted-config, so the abort is unconditional).
|
||||
if caps.FailurePolicy != platform.FailOpen && caps.FailurePolicy != platform.FailClosed {
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonInvalidCapability,
|
||||
Reason: fmt.Sprintf("FailurePolicy=%d is not a recognised value (expected FailOpen or FailClosed)",
|
||||
caps.FailurePolicy),
|
||||
}
|
||||
}
|
||||
|
||||
// Strict consistency check: Restricts=true must pair with
|
||||
// FailClosed (design hard-constraint #6).
|
||||
if caps.Restricts && caps.FailurePolicy != platform.FailClosed {
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonRestrictsMismatch,
|
||||
Reason: "Restricts=true requires FailurePolicy=FailClosed",
|
||||
}
|
||||
}
|
||||
|
||||
// Version compatibility check. Two distinct failure modes:
|
||||
//
|
||||
// 1. Parse error (constraint is malformed, e.g. ">=abc")
|
||||
// -> ReasonInvalidCapability, classified as untrusted-config
|
||||
// so the host aborts unconditionally. This is a plugin
|
||||
// authoring bug; FailurePolicy must NOT mask it.
|
||||
//
|
||||
// 2. Legitimate version mismatch (constraint parses fine but
|
||||
// current CLI does not satisfy it)
|
||||
// -> ReasonCapabilityUnmet, honours FailurePolicy. A FailOpen
|
||||
// plugin announcing ">=2.0" against a 1.x CLI is skipped
|
||||
// with a warning; a FailClosed plugin aborts.
|
||||
if ok, err := satisfiesRequiredCLIVersion(currentCLIVersion(), caps.RequiredCLIVersion); err != nil {
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonInvalidCapability,
|
||||
Reason: err.Error(),
|
||||
}
|
||||
} else if !ok {
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonCapabilityUnmet,
|
||||
Reason: fmt.Sprintf("CLI version %q does not satisfy plugin requirement %q",
|
||||
currentCLIVersion(), caps.RequiredCLIVersion),
|
||||
}
|
||||
}
|
||||
|
||||
staging := newStagingRegistrar(name)
|
||||
if err := safeCallInstall(p, staging); err != nil {
|
||||
// Don't double-wrap typed PluginInstallError -- safeCallInstall
|
||||
// already produces install_panic for recovered panics, and a
|
||||
// re-wrap would bury the precise reason_code under
|
||||
// install_failed.
|
||||
var pi *PluginInstallError
|
||||
if errors.As(err, &pi) {
|
||||
return err
|
||||
}
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonInstallFailed,
|
||||
Reason: "Install returned error",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
if err := staging.validateSelf(caps); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit staged data atomically.
|
||||
for _, e := range staging.stagedObservers {
|
||||
result.Registry.AddObserver(e)
|
||||
}
|
||||
for _, e := range staging.stagedWrappers {
|
||||
result.Registry.AddWrapper(e)
|
||||
}
|
||||
for _, e := range staging.stagedLifecycles {
|
||||
result.Registry.AddLifecycle(e)
|
||||
}
|
||||
for _, rule := range staging.rules {
|
||||
result.PluginRules = append(result.PluginRules, cmdpolicy.PluginRule{
|
||||
PluginName: name,
|
||||
Rule: rule,
|
||||
})
|
||||
}
|
||||
|
||||
// Record the plugin in the inventory. Version is fetched here under
|
||||
// a recover-wrapped helper so a plugin's Version() panic does not
|
||||
// abort the install we just committed.
|
||||
result.Plugins = append(result.Plugins, PluginInfo{
|
||||
Name: name,
|
||||
Version: safeCallVersion(p),
|
||||
Capabilities: caps,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeCallVersion mirrors safeCallName but for Plugin.Version. Failures
|
||||
// degrade to the empty string -- Version is informational, not a hard
|
||||
// contract field, so we never want it to abort installation.
|
||||
func safeCallVersion(p platform.Plugin) (v string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
v = ""
|
||||
}
|
||||
}()
|
||||
return p.Version()
|
||||
}
|
||||
|
||||
// readFailurePolicy reads Capabilities and returns the policy, falling
|
||||
// back to FailClosed if Capabilities() panics. Defensive default: we
|
||||
// assume the worst-case (safety-sensitive) when we cannot read the
|
||||
// declaration.
|
||||
//
|
||||
// **Implementation note**: FailClosed must be the value set BEFORE the
|
||||
// panic-prone call. The zero value of platform.FailurePolicy is
|
||||
// FailOpen, so a "just return after recover" pattern would silently
|
||||
// flip the safe-default to FailOpen on panic -- the opposite of what
|
||||
// the comment claims.
|
||||
func readFailurePolicy(p platform.Plugin) (policy platform.FailurePolicy) {
|
||||
policy = platform.FailClosed
|
||||
defer func() { _ = recover() }()
|
||||
policy = p.Capabilities().FailurePolicy
|
||||
return
|
||||
}
|
||||
|
||||
// safeCallName recovers from a panic in Plugin.Name() and surfaces it
|
||||
// as a typed PluginInstallError. Without recovery, a buggy plugin could
|
||||
// crash the binary before main has a chance to emit a JSON envelope.
|
||||
func safeCallName(p platform.Plugin) (string, error) {
|
||||
var (
|
||||
name string
|
||||
err error
|
||||
)
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = &PluginInstallError{
|
||||
PluginName: "<unknown>",
|
||||
ReasonCode: ReasonPluginNamePanic,
|
||||
Reason: fmt.Sprintf("Plugin.Name() panicked: %v", r),
|
||||
}
|
||||
}
|
||||
}()
|
||||
name = p.Name()
|
||||
}()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hookNamePattern.MatchString(name) {
|
||||
return "", &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonInvalidPluginName,
|
||||
Reason: fmt.Sprintf("Plugin.Name() %q must match ^[a-z0-9][a-z0-9-]*$ (no dots)", name),
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// safeCallCapabilities mirrors safeCallName for Capabilities().
|
||||
func safeCallCapabilities(p platform.Plugin) (caps platform.Capabilities, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = &PluginInstallError{
|
||||
PluginName: pluginNameOrPlaceholder(p),
|
||||
ReasonCode: ReasonCapabilitiesPanic,
|
||||
Reason: fmt.Sprintf("Plugin.Capabilities() panicked: %v", r),
|
||||
}
|
||||
}
|
||||
}()
|
||||
caps = p.Capabilities()
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
// safeCallInstall mirrors safeCallName for Install(). Install panics
|
||||
// become install_panic errors, not crashes.
|
||||
func safeCallInstall(p platform.Plugin, r platform.Registrar) (err error) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
err = &PluginInstallError{
|
||||
PluginName: pluginNameOrPlaceholder(p),
|
||||
ReasonCode: ReasonInstallPanic,
|
||||
Reason: fmt.Sprintf("Install panicked: %v", rec),
|
||||
}
|
||||
}
|
||||
}()
|
||||
return p.Install(r)
|
||||
}
|
||||
|
||||
func pluginNameOrPlaceholder(p platform.Plugin) string {
|
||||
defer func() { _ = recover() }()
|
||||
if n := p.Name(); n != "" {
|
||||
return n
|
||||
}
|
||||
return "<unknown>"
|
||||
}
|
||||
|
||||
// detectDuplicateNames scans the plugin slice for repeated Plugin.Name
|
||||
// values. Returns a typed PluginInstallError on the first duplicate so
|
||||
// the bootstrap aborts.
|
||||
func detectDuplicateNames(plugins []platform.Plugin) error {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range plugins {
|
||||
name, err := safeCallName(p)
|
||||
if err != nil {
|
||||
// Don't double-report: let installOne handle naming
|
||||
// errors per-plugin so we get the same code path.
|
||||
continue
|
||||
}
|
||||
if seen[name] {
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonDuplicatePluginName,
|
||||
Reason: fmt.Sprintf("duplicate Plugin.Name() %q across plugins", name),
|
||||
}
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internalplatform_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// happyPlugin is a textbook plugin: declares Capabilities, calls a few
|
||||
// Registrar methods, returns nil. The install pipeline must accept it.
|
||||
type happyPlugin struct{ name string }
|
||||
|
||||
func (p happyPlugin) Name() string { return p.name }
|
||||
func (p happyPlugin) Version() string { return "1.0.0" }
|
||||
func (p happyPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{
|
||||
FailurePolicy: platform.FailOpen,
|
||||
}
|
||||
}
|
||||
func (p happyPlugin) Install(r platform.Registrar) error {
|
||||
r.Observe(platform.Before, "audit-pre", platform.All(),
|
||||
func(context.Context, platform.Invocation) {})
|
||||
r.Wrap("policy", platform.All(),
|
||||
func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
return next(ctx, inv)
|
||||
}
|
||||
})
|
||||
r.On(platform.Shutdown, "flush",
|
||||
func(context.Context, *platform.LifecycleContext) error { return nil })
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_happyPlugin(t *testing.T) {
|
||||
result, err := internalplatform.InstallAll([]platform.Plugin{happyPlugin{name: "audit"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
if result.Registry == nil {
|
||||
t.Fatalf("registry should be populated")
|
||||
}
|
||||
if len(result.PluginRules) != 0 {
|
||||
t.Errorf("happy plugin did not call Restrict; rules should be empty")
|
||||
}
|
||||
// Cross-check: observers, wrappers, lifecycles got staged through to the live Registry.
|
||||
if len(result.Registry.MatchingObservers(fakeView{}, platform.Before)) != 1 {
|
||||
t.Errorf("Before observer not committed")
|
||||
}
|
||||
if len(result.Registry.MatchingWrappers(fakeView{})) != 1 {
|
||||
t.Errorf("Wrapper not committed")
|
||||
}
|
||||
if len(result.Registry.LifecycleHandlers(platform.Shutdown)) != 1 {
|
||||
t.Errorf("Shutdown lifecycle not committed")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeView satisfies platform.CommandView for selector lookups in the
|
||||
// platformhost tests; All() matches everything so the type can stay
|
||||
// trivial.
|
||||
type fakeView struct{}
|
||||
|
||||
func (fakeView) Path() string { return "" }
|
||||
func (fakeView) Domain() string { return "" }
|
||||
func (fakeView) Risk() (platform.Risk, bool) { return "", false }
|
||||
func (fakeView) Identities() []platform.Identity { return nil }
|
||||
func (fakeView) Annotation(string) (string, bool) { return "", false }
|
||||
|
||||
// A FailClosed plugin whose Install returns an error must abort
|
||||
// InstallAll. Design hard-constraint #6.
|
||||
type failClosedPlugin struct{}
|
||||
|
||||
func (failClosedPlugin) Name() string { return "secaudit" }
|
||||
func (failClosedPlugin) Version() string { return "1.0.0" }
|
||||
func (failClosedPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{
|
||||
FailurePolicy: platform.FailClosed,
|
||||
}
|
||||
}
|
||||
func (failClosedPlugin) Install(platform.Registrar) error {
|
||||
return errors.New("upstream unreachable")
|
||||
}
|
||||
|
||||
func TestInstallAll_failClosedAborts(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{failClosedPlugin{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("FailClosed install error should abort")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) {
|
||||
t.Fatalf("error must be *PluginInstallError, got %T", err)
|
||||
}
|
||||
if pi.ReasonCode != internalplatform.ReasonInstallFailed {
|
||||
t.Errorf("ReasonCode = %q, want install_failed", pi.ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
// FailOpen install failure logs a warning and skips this plugin; other
|
||||
// plugins still get installed.
|
||||
type failOpenPlugin struct{}
|
||||
|
||||
func (failOpenPlugin) Name() string { return "audit-broken" }
|
||||
func (failOpenPlugin) Version() string { return "1.0.0" }
|
||||
func (failOpenPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (failOpenPlugin) Install(platform.Registrar) error {
|
||||
return errors.New("could not connect")
|
||||
}
|
||||
|
||||
func TestInstallAll_failOpenSkips(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
plugins := []platform.Plugin{
|
||||
failOpenPlugin{},
|
||||
happyPlugin{name: "audit"},
|
||||
}
|
||||
result, err := internalplatform.InstallAll(plugins, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("FailOpen failure must not abort, got %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "audit-broken") {
|
||||
t.Errorf("FailOpen warning should mention plugin name, got %q", buf.String())
|
||||
}
|
||||
// Second plugin's observer should be present.
|
||||
if len(result.Registry.MatchingObservers(fakeView{}, platform.Before)) != 1 {
|
||||
t.Errorf("happy plugin's observer should still be installed after first plugin skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// Restricts=true with FailOpen is a configuration error: a policy
|
||||
// plugin that silently disappears under FailOpen would erase the
|
||||
// security boundary. The host must reject this combo BEFORE Install
|
||||
// runs.
|
||||
type misconfiguredRestrictPlugin struct{}
|
||||
|
||||
func (misconfiguredRestrictPlugin) Name() string { return "secaudit" }
|
||||
func (misconfiguredRestrictPlugin) Version() string { return "1.0.0" }
|
||||
func (misconfiguredRestrictPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{
|
||||
Restricts: true, // policy plugin
|
||||
FailurePolicy: platform.FailOpen, // contradicts safety contract
|
||||
}
|
||||
}
|
||||
func (misconfiguredRestrictPlugin) Install(platform.Registrar) error { return nil }
|
||||
|
||||
func TestInstallAll_restrictsRequiresFailClosed(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{misconfiguredRestrictPlugin{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("Restricts+FailOpen must abort")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonRestrictsMismatch {
|
||||
t.Fatalf("ReasonCode = %v, want restricts_mismatch", pi)
|
||||
}
|
||||
}
|
||||
|
||||
// Restricts=true but Install didn't call r.Restrict -> mismatch.
|
||||
type lyingRestrictPlugin struct{}
|
||||
|
||||
func (lyingRestrictPlugin) Name() string { return "p" }
|
||||
func (lyingRestrictPlugin) Version() string { return "1.0.0" }
|
||||
func (lyingRestrictPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{
|
||||
Restricts: true,
|
||||
FailurePolicy: platform.FailClosed,
|
||||
}
|
||||
}
|
||||
func (lyingRestrictPlugin) Install(platform.Registrar) error {
|
||||
// Forgot to call r.Restrict.
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_restrictsDeclaredButNotCalled(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{lyingRestrictPlugin{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("missing Restrict call when declared must fail")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonRestrictsMismatch {
|
||||
t.Fatalf("ReasonCode = %v, want restricts_mismatch", pi)
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin that panics inside Install must NOT crash the binary -- the
|
||||
// host recovers and converts the panic into a typed install_panic.
|
||||
type panicInstallPlugin struct{}
|
||||
|
||||
func (panicInstallPlugin) Name() string { return "panicker" }
|
||||
func (panicInstallPlugin) Version() string { return "1.0.0" }
|
||||
func (panicInstallPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (panicInstallPlugin) Install(platform.Registrar) error {
|
||||
panic("boom")
|
||||
}
|
||||
|
||||
func TestInstallAll_installPanicRecovered(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{panicInstallPlugin{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("Install panic should surface as error")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInstallPanic {
|
||||
t.Fatalf("ReasonCode = %v, want install_panic", pi)
|
||||
}
|
||||
}
|
||||
|
||||
// Two plugins with the same Name must abort before any Install runs.
|
||||
func TestInstallAll_duplicatePluginName(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{
|
||||
happyPlugin{name: "audit"},
|
||||
happyPlugin{name: "audit"},
|
||||
}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("duplicate Plugin.Name must abort")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonDuplicatePluginName {
|
||||
t.Fatalf("ReasonCode = %v, want duplicate_plugin_name", pi)
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin with an invalid Name (contains "." or starts with a hyphen)
|
||||
// must abort with invalid_plugin_name. The dot ban is critical -- the
|
||||
// "{plugin}.{hook}" namespace join would become ambiguous if dots were
|
||||
// allowed inside Plugin.Name().
|
||||
type badNamePlugin struct{ n string }
|
||||
|
||||
func (p badNamePlugin) Name() string { return p.n }
|
||||
func (p badNamePlugin) Version() string { return "1.0.0" }
|
||||
func (p badNamePlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (p badNamePlugin) Install(platform.Registrar) error { return nil }
|
||||
|
||||
func TestInstallAll_invalidPluginName(t *testing.T) {
|
||||
cases := []string{"with.dot", "", "-leading-hyphen", "UPPER"}
|
||||
for _, name := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{badNamePlugin{n: name}}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid name %q should abort", name)
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidPluginName {
|
||||
t.Fatalf("ReasonCode = %v, want invalid_plugin_name", pi)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin's Install registers two hooks with the same name -- the
|
||||
// staging Registrar rejects the second one with duplicate_hook_name.
|
||||
type duplicateHookPlugin struct{}
|
||||
|
||||
func (duplicateHookPlugin) Name() string { return "dup" }
|
||||
func (duplicateHookPlugin) Version() string { return "1.0.0" }
|
||||
func (duplicateHookPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (duplicateHookPlugin) Install(r platform.Registrar) error {
|
||||
r.Observe(platform.Before, "x", platform.All(), func(context.Context, platform.Invocation) {})
|
||||
r.Observe(platform.After, "x", platform.All(), func(context.Context, platform.Invocation) {})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_duplicateHookName(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{duplicateHookPlugin{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("duplicate hookName within same plugin must abort")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonDuplicateHookName {
|
||||
t.Fatalf("ReasonCode = %v, want duplicate_hook_name", pi)
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict contributes a rule to result.PluginRules so the pruning
|
||||
// resolver can pick it up. Exercise the full path.
|
||||
type restrictPlugin struct{ rule *platform.Rule }
|
||||
|
||||
func (p restrictPlugin) Name() string { return "secaudit" }
|
||||
func (p restrictPlugin) Version() string { return "1.0.0" }
|
||||
func (p restrictPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{
|
||||
Restricts: true,
|
||||
FailurePolicy: platform.FailClosed,
|
||||
}
|
||||
}
|
||||
func (p restrictPlugin) Install(r platform.Registrar) error {
|
||||
r.Restrict(p.rule)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_restrictPropagatesRule(t *testing.T) {
|
||||
rule := &platform.Rule{
|
||||
Name: "secaudit-policy",
|
||||
MaxRisk: "read",
|
||||
Allow: []string{"docs/**"},
|
||||
Deny: []string{"docs/+delete-doc"},
|
||||
Identities: []platform.Identity{"bot"},
|
||||
}
|
||||
result, err := internalplatform.InstallAll([]platform.Plugin{restrictPlugin{rule: rule}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
if len(result.PluginRules) != 1 {
|
||||
t.Fatalf("expected 1 plugin rule, got %d", len(result.PluginRules))
|
||||
}
|
||||
stored := result.PluginRules[0].Rule
|
||||
if stored == nil {
|
||||
t.Fatalf("stored rule is nil")
|
||||
}
|
||||
|
||||
// stagingRegistrar.Restrict defensively clones the plugin-supplied
|
||||
// rule so a misbehaving plugin can't mutate it after Install
|
||||
// returns. The clone must carry identical contents but live on a
|
||||
// distinct pointer.
|
||||
if stored == rule {
|
||||
t.Errorf("stored rule should be a clone, got identical pointer")
|
||||
}
|
||||
if stored.Name != rule.Name || stored.MaxRisk != rule.MaxRisk {
|
||||
t.Errorf("stored rule lost data: %+v", stored)
|
||||
}
|
||||
if got, want := len(stored.Allow), len(rule.Allow); got != want {
|
||||
t.Errorf("stored Allow len = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
// Verify the clone is actually isolated: mutating the plugin's
|
||||
// rule after install must not change the stored one.
|
||||
rule.Allow[0] = "evil/**"
|
||||
rule.Deny = append(rule.Deny, "extra/**")
|
||||
if stored.Allow[0] == "evil/**" {
|
||||
t.Errorf("Allow slice aliased plugin storage")
|
||||
}
|
||||
if len(stored.Deny) != 1 {
|
||||
t.Errorf("Deny slice aliased plugin storage: %v", stored.Deny)
|
||||
}
|
||||
|
||||
if result.PluginRules[0].PluginName != "secaudit" {
|
||||
t.Errorf("PluginName = %q", result.PluginRules[0].PluginName)
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic install: a plugin whose validation fails AFTER it registered
|
||||
// some hooks must NOT leak those hooks into the live registry. The
|
||||
// staging buffer is the atomicity boundary.
|
||||
type partiallyRegisterThenFailPlugin struct{}
|
||||
|
||||
func (partiallyRegisterThenFailPlugin) Name() string { return "partial" }
|
||||
func (partiallyRegisterThenFailPlugin) Version() string { return "1.0.0" }
|
||||
func (partiallyRegisterThenFailPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{
|
||||
Restricts: true, // declares Restrict but won't call it
|
||||
FailurePolicy: platform.FailClosed,
|
||||
}
|
||||
}
|
||||
func (partiallyRegisterThenFailPlugin) Install(r platform.Registrar) error {
|
||||
r.Observe(platform.Before, "would-leak", platform.All(),
|
||||
func(context.Context, platform.Invocation) {})
|
||||
// validateSelf will fail because Restricts=true but Restrict
|
||||
// was not called -- this is the atomic-rollback case.
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_atomicRollback(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll(
|
||||
[]platform.Plugin{partiallyRegisterThenFailPlugin{}, happyPlugin{name: "audit"}},
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("partial plugin should abort (FailClosed)")
|
||||
}
|
||||
// We cannot check Registry contents here because InstallAll
|
||||
// returns nil on failure; the rollback invariant is "nothing the
|
||||
// failing plugin staged ever reached a live Registry", which is
|
||||
// proven by the fact that we got nil back. A weaker but useful
|
||||
// check: even if we passed a happy second plugin, the loop must
|
||||
// have stopped at the first FailClosed failure.
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) {
|
||||
t.Fatalf("error must be *PluginInstallError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// multiRestrictPlugin calls r.Restrict twice -- the multi-rule case. A
|
||||
// single plugin may declare several scoped grants; both must be collected
|
||||
// into PluginRules under the same plugin name, in registration order.
|
||||
type multiRestrictPlugin struct{}
|
||||
|
||||
func (multiRestrictPlugin) Name() string { return "secaudit" }
|
||||
func (multiRestrictPlugin) Version() string { return "1.0.0" }
|
||||
func (multiRestrictPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{
|
||||
Restricts: true,
|
||||
FailurePolicy: platform.FailClosed,
|
||||
}
|
||||
}
|
||||
func (multiRestrictPlugin) Install(r platform.Registrar) error {
|
||||
r.Restrict(&platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead})
|
||||
r.Restrict(&platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: platform.RiskWrite})
|
||||
return nil
|
||||
}
|
||||
|
||||
// A single plugin calling Restrict more than once is valid (multi-rule
|
||||
// support): both rules are collected, in order, under the one plugin name.
|
||||
// This pins the behaviour change from the old "Restrict at most once"
|
||||
// double_restrict error.
|
||||
func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) {
|
||||
result, err := internalplatform.InstallAll([]platform.Plugin{multiRestrictPlugin{}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("multiple Restrict per plugin must succeed, got %v", err)
|
||||
}
|
||||
if len(result.PluginRules) != 2 {
|
||||
t.Fatalf("PluginRules = %d, want 2", len(result.PluginRules))
|
||||
}
|
||||
for _, pr := range result.PluginRules {
|
||||
if pr.PluginName != "secaudit" {
|
||||
t.Errorf("PluginName = %q, want secaudit", pr.PluginName)
|
||||
}
|
||||
}
|
||||
if result.PluginRules[0].Rule.Name != "docs-ro" || result.PluginRules[1].Rule.Name != "im-rw" {
|
||||
t.Errorf("rules out of order: %q, %q",
|
||||
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internalplatform
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
)
|
||||
|
||||
// HookEntry is the displayable form of one registered hook.
|
||||
type HookEntry struct {
|
||||
Name string `json:"name"`
|
||||
When string `json:"when,omitempty"` // observers only
|
||||
Event string `json:"event,omitempty"` // lifecycle only
|
||||
}
|
||||
|
||||
// PluginEntry collects everything one plugin contributed.
|
||||
type PluginEntry struct {
|
||||
Name string
|
||||
Version string
|
||||
Capabilities CapabilitiesView
|
||||
|
||||
// Rules holds the plugin's Restrict contributions, one per r.Restrict
|
||||
// call (a plugin may declare several scoped rules). Empty when the
|
||||
// plugin did not call r.Restrict.
|
||||
Rules []*RuleView
|
||||
|
||||
Observers []HookEntry
|
||||
Wrappers []HookEntry
|
||||
Lifecycles []HookEntry
|
||||
}
|
||||
|
||||
// CapabilitiesView mirrors platform.Capabilities for display. We keep a
|
||||
// separate struct so the JSON shape stays under our control and does
|
||||
// not drift with extension/platform.
|
||||
type CapabilitiesView struct {
|
||||
Restricts bool `json:"restricts"`
|
||||
FailurePolicy string `json:"failure_policy"`
|
||||
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
|
||||
}
|
||||
|
||||
// NewCapabilitiesView converts a platform.Capabilities value into the
|
||||
// display struct.
|
||||
func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
|
||||
return CapabilitiesView{
|
||||
Restricts: c.Restricts,
|
||||
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
|
||||
RequiredCLIVersion: c.RequiredCLIVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func failurePolicyLabel(p platform.FailurePolicy) string {
|
||||
switch p {
|
||||
case platform.FailOpen:
|
||||
return "FailOpen"
|
||||
case platform.FailClosed:
|
||||
return "FailClosed"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RuleView is the displayable form of a Plugin.Restrict contribution.
|
||||
type RuleView struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Allow []string `json:"allow"`
|
||||
Deny []string `json:"deny"`
|
||||
MaxRisk string `json:"max_risk"`
|
||||
Identities []string `json:"identities"`
|
||||
AllowUnannotated bool `json:"allow_unannotated"`
|
||||
}
|
||||
|
||||
// Inventory is the full snapshot.
|
||||
type Inventory struct {
|
||||
Plugins []PluginEntry
|
||||
}
|
||||
|
||||
// PluginInventorySource is the minimum slice of PluginInfo BuildInventory needs.
|
||||
type PluginInventorySource struct {
|
||||
Name string
|
||||
Version string
|
||||
Capabilities platform.Capabilities
|
||||
}
|
||||
|
||||
// RuleInventorySource is the minimum slice of cmdpolicy.PluginRule
|
||||
// BuildInventory needs. Kept as plain strings to avoid an import
|
||||
// cycle with cmdpolicy (the caller converts platform.Risk / Identity
|
||||
// to string at the boundary).
|
||||
type RuleInventorySource struct {
|
||||
PluginName string
|
||||
Allow []string
|
||||
Deny []string
|
||||
MaxRisk string
|
||||
Identities []string
|
||||
RuleName string
|
||||
Desc string
|
||||
AllowUnannotated bool
|
||||
}
|
||||
|
||||
// BuildInventory assembles an Inventory from the parts produced by
|
||||
// InstallAll: the plugin metadata list, the hook registry (may be nil
|
||||
// when no hooks were registered), and the plugin rules.
|
||||
//
|
||||
// Hooks are attributed to plugins by the namespaced name convention:
|
||||
// each entry's Name starts with "<plugin>.", and we group by the
|
||||
// leading segment up to the first dot.
|
||||
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
|
||||
byPlugin := make(map[string]*PluginEntry, len(plugins))
|
||||
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
|
||||
for _, p := range plugins {
|
||||
entry := PluginEntry{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Capabilities: NewCapabilitiesView(p.Capabilities),
|
||||
}
|
||||
out.Plugins = append(out.Plugins, entry)
|
||||
}
|
||||
for i := range out.Plugins {
|
||||
byPlugin[out.Plugins[i].Name] = &out.Plugins[i]
|
||||
}
|
||||
|
||||
if registry != nil {
|
||||
for _, e := range registry.Observers() {
|
||||
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
|
||||
entry.Observers = append(entry.Observers, HookEntry{
|
||||
Name: e.Name,
|
||||
When: whenLabel(e.When),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, e := range registry.Wrappers() {
|
||||
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
|
||||
entry.Wrappers = append(entry.Wrappers, HookEntry{
|
||||
Name: e.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, e := range registry.Lifecycles() {
|
||||
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
|
||||
entry.Lifecycles = append(entry.Lifecycles, HookEntry{
|
||||
Name: e.Name,
|
||||
Event: eventLabel(e.Event),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
if entry := byPlugin[r.PluginName]; entry != nil {
|
||||
entry.Rules = append(entry.Rules, &RuleView{
|
||||
Name: r.RuleName,
|
||||
Description: r.Desc,
|
||||
Allow: append([]string(nil), r.Allow...),
|
||||
Deny: append([]string(nil), r.Deny...),
|
||||
MaxRisk: r.MaxRisk,
|
||||
Identities: append([]string(nil), r.Identities...),
|
||||
AllowUnannotated: r.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ownerOf extracts the plugin name from a namespaced hook name. The
|
||||
// platform forbids "." in plugin names, so the first dot is always the
|
||||
// namespace separator. Names without a dot are returned as-is.
|
||||
func ownerOf(hookName string) string {
|
||||
if i := strings.IndexByte(hookName, '.'); i >= 0 {
|
||||
return hookName[:i]
|
||||
}
|
||||
return hookName
|
||||
}
|
||||
|
||||
func whenLabel(w platform.When) string {
|
||||
switch w {
|
||||
case platform.Before:
|
||||
return "Before"
|
||||
case platform.After:
|
||||
return "After"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func eventLabel(e platform.LifecycleEvent) string {
|
||||
switch e {
|
||||
case platform.Startup:
|
||||
return "Startup"
|
||||
case platform.Shutdown:
|
||||
return "Shutdown"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Active inventory storage (process-global) ---
|
||||
|
||||
var (
|
||||
inventoryMu sync.RWMutex
|
||||
activeInventory *Inventory
|
||||
)
|
||||
|
||||
// SetActiveInventory records the inventory built at bootstrap. Called
|
||||
// once from cmd/policy.go after install + wireHooks complete.
|
||||
//
|
||||
// A deep copy is taken so the snapshot is immune to later mutations of
|
||||
// the input by the caller (or by any other goroutine reading the same
|
||||
// PluginEntry slice). Without deep-copy, the shallow `cp := *inv`
|
||||
// previously still aliased Plugins / observer / wrapper / lifecycle
|
||||
// slices and the embedded RuleView's slice fields.
|
||||
func SetActiveInventory(inv *Inventory) {
|
||||
inventoryMu.Lock()
|
||||
defer inventoryMu.Unlock()
|
||||
if inv == nil {
|
||||
activeInventory = nil
|
||||
return
|
||||
}
|
||||
activeInventory = cloneInventory(inv)
|
||||
}
|
||||
|
||||
// GetActiveInventory returns a deep copy of the inventory, or nil if
|
||||
// bootstrap has not finished. Same reasoning as SetActiveInventory:
|
||||
// returning a shallow copy would let callers reach into the stored
|
||||
// global through any of the embedded slices.
|
||||
func GetActiveInventory() *Inventory {
|
||||
inventoryMu.RLock()
|
||||
defer inventoryMu.RUnlock()
|
||||
if activeInventory == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneInventory(activeInventory)
|
||||
}
|
||||
|
||||
// cloneInventory deep-copies every level the snapshot exposes:
|
||||
// top-level struct, Plugins slice, each PluginEntry's hook slices, and
|
||||
// the rule's slice fields. The hook entries themselves are value types
|
||||
// so the slice copy already disjoints them.
|
||||
func cloneInventory(in *Inventory) *Inventory {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := &Inventory{
|
||||
Plugins: make([]PluginEntry, len(in.Plugins)),
|
||||
}
|
||||
for i, p := range in.Plugins {
|
||||
entry := PluginEntry{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Capabilities: p.Capabilities,
|
||||
}
|
||||
if p.Rules != nil {
|
||||
entry.Rules = make([]*RuleView, len(p.Rules))
|
||||
for j, r := range p.Rules {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
rv := *r
|
||||
rv.Allow = append([]string(nil), r.Allow...)
|
||||
rv.Deny = append([]string(nil), r.Deny...)
|
||||
rv.Identities = append([]string(nil), r.Identities...)
|
||||
entry.Rules[j] = &rv
|
||||
}
|
||||
}
|
||||
entry.Observers = append([]HookEntry(nil), p.Observers...)
|
||||
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
|
||||
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)
|
||||
out.Plugins[i] = entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internalplatform_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
func TestBuildInventory_groupsByPluginName(t *testing.T) {
|
||||
plugins := []internalplatform.PluginInventorySource{
|
||||
{Name: "a", Version: "1.0", Capabilities: platform.Capabilities{
|
||||
Restricts: true, FailurePolicy: platform.FailClosed,
|
||||
}},
|
||||
{Name: "b", Version: "2.0"},
|
||||
}
|
||||
|
||||
r := hook.NewRegistry()
|
||||
obs := func(context.Context, platform.Invocation) {}
|
||||
wrap := func(next platform.Handler) platform.Handler { return next }
|
||||
lc := func(context.Context, *platform.LifecycleContext) error { return nil }
|
||||
|
||||
r.AddObserver(hook.ObserverEntry{Name: "a.pre", When: platform.Before, Selector: platform.All(), Fn: obs})
|
||||
r.AddObserver(hook.ObserverEntry{Name: "a.post", When: platform.After, Selector: platform.All(), Fn: obs})
|
||||
r.AddObserver(hook.ObserverEntry{Name: "b.audit", When: platform.Before, Selector: platform.All(), Fn: obs})
|
||||
r.AddWrapper(hook.WrapperEntry{Name: "a.approval", Selector: platform.All(), Fn: wrap})
|
||||
r.AddLifecycle(hook.LifecycleEntry{Name: "a.boot", Event: platform.Startup, Fn: lc})
|
||||
r.AddLifecycle(hook.LifecycleEntry{Name: "b.bye", Event: platform.Shutdown, Fn: lc})
|
||||
|
||||
rules := []internalplatform.RuleInventorySource{
|
||||
{PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
||||
}
|
||||
|
||||
inv := internalplatform.BuildInventory(plugins, r, rules)
|
||||
|
||||
if got := len(inv.Plugins); got != 2 {
|
||||
t.Fatalf("Plugins len = %d, want 2", got)
|
||||
}
|
||||
a := findPlugin(inv, "a")
|
||||
b := findPlugin(inv, "b")
|
||||
if a == nil || b == nil {
|
||||
t.Fatalf("missing entries: a=%v b=%v", a, b)
|
||||
}
|
||||
|
||||
if got := len(a.Observers); got != 2 {
|
||||
t.Errorf("a.Observers = %d, want 2", got)
|
||||
}
|
||||
if got := len(a.Wrappers); got != 1 {
|
||||
t.Errorf("a.Wrappers = %d, want 1", got)
|
||||
}
|
||||
if got := len(a.Lifecycles); got != 1 {
|
||||
t.Errorf("a.Lifecycles = %d, want 1", got)
|
||||
}
|
||||
if len(a.Rules) != 1 || a.Rules[0].Name != "a-rule" {
|
||||
t.Errorf("a.Rules = %+v, want single rule name a-rule", a.Rules)
|
||||
}
|
||||
if a.Capabilities.FailurePolicy != "FailClosed" {
|
||||
t.Errorf("a.Capabilities.FailurePolicy = %q, want FailClosed", a.Capabilities.FailurePolicy)
|
||||
}
|
||||
|
||||
if got := len(b.Observers); got != 1 {
|
||||
t.Errorf("b.Observers = %d, want 1 (only b.audit)", got)
|
||||
}
|
||||
if len(b.Rules) != 0 {
|
||||
t.Errorf("b.Rules = %+v, want empty (b did not call Restrict)", b.Rules)
|
||||
}
|
||||
if b.Capabilities.FailurePolicy != "FailOpen" {
|
||||
t.Errorf("b.Capabilities.FailurePolicy = %q, want FailOpen (zero value)", b.Capabilities.FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin contributing several rules (same PluginName, multiple
|
||||
// RuleInventorySource entries) must surface ALL of them under Rules, in
|
||||
// order -- not silently overwrite down to the last one. Pins the
|
||||
// multi-rule inventory fix.
|
||||
func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
|
||||
plugins := []internalplatform.PluginInventorySource{
|
||||
{Name: "a", Version: "1.0", Capabilities: platform.Capabilities{
|
||||
Restricts: true, FailurePolicy: platform.FailClosed,
|
||||
}},
|
||||
}
|
||||
rules := []internalplatform.RuleInventorySource{
|
||||
{PluginName: "a", RuleName: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
||||
{PluginName: "a", RuleName: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
|
||||
}
|
||||
|
||||
inv := internalplatform.BuildInventory(plugins, nil, rules)
|
||||
a := findPlugin(inv, "a")
|
||||
if a == nil {
|
||||
t.Fatalf("missing entry a")
|
||||
}
|
||||
if len(a.Rules) != 2 {
|
||||
t.Fatalf("a.Rules = %d, want 2 (both rules preserved, no overwrite)", len(a.Rules))
|
||||
}
|
||||
if a.Rules[0].Name != "docs-ro" || a.Rules[1].Name != "im-rw" {
|
||||
t.Errorf("rules out of order: %q, %q", a.Rules[0].Name, a.Rules[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInventory_empty(t *testing.T) {
|
||||
inv := internalplatform.BuildInventory(nil, nil, nil)
|
||||
if got := len(inv.Plugins); got != 0 {
|
||||
t.Errorf("Plugins len = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry {
|
||||
for i := range inv.Plugins {
|
||||
if inv.Plugins[i].Name == name {
|
||||
return &inv.Plugins[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internalplatform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
)
|
||||
|
||||
// hookNamePattern is the grammar both Plugin.Name() and hookName must
|
||||
// match -- design hard-constraint #9. The "." character is forbidden so
|
||||
// the namespace join "{plugin}.{hook}" is unambiguous.
|
||||
var hookNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// stagingRegistrar buffers every Registrar call so the platformhost can
|
||||
// commit them atomically (or discard them all) once Install returns.
|
||||
//
|
||||
// All validation happens here at staging time -- bad hookName, nil
|
||||
// handler, duplicate names, etc. produce typed errors that surface in
|
||||
// validateSelf and are translated into PluginInstallError by the host
|
||||
// loop.
|
||||
type stagingRegistrar struct {
|
||||
pluginName string
|
||||
|
||||
stagedObservers []hook.ObserverEntry
|
||||
stagedWrappers []hook.WrapperEntry
|
||||
stagedLifecycles []hook.LifecycleEntry
|
||||
|
||||
// rules holds the staged Restrict contributions, captured for the
|
||||
// host to feed into the resolver later. Empty means the plugin did
|
||||
// not call r.Restrict. A plugin may call Restrict more than once;
|
||||
// each call adds one scoped Rule (OR-combined by the engine).
|
||||
rules []*platform.Rule
|
||||
|
||||
// actuallyRestricted records whether r.Restrict was called at all
|
||||
// (even Restrict(nil)), so the Restricts-vs-actual consistency check
|
||||
// can detect the call.
|
||||
actuallyRestricted bool
|
||||
|
||||
// seenHookNames detects duplicate hookName within this plugin's
|
||||
// Install call.
|
||||
seenHookNames map[string]bool
|
||||
|
||||
// stagingErrs accumulates per-call validation errors. A single
|
||||
// Install can violate the grammar multiple times; collecting all
|
||||
// of them lets diagnostic output show the full picture.
|
||||
stagingErrs []stagingErr
|
||||
}
|
||||
|
||||
// stagingErr is the per-call buffered validation failure.
|
||||
type stagingErr struct {
|
||||
reasonCode string
|
||||
message string
|
||||
}
|
||||
|
||||
func newStagingRegistrar(pluginName string) *stagingRegistrar {
|
||||
return &stagingRegistrar{
|
||||
pluginName: pluginName,
|
||||
seenHookNames: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Registrar interface ---
|
||||
|
||||
func (r *stagingRegistrar) Observe(when platform.When, name string, sel platform.Selector, fn platform.Observer) {
|
||||
if !r.validateName(name) {
|
||||
return
|
||||
}
|
||||
if !r.validateNonNilSelector(name, sel) {
|
||||
return
|
||||
}
|
||||
if fn == nil {
|
||||
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("observe %q: handler is nil", name))
|
||||
return
|
||||
}
|
||||
if !isValidWhen(when) {
|
||||
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("observe %q: invalid When value %d", name, when))
|
||||
return
|
||||
}
|
||||
r.stagedObservers = append(r.stagedObservers, hook.ObserverEntry{
|
||||
Name: r.namespaced(name),
|
||||
When: when,
|
||||
Selector: sel,
|
||||
Fn: fn,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *stagingRegistrar) Wrap(name string, sel platform.Selector, w platform.Wrapper) {
|
||||
if !r.validateName(name) {
|
||||
return
|
||||
}
|
||||
if !r.validateNonNilSelector(name, sel) {
|
||||
return
|
||||
}
|
||||
if w == nil {
|
||||
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("wrap %q: handler is nil", name))
|
||||
return
|
||||
}
|
||||
r.stagedWrappers = append(r.stagedWrappers, hook.WrapperEntry{
|
||||
Name: r.namespaced(name),
|
||||
Selector: sel,
|
||||
Fn: w,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *stagingRegistrar) On(event platform.LifecycleEvent, name string, fn platform.LifecycleHandler) {
|
||||
if !r.validateName(name) {
|
||||
return
|
||||
}
|
||||
if fn == nil {
|
||||
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("on %q: handler is nil", name))
|
||||
return
|
||||
}
|
||||
if !isValidLifecycleEvent(event) {
|
||||
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("on %q: invalid LifecycleEvent value %d", name, event))
|
||||
return
|
||||
}
|
||||
r.stagedLifecycles = append(r.stagedLifecycles, hook.LifecycleEntry{
|
||||
Name: r.namespaced(name),
|
||||
Event: event,
|
||||
Fn: fn,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *stagingRegistrar) Restrict(rule *platform.Rule) {
|
||||
r.actuallyRestricted = true
|
||||
if rule == nil {
|
||||
r.bufferErr(ReasonInvalidRule, "Restrict(nil)")
|
||||
return
|
||||
}
|
||||
// Defensive clone: retaining the caller's *Rule directly would let
|
||||
// the plugin mutate Allow/Deny/Identities (or even the whole rule)
|
||||
// after Install returns, bypassing the validation we run on the
|
||||
// stored copy in validateSelf. Take an independent snapshot of
|
||||
// every slice field so the post-validation rule is frozen.
|
||||
cp := *rule
|
||||
cp.Allow = append([]string(nil), rule.Allow...)
|
||||
cp.Deny = append([]string(nil), rule.Deny...)
|
||||
cp.Identities = append([]platform.Identity(nil), rule.Identities...)
|
||||
r.rules = append(r.rules, &cp)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func (r *stagingRegistrar) namespaced(name string) string {
|
||||
return r.pluginName + "." + name
|
||||
}
|
||||
|
||||
func (r *stagingRegistrar) validateName(name string) bool {
|
||||
if !hookNamePattern.MatchString(name) {
|
||||
r.bufferErr(ReasonInvalidHookName, fmt.Sprintf("hookName %q must match ^[a-z0-9][a-z0-9-]*$", name))
|
||||
return false
|
||||
}
|
||||
if r.seenHookNames[name] {
|
||||
r.bufferErr(ReasonDuplicateHookName, fmt.Sprintf("hookName %q registered twice in same plugin", name))
|
||||
return false
|
||||
}
|
||||
r.seenHookNames[name] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *stagingRegistrar) validateNonNilSelector(name string, sel platform.Selector) bool {
|
||||
if sel == nil {
|
||||
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("hook %q: selector is nil", name))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *stagingRegistrar) bufferErr(reasonCode, message string) {
|
||||
r.stagingErrs = append(r.stagingErrs, stagingErr{
|
||||
reasonCode: reasonCode,
|
||||
message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// validateSelf runs after Install returns. It checks:
|
||||
//
|
||||
// - any buffered staging error -> abort
|
||||
// - Restricts declared but Install did not call r.Restrict -> abort
|
||||
// - Restricts NOT declared but Install did call r.Restrict -> abort
|
||||
//
|
||||
// Returns the first PluginInstallError encountered (callers can use
|
||||
// errors.As to inspect it). Nil means staging is clean.
|
||||
func (r *stagingRegistrar) validateSelf(caps platform.Capabilities) error {
|
||||
if len(r.stagingErrs) > 0 {
|
||||
first := r.stagingErrs[0]
|
||||
return &PluginInstallError{
|
||||
PluginName: r.pluginName,
|
||||
ReasonCode: first.reasonCode,
|
||||
Reason: first.message,
|
||||
}
|
||||
}
|
||||
if caps.Restricts && !r.actuallyRestricted {
|
||||
return &PluginInstallError{
|
||||
PluginName: r.pluginName,
|
||||
ReasonCode: ReasonRestrictsMismatch,
|
||||
Reason: "Capabilities.Restricts=true but Install did not call r.Restrict",
|
||||
}
|
||||
}
|
||||
if !caps.Restricts && r.actuallyRestricted {
|
||||
return &PluginInstallError{
|
||||
PluginName: r.pluginName,
|
||||
ReasonCode: ReasonRestrictsMismatch,
|
||||
Reason: "Capabilities.Restricts=false but Install called r.Restrict",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidWhen(w platform.When) bool {
|
||||
return w == platform.Before || w == platform.After
|
||||
}
|
||||
|
||||
func isValidLifecycleEvent(e platform.LifecycleEvent) bool {
|
||||
switch e {
|
||||
case platform.Startup, platform.Shutdown:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internalplatform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
)
|
||||
|
||||
// currentCLIVersion returns the running binary's version, redirectable
|
||||
// from tests via SetCurrentCLIVersionForTesting. Production reads from
|
||||
// internal/build.Version, which is set by -ldflags at release time.
|
||||
var currentCLIVersion = func() string { return build.Version }
|
||||
|
||||
// SetCurrentCLIVersionForTesting overrides the version reported to the
|
||||
// RequiredCLIVersion check. Returns a restore function tests must defer.
|
||||
func SetCurrentCLIVersionForTesting(v string) func() {
|
||||
old := currentCLIVersion
|
||||
currentCLIVersion = func() string { return v }
|
||||
return func() { currentCLIVersion = old }
|
||||
}
|
||||
|
||||
// satisfiesRequiredCLIVersion reports whether buildVersion meets the
|
||||
// constraint declared by Plugin.Capabilities().RequiredCLIVersion.
|
||||
//
|
||||
// Supported constraint forms (single comparator, no compound):
|
||||
//
|
||||
// "" - no requirement (always satisfied)
|
||||
// "1.2.3" - exact match (equivalent to "=1.2.3")
|
||||
// "=1.2.3" - exact match
|
||||
// ">=1.2" - buildVersion >= 1.2 (missing patch -> 0)
|
||||
// ">1.2" - strict greater than
|
||||
// "<=1.2" - less than or equal
|
||||
// "<1.2" - strict less than
|
||||
//
|
||||
// Development builds (buildVersion == "DEV" or "") always satisfy the
|
||||
// constraint; the check is meaningful only for tagged releases.
|
||||
//
|
||||
// Returns false and an error when constraint is malformed -- callers
|
||||
// should treat parse errors as fail-closed so an authoring mistake in
|
||||
// the plugin does not silently load against the wrong CLI version.
|
||||
//
|
||||
// **Order of checks**: constraint syntax is validated FIRST, before the
|
||||
// DEV-build short-circuit. A malformed constraint is a plugin authoring
|
||||
// bug; we surface it even on DEV builds so the typo can be caught
|
||||
// during plugin development instead of waiting for the first tagged
|
||||
// release to expose it.
|
||||
func satisfiesRequiredCLIVersion(buildVersion, constraint string) (bool, error) {
|
||||
constraint = strings.TrimSpace(constraint)
|
||||
if constraint == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
op, rhs := splitConstraint(constraint)
|
||||
rv, err := parseSemverPrefix(rhs)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid RequiredCLIVersion %q: %w", constraint, err)
|
||||
}
|
||||
|
||||
if buildVersion == "" || buildVersion == "DEV" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
bv, err := parseSemverPrefix(buildVersion)
|
||||
if err != nil {
|
||||
// Build version is unparseable -- treat as DEV so an exotic
|
||||
// build tag doesn't lock plugins out.
|
||||
return true, nil //nolint:nilerr // intentional fail-open for unparseable buildVersion
|
||||
}
|
||||
cmp := compareSemver(bv, rv)
|
||||
switch op {
|
||||
case "=", "":
|
||||
return cmp == 0, nil
|
||||
case ">=":
|
||||
return cmp >= 0, nil
|
||||
case ">":
|
||||
return cmp > 0, nil
|
||||
case "<=":
|
||||
return cmp <= 0, nil
|
||||
case "<":
|
||||
return cmp < 0, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid RequiredCLIVersion %q: unknown operator %q", constraint, op)
|
||||
}
|
||||
}
|
||||
|
||||
// splitConstraint extracts the leading comparator (if any) from a
|
||||
// constraint string. The operator is one of "", "=", ">=", ">", "<=", "<".
|
||||
func splitConstraint(s string) (op, rest string) {
|
||||
switch {
|
||||
case strings.HasPrefix(s, ">="):
|
||||
return ">=", strings.TrimSpace(s[2:])
|
||||
case strings.HasPrefix(s, "<="):
|
||||
return "<=", strings.TrimSpace(s[2:])
|
||||
case strings.HasPrefix(s, ">"):
|
||||
return ">", strings.TrimSpace(s[1:])
|
||||
case strings.HasPrefix(s, "<"):
|
||||
return "<", strings.TrimSpace(s[1:])
|
||||
case strings.HasPrefix(s, "="):
|
||||
return "=", strings.TrimSpace(s[1:])
|
||||
default:
|
||||
return "", s
|
||||
}
|
||||
}
|
||||
|
||||
// parseSemverPrefix parses MAJOR[.MINOR[.PATCH]] and drops any pre-release /
|
||||
// build suffix. Missing minor / patch default to 0. Accepts a leading "v".
|
||||
func parseSemverPrefix(s string) (parts [3]int, err error) {
|
||||
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
if s == "" {
|
||||
return parts, fmt.Errorf("empty version")
|
||||
}
|
||||
// Trim pre-release/build suffix at first '-' or '+'.
|
||||
for i, c := range s {
|
||||
if c == '-' || c == '+' {
|
||||
s = s[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
fields := strings.Split(s, ".")
|
||||
// Reject `1.2.3.4` and longer instead of silently truncating —
|
||||
// truncation hides the typo and lets a malformed RequiredCLIVersion
|
||||
// pass validation while the comparator below operates on the wrong
|
||||
// components. Build-version parsing has its own fail-open guard
|
||||
// upstream (see satisfiesRequiredCLIVersion comment about exotic
|
||||
// build tags), so it stays compatible.
|
||||
if len(fields) > 3 {
|
||||
return [3]int{}, fmt.Errorf("version %q has more than three numeric components", s)
|
||||
}
|
||||
for i, f := range fields {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(f))
|
||||
if err != nil || n < 0 {
|
||||
return [3]int{}, fmt.Errorf("non-numeric component %q in version %q", f, s)
|
||||
}
|
||||
parts[i] = n
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func compareSemver(a, b [3]int) int {
|
||||
for i := 0; i < 3; i++ {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internalplatform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestSatisfiesRequiredCLIVersion_constraints(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
build string
|
||||
constraint string
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty constraint always satisfied", "1.0.0", "", true, false},
|
||||
{"DEV build always satisfied", "DEV", ">=99.0.0", true, false},
|
||||
{"empty build counts as DEV", "", ">=99.0.0", true, false},
|
||||
{"v prefix stripped", "v1.0.28", ">=1.0.0", true, false},
|
||||
{"exact match implicit operator", "1.0.0", "1.0.0", true, false},
|
||||
{"exact match explicit =", "1.0.0", "=1.0.0", true, false},
|
||||
{">= equal", "1.0.0", ">=1.0.0", true, false},
|
||||
{">= higher", "1.2.0", ">=1.0.0", true, false},
|
||||
{">= lower fails", "1.0.0", ">=2.0.0", false, false},
|
||||
{"> strict higher", "1.0.1", ">1.0.0", true, false},
|
||||
{"> equal fails", "1.0.0", ">1.0.0", false, false},
|
||||
{"<= equal", "1.0.0", "<=1.0.0", true, false},
|
||||
{"<= higher fails", "2.0.0", "<=1.0.0", false, false},
|
||||
{"< strict lower", "0.9.0", "<1.0.0", true, false},
|
||||
{"missing patch defaults to 0", "1.0", ">=1.0.0", true, false},
|
||||
{"constraint with pre-release suffix", "1.0.0-rc1", ">=1.0.0", true, false},
|
||||
{"malformed constraint returns error", "1.0.0", ">=abc", false, true},
|
||||
{"malformed constraint errors on DEV too", "DEV", ">=abc", false, true},
|
||||
{"malformed constraint errors on empty build", "", ">=zzz", false, true},
|
||||
{"unparseable build version treated as DEV", "abc", ">=1.0.0", true, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := satisfiesRequiredCLIVersion(tc.build, tc.constraint)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin whose RequiredCLIVersion exceeds the running build must
|
||||
// abort install with reason_code capability_unmet. The plugin's
|
||||
// FailurePolicy then decides whether the abort bubbles up.
|
||||
func TestInstallOne_RequiredCLIVersion_UnmetFailClosedAborts(t *testing.T) {
|
||||
restore := SetCurrentCLIVersionForTesting("1.0.0")
|
||||
t.Cleanup(restore)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&capVersionPlugin{
|
||||
name: "needs-future",
|
||||
requirement: ">=99.0.0",
|
||||
fail: platform.FailClosed,
|
||||
})
|
||||
|
||||
_, err := InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected FailClosed install error, got nil")
|
||||
}
|
||||
var pi *PluginInstallError
|
||||
if !errors.As(err, &pi) {
|
||||
t.Fatalf("expected *PluginInstallError, got %T", err)
|
||||
}
|
||||
if pi.ReasonCode != ReasonCapabilityUnmet {
|
||||
t.Errorf("reason_code = %q, want %q", pi.ReasonCode, ReasonCapabilityUnmet)
|
||||
}
|
||||
}
|
||||
|
||||
// FailOpen plugin with unmet RequiredCLIVersion is skipped (warning),
|
||||
// other plugins still install.
|
||||
func TestInstallOne_RequiredCLIVersion_UnmetFailOpenSkips(t *testing.T) {
|
||||
restore := SetCurrentCLIVersionForTesting("1.0.0")
|
||||
t.Cleanup(restore)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&capVersionPlugin{
|
||||
name: "future-failopen",
|
||||
requirement: ">=99.0.0",
|
||||
fail: platform.FailOpen,
|
||||
})
|
||||
|
||||
result, err := InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FailOpen unmet must not bubble up, got: %v", err)
|
||||
}
|
||||
if result.Registry == nil {
|
||||
t.Errorf("Registry should be non-nil even after FailOpen skip")
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin authoring error in RequiredCLIVersion (parse failure) must
|
||||
// abort installation UNCONDITIONALLY. Even FailOpen cannot mask a
|
||||
// typo in the constraint string -- the plugin author asked the host
|
||||
// to do something it cannot parse, and silently skipping would hide
|
||||
// the bug from CI.
|
||||
//
|
||||
// Implementation: parse errors return ReasonInvalidCapability, which
|
||||
// isUntrustedConfigError lists alongside restricts_mismatch so
|
||||
// InstallAll's switch treats it as a hard abort.
|
||||
func TestInstallOne_RequiredCLIVersion_MalformedAbortsRegardlessOfFailurePolicy(t *testing.T) {
|
||||
restore := SetCurrentCLIVersionForTesting("1.0.0")
|
||||
t.Cleanup(restore)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
// FailOpen + malformed constraint: still aborts.
|
||||
platform.Register(&capVersionPlugin{
|
||||
name: "typo",
|
||||
requirement: ">=abc",
|
||||
fail: platform.FailOpen,
|
||||
})
|
||||
|
||||
_, err := InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed constraint to abort even FailOpen, got nil")
|
||||
}
|
||||
var pi *PluginInstallError
|
||||
if !errors.As(err, &pi) {
|
||||
t.Fatalf("expected *PluginInstallError, got %T", err)
|
||||
}
|
||||
if pi.ReasonCode != ReasonInvalidCapability {
|
||||
t.Errorf("reason_code = %q, want %q", pi.ReasonCode, ReasonInvalidCapability)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin whose RequiredCLIVersion is satisfied installs normally.
|
||||
func TestInstallOne_RequiredCLIVersion_SatisfiedInstalls(t *testing.T) {
|
||||
restore := SetCurrentCLIVersionForTesting("1.5.0")
|
||||
t.Cleanup(restore)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&capVersionPlugin{
|
||||
name: "ok",
|
||||
requirement: ">=1.0.0",
|
||||
fail: platform.FailClosed,
|
||||
})
|
||||
if _, err := InstallAll(platform.RegisteredPlugins(), nil); err != nil {
|
||||
t.Errorf("expected install success, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type capVersionPlugin struct {
|
||||
name string
|
||||
requirement string
|
||||
fail platform.FailurePolicy
|
||||
}
|
||||
|
||||
func (p *capVersionPlugin) Name() string { return p.name }
|
||||
func (p *capVersionPlugin) Version() string { return "0.0.1" }
|
||||
func (p *capVersionPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{
|
||||
RequiredCLIVersion: p.requirement,
|
||||
FailurePolicy: p.fail,
|
||||
}
|
||||
}
|
||||
func (p *capVersionPlugin) Install(platform.Registrar) error { return nil }
|
||||
Reference in New Issue
Block a user