Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion compilers/openapi/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func load(ctx context.Context, srcIndex int, src compilers.Source, opts Options)
diags = append(diags, validationDiag(srcIndex, ve))
}

resErrs, err := doc.ResolveAllReferences(ctx, soa.ResolveAllOptions{
resErrs, err := resolveAll(ctx, doc, soa.ResolveAllOptions{
OpenAPILocation: src.Path,
DisableExternalRefs: opts.DisableExternalRefs,
})
Expand Down Expand Up @@ -202,6 +202,27 @@ func unmarshal(ctx context.Context, data []byte) (doc *soa.OpenAPI, valErrs []er
return soa.Unmarshal(ctx, bytes.NewReader(data))
}

// resolveAll resolves every reference in doc, converting a panic from the
// third-party resolver into an ordinary error so the compiler upholds the
// no-panics-escape invariant. It is the resolve-side counterpart to unmarshal's
// barrier, and it is needed for the same reason: the resolver faults on shapes
// the parser accepts — a reference object whose $ref key carries no value, say,
// which nil-derefs while populating the resolved node.
//
// The error joins the resolve errors the caller already turns into diagnostics
// rather than aborting the compile, because a document that trips this is a
// malformed spec, not an I/O or programmer error. The named returns are reset in
// the recover so a partially-populated result never leaks.
func resolveAll(ctx context.Context, doc *soa.OpenAPI, opts soa.ResolveAllOptions) (resErrs []error, err error) {
defer func() {
if r := recover(); r != nil {
resErrs = nil
err = fmt.Errorf("reference resolver panicked (%v): %w", r, errParse)
}
}()
return doc.ResolveAllReferences(ctx, opts)
}

// validationDiag converts one speakeasy validation error into a diagnostic. A
// structured *validation.Error yields severity, a rule-suffixed code, and
// line:col provenance; anything else degrades to an error with the bare message.
Expand Down
37 changes: 37 additions & 0 deletions compilers/openapi/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"testing"

soa "github.com/speakeasy-api/openapi/openapi"
"github.com/speakeasy-api/openapi/validation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -91,6 +92,42 @@ func TestUnmarshal_RecoversParserPanic(t *testing.T) {
assert.Nil(t, valErrs)
}

// resolverPanicSpec is a document the parser accepts and the resolver faults on:
// `B: {$ref}` is a mapping whose $ref key carries no value, so populating the
// response reference that points at it nil-dereferences inside speakeasy.
// FuzzCycleDetector found it; the same bytes are committed as a corpus entry.
const resolverPanicSpec = "openapi: 3.0\ncomponents:\n responses:\n 000: {$ref: '#/B'}\nB: {$ref}"

// TestResolveAll_RecoversResolverPanic pins the resolve half of the
// no-panics-escape invariant. unmarshal has guarded the parser since GitHub #12;
// ResolveAllReferences was left bare, so a document that parses cleanly and
// faults during resolution took the caller's process with it.
func TestResolveAll_RecoversResolverPanic(t *testing.T) {
t.Parallel()
doc, _, err := unmarshal(t.Context(), []byte(resolverPanicSpec))
require.NoError(t, err, "the parser accepts this document")
require.NotNil(t, doc)

resErrs, err := resolveAll(t.Context(), doc, soa.ResolveAllOptions{})
require.Error(t, err)
assert.ErrorIs(t, err, errParse)
assert.Contains(t, err.Error(), "reference resolver panicked")
assert.Nil(t, resErrs, "a partially-populated result never leaks")
}

// TestCompile_ResolverPanicIsADiagnostic is the end-to-end half: the panic
// becomes an ordinary unresolved-ref diagnostic, so a malformed spec is refused
// as a spec problem rather than reported as a Go error or crashing the process.
func TestCompile_ResolverPanicIsADiagnostic(t *testing.T) {
t.Parallel()
doc, diags, err := New().Compile(t.Context(),
[]compilers.Source{{Path: "resolver-panic.yaml", Data: []byte(resolverPanicSpec)}},
compilers.Options{})
require.NoError(t, err, "a malformed spec is a spec problem, not a Go error")
assert.NotNil(t, doc, "resolution failure does not stop the document being lowered")
assertHasErrorCode(t, diags, codeUnresolvedRef)
}

func TestMapSeverity(t *testing.T) {
t.Parallel()
assert.Equal(t, ir.SeverityWarning, mapSeverity(validation.Severity("warning")))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("openapi: 3.0\ncomponents:\n responses:\n 000: {$ref: '#/B'}\nB: {$ref}")
Loading