Skip to content

Commit 994bdf0

Browse files
committed
Fix use of precedence in endpoint routing DFA (#20801)
* Fix use of precedence in endpoint routing DFA Fixes: #18677 Fixes: #16579 This is a change to how sorting is use when building endpoint routing's graph of nodes that is eventually transformed into the route table. There were bugs in how this was done that made it incompatible in some niche scenarios both with previous implementations and how we describe the features in the abstract. There are a wide array of cases that might have been impacted by this bug because routing is a pattern language. Generally the bugs will involve a catch-all, and some something that changes ordering of templates. Issue #18677 has the simplest repro for this, the following templates would not behave as expected: ``` a/{*b} {a}/{b} ``` One would expect any URL Path starting with `/a` to match the first route, but that's not what happens. --- The change supports an opt-out via the following AppContext switch: ``` Microsoft.AspNetCore.Routing.Use30CatchAllBehavior ``` Set to true to enable the buggy behavior for compatibility. --- The root cause of this bug was an issue in how the algorithm used to be build the DFA was designed. Specifically that it uses a BFS to build the graph, and it uses an up-front one-time sort of endpoints in order to drive that BFS. The building of the graph has the expectation that at each level, we will process **all** literal segments (`/a`) and then **all** parameter segments (`/{a}`) and then **all** catch-all segments (`/{*a}`). Routing defines a concept called *precedence* that defines the *conceptual* order in while segments types are ordered. So there are two problems: - We sort based on criteria other than precedence (#16579) - We can't rely on a one-time sort, it needs to be done at each level (#18677) --- The fix is to repeat the sort operation at each level and use precedence as the only key for sorting (as dictated by the graph building algo). We do a sort of the matches of each node *after* building the precedence-based part of the DFA, based on the full sorting criteria, to maintain compatibility. * Add test
1 parent e81033e commit 994bdf0

File tree

5 files changed

+529
-22
lines changed

5 files changed

+529
-22
lines changed

src/Http/Routing/ref/Microsoft.AspNetCore.Routing.Manual.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ internal static partial class FastPathTokenizer
198198

199199
internal partial class DfaMatcherBuilder : Microsoft.AspNetCore.Routing.Matching.MatcherBuilder
200200
{
201-
public DfaMatcherBuilder(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.AspNetCore.Routing.ParameterPolicyFactory parameterPolicyFactory, Microsoft.AspNetCore.Routing.Matching.EndpointSelector selector, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Routing.MatcherPolicy> policies) { }
201+
public DfaMatcherBuilder(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.AspNetCore.Routing.ParameterPolicyFactory parameterPolicyFactory, Microsoft.AspNetCore.Routing.Matching.EndpointSelector selector, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Routing.MatcherPolicy> policies, bool? useLegacy30Behavior = null) { }
202202
public override void AddEndpoint(Microsoft.AspNetCore.Routing.RouteEndpoint endpoint) { }
203203
public override Microsoft.AspNetCore.Routing.Matching.Matcher Build() { throw null; }
204204
public Microsoft.AspNetCore.Routing.Matching.DfaNode BuildDfaTree(bool includeLabel = false) { throw null; }

src/Http/Routing/src/Matching/DfaMatcherBuilder.cs

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
55
using System.Collections.Generic;
66
using System.Linq;
77
using Microsoft.AspNetCore.Http;
88
using Microsoft.AspNetCore.Routing.Patterns;
9+
using Microsoft.AspNetCore.Routing.Template;
910
using Microsoft.Extensions.Logging;
1011

1112
namespace Microsoft.AspNetCore.Routing.Matching
@@ -20,6 +21,7 @@ internal class DfaMatcherBuilder : MatcherBuilder
2021
private readonly IEndpointSelectorPolicy[] _endpointSelectorPolicies;
2122
private readonly INodeBuilderPolicy[] _nodeBuilders;
2223
private readonly EndpointComparer _comparer;
24+
private readonly bool _useLegacy30Behavior;
2325

2426
// These collections are reused when building candidates
2527
private readonly Dictionary<string, int> _assignments;
@@ -34,12 +36,26 @@ public DfaMatcherBuilder(
3436
ILoggerFactory loggerFactory,
3537
ParameterPolicyFactory parameterPolicyFactory,
3638
EndpointSelector selector,
37-
IEnumerable<MatcherPolicy> policies)
39+
IEnumerable<MatcherPolicy> policies,
40+
bool? useLegacy30Behavior = null)
3841
{
3942
_loggerFactory = loggerFactory;
4043
_parameterPolicyFactory = parameterPolicyFactory;
4144
_selector = selector;
4245

46+
if (useLegacy30Behavior.HasValue)
47+
{
48+
_useLegacy30Behavior = useLegacy30Behavior.Value;
49+
}
50+
else if (AppContext.TryGetSwitch("Microsoft.AspNetCore.Routing.Use30CatchAllBehavior", out var enabled))
51+
{
52+
_useLegacy30Behavior = enabled;
53+
}
54+
else
55+
{
56+
_useLegacy30Behavior = false;
57+
}
58+
4359
var (nodeBuilderPolicies, endpointComparerPolicies, endpointSelectorPolicies) = ExtractPolicies(policies.OrderBy(p => p.Order));
4460
_endpointSelectorPolicies = endpointSelectorPolicies;
4561
_nodeBuilders = nodeBuilderPolicies;
@@ -52,24 +68,30 @@ public DfaMatcherBuilder(
5268
_constraints = new List<KeyValuePair<string, IRouteConstraint>>();
5369
}
5470

71+
// Used in tests
72+
internal EndpointComparer Comparer => _comparer;
73+
5574
public override void AddEndpoint(RouteEndpoint endpoint)
5675
{
5776
_endpoints.Add(endpoint);
5877
}
5978

6079
public DfaNode BuildDfaTree(bool includeLabel = false)
6180
{
62-
// We build the tree by doing a BFS over the list of entries. This is important
63-
// because a 'parameter' node can also traverse the same paths that literal nodes
64-
// traverse. This means that we need to order the entries first, or else we will
65-
// miss possible edges in the DFA.
66-
_endpoints.Sort(_comparer);
81+
if (_useLegacy30Behavior)
82+
{
83+
// In 3.0 we did a global sort of the endpoints up front. This was a bug, because we actually want
84+
// do do the sort at each level of the tree based on precedence.
85+
//
86+
// _useLegacy30Behavior enables opt-out via an AppContext switch.
87+
_endpoints.Sort(_comparer);
88+
}
6789

6890
// Since we're doing a BFS we will process each 'level' of the tree in stages
6991
// this list will hold the set of items we need to process at the current
7092
// stage.
71-
var work = new List<(RouteEndpoint endpoint, List<DfaNode> parents)>(_endpoints.Count);
72-
List<(RouteEndpoint endpoint, List<DfaNode> parents)> previousWork = null;
93+
var work = new List<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)>(_endpoints.Count);
94+
List<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)> previousWork = null;
7395

7496
var root = new DfaNode() { PathDepth = 0, Label = includeLabel ? "/" : null };
7597

@@ -79,21 +101,37 @@ public DfaNode BuildDfaTree(bool includeLabel = false)
79101
for (var i = 0; i < _endpoints.Count; i++)
80102
{
81103
var endpoint = _endpoints[i];
82-
maxDepth = Math.Max(maxDepth, endpoint.RoutePattern.PathSegments.Count);
104+
var precedenceDigit = GetPrecedenceDigitAtDepth(endpoint, depth: 0);
105+
work.Add((endpoint, precedenceDigit, new List<DfaNode>() { root, }));
83106

84-
work.Add((endpoint, new List<DfaNode>() { root, }));
107+
maxDepth = Math.Max(maxDepth, endpoint.RoutePattern.PathSegments.Count);
85108
}
109+
86110
var workCount = work.Count;
87111

112+
// Sort work at each level by *PRECEDENCE OF THE CURRENT SEGMENT*.
113+
//
114+
// We build the tree by doing a BFS over the list of entries. This is important
115+
// because a 'parameter' node can also traverse the same paths that literal nodes
116+
// traverse. This means that we need to order the entries first, or else we will
117+
// miss possible edges in the DFA.
118+
//
119+
// We'll sort the matches again later using the *real* comparer once building the
120+
// precedence part of the DFA is over.
121+
var precedenceDigitComparer = Comparer<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)>.Create((x, y) =>
122+
{
123+
return x.precedenceDigit.CompareTo(y.precedenceDigit);
124+
});
125+
88126
// Now we process the entries a level at a time.
89127
for (var depth = 0; depth <= maxDepth; depth++)
90128
{
91129
// As we process items, collect the next set of items.
92-
List<(RouteEndpoint endpoint, List<DfaNode> parents)> nextWork;
130+
List<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)> nextWork;
93131
var nextWorkCount = 0;
94132
if (previousWork == null)
95133
{
96-
nextWork = new List<(RouteEndpoint endpoint, List<DfaNode> parents)>();
134+
nextWork = new List<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)>();
97135
}
98136
else
99137
{
@@ -102,9 +140,17 @@ public DfaNode BuildDfaTree(bool includeLabel = false)
102140
nextWork = previousWork;
103141
}
104142

143+
if (!_useLegacy30Behavior)
144+
{
145+
// The fix for the 3.0 sorting behavior bug.
146+
147+
// See comments on precedenceDigitComparer
148+
work.Sort(0, workCount, precedenceDigitComparer);
149+
}
150+
105151
for (var i = 0; i < workCount; i++)
106152
{
107-
var (endpoint, parents) = work[i];
153+
var (endpoint, _, parents) = work[i];
108154

109155
if (!HasAdditionalRequiredSegments(endpoint, depth))
110156
{
@@ -122,15 +168,17 @@ public DfaNode BuildDfaTree(bool includeLabel = false)
122168
nextParents = nextWork[nextWorkCount].parents;
123169
nextParents.Clear();
124170

125-
nextWork[nextWorkCount] = (endpoint, nextParents);
171+
var nextPrecedenceDigit = GetPrecedenceDigitAtDepth(endpoint, depth + 1);
172+
nextWork[nextWorkCount] = (endpoint, nextPrecedenceDigit, nextParents);
126173
}
127174
else
128175
{
129176
nextParents = new List<DfaNode>();
130177

131178
// Add to the next set of work now so the list will be reused
132179
// even if there are no parents
133-
nextWork.Add((endpoint, nextParents));
180+
var nextPrecedenceDigit = GetPrecedenceDigitAtDepth(endpoint, depth + 1);
181+
nextWork.Add((endpoint, nextPrecedenceDigit, nextParents));
134182
}
135183

136184
var segment = GetCurrentSegment(endpoint, depth);
@@ -281,7 +329,7 @@ private static void AddLiteralNode(bool includeLabel, List<DfaNode> nextParents,
281329
nextParents.Add(next);
282330
}
283331

284-
private RoutePatternPathSegment GetCurrentSegment(RouteEndpoint endpoint, int depth)
332+
private static RoutePatternPathSegment GetCurrentSegment(RouteEndpoint endpoint, int depth)
285333
{
286334
if (depth < endpoint.RoutePattern.PathSegments.Count)
287335
{
@@ -302,6 +350,18 @@ private RoutePatternPathSegment GetCurrentSegment(RouteEndpoint endpoint, int de
302350
return null;
303351
}
304352

353+
private static int GetPrecedenceDigitAtDepth(RouteEndpoint endpoint, int depth)
354+
{
355+
var segment = GetCurrentSegment(endpoint, depth);
356+
if (segment is null)
357+
{
358+
// Treat "no segment" as high priority. it won't effect the algorithm, but we need to define a sort-order.
359+
return 0;
360+
}
361+
362+
return RoutePrecedence.ComputeInboundPrecedenceDigit(endpoint.RoutePattern, segment);
363+
}
364+
305365
public override Matcher Build()
306366
{
307367
#if DEBUG
@@ -670,6 +730,10 @@ private void ApplyPolicies(DfaNode node)
670730
return;
671731
}
672732

733+
// We're done with the precedence based work. Sort the endpoints
734+
// before applying policies for simplicity in policy-related code.
735+
node.Matches.Sort(_comparer);
736+
673737
// Start with the current node as the root.
674738
var work = new List<DfaNode>() { node, };
675739
List<DfaNode> previousWork = null;

src/Http/Routing/src/Template/RoutePrecedence.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ private static int ComputeInboundPrecedenceDigit(TemplateSegment segment)
219219
// see description on ComputeInboundPrecedenceDigit(TemplateSegment segment)
220220
//
221221
// With a RoutePattern, parameters with a required value are treated as a literal segment
222-
private static int ComputeInboundPrecedenceDigit(RoutePattern routePattern, RoutePatternPathSegment pathSegment)
222+
internal static int ComputeInboundPrecedenceDigit(RoutePattern routePattern, RoutePatternPathSegment pathSegment)
223223
{
224224
if (pathSegment.Parts.Count > 1)
225225
{
@@ -260,4 +260,4 @@ private static int ComputeInboundPrecedenceDigit(RoutePattern routePattern, Rout
260260
}
261261
}
262262
}
263-
}
263+
}

0 commit comments

Comments
 (0)