encoding/openapi: detect cycles when expanding references

Fixes #915

Change-Id: Ie781ee316e8675da66f7ca3bea4c841acaaa8a5b
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/9603
Reviewed-by: CUE cueckoo <cueckoo@gmail.com>
Reviewed-by: Marcel van Lohuizen <mpvl@golang.org>
diff --git a/encoding/openapi/build.go b/encoding/openapi/build.go
index b7a958d..f22a921 100644
--- a/encoding/openapi/build.go
+++ b/encoding/openapi/build.go
@@ -36,6 +36,7 @@
 	instExt   *cue.Instance
 	refPrefix string
 	path      []string
+	errs      errors.Error
 
 	expandRefs    bool
 	structural    bool
@@ -49,6 +50,14 @@
 
 	// Track external schemas.
 	externalRefs map[string]*externalType
+
+	// Used for cycle detection in case of using ExpandReferences. At the
+	// moment, CUE does not detect cycles when a user forcefully steps into a
+	// pattern constraint.
+	//
+	// TODO: consider an option in the CUE API where optional fields are
+	// recursively evaluated.
+	cycleNodes []*adt.Vertex
 }
 
 type externalType struct {
@@ -168,7 +177,7 @@
 		return x < y
 	})
 
-	return (*ast.StructLit)(c.schemas), nil
+	return (*ast.StructLit)(c.schemas), c.errs
 }
 
 func (c *buildContext) build(name string, v cue.Value) *ast.StructLit {
@@ -314,6 +323,9 @@
 }
 
 func (b *builder) value(v cue.Value, f typeFunc) (isRef bool) {
+	b.pushNode(v)
+	defer b.popNode()
+
 	count := 0
 	disallowDefault := false
 	var values cue.Value
@@ -770,7 +782,8 @@
 		b.setSingle("properties", (*ast.StructLit)(properties), false)
 	}
 
-	if t, ok := v.Elem(); ok && (b.core == nil || b.core.items == nil) {
+	if t, ok := v.Elem(); ok &&
+		(b.core == nil || b.core.items == nil) && b.checkCycle(t) {
 		schema := b.schema(nil, "*", t)
 		if len(schema.Elts) > 0 {
 			b.setSingle("additionalProperties", schema, true) // Not allowed in structural.
@@ -871,7 +884,7 @@
 	}
 
 	if !hasMax || int64(len(items)) < maxLength {
-		if typ, ok := v.Elem(); ok {
+		if typ, ok := v.Elem(); ok && b.checkCycle(typ) {
 			var core *builder
 			if b.core != nil {
 				core = b.core.items
diff --git a/encoding/openapi/crd.go b/encoding/openapi/crd.go
index 7251b43..dbf689f 100644
--- a/encoding/openapi/crd.go
+++ b/encoding/openapi/crd.go
@@ -109,6 +109,9 @@
 // To this extent, all fields of both conjunctions and disjunctions are
 // collected in a single properties map.
 func (b *builder) buildCore(v cue.Value) {
+	b.pushNode(v)
+	defer b.popNode()
+
 	if !b.ctx.expandRefs {
 		_, r := v.Reference()
 		if len(r) > 0 {
@@ -126,6 +129,9 @@
 		switch b.kind {
 		case cue.StructKind:
 			if typ, ok := v.Elem(); ok {
+				if !b.checkCycle(typ) {
+					return
+				}
 				if b.items == nil {
 					b.items = newCoreBuilder(b.ctx)
 				}
@@ -135,6 +141,9 @@
 
 		case cue.ListKind:
 			if typ, ok := v.Elem(); ok {
+				if !b.checkCycle(typ) {
+					return
+				}
 				if b.items == nil {
 					b.items = newCoreBuilder(b.ctx)
 				}
diff --git a/encoding/openapi/cycle.go b/encoding/openapi/cycle.go
new file mode 100644
index 0000000..ef62294
--- /dev/null
+++ b/encoding/openapi/cycle.go
@@ -0,0 +1,59 @@
+// Copyright 2021 CUE Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package openapi
+
+import (
+	"cuelang.org/go/cue"
+	"cuelang.org/go/cue/errors"
+	"cuelang.org/go/cue/token"
+	"cuelang.org/go/internal/core/dep"
+	"cuelang.org/go/internal/core/eval"
+	internalvalue "cuelang.org/go/internal/value"
+)
+
+func (b *builder) pushNode(v cue.Value) {
+	_, n := internalvalue.ToInternal(v)
+	b.ctx.cycleNodes = append(b.ctx.cycleNodes, n)
+}
+
+func (b *builder) popNode() {
+	b.ctx.cycleNodes = b.ctx.cycleNodes[:len(b.ctx.cycleNodes)-1]
+}
+
+func (b *builder) checkCycle(v cue.Value) bool {
+	if !b.ctx.expandRefs {
+		return true
+	}
+	r, n := internalvalue.ToInternal(v)
+	ctx := eval.NewContext(r, n)
+
+	err := dep.Visit(ctx, n, func(d dep.Dependency) error {
+		for _, m := range b.ctx.cycleNodes {
+			if m == d.Node {
+				var p token.Pos
+				if src := d.Node.Source(); src != nil {
+					p = src.Pos()
+				}
+				err := errors.Newf(p,
+					"cycle in reference at %v: cyclic structures not allowed when reference expansion is requested", v.Path())
+				b.ctx.errs = errors.Append(b.ctx.errs, err)
+				return err
+			}
+		}
+		return nil
+	})
+
+	return err == nil
+}
diff --git a/encoding/openapi/openapi_test.go b/encoding/openapi/openapi_test.go
index 8e93a80..3f96175 100644
--- a/encoding/openapi/openapi_test.go
+++ b/encoding/openapi/openapi_test.go
@@ -43,70 +43,71 @@
 	testCases := []struct {
 		in, out string
 		config  *openapi.Config
+		err     string
 	}{{
-		"structural.cue",
-		"structural.json",
-		resolveRefs,
+		in:     "structural.cue",
+		out:    "structural.json",
+		config: resolveRefs,
 	}, {
-		"nested.cue",
-		"nested.json",
-		defaultConfig,
+		in:     "nested.cue",
+		out:    "nested.json",
+		config: defaultConfig,
 	}, {
-		"simple.cue",
-		"simple.json",
-		resolveRefs,
+		in:     "simple.cue",
+		out:    "simple.json",
+		config: resolveRefs,
 	}, {
-		"simple.cue",
-		"simple-filter.json",
-		&openapi.Config{Info: info, FieldFilter: "min.*|max.*"},
+		in:     "simple.cue",
+		out:    "simple-filter.json",
+		config: &openapi.Config{Info: info, FieldFilter: "min.*|max.*"},
 	}, {
-		"array.cue",
-		"array.json",
-		defaultConfig,
+		in:     "array.cue",
+		out:    "array.json",
+		config: defaultConfig,
 	}, {
-		"enum.cue",
-		"enum.json",
-		defaultConfig,
+		in:     "enum.cue",
+		out:    "enum.json",
+		config: defaultConfig,
 	}, {
-		"struct.cue",
-		"struct.json",
-		defaultConfig,
+		in:     "struct.cue",
+		out:    "struct.json",
+		config: defaultConfig,
 	}, {
-		"strings.cue",
-		"strings.json",
-		defaultConfig,
+		in:     "strings.cue",
+		out:    "strings.json",
+		config: defaultConfig,
 	}, {
-		"nums.cue",
-		"nums.json",
-		defaultConfig,
+		in:     "nums.cue",
+		out:    "nums.json",
+		config: defaultConfig,
 	}, {
-		"nums.cue",
-		"nums-v3.1.0.json",
-		&openapi.Config{Info: info, Version: "3.1.0"},
+		in:     "nums.cue",
+		out:    "nums-v3.1.0.json",
+		config: &openapi.Config{Info: info, Version: "3.1.0"},
 	}, {
-		"builtins.cue",
-		"builtins.json",
-		defaultConfig,
+		in:     "builtins.cue",
+		out:    "builtins.json",
+		config: defaultConfig,
 	}, {
-		"oneof.cue",
-		"oneof.json",
-		defaultConfig,
+		in:     "oneof.cue",
+		out:    "oneof.json",
+		config: defaultConfig,
 	}, {
-		"oneof.cue",
-		"oneof-resolve.json",
-		resolveRefs,
+		in:     "oneof.cue",
+		out:    "oneof-resolve.json",
+		config: resolveRefs,
 	}, {
-		"openapi.cue",
-		"openapi.json",
-		defaultConfig,
+		in:     "openapi.cue",
+		out:    "openapi.json",
+		config: defaultConfig,
 	}, {
-		"openapi.cue",
-		"openapi-norefs.json",
-		resolveRefs,
+		in:     "openapi.cue",
+		out:    "openapi-norefs.json",
+		config: resolveRefs,
 	}, {
-		"oneof.cue",
-		"oneof-funcs.json",
-		&openapi.Config{
+		in:  "oneof.cue",
+		out: "oneof-funcs.json",
+		config: &openapi.Config{
 			Info: info,
 			ReferenceFunc: func(inst *cue.Instance, path []string) string {
 				return strings.ToUpper(strings.Join(path, "_"))
@@ -116,9 +117,9 @@
 			},
 		},
 	}, {
-		"refs.cue",
-		"refs.json",
-		&openapi.Config{
+		in:  "refs.cue",
+		out: "refs.json",
+		config: &openapi.Config{
 			Info: info,
 			ReferenceFunc: func(inst *cue.Instance, path []string) string {
 				switch {
@@ -129,9 +130,19 @@
 			},
 		},
 	}, {
-		"issue131.cue",
-		"issue131.json",
-		&openapi.Config{Info: info, SelfContained: true},
+		in:     "issue131.cue",
+		out:    "issue131.json",
+		config: &openapi.Config{Info: info, SelfContained: true},
+	}, {
+		// Issue #915
+		in:     "cycle.cue",
+		out:    "cycle.json",
+		config: &openapi.Config{Info: info},
+	}, {
+		// Issue #915
+		in:     "cycle.cue",
+		config: &openapi.Config{Info: info, ExpandReferences: true},
+		err:    "cycle",
 	}}
 	for _, tc := range testCases {
 		t.Run(tc.out, func(t *testing.T) {
@@ -144,16 +155,24 @@
 				t.Fatal(errors.Details(inst.Err, nil))
 			}
 
-			all, err := tc.config.All(inst)
-			if err != nil {
-				t.Fatal(err)
-			}
-			walk(all)
-
 			b, err := openapi.Gen(inst, tc.config)
 			if err != nil {
-				t.Fatal(err)
+				if tc.err == "" {
+					t.Fatal("unexpected error:", errors.Details(inst.Err, nil))
+				}
+				return
 			}
+
+			if tc.err != "" {
+				t.Fatal("unexpected success:", tc.err)
+			} else {
+				all, err := tc.config.All(inst)
+				if err != nil {
+					t.Fatal(err)
+				}
+				walk(all)
+			}
+
 			var out = &bytes.Buffer{}
 			_ = json.Indent(out, b, "", "   ")
 
@@ -206,7 +225,7 @@
 		ExpandReferences: true,
 	})
 	if err != nil {
-		t.Fatal(err)
+		t.Fatal(errors.Details(err, nil))
 	}
 
 	var out = &bytes.Buffer{}
diff --git a/encoding/openapi/testdata/cycle.cue b/encoding/openapi/testdata/cycle.cue
new file mode 100644
index 0000000..eb5cd11
--- /dev/null
+++ b/encoding/openapi/testdata/cycle.cue
@@ -0,0 +1,2 @@
+		// Issue #915
+#Foo: [string]: #Foo
diff --git a/encoding/openapi/testdata/cycle.json b/encoding/openapi/testdata/cycle.json
new file mode 100644
index 0000000..49c86dc
--- /dev/null
+++ b/encoding/openapi/testdata/cycle.json
@@ -0,0 +1,19 @@
+{
+   "openapi": "3.0.0",
+   "info": {
+      "title": "test",
+      "version": "v1"
+   },
+   "paths": {},
+   "components": {
+      "schemas": {
+         "Foo": {
+            "description": "Issue #915",
+            "type": "object",
+            "additionalProperties": {
+               "$ref": "#/components/schemas/Foo"
+            }
+         }
+      }
+   }
+}
\ No newline at end of file