Skip to content
This repository was archived by the owner on Nov 1, 2023. It is now read-only.

Commit d34138d

Browse files
authored
Improve area/iteration path validation (#3489)
* Add more comprehensive checks and better error messages to area/iteration path validation * Join invalid chars with space instead of comma * Make tree path validation more testable * Add error code for invalid ADO project in config * Write unit tests for tree path validation * Format tree path unit tests * Merge escape character and control character checks and clarify error message
1 parent 896329d commit d34138d

File tree

4 files changed

+238
-16
lines changed

4 files changed

+238
-16
lines changed

src/ApiService/ApiService/OneFuzzTypes/Enums.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public enum ErrorCode {
4949
ADO_VALIDATION_MISSING_PAT_SCOPES = 492,
5050
ADO_WORKITEM_PROCESSING_DISABLED = 494,
5151
ADO_VALIDATION_INVALID_PATH = 495,
52+
ADO_VALIDATION_INVALID_PROJECT = 496,
5253
// NB: if you update this enum, also update enums.py
5354
}
5455

src/ApiService/ApiService/onefuzzlib/notifications/Ado.cs

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,30 +89,97 @@ private static bool IsTransient(Exception e) {
8989
return errorCodes.Any(errorStr.Contains);
9090
}
9191

92-
private static async Async.Task<OneFuzzResultVoid> ValidatePath(string project, string path, TreeStructureGroup structureGroup, WorkItemTrackingHttpClient client) {
93-
var pathType = (structureGroup == TreeStructureGroup.Areas) ? "Area" : "Iteration";
94-
var pathParts = path.Split('\\');
95-
if (!string.Equals(pathParts[0], project, StringComparison.OrdinalIgnoreCase)) {
92+
public static OneFuzzResultVoid ValidateTreePath(IEnumerable<string> path, WorkItemClassificationNode? root) {
93+
if (root is null) {
94+
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PROJECT, new string[] {
95+
$"Path \"{string.Join('\\', path)}\" is invalid. The specified ADO project doesn't exist.",
96+
"Double check the 'project' field in your ADO config.",
97+
});
98+
}
99+
100+
string treeNodeTypeName;
101+
switch (root.StructureType) {
102+
case TreeNodeStructureType.Area:
103+
treeNodeTypeName = "Area";
104+
break;
105+
case TreeNodeStructureType.Iteration:
106+
treeNodeTypeName = "Iteration";
107+
break;
108+
default:
109+
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
110+
$"Path root \"{root.Name}\" is an unsupported type. Expected Area or Iteration but got {root.StructureType}.",
111+
});
112+
}
113+
114+
// Validate path based on
115+
// https://learn.microsoft.com/en-us/azure/devops/organizations/settings/about-areas-iterations?view=azure-devops#naming-restrictions
116+
var maxNodeLength = 255;
117+
var maxDepth = 13;
118+
// Invalid characters from the link above plus the escape sequences (since they have backslashes and produce confusingly formatted errors if not caught here)
119+
var invalidChars = new char[] { '/', ':', '*', '?', '"', '<', '>', '|', ';', '#', '$', '*', '{', '}', ',', '+', '=', '[', ']' };
120+
121+
// Ensure that none of the path parts are too long
122+
var erroneous = path.FirstOrDefault(part => part.Length > maxNodeLength);
123+
if (erroneous != null) {
124+
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
125+
$"{treeNodeTypeName} Path \"{string.Join('\\', path)}\" is invalid. \"{erroneous}\" is too long. It must be less than {maxNodeLength} characters.",
126+
"Learn more about naming restrictions here: https://learn.microsoft.com/en-us/azure/devops/organizations/settings/about-areas-iterations?view=azure-devops#naming-restrictions"
127+
});
128+
}
129+
130+
// Ensure that none of the path parts contain invalid characters
131+
erroneous = path.FirstOrDefault(part => invalidChars.Any(part.Contains));
132+
if (erroneous != null) {
96133
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
97-
$"Path \"{path}\" is invalid. It must start with the project name, \"{project}\".",
98-
$"Example: \"{project}\\{path}\".",
134+
$"{treeNodeTypeName} Path \"{string.Join('\\', path)}\" is invalid. \"{erroneous}\" contains an invalid character ({string.Join(" ", invalidChars)}).",
135+
"Make sure that the path is separated by backslashes (\\) and not forward slashes (/).",
136+
"Learn more about naming restrictions here: https://learn.microsoft.com/en-us/azure/devops/organizations/settings/about-areas-iterations?view=azure-devops#naming-restrictions"
99137
});
100138
}
101139

102-
var current = await client.GetClassificationNodeAsync(project, structureGroup, depth: pathParts.Length - 1);
103-
if (current == null) {
140+
// Ensure no unicode control characters
141+
erroneous = path.FirstOrDefault(part => part.Any(ch => char.IsControl(ch)));
142+
if (erroneous != null) {
104143
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
105-
$"{pathType} Path \"{path}\" is invalid. \"{project}\" is not a valid project.",
144+
// More about control codes and their range here: https://en.wikipedia.org/wiki/Unicode_control_characters
145+
$"{treeNodeTypeName} Path \"{string.Join('\\', path)}\" is invalid. \"{erroneous}\" contains a unicode control character (\\u0000 - \\u001F or \\u007F - \\u009F).",
146+
"Make sure that you're path doesn't contain any escape characters (\\0 \\a \\b \\f \\n \\r \\t \\v).",
147+
"Learn more about naming restrictions here: https://learn.microsoft.com/en-us/azure/devops/organizations/settings/about-areas-iterations?view=azure-devops#naming-restrictions"
106148
});
107149
}
108150

109-
foreach (var part in pathParts.Skip(1)) {
151+
// Ensure that there aren't too many path parts
152+
if (path.Count() > maxDepth) {
153+
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
154+
$"{treeNodeTypeName} Path \"{string.Join('\\', path)}\" is invalid. It must be less than {maxDepth} levels deep.",
155+
"Learn more about naming restrictions here: https://learn.microsoft.com/en-us/azure/devops/organizations/settings/about-areas-iterations?view=azure-devops#naming-restrictions"
156+
});
157+
}
158+
159+
160+
// Path should always start with the project name ADO expects an absolute path
161+
if (!string.Equals(path.First(), root.Name, StringComparison.OrdinalIgnoreCase)) {
162+
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
163+
$"{treeNodeTypeName} Path \"{string.Join('\\', path)}\" is invalid. It must start with the project name, \"{root.Name}\".",
164+
$"Example: \"{root.Name}\\{path}\".",
165+
});
166+
}
167+
168+
// Validate that each part of the path is a valid child of the previous part
169+
var current = root;
170+
foreach (var part in path.Skip(1)) {
110171
var child = current.Children?.FirstOrDefault(x => string.Equals(x.Name, part, StringComparison.OrdinalIgnoreCase));
111172
if (child == null) {
112-
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
113-
$"{pathType} Path \"{path}\" is invalid. \"{part}\" is not a valid child of \"{current.Name}\".",
114-
$"Valid children of \"{current.Name}\" are: [{string.Join(',', current.Children?.Select(x => $"\"{x.Name}\"") ?? new List<string>())}].",
115-
});
173+
if (current.Children is null || !current.Children.Any()) {
174+
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
175+
$"{treeNodeTypeName} Path \"{string.Join('\\', path)}\" is invalid. \"{current.Name}\" has no children.",
176+
});
177+
} else {
178+
return OneFuzzResultVoid.Error(ErrorCode.ADO_VALIDATION_INVALID_PATH, new string[] {
179+
$"{treeNodeTypeName} Path \"{string.Join('\\', path)}\" is invalid. \"{part}\" is not a valid child of \"{current.Name}\".",
180+
$"Valid children of \"{current.Name}\" are: [{string.Join(',', current.Children?.Select(x => $"\"{x.Name}\"") ?? new List<string>())}].",
181+
});
182+
}
116183
}
117184

118185
current = child;
@@ -195,14 +262,19 @@ await policy.ExecuteAsync(async () => {
195262

196263
try {
197264
// Validate AreaPath and IterationPath exist
265+
// This also validates that the config.Project exists
198266
if (config.AdoFields.TryGetValue("System.AreaPath", out var areaPathString)) {
199-
var validateAreaPath = await ValidatePath(config.Project, areaPathString, TreeStructureGroup.Areas, witClient);
267+
var path = areaPathString.Split('\\');
268+
var root = await witClient.GetClassificationNodeAsync(config.Project, TreeStructureGroup.Areas, depth: path.Length - 1);
269+
var validateAreaPath = ValidateTreePath(path, root);
200270
if (!validateAreaPath.IsOk) {
201271
return validateAreaPath;
202272
}
203273
}
204274
if (config.AdoFields.TryGetValue("System.IterationPath", out var iterationPathString)) {
205-
var validateIterationPath = await ValidatePath(config.Project, iterationPathString, TreeStructureGroup.Iterations, witClient);
275+
var path = iterationPathString.Split('\\');
276+
var root = await witClient.GetClassificationNodeAsync(config.Project, TreeStructureGroup.Iterations, depth: path.Length - 1);
277+
var validateIterationPath = ValidateTreePath(path, root);
206278
if (!validateIterationPath.IsOk) {
207279
return validateIterationPath;
208280
}

src/ApiService/Tests/TreePathTests.cs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using Microsoft.OneFuzz.Service;
4+
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
5+
using Xunit;
6+
7+
namespace Tests;
8+
9+
// This might be a good candidate for property based testing
10+
// https://fscheck.github.io/FsCheck//QuickStart.html
11+
public class TreePathTests {
12+
private static IEnumerable<string> SplitPath(string path) {
13+
return path.Split('\\');
14+
}
15+
16+
private static WorkItemClassificationNode MockTreeNode(IEnumerable<string> path, TreeNodeStructureType structureType) {
17+
var root = new WorkItemClassificationNode() {
18+
Name = path.First(),
19+
StructureType = structureType
20+
};
21+
22+
var current = root;
23+
foreach (var segment in path.Skip(1)) {
24+
var child = new WorkItemClassificationNode {
25+
Name = segment
26+
};
27+
current.Children = new[] { child };
28+
current = child;
29+
}
30+
31+
return root;
32+
}
33+
34+
35+
[Fact]
36+
public void TestValidPath() {
37+
var path = SplitPath(@"project\foo\bar\baz");
38+
var root = MockTreeNode(path, TreeNodeStructureType.Area);
39+
40+
var result = Ado.ValidateTreePath(path, root);
41+
42+
Assert.True(result.IsOk);
43+
}
44+
45+
[Fact]
46+
public void TestNullTreeNode() { // A null tree node indicates an invalid ADO project was used in the query
47+
var path = SplitPath(@"project\foo\bar\baz");
48+
49+
var result = Ado.ValidateTreePath(path, null);
50+
51+
Assert.False(result.IsOk);
52+
Assert.Equal(ErrorCode.ADO_VALIDATION_INVALID_PROJECT, result.ErrorV!.Code);
53+
Assert.Contains("ADO project doesn't exist", result.ErrorV!.Errors![0]);
54+
}
55+
56+
[Fact]
57+
public void TestPathPartTooLong() {
58+
var path = SplitPath(@"project\foo\barbazquxquuxcorgegraultgarplywaldofredplughxyzzythudbarbazquxquuxcorgegraultgarplywaldofredplughxyzzythudbarbazquxquuxcorgegraultgarplywaldofredplughxyzzythudbarbazquxquuxcorgegraultgarplywaldofredplughxyzzythudbarbazquxquuxcorgegraultgarplywaldofredplughxyzzythud\baz");
59+
var root = MockTreeNode(path, TreeNodeStructureType.Iteration);
60+
61+
var result = Ado.ValidateTreePath(path, root);
62+
63+
Assert.False(result.IsOk);
64+
Assert.Equal(ErrorCode.ADO_VALIDATION_INVALID_PATH, result.ErrorV!.Code);
65+
Assert.Contains("too long", result.ErrorV!.Errors![0]);
66+
}
67+
68+
[Theory]
69+
[InlineData("project/foo/bar/baz")]
70+
[InlineData("project\\foo:\\bar\\baz")]
71+
public void TestPathContainsInvalidChar(string invalidPath) {
72+
var path = SplitPath(invalidPath);
73+
var treePath = SplitPath(@"project\foo\bar\baz");
74+
var root = MockTreeNode(treePath, TreeNodeStructureType.Area);
75+
76+
var result = Ado.ValidateTreePath(path, root);
77+
78+
Assert.False(result.IsOk);
79+
Assert.Equal(ErrorCode.ADO_VALIDATION_INVALID_PATH, result.ErrorV!.Code);
80+
Assert.Contains("invalid character", result.ErrorV!.Errors![0]);
81+
}
82+
83+
[Theory]
84+
[InlineData("project\\foo\\ba\u0005r\\baz")]
85+
[InlineData("project\\\nfoo\\bar\\baz")]
86+
public void TestPathContainsUnicodeControlChar(string invalidPath) {
87+
var path = SplitPath(invalidPath);
88+
var treePath = SplitPath(@"project\foo\bar\baz");
89+
var root = MockTreeNode(treePath, TreeNodeStructureType.Area);
90+
91+
var result = Ado.ValidateTreePath(path, root);
92+
93+
Assert.False(result.IsOk);
94+
Assert.Equal(ErrorCode.ADO_VALIDATION_INVALID_PATH, result.ErrorV!.Code);
95+
Assert.Contains("unicode control character", result.ErrorV!.Errors![0]);
96+
}
97+
98+
[Fact]
99+
public void TestPathTooDeep() {
100+
var path = SplitPath(@"project\foo\bar\baz\qux\quux\corge\grault\garply\waldo\fred\plugh\xyzzy\thud");
101+
var root = MockTreeNode(path, TreeNodeStructureType.Area);
102+
103+
var result = Ado.ValidateTreePath(path, root);
104+
105+
Assert.False(result.IsOk);
106+
Assert.Equal(ErrorCode.ADO_VALIDATION_INVALID_PATH, result.ErrorV!.Code);
107+
Assert.Contains("levels deep", result.ErrorV!.Errors![0]);
108+
}
109+
110+
[Fact]
111+
public void TestPathWithoutProjectName() {
112+
var path = SplitPath(@"foo\bar\baz");
113+
var treePath = SplitPath(@"project\foo\bar\baz");
114+
var root = MockTreeNode(treePath, TreeNodeStructureType.Iteration);
115+
116+
var result = Ado.ValidateTreePath(path, root);
117+
118+
Assert.False(result.IsOk);
119+
Assert.Equal(ErrorCode.ADO_VALIDATION_INVALID_PATH, result.ErrorV!.Code);
120+
Assert.Contains("start with the project name", result.ErrorV!.Errors![0]);
121+
}
122+
123+
[Fact]
124+
public void TestPathWithInvalidChild() {
125+
var path = SplitPath(@"project\foo\baz");
126+
var treePath = SplitPath(@"project\foo\bar");
127+
var root = MockTreeNode(treePath, TreeNodeStructureType.Iteration);
128+
129+
var result = Ado.ValidateTreePath(path, root);
130+
131+
Assert.False(result.IsOk);
132+
Assert.Equal(ErrorCode.ADO_VALIDATION_INVALID_PATH, result.ErrorV!.Code);
133+
Assert.Contains("not a valid child", result.ErrorV!.Errors![0]);
134+
}
135+
136+
[Fact]
137+
public void TestPathWithExtraChild() {
138+
var path = SplitPath(@"project\foo\bar\baz");
139+
var treePath = SplitPath(@"project\foo\bar");
140+
var root = MockTreeNode(treePath, TreeNodeStructureType.Iteration);
141+
142+
var result = Ado.ValidateTreePath(path, root);
143+
144+
Assert.False(result.IsOk);
145+
Assert.Equal(ErrorCode.ADO_VALIDATION_INVALID_PATH, result.ErrorV!.Code);
146+
Assert.Contains("has no children", result.ErrorV!.Errors![0]);
147+
}
148+
}

src/pytypes/onefuzztypes/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ class ErrorCode(Enum):
303303
ADO_VALIDATION_UNEXPECTED_ERROR = 491
304304
ADO_VALIDATION_MISSING_PAT_SCOPES = 492
305305
ADO_VALIDATION_INVALID_PATH = 495
306+
ADO_VALIDATION_INVALID_PROJECT = 496
306307
# NB: if you update this enum, also update Enums.cs
307308

308309

0 commit comments

Comments
 (0)