Skip to content

Commit c5c4ca0

Browse files
committed
fix(values): schema validation and ref internal support
Signed-off-by: Brandt Keller <brandt.keller@defenseunicorns.com>
1 parent 723605b commit c5c4ca0

9 files changed

Lines changed: 160 additions & 84 deletions

File tree

src/pkg/packager/layout/assemble.go

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,9 +1042,9 @@ func mergeAndWriteValuesSchema(ctx context.Context, parentSchema string, importe
10421042
return nil
10431043
}
10441044

1045-
// loadSchema reads a schema file, rejects any "$ref" pointers before handing the
1046-
// document to gojsonschema, then validates the schema structure. CheckNoRefs runs
1047-
// first so that gojsonschema never attempts to resolve external URIs.
1045+
// loadSchema reads a schema file, rejects any external "$ref" pointers before handing
1046+
// the document to gojsonschema, then validates the schema structure. CheckNoExternalRefs
1047+
// runs first so that gojsonschema never attempts to resolve external URIs.
10481048
loadSchema := func(relPath, label string) (map[string]any, error) {
10491049
src := relPath
10501050
if !filepath.IsAbs(src) {
@@ -1058,7 +1058,7 @@ func mergeAndWriteValuesSchema(ctx context.Context, parentSchema string, importe
10581058
if err := json.Unmarshal(b, &s); err != nil {
10591059
return nil, fmt.Errorf("parsing %s schema: %w", label, err)
10601060
}
1061-
if err := value.CheckNoRefs(s); err != nil {
1061+
if err := value.CheckNoExternalRefs(s); err != nil {
10621062
return nil, fmt.Errorf("%s schema %s: %w", label, relPath, err)
10631063
}
10641064
if err := value.ValidateSchemaDocument(s); err != nil {
@@ -1093,10 +1093,13 @@ func mergeAndWriteValuesSchema(ctx context.Context, parentSchema string, importe
10931093
if err != nil {
10941094
return err
10951095
}
1096+
if schemaVersion(child) == "" {
1097+
return fmt.Errorf("imported schema %s: missing \"$schema\" version declaration; all schemas being merged must specify a version", schemaRelPath)
1098+
}
10961099
if merged == nil {
10971100
merged = child
10981101
} else {
1099-
if err := checkCompatibleDialect(merged, child, schemaRelPath); err != nil {
1102+
if err := checkCompatibleVersion(merged, child, schemaRelPath); err != nil {
11001103
return err
11011104
}
11021105
merged = value.MergeSchemas(merged, child)
@@ -1109,12 +1112,19 @@ func mergeAndWriteValuesSchema(ctx context.Context, parentSchema string, importe
11091112
if err != nil {
11101113
return err
11111114
}
1112-
if err := checkCompatibleDialect(parent, merged, "imported schemas"); err != nil {
1115+
if schemaVersion(parent) == "" {
1116+
return fmt.Errorf("parent schema %s: missing \"$schema\" version declaration; all schemas being merged must specify a version", parentSchema)
1117+
}
1118+
if err := checkCompatibleVersion(parent, merged, "imported schemas"); err != nil {
11131119
return err
11141120
}
11151121
merged = value.MergeSchemas(parent, merged)
11161122
}
11171123

1124+
if err := value.ValidateSchemaDocument(merged); err != nil {
1125+
return fmt.Errorf("merged values schema is invalid: %w", err)
1126+
}
1127+
11181128
dst := filepath.Join(buildPath, ValuesSchema)
11191129
l.Debug("writing merged values schema", "dst", dst)
11201130
b, err := json.MarshalIndent(merged, "", " ")
@@ -1127,20 +1137,21 @@ func mergeAndWriteValuesSchema(ctx context.Context, parentSchema string, importe
11271137
return nil
11281138
}
11291139

1130-
// schemaDialect extracts the "$schema" dialect URI from a schema map, returning "" if absent or not a string
1131-
func schemaDialect(s map[string]any) string {
1140+
// schemaVersion extracts the "$schema" version URI from a schema map, returning "" if absent or not a string.
1141+
func schemaVersion(s map[string]any) string {
11321142
if v, ok := s["$schema"].(string); ok {
11331143
return v
11341144
}
11351145
return ""
11361146
}
11371147

1138-
// checkCompatibleDialect errors when both schemas explicitly declare a "$schema" dialect that differ
1139-
func checkCompatibleDialect(accumulated, incoming map[string]any, incomingLabel string) error {
1140-
a := schemaDialect(accumulated)
1141-
b := schemaDialect(incoming)
1142-
if a != "" && b != "" && a != b {
1143-
return fmt.Errorf("cannot merge schemas with different dialects: accumulated schema uses %q but %s declares %q", a, incomingLabel, b)
1148+
// checkCompatibleVersion errors when the accumulated merged schema and an incoming schema declare
1149+
// different "$schema" versions, preventing silent cross-version merge bugs.
1150+
func checkCompatibleVersion(accumulated, incoming map[string]any, incomingLabel string) error {
1151+
a := schemaVersion(accumulated)
1152+
b := schemaVersion(incoming)
1153+
if a != b {
1154+
return fmt.Errorf("cannot merge schemas with different versions: accumulated schema uses %q but %s declares %q", a, incomingLabel, b)
11441155
}
11451156
return nil
11461157
}

src/pkg/packager/layout/assemble_test.go

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -429,43 +429,61 @@ func TestMergeAndWriteValuesSchema(t *testing.T) {
429429
require.Equal(t, string(original), string(written), "verbatim copy should match source file exactly")
430430
})
431431

432-
t.Run("rejects parent schema containing $ref even with no children", func(t *testing.T) {
432+
t.Run("rejects parent schema containing external $ref", func(t *testing.T) {
433433
t.Parallel()
434434
buildPath := t.TempDir()
435-
err := mergeAndWriteValuesSchema(ctx, "child-with-ref.schema.json", nil, testdataDir, buildPath)
435+
err := mergeAndWriteValuesSchema(ctx, "child-with-external-ref.schema.json", nil, testdataDir, buildPath)
436436
require.ErrorContains(t, err, "$ref")
437437
})
438438

439-
t.Run("rejects child schema containing $ref", func(t *testing.T) {
439+
t.Run("rejects child schema containing external $ref", func(t *testing.T) {
440440
t.Parallel()
441441
buildPath := t.TempDir()
442-
err := mergeAndWriteValuesSchema(ctx, "parent-with-required.schema.json", []string{"child-with-ref.schema.json"}, testdataDir, buildPath)
442+
err := mergeAndWriteValuesSchema(ctx, "parent-with-required.schema.json", []string{"child-with-external-ref.schema.json"}, testdataDir, buildPath)
443443
require.ErrorContains(t, err, "$ref")
444444
})
445445

446-
t.Run("rejects merge when parent and child declare different dialects", func(t *testing.T) {
446+
t.Run("allows internal fragment refs in schemas being merged", func(t *testing.T) {
447+
t.Parallel()
448+
buildPath := t.TempDir()
449+
// child-with-ref.schema.json uses "$ref": "#/definitions/name" — internal, safe to merge
450+
err := mergeAndWriteValuesSchema(ctx, "parent-with-required.schema.json", []string{"child-with-ref.schema.json"}, testdataDir, buildPath)
451+
require.NoError(t, err)
452+
})
453+
454+
t.Run("rejects merge when parent and child declare different versions", func(t *testing.T) {
447455
t.Parallel()
448456
buildPath := t.TempDir()
449457
// parent-with-required declares draft-07; child-wrong-version declares 2019-09
450458
err := mergeAndWriteValuesSchema(ctx, "parent-with-required.schema.json", []string{"child-wrong-version.schema.json"}, testdataDir, buildPath)
451-
require.ErrorContains(t, err, "different dialects")
459+
require.ErrorContains(t, err, "different versions")
452460
require.ErrorContains(t, err, "draft-07")
453461
require.ErrorContains(t, err, "2019-09")
454462
})
455463

456-
t.Run("allows merge when child omits dialect", func(t *testing.T) {
464+
t.Run("preserves child definitions when parent overrides with empty map so internal refs remain valid", func(t *testing.T) {
457465
t.Parallel()
458466
buildPath := t.TempDir()
459-
// child-no-dialect has no $schema; absence is treated as compatible with any version
460-
err := mergeAndWriteValuesSchema(ctx, "parent-with-required.schema.json", []string{"child-no-dialect.schema.json"}, testdataDir, buildPath)
467+
// child-with-ref uses $ref: "#/definitions/name" with a matching definition.
468+
// parent-overrides-definitions sets definitions: {} (empty), which previously
469+
// deleted the child's definition and left the $ref unresolvable.
470+
// With definitions merged like properties, the child-only "name" entry survives.
471+
err := mergeAndWriteValuesSchema(ctx, "parent-overrides-definitions.schema.json", []string{"child-with-ref.schema.json"}, testdataDir, buildPath)
461472
require.NoError(t, err)
462473
})
463474

464-
t.Run("allows merge when both schemas omit dialect", func(t *testing.T) {
475+
t.Run("rejects merge when child omits version", func(t *testing.T) {
465476
t.Parallel()
466477
buildPath := t.TempDir()
467-
err := mergeAndWriteValuesSchema(ctx, "", []string{"child-no-dialect.schema.json", "child-no-dialect.schema.json"}, testdataDir, buildPath)
468-
require.NoError(t, err)
478+
err := mergeAndWriteValuesSchema(ctx, "parent-with-required.schema.json", []string{"child-no-dialect.schema.json"}, testdataDir, buildPath)
479+
require.ErrorContains(t, err, "missing \"$schema\" version declaration")
480+
})
481+
482+
t.Run("rejects merge when parent omits version", func(t *testing.T) {
483+
t.Parallel()
484+
buildPath := t.TempDir()
485+
err := mergeAndWriteValuesSchema(ctx, "child-no-dialect.schema.json", []string{"child.schema.json"}, testdataDir, buildPath)
486+
require.ErrorContains(t, err, "missing \"$schema\" version declaration")
469487
})
470488

471489
mergeTests := []struct {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"type": "object",
4+
"properties": {
5+
"appName": { "$ref": "./defs/name.json" }
6+
}
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"type": "object",
4+
"definitions": {}
5+
}

src/pkg/packager/load/testdata/import/values/schema-deep/expected-merged-schema.json

Lines changed: 0 additions & 10 deletions
This file was deleted.

src/pkg/packager/load/testdata/import/values/schema-parent-empty/expected-merged-schema.json

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/pkg/packager/load/testdata/import/values/schema-parent-wins/expected-merged-schema.json

Lines changed: 0 additions & 10 deletions
This file was deleted.

src/pkg/value/schema.go

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,41 +7,46 @@ import (
77
"fmt"
88
"maps"
99
"slices"
10+
"strings"
1011
)
1112

12-
// CheckNoRefs returns an error if the schema object contains any "$ref" pointers.
13-
// "$ref" is not supported because referenced files are not bundled into the assembled
14-
// package and cannot be resolved after assembly. Flatten the schema into a single
15-
// self-contained file before use.
16-
func CheckNoRefs(schema map[string]any) error {
17-
return checkNoRefsInObject(schema)
13+
// CheckNoExternalRefs returns an error if the schema contains any external reference
14+
// pointers ($ref, $dynamicRef, $recursiveRef). Internal fragment references that start
15+
// with "#" — such as "#/definitions/Foo" or "#/$defs/Foo" — are allowed because the
16+
// referenced definition is part of the same document and travels with the schema during
17+
// merge and assembly. External references (relative file paths, HTTP URIs) are rejected
18+
// because the referenced files are not bundled into the assembled package.
19+
func CheckNoExternalRefs(schema map[string]any) error {
20+
return checkNoExternalRefsInObject(schema)
1821
}
1922

20-
var blockedRefKeywords = []string{"$ref", "$dynamicRef", "$recursiveRef"}
23+
var externalRefKeywords = []string{"$ref", "$dynamicRef", "$recursiveRef"}
2124

22-
func checkNoRefsInObject(node map[string]any) error {
23-
for _, kw := range blockedRefKeywords {
24-
if _, has := node[kw]; has {
25-
return fmt.Errorf("schema contains a %q pointer; flatten the schema into a single self-contained file", kw)
25+
func checkNoExternalRefsInObject(node map[string]any) error {
26+
for _, kw := range externalRefKeywords {
27+
if val, has := node[kw]; has {
28+
if ref, ok := val.(string); ok && !strings.HasPrefix(ref, "#") {
29+
return fmt.Errorf("schema contains an external %q pointer %q; only internal references (\"#/...\") are supported — external files are not bundled into the assembled package", kw, ref)
30+
}
2631
}
2732
}
2833
for _, key := range slices.Sorted(maps.Keys(node)) {
29-
if err := checkNoRefsInValue(key, node[key]); err != nil {
34+
if err := checkNoExternalRefsInValue(key, node[key]); err != nil {
3035
return err
3136
}
3237
}
3338
return nil
3439
}
3540

36-
func checkNoRefsInValue(key string, val any) error {
41+
func checkNoExternalRefsInValue(key string, val any) error {
3742
switch v := val.(type) {
3843
case map[string]any:
39-
if err := checkNoRefsInObject(v); err != nil {
44+
if err := checkNoExternalRefsInObject(v); err != nil {
4045
return fmt.Errorf("%s: %w", key, err)
4146
}
4247
case []any:
4348
for i, item := range v {
44-
if err := checkNoRefsInValue(fmt.Sprintf("%s[%d]", key, i), item); err != nil {
49+
if err := checkNoExternalRefsInValue(fmt.Sprintf("%s[%d]", key, i), item); err != nil {
4550
return err
4651
}
4752
}
@@ -77,15 +82,17 @@ func copyMap(m map[string]any) map[string]any {
7782

7883
// MergeSchemas merges child into parent with parent-wins semantics and returns a new map.
7984
// Neither parent nor child is modified. Rules:
80-
// - "properties": recursively merged; parent wins on same key
85+
// - "properties", "definitions", "$defs", "patternProperties", "dependentSchemas":
86+
// all are maps of string→schema and are recursively merged; parent wins on same key,
87+
// child-only entries are preserved so internal $ref pointers remain valid
8188
// - "required": union of both arrays, deduplicated
8289
// - all other keys: parent wins (child value used only when key absent from parent)
8390
func MergeSchemas(parent, child map[string]any) map[string]any {
8491
result := copyMap(parent)
8592
for key, childVal := range child {
8693
switch key {
87-
case "properties":
88-
result["properties"] = mergeProperties(result["properties"], childVal)
94+
case "properties", "definitions", "$defs", "patternProperties", "dependentSchemas":
95+
result[key] = mergeProperties(result[key], childVal)
8996
case "required":
9097
if req := mergeRequired(result["required"], childVal); len(req) > 0 {
9198
result["required"] = req

0 commit comments

Comments
 (0)