Skip to content

Commit f9556d7

Browse files
hakleinclaude
andauthored
Reject cyclic substitutesFor chains instead of looping until OOM (#2038)
A bundle whose olm.substitutesFor annotation references its own CSV name (or a set of bundles forming an indirect cycle) sent the chain walks in addSubstitutesFor into infinite loops: the skips-accumulating walk grows its slice on every iteration until the process dies with "fatal error: runtime: out of memory". This is how a single self-referencing bundle in web-terminal-operator 1.7.x metadata took down IIB catalog builds (OCPBUGS-37284). Guard every substitutesFor chain walk with a visited set seeded with the walk's starting node, so any cycle - direct or indirect - fails fast with an error naming the offending bundle instead of exhausting memory. Signed-off-by: Harald Klein <hklein@redhat.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 37385d0 commit f9556d7

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

pkg/sqlite/load.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ func (s *sqlLoader) addOperatorBundle(tx *sql.Tx, bundle *registry.Bundle) error
162162
return s.addAPIs(tx, bundle)
163163
}
164164

165+
// markSeen records name in seen, erroring if it was already there. It guards
166+
// the substitutesFor chain walks below against cyclic references (e.g. a
167+
// bundle substituting for itself), which would otherwise loop until the
168+
// process runs out of memory (OCPBUGS-37284).
169+
func markSeen(seen map[string]struct{}, name string) error {
170+
if _, ok := seen[name]; ok {
171+
return fmt.Errorf("cyclic substitutesFor chain detected involving %q", name)
172+
}
173+
seen[name] = struct{}{}
174+
return nil
175+
}
176+
165177
func (s *sqlLoader) addSubstitutesFor(tx *sql.Tx, bundle *registry.Bundle) error {
166178
updateBundleReplaces, err := tx.Prepare("update operatorbundle set replaces = ? where replaces = ?")
167179
if err != nil {
@@ -217,11 +229,15 @@ func (s *sqlLoader) addSubstitutesFor(tx *sql.Tx, bundle *registry.Bundle) error
217229
if err != nil {
218230
return err
219231
}
232+
otherSeen := map[string]struct{}{csvName: {}}
220233
for len(otherSubstitutions) > 0 {
221234
// consume the slice of substitutions
222235
otherSubstitution := otherSubstitutions[0]
223236
otherSubstitutions = otherSubstitutions[1:]
224237
if otherSubstitution != csvName {
238+
if err := markSeen(otherSeen, otherSubstitution); err != nil {
239+
return err
240+
}
225241
// Another bundle is substituting for that same bundle
226242
// Get other bundle's version
227243
_, _, rawVersion, err := s.getBundleSkipsReplacesVersion(tx, otherSubstitution)
@@ -288,7 +304,11 @@ func (s *sqlLoader) addSubstitutesFor(tx *sql.Tx, bundle *registry.Bundle) error
288304

289305
// If the substituted-for of the current bundle substitutes for another bundle
290306
// it should also be added to the skips of the substitutesFor bundle
307+
seen := map[string]struct{}{csvName: {}}
291308
for substitutesFor != "" {
309+
if err := markSeen(seen, substitutesFor); err != nil {
310+
return err
311+
}
292312
skips = append(skips, substitutesFor)
293313
substitutesFor, err = s.getBundleSubstitution(tx, substitutesFor)
294314
if err != nil {
@@ -305,7 +325,11 @@ func (s *sqlLoader) addSubstitutesFor(tx *sql.Tx, bundle *registry.Bundle) error
305325
if err != nil || len(substitutesFors) > 1 {
306326
return err
307327
}
328+
seen = map[string]struct{}{csvName: {}}
308329
for len(substitutesFors) > 0 {
330+
if err := markSeen(seen, substitutesFors[0]); err != nil {
331+
return err
332+
}
309333
err = s.appendSkips(tx, append(skips, csvName), substitutesFors[0])
310334
if err != nil {
311335
return err
@@ -318,6 +342,7 @@ func (s *sqlLoader) addSubstitutesFor(tx *sql.Tx, bundle *registry.Bundle) error
318342

319343
// Bundles that skip a bundle that is substituted for
320344
// should also skip the substituted-for bundle
345+
// nolint:nestif
321346
if len(skips) != 0 {
322347
// ensure slice of skips doesn't contain duplicates
323348
substitutesSkips := make(map[string]struct{})
@@ -328,12 +353,16 @@ func (s *sqlLoader) addSubstitutesFor(tx *sql.Tx, bundle *registry.Bundle) error
328353
if err != nil || len(substitutesFors) > 1 {
329354
return err
330355
}
356+
seen = map[string]struct{}{skip: {}}
331357
for len(substitutesFors) > 0 {
332358
// consume the slice of substitutions
333359
substitutesFor = substitutesFors[0]
334360
substitutesFors = substitutesFors[1:]
335361
// shouldn't skip yourself
336362
if substitutesFor != csvName {
363+
if err := markSeen(seen, substitutesFor); err != nil {
364+
return err
365+
}
337366
substitutesSkips[substitutesFor] = struct{}{}
338367
substitutesFors, err = s.getBundlesThatSubstitutesFor(tx, substitutesFor)
339368
if err != nil || len(substitutesFors) > 1 {
@@ -356,9 +385,13 @@ func (s *sqlLoader) addSubstitutesFor(tx *sql.Tx, bundle *registry.Bundle) error
356385
if err != nil {
357386
return err
358387
}
388+
seen = map[string]struct{}{replaces: {}}
359389
for len(substitutesFors) > 0 {
360390
// update the replaces to a newer substitution
361391
replaces = substitutesFors[0]
392+
if err := markSeen(seen, replaces); err != nil {
393+
return err
394+
}
362395
// try to get the substitution of the substitution
363396
substitutesFors, err = s.getBundlesThatSubstitutesFor(tx, replaces)
364397
if err != nil || len(substitutesFors) > 1 {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package sqlite
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
13+
"github.com/operator-framework/operator-registry/pkg/registry"
14+
)
15+
16+
func newUnstructuredCSVWithSubstitutesFor(t *testing.T, name, substitutesFor string) *unstructured.Unstructured {
17+
csv := &registry.ClusterServiceVersion{}
18+
csv.Kind = "ClusterServiceVersion"
19+
csv.SetName(name)
20+
csv.SetAnnotations(map[string]string{"olm.substitutesFor": substitutesFor})
21+
csv.Spec = json.RawMessage(fmt.Sprintf(`{"version": %q}`, "1.0.0"))
22+
23+
out, err := runtime.DefaultUnstructuredConverter.ToUnstructured(csv)
24+
require.NoError(t, err)
25+
return &unstructured.Unstructured{Object: out}
26+
}
27+
28+
func newSubstitutesForLoader(t *testing.T) (*sqlLoader, func()) {
29+
db, cleanup := CreateTestDB(t)
30+
store, err := NewSQLLiteLoader(db, WithEnableAlpha(true))
31+
require.NoError(t, err)
32+
require.NoError(t, store.Migrate(context.TODO()))
33+
return store.(*sqlLoader), cleanup
34+
}
35+
36+
// Reproducer for OCPBUGS-37284: a bundle whose olm.substitutesFor annotation
37+
// references its own CSV name sends addSubstitutesFor into an unbounded chain
38+
// walk that grows the skips slice until the process runs out of memory.
39+
// It must be rejected with an error instead.
40+
func TestSubstitutesForSelfReference(t *testing.T) {
41+
loader, cleanup := newSubstitutesForLoader(t)
42+
defer cleanup()
43+
44+
self := newBundle(t, "csv-self", "pkg", []string{"stable"},
45+
newUnstructuredCSVWithSubstitutesFor(t, "csv-self", "csv-self"))
46+
47+
err := loader.AddOperatorBundle(self)
48+
require.ErrorContains(t, err, "cyclic substitutesFor")
49+
}
50+
51+
// An indirect cycle (A substitutes for B, B substitutes for A) must also be
52+
// rejected instead of looping forever.
53+
func TestSubstitutesForIndirectCycle(t *testing.T) {
54+
loader, cleanup := newSubstitutesForLoader(t)
55+
defer cleanup()
56+
57+
a := newBundle(t, "csv-a", "pkg", []string{"stable"},
58+
newUnstructuredCSVWithSubstitutesFor(t, "csv-a", "csv-b"))
59+
// csv-b does not exist yet, so adding csv-a is legal
60+
require.NoError(t, loader.AddOperatorBundle(a))
61+
62+
b := newBundle(t, "csv-b", "pkg", []string{"stable"},
63+
newUnstructuredCSVWithSubstitutesFor(t, "csv-b", "csv-a"))
64+
65+
err := loader.AddOperatorBundle(b)
66+
require.ErrorContains(t, err, "cyclic substitutesFor")
67+
}

0 commit comments

Comments
 (0)