diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 0945dac6..d9cf0020 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -176,6 +176,7 @@ func conformanceCases() []conformanceCase { {"enum-numeric", assertEnumNumeric, []string{"enums-numeric"}}, {"empty-enum", assertEmptyEnum, nil}, {"scalar-format", assertScalarFormat, []string{"custom-scalars"}}, + {"secret-format", assertSecretFormat, nil}, {"encoding-byte", assertEncodingByte, []string{"encoding-hints"}}, {"content-vocabulary", assertContentVocabulary, []string{"encoding-hints"}}, {"xml-hints", assertXMLHints, nil}, @@ -1126,11 +1127,141 @@ func assertScalarFormat(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { require.NotNil(t, sc.Constraints.MinLength) assert.Equal(t, int64(4), *sc.Constraints.MinLength) - // format: password is a redaction request about a use of the value, so it - // lands on the property rather than on the shared encoding node. + // format: password is a redaction request about how the value is handled, so + // it hoists a Scalar of its own carrying Sensitive and the verbatim spelling, + // and the property that writes it keeps Property.Secret beside that node. token, ok := propByWire(h, "token") require.True(t, ok) - assert.True(t, token.Secret) + assert.True(t, token.Secret, "the property that writes it is secret at the use") + secret, ok := doc.Types[token.Type.Target].(*ir.Scalar) + require.True(t, ok, "password no longer stays on the shared primitive") + assert.True(t, secret.Sensitive, "whole-type redaction") + require.NotNil(t, secret.Encoding) + assert.Equal(t, "password", secret.Encoding.Name, "the source token verbatim") + require.NotNil(t, secret.Base) + assert.Equal(t, ir.TypeID("t/prim/string"), secret.Base.Target, "over the bare type's primitive") +} + +// assertSecretFormat pins where a `format: password` position's fact lands: a +// Scalar of its own carrying Sensitive, the verbatim spelling and the bounds +// written beside it at every scalar position, and Property.Secret wherever a +// property or a header is the carrier, through any wrapper. It reads the nodes +// rather than the golden's bytes, so a node that moved to another position +// while keeping the same fields still fails (GitHub #579). +func assertSecretFormat(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + // The named component every reference resolves to, carrying its own bound. + requireSecretScalar(t, doc, namedID("Pw"), "component schema") + requireLengthBounds(t, doc, namedID("Pw"), new(int64(8)), nil, "component schema") + + login, ok := doc.Types[namedID("Login")].(*ir.Model) + require.True(t, ok, "Login is a model") + + // Inline property: Property.Secret beside the hoisted node. + inline, ok := propByWire(login, "inline") + require.True(t, ok) + assert.True(t, inline.Secret, "the inline property is secret") + requireSecretScalar(t, doc, inline.Type.Target, "inline property schema") + + // Property via $ref: Secret from the referent, which carries the node. + viaRef, ok := propByWire(login, "viaRef") + require.True(t, ok) + assert.True(t, viaRef.Secret, "a $ref to a redaction schema is secret at the use") + assert.Equal(t, namedID("Pw"), viaRef.Type.Target, "it resolves to the component") + + // The wrappers: each reaches the Sensitive node through a nullable ref or a + // Base chain, and each use is secret all the same. + for _, wire := range []string{"nullable", "wrapped", "viaAlias"} { + p, ok := propByWire(login, wire) + require.True(t, ok, wire) + assert.True(t, p.Secret, "%s: a use reaching the node through a wrapper is secret", wire) + } + + // Array items. + arr, ok := propByWire(login, "arr") + require.True(t, ok) + list, ok := doc.Types[arr.Type.Target].(*ir.List) + require.True(t, ok, "arr hoists a list") + requireSecretScalar(t, doc, list.Elem.Target, "array items") + requireLengthBounds(t, doc, list.Elem.Target, nil, new(int64(64)), "array items") + + // additionalProperties and patternProperties. + bag, ok := doc.Types[namedID("Bag")].(*ir.Model) + require.True(t, ok, "Bag is a model") + require.NotNil(t, bag.AdditionalProps) + requireSecretScalar(t, doc, bag.AdditionalProps.Value.Target, "additionalProperties") + require.Len(t, bag.AdditionalProps.Patterns, 1) + requireSecretScalar(t, doc, bag.AdditionalProps.Patterns[0].Value.Target, "patternProperties") + + // prefixItems. + pair, ok := doc.Types[namedID("Pair")].(*ir.Tuple) + require.True(t, ok, "prefixItems hoists a tuple") + require.Len(t, pair.Elems, 1) + requireSecretScalar(t, doc, pair.Elems[0].Target, "prefixItems") + + op, ok := opByName(doc, "login") + require.True(t, ok) + + // A header parameter's schema, inline, and a query parameter's, via $ref. + token, ok := paramByName(op, "X-Token") + require.True(t, ok) + requireSecretScalar(t, doc, token.Type.Target, "header parameter schema") + requireLengthBounds(t, doc, token.Type.Target, new(int64(12)), nil, "header parameter schema") + key, ok := paramByName(op, "api_key") + require.True(t, ok) + assert.Equal(t, namedID("Pw"), key.Type.Target, "the query parameter resolves to the component") + + // Request and response content schemas. + requireSecretScalar(t, doc, openapitest.BodyTarget(t, op.Request), "request content schema") + require.Len(t, op.Responses, 1) + requireSecretScalar(t, doc, openapitest.BodyTarget(t, op.Responses[0].Payload), "response content schema") + + // A response header: Property.Secret beside the node, and through a + // nullable reference to the component. + require.Len(t, op.Responses[0].Headers, 2) + header := op.Responses[0].Headers[0] + assert.True(t, header.Secret, "the response header is secret") + requireSecretScalar(t, doc, header.Type.Target, "response header schema") + maybe := op.Responses[0].Headers[1] + assert.True(t, maybe.Secret, "a nullable reference to a redaction schema is secret") + assert.Equal(t, ir.TypeRef{Target: namedID("Pw"), Nullable: true}, maybe.Type) + + // The guard: a bare string still targets the shared primitive, which stays + // not sensitive — the redaction must never leak onto the node every + // declaration of the type shares (invariant 3). + plain, ok := propByWire(login, "plain") + require.True(t, ok) + assert.Equal(t, ir.TypeID("t/prim/string"), plain.Type.Target, + "a plain string keeps the shared primitive") + assert.False(t, plain.Secret, "and asks for no redaction") + prim, ok := doc.Types[ir.TypeID("t/prim/string")] + require.True(t, ok, "the shared string primitive is registered") + assert.False(t, prim.Common().Sensitive, "the shared primitive is never sensitive") +} + +// requireSecretScalar requires the node at id to be the Scalar a +// `format: password` position hoists, over the bare type's own primitive. +func requireSecretScalar(t *testing.T, doc *ir.Document, id ir.TypeID, position string) { + t.Helper() + node, ok := doc.Types[id] + require.True(t, ok, "%s: nothing is interned at %s", position, id) + sc, ok := node.(*ir.Scalar) + require.True(t, ok, "%s: the position hoists a Scalar of its own", position) + assert.True(t, sc.Sensitive, "%s: whole-type redaction", position) + require.NotNil(t, sc.Encoding, "%s: the format reaches Encoding", position) + assert.Equal(t, "password", sc.Encoding.Name, "%s: the source token verbatim", position) + require.NotNil(t, sc.Base, "%s: a base to ride", position) + assert.Equal(t, ir.TypeID("t/prim/string"), sc.Base.Target, "%s: over the bare type's primitive", position) +} + +// requireLengthBounds requires the Scalar at id to carry the length bounds the +// position wrote beside its format: a hoisting format must not drop them. +func requireLengthBounds(t *testing.T, doc *ir.Document, id ir.TypeID, minLen, maxLen *int64, position string) { + t.Helper() + sc, ok := doc.Types[id].(*ir.Scalar) + require.True(t, ok, "%s: a Scalar at %s", position, id) + require.NotNil(t, sc.Constraints, "%s: the bounds written beside the format", position) + assert.Equal(t, minLen, sc.Constraints.MinLength, "%s: minLength", position) + assert.Equal(t, maxLen, sc.Constraints.MaxLength, "%s: maxLength", position) } // assertXMLHints covers the XML wire shape, whose hints attach at two carriers diff --git a/compilers/openapi/internal/merge/merge.go b/compilers/openapi/internal/merge/merge.go index 45a2c5ad..f24c6405 100644 --- a/compilers/openapi/internal/merge/merge.go +++ b/compilers/openapi/internal/merge/merge.go @@ -300,6 +300,10 @@ const maxTypeResolveDepth = 64 func (g *Merger) recordRedeclarationConflict(dst, src *ir.Property) (dropped, lost bool) { pointer := src.Provenance.Pointer if dst.Type.Target != src.Type.Target { + // A format hoist alone differs here: `{type: string, format: password}` + // owns a Scalar while a bare string stays on the shared primitive, so the + // later declaration is dropped whole, its shape details with it, and which + // one that is depends on branch order. Nothing reports it (GitHub #782). dropped = true } else { // Same referent, and the only thing left for the two to disagree about diff --git a/compilers/openapi/internal/operation/params.go b/compilers/openapi/internal/operation/params.go index 96661569..e9e2112f 100644 --- a/compilers/openapi/internal/operation/params.go +++ b/compilers/openapi/internal/operation/params.go @@ -125,6 +125,12 @@ func reservedHeaderParamDiag(c lowering.Ctx, name string, in soa.ParameterIn, pp // parameter, whose media type goes on the binding. Constraints come from that // same schema position; the default comes from it too, falling back to its $ref // target (§14). +// +// `format: password` on the parameter's schema hoists a Scalar carrying +// Sensitive. ir.Parameter has no redaction field (GitHub #578 owns one), so the +// type is the only carrier, and through a wrapper — an alias component, an +// allOf over one $ref — the parameter's own node is not Sensitive: only the node +// its Base chain reaches is. func fillParamType(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, param *ir.Parameter, binding *ir.HTTPParamBinding, p *soa.Parameter, pptr jsontext.Pointer, name string) []ir.Diagnostic { elected, diags := electTypeSpelling(c, p.GetSchema(), p.GetContent(), p.GetRootNode(), pptr) paramType, typeDiags := schema.CarriedRef(c, ts, anchors, schema.TopLevelDepth, elected.js, elected.pointer, name) diff --git a/compilers/openapi/internal/schema/compose_test.go b/compilers/openapi/internal/schema/compose_test.go index 287bca02..21b988a0 100644 --- a/compilers/openapi/internal/schema/compose_test.go +++ b/compilers/openapi/internal/schema/compose_test.go @@ -90,7 +90,11 @@ func TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder(t *testing.T) { t.Parallel() // The bare declaration comes first and the richer one second: reconciliation // must still surface every optional detail, so branch order never loses - // information (the reverse of the forkee documented-first shape). + // information (the reverse of the forkee documented-first shape). The richer + // branch writes no format because a format that hoists a node of its own + // changes the branch's type target, and the fold then drops the declaration + // rather than folding its details (GitHub #782). The secrecy half of that is + // TestAllOf_RedeclarationOrsSecret. spec := openapitest.ComponentSpec(` Tokenish: allOf: - type: object @@ -101,7 +105,6 @@ func TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder(t *testing.T) { properties: token: type: string - format: password description: the access token default: none minLength: 1 @@ -117,7 +120,6 @@ func TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder(t *testing.T) { tok := m.Properties[0] assert.True(t, tok.Required, "required in the richer branch => required on the merged property") - assert.True(t, tok.Secret, "secrecy (format:password) adopted from the richer branch") assert.Equal(t, "the access token", tok.Docs.Description, "description adopted whichever branch carries it") require.NotNil(t, tok.Default, "default adopted from the richer branch") require.NotNil(t, tok.Constraints, "constraints adopted from the richer branch") @@ -127,6 +129,46 @@ func TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder(t *testing.T) { require.Len(t, tok.Examples, 1, "examples adopted from the richer branch") } +// TestAllOf_RedeclarationOrsSecret pins the one property field a redeclaration +// OR-s rather than folds: a redaction requested by either branch survives the +// merge whichever declaration the type fold keeps (reconcileProperty). The two +// branches no longer share a target once one of them writes `format: password`, +// which hoists a Scalar of its own, so the shape fold drops the losing +// declaration and keeps it verbatim rather than folding its details, silently +// and in an order-dependent way (GitHub #782). Once that is fixed, this case +// should assert the folded fields as well. +func TestAllOf_RedeclarationOrsSecret(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, branches string }{ + {"password first", ` - type: object + properties: + token: {type: string, format: password} + - type: object + properties: + token: {type: string} +`}, + {"password second", ` - type: object + properties: + token: {type: string} + - type: object + properties: + token: {type: string, format: password} +`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(" Tokenish:\n allOf:\n" + tc.branches) + doc, diags := lowerSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + m, ok := doc.Types[componentID("Tokenish")].(*ir.Model) + require.True(t, ok, "Tokenish should be a model") + require.Len(t, m.Properties, 1, "token reconciles to a single property") + assert.True(t, m.Properties[0].Secret, + "either branch's format:password makes the merged field secret, %s", tc.name) + }) + } +} + func TestAllOf_ConflictingRedeclaredDescriptionDiagnosed(t *testing.T) { t.Parallel() // Two branches describe the same field differently. The first declaration in diff --git a/compilers/openapi/internal/schema/hoist_internal_test.go b/compilers/openapi/internal/schema/hoist_internal_test.go index 5ac6ec60..a395762c 100644 --- a/compilers/openapi/internal/schema/hoist_internal_test.go +++ b/compilers/openapi/internal/schema/hoist_internal_test.go @@ -1,6 +1,7 @@ package schema import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -99,6 +100,29 @@ func TestRegisteredNode_ReportsAMissRatherThanDroppingIt(t *testing.T) { assert.Equal(t, id, td.Common().ID) } +// TestFormatTable_EveryRowDistinguishesTheBareType pins the invariant the +// `string/password` row broke (GitHub #579): a (type, format) key may only map +// to a primitive kind the pairing itself distinguishes, because a row mapping to +// the kind the bare type selects makes the pairing unreadable — the position +// resolves to the shared primitive and nothing anywhere records the format. A +// format whose kind does not differ must be absent from the table and hoist a +// node instead, as byte, password and an unknown format do. +func TestFormatTable_EveryRowDistinguishesTheBareType(t *testing.T) { + t.Parallel() + for key, prim := range formatTable { + typ, _, paired := strings.Cut(key, "/") + if !paired { + continue // the bare type's own row, which no format selects + } + bare, ok := formatTable[typ] + require.True(t, ok, "row %q names a type with no row of its own", key) + assert.NotEqual(t, bare, prim, + "row %q maps to the same kind (%s) as the bare type %q, so the pairing "+ + "reaches no field once the position resolves to the shared primitive; "+ + "a format whose kind does not differ must hoist a node instead", key, prim, typ) + } +} + // TestRegisteredNode_LeavesANodeStillBeingBuiltToItsBuilder pins the one miss // that is no bug: a revisit while the node's build is still running further up // the walk. That frame finishes the node once its build returns, so the diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index c3431c92..5856dd6f 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -719,10 +719,11 @@ func isKind(td ir.TypeDef, k ir.TypeKind) bool { } // formatHome reports whether the position's `format` reached a field. A Scalar -// hoisted for it carries it in Encoding; a (type, format) pairing formatTable -// knows selects a primitive, which carries the pairing in the primitive kind -// itself. With no type declared there was no pairing to make and nothing read it, -// and a node of any other kind has no Encoding field at all. +// hoisted for it carries it in Encoding, as byte, password and an unknown format +// each hoist one. A pairing formatTable knows still selects a primitive, and +// only pairings whose kind differs from the bare type's are listed, so the kind +// is the fact's home. With no type declared there was no pairing to read, and a +// node of any other kind has no Encoding field at all. func formatHome(td ir.TypeDef, s *oas3.Schema) bool { switch n := td.(type) { case *ir.Scalar: @@ -1105,7 +1106,13 @@ func FillPropertyDetail(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, } tgt := resolve.TargetSchema(js, ref) diags := fillPropertyDefault(c, p, ref, tgt, pointer) - if ref.GetFormat() == "password" { + // The referent is read beside the use site for the reason §14 reads one for + // any other declaration-scoped, use-binding fact (annotation.EffectiveVisibility, + // fillPropertyDefault): a $ref to a redaction schema states the redaction at + // the use, and the reference node's own format is empty. A use reaching the + // node through a wrapper is MarkSecretUses' to mark, once the node exists. + if ref.GetFormat() == redactionFormat || + (tgt != nil && tgt.GetFormat() == redactionFormat) { p.Secret = true } diags = append(diags, fillPropertyVisibility(c, p, ref, tgt, pointer)...) @@ -1206,13 +1213,13 @@ func fillPropertyDefault(c lowering.Ctx, p *ir.Property, ref, tgt *oas3.Schema, // fillPropertyConstraints attaches the property's scalar constraints, and the // co-declared bound keyword that reached none of them, to the property itself. -// ir.Property is the carrier at this position: a property's schema is read -// through CarriedRef, so it hoists no node of its own to hold either. +// ir.Property is the one home every property has: its schema is read through +// CarriedRef and hoists no node unless a format needs one, and even then the +// property keeps its own copy. // -// It reads ref alone and never the $ref target, which is why no tgt reaches it: -// bounds conjoin rather than override, so a referent's bound merged here under -// use-site precedence would publish the wider of the two as the whole truth. It -// stays on the node the reference points at instead (ir-design §12.2). +// It reads ref alone, never the $ref target: bounds conjoin rather than +// override, so merging a referent's bound here would publish the wider one as +// the whole truth. It stays on the referenced node (ir-design §12.2). func fillPropertyConstraints(c lowering.Ctx, p *ir.Property, ref *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { cons, diags := schemaConstraints(c, &p.Unmodeled, ref, pointer) if cons != nil { @@ -1222,17 +1229,15 @@ func fillPropertyConstraints(c lowering.Ctx, p *ir.Property, ref *oas3.Schema, p } // attachDeclaredAnnotations records every annotation s declares on the type -// node pointer owns, the one structural home they have (ir.TypeCommon). +// node pointer owns, the one structural home they have (ir.TypeCommon). It is +// their sole reader and runs above lower()'s dispatch, so a new lowering +// destination cannot forget them (GitHub #114). // -// It is the sole reader of declaration-scoped annotations and runs above -// lower()'s dispatch: every declaration reaches it through schemaBody or an -// alias fallback, so a new lowering destination cannot forget to read them -// (GitHub #114). -// -// A schema whose body reduced to a shared primitive owns no node; its -// annotations stay with the declaring property (FillPropertyDetail). Ownership -// is checked before conversion because callers cover a pointer in either order, -// and only the one that finds a node may emit conversion diagnostics. +// A body reduced to a shared primitive owns no node, and its annotations stay +// with the declaring property (FillPropertyDetail); one that hoisted a node (a +// Model, an Enum, a format's Scalar) keeps them there. Ownership is checked +// before conversion because callers cover a pointer in either order, and only +// the one that finds a node may emit conversion diagnostics. func attachDeclaredAnnotations(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, s *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { // No node here means a position that reduced to a shared primitive, or one // still being built further up this walk, whose builder attaches the same @@ -1339,9 +1344,20 @@ func buildTuple(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth i return t, diags } +// redactionFormat is the one OpenAPI format stating how a value is handled +// rather than what shape it has: the value must be redacted. No PrimKind can +// hold it, since a primitive is shared by every declaration of its type, so a +// position writing it hoists a Scalar carrying Sensitive and the verbatim +// spelling in Encoding.Name, as byte and an unknown format do. +// +// Property.Secret has two writers. FillPropertyDetail reads the format on the +// declaration, which the allOf fold OR-s across redeclarations; MarkSecretUses +// reads the lowered node, catching a use that reaches it through a wrapper. +const redactionFormat = "password" + // scalarTypeID maps a scalar (type, format) pair to a TypeID via formatTable: a -// known pairing interns the shared primitive; byte, an unknown format, and the -// 2020-12 content vocabulary each hoist a named Scalar wrapping the base +// known pairing interns the shared primitive; byte, password, an unknown format, +// and the 2020-12 content vocabulary each hoist a named Scalar wrapping the base // primitive with an Encoding, so what the position wrote never leaks onto the // shared primitive every other declaration of that type also resolves to. func scalarTypeID(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, st oas3.SchemaType, pointer jsontext.Pointer, hint string) (ir.TypeID, []ir.Diagnostic) { @@ -1349,6 +1365,11 @@ func scalarTypeID(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth if st == oas3.SchemaTypeString && format == "byte" { return hoistByteScalar(c, ts, anchors, depth, s, pointer, hint) } + // Keyed on the format rather than on the string type, so `format: password` + // on a non-string type and on a union variant states the same redaction. + if format == redactionFormat { + return hoistFormatScalar(c, ts, anchors, depth, s, baseForType(st), format, pointer, hint) + } key := string(st) if format != "" { key += "/" + format @@ -1390,12 +1411,14 @@ func hoistByteScalar(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, de return id, diags } -// hoistFormatScalar hoists a scalar over base carrying an unknown format as its -// encoding name, preserving the format losslessly. +// hoistFormatScalar hoists a scalar over base carrying a format no primitive +// holds as its encoding name, preserving the format losslessly. The redaction +// format also sets Sensitive, the field its fact lowers to. func hoistFormatScalar(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, base ir.PrimKind, format string, pointer jsontext.Pointer, hint string) (ir.TypeID, []ir.Diagnostic) { var diags []ir.Diagnostic id := internNode(c, ts, pointer, hint, func(common ir.TypeCommon) ir.TypeDef { baseRef := ts.PrimRef(base) + common.Sensitive = format == redactionFormat enc, encDiags := scalarEncoding(c, ts, anchors, depth, s, format, &common, pointer, hint) diags = append(diags, encDiags...) cons, consDiags := schemaConstraints(c, &common.Unmodeled, s, pointer) @@ -1934,7 +1957,13 @@ func (w *anchorWalk) charge(depth int) bool { } // formatTable maps a scalar "type" or "type/format" key to its IR primitive. -// Keys absent here (byte, and any unknown format) hoist a Scalar instead. +// Keys absent here (byte, password, and any unknown format) hoist a Scalar +// instead, and for one reason: the fact a missing key states is not a shape any +// PrimKind names. Every pairing that is present differs from its bare type in +// the *kind* it selects, which is the only form of the fact the shared primitive +// can hold — so a row mapping a pairing to the kind its bare type selects would +// make the pairing unreadable, and is absent rather than present +// (TestFormatTable_EveryRowDistinguishesTheBareType, GitHub #579). var formatTable = map[string]ir.PrimKind{ "string": ir.PrimString, "string/date": ir.PrimDate, @@ -1944,7 +1973,6 @@ var formatTable = map[string]ir.PrimKind{ "string/uri": ir.PrimURL, "string/date-time": ir.PrimDatetimeOffset, "string/binary": ir.PrimBytes, - "string/password": ir.PrimString, "integer": ir.PrimInteger, "integer/int32": ir.PrimInt32, "integer/int64": ir.PrimInt64, diff --git a/compilers/openapi/internal/schema/schema_test.go b/compilers/openapi/internal/schema/schema_test.go index 54d66a76..547bac91 100644 --- a/compilers/openapi/internal/schema/schema_test.go +++ b/compilers/openapi/internal/schema/schema_test.go @@ -5058,6 +5058,130 @@ func TestCoDeclaredBound_ASingleKeywordKeepsNothing(t *testing.T) { assert.Empty(t, propertyOf(t, doc, "Holder", "low").Unmodeled) } +// TestRedactionFormat_HoistsASensitiveScalar pins what a `format: password` +// schema position lowers to: a Scalar of its own, over the bare type's primitive, +// carrying Sensitive (the IR's whole-type redaction field) and the source token +// verbatim in Encoding.Name. The shared primitive cannot hold the fact — it is +// interned once per kind and reused by every declaration of that type — so the +// position hoists, exactly as byte and an unknown format do (GitHub #579). +func TestRedactionFormat_HoistsASensitiveScalar(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpec( + " Pw: {type: string, format: password}\n"+ + " Holder: {type: object, properties: {token: {type: string, format: password}}}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + pw, ok := typeByName(doc, "Pw").(*ir.Scalar) + require.True(t, ok, "a component writing the format owns a Scalar") + assert.True(t, pw.Sensitive, "whole-type redaction") + require.NotNil(t, pw.Encoding) + assert.Equal(t, "password", pw.Encoding.Name, "the source token verbatim") + require.NotNil(t, pw.Base) + assert.Equal(t, ir.TypeID("t/prim/string"), pw.Base.Target, "over the bare type's primitive") + + // The inline property position: the per-use carrier beside a node of its own. + token := propertyOf(t, doc, "Holder", "token") + assert.True(t, token.Secret, "the property carries the per-use form beside it") + assert.NotEqual(t, ir.TypeID("t/prim/string"), token.Type.Target, + "the position hoists rather than staying on the shared primitive") + sc, ok := doc.Types[token.Type.Target].(*ir.Scalar) + require.True(t, ok) + assert.True(t, sc.Sensitive) +} + +// TestRedactionFormat_PredicateIsTheFormatNotTheType pins that the arm keys on +// `format` rather than on `string`: the same format on a non-string type states +// the same redaction. +func TestRedactionFormat_PredicateIsTheFormatNotTheType(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpec( + " Secret: {type: integer, format: password}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + sc, ok := typeByName(doc, "Secret").(*ir.Scalar) + require.True(t, ok, "a non-string type writing the format still hoists") + assert.True(t, sc.Sensitive) + require.NotNil(t, sc.Encoding) + assert.Equal(t, "password", sc.Encoding.Name) + require.NotNil(t, sc.Base) + assert.Equal(t, ir.TypeID("t/prim/integer"), sc.Base.Target, "over the bare type's own primitive") +} + +// TestFillPropertyDetail_SecretFollowsTheReferent pins both sides of the $ref +// read: a property referencing a redaction schema is secret at the use, and one +// referencing a plain string component stays not secret. +func TestFillPropertyDetail_SecretFollowsTheReferent(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpec( + " Pw: {type: string, format: password}\n"+ + " Plain: {type: string}\n"+ + " Login: {type: object, properties: {secret: {$ref: '#/components/schemas/Pw'}, "+ + "plain: {$ref: '#/components/schemas/Plain'}}}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + assert.True(t, propertyOf(t, doc, "Login", "secret").Secret, + "the use site reads the referent's format") + assert.False(t, propertyOf(t, doc, "Login", "plain").Secret, + "a $ref to a plain component stays not secret") +} + +// TestRedactionFormat_SharedPrimitiveStaysNotSensitive pins invariant 3 for the +// new hoist: the fact lands on the position's own node and never on +// t/prim/string, which every other plain string declaration resolves to. +func TestRedactionFormat_SharedPrimitiveStaysNotSensitive(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpec( + " Pw: {type: string, format: password}\n"+ + " Plain: {type: string}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + assert.False(t, typeByName(doc, "Plain").Common().Sensitive) + shared, ok := doc.Types[ir.TypeID("t/prim/string")] + require.True(t, ok, "the shared string primitive is registered") + assert.False(t, shared.Common().Sensitive, "the shared primitive is never sensitive") +} + +// TestRedactionFormat_BesideContentEncoding pins the one hoist where the format +// is not Encoding.Name: contentEncoding names the encoding, so the format is kept +// verbatim under Unmodeled and reported, while Sensitive still holds. +func TestRedactionFormat_BesideContentEncoding(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpec( + " Encoded: {type: string, format: password, contentEncoding: base64, contentMediaType: text/plain}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + sc, ok := typeByName(doc, "Encoded").(*ir.Scalar) + require.True(t, ok) + assert.True(t, sc.Sensitive, "the redaction does not depend on which keyword names the encoding") + require.NotNil(t, sc.Encoding) + assert.Equal(t, "base64", sc.Encoding.Name, "contentEncoding wins Encoding.Name") + assert.Contains(t, sc.Unmodeled, "openapi:format", "the format is kept verbatim beside it") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Encoded/format"), + "the format beside contentEncoding is reported where it was written") +} + +// TestRedactionFormat_BesideEnumOrConstIsNotSensitive pins a recorded limit +// rather than a goal: an enum or a const lowers to an Enum or a Literal, which +// keeps the format verbatim and is not Sensitive (GitHub #607, #668). The +// property beside it still reads the format itself, so it stays secret. +func TestRedactionFormat_BesideEnumOrConstIsNotSensitive(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpec( + " Holder:\n type: object\n properties:\n"+ + " choice: {type: string, format: password, enum: [a]}\n"+ + " fixed: {type: string, format: password, const: abc}\n")) + openapitest.RequireNoErrorDiags(t, diags) + + for _, wire := range []string{"choice", "fixed"} { + p := propertyOf(t, doc, "Holder", wire) + assert.True(t, p.Secret, "%s: the property reads its own format", wire) + node, ok := doc.Types[p.Type.Target] + require.True(t, ok, wire) + assert.False(t, node.Common().Sensitive, "%s: no Scalar to carry it (#607, #668)", wire) + assert.Contains(t, node.Common().Unmodeled, "openapi:format", "%s: kept verbatim", wire) + } +} + // TestSchemaRef_ToAPointerEndingInSlashLowersWhatTheResolverBuilt pins that a // $ref ending in '/' lowers the object the resolver built for it, the parent it // reads (GitHub #770): neither the model nor the load phase's walk diff --git a/compilers/openapi/internal/schema/secret.go b/compilers/openapi/internal/schema/secret.go new file mode 100644 index 00000000..83c56d9d --- /dev/null +++ b/compilers/openapi/internal/schema/secret.go @@ -0,0 +1,105 @@ +package schema + +import ( + "reflect" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/lowering" + "github.com/dexpace/morphic/ir" +) + +// propertyType is what MarkSecretUses looks for in the walk. +var propertyType = reflect.TypeFor[ir.Property]() + +// MarkSecretUses sets Property.Secret on every property in doc whose type +// reaches a Sensitive node, so the per-use carrier agrees with the node however +// the use spells its reference: a nullable oneOf or anyOf, an allOf wrapping one +// $ref, or an alias component. It runs once lowering is done, because a $ref +// names a component's ID before that component is lowered. +// +// It only ever sets the flag. A property FillPropertyDetail or the allOf fold +// already marked stays marked. +func MarkSecretUses(c lowering.Ctx, doc *ir.Document) []ir.Diagnostic { + if doc == nil { + return nil + } + return markSecretUses(c, doc, doc.Types) +} + +// markSecretUses is MarkSecretUses over any root, reporting what it could not +// do rather than skipping it in silence: a walk the depth cap cut short, and a +// property it reached without being able to set it. +func markSecretUses(c lowering.Ctx, root any, reg ir.TypeRegistry) []ir.Diagnostic { + unaddressable := 0 + truncated := ir.WalkValues(root, ir.DocumentPath, func(v reflect.Value, _ string) bool { + if v.Type() != propertyType { + return true + } + p, ok := addressedProperty(v) + if !ok { + unaddressable++ + return true + } + if reachesSensitive(reg, p.Type) { + p.Secret = true + } + return true + }) + var diags []ir.Diagnostic + if truncated { + diags = append(diags, c.DiagAt(ir.SeverityError, diag.InternalInvariant, "", + "internal: the walk marking secret properties passed ir.MaxWalkDepth")) + } + if unaddressable > 0 { + diags = append(diags, c.DiagAt(ir.SeverityError, diag.InternalInvariant, "", + "internal: %d properties held by value could not be marked secret", unaddressable)) + } + return diags +} + +// addressedProperty returns the property v holds, when v is addressable. Every +// property in a Document is reached through a pointer or a slice, so a miss is +// a value the walk was handed by copy. +func addressedProperty(v reflect.Value) (*ir.Property, bool) { + if !v.CanAddr() { + return nil, false + } + p, ok := v.Addr().Interface().(*ir.Property) + return p, ok +} + +// reachesSensitive reports whether ref's target, or a node its Base chain +// reaches, is Sensitive. The chain is what a wrapper lowers to: an alias +// component is a Scalar over its target, and an allOf over one $ref a Model +// with that target as its Base. It is bounded by the registry's size, which no +// chain without a cycle can exceed. +func reachesSensitive(reg ir.TypeRegistry, ref ir.TypeRef) bool { + id := ref.Target + for range len(reg) + 1 { + td, ok := reg[id] + if !ok || ir.IsNilTypeDef(td) { + return false + } + if td.Common().Sensitive { + return true + } + base := baseOf(td) + if base == nil { + return false + } + id = base.Target + } + return false +} + +// baseOf returns the Base a node of a kind that has one points at, or nil. +func baseOf(td ir.TypeDef) *ir.TypeRef { + switch n := td.(type) { + case *ir.Scalar: + return n.Base + case *ir.Model: + return n.Base + default: + return nil + } +} diff --git a/compilers/openapi/internal/schema/secret_internal_test.go b/compilers/openapi/internal/schema/secret_internal_test.go new file mode 100644 index 00000000..1186ffe5 --- /dev/null +++ b/compilers/openapi/internal/schema/secret_internal_test.go @@ -0,0 +1,66 @@ +package schema + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/lowering" + "github.com/dexpace/morphic/ir" +) + +// TestMarkSecretUses_ANilDocumentIsANoOp pins the boundary guard. +func TestMarkSecretUses_ANilDocumentIsANoOp(t *testing.T) { + t.Parallel() + assert.Empty(t, MarkSecretUses(lowering.Ctx{}, nil)) +} + +// TestMarkSecretUses_ReportsAPropertyItCannotSet pins that a property reached +// by copy is reported rather than left unmarked in silence. +func TestMarkSecretUses_ReportsAPropertyItCannotSet(t *testing.T) { + t.Parallel() + reg := ir.TypeRegistry{"t/pw": &ir.Scalar{TypeCommon: ir.TypeCommon{ID: "t/pw", Sensitive: true}}} + diags := markSecretUses(lowering.Ctx{}, ir.Property{Type: ir.TypeRef{Target: "t/pw"}}, reg) + require.Len(t, diags, 1) + assert.Equal(t, diag.InternalInvariant, diags[0].Code) + assert.Contains(t, diags[0].Message, "held by value") +} + +// TestMarkSecretUses_ReportsATruncatedWalk pins that a walk the depth cap cut +// short is reported, since the properties past the cap went unread. +func TestMarkSecretUses_ReportsATruncatedWalk(t *testing.T) { + t.Parallel() + var deep any = ir.Property{} + for range ir.MaxWalkDepth { + deep = []any{deep} + } + diags := markSecretUses(lowering.Ctx{}, deep, ir.TypeRegistry{}) + require.Len(t, diags, 1) + assert.Equal(t, diag.InternalInvariant, diags[0].Code) + assert.Contains(t, diags[0].Message, "MaxWalkDepth") +} + +// TestReachesSensitive_StopsWhereTheChainEnds pins each way the Base chain +// ends: a Sensitive node, a target the registry lacks, a kind with no Base, +// and a cycle, which the registry-sized bound cuts. +func TestReachesSensitive_StopsWhereTheChainEnds(t *testing.T) { + t.Parallel() + ref := func(id ir.TypeID) *ir.TypeRef { return &ir.TypeRef{Target: id} } + reg := ir.TypeRegistry{ + "t/pw": &ir.Scalar{TypeCommon: ir.TypeCommon{ID: "t/pw", Sensitive: true}}, + "t/alias": &ir.Scalar{TypeCommon: ir.TypeCommon{ID: "t/alias"}, Base: ref("t/pw")}, + "t/wrap": &ir.Model{TypeCommon: ir.TypeCommon{ID: "t/wrap"}, Base: ref("t/alias")}, + "t/list": &ir.List{TypeCommon: ir.TypeCommon{ID: "t/list"}, Elem: ir.TypeRef{Target: "t/pw"}}, + "t/a": &ir.Scalar{TypeCommon: ir.TypeCommon{ID: "t/a"}, Base: ref("t/b")}, + "t/b": &ir.Scalar{TypeCommon: ir.TypeCommon{ID: "t/b"}, Base: ref("t/a")}, + "t/gone": &ir.Scalar{TypeCommon: ir.TypeCommon{ID: "t/gone"}, Base: ref("t/missing")}, + } + for id, want := range map[ir.TypeID]bool{ + "t/pw": true, "t/alias": true, "t/wrap": true, + "t/list": false, "t/a": false, "t/gone": false, "t/missing": false, + } { + assert.Equal(t, want, reachesSensitive(reg, ir.TypeRef{Target: id}), "%s", id) + } +} diff --git a/compilers/openapi/openapi.go b/compilers/openapi/openapi.go index c9bb6b31..76d7d7c7 100644 --- a/compilers/openapi/openapi.go +++ b/compilers/openapi/openapi.go @@ -181,6 +181,7 @@ func run(ctx context.Context, c lowering.Ctx, ts *compile.Types) (*ir.Document, if err := ctx.Err(); err != nil { return nil, acc.List(), err } + acc.AppendAll(schema.MarkSecretUses(c, out)) m, metaDiags := lowerMeta(c) out.Name, out.Version = m.Name, m.Version diff --git a/docs/ir-design.md b/docs/ir-design.md index 79eb78ba..c7482132 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -430,7 +430,7 @@ type TypeCommon struct { Docs Docs Tags []string // free-form labels (Smithy @tags, OpenAPI tag membership on types via policy); // tag metadata lives once in Document.TagDefs - Sensitive bool // whole-type redaction (Smithy @sensitive on shapes); Property.Secret is the per-use form + Sensitive bool // whole-type redaction (Smithy @sensitive on shapes, OpenAPI format:password); Property.Secret is the per-use form Access string // "" = public; "internal" = not part of the exported SDK surface // (protobuf editions export/local, TCGC @access(internal), Smithy @internal via policy) Deprecation *Deprecation @@ -2131,7 +2131,7 @@ How each format's distinctive concepts land in the IR (full details live with ea | Format | Lowering highlights | |---|---| -| **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref, a null-only branch set (every branch a bare `type: null`) → the same nullable `any` a bare `{type: null}` schema at that position lowers to, with the oneOf/anyOf itself kept verbatim Unmodeled (`degraded_lowering`) since no node built for it carries a branch set, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7); the same co-declared `oneOf`/`anyOf` beside a `$ref` at the same level, rather than a structural body, has nothing at that position to distribute the union across either, so it lowers to the alias over the `$ref` target with the union kept verbatim beside it (`degraded_lowering`), through the same keeper as the structural-body case; an `allOf` beside a `$ref` at the same level joins the alias's unhomed-keyword census the same way (`degraded_lowering`), since a `$ref` site elects no composition family for it to lose or win; competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default), and a subtype the mapping names under several keys — an alias tag — takes the smallest in byte order as its DiscriminatorValue, since the field holds one and a mapping's key order is not a property of the document, with every key kept in the base's Mapping and the election reported (`degraded-construct`, info); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; a declared `style` there is refused by the parser, while `explode` or `allowReserved` (which it does not check; GitHub #408) lowers as declared + `invalid-location-keyword` warning at the keyword's own pointer; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated; 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding//allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it first; response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook; callbacks → Callbacks; links → Response.Unmodeled and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; `contentEncoding`/`contentMediaType`/`contentSchema` → `Encoding` on the scalar the position lowers to — the last as `Encoding.Schema`, a TypeRef to the decoded shape hoisted at its own pointer — and all three → Unmodeled (`no_ir_home`) at a position with no `Encoding` field, per §4.7; `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution); `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a property redeclared across branches with a type, constraint keyword, default, description, examples, deprecation or `xml` the merge drops → the losing declaration's node verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, an info `degraded-construct` naming a detail held differently, and nullability intersecting rather than dropping where the targets agree; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags//x-*`, `openapi:tags//externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks//x-*`; on the content, `openapi:encoding//x-*` and `openapi:itemEncoding/x-*`, `` and `` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3 — constraints excepted, since bounds conjoin rather than override: each position keeps the ones it declared and none is copied to a use site (§12.2); a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred | +| **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref, a null-only branch set (every branch a bare `type: null`) → the same nullable `any` a bare `{type: null}` schema at that position lowers to, with the oneOf/anyOf itself kept verbatim Unmodeled (`degraded_lowering`) since no node built for it carries a branch set, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7); the same co-declared `oneOf`/`anyOf` beside a `$ref` at the same level, rather than a structural body, has nothing at that position to distribute the union across either, so it lowers to the alias over the `$ref` target with the union kept verbatim beside it (`degraded_lowering`), through the same keeper as the structural-body case; an `allOf` beside a `$ref` at the same level joins the alias's unhomed-keyword census the same way (`degraded_lowering`), since a `$ref` site elects no composition family for it to lose or win; competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default), and a subtype the mapping names under several keys — an alias tag — takes the smallest in byte order as its DiscriminatorValue, since the field holds one and a mapping's key order is not a property of the document, with every key kept in the base's Mapping and the election reported (`degraded-construct`, info); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; a declared `style` there is refused by the parser, while `explode` or `allowReserved` (which it does not check; GitHub #408) lowers as declared + `invalid-location-keyword` warning at the keyword's own pointer; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated; 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding//allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it first; response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook; callbacks → Callbacks; links → Response.Unmodeled and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; `contentEncoding`/`contentMediaType`/`contentSchema` → `Encoding` on the scalar the position lowers to — the last as `Encoding.Schema`, a TypeRef to the decoded shape hoisted at its own pointer — and all three → Unmodeled (`no_ir_home`) at a position with no `Encoding` field, per §4.7; `format: password` — a redaction request about a value's handling rather than its shape, so no `PrimKind` can hold it and the shared primitive must not — → a Scalar of its own at a scalar position, carrying `Base` (the bare type's primitive) + `Sensitive` + `Encoding.Name` verbatim `"password"` (beside a differing `contentEncoding`, `Encoding.Name` is that encoding and the format is kept verbatim in Unmodeled with a `degraded-construct` info), with `Property.Secret` set on every property or header whose type reaches that node, directly, through a nullable ref or along a `Base` chain (a parameter has no per-use field, so #578 owns that carrier); beside `enum` or `const` the position lowers to an Enum or a Literal instead, which keeps the format verbatim in Unmodeled and is not `Sensitive` (#607, #668); `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution); `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a property redeclared across branches with a type, constraint keyword, default, description, examples, deprecation or `xml` the merge drops → the losing declaration's node verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, an info `degraded-construct` naming a detail held differently, and nullability intersecting rather than dropping where the targets agree; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags//x-*`, `openapi:tags//externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks//x-*`; on the content, `openapi:encoding//x-*` and `openapi:itemEncoding/x-*`, `` and `` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3 — constraints excepted, since bounds conjoin rather than override: each position keeps the ones it declared and none is copied to a use site (§12.2); a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred | | **Swagger 2.0** | lifted to OpenAPI 3.x shape first (body/formData → Payload; host/basePath/schemes → Servers; consumes/produces → content types), then the OpenAPI lowering runs | | **TypeSpec** | consumed post-check (monomorphized, `isFinished`); template instances → TypeCommon.Instantiation incl. value args → TemplateArg; models → Model w/ Base + spread provenance → Mixins; scalars → Scalar chains, constructors in values → Value.Ctor; `@encode`/`@format` → Encoding triple; `@encodedName` → WireNameByFormat at property AND type level; unions w/ named variants → Union, `@discriminated` → Discriminator.PropertyName/Envelope/EnvelopeValueName; `| null` → Nullable; visibility classes (incl. custom, `@invisible` → Visibility.None) → Visibility, op overrides → ParameterVisibility/ReturnTypeVisibility; `@patch` implicitOptionality → HTTPBinding.PatchImplicitOptionality; interfaces → OperationGroups (versionable); `@overload` → OverloadOf; `@sharedRoute` → SharedRoute; `@service` → Service; versioning decorators incl. `@typeChangedFrom`/`@madeOptional`/`@madeRequired` and add/remove cycles → Availability timeline (on members/variants/params too); pagination decorators incl. prev/first/last links and header continuation tokens → Pagination PropPaths (In:"header"); Azure.Core `@pollingOperation`/`@finalOperation` → LongRunning; multipart w/ parts → Content.Encoding/PartEncoding, `Http.File` → FileInfo (content-type set, contents chain, filename location); streams/SSE → StreamDetail + Variant.Event (contentType, terminal); `@error` → UsageFlags.Error; `@example`/`@opExample` → Examples (Input/Output pairs); `@pattern` message → Constraints.PatternMessage; `@mediaTypeHint` → TypeCommon.MediaTypeHint; `never` members deleted + diagnostic per §4.8; TCGC client-shaping decorators (`@clientName`, `@access`, `@usage`, `@scope`, `@override`, …) → namespaced Unmodeled (`out_of_scope`) consumed by emitter policy, never IR semantics; values/consts incl. enum-member refs → Values channel | | **Smithy 2.0** | structures → Model, mixins → Mixins (non-structure mixins flattened — spec-sanctioned); `document` → Any; unions → WireTagged Union, member `@jsonName` → Variant.WireName; enum/intEnum → Enum (open by default); `@sparse` → element Nullable; traits: constraints → Constraints, `@paginated` → Pagination (declared), `@retryable` → ErrorCase.Retryable + Throttling, `@error` fault → ErrorCase.Fault, `@readonly` → Idempotency safe, `@idempotent`/`@idempotencyToken` → Idempotency, `@sensitive` → Sensitive/Secret, `@tags` → Tags, `@clientOptional`/`@input` → Property.ClientOptional (+InputOnly), `@addedDefault` → DefaultAdded, root-shape `@default` pushed down to properties w/ provenance; `@streaming` blob → StreamDetail (+`@requiresLength` → RequiresLength); event streams → StreamDetail.Events union + Property.EventHeader/EventPayload + Initial messages; service-level errors → Service.CommonErrors; protocol traits → Service.Protocols; service `rename`/`version` → Service.Renames/Version; resources → OperationGroup + ResourceInfo (identifiers, properties, lifecycle incl. put/@noReplace, instance vs collection ops); http traits → HTTPBinding incl. `@endpoint`/`@hostLabel` → HostPrefix/host location (additive binding), `@httpPrefixHeaders`/`@httpQueryParams` → Prefix bindings, `@httpResponseCode` → Response.StatusCodeProp, `@requestCompression` → Compression, `@httpChecksumRequired` → ChecksumRequired; `@auth` order → priority-ordered Auth, `@optionalAuth` → empty option; `@jsonName` → WireName; `@mediaType` → Encoding.MediaType; xml traits → XMLHints at type and property level; `@examples` → Examples (Input/Output/Error); waiters + rules-engine traits → verbatim Unmodeled (`out_of_scope`, §15); `smithy.api#Unit` → nil payload / shared empty Model for tag-only variants; other traits → namespaced Unmodeled | diff --git a/ir/typedef.go b/ir/typedef.go index ba764dd4..b4a89565 100644 --- a/ir/typedef.go +++ b/ir/typedef.go @@ -89,8 +89,8 @@ type TypeCommon struct { // Tags are free-form labels (Smithy @tags, OpenAPI tag membership via policy); // tag metadata lives once in Document.TagDefs. Tags []string `json:"tags,omitempty"` - // Sensitive requests whole-type redaction (Smithy @sensitive on shapes); - // Property.Secret is the per-use form. + // Sensitive requests whole-type redaction (Smithy @sensitive on shapes, + // OpenAPI `format: password`); Property.Secret is the per-use form. Sensitive bool `json:"sensitive"` // Access is "" for public or "internal" for a type outside the exported SDK // surface (protobuf editions export/local, TCGC @access(internal)). diff --git a/testdata/conformance/openapi/scalar-format.golden.json b/testdata/conformance/openapi/scalar-format.golden.json index e7c1b7a9..aaebca8b 100644 --- a/testdata/conformance/openapi/scalar-format.golden.json +++ b/testdata/conformance/openapi/scalar-format.golden.json @@ -67,6 +67,27 @@ "name": "hex-color" } }, + "t/anon/components/schemas/Holder/properties/token": { + "kind": "scalar", + "id": "t/anon/components/schemas/Holder/properties/token", + "name": { + "hint": "token" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Holder/properties/token" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "password" + } + }, "t/openapi/components/schemas/Holder": { "kind": "model", "id": "t/openapi/components/schemas/Holder", @@ -121,7 +142,7 @@ }, "wireName": "token", "type": { - "target": "t/prim/string", + "target": "t/anon/components/schemas/Holder/properties/token", "nullable": false }, "required": false, diff --git a/testdata/conformance/openapi/secret-format.golden.json b/testdata/conformance/openapi/secret-format.golden.json new file mode 100644 index 00000000..c9069fa4 --- /dev/null +++ b/testdata/conformance/openapi/secret-format.golden.json @@ -0,0 +1,812 @@ +{ + "irVersion": "0.7.0", + "idSpaces": { + "auth": [ + "openapi" + ], + "g": [ + "default", + "path-prefix", + "tags", + "webhooks" + ], + "op": [ + "openapi" + ], + "p": [ + "openapi" + ], + "s": [ + "openapi" + ], + "t": [ + "anon", + "composed", + "openapi" + ] + }, + "name": "SecretFormat", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "SecretFormat", + "canonical": "secret_format" + }, + "docs": {}, + "groups": [ + { + "id": "g/default", + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1login/post", + "name": { + "source": "login", + "canonical": "login" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "X-Token", + "canonical": "x_token" + }, + "type": { + "target": "t/anon/paths/~1login/post/parameters/0/schema", + "nullable": false + }, + "required": false, + "constraints": { + "minLength": 12, + "uniqueItems": false + }, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post/parameters/0" + } + }, + { + "name": { + "source": "api_key", + "canonical": "api_key" + }, + "type": { + "target": "t/openapi/components/schemas/Pw", + "nullable": false + }, + "required": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post/parameters/1" + } + } + ], + "request": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/paths/~1login/post/requestBody/content/application~1json/schema", + "nullable": false + } + } + ], + "required": false + }, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/paths/~1login/post/responses/200/content/application~1json/schema", + "nullable": false + } + } + ] + }, + "headers": [ + { + "id": "p/openapi/paths/~1login/post/responses/200/headers/X-Secret", + "name": { + "source": "X-Secret", + "canonical": "x_secret" + }, + "wireName": "X-Secret", + "type": { + "target": "t/anon/paths/~1login/post/responses/200/headers/X-Secret/schema", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post/responses/200/headers/X-Secret" + } + }, + { + "id": "p/openapi/paths/~1login/post/responses/200/headers/X-Maybe", + "name": { + "source": "X-Maybe", + "canonical": "x_maybe" + }, + "wireName": "X-Maybe", + "type": { + "target": "t/openapi/components/schemas/Pw", + "nullable": true + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post/responses/200/headers/X-Maybe" + } + } + ], + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "POST", + "uriTemplate": "/login", + "sharedRoute": false, + "paramBindings": [ + { + "param": "X-Token", + "location": "header", + "wireName": "X-Token", + "style": "simple", + "explode": false, + "allowReserved": false + }, + { + "param": "api_key", + "location": "query", + "wireName": "api_key", + "style": "form", + "explode": true, + "allowReserved": false + } + ], + "requestContentTypes": [ + "application/json" + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/components/schemas/Bag/additionalProperties": { + "kind": "scalar", + "id": "t/anon/components/schemas/Bag/additionalProperties", + "name": { + "hint": "bag_value" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bag/additionalProperties" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "password" + } + }, + "t/anon/components/schemas/Bag/patternProperties/^x-": { + "kind": "scalar", + "id": "t/anon/components/schemas/Bag/patternProperties/^x-", + "name": { + "hint": "bag_pattern" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bag/patternProperties/^x-" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "password" + } + }, + "t/anon/components/schemas/Login/properties/arr": { + "kind": "list", + "id": "t/anon/components/schemas/Login/properties/arr", + "name": { + "hint": "arr" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/arr" + }, + "elem": { + "target": "t/anon/components/schemas/Login/properties/arr/items", + "nullable": false + } + }, + "t/anon/components/schemas/Login/properties/arr/items": { + "kind": "scalar", + "id": "t/anon/components/schemas/Login/properties/arr/items", + "name": { + "hint": "arr_item" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/arr/items" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "constraints": { + "maxLength": 64, + "uniqueItems": false + }, + "encoding": { + "name": "password" + } + }, + "t/anon/components/schemas/Login/properties/inline": { + "kind": "scalar", + "id": "t/anon/components/schemas/Login/properties/inline", + "name": { + "hint": "inline" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/inline" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "password" + } + }, + "t/anon/components/schemas/Login/properties/wrapped": { + "kind": "model", + "id": "t/anon/components/schemas/Login/properties/wrapped", + "name": { + "hint": "wrapped" + }, + "anonymous": true, + "docs": { + "description": "annotated" + }, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/wrapped" + }, + "base": { + "target": "t/openapi/components/schemas/Pw", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/Pair/prefixItems/0": { + "kind": "scalar", + "id": "t/anon/components/schemas/Pair/prefixItems/0", + "name": { + "hint": "pair_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pair/prefixItems/0" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "password" + } + }, + "t/anon/paths/~1login/post/parameters/0/schema": { + "kind": "scalar", + "id": "t/anon/paths/~1login/post/parameters/0/schema", + "name": { + "hint": "x_token" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post/parameters/0/schema" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "constraints": { + "minLength": 12, + "uniqueItems": false + }, + "encoding": { + "name": "password" + } + }, + "t/anon/paths/~1login/post/requestBody/content/application~1json/schema": { + "kind": "scalar", + "id": "t/anon/paths/~1login/post/requestBody/content/application~1json/schema", + "name": { + "hint": "login_request" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post/requestBody/content/application~1json/schema" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "password" + } + }, + "t/anon/paths/~1login/post/responses/200/content/application~1json/schema": { + "kind": "scalar", + "id": "t/anon/paths/~1login/post/responses/200/content/application~1json/schema", + "name": { + "hint": "response" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post/responses/200/content/application~1json/schema" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "password" + } + }, + "t/anon/paths/~1login/post/responses/200/headers/X-Secret/schema": { + "kind": "scalar", + "id": "t/anon/paths/~1login/post/responses/200/headers/X-Secret/schema", + "name": { + "hint": "x_secret" + }, + "anonymous": true, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/paths/~1login/post/responses/200/headers/X-Secret/schema" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "password" + } + }, + "t/openapi/components/schemas/Bag": { + "kind": "model", + "id": "t/openapi/components/schemas/Bag", + "name": { + "source": "Bag", + "canonical": "bag" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bag" + }, + "additionalProps": { + "value": { + "target": "t/anon/components/schemas/Bag/additionalProperties", + "nullable": false + }, + "patterns": [ + { + "pattern": "^x-", + "value": { + "target": "t/anon/components/schemas/Bag/patternProperties/^x-", + "nullable": false + } + } + ] + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Chain": { + "kind": "scalar", + "id": "t/openapi/components/schemas/Chain", + "name": { + "source": "Chain", + "canonical": "chain" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Chain" + }, + "base": { + "target": "t/openapi/components/schemas/Pw", + "nullable": false + } + }, + "t/openapi/components/schemas/Login": { + "kind": "model", + "id": "t/openapi/components/schemas/Login", + "name": { + "source": "Login", + "canonical": "login" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Login/properties/viaRef", + "name": { + "source": "viaRef", + "canonical": "via_ref" + }, + "wireName": "viaRef", + "type": { + "target": "t/openapi/components/schemas/Pw", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/viaRef" + } + }, + { + "id": "p/openapi/components/schemas/Login/properties/inline", + "name": { + "source": "inline", + "canonical": "inline" + }, + "wireName": "inline", + "type": { + "target": "t/anon/components/schemas/Login/properties/inline", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/inline" + } + }, + { + "id": "p/openapi/components/schemas/Login/properties/nullable", + "name": { + "source": "nullable", + "canonical": "nullable" + }, + "wireName": "nullable", + "type": { + "target": "t/openapi/components/schemas/Pw", + "nullable": true + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/nullable" + } + }, + { + "id": "p/openapi/components/schemas/Login/properties/wrapped", + "name": { + "source": "wrapped", + "canonical": "wrapped" + }, + "wireName": "wrapped", + "type": { + "target": "t/anon/components/schemas/Login/properties/wrapped", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/wrapped" + } + }, + { + "id": "p/openapi/components/schemas/Login/properties/viaAlias", + "name": { + "source": "viaAlias", + "canonical": "via_alias" + }, + "wireName": "viaAlias", + "type": { + "target": "t/openapi/components/schemas/Chain", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/viaAlias" + } + }, + { + "id": "p/openapi/components/schemas/Login/properties/arr", + "name": { + "source": "arr", + "canonical": "arr" + }, + "wireName": "arr", + "type": { + "target": "t/anon/components/schemas/Login/properties/arr", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/arr" + } + }, + { + "id": "p/openapi/components/schemas/Login/properties/plain", + "name": { + "source": "plain", + "canonical": "plain" + }, + "wireName": "plain", + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Login/properties/plain" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Pair": { + "kind": "tuple", + "id": "t/openapi/components/schemas/Pair", + "name": { + "source": "Pair", + "canonical": "pair" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pair" + }, + "elems": [ + { + "target": "t/anon/components/schemas/Pair/prefixItems/0", + "nullable": false + } + ] + }, + "t/openapi/components/schemas/Pw": { + "kind": "scalar", + "id": "t/openapi/components/schemas/Pw", + "name": { + "source": "Pw", + "canonical": "pw" + }, + "anonymous": false, + "docs": {}, + "sensitive": true, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pw" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "constraints": { + "minLength": 8, + "uniqueItems": false + }, + "encoding": { + "name": "password" + } + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "secret-format.yaml", + "hash": "4c9f76f4d779535dfd001ac78e08cb9c2508384c99123ddbbbe202573d0bad02" + } + ] +} diff --git a/testdata/conformance/openapi/secret-format.yaml b/testdata/conformance/openapi/secret-format.yaml new file mode 100644 index 00000000..2fe52ed6 --- /dev/null +++ b/testdata/conformance/openapi/secret-format.yaml @@ -0,0 +1,64 @@ +# format: password states a value must be redacted — a fact about how the value +# is handled rather than what shape it has — so at a scalar position it hoists a +# Scalar carrying Sensitive and the verbatim spelling, with Property.Secret +# beside it where a property or a header carries one, however the use reaches +# the node. This fixture writes it once per position the compiler reaches, so a +# regression at any one of them reddens the golden and the assertion beside it +# (GitHub #579). Beside enum or const the format lowers differently (#607, #668). +openapi: 3.1.0 +info: {title: SecretFormat, version: "1.0.0"} +paths: + /login: + post: + operationId: login + parameters: + # A parameter schema, inline, and one reached through a $ref. + - {name: X-Token, in: header, schema: {type: string, format: password, minLength: 12}} + - {name: api_key, in: query, schema: {$ref: '#/components/schemas/Pw'}} + requestBody: + content: + application/json: + # The media-type schema itself: a hoisted position with no carrier. + schema: {type: string, format: password} + responses: + '200': + description: ok + headers: + X-Secret: + schema: {type: string, format: password} + # A nullable reference: the header's type is the component itself. + X-Maybe: + schema: {oneOf: [{$ref: '#/components/schemas/Pw'}, {type: 'null'}]} + content: + application/json: + schema: {type: string, format: password} +components: + schemas: + Pw: {type: string, format: password, minLength: 8} + Login: + type: object + properties: + viaRef: {$ref: '#/components/schemas/Pw'} + inline: {type: string, format: password} + # The wrappers a use reaches the redaction node through. + nullable: {oneOf: [{$ref: '#/components/schemas/Pw'}, {type: 'null'}]} + wrapped: {allOf: [{$ref: '#/components/schemas/Pw'}], description: annotated} + viaAlias: {$ref: '#/components/schemas/Chain'} + arr: + type: array + items: {type: string, format: password, maxLength: 64} + # The guard: a bare string must keep resolving to the shared primitive, + # which stays not sensitive. + plain: {type: string} + Bag: + type: object + patternProperties: + '^x-': {type: string, format: password} + additionalProperties: {type: string, format: password} + Pair: + type: array + prefixItems: + - {type: string, format: password} + # An alias declared after its first use, so no node exists when Login is + # lowered. + Chain: {$ref: '#/components/schemas/Pw'} diff --git a/testdata/conformance/openapi/unwitnessed.golden.txt b/testdata/conformance/openapi/unwitnessed.golden.txt index 338ac31d..28652576 100644 --- a/testdata/conformance/openapi/unwitnessed.golden.txt +++ b/testdata/conformance/openapi/unwitnessed.golden.txt @@ -205,7 +205,6 @@ TypeCommon.Availability TypeCommon.Instantiation TypeCommon.MediaTypeHint TypeCommon.Namespace -TypeCommon.Sensitive TypeCommon.Tags TypeCommon.Usage TypeCommon.WireNameByFormat