diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 336e1ea..cf3818d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,6 +25,22 @@ jobs: with: persist-credentials: false + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: cmd/cue2openapi/go.mod + cache-dependency-path: | + cmd/cue2openapi/go.sum + cmd/openapi2md/go.sum + + - name: Test CUE to OpenAPI converter + working-directory: cmd/cue2openapi + run: go test -count=1 ./... + + - name: Test OpenAPI to Markdown converter + working-directory: cmd/openapi2md + run: go test -count=1 ./... + - name: Setup Cue uses: cue-lang/setup-cue@a93fa358375740cd8b0078f76355512b9208acb1 # v1.0.1 diff --git a/cmd/cue2openapi/converter.go b/cmd/cue2openapi/converter.go index 4072a6a..ae95648 100644 --- a/cmd/cue2openapi/converter.go +++ b/cmd/cue2openapi/converter.go @@ -9,6 +9,7 @@ import ( "strings" "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/literal" "cuelang.org/go/cue/load" "cuelang.org/go/cue/token" "github.com/goccy/go-yaml" @@ -46,6 +47,7 @@ type SchemaInfo struct { Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"` Format string `yaml:"format,omitempty" json:"format,omitempty"` Items interface{} `yaml:"items,omitempty" json:"items,omitempty"` + Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"` Ref string `yaml:"$ref,omitempty" json:"$ref,omitempty"` } @@ -301,7 +303,7 @@ func convertStructToSchema(st *ast.StructLit, spec *OpenAPISpec, description str if fieldName != "" { schema.Properties[fieldName] = fieldSchema // Check if field is required - if x.Optional == token.NoPos { + if x.Constraint != token.OPTION { schema.Required = append(schema.Required, fieldName) } } @@ -348,6 +350,8 @@ func convertExprToSchema(expr ast.Expr, spec *OpenAPISpec, description string) i return convertIdentToSchema(x, spec, description) case *ast.BinaryExpr: return convertBinaryExprToSchema(x, spec, description) + case *ast.ParenExpr: + return convertExprToSchema(x.X, spec, description) case *ast.ListLit: return convertListLitToSchema(x, spec, description) case *ast.StructLit: @@ -404,12 +408,142 @@ func convertBinaryExprToSchema(expr *ast.BinaryExpr, spec *OpenAPISpec, descript // Handle union types (disjunctions) if expr.Op == token.OR { + if isStringListUnion(expr) { + return &SchemaInfo{ + Type: "array", + Description: description, + Items: &SchemaInfo{Type: "string"}, + } + } + if values, ok := stringLiteralUnion(expr); ok { + return &SchemaInfo{ + Type: "string", + Description: description, + Enum: values, + } + } return &SchemaInfo{Type: "string", Description: description} } return &SchemaInfo{Type: "string", Description: description} } +func stringLiteralUnion(expr ast.Expr) ([]string, bool) { + values := make([]string, 0) + seen := make(map[string]struct{}) + if !collectStringLiteralUnion(expr, &values, seen) || len(values) == 0 { + return nil, false + } + return values, true +} + +func collectStringLiteralUnion(expr ast.Expr, values *[]string, seen map[string]struct{}) bool { + switch x := expr.(type) { + case *ast.ParenExpr: + return collectStringLiteralUnion(x.X, values, seen) + case *ast.UnaryExpr: + return x.Op == token.MUL && collectStringLiteralUnion(x.X, values, seen) + case *ast.BinaryExpr: + if x.Op != token.OR { + return false + } + return collectStringLiteralUnion(x.X, values, seen) && + collectStringLiteralUnion(x.Y, values, seen) + case *ast.BasicLit: + if x.Kind != token.STRING { + return false + } + quote, _, _, err := literal.ParseQuotes(x.Value, x.Value) + if err != nil || !quote.IsDouble() { + return false + } + value, err := literal.Unquote(x.Value) + if err != nil { + return false + } + if _, exists := seen[value]; !exists { + seen[value] = struct{}{} + *values = append(*values, value) + } + return true + default: + return false + } +} + +func isStringListUnion(expr ast.Expr) bool { + binary, ok := expr.(*ast.BinaryExpr) + return ok && binary.Op == token.OR && + isStringListExpression(binary.X) && isStringListExpression(binary.Y) +} + +func isStringListExpression(expr ast.Expr) bool { + switch x := expr.(type) { + case *ast.ParenExpr: + return isStringListExpression(x.X) + case *ast.UnaryExpr: + return x.Op == token.MUL && isStringListExpression(x.X) + case *ast.BinaryExpr: + return x.Op == token.OR && + isStringListExpression(x.X) && isStringListExpression(x.Y) + case *ast.ListLit: + return isStringListLiteral(x) + default: + return false + } +} + +func isStringListLiteral(list *ast.ListLit) bool { + if len(list.Elts) == 0 { + return false + } + for _, element := range list.Elts { + switch x := element.(type) { + case *ast.Ellipsis: + if !isStringTypeExpression(x.Type) { + return false + } + default: + if !isStringListElement(element) { + return false + } + } + } + return true +} + +func isStringListElement(expr ast.Expr) bool { + switch x := expr.(type) { + case *ast.ParenExpr: + return isStringListElement(x.X) + case *ast.UnaryExpr: + return x.Op == token.MUL && isStringListElement(x.X) + case *ast.BasicLit: + if x.Kind != token.STRING { + return false + } + quote, _, _, err := literal.ParseQuotes(x.Value, x.Value) + return err == nil && quote.IsDouble() + case *ast.Ident: + return x.Name == "string" + default: + return false + } +} + +func isStringTypeExpression(expr ast.Expr) bool { + switch x := expr.(type) { + case *ast.ParenExpr: + return isStringTypeExpression(x.X) + case *ast.UnaryExpr: + return x.Op == token.MUL && isStringTypeExpression(x.X) + case *ast.Ident: + return x.Name == "string" + default: + return false + } +} + func convertListLitToSchema(list *ast.ListLit, spec *OpenAPISpec, description string) interface{} { // Check if it's an ellipsis list like [...#Contact] or [#Contact, ...] for _, elt := range list.Elts { diff --git a/cmd/cue2openapi/converter_test.go b/cmd/cue2openapi/converter_test.go new file mode 100644 index 0000000..c2b8604 --- /dev/null +++ b/cmd/cue2openapi/converter_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "cuelang.org/go/cue/parser" + "github.com/goccy/go-yaml" +) + +func TestSecurityToolUnionProjection(t *testing.T) { + t.Parallel() + + schemaDir := t.TempDir() + schemaPath := filepath.Join(schemaDir, "schema.cue") + schema := `package spec + +#SecurityTool: { + type: "fuzzing" | "container" | "secret" | "SCA" | "SAST" | "other" + rulesets: ["default"] | [...string] +} +` + if err := os.WriteFile(schemaPath, []byte(schema), 0o600); err != nil { + t.Fatalf("write schema fixture: %v", err) + } + + outputPath := filepath.Join(t.TempDir(), "openapi.yaml") + if err := convertCUEToOpenAPI(schemaDir, outputPath, ConvertOpts{Version: "test"}); err != nil { + t.Fatalf("convert CUE to OpenAPI: %v", err) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read generated OpenAPI: %v", err) + } + var document struct { + Components struct { + Schemas map[string]struct { + Properties map[string]struct { + Type string `yaml:"type"` + Enum []string `yaml:"enum"` + Items *struct { + Type string `yaml:"type"` + } `yaml:"items"` + } `yaml:"properties"` + } `yaml:"schemas"` + } `yaml:"components"` + } + if err := yaml.Unmarshal(data, &document); err != nil { + t.Fatalf("parse generated OpenAPI: %v", err) + } + + securityTool, ok := document.Components.Schemas["SecurityTool"] + if !ok { + t.Fatal("generated OpenAPI is missing SecurityTool") + } + rulesets := securityTool.Properties["rulesets"] + if rulesets.Type != "array" || rulesets.Items == nil || rulesets.Items.Type != "string" { + t.Fatalf("rulesets = type %q items %#v; want array[string]", rulesets.Type, rulesets.Items) + } + + wantEnum := []string{"fuzzing", "container", "secret", "SCA", "SAST", "other"} + if got := securityTool.Properties["type"].Enum; !reflect.DeepEqual(got, wantEnum) { + t.Fatalf("SecurityTool.type enum = %#v; want %#v", got, wantEnum) + } +} + +func TestUnionProjectionIsTypeSafe(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expression string + wantType string + wantItemsType string + wantEnum []string + }{ + { + name: "nested enum preserves source order and removes duplicates", + expression: `("fuzzing" | "SCA") | ("SAST" | "fuzzing")`, + wantType: "string", + wantEnum: []string{"fuzzing", "SCA", "SAST"}, + }, + { + name: "fully parenthesized enum is preserved", + expression: `((("fuzzing" | "SCA")))`, + wantType: "string", + wantEnum: []string{"fuzzing", "SCA"}, + }, + { + name: "defaulted enum is preserved", + expression: `*"fuzzing" | "SCA"`, + wantType: "string", + wantEnum: []string{"fuzzing", "SCA"}, + }, + { + name: "escaped and unicode enum literals are decoded", + expression: `"line\nfeed" | "café"`, + wantType: "string", + wantEnum: []string{"line\nfeed", "café"}, + }, + { + name: "mixed scalar union fails closed without enum", + expression: `"fuzzing" | bool`, + wantType: "string", + }, + { + name: "string list union becomes array of strings", + expression: `["default"] | ([...string] | [string])`, + wantType: "array", + wantItemsType: "string", + }, + { + name: "fully parenthesized list union remains an array", + expression: `((["default"] | [...string]))`, + wantType: "array", + wantItemsType: "string", + }, + { + name: "parenthesized list elements and ellipsis type remain strings", + expression: `[("default")] | [...(string)]`, + wantType: "array", + wantItemsType: "string", + }, + { + name: "defaulted string list remains an array", + expression: `*["default"] | [...string]`, + wantType: "array", + wantItemsType: "string", + }, + { + name: "mixed list union fails closed", + expression: `["default"] | [...int]`, + wantType: "string", + }, + { + name: "non-default unary constraint is not treated as a string list", + expression: `!=["default"] | [...string]`, + wantType: "string", + }, + { + name: "empty list does not imply string items", + expression: `[] | [...string]`, + wantType: "string", + }, + { + name: "bytes literals are not projected as string enums", + expression: `'fuzzing' | 'SCA'`, + wantType: "string", + }, + { + name: "mixed string and bytes literals fail closed", + expression: `"fuzzing" | 'SCA'`, + wantType: "string", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + expr, err := parser.ParseExpr("test.cue", test.expression) + if err != nil { + t.Fatalf("parse expression %q: %v", test.expression, err) + } + got, ok := convertExprToSchema(expr, &OpenAPISpec{}, "").(*SchemaInfo) + if !ok { + t.Fatalf("projection type = %T; want *SchemaInfo", got) + } + if got.Type != test.wantType { + t.Fatalf("type = %q; want %q", got.Type, test.wantType) + } + if !reflect.DeepEqual(got.Enum, test.wantEnum) { + t.Fatalf("enum = %#v; want %#v", got.Enum, test.wantEnum) + } + if test.wantItemsType == "" { + if got.Items != nil { + t.Fatalf("items = %#v; want nil", got.Items) + } + return + } + items, ok := got.Items.(*SchemaInfo) + if !ok || items.Type != test.wantItemsType { + t.Fatalf("items = %#v; want type %q", got.Items, test.wantItemsType) + } + }) + } +} diff --git a/cmd/openapi2md/main.go b/cmd/openapi2md/main.go index 8af8b56..7fd2a5d 100644 --- a/cmd/openapi2md/main.go +++ b/cmd/openapi2md/main.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "unicode" @@ -37,6 +38,7 @@ type Schema struct { Pattern string `yaml:"pattern"` Format string `yaml:"format"` Items interface{} `yaml:"items"` + Enum []string `yaml:"enum"` Ref string `yaml:"$ref"` } @@ -542,12 +544,53 @@ func formatFieldWithNested(fieldName string, fieldSchema Schema, spec OpenAPISpe var buf strings.Builder fieldLine, description := formatFieldInline(fieldName, fieldSchema, spec, "", isRequired, schemaToFile) buf.WriteString(fieldLine + "\n\n") + if len(fieldSchema.Enum) > 0 { + allowed := make([]string, 0, len(fieldSchema.Enum)) + for _, value := range fieldSchema.Enum { + allowed = append(allowed, formatEnumValue(value)) + } + if description != "" { + description += "\n\n" + } + description += "Allowed values: " + strings.Join(allowed, ", ") + "." + } if description != "" { buf.WriteString(description + "\n") } return buf.String() } +func formatEnumValue(value string) string { + display := value + if value == "" || strings.TrimSpace(value) != value || + strings.IndexFunc(value, func(r rune) bool { return !unicode.IsPrint(r) }) >= 0 { + display = strconv.Quote(value) + } + return markdownCodeSpan(display) +} + +func markdownCodeSpan(value string) string { + maxRun := 0 + currentRun := 0 + for _, r := range value { + if r == '`' { + currentRun++ + if currentRun > maxRun { + maxRun = currentRun + } + continue + } + currentRun = 0 + } + + fence := strings.Repeat("`", maxRun+1) + padding := "" + if strings.HasPrefix(value, "`") || strings.HasSuffix(value, "`") { + padding = " " + } + return fence + padding + value + padding + fence +} + func generateRootSection(rootName string, schema Schema, spec OpenAPISpec, schemaToFile map[string]string) string { var buf strings.Builder @@ -566,12 +609,12 @@ func generateRootSection(rootName string, schema Schema, spec OpenAPISpec, schem // Output all fields in order (required first, then optional) // Sort by required status, then by name type fieldInfo struct { - name string - schema Schema - required bool + name string + schema Schema + required bool } var fields []fieldInfo - + for _, propName := range propNames { isRequired := false for _, req := range schema.Required { @@ -588,12 +631,12 @@ func generateRootSection(rootName string, schema Schema, spec OpenAPISpec, schem continue } fields = append(fields, fieldInfo{ - name: propName, - schema: prop, + name: propName, + schema: prop, required: isRequired, }) } - + // Sort: required first, then by name sort.Slice(fields, func(i, j int) bool { if fields[i].required != fields[j].required { diff --git a/cmd/openapi2md/main_test.go b/cmd/openapi2md/main_test.go new file mode 100644 index 0000000..bce2834 --- /dev/null +++ b/cmd/openapi2md/main_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMarkdownPreservesArrayAndEnumSemantics(t *testing.T) { + t.Parallel() + + inputPath := filepath.Join(t.TempDir(), "openapi.yaml") + input := `openapi: 3.0.3 +info: + title: Security Insights + version: test +components: + schemas: + SecurityInsights: + type: object + properties: {} + SecurityTool: + type: object + properties: + rulesets: + type: array + items: + type: string + type: + type: string + enum: + - fuzzing + - container + - secret + - SCA + - SAST + - other + required: + - rulesets + - type +` + if err := os.WriteFile(inputPath, []byte(input), 0o600); err != nil { + t.Fatalf("write OpenAPI fixture: %v", err) + } + + outputDir := t.TempDir() + if err := convertOpenAPIToMarkdown(inputPath, outputDir, []string{"SecurityInsights"}); err != nil { + t.Fatalf("convert OpenAPI to Markdown: %v", err) + } + data, err := os.ReadFile(filepath.Join(outputDir, "schema.md")) + if err != nil { + t.Fatalf("read generated Markdown: %v", err) + } + got := string(data) + if !strings.Contains(got, "`rulesets` **array[string]** _Required_") { + t.Fatalf("generated Markdown does not preserve rulesets as array[string]:\n%s", got) + } + wantAllowed := "Allowed values: `fuzzing`, `container`, `secret`, `SCA`, `SAST`, `other`." + if !strings.Contains(got, wantAllowed) { + t.Fatalf("generated Markdown does not preserve enum values; want %q:\n%s", wantAllowed, got) + } +} + +func TestFormatEnumValueUsesSafeCodeSpans(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + want string + }{ + {name: "plain", value: "fuzzing", want: "`fuzzing`"}, + {name: "embedded backtick", value: "tick`mark", want: "``tick`mark``"}, + {name: "edge backticks", value: "`tick`", want: "`` `tick` ``"}, + {name: "newline", value: "line\nfeed", want: "`\"line\\nfeed\"`"}, + {name: "empty", value: "", want: "`\"\"`"}, + {name: "edge whitespace", value: " padded ", want: "`\" padded \"`"}, + {name: "nul", value: "a\x00b", want: "`\"a\\x00b\"`"}, + {name: "backspace", value: "a\bb", want: "`\"a\\bb\"`"}, + {name: "form feed", value: "a\fb", want: "`\"a\\fb\"`"}, + {name: "escape", value: "a\x1bb", want: "`\"a\\x1bb\"`"}, + {name: "line separator", value: "a\u2028b", want: "`\"a\\u2028b\"`"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := formatEnumValue(test.value); got != test.want { + t.Fatalf("formatEnumValue(%q) = %q; want %q", test.value, got, test.want) + } + }) + } +} diff --git a/docs/schema.md b/docs/schema.md index d66313d..77e3713 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -286,6 +286,8 @@ An object describing security-related artifacts, champions, and tooling for the Indicates the repository’s current [Repo Status](https://repostatus.org). +Allowed values: `active`, `abandoned`, `concept`, `inactive`, `moved`, `suspended`, `unsupported`, `WIP`. + `url` **[URL](#url)** _Required_ The main URL for this repository. @@ -362,7 +364,7 @@ The name of the tool. Where to find the tool's scan results, grouped by how they are run (ad hoc, CI, or release). -`rulesets` **string** _Required_ +`rulesets` **array[string]** _Required_ The set of rules or configurations applied by the tool. If customization is not enabled, the only value here should be "default". @@ -370,6 +372,8 @@ The set of rules or configurations applied by the tool. If customization is not The general category or type of the tool. +Allowed values: `fuzzing`, `container`, `secret`, `SCA`, `SAST`, `other`. + `comment` **string** Additional notes about the tool’s usage or configuration. diff --git a/spec/schema.md b/spec/schema.md index 1e6e2d9..da34305 100644 --- a/spec/schema.md +++ b/spec/schema.md @@ -279,6 +279,8 @@ An object describing security-related artifacts, champions, and tooling for the Indicates the repository’s current [Repo Status](https://repostatus.org). +Allowed values: `active`, `abandoned`, `concept`, `inactive`, `moved`, `suspended`, `unsupported`, `WIP`. + `url` **[URL](#url)** _Required_ The main URL for this repository. @@ -355,7 +357,7 @@ The name of the tool. Where to find the tool's scan results, grouped by how they are run (ad hoc, CI, or release). -`rulesets` **string** _Required_ +`rulesets` **array[string]** _Required_ The set of rules or configurations applied by the tool. If customization is not enabled, the only value here should be "default". @@ -363,6 +365,8 @@ The set of rules or configurations applied by the tool. If customization is not The general category or type of the tool. +Allowed values: `fuzzing`, `container`, `secret`, `SCA`, `SAST`, `other`. + `comment` **string** Additional notes about the tool’s usage or configuration.