Repository navigation
Expand file tree
/
Copy pathPortfolioValidator.cs
More file actions
318 lines (285 loc) · 15.7 KB
/
Copy pathPortfolioValidator.cs
File metadata and controls
318 lines (285 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace ProCoderJi.Api.Features.Portfolio;
public enum PortfolioMode { Draft, Publish }
/// <summary>A problem at a field path such as <c>projects[0].link</c>.</summary>
public readonly record struct PortfolioProblem(string Path, string Code)
{
/// <summary>The stable <c>path:code</c> form used by the shared fixtures.</summary>
public override string ToString() => $"{Path}:{Code}";
}
/// <summary>
/// Trusted portfolio validation. The frontend implements the same rules in
/// <c>src/features/portfolio/validation.ts</c>, and both run against
/// <c>shared/portfolio/fixtures.json</c>. Keep the two in step: when a check changes, add a
/// fixture first. The rules avoid URI parsers and culture-aware string functions on purpose,
/// because those behave differently in .NET and JavaScript.
/// </summary>
public sealed class PortfolioValidator
{
private static readonly string[] TopLevelFields =
["theme", "name", "headline", "about", "skills", "projects", "education", "experience", "achievements", "links"];
private readonly PortfolioRules rules;
private readonly Regex control;
private readonly Regex htmlLike;
private readonly Regex linkForbidden;
private readonly Regex host;
private readonly Regex email;
private readonly Regex username;
private readonly HashSet<string> reserved;
private readonly HashSet<string> multiline;
public PortfolioValidator(PortfolioRules rules)
{
this.rules = rules;
control = Pattern(rules.Text.ControlCharacters);
htmlLike = Pattern(rules.Text.HtmlLike);
linkForbidden = Pattern(rules.Link.ForbiddenCharacters);
host = Anchored(rules.Link.HostPattern);
email = Anchored(rules.EmailPattern);
username = Anchored(rules.Username.Pattern);
reserved = new HashSet<string>(rules.Username.Reserved, StringComparer.Ordinal);
multiline = new HashSet<string>(rules.MultilineFields, StringComparer.Ordinal);
}
public static PortfolioValidator CreateDefault() => new(PortfolioRules.LoadDefault());
// .NET's "$" also matches before a final newline; "\z" does not, so "riya\n" cannot slip through.
private static Regex Anchored(string pattern) => new(pattern + "\\z", RegexOptions.CultureInvariant);
private static Regex Pattern(string pattern) => new(pattern, RegexOptions.CultureInvariant);
public IReadOnlyList<PortfolioProblem> ValidateUsername(string value) =>
ValidateUsername(JsonValue.Create(value));
/// <summary>Checks a username for the public address <c>/u/<username></c>.</summary>
public IReadOnlyList<PortfolioProblem> ValidateUsername(JsonNode? node)
{
const string path = "username";
var rule = rules.Username;
var read = new List<PortfolioProblem>();
if (!Read(read, path, node, out var value)) return read;
if (IsBlank(value)) return [new(path, "required")];
if (value.Length < rule.MinLength) return [new(path, "too-short")];
if (value.Length > rule.MaxLength) return [new(path, "too-long")];
if (!username.IsMatch(value)) return [new(path, "invalid-format")];
if (reserved.Contains(value)) return [new(path, "reserved")];
var squashed = value.Replace("-", "", StringComparison.Ordinal);
if (rule.BlockedWords.Any(word => squashed.Contains(word, StringComparison.Ordinal)))
return [new(path, "blocked-word")];
return [];
}
/// <summary>
/// Checks untrusted portfolio data. A draft only has to be well-formed; publishing also needs
/// the fields listed in <c>requiredForPublish</c>. An empty result means the data is valid.
/// </summary>
public IReadOnlyList<PortfolioProblem> Validate(JsonNode? node, PortfolioMode mode)
{
var problems = new List<PortfolioProblem>();
if (node is not JsonObject root) return [new("$", "invalid-type")];
if (!CheckUnknownFields(problems, "", root, TopLevelFields)) return problems;
bool RequiredOnPublish(string key) => mode == PortfolioMode.Publish && rules.RequiredForPublish.Contains(key);
var theme = Get(root, "theme");
if (theme.Present)
{
var read = TryString(theme.Node, out var name);
if (read == StringRead.NotAString) problems.Add(new("theme", "invalid-type"));
else if (read == StringRead.Malformed) problems.Add(new("theme", "invalid-characters"));
else if (!rules.Themes.Contains(name)) problems.Add(new("theme", "invalid-format"));
}
CheckText(problems, "name", Get(root, "name"), "name", RequiredOnPublish("name"));
CheckText(problems, "headline", Get(root, "headline"), "headline", RequiredOnPublish("headline"));
CheckText(problems, "about", Get(root, "about"), "about", false);
CheckList(problems, "skills", Get(root, "skills"), "skills", (path, item) =>
CheckText(problems, path, new(true, item), "skills.item", true));
CheckList(problems, "projects", Get(root, "projects"), "projects", (path, item) =>
CheckObjectItem(problems, path, item, ["title", "link", "technologies", "description"], project =>
{
CheckText(problems, $"{path}.title", Get(project, "title"), "projects.title", true);
CheckLink(problems, $"{path}.link", Get(project, "link"), null);
CheckList(problems, $"{path}.technologies", Get(project, "technologies"), "projects.technologies", (techPath, tech) =>
CheckText(problems, techPath, new(true, tech), "projects.technologies.item", true));
CheckText(problems, $"{path}.description", Get(project, "description"), "projects.description", false);
}));
CheckList(problems, "education", Get(root, "education"), "education", (path, item) =>
CheckObjectItem(problems, path, item, ["institution", "degree", "period", "details"], entry =>
{
CheckText(problems, $"{path}.institution", Get(entry, "institution"), "education.institution", true);
CheckText(problems, $"{path}.degree", Get(entry, "degree"), "education.degree", false);
CheckText(problems, $"{path}.period", Get(entry, "period"), "education.period", false);
CheckText(problems, $"{path}.details", Get(entry, "details"), "education.details", false);
}));
CheckList(problems, "experience", Get(root, "experience"), "experience", (path, item) =>
CheckObjectItem(problems, path, item, ["organization", "role", "period", "description"], entry =>
{
CheckText(problems, $"{path}.organization", Get(entry, "organization"), "experience.organization", true);
CheckText(problems, $"{path}.role", Get(entry, "role"), "experience.role", true);
CheckText(problems, $"{path}.period", Get(entry, "period"), "experience.period", false);
CheckText(problems, $"{path}.description", Get(entry, "description"), "experience.description", false);
}));
CheckList(problems, "achievements", Get(root, "achievements"), "achievements", (path, item) =>
CheckText(problems, path, new(true, item), "achievements.item", true));
var links = Get(root, "links");
if (links.Present)
{
if (links.Node is not JsonObject linkObject) problems.Add(new("links", "invalid-type"));
else if (CheckUnknownFields(problems, "links", linkObject, ["github", "linkedin", "website", "email"]))
{
CheckLink(problems, "links.github", Get(linkObject, "github"), "links.github");
CheckLink(problems, "links.linkedin", Get(linkObject, "linkedin"), "links.linkedin");
CheckLink(problems, "links.website", Get(linkObject, "website"), null);
CheckEmail(problems, "links.email", Get(linkObject, "email"));
}
}
return problems;
}
private readonly record struct Field(bool Present, JsonNode? Node);
private static Field Get(JsonObject source, string key) =>
source.TryGetPropertyValue(key, out var node) ? new(true, node) : new(false, null);
private enum StringRead { Ok, NotAString, Malformed }
/// <summary>
/// Reads a JSON string without throwing. Depending on the runtime, a lazily decoded string
/// that holds a lone surrogate throws while it is read; that and a programmatically built
/// string with a lone surrogate are both reported as malformed.
/// </summary>
private static StringRead TryString(JsonNode? node, out string value)
{
value = "";
if (node is not JsonValue json) return StringRead.NotAString;
string? text;
try
{
if (!json.TryGetValue<string>(out text) || text is null) return StringRead.NotAString;
}
catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException)
{
return StringRead.Malformed;
}
if (!IsWellFormed(text)) return StringRead.Malformed;
value = text;
return StringRead.Ok;
}
/// <summary>Adds the problem for an unreadable value; returns true when the value was read.</summary>
private static bool Read(List<PortfolioProblem> problems, string path, JsonNode? node, out string value)
{
switch (TryString(node, out value))
{
case StringRead.Ok:
return true;
case StringRead.Malformed:
problems.Add(new(path, "invalid-characters"));
return false;
default:
problems.Add(new(path, "invalid-type"));
return false;
}
}
/// <summary>False when the text has a lone surrogate half, which no encoding can represent.</summary>
private static bool IsWellFormed(string text)
{
for (var i = 0; i < text.Length; i++)
{
if (char.IsHighSurrogate(text[i]))
{
if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return false;
i++;
}
else if (char.IsLowSurrogate(text[i])) return false;
}
return true;
}
private static bool IsBlank(string value) => value.All(c => c is ' ' or '\t' or '\r' or '\n');
/// <summary>Lower-cases A-Z only, so other characters such as the Kelvin sign are never folded.</summary>
private static string AsciiLower(string value) =>
string.Create(value.Length, value, static (span, source) =>
{
for (var i = 0; i < source.Length; i++)
span[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + 32) : source[i];
});
private int Limit(IReadOnlyDictionary<string, int> table, string key) =>
table.TryGetValue(key, out var value) ? value : throw new InvalidDataException($"Missing portfolio rule: {key}");
/// <summary>
/// Reports unknown field names. Returns false, after reporting <c>invalid-characters</c> for the
/// object itself, when a field name is not well-formed text; the caller then skips that object.
/// System.Text.Json decodes property names lazily, so a lone surrogate escape in a parsed name
/// throws the first time the object is read.
/// </summary>
private static bool CheckUnknownFields(List<PortfolioProblem> problems, string path, JsonObject source, IReadOnlyCollection<string> allowed)
{
List<string> keys;
try { keys = source.Select(property => property.Key).ToList(); }
catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException)
{
problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters"));
return false;
}
if (keys.Any(key => !IsWellFormed(key)))
{
problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters"));
return false;
}
foreach (var key in keys)
if (!allowed.Contains(key)) problems.Add(new(path.Length == 0 ? key : $"{path}.{key}", "unknown-field"));
return true;
}
private void CheckText(List<PortfolioProblem> problems, string path, Field field, string key, bool required)
{
if (!field.Present)
{
if (required) problems.Add(new(path, "required"));
return;
}
if (!Read(problems, path, field.Node, out var value)) return;
// Length comes before the blank check, so a field of only spaces cannot exceed its limit.
if (value.Length > Limit(rules.MaxLength, key)) { problems.Add(new(path, "too-long")); return; }
if (IsBlank(value))
{
if (required) problems.Add(new(path, "required"));
return;
}
if (control.IsMatch(value) || (!multiline.Contains(key) && value.AsSpan().IndexOfAny('\r', '\n') >= 0))
{
problems.Add(new(path, "invalid-characters"));
return;
}
if (htmlLike.IsMatch(value)) problems.Add(new(path, "html-like"));
}
/// <summary>An optional https link. <paramref name="hostKey"/> selects the host restriction, such as <c>links.github</c>.</summary>
private void CheckLink(List<PortfolioProblem> problems, string path, Field field, string? hostKey)
{
if (!field.Present) return;
if (!Read(problems, path, field.Node, out var value)) return;
if (value.Length > Limit(rules.MaxLength, "link")) { problems.Add(new(path, "too-long")); return; }
if (IsBlank(value)) return;
var scheme = rules.Link.Scheme;
if (linkForbidden.IsMatch(value) || value.Length < scheme.Length || AsciiLower(value[..scheme.Length]) != scheme)
{
problems.Add(new(path, "invalid-link"));
return;
}
var rest = value[scheme.Length..];
var end = rest.AsSpan().IndexOfAny('/', '?', '#');
var authority = end < 0 ? rest : rest[..end];
// The host pattern allows only letters, digits, hyphens and dots, so credentials
// (user@host), ports (host:8443), IP literals and empty hosts all fail it.
var hostName = AsciiLower(authority);
if (!host.IsMatch(hostName)) { problems.Add(new(path, "invalid-link")); return; }
if (hostKey is not null && rules.Link.HostRestrictions.TryGetValue(hostKey, out var allowed)
&& !allowed.Any(domain => hostName == domain || hostName.EndsWith("." + domain, StringComparison.Ordinal)))
problems.Add(new(path, "invalid-host"));
}
private void CheckEmail(List<PortfolioProblem> problems, string path, Field field)
{
if (!field.Present) return;
if (!Read(problems, path, field.Node, out var value)) return;
if (value.Length > Limit(rules.MaxLength, "email")) { problems.Add(new(path, "too-long")); return; }
if (IsBlank(value)) return;
if (!email.IsMatch(value)) problems.Add(new(path, "invalid-email"));
}
private void CheckList(List<PortfolioProblem> problems, string path, Field field, string key, Action<string, JsonNode?> checkItem)
{
if (!field.Present) return;
if (field.Node is not JsonArray array) { problems.Add(new(path, "invalid-type")); return; }
if (array.Count > Limit(rules.MaxItems, key)) { problems.Add(new(path, "too-many")); return; }
for (var i = 0; i < array.Count; i++) checkItem($"{path}[{i}]", array[i]);
}
private static void CheckObjectItem(List<PortfolioProblem> problems, string path, JsonNode? item, string[] allowed, Action<JsonObject> checkFields)
{
if (item is not JsonObject source) { problems.Add(new(path, "invalid-type")); return; }
if (CheckUnknownFields(problems, path, source, allowed)) checkFields(source);
}
}