Skip to content

Commit 26c5ce1

Browse files
fix: prevent infinite loop in GetReferenceChain() with circular references (#120)
1 parent b4c15d7 commit 26c5ce1

2 files changed

Lines changed: 58 additions & 0 deletions

File tree

jsonschema/oas3/resolution.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,17 @@ func (j *JSONSchema[T]) GetReferenceChain() []*ReferenceChainEntry {
194194
}
195195

196196
var chain []*ReferenceChainEntry
197+
visited := make(map[*JSONSchema[Referenceable]]bool)
197198

198199
// Walk from the immediate parent up to the top-level
199200
current := j.parent
200201
for current != nil {
202+
// Detect circular reference in parent chain - stop if we've seen this schema before
203+
if visited[current] {
204+
break
205+
}
206+
visited[current] = true
207+
201208
if current.IsReference() {
202209
entry := &ReferenceChainEntry{
203210
Schema: current,

jsonschema/oas3/resolution_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,3 +1214,54 @@ func TestJSONSchema_GetTopLevelReference(t *testing.T) {
12141214
assert.Equal(t, refTopLevel, entry.Schema)
12151215
})
12161216
}
1217+
1218+
// Test GetReferenceChain with circular reference (regression test for infinite loop bug)
1219+
func TestJSONSchema_GetReferenceChain_CircularReference(t *testing.T) {
1220+
t.Parallel()
1221+
1222+
t.Run("circular parent chain terminates without infinite loop", func(t *testing.T) {
1223+
t.Parallel()
1224+
// Create a circular reference: A -> B -> A
1225+
// This simulates a self-referential schema like:
1226+
// Node:
1227+
// properties:
1228+
// next:
1229+
// $ref: "#/components/schemas/Node"
1230+
1231+
schemaA := createSchemaWithRef("#/components/schemas/Node")
1232+
schemaB := createSchemaWithRef("#/components/schemas/Node")
1233+
childSchema := createSimpleSchema()
1234+
1235+
// Create circular parent chain: childSchema -> schemaB -> schemaA -> schemaB (circular)
1236+
schemaA.SetParent(schemaB)
1237+
schemaB.SetParent(schemaA)
1238+
childSchema.SetParent(schemaB)
1239+
1240+
// This should NOT hang or infinite loop - it should detect the cycle and return
1241+
chain := childSchema.GetReferenceChain()
1242+
1243+
// Chain should contain both schemas (visited before cycle detected)
1244+
// The exact length depends on when the cycle is detected, but it should be finite
1245+
assert.NotNil(t, chain, "chain should not be nil even with circular references")
1246+
assert.LessOrEqual(t, len(chain), 2, "chain should be bounded, not infinite")
1247+
})
1248+
1249+
t.Run("self-referential parent terminates without infinite loop", func(t *testing.T) {
1250+
t.Parallel()
1251+
// Create a self-referential schema: A -> A
1252+
schemaA := createSchemaWithRef("#/components/schemas/Self")
1253+
childSchema := createSimpleSchema()
1254+
1255+
// Schema A's parent is itself
1256+
schemaA.SetParent(schemaA)
1257+
childSchema.SetParent(schemaA)
1258+
1259+
// This should NOT hang or infinite loop
1260+
chain := childSchema.GetReferenceChain()
1261+
1262+
// Chain should contain exactly one entry (schemaA visited once before cycle detected)
1263+
assert.NotNil(t, chain, "chain should not be nil even with self-reference")
1264+
assert.Len(t, chain, 1, "chain should contain exactly one entry for self-referential parent")
1265+
assert.Equal(t, "#/components/schemas/Self", string(chain[0].Reference))
1266+
})
1267+
}

0 commit comments

Comments
 (0)