Skip to content

Commit e4cf1aa

Browse files
Copilotintel352
andauthored
Fix PluginManifest to gracefully parse legacy object-style capabilities in plugin.json (#322)
* Initial plan * Fix PluginManifest to handle legacy object-style capabilities in plugin.json Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * Return error for unsupported capabilities JSON type; add negative test Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * Resolve merge conflict: add MinEngineVersion, adopt rawManifest/withRawCaps pattern, move Dependency Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> Co-authored-by: Jonathan Langevin <codingsloth@pm.me>
1 parent 31f2154 commit e4cf1aa

2 files changed

Lines changed: 155 additions & 66 deletions

File tree

plugin/manifest.go

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ type Dependency struct {
9393
//
9494
// When the legacy object format is detected, its type lists are merged into the
9595
// top-level ModuleTypes, StepTypes, and TriggerTypes fields so callers always
96-
// find types in a consistent location.
96+
// find types in a consistent location. Any other JSON type (string, number,
97+
// bool) is rejected with a descriptive error.
9798
func (m *PluginManifest) UnmarshalJSON(data []byte) error {
9899
// rawManifest breaks the recursion: it is the same layout as PluginManifest
99100
// but without the custom UnmarshalJSON method.
@@ -114,32 +115,70 @@ func (m *PluginManifest) UnmarshalJSON(data []byte) error {
114115
if len(raw.Capabilities) == 0 {
115116
return nil
116117
}
117-
switch raw.Capabilities[0] {
118+
119+
// Peek at the first non-whitespace byte to decide which branch to take.
120+
// This avoids silently ignoring genuinely invalid capability values.
121+
switch firstNonSpace(raw.Capabilities) {
122+
case 0, 'n':
123+
// Empty or JSON null – treat as absent.
124+
118125
case '[':
119-
// New format: array of CapabilityDecl
126+
// New format: array of CapabilityDecl.
120127
var caps []CapabilityDecl
121128
if err := json.Unmarshal(raw.Capabilities, &caps); err != nil {
122129
return fmt.Errorf("invalid capabilities array: %w", err)
123130
}
124131
m.Capabilities = caps
132+
125133
case '{':
126134
// Legacy format: object with configProvider, moduleTypes, stepTypes, triggerTypes.
127135
// Merge type lists into the top-level fields so callers see them consistently.
128136
var legacyCaps struct {
129-
ModuleTypes []string `json:"moduleTypes"`
130-
StepTypes []string `json:"stepTypes"`
131-
TriggerTypes []string `json:"triggerTypes"`
137+
ModuleTypes []string `json:"moduleTypes"`
138+
StepTypes []string `json:"stepTypes"`
139+
TriggerTypes []string `json:"triggerTypes"`
140+
WorkflowTypes []string `json:"workflowTypes"`
132141
}
133142
if err := json.Unmarshal(raw.Capabilities, &legacyCaps); err != nil {
134143
return fmt.Errorf("invalid capabilities object: %w", err)
135144
}
136-
m.ModuleTypes = append(m.ModuleTypes, legacyCaps.ModuleTypes...)
137-
m.StepTypes = append(m.StepTypes, legacyCaps.StepTypes...)
138-
m.TriggerTypes = append(m.TriggerTypes, legacyCaps.TriggerTypes...)
145+
m.ModuleTypes = appendUnique(m.ModuleTypes, legacyCaps.ModuleTypes...)
146+
m.StepTypes = appendUnique(m.StepTypes, legacyCaps.StepTypes...)
147+
m.TriggerTypes = appendUnique(m.TriggerTypes, legacyCaps.TriggerTypes...)
148+
m.WorkflowTypes = appendUnique(m.WorkflowTypes, legacyCaps.WorkflowTypes...)
149+
150+
default:
151+
return fmt.Errorf("capabilities: unsupported JSON type (expected array or object, got %q)", string(raw.Capabilities))
139152
}
153+
140154
return nil
141155
}
142156

157+
// firstNonSpace returns the first non-whitespace byte in b, or 0 if b is empty/all-whitespace.
158+
func firstNonSpace(b []byte) byte {
159+
for _, c := range b {
160+
if c != ' ' && c != '\t' && c != '\r' && c != '\n' {
161+
return c
162+
}
163+
}
164+
return 0
165+
}
166+
167+
// appendUnique appends values to dst, skipping any that are already present.
168+
func appendUnique(dst []string, values ...string) []string {
169+
existing := make(map[string]struct{}, len(dst))
170+
for _, v := range dst {
171+
existing[v] = struct{}{}
172+
}
173+
for _, v := range values {
174+
if _, ok := existing[v]; !ok {
175+
dst = append(dst, v)
176+
existing[v] = struct{}{}
177+
}
178+
}
179+
return dst
180+
}
181+
143182
// Validate checks that a manifest has all required fields and valid semver.
144183
func (m *PluginManifest) Validate() error {
145184
if m.Name == "" {

plugin/manifest_test.go

Lines changed: 107 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -426,86 +426,136 @@ func TestManifestEngineFieldsLoadFromFile(t *testing.T) {
426426
}
427427
}
428428

429-
func TestPluginManifest_LegacyCapabilities(t *testing.T) {
430-
// Legacy format: capabilities is a JSON object with configProvider, moduleTypes, etc.
431-
legacyJSON := `{
432-
"name": "legacy-plugin",
429+
// TestManifestLegacyCapabilitiesObject verifies that a plugin.json whose
430+
// "capabilities" field is a plain JSON object (the format used by external
431+
// plugins such as workflow-plugin-authz) is parsed without error and that the
432+
// type lists nested inside the object are promoted to the manifest's top-level
433+
// ModuleTypes/StepTypes/TriggerTypes fields.
434+
func TestManifestLegacyCapabilitiesObject(t *testing.T) {
435+
const legacyJSON = `{
436+
"name": "workflow-plugin-authz",
433437
"version": "1.0.0",
434-
"author": "Test",
435-
"description": "Legacy capabilities test",
438+
"description": "RBAC authorization plugin using Casbin",
439+
"author": "GoCodeAlone",
440+
"license": "MIT",
441+
"type": "external",
442+
"tier": "core",
443+
"minEngineVersion": "0.3.11",
444+
"keywords": ["authz", "rbac", "casbin", "authorization", "policy"],
445+
"homepage": "https://github.com/GoCodeAlone/workflow-plugin-authz",
446+
"repository": "https://github.com/GoCodeAlone/workflow-plugin-authz",
436447
"capabilities": {
437-
"configProvider": true,
438-
"moduleTypes": ["test.module"],
439-
"stepTypes": ["step.test"],
440-
"triggerTypes": ["trigger.test"]
448+
"configProvider": false,
449+
"moduleTypes": ["authz.casbin"],
450+
"stepTypes": [
451+
"step.authz_check_casbin",
452+
"step.authz_add_policy",
453+
"step.authz_remove_policy",
454+
"step.authz_role_assign"
455+
],
456+
"triggerTypes": []
441457
}
442458
}`
443459

444460
var m PluginManifest
445461
if err := json.Unmarshal([]byte(legacyJSON), &m); err != nil {
446-
t.Fatalf("Unmarshal legacy capabilities: %v", err)
462+
t.Fatalf("unexpected unmarshal error for legacy capabilities object: %v", err)
447463
}
448-
if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "test.module" {
449-
t.Errorf("ModuleTypes = %v, want [test.module]", m.ModuleTypes)
450-
}
451-
if len(m.StepTypes) != 1 || m.StepTypes[0] != "step.test" {
452-
t.Errorf("StepTypes = %v, want [step.test]", m.StepTypes)
453-
}
454-
if len(m.TriggerTypes) != 1 || m.TriggerTypes[0] != "trigger.test" {
455-
t.Errorf("TriggerTypes = %v, want [trigger.test]", m.TriggerTypes)
456-
}
457-
// Legacy object format should not populate Capabilities slice
464+
465+
// Capabilities array should be nil / empty – the object format has no CapabilityDecl items.
458466
if len(m.Capabilities) != 0 {
459-
t.Errorf("Capabilities = %v, want empty for legacy object format", m.Capabilities)
467+
t.Errorf("Capabilities = %v, want empty", m.Capabilities)
460468
}
461-
}
462469

463-
func TestPluginManifest_NewCapabilitiesArrayFormat(t *testing.T) {
464-
// New format: capabilities is a JSON array of CapabilityDecl
465-
newJSON := `{
466-
"name": "new-plugin",
467-
"version": "1.0.0",
468-
"author": "Test",
469-
"description": "New capabilities test",
470-
"moduleTypes": ["test.module"],
471-
"stepTypes": ["step.test"],
472-
"capabilities": [{"name": "step.test", "role": "provider"}]
473-
}`
470+
// moduleTypes from the nested object should be promoted to the top level.
471+
if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "authz.casbin" {
472+
t.Errorf("ModuleTypes = %v, want [authz.casbin]", m.ModuleTypes)
473+
}
474474

475-
var m PluginManifest
476-
if err := json.Unmarshal([]byte(newJSON), &m); err != nil {
477-
t.Fatalf("Unmarshal new capabilities: %v", err)
475+
// stepTypes should be promoted.
476+
wantSteps := []string{
477+
"step.authz_check_casbin",
478+
"step.authz_add_policy",
479+
"step.authz_remove_policy",
480+
"step.authz_role_assign",
478481
}
479-
if len(m.Capabilities) != 1 || m.Capabilities[0].Name != "step.test" {
480-
t.Errorf("Capabilities = %v, want [{step.test provider 0}]", m.Capabilities)
482+
if len(m.StepTypes) != len(wantSteps) {
483+
t.Errorf("StepTypes len = %d, want %d; got %v", len(m.StepTypes), len(wantSteps), m.StepTypes)
484+
} else {
485+
for i, want := range wantSteps {
486+
if m.StepTypes[i] != want {
487+
t.Errorf("StepTypes[%d] = %q, want %q", i, m.StepTypes[i], want)
488+
}
489+
}
481490
}
482-
if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "test.module" {
483-
t.Errorf("ModuleTypes = %v, want [test.module]", m.ModuleTypes)
491+
492+
// triggerTypes is an empty array – TriggerTypes should remain nil/empty.
493+
if len(m.TriggerTypes) != 0 {
494+
t.Errorf("TriggerTypes = %v, want empty", m.TriggerTypes)
484495
}
485496
}
486497

487-
func TestPluginManifest_LegacyCapabilitiesMergesWithTopLevel(t *testing.T) {
488-
// Top-level fields should be merged with types from legacy capabilities object
489-
legacyJSON := `{
490-
"name": "merged-plugin",
498+
// TestManifestLegacyCapabilitiesObjectFile verifies that LoadManifest succeeds
499+
// for a plugin.json that uses the legacy object-style capabilities field.
500+
func TestManifestLegacyCapabilitiesObjectFile(t *testing.T) {
501+
const legacyJSON = `{
502+
"name": "workflow-plugin-authz",
491503
"version": "1.0.0",
492-
"author": "Test",
493-
"description": "Merge test",
494-
"moduleTypes": ["existing.module"],
504+
"description": "RBAC authorization plugin",
505+
"author": "GoCodeAlone",
495506
"capabilities": {
496-
"moduleTypes": ["caps.module"],
497-
"stepTypes": ["step.caps"]
507+
"moduleTypes": ["authz.casbin"],
508+
"stepTypes": ["step.authz_check"],
509+
"triggerTypes": []
498510
}
499511
}`
500512

501-
var m PluginManifest
502-
if err := json.Unmarshal([]byte(legacyJSON), &m); err != nil {
503-
t.Fatalf("Unmarshal: %v", err)
513+
dir := t.TempDir()
514+
path := filepath.Join(dir, "plugin.json")
515+
if err := os.WriteFile(path, []byte(legacyJSON), 0644); err != nil {
516+
t.Fatal(err)
517+
}
518+
519+
m, err := LoadManifest(path)
520+
if err != nil {
521+
t.Fatalf("LoadManifest error: %v", err)
522+
}
523+
if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "authz.casbin" {
524+
t.Errorf("ModuleTypes = %v, want [authz.casbin]", m.ModuleTypes)
504525
}
505-
if len(m.ModuleTypes) != 2 {
506-
t.Errorf("ModuleTypes = %v, want [existing.module caps.module]", m.ModuleTypes)
526+
if len(m.StepTypes) != 1 || m.StepTypes[0] != "step.authz_check" {
527+
t.Errorf("StepTypes = %v, want [step.authz_check]", m.StepTypes)
507528
}
508-
if len(m.StepTypes) != 1 || m.StepTypes[0] != "step.caps" {
509-
t.Errorf("StepTypes = %v, want [step.caps]", m.StepTypes)
529+
}
530+
531+
// TestManifestCapabilitiesInvalidFormat verifies that a plugin.json whose
532+
// "capabilities" field is neither an array nor an object (e.g. a bare string)
533+
// is rejected with a descriptive error.
534+
func TestManifestCapabilitiesInvalidFormat(t *testing.T) {
535+
cases := []struct {
536+
name string
537+
json string
538+
}{
539+
{
540+
name: "string value",
541+
json: `{"name":"p","version":"1.0.0","author":"A","description":"D","capabilities":"oops"}`,
542+
},
543+
{
544+
name: "numeric value",
545+
json: `{"name":"p","version":"1.0.0","author":"A","description":"D","capabilities":42}`,
546+
},
547+
{
548+
name: "boolean value",
549+
json: `{"name":"p","version":"1.0.0","author":"A","description":"D","capabilities":true}`,
550+
},
551+
}
552+
for _, tc := range cases {
553+
t.Run(tc.name, func(t *testing.T) {
554+
var m PluginManifest
555+
err := json.Unmarshal([]byte(tc.json), &m)
556+
if err == nil {
557+
t.Errorf("expected error for capabilities %s, got nil", tc.name)
558+
}
559+
})
510560
}
511561
}

0 commit comments

Comments
 (0)