Skip to content

Commit a869bf1

Browse files
committed
fix(api): an invalid source name in the URL is a 404, not a 500
GET and DELETE /api/sources/{name} passed the raw route segment into the registry store, whose GetAsync/DeleteAsync called ValidateName and threw ArgumentException for a name it could never have written. Nothing above catches that, so GET /api/sources/a%20b answered 500 with the whole validation regex in the body, where an unknown-but-legal name has always answered a clean 404. Measured before changing anything: "a b" and "../evil" both threw; a legal-but-unknown name returned 404. The rule, applied in both stores: a LOOKUP for a name the store could never have written is a miss, not an error. Writes still validate — there a bad name is the caller's mistake, and rejecting it is what keeps the name from ever becoming a key or a path. The names that reach GetPath are unchanged; only the action on rejection differs. Fixing it at the store rather than at each endpoint covers every caller at once: the same lookup backs source.ref resolution, so a report referencing an unusable source name now fails with "No source named 'x' is registered" instead of a 500 out of the compiler. Documented on ISourceRegistryStore, since this is now part of its contract rather than an implementation detail. Found while attempting something else. Adding the Windows device names (CON, NUL, COM1…) to the name grammar looked like a one-line fix and was not: DynamicReportName.IsValid serves as both a validator (reject -> 400) and a discriminator (could a file-backed store hold this?), so narrowing it turned source.ref: CON and GET /api/sources/CON from clean 404s into 500s, and would have left a nul.json created on Linux un-deletable. That attempt is backed out and recorded as backlog 1e; this commit fixes the 500 half of its fallout, which was a real pre-existing bug on its own. An existing test asserted the old contract (Invalid_name_throws_on_every_ operation) and went red — the full suite caught what my targeted runs on the in-memory store and the endpoints did not. Rewritten to the new one, including that the store's directory is never created, which is the strongest available proof the name did not reach GetPath. Full suite: 1 711 green across 33 projects.
1 parent f248451 commit a869bf1

8 files changed

Lines changed: 147 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
99
## [Unreleased]
1010

1111
### Fixed
12+
- **An invalid source name in the URL is a `404`, not a `500`.** `GET`/`DELETE /api/sources/{name}`
13+
passed the raw route segment into the registry store, whose `GetAsync`/`DeleteAsync` threw
14+
`ArgumentException` for a name it could never have written — and nothing above catches it, so
15+
`GET /api/sources/a%20b` answered **500 with the whole validation regex in the body**, where an
16+
unknown-but-legal name has always answered a clean `404`. Reads and deletes now treat such a name as
17+
a miss; writes still validate, since there a bad name is the caller's mistake and rejecting it is
18+
what keeps it from ever becoming a key or a path. The same lookup backs `source.ref` resolution, so
19+
a report referencing an unusable source name now fails with "No source named 'x' is registered"
20+
instead of a 500.
21+
1222
- **A dynamic report name could end in a newline.** `DynamicReportName.Pattern` was anchored with
1323
`$`, which in .NET matches at the end of input **and** immediately before a trailing newline — so
1424
a name ending in one was accepted. That name is remotely creatable via `POST /api/reports`, and

docs/STATUS-AND-BACKLOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,26 @@ would escape the per-report skip and stop the host from starting at all. Doing t
150150
deciding where a code-first name is validated and with which exception, which is a design question,
151151
and the input is the host developer's own literal rather than anything remote.
152152

153+
### 1e. Windows device names still pass the report/source name grammar — open
154+
155+
`CON`, `NUL`, `PRN`, `AUX`, `COM1``COM9` and `LPT1``LPT9` satisfy `DynamicReportName`, but a name
156+
becomes a file name and on Windows `CON.json` resolves to a device: the save throws
157+
`FileNotFoundException`, so `POST /api/reports` answers `500` for what is really an invalid name.
158+
Measured, not assumed — CON/NUL/PRN/AUX/COM1/LPT1 all failed to write on Windows 10 while `CON1` wrote
159+
normally, and `COM0`/`LPT0` turned out to be ordinary writable files despite being documented as
160+
reserved. No data is at risk: the create path already rolls the registry back.
161+
162+
**Attempted and backed out.** Adding the device names to the pattern looks like a one-line fix and is
163+
not, because `DynamicReportName.IsValid` serves two roles: a *validator* (reject → 400) and a
164+
*discriminator* (could a file-backed store hold this?). Narrowing it flips the second role — a
165+
`source.ref` of `CON` and `GET /api/sources/CON` both turned from a clean 404/400 into a 500, and a
166+
`nul.json` created on Linux before the change would rehydrate and run but answer `409 "code-registered"`
167+
on delete, unrecoverable except by deleting the file by hand.
168+
169+
The `500` half of that fallout is fixed (see the source-store lookup change above); the remaining work
170+
is separating the two roles, and deciding what happens to an already-stored name that the new rule
171+
would reject. That is a design decision, and the bug it fixes is a wrong status code.
172+
153173
### 2. CI hardening
154174
- **Fail (not skip) the Testcontainers integration tests when Docker is absent in CI.****done**:
155175
the five container `ServerFixture`s now swallow a start failure only through an exception filter,

src/NeoReports.Core/SourceRegistry/FileSourceRegistryStore.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,14 @@ public async Task SaveAsync(SourceDefinition definition, CancellationToken cance
4646
/// <inheritdoc />
4747
public async Task<SourceDefinition?> GetAsync(string name, CancellationToken cancellationToken)
4848
{
49-
ValidateName(name);
49+
// A LOOKUP for a name this store could never have written is a miss, not an error. It used to
50+
// throw, and nothing above catches ArgumentException: GET /api/sources/{name} with a name like
51+
// "a b" answered 500 with the whole validation regex in the body, where an unknown-but-legal
52+
// name answers a clean 404. Writes still validate — there a bad name is the caller's mistake,
53+
// and rejecting it is what keeps the name from ever becoming a path.
54+
if (!DynamicReportName.IsValid(name))
55+
return null;
56+
5057
string path = GetPath(name);
5158
if (!File.Exists(path))
5259
return null;
@@ -58,7 +65,12 @@ public async Task SaveAsync(SourceDefinition definition, CancellationToken cance
5865
/// <inheritdoc />
5966
public Task<bool> DeleteAsync(string name, CancellationToken cancellationToken)
6067
{
61-
ValidateName(name);
68+
// Same rule as GetAsync: nothing under an unwritable name can be there to remove, so report
69+
// "removed nothing" rather than throwing at a caller that only asked. Also keeps the name away
70+
// from GetPath, which is the whole reason it was validated here.
71+
if (!DynamicReportName.IsValid(name))
72+
return Task.FromResult(false);
73+
6274
string path = GetPath(name);
6375
if (!File.Exists(path))
6476
return Task.FromResult(false);

src/NeoReports.Core/SourceRegistry/ISourceRegistryStore.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,34 @@ namespace NeoReports.Core.SourceRegistry;
55
/// including any <c>${VAR}</c> placeholders — the store never resolves environment variables;
66
/// that is <see cref="ISourceRegistry"/>'s job, at run time.
77
/// </summary>
8+
/// <remarks>
9+
/// <b>Names.</b> A write rejects a name the store cannot key — it becomes a file name in a
10+
/// file-backed implementation, so this is what keeps a caller-supplied name from becoming a path.
11+
/// A <i>read</i> or <i>delete</i> under such a name is a miss rather than an error: an implementation
12+
/// returns <c>null</c>/<c>false</c> instead of throwing, because a lookup for something that could
13+
/// never have been written is simply not found. Throwing there made an endpoint answer 500 for a name
14+
/// that a plain 404 already describes.
15+
/// </remarks>
816
public interface ISourceRegistryStore
917
{
1018
/// <summary>Creates or fully replaces the definition under <c>definition.Name</c>.</summary>
1119
/// <param name="definition">The source definition to persist.</param>
20+
/// <exception cref="ArgumentException">Thrown when the name is one the store cannot key.</exception>
1221
/// <param name="cancellationToken">Cancellation token.</param>
1322
Task SaveAsync(SourceDefinition definition, CancellationToken cancellationToken);
1423

15-
/// <summary>Reads a source definition by name, or <c>null</c> when it doesn't exist.</summary>
24+
/// <summary>
25+
/// Reads a source definition by name, or <c>null</c> when it doesn't exist — including when the
26+
/// name is one this store could never have written.
27+
/// </summary>
1628
/// <param name="name">The source name.</param>
1729
/// <param name="cancellationToken">Cancellation token.</param>
1830
Task<SourceDefinition?> GetAsync(string name, CancellationToken cancellationToken);
1931

20-
/// <summary>Deletes a source definition. A no-op (returns <c>false</c>) when it doesn't exist.</summary>
32+
/// <summary>
33+
/// Deletes a source definition. A no-op (returns <c>false</c>) when it doesn't exist, including
34+
/// when the name is one this store could never have written.
35+
/// </summary>
2136
/// <param name="name">The source name.</param>
2237
/// <param name="cancellationToken">Cancellation token.</param>
2338
/// <returns><c>true</c> when a definition existed and was removed.</returns>

src/NeoReports.Core/SourceRegistry/InMemorySourceRegistryStore.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,24 @@ public Task SaveAsync(SourceDefinition definition, CancellationToken cancellatio
2424
/// <inheritdoc />
2525
public Task<SourceDefinition?> GetAsync(string name, CancellationToken cancellationToken)
2626
{
27-
ValidateName(name);
27+
// A LOOKUP for a name this store could never have written is a miss, not an error. It used to
28+
// throw, and nothing above catches ArgumentException: GET /api/sources/{name} with a name like
29+
// "a b" answered 500 with the whole validation regex in the body, where an unknown-but-legal
30+
// name answers a clean 404. Writes still validate — there a bad name is the caller's mistake.
31+
if (!DynamicReportName.IsValid(name))
32+
return Task.FromResult<SourceDefinition?>(null);
33+
2834
return Task.FromResult(_definitions.TryGetValue(name, out SourceDefinition? definition) ? definition : null);
2935
}
3036

3137
/// <inheritdoc />
3238
public Task<bool> DeleteAsync(string name, CancellationToken cancellationToken)
3339
{
34-
ValidateName(name);
40+
// Same rule as GetAsync: nothing under an unwritable name can be there to remove, so report
41+
// "removed nothing" rather than throwing at a caller that only asked.
42+
if (!DynamicReportName.IsValid(name))
43+
return Task.FromResult(false);
44+
3545
return Task.FromResult(_definitions.TryRemove(name, out _));
3646
}
3747

tests/NeoReports.AspNetCore.IntegrationTests/SourceEndpointTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,31 @@ private static void AddSourceRegistryHost(IServiceCollection services)
2424
services.AddSingleton<ISourceHealthCheck, FakeSourceHealthCheck>();
2525
}
2626

27+
/// <summary>
28+
/// A route name the registry could never have written used to reach the store's ValidateName,
29+
/// which throws ArgumentException — and nothing above catches it, so the caller got a 500 whose
30+
/// body carried the whole validation regex. An unknown-but-legal name has always answered a clean
31+
/// 404; these now do the same.
32+
/// </summary>
33+
[Theory]
34+
[InlineData("a b")]
35+
[InlineData("../evil")]
36+
[InlineData("1abc")]
37+
public async Task An_unwritable_source_name_is_404_not_500(string name)
38+
{
39+
using var host = await TestApp.StartAsync(AddSourceRegistryHost);
40+
HttpClient client = host.GetTestClient();
41+
string escaped = Uri.EscapeDataString(name);
42+
43+
HttpResponseMessage read = await client.GetAsync($"/api/sources/{escaped}");
44+
read.StatusCode.ShouldBe(HttpStatusCode.NotFound);
45+
46+
// And the answer must not hand back the validation rule.
47+
(await read.Content.ReadAsStringAsync()).ShouldNotContain("[a-zA-Z]");
48+
49+
(await client.DeleteAsync($"/api/sources/{escaped}")).StatusCode.ShouldBe(HttpStatusCode.NotFound);
50+
}
51+
2752
/// <summary>
2853
/// A source's <c>properties</c> bag is typed <c>object?</c>, so System.Text.Json hands each value
2954
/// over as a <c>JsonElement</c>. <c>FileSourceRegistryStore</c> launders that away by serializing,

tests/NeoReports.Core.UnitTests/SourceRegistry/FileSourceRegistryStoreTests.cs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,31 @@ public async Task Get_a_corrupt_file_returns_null_rather_than_throwing()
128128
(await store.GetAsync("broken", CancellationToken.None)).ShouldBeNull();
129129
}
130130

131+
/// <summary>
132+
/// A write under a name the store cannot key is the caller's mistake and still throws — rejecting
133+
/// it is what keeps the name from ever becoming a path. A read or delete is a miss instead: a
134+
/// lookup for something that could never have been written is simply not found, and throwing there
135+
/// made <c>GET /api/sources/{name}</c> answer 500 (with the validation regex in the body) for a
136+
/// name a plain 404 already describes.
137+
/// </summary>
131138
[Theory]
132139
[InlineData("../evil")]
133140
[InlineData("a b")]
134141
[InlineData("")]
135-
public async Task Invalid_name_throws_on_every_operation(string invalidName)
142+
public async Task Invalid_name_is_refused_on_write_and_a_miss_on_read(string invalidName)
136143
{
137144
var store = new FileSourceRegistryStore(_directory);
138-
await Should.ThrowAsync<ArgumentException>(() => store.SaveAsync(new SourceDefinition(invalidName, "sql"), CancellationToken.None));
139-
await Should.ThrowAsync<ArgumentException>(() => store.GetAsync(invalidName, CancellationToken.None));
140-
await Should.ThrowAsync<ArgumentException>(() => store.DeleteAsync(invalidName, CancellationToken.None));
145+
146+
await Should.ThrowAsync<ArgumentException>(
147+
() => store.SaveAsync(new SourceDefinition(invalidName, "sql"), CancellationToken.None));
148+
149+
(await store.GetAsync(invalidName, CancellationToken.None)).ShouldBeNull();
150+
(await store.DeleteAsync(invalidName, CancellationToken.None)).ShouldBeFalse();
151+
152+
// None of the three touched the filesystem: the store only creates its directory on a
153+
// successful write, so its absence is the strongest available proof that the name never
154+
// reached GetPath.
155+
Directory.Exists(_directory).ShouldBeFalse();
141156
}
142157

143158
public void Dispose()

tests/NeoReports.Core.UnitTests/SourceRegistry/InMemorySourceRegistryStoreTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,34 @@ public async Task List_returns_every_definition_sorted_by_name()
3535

3636
(await store.ListAsync(CancellationToken.None)).Select(d => d.Name).ShouldBe(new[] { "alpha-db", "beta-db" });
3737
}
38+
// A lookup for a name the store could never have written is a miss, not an error. It used to
39+
// throw ArgumentException, which nothing above catches — GET /api/sources/{name} with such a name
40+
// answered 500 (echoing the validation regex) where an unknown-but-legal name answers 404.
41+
[Theory]
42+
[InlineData("a b")]
43+
[InlineData("../evil")]
44+
[InlineData("1abc")]
45+
[InlineData("")]
46+
public async Task A_lookup_for_an_unwritable_name_is_a_miss(string name)
47+
{
48+
var store = new InMemorySourceRegistryStore();
49+
await store.SaveAsync(new SourceDefinition("real-db", "sql"), CancellationToken.None);
50+
51+
(await store.GetAsync(name, CancellationToken.None)).ShouldBeNull();
52+
(await store.DeleteAsync(name, CancellationToken.None)).ShouldBeFalse();
53+
54+
// And the store is untouched by the attempt.
55+
(await store.ListAsync(CancellationToken.None)).Count.ShouldBe(1);
56+
}
57+
58+
[Fact]
59+
public async Task A_write_under_an_unwritable_name_still_throws()
60+
{
61+
var store = new InMemorySourceRegistryStore();
62+
63+
// Writes keep validating: there a bad name is the caller's mistake, and rejecting it is what
64+
// keeps the name from ever becoming a key or a path.
65+
await Should.ThrowAsync<ArgumentException>(
66+
() => store.SaveAsync(new SourceDefinition("a b", "sql"), CancellationToken.None));
67+
}
3868
}

0 commit comments

Comments
 (0)