Skip to content

Commit e1d9481

Browse files
committed
feat: Add extension mechanism for third-party tools in .project format
Signed-off-by: Aaravanand00 <aaravanand5749@gmail.com>
1 parent c7c6bdf commit e1d9481

File tree

5 files changed

+359
-1
lines changed

5 files changed

+359
-1
lines changed

utilities/dot-project/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ A Go utility that validates CNCF project metadata and maintainer rosters. It che
77
- Validates project YAML files against structured schema requirements
88
- Detects content drift using SHA256 hashes and cached history
99
- Validates maintainer definitions against canonical `.project` repository data
10+
- **Extension mechanism for third-party tools** (schema version 1.1.0+)
1011
- Stubbed third-party verification hook for maintainer identity checks
1112
- Multiple output formats: human-readable text, JSON, YAML
1213
- Includes GitHub Actions workflow and Makefile helpers
@@ -124,6 +125,42 @@ documentation:
124125
api: { path: "docs/API.md" }
125126
```
126127

128+
### Extensions (schema_version >= 1.1.0)
129+
130+
Extensions allow third-party tools to store their configuration within the `.project` file without conflicting with core fields. Each extension is namespaced by tool name.
131+
132+
```yaml
133+
schema_version: "1.1.0" # Required for extensions
134+
# ... other fields ...
135+
136+
extensions:
137+
# Tool-specific configuration
138+
scorecard:
139+
metadata:
140+
author: "OSSF"
141+
homepage: "https://securityscorecards.dev"
142+
repository: "https://github.com/ossf/scorecard"
143+
license: "Apache-2.0"
144+
version: "4.0.0"
145+
config:
146+
checks:
147+
- Binary-Artifacts
148+
- Branch-Protection
149+
threshold: 7.0
150+
151+
clomonitor:
152+
metadata:
153+
author: "CNCF"
154+
homepage: "https://clomonitor.io"
155+
config:
156+
category: "platform"
157+
```
158+
159+
**Extension naming rules:**
160+
- Use alphanumeric characters, hyphens, underscores, and dots
161+
- Reserved names (core field names) cannot be used
162+
- Namespacing with organization prefix is recommended (e.g., `my-org.tool-name`)
163+
127164
Each maintainer entry must contain a `project-maintainers` team which cannot be empty. Handles are normalized (trimmed and stripped of leading `@`) before verification.
128165

129166
## Maintainer Verification Stub
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package projects
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestExtensionValidation(t *testing.T) {
9+
baseProject := Project{
10+
Name: "Test Project",
11+
Description: "A test project",
12+
SchemaVersion: "1.1.0",
13+
MaturityLog: []MaturityEntry{
14+
{
15+
Phase: "incubating",
16+
Date: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
17+
Issue: "https://github.com/cncf/toc/issues/123",
18+
},
19+
},
20+
Repositories: []string{"https://github.com/test/repo"},
21+
}
22+
23+
t.Run("valid extension", func(t *testing.T) {
24+
project := baseProject
25+
project.Extensions = map[string]Extension{
26+
"my-tool": {
27+
Metadata: &ExtensionMetadata{
28+
Author: "Test Author",
29+
Homepage: "https://example.com",
30+
Repository: "https://github.com/test/tool",
31+
License: "Apache-2.0",
32+
Version: "1.0.0",
33+
},
34+
Config: map[string]interface{}{
35+
"enabled": true,
36+
"setting": "value",
37+
},
38+
},
39+
}
40+
41+
errors := validateProjectStruct(project)
42+
if len(errors) != 0 {
43+
t.Errorf("Expected no errors for valid extension, got: %v", errors)
44+
}
45+
})
46+
47+
t.Run("extension without schema version", func(t *testing.T) {
48+
project := baseProject
49+
project.SchemaVersion = ""
50+
project.Extensions = map[string]Extension{
51+
"my-tool": {Config: map[string]interface{}{"key": "value"}},
52+
}
53+
54+
errors := validateProjectStruct(project)
55+
found := false
56+
for _, err := range errors {
57+
if err == "extensions require schema_version >= 1.1.0" {
58+
found = true
59+
break
60+
}
61+
}
62+
if !found {
63+
t.Errorf("Expected schema version error, got: %v", errors)
64+
}
65+
})
66+
67+
t.Run("extension with old schema version", func(t *testing.T) {
68+
project := baseProject
69+
project.SchemaVersion = "1.0.0"
70+
project.Extensions = map[string]Extension{
71+
"my-tool": {Config: map[string]interface{}{"key": "value"}},
72+
}
73+
74+
errors := validateProjectStruct(project)
75+
found := false
76+
for _, err := range errors {
77+
if err == "extensions require schema_version >= 1.1.0" {
78+
found = true
79+
break
80+
}
81+
}
82+
if !found {
83+
t.Errorf("Expected schema version error, got: %v", errors)
84+
}
85+
})
86+
87+
t.Run("reserved extension name", func(t *testing.T) {
88+
project := baseProject
89+
project.Extensions = map[string]Extension{
90+
"name": {Config: map[string]interface{}{"key": "value"}},
91+
}
92+
93+
errors := validateProjectStruct(project)
94+
found := false
95+
for _, err := range errors {
96+
if err == "extensions.name: 'name' is a reserved name" {
97+
found = true
98+
break
99+
}
100+
}
101+
if !found {
102+
t.Errorf("Expected reserved name error, got: %v", errors)
103+
}
104+
})
105+
106+
t.Run("invalid extension name format", func(t *testing.T) {
107+
project := baseProject
108+
project.Extensions = map[string]Extension{
109+
"my tool!": {Config: map[string]interface{}{"key": "value"}},
110+
}
111+
112+
errors := validateProjectStruct(project)
113+
found := false
114+
for _, err := range errors {
115+
if err == "extensions.my tool!: invalid name format (use alphanumeric, hyphens, underscores, dots)" {
116+
found = true
117+
break
118+
}
119+
}
120+
if !found {
121+
t.Errorf("Expected invalid name format error, got: %v", errors)
122+
}
123+
})
124+
125+
t.Run("invalid metadata URL", func(t *testing.T) {
126+
project := baseProject
127+
project.Extensions = map[string]Extension{
128+
"my-tool": {
129+
Metadata: &ExtensionMetadata{
130+
Homepage: "not-a-url",
131+
},
132+
},
133+
}
134+
135+
errors := validateProjectStruct(project)
136+
found := false
137+
for _, err := range errors {
138+
if err == "extensions.my-tool.metadata.homepage is not a valid URL" {
139+
found = true
140+
break
141+
}
142+
}
143+
if !found {
144+
t.Errorf("Expected invalid URL error, got: %v", errors)
145+
}
146+
})
147+
}
148+
149+
func TestIsValidExtensionName(t *testing.T) {
150+
testCases := []struct {
151+
name string
152+
valid bool
153+
}{
154+
{"my-tool", true},
155+
{"my_tool", true},
156+
{"my.tool", true},
157+
{"MyTool123", true},
158+
{"tool", true},
159+
{"my tool", false},
160+
{"my-tool!", false},
161+
{"", false},
162+
{"tool@name", false},
163+
{"tool#name", false},
164+
}
165+
166+
for _, tc := range testCases {
167+
result := isValidExtensionName(tc.name)
168+
if result != tc.valid {
169+
t.Errorf("isValidExtensionName(%q) = %v, expected %v", tc.name, result, tc.valid)
170+
}
171+
}
172+
}
173+
174+
func TestIsVersionAtLeast(t *testing.T) {
175+
testCases := []struct {
176+
version string
177+
minVersion string
178+
expected bool
179+
}{
180+
{"1.1.0", "1.1.0", true},
181+
{"1.2.0", "1.1.0", true},
182+
{"2.0.0", "1.1.0", true},
183+
{"1.0.0", "1.1.0", false},
184+
{"0.9.0", "1.1.0", false},
185+
}
186+
187+
for _, tc := range testCases {
188+
result := isVersionAtLeast(tc.version, tc.minVersion)
189+
if result != tc.expected {
190+
t.Errorf("isVersionAtLeast(%q, %q) = %v, expected %v",
191+
tc.version, tc.minVersion, result, tc.expected)
192+
}
193+
}
194+
}
195+
196+
func TestBackwardCompatibility(t *testing.T) {
197+
// Test that projects without extensions still validate correctly
198+
project := Project{
199+
Name: "Legacy Project",
200+
Description: "A project without extensions",
201+
SchemaVersion: "1.0.0",
202+
MaturityLog: []MaturityEntry{
203+
{
204+
Phase: "incubating",
205+
Date: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
206+
Issue: "https://github.com/cncf/toc/issues/123",
207+
},
208+
},
209+
Repositories: []string{"https://github.com/test/repo"},
210+
}
211+
212+
errors := validateProjectStruct(project)
213+
if len(errors) != 0 {
214+
t.Errorf("Expected no errors for legacy project, got: %v", errors)
215+
}
216+
}

utilities/dot-project/types.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import (
55
"time"
66
)
77

8+
// SchemaVersionWithExtensions is the minimum version that supports extensions
9+
const SchemaVersionWithExtensions = "1.1.0"
10+
811
type ProjectList []string
912

1013
type Project struct {
@@ -25,6 +28,26 @@ type Project struct {
2528
Governance *GovernanceConfig `json:"governance,omitempty" yaml:"governance,omitempty"`
2629
Legal *LegalConfig `json:"legal,omitempty" yaml:"legal,omitempty"`
2730
Documentation *DocumentationConfig `json:"documentation,omitempty" yaml:"documentation,omitempty"`
31+
32+
// Extensions for third-party tools (requires schema_version >= 1.1.0)
33+
Extensions map[string]Extension `json:"extensions,omitempty" yaml:"extensions,omitempty"`
34+
}
35+
36+
// Extension represents a third-party tool extension configuration
37+
type Extension struct {
38+
// Metadata about the extension
39+
Metadata *ExtensionMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"`
40+
// Tool-specific configuration (arbitrary key-value pairs)
41+
Config map[string]interface{} `json:"config,omitempty" yaml:"config,omitempty"`
42+
}
43+
44+
// ExtensionMetadata contains information about the extension provider
45+
type ExtensionMetadata struct {
46+
Author string `json:"author,omitempty" yaml:"author,omitempty"`
47+
Homepage string `json:"homepage,omitempty" yaml:"homepage,omitempty"`
48+
Repository string `json:"repository,omitempty" yaml:"repository,omitempty"`
49+
License string `json:"license,omitempty" yaml:"license,omitempty"`
50+
Version string `json:"version,omitempty" yaml:"version,omitempty"`
2851
}
2952

3053
type PathRef struct {

utilities/dot-project/validator.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,81 @@ func validateProjectStruct(project Project) []string {
322322
}
323323
}
324324

325+
// Validate extensions
326+
if len(project.Extensions) > 0 {
327+
extensionErrors := validateExtensions(project)
328+
errors = append(errors, extensionErrors...)
329+
}
330+
325331
return errors
326332
}
327333

334+
// reservedExtensionNames contains names that cannot be used as extension keys
335+
// to prevent conflicts with core project fields
336+
var reservedExtensionNames = map[string]bool{
337+
"name": true, "description": true, "maturity_log": true,
338+
"repositories": true, "social": true, "artwork": true,
339+
"website": true, "mailing_lists": true, "audits": true,
340+
"schema_version": true, "type": true, "security": true,
341+
"governance": true, "legal": true, "documentation": true,
342+
"extensions": true,
343+
}
344+
345+
// validateExtensions validates the extensions section of a project
346+
func validateExtensions(project Project) []string {
347+
var errors []string
348+
349+
// Check schema version requirement
350+
if project.SchemaVersion == "" || !isVersionAtLeast(project.SchemaVersion, SchemaVersionWithExtensions) {
351+
errors = append(errors, fmt.Sprintf("extensions require schema_version >= %s", SchemaVersionWithExtensions))
352+
return errors
353+
}
354+
355+
for name, ext := range project.Extensions {
356+
// Validate extension name format (alphanumeric, hyphens, underscores, dots)
357+
if !isValidExtensionName(name) {
358+
errors = append(errors, fmt.Sprintf("extensions.%s: invalid name format (use alphanumeric, hyphens, underscores, dots)", name))
359+
}
360+
361+
// Check for reserved names
362+
if reservedExtensionNames[name] {
363+
errors = append(errors, fmt.Sprintf("extensions.%s: '%s' is a reserved name", name, name))
364+
}
365+
366+
// Validate metadata URLs if provided
367+
if ext.Metadata != nil {
368+
if ext.Metadata.Homepage != "" && !isValidURL(ext.Metadata.Homepage) {
369+
errors = append(errors, fmt.Sprintf("extensions.%s.metadata.homepage is not a valid URL", name))
370+
}
371+
if ext.Metadata.Repository != "" && !isValidURL(ext.Metadata.Repository) {
372+
errors = append(errors, fmt.Sprintf("extensions.%s.metadata.repository is not a valid URL", name))
373+
}
374+
}
375+
}
376+
377+
return errors
378+
}
379+
380+
// isValidExtensionName checks if an extension name follows naming conventions
381+
func isValidExtensionName(name string) bool {
382+
if name == "" {
383+
return false
384+
}
385+
for _, r := range name {
386+
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
387+
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.') {
388+
return false
389+
}
390+
}
391+
return true
392+
}
393+
394+
// isVersionAtLeast compares semantic versions (simple comparison)
395+
func isVersionAtLeast(version, minVersion string) bool {
396+
// Simple version comparison for x.y.z format
397+
return version >= minVersion
398+
}
399+
328400
// isValidURL checks if a string is a valid URL
329401
func isValidURL(str string) bool {
330402
if !strings.HasPrefix(str, "http://") && !strings.HasPrefix(str, "https://") {

0 commit comments

Comments
 (0)