Skip to content

Commit 730e1ad

Browse files
committed
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 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.
1 parent bf57130 commit 730e1ad

File tree

4 files changed

+342
-21
lines changed

4 files changed

+342
-21
lines changed

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

Lines changed: 49 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
@@ -59,17 +60,11 @@ public override void AddEndpoint(RouteEndpoint endpoint)
5960

6061
public DfaNode BuildDfaTree(bool includeLabel = false)
6162
{
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);
67-
6863
// Since we're doing a BFS we will process each 'level' of the tree in stages
6964
// this list will hold the set of items we need to process at the current
7065
// stage.
71-
var work = new List<(RouteEndpoint endpoint, List<DfaNode> parents)>(_endpoints.Count);
72-
List<(RouteEndpoint endpoint, List<DfaNode> parents)> previousWork = null;
66+
var work = new List<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)>(_endpoints.Count);
67+
List<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)> previousWork = null;
7368

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

@@ -79,21 +74,37 @@ public DfaNode BuildDfaTree(bool includeLabel = false)
7974
for (var i = 0; i < _endpoints.Count; i++)
8075
{
8176
var endpoint = _endpoints[i];
82-
maxDepth = Math.Max(maxDepth, endpoint.RoutePattern.PathSegments.Count);
77+
var precedenceDigit = GetPrecedenceDigitAtDepth(endpoint, depth: 0);
78+
work.Add((endpoint, precedenceDigit, new List<DfaNode>() { root, }));
8379

84-
work.Add((endpoint, new List<DfaNode>() { root, }));
80+
maxDepth = Math.Max(maxDepth, endpoint.RoutePattern.PathSegments.Count);
8581
}
82+
8683
var workCount = work.Count;
8784

85+
// Sort work at each level by *PRECEDENCE OF THE CURRENT SEGMENT*.
86+
//
87+
// We build the tree by doing a BFS over the list of entries. This is important
88+
// because a 'parameter' node can also traverse the same paths that literal nodes
89+
// traverse. This means that we need to order the entries first, or else we will
90+
// miss possible edges in the DFA.
91+
//
92+
// We'll sort the matches again later using the *real* comparer once building the
93+
// precedence part of the DFA is over.
94+
var precedenceDigitComparer = Comparer<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)>.Create((x, y) =>
95+
{
96+
return x.precedenceDigit.CompareTo(y.precedenceDigit);
97+
});
98+
8899
// Now we process the entries a level at a time.
89100
for (var depth = 0; depth <= maxDepth; depth++)
90101
{
91102
// As we process items, collect the next set of items.
92-
List<(RouteEndpoint endpoint, List<DfaNode> parents)> nextWork;
103+
List<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)> nextWork;
93104
var nextWorkCount = 0;
94105
if (previousWork == null)
95106
{
96-
nextWork = new List<(RouteEndpoint endpoint, List<DfaNode> parents)>();
107+
nextWork = new List<(RouteEndpoint endpoint, int precedenceDigit, List<DfaNode> parents)>();
97108
}
98109
else
99110
{
@@ -102,9 +113,12 @@ public DfaNode BuildDfaTree(bool includeLabel = false)
102113
nextWork = previousWork;
103114
}
104115

116+
// See comments on precedenceDigitComparer
117+
work.Sort(0, workCount, precedenceDigitComparer);
118+
105119
for (var i = 0; i < workCount; i++)
106120
{
107-
var (endpoint, parents) = work[i];
121+
var (endpoint, _, parents) = work[i];
108122

109123
if (!HasAdditionalRequiredSegments(endpoint, depth))
110124
{
@@ -122,15 +136,17 @@ public DfaNode BuildDfaTree(bool includeLabel = false)
122136
nextParents = nextWork[nextWorkCount].parents;
123137
nextParents.Clear();
124138

125-
nextWork[nextWorkCount] = (endpoint, nextParents);
139+
var nextPrecedenceDigit = GetPrecedenceDigitAtDepth(endpoint, depth + 1);
140+
nextWork[nextWorkCount] = (endpoint, nextPrecedenceDigit, nextParents);
126141
}
127142
else
128143
{
129144
nextParents = new List<DfaNode>();
130145

131146
// Add to the next set of work now so the list will be reused
132147
// even if there are no parents
133-
nextWork.Add((endpoint, nextParents));
148+
var nextPrecedenceDigit = GetPrecedenceDigitAtDepth(endpoint, depth + 1);
149+
nextWork.Add((endpoint, nextPrecedenceDigit, nextParents));
134150
}
135151

136152
var segment = GetCurrentSegment(endpoint, depth);
@@ -281,7 +297,7 @@ private static void AddLiteralNode(bool includeLabel, List<DfaNode> nextParents,
281297
nextParents.Add(next);
282298
}
283299

284-
private RoutePatternPathSegment GetCurrentSegment(RouteEndpoint endpoint, int depth)
300+
private static RoutePatternPathSegment GetCurrentSegment(RouteEndpoint endpoint, int depth)
285301
{
286302
if (depth < endpoint.RoutePattern.PathSegments.Count)
287303
{
@@ -302,6 +318,18 @@ private RoutePatternPathSegment GetCurrentSegment(RouteEndpoint endpoint, int de
302318
return null;
303319
}
304320

321+
private static int GetPrecedenceDigitAtDepth(RouteEndpoint endpoint, int depth)
322+
{
323+
var segment = GetCurrentSegment(endpoint, depth);
324+
if (segment is null)
325+
{
326+
// Treat "no segment" as high priority. it won't effect the algorithm, but we need to define a sort-order.
327+
return 0;
328+
}
329+
330+
return RoutePrecedence.ComputeInboundPrecedenceDigit(endpoint.RoutePattern, segment);
331+
}
332+
305333
public override Matcher Build()
306334
{
307335
#if DEBUG
@@ -670,6 +698,10 @@ private void ApplyPolicies(DfaNode node)
670698
return;
671699
}
672700

701+
// We're done with the precedence based work. Sort the endpoints
702+
// before applying policies for simplicity in policy-related code.
703+
node.Matches.Sort(_comparer);
704+
673705
// Start with the current node as the root.
674706
var work = new List<DfaNode>() { node, };
675707
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+
}

src/Http/Routing/test/UnitTests/Matching/DfaMatcherBuilderTest.cs

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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;
@@ -459,6 +459,200 @@ public void BuildDfaTree_MultipleEndpoint_ParameterAndCatchAll()
459459
Assert.Same(catchAll, catchAll.CatchAll);
460460
}
461461

462+
// Regression test for https://github.com/dotnet/aspnetcore/issues/16579
463+
[Fact]
464+
public void BuildDfaTree_MultipleEndpoint_ParameterAndCatchAll_OnSameNode_Order1()
465+
{
466+
// Arrange
467+
var builder = CreateDfaMatcherBuilder();
468+
469+
var endpoint1 = CreateEndpoint("a/{b}", order: 0);
470+
builder.AddEndpoint(endpoint1);
471+
472+
var endpoint2 = CreateEndpoint("a/{*b}", order: 1);
473+
builder.AddEndpoint(endpoint2);
474+
475+
// Act
476+
var root = builder.BuildDfaTree();
477+
478+
// Assert
479+
Assert.Null(root.Matches);
480+
Assert.Null(root.Parameters);
481+
482+
var next = Assert.Single(root.Literals);
483+
Assert.Equal("a", next.Key);
484+
485+
var a = next.Value;
486+
Assert.Same(endpoint2, Assert.Single(a.Matches));
487+
Assert.Null(a.Literals);
488+
489+
var b = a.Parameters;
490+
Assert.Collection(
491+
b.Matches,
492+
e => Assert.Same(endpoint1, e),
493+
e => Assert.Same(endpoint2, e));
494+
Assert.Null(b.Literals);
495+
Assert.Null(b.Parameters);
496+
Assert.NotNull(b.CatchAll);
497+
498+
var catchAll = b.CatchAll;
499+
Assert.Same(endpoint2, Assert.Single(catchAll.Matches));
500+
Assert.Null(catchAll.Literals);
501+
Assert.Same(catchAll, catchAll.Parameters);
502+
Assert.Same(catchAll, catchAll.CatchAll);
503+
}
504+
505+
// Regression test for https://github.com/dotnet/aspnetcore/issues/16579
506+
[Fact]
507+
public void BuildDfaTree_MultipleEndpoint_ParameterAndCatchAll_OnSameNode_Order2()
508+
{
509+
// Arrange
510+
var builder = CreateDfaMatcherBuilder();
511+
512+
var endpoint1 = CreateEndpoint("a/{*b}", order: 0);
513+
builder.AddEndpoint(endpoint1);
514+
515+
var endpoint2 = CreateEndpoint("a/{b}", order: 1);
516+
builder.AddEndpoint(endpoint2);
517+
518+
// Act
519+
var root = builder.BuildDfaTree();
520+
521+
// Assert
522+
Assert.Null(root.Matches);
523+
Assert.Null(root.Parameters);
524+
525+
var next = Assert.Single(root.Literals);
526+
Assert.Equal("a", next.Key);
527+
528+
var a = next.Value;
529+
Assert.Same(endpoint1, Assert.Single(a.Matches));
530+
Assert.Null(a.Literals);
531+
532+
var b = a.Parameters;
533+
Assert.Collection(
534+
b.Matches,
535+
e => Assert.Same(endpoint1, e),
536+
e => Assert.Same(endpoint2, e));
537+
Assert.Null(b.Literals);
538+
Assert.Null(b.Parameters);
539+
Assert.NotNull(b.CatchAll);
540+
541+
var catchAll = b.CatchAll;
542+
Assert.Same(endpoint1, Assert.Single(catchAll.Matches));
543+
Assert.Null(catchAll.Literals);
544+
Assert.Same(catchAll, catchAll.Parameters);
545+
Assert.Same(catchAll, catchAll.CatchAll);
546+
}
547+
548+
// Regression test for https://github.com/dotnet/aspnetcore/issues/18677
549+
[Fact]
550+
public void BuildDfaTree_MultipleEndpoint_CatchAllWithHigherPrecedenceThanParameter_Order1()
551+
{
552+
// Arrange
553+
var builder = CreateDfaMatcherBuilder();
554+
555+
var endpoint1 = CreateEndpoint("{a}/{b}", order: 0);
556+
builder.AddEndpoint(endpoint1);
557+
558+
var endpoint2 = CreateEndpoint("a/{*b}", order: 1);
559+
builder.AddEndpoint(endpoint2);
560+
561+
// Act
562+
var root = builder.BuildDfaTree();
563+
564+
// Assert
565+
Assert.Null(root.Matches);
566+
567+
var next = Assert.Single(root.Literals);
568+
Assert.Equal("a", next.Key);
569+
570+
var a1 = next.Value;
571+
Assert.Same(endpoint2, Assert.Single(a1.Matches));
572+
Assert.Null(a1.Literals);
573+
574+
var b1 = a1.Parameters;
575+
Assert.Collection(
576+
b1.Matches,
577+
e => Assert.Same(endpoint1, e),
578+
e => Assert.Same(endpoint2, e));
579+
Assert.Null(b1.Literals);
580+
Assert.Null(b1.Parameters);
581+
Assert.NotNull(b1.CatchAll);
582+
583+
var catchAll1 = b1.CatchAll;
584+
Assert.Same(endpoint2, Assert.Single(catchAll1.Matches));
585+
Assert.Null(catchAll1.Literals);
586+
Assert.Same(catchAll1, catchAll1.Parameters);
587+
Assert.Same(catchAll1, catchAll1.CatchAll);
588+
589+
var a2 = root.Parameters;
590+
Assert.Null(a2.Matches);
591+
Assert.Null(a2.Literals);
592+
593+
var b2 = a2.Parameters;
594+
Assert.Collection(
595+
b2.Matches,
596+
e => Assert.Same(endpoint1, e));
597+
Assert.Null(b2.Literals);
598+
Assert.Null(b2.Parameters);
599+
Assert.Null(b2.CatchAll);
600+
}
601+
602+
// Regression test for https://github.com/dotnet/aspnetcore/issues/18677
603+
[Fact]
604+
public void BuildDfaTree_MultipleEndpoint_CatchAllWithHigherPrecedenceThanParameter_Order2()
605+
{
606+
// Arrange
607+
var builder = CreateDfaMatcherBuilder();
608+
609+
var endpoint1 = CreateEndpoint("a/{*b}", order: 0);
610+
builder.AddEndpoint(endpoint1);
611+
612+
var endpoint2 = CreateEndpoint("{a}/{b}", order: 1);
613+
builder.AddEndpoint(endpoint2);
614+
615+
// Act
616+
var root = builder.BuildDfaTree();
617+
618+
// Assert
619+
Assert.Null(root.Matches);
620+
621+
var next = Assert.Single(root.Literals);
622+
Assert.Equal("a", next.Key);
623+
624+
var a1 = next.Value;
625+
Assert.Same(endpoint1, Assert.Single(a1.Matches));
626+
Assert.Null(a1.Literals);
627+
628+
var b1 = a1.Parameters;
629+
Assert.Collection(
630+
b1.Matches,
631+
e => Assert.Same(endpoint1, e),
632+
e => Assert.Same(endpoint2, e));
633+
Assert.Null(b1.Literals);
634+
Assert.Null(b1.Parameters);
635+
Assert.NotNull(b1.CatchAll);
636+
637+
var catchAll1 = b1.CatchAll;
638+
Assert.Same(endpoint1, Assert.Single(catchAll1.Matches));
639+
Assert.Null(catchAll1.Literals);
640+
Assert.Same(catchAll1, catchAll1.Parameters);
641+
Assert.Same(catchAll1, catchAll1.CatchAll);
642+
643+
var a2 = root.Parameters;
644+
Assert.Null(a2.Matches);
645+
Assert.Null(a2.Literals);
646+
647+
var b2 = a2.Parameters;
648+
Assert.Collection(
649+
b2.Matches,
650+
e => Assert.Same(endpoint2, e));
651+
Assert.Null(b2.Literals);
652+
Assert.Null(b2.Parameters);
653+
Assert.Null(b2.CatchAll);
654+
}
655+
462656
[Fact]
463657
public void BuildDfaTree_WithPolicies()
464658
{
@@ -1281,9 +1475,10 @@ private static RouteEndpoint CreateEndpoint(
12811475
object defaults = null,
12821476
object constraints = null,
12831477
object requiredValues = null,
1478+
int order = 0,
12841479
params object[] metadata)
12851480
{
1286-
return EndpointFactory.CreateRouteEndpoint(template, defaults, constraints, requiredValues, metadata: metadata);
1481+
return EndpointFactory.CreateRouteEndpoint(template, defaults, constraints, requiredValues, order: order, metadata: metadata);
12871482
}
12881483

12891484
private class TestMetadata1

0 commit comments

Comments
 (0)