Skip to content

Commit c063087

Browse files
Copilotbaywet
andauthored
fix(library): root policy segment matching to prevent false positives in schema trees
Agent-Logs-Url: https://github.com/microsoft/OpenAPI.NET/sessions/ade1e55d-3bd6-4d67-baee-32d89b1ad9f9 Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
1 parent c0d89d1 commit c063087

File tree

2 files changed

+129
-8
lines changed

2 files changed

+129
-8
lines changed

src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,33 @@ internal static string BuildPath(string[] segments, int length)
143143
#endif
144144
}
145145

146+
/// <summary>
147+
/// HTTP method names used to identify operation-level structural positions in the path.
148+
/// </summary>
149+
internal static readonly HashSet<string> HttpMethods = new(StringComparer.OrdinalIgnoreCase)
150+
{
151+
"get", "post", "put", "delete", "patch", "options", "head", "trace",
152+
};
153+
154+
/// <summary>
155+
/// Returns <c>true</c> if any segment before <paramref name="exclusiveUpperBound"/> indicates
156+
/// that the path has descended into a JSON Schema property tree — meaning subsequent segment
157+
/// names are schema property/key names rather than OpenAPI structural keywords.
158+
/// </summary>
159+
internal static bool IsSchemaContext(string[] segments, int exclusiveUpperBound)
160+
{
161+
for (var i = 0; i < exclusiveUpperBound; i++)
162+
{
163+
if (string.Equals(segments[i], OpenApiConstants.Properties, StringComparison.Ordinal) ||
164+
string.Equals(segments[i], OpenApiConstants.AdditionalProperties, StringComparison.Ordinal))
165+
{
166+
return true;
167+
}
168+
}
169+
170+
return false;
171+
}
172+
146173
/// <summary>
147174
/// Copies segments into the target buffer, skipping a contiguous range.
148175
/// Returns the number of segments written.
@@ -166,8 +193,7 @@ internal static int CopySkipping(string[] source, int sourceLength, string[] tar
166193

167194
/// <summary>
168195
/// Returns null for paths that have no equivalent in OpenAPI v2 (Swagger).
169-
/// Covers: servers, webhooks, callbacks, links, requestBody (inline),
170-
/// encoding, and unsupported component types.
196+
/// Covers: servers, webhooks, callbacks, links, encoding, and unsupported component types.
171197
/// </summary>
172198
internal sealed class V2UnsupportedPathPolicy : IOpenApiPathRepresentationPolicy
173199
{
@@ -219,7 +245,14 @@ public bool TryGetVersionedPath(string[] segments, out string? result)
219245
{
220246
var segment = segments[i];
221247

222-
// servers, callbacks, links, requestBody at any nested level
248+
// Skip segments that are inside a schema property tree — they are property names,
249+
// not structural OpenAPI keywords.
250+
if (OpenApiPathHelper.IsSchemaContext(segments, i))
251+
{
252+
continue;
253+
}
254+
255+
// servers, callbacks, links at any nested level
223256
if (UnsupportedSegments.Contains(segment))
224257
{
225258
return true;
@@ -252,11 +285,13 @@ public bool TryGetVersionedPath(string[] segments, out string? result)
252285
{
253286
result = null;
254287

255-
// Find requestBody segment
288+
// Find requestBody immediately after an HTTP method — operation-level requestBody only.
289+
// This prevents false positives when "requestBody" is a schema property name.
256290
var requestBodyIndex = -1;
257-
for (var i = 0; i < segments.Length; i++)
291+
for (var i = 1; i < segments.Length; i++)
258292
{
259-
if (string.Equals(segments[i], OpenApiConstants.RequestBody, StringComparison.Ordinal))
293+
if (string.Equals(segments[i], OpenApiConstants.RequestBody, StringComparison.Ordinal) &&
294+
OpenApiPathHelper.HttpMethods.Contains(segments[i - 1]))
260295
{
261296
requestBodyIndex = i;
262297
break;
@@ -364,6 +399,7 @@ public bool TryGetVersionedPath(string[] segments, out string? result)
364399
// Content unwrapping: skip "content" and "{mediaType}" after "responses/{code}"
365400
if (string.Equals(segments[i], OpenApiConstants.Content, StringComparison.Ordinal) &&
366401
i >= 3 &&
402+
!OpenApiPathHelper.IsSchemaContext(segments, i) &&
367403
string.Equals(segments[i - 2], OpenApiConstants.Responses, StringComparison.Ordinal) &&
368404
i + 1 < segments.Length)
369405
{
@@ -374,6 +410,7 @@ public bool TryGetVersionedPath(string[] segments, out string? result)
374410
// Header schema unwrapping: skip "schema" after "headers/{name}"
375411
if (string.Equals(segments[i], OpenApiConstants.Schema, StringComparison.Ordinal) &&
376412
i >= 3 &&
413+
!OpenApiPathHelper.IsSchemaContext(segments, i) &&
377414
string.Equals(segments[i - 2], OpenApiConstants.Headers, StringComparison.Ordinal))
378415
{
379416
continue;
@@ -397,11 +434,15 @@ public bool TryGetVersionedPath(string[] segments, out string? result)
397434
{
398435
result = null;
399436

400-
// Find: responses / {code} / content / {mediaType}
437+
// Find: {method} / responses / {code} / content / {mediaType}
438+
// Require the HTTP method immediately before "responses" to anchor this to a real
439+
// operation response, preventing false positives from schema properties named "responses"
440+
// or from extension paths that happen to match the pattern.
401441
var contentIndex = -1;
402-
for (var i = 0; i < segments.Length - 3; i++)
442+
for (var i = 1; i < segments.Length - 3; i++)
403443
{
404444
if (string.Equals(segments[i], OpenApiConstants.Responses, StringComparison.Ordinal) &&
445+
OpenApiPathHelper.HttpMethods.Contains(segments[i - 1]) &&
405446
string.Equals(segments[i + 2], OpenApiConstants.Content, StringComparison.Ordinal))
406447
{
407448
contentIndex = i + 2;
@@ -433,10 +474,12 @@ public bool TryGetVersionedPath(string[] segments, out string? result)
433474
result = null;
434475

435476
// Find: headers / {name} / schema
477+
// Guard against schema property trees where "headers" is just a property name.
436478
var schemaIndex = -1;
437479
for (var i = 0; i < segments.Length - 2; i++)
438480
{
439481
if (string.Equals(segments[i], OpenApiConstants.Headers, StringComparison.Ordinal) &&
482+
!OpenApiPathHelper.IsSchemaContext(segments, i) &&
440483
string.Equals(segments[i + 2], OpenApiConstants.Schema, StringComparison.Ordinal))
441484
{
442485
schemaIndex = i + 2;

test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,84 @@ public void Issue2806_ResponseContentSchemaPath_ConvertedToV2()
280280

281281
#endregion
282282

283+
#region V2 rooting / false-positive guards
284+
285+
// V2ResponseContentUnwrappingPolicy false positives: "responses" and "content" are schema property names.
286+
[Theory]
287+
[InlineData("#/paths/~1items/get/responses/200/schema/properties/responses/200/content/application~1json")]
288+
[InlineData("#/x-ms-sneaky-extension/additionalReference/responses/data/content/0")]
289+
[InlineData("#/paths/~1items/get/parameters/0/schema/properties/responses/200/content/application~1json/schema")]
290+
public void V2_ResponsesContentInsideSchemaOrExtension_NotUnwrapped(string path)
291+
{
292+
var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0);
293+
Assert.Equal(path, result);
294+
}
295+
296+
// V2RequestBodyToBodyParameterPolicy false positives: "requestBody" is a schema property name.
297+
[Theory]
298+
[InlineData("#/paths/~1items/get/responses/200/schema/properties/requestBody/content/application~1json/schema")]
299+
[InlineData("#/paths/~1items/get/responses/200/schema/properties/requestBody/description")]
300+
public void V2_RequestBodyInsideSchemaProperties_NotConvertedToBodyParameter(string path)
301+
{
302+
var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0);
303+
Assert.Equal(path, result);
304+
}
305+
306+
// V2HeaderSchemaUnwrappingPolicy false positives: "headers" is a schema property name.
307+
[Theory]
308+
[InlineData("#/paths/~1items/get/responses/200/schema/properties/headers/X-My-Header/schema/type")]
309+
[InlineData("#/paths/~1items/get/responses/200/schema/properties/headers/X-My-Header/schema")]
310+
public void V2_HeaderSchemaInsideSchemaProperties_NotUnwrapped(string path)
311+
{
312+
var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0);
313+
Assert.Equal(path, result);
314+
}
315+
316+
// V2UnsupportedPathPolicy false positives: unsupported keywords are schema property names.
317+
[Theory]
318+
[InlineData("#/paths/~1items/get/responses/200/schema/properties/servers/0")]
319+
[InlineData("#/paths/~1items/get/responses/200/schema/properties/callbacks/onEvent")]
320+
[InlineData("#/paths/~1items/get/responses/200/schema/properties/links/item")]
321+
public void V2_UnsupportedKeywordsInsideSchemaProperties_NotReturnedNull(string path)
322+
{
323+
var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0);
324+
Assert.Equal(path, result);
325+
}
326+
327+
// V2ComponentRenamePolicy false positives: "content" after a property named "responses" in a schema.
328+
[Fact]
329+
public void V2_ComponentSchemaWithResponsesPropertyContainingContent_ContentNotUnwrapped()
330+
{
331+
// components/schemas/MyModel has a property "responses" whose value has a "content" property.
332+
// The inline content-unwrapping should NOT fire here.
333+
var path = "#/components/schemas/MyModel/properties/responses/properties/content/properties/foo";
334+
var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0);
335+
// Only the component rename applies (#/components/schemas → #/definitions).
336+
Assert.Equal("#/definitions/MyModel/properties/responses/properties/content/properties/foo", result);
337+
}
338+
339+
[Fact]
340+
public void V2_ComponentSchemaWithRequestBodyPropertyInSchema_RequestBodyNotConvertedToBodyParameter()
341+
{
342+
// A component schema with a property named "requestBody" — component rename applies but
343+
// requestBody → body parameter conversion should NOT fire.
344+
var path = "#/components/schemas/Pet/properties/requestBody/content/application~1json";
345+
var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0);
346+
Assert.Equal("#/definitions/Pet/properties/requestBody/content/application~1json", result);
347+
}
348+
349+
[Fact]
350+
public void V2_ComponentSchemaWithHeadersPropertyInSchema_HeaderSchemaNotUnwrapped()
351+
{
352+
// A component schema with a property named "headers" — component rename applies but
353+
// headers/schema unwrapping should NOT fire.
354+
var path = "#/components/schemas/Pet/properties/headers/Content-Type/schema";
355+
var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0);
356+
Assert.Equal("#/definitions/Pet/properties/headers/Content-Type/schema", result);
357+
}
358+
359+
#endregion
360+
283361
#region OpenApiValidatorError.GetVersionedPointer
284362

285363
[Fact]

0 commit comments

Comments
 (0)