fix(engine): review of #203: a refused address is not repeated, a list ends only on what the server says about paging, and more is bounded
An independent review found no way to send a token to another host on an address that was typed right, and these gaps. - An address that is refused is no longer repeated in the message: one that was refused for the password or the token in it put that into the text. - 'https:/host' with one slash was taken for a host named 'https'. Text without a scheme is a host with at most a port; anything else is refused. A host with characters that have no place in a name is refused instead of throwing. - The client all accounts share keeps no cookies: what one account's server set travelled with another account's token. - A Link header that is not about paging (one a proxy added) ended a list after its first page. Only paging relations say that there is no next page. - A connection lost while an answer is read is 'unavailable', not an unexpected error. A page with more items than were asked for is refused, so a list has an upper bound. A token with a space or a line break in it is refused before anything is sent, and is not repeated. - A token that may not read organizations is refused at an organization's list whatever the name is: the account path is tried then too, so it still lists its own account. - A redirect says that the organization or account may have been renamed; 408 is 'retry later'; other statuses no longer claim there is no API. - Tests for each, for the escaping of names in requests and clone addresses, for the time limit covering the reading of an answer, and that the client of production is the one built on the handler whose settings are asserted. Refs #202 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
1 parent
0d76ea6a95
commit
bce029dc16
7 files changed
+437
-54
No files matched your search
@@ -33,7 +33,11 @@ were the two lister implementations and the failure type they throw.
|
||||
message handler, so none of it is excluded from the coverage gate.
|
||||
- **The address of a server** (`GiteaServerUrl`) is HTTPS, has no user name or
|
||||
password in it, and nothing after its path. A host typed without a scheme is
|
||||
taken as HTTPS. `http://` is refused, and there is no setting that allows it.
|
||||
taken as HTTPS; text that has a scheme-like beginning and is not an address
|
||||
(`https:/host` with one slash) is refused instead of being taken for a host.
|
||||
`http://` is refused, and there is no setting that allows it. An address that
|
||||
is refused is not repeated in the message: it may have been refused for the
|
||||
password or token in it.
|
||||
- **Certificates are validated by the platform, and that cannot be switched off.**
|
||||
A server with a certificate from a private authority works once Windows trusts
|
||||
that authority.
|
||||
@@ -42,14 +46,17 @@ were the two lister implementations and the failure type they throw.
|
||||
`Authorization` header. Redirects are not followed; a redirect is reported as
|
||||
"this is not the server's own address".
|
||||
- The address of a next page is never taken from the server (`Link` headers are
|
||||
read only for "is there a next page").
|
||||
read only for "is there a next page", and only their paging relations).
|
||||
- Nothing a server sets is kept: the one client all accounts share uses no
|
||||
cookies.
|
||||
- Clone addresses are built from the configured address, the owner and the
|
||||
repository name. The `clone_url` in the server's answers is not read. So the
|
||||
host that gets the token for a clone is the host the account names, whatever
|
||||
the server's own idea of its address is.
|
||||
- **What a server sends is bounded**: the size of an answer, the number of pages of
|
||||
a list, and the time of a request. A server's own words reach a message only as
|
||||
the reason of a refusal, on one line and cut to a length.
|
||||
a list and of items on a page (so a list has at most 100,000 items), and the
|
||||
time of a request, the reading of its answer included. A server's own words
|
||||
reach a message only as the reason of a refusal, on one line and cut to a length.
|
||||
- **Paging follows what the server says**, not the size of a page: a Gitea server
|
||||
caps the page size, and leaves out of a page what the token may not see, so a
|
||||
short or empty page does not end a list. Where a server sends no `Link` header,
|
||||
@@ -58,6 +65,9 @@ were the two lister implementations and the failure type they throw.
|
||||
own account gets its own repositories, another account the ones the token can see
|
||||
there. From the list of the token's own repositories only those the account owns
|
||||
are taken; that list also holds repositories of others the token may work on.
|
||||
A token that may not read organizations is refused at an organization's list
|
||||
whatever the name is; the account path is tried then as well, so such a token
|
||||
still lists its own account.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -89,6 +99,10 @@ were the two lister implementations and the failure type they throw.
|
||||
Gitea server (#201, step 2).
|
||||
- The engine's public surface grows (`ForgeKind`, `ForgeAccessException`,
|
||||
`ForgeAccessKind`, `GiteaServerUrl`, the two listers), and the base type of
|
||||
`GitHubAccessException` changes: a minor version for the package.
|
||||
`GitHubAccessException` changes: a minor version for the package. Code that
|
||||
catches either type, or reads `Kind`, is not affected; code that looks `Kind`
|
||||
up by name through reflection now finds two properties.
|
||||
- A server answers a renamed organization or account with a redirect, which gclo
|
||||
does not follow: the message says to use the new name.
|
||||
- Accounts, the command line and the desktop app follow in their own changes
|
||||
(#201). Until then the app and the CLI behave as before.
|
||||
@@ -100,7 +100,7 @@ flowchart LR
|
||||
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
|
||||
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
|
||||
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
|
||||
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages, requests in time. A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
|
||||
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
|
||||
| T13 | CLI / data directory | Tampering | A malicious `accounts.json` or `settings.json` redirects targets | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained | Accepted (same-user boundary) |
|
||||
|
||||
## 7. Accepted risks
|
||||
|
||||
@@ -51,9 +51,26 @@ public sealed class GiteaListerTests : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>A body that never ends: a server that keeps sending.</summary>
|
||||
private sealed class Endless : Stream
|
||||
/// <summary>An answer's body whose every read is scripted: one that never ends, one that breaks off, one that stalls.</summary>
|
||||
private sealed class Body(Func<Memory<byte>, CancellationToken, ValueTask<int>> read) : Stream
|
||||
{
|
||||
/// <summary>A server that keeps sending.</summary>
|
||||
public static Body Endless() => new((buffer, _) =>
|
||||
{
|
||||
buffer.Span.Fill((byte)' ');
|
||||
return ValueTask.FromResult(buffer.Length);
|
||||
});
|
||||
|
||||
/// <summary>A connection that ends while the answer is read.</summary>
|
||||
public static Body BreaksOff() => new((_, _) => throw new IOException("The response ended prematurely."));
|
||||
|
||||
/// <summary>A server that stops sending and does not close.</summary>
|
||||
public static Body Stalls() => new(async (_, token) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, token);
|
||||
return 0;
|
||||
});
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
@@ -77,10 +94,17 @@ public sealed class GiteaListerTests : IDisposable
|
||||
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
Array.Fill(buffer, (byte)' ', offset, count);
|
||||
return count;
|
||||
}
|
||||
=> read(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> read(buffer, cancellationToken);
|
||||
|
||||
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> read(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
|
||||
|
||||
@@ -97,6 +121,11 @@ public sealed class GiteaListerTests : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>A test that would hang if a bound were missing fails instead.</summary>
|
||||
private static Task<T> Bounded<T>(Task<T> task) => task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- paging
|
||||
|
||||
[Fact]
|
||||
@@ -144,7 +173,7 @@ public sealed class GiteaListerTests : IDisposable
|
||||
_server.OnPage("orgs/acme/repos", page, [Repo("same")], more: true);
|
||||
}
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => Bounded(List()));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains("did not answer like a Gitea server", ex.Message);
|
||||
@@ -372,10 +401,12 @@ public sealed class GiteaListerTests : IDisposable
|
||||
[InlineData(HttpStatusCode.ProxyAuthenticationRequired, ForgeAccessKind.Unavailable, "A proxy on the way to git.example.test asked gclo to sign in (HTTP 407)")]
|
||||
[InlineData(HttpStatusCode.InternalServerError, ForgeAccessKind.Unavailable, "answered with an error on its side (HTTP 500); retry later")]
|
||||
[InlineData(HttpStatusCode.BadGateway, ForgeAccessKind.Unavailable, "answered with an error on its side (HTTP 502); retry later")]
|
||||
[InlineData(HttpStatusCode.Found, ForgeAccessKind.NotFound, "answered with a redirect (HTTP 302) instead of the Gitea API")]
|
||||
[InlineData(HttpStatusCode.PermanentRedirect, ForgeAccessKind.NotFound, "answered with a redirect (HTTP 308) instead of the Gitea API")]
|
||||
[InlineData(HttpStatusCode.BadRequest, ForgeAccessKind.NotFound, "There is no Gitea API at this address of git.example.test (HTTP 400)")]
|
||||
[InlineData(HttpStatusCode.MethodNotAllowed, ForgeAccessKind.NotFound, "There is no Gitea API at this address of git.example.test (HTTP 405)")]
|
||||
[InlineData(HttpStatusCode.RequestTimeout, ForgeAccessKind.Unavailable, "did not answer in time (HTTP 408); retry later")]
|
||||
[InlineData(HttpStatusCode.Found, ForgeAccessKind.NotFound, "answered with a redirect (HTTP 302) instead of what was asked for")]
|
||||
[InlineData(HttpStatusCode.TemporaryRedirect, ForgeAccessKind.NotFound, "If the organization or account was renamed, use its new name.")]
|
||||
[InlineData(HttpStatusCode.PermanentRedirect, ForgeAccessKind.NotFound, "it has to be the address the server itself uses")]
|
||||
[InlineData(HttpStatusCode.BadRequest, ForgeAccessKind.NotFound, "answered with HTTP 400, which the Gitea API does not answer here. Check the server address")]
|
||||
[InlineData(HttpStatusCode.UnprocessableEntity, ForgeAccessKind.NotFound, "answered with HTTP 422, which the Gitea API does not answer here")]
|
||||
public async Task AStatusThatIsNotSuccess_HasItsKind_AndNamesTheServer(HttpStatusCode status, ForgeAccessKind kind, string said)
|
||||
{
|
||||
_server.On("orgs/acme/repos?page=1&limit=50", () => Refusal(status, "whatever the server says"));
|
||||
@@ -405,6 +436,7 @@ public sealed class GiteaListerTests : IDisposable
|
||||
[Fact]
|
||||
public async Task ARefusal_CarriesTheServersReason_OnOneLine_AndSaysWhichScopesAreNeeded()
|
||||
{
|
||||
TheTokenBelongsTo("me"); // and "acme" is no account either: the refusal is the answer
|
||||
_server.On(
|
||||
"orgs/acme/repos?page=1&limit=50",
|
||||
() => Refusal(HttpStatusCode.Forbidden, "token does not have at least one of required scope(s):\\n [read:organization]"));
|
||||
@@ -427,6 +459,7 @@ public sealed class GiteaListerTests : IDisposable
|
||||
[InlineData("")]
|
||||
public async Task ARefusalWithoutAReasonFromTheApi_SaysNone_AndCopiesNoPage(string body)
|
||||
{
|
||||
TheTokenBelongsTo("me");
|
||||
_server.On("orgs/acme/repos?page=1&limit=50", () => Json(body, HttpStatusCode.Forbidden));
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
@@ -440,11 +473,12 @@ public sealed class GiteaListerTests : IDisposable
|
||||
[Fact]
|
||||
public async Task ARefusalThatDoesNotEnd_IsNotReadForItsReason()
|
||||
{
|
||||
TheTokenBelongsTo("me");
|
||||
_server.On(
|
||||
"orgs/acme/repos?page=1&limit=50",
|
||||
() => new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StreamContent(new Endless()) });
|
||||
() => new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StreamContent(Body.Endless()) });
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => Bounded(List()));
|
||||
|
||||
Assert.StartsWith($"{Host} refused access (403). ", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -474,9 +508,9 @@ public sealed class GiteaListerTests : IDisposable
|
||||
{
|
||||
_server.On(
|
||||
"orgs/acme/repos?page=1&limit=50",
|
||||
() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new Endless()) });
|
||||
() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(Body.Endless()) });
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => Bounded(List()));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains("did not answer like a Gitea server", ex.Message);
|
||||
@@ -504,7 +538,7 @@ public sealed class GiteaListerTests : IDisposable
|
||||
_server.OnRequest = (_, token) => Task.Delay(Timeout.Infinite, token);
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(
|
||||
() => Repos(requestTimeout: TimeSpan.FromMilliseconds(30)).ListOrganizationRepositoriesAsync("acme", Token));
|
||||
() => Bounded(Repos(requestTimeout: TimeSpan.FromMilliseconds(30)).ListOrganizationRepositoriesAsync("acme", Token)));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unavailable, ex.Kind);
|
||||
Assert.Equal($"{Host} did not answer in time; retry later.", ex.Message);
|
||||
@@ -643,7 +677,14 @@ public sealed class GiteaListerTests : IDisposable
|
||||
|
||||
ArgumentException plain = Assert.Throws<ArgumentException>(() => new GiteaRepositoryLister(new Uri("http://git.example.test")));
|
||||
Assert.Contains("it has to begin with https://", plain.Message);
|
||||
Assert.Throws<ArgumentException>(() => new GiteaOrganizationLister(new Uri("https://user:secret@git.example.test")));
|
||||
|
||||
// An address that is refused is not repeated: this one is refused for the secret in it.
|
||||
ArgumentException withASecret = Assert.Throws<ArgumentException>(
|
||||
() => new GiteaOrganizationLister(new Uri("https://user:s3cret-token@git.example.test")));
|
||||
Assert.DoesNotContain("s3cret", withASecret.Message, StringComparison.Ordinal);
|
||||
ArgumentException inTheQuery = Assert.Throws<ArgumentException>(
|
||||
() => new GiteaRepositoryLister(new Uri("https://git.example.test/?access_token=s3cret-token")));
|
||||
Assert.DoesNotContain("s3cret", inTheQuery.Message, StringComparison.Ordinal);
|
||||
Assert.Throws<ArgumentNullException>(() => new GiteaRepositoryLister((Uri)null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new GiteaRepositoryLister((GiteaApi)null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new GiteaOrganizationLister((GiteaApi)null!));
|
||||
@@ -658,6 +699,16 @@ public sealed class GiteaListerTests : IDisposable
|
||||
// a validation callback is how certificate checks are switched off.
|
||||
Assert.False(GiteaApi.SharedHandler.AllowAutoRedirect);
|
||||
Assert.Null(GiteaApi.SharedHandler.SslOptions.RemoteCertificateValidationCallback);
|
||||
|
||||
// One client serves every account: what one account's server sets must not travel with another's token.
|
||||
Assert.False(GiteaApi.SharedHandler.UseCookies);
|
||||
|
||||
// And the client of production is the one built on that handler.
|
||||
System.Reflection.FieldInfo? handler = typeof(HttpMessageInvoker).GetField(
|
||||
"_handler", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.True(handler is not null, "HttpMessageInvoker keeps its handler elsewhere now: find it, this test must keep looking at it.");
|
||||
Assert.Same(GiteaApi.SharedHandler, handler.GetValue(GiteaApi.SharedClient));
|
||||
Assert.Equal(Timeout.InfiniteTimeSpan, GiteaApi.SharedClient.Timeout); // each request has its own limit
|
||||
}
|
||||
|
||||
|
||||
@@ -669,4 +720,164 @@ public sealed class GiteaListerTests : IDisposable
|
||||
Assert.Equal("git.example.test:8443", new GiteaApi(new Uri("https://git.example.test:8443")).Host);
|
||||
Assert.Equal("https://git.example.test/gitea/", new GiteaApi(new Uri("https://GIT.example.test/gitea")).Server.AbsoluteUri);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- review of #203
|
||||
|
||||
[Theory]
|
||||
[InlineData("<https://x/y?page=2>; rel=\"next\"", true)]
|
||||
[InlineData("<https://x/y?page=2>; rel=next", true)]
|
||||
[InlineData("<https://x/y?page=2>; rel=\"prev next\"", true)]
|
||||
[InlineData("<https://x/y?page=2>; REL=\"NEXT\"", true)]
|
||||
[InlineData("<https://x/y?page=1>; rel=\"first\",<https://x/y?page=9>; rel=\"last\",<https://x/y?page=2>; rel=\"next\"", true)]
|
||||
[InlineData("<https://x/y?page=1>; rel=\"first\",<https://x/y?page=8>; rel=\"prev\"", false)]
|
||||
[InlineData("<https://x/y?page=9>; rel=last", false)]
|
||||
[InlineData("</app.css>; rel=preload; as=style", null)] // something on the way added a link of its own
|
||||
[InlineData("</app.css>; title=\"rel=next\"", null)]
|
||||
[InlineData("<https://x/y>", null)]
|
||||
[InlineData("<https://x/y>; rel", null)]
|
||||
[InlineData("", null)]
|
||||
public void WhatLinkHeadersSayAboutANextPage_IsReadFromTheirRelations(string header, bool? expected)
|
||||
=> Assert.Equal(expected, GiteaApi.SaysAboutANextPage([header]));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkThatIsNotAboutPaging_DoesNotEndTheList()
|
||||
{
|
||||
_server
|
||||
.On("orgs/acme/repos?page=1&limit=50", () =>
|
||||
{
|
||||
HttpResponseMessage response = Json($"[{Repo("a")}]");
|
||||
response.Headers.TryAddWithoutValidation("Link", "</assets/app.css>; rel=preload; as=style");
|
||||
return response;
|
||||
})
|
||||
.OnPage("orgs/acme/repos", 2, [Repo("b")])
|
||||
.OnPage("orgs/acme/repos", 3, []);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await List();
|
||||
|
||||
Assert.Equal(["a", "b"], repos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task APageWithMoreThanWasAskedFor_IsNotTaken_SoAListHasAnUpperBound()
|
||||
{
|
||||
_server.OnPage("orgs/acme/repos", 1, Enumerable.Range(0, GiteaApi.PageSize + 1).Select(i => Repo($"r{i}")), more: false);
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains("did not answer like a Gitea server", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AConnectionLostWhileTheAnswerIsRead_IsUnavailable_NotAnUnexpectedError()
|
||||
{
|
||||
_server.On(
|
||||
"orgs/acme/repos?page=1&limit=50",
|
||||
() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(Body.BreaksOff()) });
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unavailable, ex.Kind);
|
||||
Assert.Equal($"The connection to {Host} was lost while its answer was read; retry later.", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AConnectionLostWhileARefusalIsRead_LeavesTheRefusal_WithoutAReason()
|
||||
{
|
||||
_server.On("user", () => new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StreamContent(Body.BreaksOff()) });
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => Orgs().ListOrganizationsAsync(Token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Forbidden, ex.Kind);
|
||||
Assert.StartsWith($"{Host} refused access (403). ", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task TheTimeLimitOfARequest_CoversTheReadingOfItsAnswer()
|
||||
{
|
||||
_server.On(
|
||||
"orgs/acme/repos?page=1&limit=50",
|
||||
() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(Body.Stalls()) });
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(
|
||||
() => Bounded(Repos(requestTimeout: TimeSpan.FromMilliseconds(30)).ListOrganizationRepositoriesAsync("acme", Token)));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unavailable, ex.Kind);
|
||||
Assert.Equal($"{Host} did not answer in time; retry later.", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("gitea-test\ntoken")]
|
||||
[InlineData("gitea-test token")]
|
||||
[InlineData("gitea-test-token\r\n")]
|
||||
public async Task WhatIsNotAToken_IsNotSent_AndNotRepeated(string token)
|
||||
{
|
||||
ForgeAccessException fromRepositories = await Assert.ThrowsAsync<ForgeAccessException>(
|
||||
() => Repos().ListOrganizationRepositoriesAsync("acme", token));
|
||||
ForgeAccessException fromOrganizations = await Assert.ThrowsAsync<ForgeAccessException>(
|
||||
() => Orgs().ListOrganizationsAsync(token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unauthorized, fromRepositories.Kind);
|
||||
Assert.Equal(ForgeAccessKind.Unauthorized, fromOrganizations.Kind);
|
||||
Assert.Contains("The token contains a space or a line break", fromRepositories.Message);
|
||||
Assert.DoesNotContain("gitea-test", fromRepositories.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("gitea-test", fromOrganizations.Message, StringComparison.Ordinal);
|
||||
Assert.Empty(_server.Requests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ANameWithACharacterThatMeansSomethingInAnAddress_IsOneSegment_InTheRequestAndInTheCloneAddress()
|
||||
{
|
||||
_server.OnPage("orgs/a%23b/repos", 1, [Repo("x#y", owner: "a#b")], more: false);
|
||||
|
||||
RepoDescriptor repo = Assert.Single(await List("a#b"));
|
||||
|
||||
Assert.Equal($"https://{Host}/a%23b/x%23y.git", repo.CloneUrl);
|
||||
Assert.Equal($"/api/v1/orgs/a%23b/repos", Assert.Single(_server.Requests).Address.AbsolutePath);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ATokenThatMayNotReadOrganizations_StillListsItsOwnAccount()
|
||||
{
|
||||
// Such a token is refused at the organization's list whatever the name is.
|
||||
TheTokenBelongsTo("me");
|
||||
_server
|
||||
.On("orgs/me/repos?page=1&limit=50", () => Refusal(HttpStatusCode.Forbidden, "token does not have at least one of required scope(s): [read:organization]"))
|
||||
.OnPage("user/repos", 1, [Repo("mine", owner: "me")], more: false);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await List("me");
|
||||
|
||||
Assert.Equal("mine", Assert.Single(repos).Name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ATokenThatMayNotReadOrganizations_ListsAnotherAccountToo()
|
||||
{
|
||||
TheTokenBelongsTo("me");
|
||||
_server
|
||||
.On("orgs/bob/repos?page=1&limit=50", () => Refusal(HttpStatusCode.Forbidden, "token does not have at least one of required scope(s): [read:organization]"))
|
||||
.OnPage("users/bob/repos", 1, [Repo("b", owner: "bob")], more: false);
|
||||
|
||||
Assert.Equal("b", Assert.Single(await List("bob")).Name);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ public sealed class GiteaServerUrlTests
|
||||
[InlineData("https://example.test/gitea/", "https://example.test/gitea/")]
|
||||
[InlineData("https://bücher.example.test", "https://xn--bcher-kva.example.test/")]
|
||||
[InlineData("https://10.1.2.3:3000", "https://10.1.2.3:3000/")] // a server on the local network is the usual case
|
||||
[InlineData("localhost:3000/gitea", "https://localhost:3000/gitea/")]
|
||||
[InlineData("[2001:db8::1]", "https://[2001:db8::1]/")]
|
||||
[InlineData("[2001:db8::1]:3000", "https://[2001:db8::1]:3000/")]
|
||||
public void AnAddress_BecomesTheServersBaseAddress(string typed, string expected)
|
||||
{
|
||||
Assert.True(GiteaServerUrl.TryNormalize(typed, out Uri? server, out string? reason));
|
||||
@@ -51,6 +54,14 @@ public sealed class GiteaServerUrlTests
|
||||
[InlineData("https://", "it is not an address")]
|
||||
[InlineData("://nothing", "it is not an address")]
|
||||
[InlineData("https://[not-an-address", "it is not an address")]
|
||||
[InlineData("https:/git.example.test", "it is not an address (a server's address looks like")] // one slash: "https" must not become the host
|
||||
[InlineData("http:/git.example.test", "it is not an address (a server's address looks like")]
|
||||
[InlineData("http:git.example.test", "it is not an address (a server's address looks like")]
|
||||
[InlineData("git.example.test:", "it is not an address (a server's address looks like")]
|
||||
[InlineData("git.example.test:port", "it is not an address (a server's address looks like")]
|
||||
[InlineData("git.example.test:123456/gitea", "it is not an address (a server's address looks like")]
|
||||
[InlineData("[2001:db8::1]:x", "it is not an address (a server's address looks like")]
|
||||
[InlineData("https://exa\u00B4mple.test", "it is not an address")] // a character that has no place in a host name
|
||||
public void WhatCannotBeAServersAddress_IsRefused_WithTheReason(string? typed, string expected)
|
||||
{
|
||||
Assert.False(GiteaServerUrl.TryNormalize(typed, out Uri? server, out string? reason));
|
||||
@@ -58,4 +69,31 @@ public sealed class GiteaServerUrlTests
|
||||
Assert.Null(server);
|
||||
Assert.StartsWith(expected, reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void WhateverIsTyped_ItIsAnsweredWithYesOrNo_NeverWithAnException()
|
||||
{
|
||||
// Every character of the first plane once in a host, and a few thousand random strings.
|
||||
var random = new Random(20261111);
|
||||
for(int c = 1; c < 0xD800; c++)
|
||||
{
|
||||
_ = GiteaServerUrl.TryNormalize($"https://exa{(char)c}mple.test/", out _, out _);
|
||||
_ = GiteaServerUrl.TryNormalize($"exa{(char)c}mple.test", out _, out _);
|
||||
}
|
||||
for(int i = 0; i < 4000; i++)
|
||||
{
|
||||
string text = new([.. Enumerable.Range(0, random.Next(1, 24)).Select(_ => (char)random.Next(0x20, 0x180))]);
|
||||
if(GiteaServerUrl.TryNormalize(text, out Uri? server, out string? reason))
|
||||
{
|
||||
Assert.Equal(Uri.UriSchemeHttps, server.Scheme);
|
||||
Assert.Equal("", server.UserInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
-25
@@ -29,6 +29,7 @@ namespace gclo.Engine;
|
||||
/// (<see cref="CloneUrl"/>), not read from the server's answers: the git client
|
||||
/// gives the token to the host of the clone address.</item>
|
||||
/// <item>Answers are bounded in size, lists in length, and a request in time.</item>
|
||||
/// <item>Nothing a server sets is kept between requests: no cookies.</item>
|
||||
/// <item>The server's own words reach a message only as the reason of a refusal,
|
||||
/// on one line and cut to a length.</item>
|
||||
/// </list>
|
||||
@@ -49,20 +50,23 @@ internal sealed class GiteaApi
|
||||
|
||||
/// <summary>
|
||||
/// How the requests of production are carried: redirects are not followed (the
|
||||
/// token stays on the host the account names), and certificates are validated by
|
||||
/// the platform's own rules, with no callback that could accept another.
|
||||
/// token stays on the host the account names), certificates are validated by the
|
||||
/// platform's own rules, with no callback that could accept another, and cookies
|
||||
/// are neither kept nor sent (one account's requests leave nothing that another's
|
||||
/// would carry).
|
||||
/// </summary>
|
||||
internal static readonly SocketsHttpHandler SharedHandler = new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// One client for every server: connections are pooled per host, and nothing that
|
||||
/// belongs to one account (its token) is kept on it.
|
||||
/// One client for every server, over <see cref="SharedHandler"/>: connections are
|
||||
/// pooled per host, and nothing that belongs to one account is kept on it.
|
||||
/// </summary>
|
||||
private static readonly HttpClient s_shared = new(SharedHandler)
|
||||
internal static readonly HttpClient SharedClient = new(SharedHandler)
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan, // each request has its own limit, which also covers reading the answer
|
||||
};
|
||||
@@ -93,13 +97,17 @@ internal sealed class GiteaApi
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
if(!GiteaServerUrl.TryNormalize(server.OriginalString, out Uri? normalized, out string? reason))
|
||||
{
|
||||
throw new ArgumentException($"'{server.OriginalString}' cannot be the address of a Gitea server: {reason}.", nameof(server));
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The address itself is not repeated: one that was refused for having //
|
||||
// a password or a token in it would put that into the message. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
throw new ArgumentException($"This cannot be the address of a Gitea server: {reason}.", nameof(server));
|
||||
}
|
||||
|
||||
Server = normalized;
|
||||
Host = normalized.Authority;
|
||||
_api = new Uri(normalized, "api/v1/");
|
||||
_http = http ?? s_shared;
|
||||
_http = http ?? SharedClient;
|
||||
_requestTimeout = requestTimeout ?? TimeSpan.FromSeconds(100);
|
||||
}
|
||||
|
||||
@@ -132,7 +140,11 @@ internal sealed class GiteaApi
|
||||
/// an empty page ends the list. A page that is not full ends nothing: the server
|
||||
/// has a page size of its own, and it leaves out what the token may not see.
|
||||
/// </summary>
|
||||
/// <exception cref="GiteaUnexpectedAnswerException">The list did not end within <see cref="MaxPages"/> pages.</exception>
|
||||
/// <exception cref="GiteaUnexpectedAnswerException">
|
||||
/// The list did not end within <see cref="MaxPages"/> pages, or a page held more
|
||||
/// than was asked for (so a list is at most <see cref="MaxPages"/> times
|
||||
/// <see cref="PageSize"/> items).
|
||||
/// </exception>
|
||||
public async Task<List<T>> ListAsync<T>(string path, string token, JsonTypeInfo<List<T?>> type, CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
@@ -142,6 +154,10 @@ internal sealed class GiteaApi
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
(List<T?> items, bool? more) = await SendAsync($"{path}?page={page}&limit={PageSize}", token, type, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if(items.Count > PageSize)
|
||||
{
|
||||
throw new GiteaUnexpectedAnswerException($"A page held {items.Count} items; {PageSize} were asked for.");
|
||||
}
|
||||
all.AddRange(items.OfType<T>());
|
||||
if(more == false || (more is null && items.Count == 0))
|
||||
{
|
||||
@@ -214,20 +230,29 @@ internal sealed class GiteaApi
|
||||
$"{Host} answered with an error on its side (HTTP {(int)server.Status}); retry later.",
|
||||
failure),
|
||||
|
||||
GiteaStatusException { Status: HttpStatusCode.RequestTimeout } => new ForgeAccessException(
|
||||
ForgeAccessKind.Unavailable, $"{Host} did not answer in time (HTTP 408); retry later.", failure),
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// What is left of the statuses says that the address is not a Gitea //
|
||||
// server's: a redirect (never followed: the token stays where the //
|
||||
// account says), a page that is not there, a request it does not take. //
|
||||
// A redirect is never followed: the token stays where the account //
|
||||
// says. A server answers with one when the address is not its own, and //
|
||||
// Gitea also does for an organization or account that was renamed. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
GiteaStatusException { Status: >= HttpStatusCode.MultipleChoices and < HttpStatusCode.BadRequest } redirect => new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"{Host} answered with a redirect (HTTP {(int)redirect.Status}) instead of the Gitea API. {CheckTheAddress} "
|
||||
+ "It has to be the address the server itself uses.",
|
||||
$"{Host} answered with a redirect (HTTP {(int)redirect.Status}) instead of what was asked for. "
|
||||
+ "If the organization or account was renamed, use its new name. Otherwise check the server address of this account: "
|
||||
+ "it has to be the address the server itself uses.",
|
||||
failure),
|
||||
|
||||
GiteaStatusException { Status: HttpStatusCode.NotFound } => new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"There is no Gitea API at this address of {Host} (HTTP 404). {CheckTheAddress}",
|
||||
failure),
|
||||
|
||||
GiteaStatusException other => new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"There is no Gitea API at this address of {Host} (HTTP {(int)other.Status}). {CheckTheAddress}",
|
||||
$"{Host} answered with HTTP {(int)other.Status}, which the Gitea API does not answer here. {CheckTheAddress}",
|
||||
failure),
|
||||
|
||||
GiteaUnexpectedAnswerException => new ForgeAccessException(
|
||||
@@ -239,6 +264,10 @@ internal sealed class GiteaApi
|
||||
HttpRequestException network => new ForgeAccessException(
|
||||
ForgeAccessKind.Unavailable, ForgeFailures.Unreachable(Host, network.HttpRequestError), failure),
|
||||
|
||||
// The connection ended while the answer was being read.
|
||||
IOException => new ForgeAccessException(
|
||||
ForgeAccessKind.Unavailable, $"The connection to {Host} was lost while its answer was read; retry later.", failure),
|
||||
|
||||
OperationCanceledException when !canceledByCaller => new ForgeAccessException(
|
||||
ForgeAccessKind.Unavailable, $"{Host} did not answer in time; retry later.", failure),
|
||||
|
||||
@@ -256,6 +285,17 @@ internal sealed class GiteaApi
|
||||
string relative, string token, JsonTypeInfo<T> type, CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
if(token.Any(c => char.IsControl(c) || char.IsWhiteSpace(c)))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Not a token: a line break or a space came along when it was pasted. //
|
||||
// It cannot be put into a request, and it is not put into a message. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
throw new ForgeAccessException(
|
||||
ForgeAccessKind.Unauthorized,
|
||||
"The token contains a space or a line break, which no access token does. Check what was entered or pasted as the token.");
|
||||
}
|
||||
|
||||
using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
limit.CancelAfter(_requestTimeout);
|
||||
|
||||
@@ -283,14 +323,47 @@ internal sealed class GiteaApi
|
||||
throw new GiteaUnexpectedAnswerException("The answer is not the JSON the API gives.", notTheApi);
|
||||
}
|
||||
|
||||
bool? more = response.Headers.TryGetValues("Link", out IEnumerable<string>? links)
|
||||
? links.Any(link => link.Contains("rel=\"next\"", StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
bool? more = response.Headers.TryGetValues("Link", out IEnumerable<string>? links) ? SaysAboutANextPage(links) : null;
|
||||
return (value ?? throw new GiteaUnexpectedAnswerException("The answer is empty."), more);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What <c>Link</c> headers say about a next page: true when one of the links has
|
||||
/// the relation <c>next</c>; false when the links are about paging (they name a
|
||||
/// first, a previous or a last page) and none is the next; null when they say
|
||||
/// nothing about paging (a link something on the way added, such as a preload).
|
||||
/// Only the relations are read. The addresses are not: the next page is asked for
|
||||
/// at the account's server, by its number.
|
||||
/// </summary>
|
||||
internal static bool? SaysAboutANextPage(IEnumerable<string> links)
|
||||
{
|
||||
bool aboutPaging = false;
|
||||
foreach(string link in links.SelectMany(header => header.Split(',')))
|
||||
{
|
||||
foreach(string parameter in link.Split(';').Skip(1))
|
||||
{
|
||||
string[] pair = parameter.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if(pair.Length != 2 || !string.Equals(pair[0], "rel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach(string relation in pair[1].Trim('"').Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if(string.Equals(relation, "next", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
aboutPaging |= relation.ToUpperInvariant() is "PREV" or "PREVIOUS" or "FIRST" or "LAST";
|
||||
}
|
||||
}
|
||||
}
|
||||
return aboutPaging ? false : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The reason the server gave for a refusal: the <c>message</c> of the JSON
|
||||
/// document it answered with. Anything else (no body, a page of HTML, too much)
|
||||
@@ -298,18 +371,16 @@ internal sealed class GiteaApi
|
||||
/// </summary>
|
||||
private static async Task<string> ReasonOfAsync(HttpResponseMessage refusal, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[]? body = await ReadAsync(refusal.Content, MaxRefusalBytes, cancellationToken).ConfigureAwait(false);
|
||||
if(body is null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return ForgeFailures.OneLine(JsonSerializer.Deserialize(body, GiteaJsonContext.Default.GiteaRefusal)?.Message);
|
||||
byte[]? body = await ReadAsync(refusal.Content, MaxRefusalBytes, cancellationToken).ConfigureAwait(false);
|
||||
return body is null
|
||||
? ""
|
||||
: ForgeFailures.OneLine(JsonSerializer.Deserialize(body, GiteaJsonContext.Default.GiteaRefusal)?.Message);
|
||||
}
|
||||
catch(JsonException)
|
||||
catch(Exception noReason) when(noReason is JsonException or IOException)
|
||||
{
|
||||
// Not the API's document, or the connection ended while it was read: the status is what is known.
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +67,16 @@ public sealed class GiteaRepositoryLister : IRepositoryLister
|
||||
.ListAsync($"orgs/{name}/repos", token, GiteaJsonContext.Default.ListGiteaRepository, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch(GiteaStatusException notAnOrganization) when(notAnOrganization.Status == HttpStatusCode.NotFound)
|
||||
catch(GiteaStatusException notAnOrganization)
|
||||
when(notAnOrganization.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// No organization of that name that the token can see: it is a //
|
||||
// user account, or nothing. The token's own account gets its own //
|
||||
// repositories (the private ones too); another account the ones //
|
||||
// the token can see there. //
|
||||
// No organization of that name that the token can list: it is a //
|
||||
// user account, or nothing. (A token that may not read //
|
||||
// organizations is refused here whatever the name is, its own //
|
||||
// account included: that is not yet an answer either.) The token's //
|
||||
// own account gets its own repositories (the private ones too); //
|
||||
// another account the ones the token can see there. //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
string own = await _api.GetOwnNameAsync(token, cancellationToken).ConfigureAwait(false);
|
||||
bool isOwn = string.Equals(own, organization, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -85,6 +88,11 @@ public sealed class GiteaRepositoryLister : IRepositoryLister
|
||||
}
|
||||
catch(GiteaStatusException nobody) when(nobody.Status == HttpStatusCode.NotFound && !isOwn)
|
||||
{
|
||||
if(notAnOrganization.Status == HttpStatusCode.Forbidden)
|
||||
{
|
||||
// Not an account either: the refusal of the organization is the answer.
|
||||
throw _api.Translate(notAnOrganization);
|
||||
}
|
||||
throw new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"'{organization}' was found neither as an organization nor as a user account on {_api.Host} (404), "
|
||||
|
||||
@@ -43,6 +43,17 @@ public static class GiteaServerUrl
|
||||
|
||||
if(!typed.Contains("://", StringComparison.Ordinal))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// No scheme: a host, perhaps with a port and a path. A colon that is //
|
||||
// not followed by a port is something else ("https:/host" with one //
|
||||
// slash, "http:host"), and prefixing it would make its first word the //
|
||||
// host the token is sent to. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
if(!IsAHostWithAtMostAPort(typed))
|
||||
{
|
||||
reason = "it is not an address (a server's address looks like https://git.example.com)";
|
||||
return false;
|
||||
}
|
||||
typed = "https://" + typed;
|
||||
}
|
||||
|
||||
@@ -75,8 +86,38 @@ public static class GiteaServerUrl
|
||||
}
|
||||
|
||||
string path = parsed.AbsolutePath.EndsWith('/') ? parsed.AbsolutePath : parsed.AbsolutePath + "/";
|
||||
server = new UriBuilder(Uri.UriSchemeHttps, parsed.IdnHost, parsed.IsDefaultPort ? -1 : parsed.Port, path).Uri;
|
||||
try
|
||||
{
|
||||
server = new UriBuilder(Uri.UriSchemeHttps, parsed.IdnHost, parsed.IsDefaultPort ? -1 : parsed.Port, path).Uri;
|
||||
}
|
||||
catch(UriFormatException)
|
||||
{
|
||||
// A host with characters that have no place in a name (many symbols do not).
|
||||
reason = "it is not an address";
|
||||
return false;
|
||||
}
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when text without a scheme reads as a host, optionally followed by a port
|
||||
/// and a path: everything between the first colon outside brackets (an IPv6
|
||||
/// literal has its own) and the next slash is digits.
|
||||
/// </summary>
|
||||
private static bool IsAHostWithAtMostAPort(string text)
|
||||
{
|
||||
int from = text.StartsWith('[') ? Math.Max(0, text.IndexOf(']', StringComparison.Ordinal)) : 0;
|
||||
int colon = text.IndexOf(':', from);
|
||||
if(colon < 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
int slash = text.IndexOf('/', colon);
|
||||
ReadOnlySpan<char> port = text.AsSpan(colon + 1, (slash < 0 ? text.Length : slash) - colon - 1);
|
||||
return port.Length is > 0 and <= 5 && !port.ContainsAnyExceptInRange('0', '9');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user