From 0d76ea6a959d8df60c4c69ffea7e366a219277a9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 11 Nov 2026 00:49:22 -0600 Subject: [PATCH 1/2] feat(engine): a forge-neutral failure type and listers for Gitea servers The engine can list the organizations and repositories of a Gitea server (or one that speaks its API) beside GitHub. Library only: accounts, the CLI and the app do not use it yet (#201). - ForgeKind, and ForgeAccessException with ForgeAccessKind: what a failed request to any forge means to a caller. GitHubAccessException derives from it and keeps its own Kind, so code written against it still compiles and still catches. - GiteaServerUrl: the address of a server is HTTPS, without credentials, and without anything after its path. There is no setting for http. - GiteaRepositoryLister and GiteaOrganizationLister, on HttpClient and source-generated JSON, with no new package. The token goes to the server the lister was made for and nowhere else: redirects are not followed, the address of a next page is never taken from an answer, and clone addresses are built from the configured address instead of being read from the server. Certificates are validated by the platform; nothing switches that off. Answers, lists and requests are bounded. - Paging follows what the server says about a next page: a Gitea server caps the page size and leaves out what the token may not see, so a short or empty page does not end a list. - A lister contract test suite that the listers of both forges pass. - ADR 0010; threat model T16. Closes #202 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- docs/adr/0010-forges-beside-github.md | 94 +++ docs/security/threat-model.md | 3 + gclo.Engine.Tests/FakeGiteaServer.cs | 123 ++++ .../ForgeAccessExceptionTests.cs | 61 ++ .../GitHubListerContractTests.cs | 112 +++ gclo.Engine.Tests/GitHubListerTests.cs | 3 +- gclo.Engine.Tests/GiteaListerContractTests.cs | 66 ++ gclo.Engine.Tests/GiteaListerTests.cs | 672 ++++++++++++++++++ gclo.Engine.Tests/GiteaServerUrlTests.cs | 61 ++ gclo.Engine.Tests/ListerContractTests.cs | 193 +++++ gclo.Engine/ForgeAccessException.cs | 29 + gclo.Engine/ForgeAccessKind.cs | 36 + gclo.Engine/ForgeFailures.cs | 63 ++ gclo.Engine/ForgeKind.cs | 17 + gclo.Engine/GitHubAccessException.cs | 11 +- gclo.Engine/GitHubFailures.cs | 53 +- gclo.Engine/GiteaAccount.cs | 26 + gclo.Engine/GiteaApi.cs | 339 +++++++++ gclo.Engine/GiteaJsonContext.cs | 20 + gclo.Engine/GiteaOrganizationLister.cs | 86 +++ gclo.Engine/GiteaRefusal.cs | 14 + gclo.Engine/GiteaRepository.cs | 27 + gclo.Engine/GiteaRepositoryLister.cs | 123 ++++ gclo.Engine/GiteaServerUrl.cs | 82 +++ gclo.Engine/GiteaStatusException.cs | 35 + gclo.Engine/GiteaUnexpectedAnswerException.cs | 21 + gclo.Engine/IOrganizationLister.cs | 12 +- gclo.Engine/IRepositoryLister.cs | 3 +- gclo.Engine/PACKAGE.md | 7 +- gclo.Engine/RepoDescriptor.cs | 2 +- gclo.Engine/gclo.Engine.csproj | 2 +- 31 files changed, 2328 insertions(+), 68 deletions(-) create mode 100644 docs/adr/0010-forges-beside-github.md create mode 100644 gclo.Engine.Tests/FakeGiteaServer.cs create mode 100644 gclo.Engine.Tests/ForgeAccessExceptionTests.cs create mode 100644 gclo.Engine.Tests/GitHubListerContractTests.cs create mode 100644 gclo.Engine.Tests/GiteaListerContractTests.cs create mode 100644 gclo.Engine.Tests/GiteaListerTests.cs create mode 100644 gclo.Engine.Tests/GiteaServerUrlTests.cs create mode 100644 gclo.Engine.Tests/ListerContractTests.cs create mode 100644 gclo.Engine/ForgeAccessException.cs create mode 100644 gclo.Engine/ForgeAccessKind.cs create mode 100644 gclo.Engine/ForgeFailures.cs create mode 100644 gclo.Engine/ForgeKind.cs create mode 100644 gclo.Engine/GiteaAccount.cs create mode 100644 gclo.Engine/GiteaApi.cs create mode 100644 gclo.Engine/GiteaJsonContext.cs create mode 100644 gclo.Engine/GiteaOrganizationLister.cs create mode 100644 gclo.Engine/GiteaRefusal.cs create mode 100644 gclo.Engine/GiteaRepository.cs create mode 100644 gclo.Engine/GiteaRepositoryLister.cs create mode 100644 gclo.Engine/GiteaServerUrl.cs create mode 100644 gclo.Engine/GiteaStatusException.cs create mode 100644 gclo.Engine/GiteaUnexpectedAnswerException.cs diff --git a/docs/adr/0010-forges-beside-github.md b/docs/adr/0010-forges-beside-github.md new file mode 100644 index 0000000..287bfdf --- /dev/null +++ b/docs/adr/0010-forges-beside-github.md @@ -0,0 +1,94 @@ +# 0010. Forges beside GitHub: Gitea servers at an address the user names + +- Status: accepted +- Date: 2026-11-11 +- Issues: #201 (the feature), #202 (the engine, this decision's first use) + +## Context + +gclo lists and clones the repositories of a GitHub organization or account. The +project itself now lives on a Gitea server, and so do the repositories of people +who want to mirror theirs. Gitea (and Forgejo, which speaks the same API) differs +from GitHub in one way that matters for security: there is no one well-known host. +The server is at an address a user types. That address decides where the account's +token is sent. + +The engine already had the right seams: `IRepositoryLister`, `IOrganizationLister` +and `IGitClient` say nothing about GitHub, and since #139 the git client gives the +token only to the host of the clone address it was handed. What was GitHub-specific +were the two lister implementations and the failure type they throw. + +## Decision + +- **A forge is a pair of listers.** `GiteaRepositoryLister` and + `GiteaOrganizationLister` implement the existing interfaces and are constructed + with the server's address. `ForgeKind` names the kinds (`GitHub`, `Gitea`). The + sync engine, the path recovery and the git client are not changed. +- **One failure type.** `ForgeAccessException` with `ForgeAccessKind` is what a + failed request to any forge is translated into. `GitHubAccessException` now + derives from it and keeps its own `Kind`, so code written against it still + compiles and still catches. +- **No new dependency.** The Gitea API is called with `HttpClient` and read with + source-generated `System.Text.Json`. Both are tested offline through a fake + 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. +- **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. +- **The token goes to the account's server and nowhere else.** + - Every API request is made to the configured address, with the token in the + `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"). + - 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. +- **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, + an empty page does. +- **An account that is not an organization** is listed as on GitHub: the token's + 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. + +## Alternatives considered + +- **A Gitea client package.** One more dependency to track and to trust with the + token, for six endpoints. +- **Using `clone_url` from the answers.** It would follow a server whose configured + root address differs from the one the user reaches it by (a reverse proxy), but + it lets the server name the host that receives the token for git. Building the + address also makes such a server work. +- **Following redirects on the same host.** Simpler to refuse them all than to + decide which are the same host. +- **Allowing `http://` for a server on a local network, or a switch to accept any + certificate.** Both put the token on the wire, or in the hands of whoever answers, + in exactly the setups where nobody is watching. The supported route for a private + authority is to trust it in Windows. + +## Consequences + +- A server reachable only through a sign-in page or a single-sign-on proxy does not + work: the answer is a page, not the API, and gclo says that the address does not + answer like a Gitea server. +- A token needs to be allowed to read repositories, organizations and the user + (`read:repository`, `read:organization`, `read:user`). Without the second, the + organization list holds the personal account only and an organization is typed + by hand, as on GitHub. +- The git client uses the same credential form as for GitHub (a fixed user name, + the token as the password). Gitea accepts a token as the password with any user + name; this is to be confirmed against a live server before accounts can use a + 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. +- Accounts, the command line and the desktop app follow in their own changes + (#201). Until then the app and the CLI behave as before. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 037d726..b776eef 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -27,6 +27,7 @@ about the user's accounts or repositories. It sends nothing anywhere else. | gclo app / CLI | `gclo.exe` (WinUI 3) and the CLI `gclo.exe`, same engine | Trusted; runs as the user | | GitHub REST API | Lists organizations and repositories | Untrusted input (responses are parsed), authenticated by the PAT | | Git remotes (`github.com`) | Clone and fetch over HTTPS via libgit2 | Untrusted input (repository content, including file paths) | +| Gitea server at an address the user names | Lists organizations and repositories through its REST API (ADR 0010). At this revision in the engine library only: accounts, the CLI and the app do not use it yet (#201) | Untrusted beyond being the place the user chose to send the account's token to: its answers are parsed, bounded, and never decide where the token goes | | Windows Credential Manager | Stores saved-account and default tokens | Trusted store; boundary is the Windows user account | | Local data directory | `%LOCALAPPDATA%\KofTwentyTwo\gclo` (or `GCLO_DATA_DIR`): settings, accounts, logs. Deliberately outside the Velopack install root `%LOCALAPPDATA%\gclo`, which Setup.exe deletes on reinstall (#91) | Trusted; same user | | Target folder | Where repositories are written; may already contain repositories | Partly untrusted (pre-existing content, `.git` config) | @@ -99,6 +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://.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) | | 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 @@ -130,3 +132,4 @@ flowchart LR | 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | Update feed and release downloads moved from GitHub Releases to the project's releases on `git.kof22.com` (ADR 0009, #170): the in-app updater reads the feed with Velopack's Gitea source, anonymously over HTTPS, and the Scoop and Chocolatey manifests download from there. The updater verifies packages against the hashes in the feed, as before; it has never checked a signature or an attestation. The trust that was placed in GitHub's release hosting (account security, immutable releases) is now placed in a self-hosted server: whoever can publish a release there, or holds its TLS certificate or DNS, can deliver an update that installs without a warning. T1 is rewritten to say so and its status changed from mitigated to accepted. The manifests still pin a SHA-256 (T2). | | 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | Corrections from the release reviews (#183): the header names the version reviewed; section 1 says that an installed build contacts `git.kof22.com` for updates; the accepted risks no longer say the binaries are unsigned (they are signed, ADR 0008) and name T1 and the Scoop part of T2 instead; T2 and T11 say what ADR 0009 changed (the Scoop manifest and the file it names come from one server; the copy of release assets to that server is manual and outside the attested pipeline). | | 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T4: a proxy named in a folder's own git configuration, and its own `http.followRedirects`, are refused like a URL rewrite, so the folder does not choose the server a fetch is made through or sent on to (the token was not exposed by the proxy: TLS is end to end). T3: a repository whose name is git's own folder name, in any spelling Windows opens as that folder, fails its row in the sync on every platform, not only on Windows, and is refused by the WSL clone (#184). | +| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 added with the Gitea listers of the engine (ADR 0010, #202): a server at an address the user names is a new component and a new place a token is sent to. The rules that keep the token on that server are stated there. Accounts, the CLI and the app do not use a Gitea server yet. | diff --git a/gclo.Engine.Tests/FakeGiteaServer.cs b/gclo.Engine.Tests/FakeGiteaServer.cs new file mode 100644 index 0000000..254c26d --- /dev/null +++ b/gclo.Engine.Tests/FakeGiteaServer.cs @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Collections.Concurrent; +using System.Net; +using System.Text; + + +namespace gclo.Engine.Tests; + + +/// +/// A Gitea server for the offline suite: answers the requests of +/// from a table of paths, and records what it was asked and with which token. +/// Anything not in the table is answered the way Gitea answers an unknown path: 404 +/// with a JSON reason. +/// +internal sealed class FakeGiteaServer : HttpMessageHandler +{ + /// The address accounts of this server are given. + public static readonly Uri Address = new("https://git.example.test/"); + + private readonly ConcurrentQueue<(Uri Address, string? Authorization)> _requests = new(); + + private readonly ConcurrentDictionary> _answers = new(StringComparer.Ordinal); + + + + /// Called for every request before it is answered; throwing here is a request that fails on the way. + public Func? OnRequest { get; set; } + + /// What a path that is not in the table is answered with; Gitea's own 404 when null. + public Func? Otherwise { get; set; } + + /// Every request so far: its address and its Authorization header. + public IReadOnlyList<(Uri Address, string? Authorization)> Requests => [.. _requests]; + + /// The paths (with query, after /api/v1/) asked for so far. + public IReadOnlyList Asked => [.. _requests.Select(r => r.Address.PathAndQuery["/api/v1/".Length..])]; + + + + /// A client of this server at . + public GiteaApi Api(TimeSpan? requestTimeout = null) => new(Address, new HttpClient(this), requestTimeout); + + + + /// Answers (after /api/v1/, with its query) with what makes. + public FakeGiteaServer On(string path, Func answer) + { + _answers[path] = answer; + return this; + } + + + + /// Answers page of the list at with . + /// The list's path, without query. + /// The page, from 1. + /// The JSON of each item. + /// What the Link header says about a next page; null for no Link header. + public FakeGiteaServer OnPage(string path, int page, IEnumerable items, bool? more = null) + => On($"{path}?page={page}&limit={GiteaApi.PageSize}", () => Json($"[{string.Join(',', items)}]", link: more)); + + + + /// A JSON answer. + public static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK, bool? link = null) + { + var response = new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + if(link is bool more) + { + response.Headers.TryAddWithoutValidation( + "Link", + more + ? "; rel=\"next\",; rel=\"last\"" + : "; rel=\"first\""); + } + return response; + } + + + + /// The answer of a refused request, as Gitea gives it. + public static HttpResponseMessage Refusal(HttpStatusCode status, string message) + => Json($$"""{"message":"{{message}}","url":"https://git.example.test/api/swagger"}""", status); + + + + /// A repository as the API describes it. + public static string Repo( + string name, string? owner = "acme", string? branch = "main", bool archived = false, bool empty = false) + { + string ownerJson = owner is null ? "null" : $$"""{"login":"{{owner}}","id":7}"""; + string branchJson = branch is null ? "null" : $"\"{branch}\""; + return $$""" + {"id":1,"name":"{{name}}","full_name":"{{owner}}/{{name}}","owner":{{ownerJson}}, + "clone_url":"https://attacker.example.test/{{owner}}/{{name}}.git","default_branch":{{branchJson}}, + "archived":{{(archived ? "true" : "false")}},"empty":{{(empty ? "true" : "false")}},"mirror":false} + """; + } + + + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _requests.Enqueue((request.RequestUri!, request.Headers.Authorization?.ToString())); + if(OnRequest is { } onRequest) + { + await onRequest(request, cancellationToken); + } + cancellationToken.ThrowIfCancellationRequested(); + + string path = request.RequestUri!.PathAndQuery; + string key = path.StartsWith("/api/v1/", StringComparison.Ordinal) ? path["/api/v1/".Length..] : path; + return _answers.TryGetValue(key, out Func? answer) + ? answer() + : Otherwise?.Invoke() ?? Refusal(HttpStatusCode.NotFound, "Not Found"); + } +} diff --git a/gclo.Engine.Tests/ForgeAccessExceptionTests.cs b/gclo.Engine.Tests/ForgeAccessExceptionTests.cs new file mode 100644 index 0000000..8f4e4d9 --- /dev/null +++ b/gclo.Engine.Tests/ForgeAccessExceptionTests.cs @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine.Tests; + + +/// +/// One failure type for every forge: code that handles +/// handles the GitHub listers' failures too, and code written against +/// before there was a second forge keeps working. +/// +public sealed class ForgeAccessExceptionTests +{ + [Fact] + public void TheKindsOfBothTypes_AreTheSame_NameForNameAndValueForValue() + { + Assert.Equal(Enum.GetNames(), Enum.GetNames(), StringComparer.Ordinal); + Assert.Equal( + Enum.GetValues().Select(kind => (int)kind), + Enum.GetValues().Select(kind => (int)kind)); + } + + + + [Theory] + [InlineData(GitHubAccessKind.Unauthorized, ForgeAccessKind.Unauthorized)] + [InlineData(GitHubAccessKind.Forbidden, ForgeAccessKind.Forbidden)] + [InlineData(GitHubAccessKind.RateLimited, ForgeAccessKind.RateLimited)] + [InlineData(GitHubAccessKind.NotFound, ForgeAccessKind.NotFound)] + [InlineData(GitHubAccessKind.Unavailable, ForgeAccessKind.Unavailable)] + public void AGitHubFailure_IsAForgeFailureOfTheSameKind(GitHubAccessKind gitHub, ForgeAccessKind forge) + { + var cause = new TimeoutException("cause"); + var failure = new GitHubAccessException(gitHub, "what happened", cause); + + ForgeAccessException asForge = failure; + InvalidOperationException asBefore = failure; + + Assert.Equal(gitHub, failure.Kind); + Assert.Equal(forge, asForge.Kind); + Assert.Equal("what happened", asBefore.Message); + Assert.Same(cause, asBefore.InnerException); + } + + + + [Fact] + public void AForgeFailure_CarriesItsKind_ItsMessage_AndItsCause() + { + var cause = new TimeoutException("cause"); + + var failure = new ForgeAccessException(ForgeAccessKind.RateLimited, "slow down", cause); + + Assert.Equal(ForgeAccessKind.RateLimited, failure.Kind); + Assert.Equal("slow down", failure.Message); + Assert.Same(cause, failure.InnerException); + Assert.Null(new ForgeAccessException(ForgeAccessKind.NotFound, "nothing there").InnerException); + } +} diff --git a/gclo.Engine.Tests/GitHubListerContractTests.cs b/gclo.Engine.Tests/GitHubListerContractTests.cs new file mode 100644 index 0000000..4adf2cc --- /dev/null +++ b/gclo.Engine.Tests/GitHubListerContractTests.cs @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Net; +using Octokit; + + +namespace gclo.Engine.Tests; + + +/// The lister contract, kept by the GitHub listers. +public sealed class GitHubListerContractTests : ListerContractTests +{ + private readonly GitHubListerTests.FakeGateway _gateway = new(); + + private int _requests; + + + + /// + protected override IRepositoryLister Repositories => new GitHubRepositoryLister(_ => _gateway); + + /// + protected override IOrganizationLister Organizations => new GitHubOrganizationLister(_ => _gateway); + + /// + protected override int RequestsMade => _requests; + + + + public GitHubListerContractTests() + { + // Until a test says otherwise: no organization is known, and every request is counted. + _gateway.OrgPages = (_, _) => Asked>(() => throw NotFound()); + _gateway.OwnPages = _ => Asked>(() => []); + _gateway.UserPages = (_, _) => Asked>(() => throw NotFound()); + _gateway.CurrentUser = () => Asked(() => "me"); + _gateway.OrgLogins = () => Asked>(() => []); + _gateway.AccountKind = _ => Asked(() => GitHubAccountKind.NotFound); + } + + + + private static NotFoundException NotFound() => new("nope", HttpStatusCode.NotFound); + + + + private Task Asked(Func answer) + { + Interlocked.Increment(ref _requests); + try + { + return Task.FromResult(answer()); + } + catch(Exception failure) + { + return Task.FromException(failure); + } + } + + + + private void EveryRequestFailsWith(Func failure) + { + _gateway.OrgPages = (_, _) => Asked>(() => throw failure()); + _gateway.CurrentUser = () => Asked(() => throw failure()); + } + + + + /// + protected override void GivenAnOrganization(string name, params string[] repositories) + { + Func>> before = _gateway.OrgPages; + _gateway.OrgPages = (organization, page) => string.Equals(organization, name, StringComparison.Ordinal) + ? Asked>(() => page == 1 + ? [.. repositories.Select(repository => new GitHubRepo(repository, $"https://github.example.test/{name}/{repository}.git", "main", false))] + : []) + : before(organization, page); + } + + + + /// + protected override void GivenTheTokenBelongsTo(string account, params string[] organizations) + { + _gateway.CurrentUser = () => Asked(() => account); + _gateway.OrgLogins = () => Asked>(() => organizations); + } + + + + /// + protected override void GivenNothingIsCalled(string name) + { + // Nothing to set up: what this gateway does not know is not found. + } + + + + /// + protected override void GivenTheTokenIsRejected() + => EveryRequestFailsWith(() => new AuthorizationException(HttpStatusCode.Unauthorized, null)); + + + + /// + protected override void GivenTheForgeFails() + => EveryRequestFailsWith(() => new ApiException("down", HttpStatusCode.ServiceUnavailable)); +} diff --git a/gclo.Engine.Tests/GitHubListerTests.cs b/gclo.Engine.Tests/GitHubListerTests.cs index 0672307..d02650b 100644 --- a/gclo.Engine.Tests/GitHubListerTests.cs +++ b/gclo.Engine.Tests/GitHubListerTests.cs @@ -17,7 +17,8 @@ namespace gclo.Engine.Tests; /// public sealed class GitHubListerTests { - private sealed class FakeGateway : IGitHubGateway + /// A GitHub gateway whose every call is scripted. Also used by . + internal sealed class FakeGateway : IGitHubGateway { public Func>> OrgPages { get; set; } = (_, _) => Task.FromResult>([]); diff --git a/gclo.Engine.Tests/GiteaListerContractTests.cs b/gclo.Engine.Tests/GiteaListerContractTests.cs new file mode 100644 index 0000000..c687878 --- /dev/null +++ b/gclo.Engine.Tests/GiteaListerContractTests.cs @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Net; +using static gclo.Engine.Tests.FakeGiteaServer; + + +namespace gclo.Engine.Tests; + + +/// The lister contract, kept by the Gitea listers. +public sealed class GiteaListerContractTests : ListerContractTests, IDisposable +{ + private readonly FakeGiteaServer _server = new(); + + + + public void Dispose() => _server.Dispose(); + + + + /// + protected override IRepositoryLister Repositories => new GiteaRepositoryLister(_server.Api()); + + /// + protected override IOrganizationLister Organizations => new GiteaOrganizationLister(_server.Api()); + + /// + protected override int RequestsMade => _server.Requests.Count; + + + + /// + protected override void GivenAnOrganization(string name, params string[] repositories) + => _server.OnPage($"orgs/{name}/repos", 1, repositories.Select(repository => Repo(repository, owner: name)), more: false); + + + + /// + protected override void GivenTheTokenBelongsTo(string account, params string[] organizations) + => _server + .On("user", () => Json($$"""{"id":1,"login":"{{account}}"}""")) + .OnPage("user/orgs", 1, organizations.Select(organization => $$"""{"id":2,"username":"{{organization}}"}"""), more: false); + + + + /// + protected override void GivenNothingIsCalled(string name) + { + // Nothing to set up: what the server does not know, it answers with 404. + } + + + + /// + protected override void GivenTheTokenIsRejected() + => _server.Otherwise = () => Refusal(HttpStatusCode.Unauthorized, "invalid username, password or token"); + + + + /// + protected override void GivenTheForgeFails() + => _server.Otherwise = () => Refusal(HttpStatusCode.ServiceUnavailable, "down for maintenance"); +} diff --git a/gclo.Engine.Tests/GiteaListerTests.cs b/gclo.Engine.Tests/GiteaListerTests.cs new file mode 100644 index 0000000..cd27cd2 --- /dev/null +++ b/gclo.Engine.Tests/GiteaListerTests.cs @@ -0,0 +1,672 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Net; +using static gclo.Engine.Tests.FakeGiteaServer; + + +namespace gclo.Engine.Tests; + + +/// +/// The Gitea listers over a fake server: paging, the account fallback, what is and is +/// not taken from the server's answers, and what every kind of failure means. The +/// server's address is one a user typed, so the tests also pin where the token goes +/// and that nothing the server says moves it elsewhere. +/// +public sealed class GiteaListerTests : IDisposable +{ + private const string Token = "gitea-test-token"; + + private const string Host = "git.example.test"; + + private readonly FakeGiteaServer _server = new(); + + + + public void Dispose() => _server.Dispose(); + + + + private GiteaRepositoryLister Repos(TimeSpan? requestTimeout = null) => new(_server.Api(requestTimeout)); + + + + private GiteaOrganizationLister Orgs() => new(_server.Api()); + + + + private static string Account(string login) => $$"""{"id":1,"login":"{{login}}","username":"","email":"someone@example.test"}"""; + + + + private void TheTokenBelongsTo(string login) => _server.On("user", () => Json(Account(login))); + + + + private Task> List(string organization = "acme", CancellationToken cancellationToken = default) + => Repos().ListOrganizationRepositoriesAsync(organization, Token, cancellationToken); + + + + /// A body that never ends: a server that keeps sending. + private sealed class Endless : Stream + { + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + + + public override void Flush() + { + } + + + + public override int Read(byte[] buffer, int offset, int count) + { + Array.Fill(buffer, (byte)' ', offset, count); + return count; + } + + + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + + + public override void SetLength(long value) => throw new NotSupportedException(); + + + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + + + // ---------------------------------------------------------------- paging + + [Fact] + public async Task Repos_PagesWhileTheServerSaysThereIsANextPage_AndSortsByName() + { + // The second page is empty (everything on it was hidden from the token) and + // the first is not full: neither ends a list the server says goes on. + _server + .OnPage("orgs/acme/repos", 1, [Repo("zeta"), Repo("Alpha")], more: true) + .OnPage("orgs/acme/repos", 2, [], more: true) + .OnPage("orgs/acme/repos", 3, [Repo("mid")], more: false); + + IReadOnlyList repos = await List(); + + Assert.Equal(["Alpha", "mid", "zeta"], repos.Select(r => r.Name), StringComparer.Ordinal); + Assert.Equal( + ["orgs/acme/repos?page=1&limit=50", "orgs/acme/repos?page=2&limit=50", "orgs/acme/repos?page=3&limit=50"], + _server.Asked, + StringComparer.Ordinal); + } + + + + [Fact] + public async Task Repos_WhereTheServerDoesNotSay_AnEmptyPageEndsTheList() + { + _server + .OnPage("orgs/acme/repos", 1, [Repo("a")]) + .OnPage("orgs/acme/repos", 2, [Repo("b")]) + .OnPage("orgs/acme/repos", 3, []); + + IReadOnlyList repos = await List(); + + Assert.Equal(["a", "b"], repos.Select(r => r.Name), StringComparer.Ordinal); + Assert.Equal(3, _server.Asked.Count); + } + + + + [Fact] + public async Task Repos_AListThatDoesNotEnd_IsNotFollowedForEver() + { + for(int page = 1; page <= GiteaApi.MaxPages; page++) + { + _server.OnPage("orgs/acme/repos", page, [Repo("same")], more: true); + } + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + Assert.Contains("did not answer like a Gitea server", ex.Message); + Assert.Equal(GiteaApi.MaxPages, _server.Asked.Count); + } + + + + [Fact] + public async Task Repos_DuplicateNames_AreListedOnce() + { + _server.OnPage("orgs/acme/repos", 1, [Repo("Tool"), Repo("tool"), Repo("other")], more: false); + + IReadOnlyList repos = await List(); + + Assert.Equal(["other", "Tool"], repos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + // ---------------------------------------------------------------- what is taken from the answers + + [Fact] + public async Task Repos_MapFields_AndTheCloneAddressIsBuiltFromTheAccountsServer_NotTakenFromTheAnswer() + { + _server.OnPage( + "orgs/acme/repos", + 1, + [Repo("tool", archived: true), Repo("fresh", empty: true), Repo("odd", branch: ""), Repo("bare", branch: null), """{"name":" "}""", "null"], + more: false); + + IReadOnlyList repos = await List(); + + Assert.Equal( + [ + new RepoDescriptor("bare", $"https://{Host}/acme/bare.git", null, false), + new RepoDescriptor("fresh", $"https://{Host}/acme/fresh.git", null, false), // no commits: no branch to speak of + new RepoDescriptor("odd", $"https://{Host}/acme/odd.git", null, false), + new RepoDescriptor("tool", $"https://{Host}/acme/tool.git", "main", true), + ], + repos); + Assert.DoesNotContain(repos, r => r.CloneUrl.Contains("attacker", StringComparison.Ordinal)); + } + + + + [Fact] + public async Task Repos_AServerUnderAPath_IsAskedThere_AndClonedFromThere() + { + var server = new FakeGiteaServer(); + server.On("/gitea/api/v1/orgs/acme/repos?page=1&limit=50", () => Json($"[{Repo("tool")}]", link: false)); + var lister = new GiteaRepositoryLister(new GiteaApi(new Uri("https://example.test/gitea"), new HttpClient(server))); + + IReadOnlyList repos = await lister.ListOrganizationRepositoriesAsync("acme", Token); + + Assert.Equal("https://example.test/gitea/acme/tool.git", Assert.Single(repos).CloneUrl); + } + + + + [Fact] + public async Task EveryRequest_GoesToTheAccountsServer_WithTheTokenInItsHeader_WhateverTheAnswersPointAt() + { + // The Link headers and the clone addresses of the answers name other hosts. + _server + .OnPage("orgs/acme/repos", 1, [Repo("a")], more: true) + .OnPage("orgs/acme/repos", 2, [Repo("b")], more: false); + + await List(); + + Assert.Equal(2, _server.Requests.Count); + Assert.All(_server.Requests, request => + { + Assert.Equal(Uri.UriSchemeHttps, request.Address.Scheme); + Assert.Equal(Host, request.Address.Host); + Assert.Equal("token " + Token, request.Authorization); + Assert.DoesNotContain(Token, request.Address.AbsoluteUri, StringComparison.Ordinal); + }); + } + + + + // ---------------------------------------------------------------- an account that is not an organization + + [Fact] + public async Task Repos_OfTheTokensOwnAccount_AreItsOwn_NotThoseOfOthersItMayWorkOn() + { + TheTokenBelongsTo("Me"); + _server.OnPage( + "user/repos", + 1, + [Repo("mine", owner: "Me"), Repo("theirs", owner: "acme"), Repo("ownerless", owner: null)], + more: false); + + IReadOnlyList repos = await List("me"); + + Assert.Equal(["mine", "ownerless"], repos.Select(r => r.Name), StringComparer.Ordinal); + Assert.Equal($"https://{Host}/Me/mine.git", repos[0].CloneUrl); + Assert.Equal($"https://{Host}/me/ownerless.git", repos[1].CloneUrl); // no owner in the answer: the name that was asked for + Assert.Equal(["orgs/me/repos?page=1&limit=50", "user", "user/repos?page=1&limit=50"], _server.Asked, StringComparer.Ordinal); + } + + + + [Fact] + public async Task Repos_OfAnotherAccount_AreWhatTheTokenSeesThere() + { + TheTokenBelongsTo("me"); + _server.OnPage("users/bob/repos", 1, [Repo("b", owner: "bob")], more: false); + + IReadOnlyList repos = await List("bob"); + + Assert.Equal($"https://{Host}/bob/b.git", Assert.Single(repos).CloneUrl); + } + + + + [Fact] + public async Task Repos_NeitherOrganizationNorAccount_IsNotFound_WithASpellingHint() + { + TheTokenBelongsTo("me"); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List("nobody")); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + Assert.Contains($"'nobody' was found neither as an organization nor as a user account on {Host} (404)", ex.Message); + Assert.Contains("Check the spelling", ex.Message); + } + + + + [Fact] + public async Task Repos_WhenTheServerNamesNoAccountForTheToken_ItDoesNotAnswerLikeAGiteaServer() + { + _server.On("user", () => Json("""{"id":1}""")); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + Assert.Contains("did not answer like a Gitea server", ex.Message); + } + + + + // ---------------------------------------------------------------- arguments and cancellation + + [Theory] + [InlineData("../escape")] + [InlineData("nested/child")] + [InlineData("name:stream")] + public async Task Repos_ANameThatIsNotOnePlainName_IsRefusedBeforeAnyRequest(string organization) + { + ForgeAccessException ex = await Assert.ThrowsAsync(() => List(organization)); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + Assert.Contains("is not an organization or account name", ex.Message); + Assert.Empty(_server.Requests); + } + + + + [Theory] + [InlineData("", Token)] + [InlineData(" ", Token)] + [InlineData("acme", "")] + [InlineData("acme", " ")] + public async Task Repos_BlankArguments_Throw(string organization, string token) + { + await Assert.ThrowsAnyAsync(() => Repos().ListOrganizationRepositoriesAsync(organization, token)); + Assert.Empty(_server.Requests); + } + + + + [Fact] + public async Task Repos_AlreadyCanceled_ThrowBeforeAnyRequest() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => List(cancellationToken: cts.Token)); + Assert.Empty(_server.Requests); + } + + + + [Fact] + public async Task Repos_CanceledBetweenPages_Throw_AndAskForNoMore() + { + using var cts = new CancellationTokenSource(); + _server + .On("orgs/acme/repos?page=1&limit=50", () => + { + cts.Cancel(); // while the first page is on its way + return Json($"[{Repo("a")}]", link: true); + }) + .OnPage("orgs/acme/repos", 2, [Repo("b")], more: false); + + await Assert.ThrowsAnyAsync(() => List(cancellationToken: cts.Token)); + Assert.Single(_server.Asked); + } + + + + [Fact] + public async Task Repos_CanceledDuringARequestThatHangs_IsACancellation_NotAFailureOfTheServer() + { + using var cts = new CancellationTokenSource(); + _server.OnRequest = async (_, token) => + { + cts.CancelAfter(TimeSpan.FromMilliseconds(20)); + await Task.Delay(Timeout.Infinite, token); + }; + + await Assert.ThrowsAnyAsync(() => List(cancellationToken: cts.Token)); + } + + + + // ---------------------------------------------------------------- failures + + [Theory] + [InlineData(HttpStatusCode.Unauthorized, ForgeAccessKind.Unauthorized, "rejected the token (401)")] + [InlineData(HttpStatusCode.TooManyRequests, ForgeAccessKind.RateLimited, "limiting requests from this token (429")] + [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)")] + 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")); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.Equal(kind, ex.Kind); + Assert.Contains(said, ex.Message); + Assert.Single(_server.Requests); // nothing is asked a second time, and a redirect is not followed + Assert.IsType(ex.InnerException); + } + + + + [Fact] + public async Task AnAddressWithNoApiBehindIt_SaysSo_InsteadOfBlamingTheNameThatWasAskedFor() + { + // Every path is 404, the 'who am I' of the account fallback too. + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + Assert.Contains($"There is no Gitea API at this address of {Host} (HTTP 404). Check the server address of this account.", ex.Message); + } + + + + [Fact] + public async Task ARefusal_CarriesTheServersReason_OnOneLine_AndSaysWhichScopesAreNeeded() + { + _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]")); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.Equal(ForgeAccessKind.Forbidden, ex.Kind); + Assert.Equal( + $"{Host} refused access (403): token does not have at least one of required scope(s): [read:organization]. " + + "Check what the access token is allowed to do: gclo reads repositories, organizations and the user " + + "(the scopes read:repository, read:organization and read:user).", + ex.Message); + } + + + + [Theory] + [InlineData("Blocked by the web filter")] + [InlineData("""{"error":"no message here"}""")] + [InlineData("")] + public async Task ARefusalWithoutAReasonFromTheApi_SaysNone_AndCopiesNoPage(string body) + { + _server.On("orgs/acme/repos?page=1&limit=50", () => Json(body, HttpStatusCode.Forbidden)); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.StartsWith($"{Host} refused access (403). Check what the access token is allowed to do", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("web filter", ex.Message, StringComparison.Ordinal); + } + + + + [Fact] + public async Task ARefusalThatDoesNotEnd_IsNotReadForItsReason() + { + _server.On( + "orgs/acme/repos?page=1&limit=50", + () => new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StreamContent(new Endless()) }); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.StartsWith($"{Host} refused access (403). ", ex.Message, StringComparison.Ordinal); + } + + + + [Theory] + [InlineData("Sign in")] // a sign-in page in front of the server + [InlineData("null")] + [InlineData("""{"not":"a list"}""")] + [InlineData("""[{"name":5}]""")] + public async Task AnAnswerThatIsNotTheApisJson_SaysThatTheAddressIsNotAGiteaServers(string body) + { + _server.On("orgs/acme/repos?page=1&limit=50", () => Json(body)); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + Assert.Contains($"{Host} did not answer like a Gitea server. Check the server address of this account.", ex.Message); + Assert.DoesNotContain("Sign in", ex.Message, StringComparison.Ordinal); + } + + + + [Fact] + public async Task AnAnswerThatDoesNotEnd_IsNotReadForEver() + { + _server.On( + "orgs/acme/repos?page=1&limit=50", + () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new Endless()) }); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + Assert.Contains("did not answer like a Gitea server", ex.Message); + } + + + + [Fact] + public async Task AServerThatCannotBeReached_IsUnavailable_InWordsThatAreNotThePlatformsOwn() + { + _server.OnRequest = (_, _) => throw new HttpRequestException( + HttpRequestError.NameResolutionError, "proxy http://user:hunter2@proxy.example.test said no"); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => List()); + + Assert.Equal(ForgeAccessKind.Unavailable, ex.Kind); + Assert.Equal($"{Host} could not be reached: its name could not be resolved (DNS). Check the network connection and retry.", ex.Message); + } + + + + [Fact] + public async Task ARequestThatTakesTooLong_IsUnavailable_NotACancellation() + { + _server.OnRequest = (_, token) => Task.Delay(Timeout.Infinite, token); + + ForgeAccessException ex = await Assert.ThrowsAsync( + () => 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); + } + + + + [Fact] + public async Task AFailureWithNoKnownMeaning_StaysWhatItIs() + { + _server.OnRequest = (_, _) => throw new InvalidOperationException("something nobody planned for"); + + InvalidOperationException ex = await Assert.ThrowsAsync(() => List()); + + Assert.IsNotType(ex); + Assert.Equal("something nobody planned for", ex.Message); + } + + + + [Fact] + public void Failures_WhatHasNoKnownMeaning_IsNotTranslated_AndACancellationByTheCallerIsNotATimeout() + { + GiteaApi api = _server.Api(); + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + + Assert.False(api.Knows(new InvalidOperationException("?"), CancellationToken.None)); + Assert.Throws(() => api.Translate(new InvalidOperationException("?"))); + Assert.True(api.Knows(new OperationCanceledException(), CancellationToken.None)); + Assert.False(api.Knows(new OperationCanceledException(), canceled.Token)); + + // Decided when it was asked: a cancellation that arrives afterwards changes nothing. + Assert.Equal(ForgeAccessKind.Unavailable, api.Translate(new OperationCanceledException()).Kind); + } + + + + // ---------------------------------------------------------------- organizations + + [Fact] + public async Task Orgs_TheTokensOwnAccountFirst_ThenItsOrganizationsSorted_EachOnce() + { + TheTokenBelongsTo("me"); + _server.OnPage( + "user/orgs", + 1, + ["""{"id":2,"username":"zeta"}""", """{"id":3,"username":"Alpha","login":""}""", """{"id":4,"username":"ME"}""", "null", """{"id":5}""", """{"id":6,"username":"alpha"}"""], + more: false); + + IReadOnlyList names = await Orgs().ListOrganizationsAsync(Token); + + Assert.Equal(["me", "Alpha", "zeta"], names, StringComparer.Ordinal); + Assert.All(_server.Requests, request => Assert.Equal("token " + Token, request.Authorization)); + } + + + + [Fact] + public async Task Orgs_ATokenThatMayNotListOrganizations_StillGetsItsOwnAccount() + { + TheTokenBelongsTo("me"); + _server.On( + "user/orgs?page=1&limit=50", + () => Refusal(HttpStatusCode.Forbidden, "token does not have at least one of required scope(s): [read:organization]")); + + IReadOnlyList names = await Orgs().ListOrganizationsAsync(Token); + + Assert.Equal(["me"], names, StringComparer.Ordinal); + } + + + + [Fact] + public async Task Orgs_ATokenThatMayNotReadItsOwnAccount_IsForbidden_WithTheServersReason() + { + _server.On("user", () => Refusal(HttpStatusCode.Forbidden, "token does not have at least one of required scope(s): [read:user]")); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => Orgs().ListOrganizationsAsync(Token)); + + Assert.Equal(ForgeAccessKind.Forbidden, ex.Kind); + Assert.Contains("[read:user]", ex.Message); + } + + + + [Fact] + public async Task Orgs_ATokenTheServerRejects_IsUnauthorized() + { + _server.On("user", () => Refusal(HttpStatusCode.Unauthorized, "invalid username, password or token")); + + ForgeAccessException ex = await Assert.ThrowsAsync(() => Orgs().ListOrganizationsAsync(Token)); + + Assert.Equal(ForgeAccessKind.Unauthorized, ex.Kind); + Assert.Equal($"{Host} rejected the token (401). Check that the access token is complete and has not been deleted or expired.", ex.Message); + } + + + + [Fact] + public async Task Orgs_AlreadyCanceled_OrABlankToken_AskNothing() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => Orgs().ListOrganizationsAsync(Token, cts.Token)); + await Assert.ThrowsAnyAsync(() => Orgs().ListOrganizationsAsync(" ")); + Assert.Empty(_server.Requests); + } + + + + [Fact] + public async Task Orgs_CanceledAfterTheAccountWasRead_Throw() + { + using var cts = new CancellationTokenSource(); + TheTokenBelongsTo("me"); + _server.On("user/orgs?page=1&limit=50", () => + { + cts.Cancel(); // while the answer is on its way + return Json("[]", link: false); + }); + + await Assert.ThrowsAnyAsync(() => Orgs().ListOrganizationsAsync(Token, cts.Token)); + } + + + + // ---------------------------------------------------------------- construction + + [Fact] + public void Listers_AreMadeForAnHttpsAddress_AndForNoOther() + { + _ = new GiteaRepositoryLister(new Uri("https://git.example.test")); + _ = new GiteaOrganizationLister(new Uri("https://git.example.test/gitea/")); + + ArgumentException plain = Assert.Throws(() => new GiteaRepositoryLister(new Uri("http://git.example.test"))); + Assert.Contains("it has to begin with https://", plain.Message); + Assert.Throws(() => new GiteaOrganizationLister(new Uri("https://user:secret@git.example.test"))); + Assert.Throws(() => new GiteaRepositoryLister((Uri)null!)); + Assert.Throws(() => new GiteaRepositoryLister((GiteaApi)null!)); + Assert.Throws(() => new GiteaOrganizationLister((GiteaApi)null!)); + } + + + + [Fact] + public void InProduction_RedirectsAreNotFollowed_AndCertificatesAreJudgedByThePlatformAlone() + { + // A redirect would carry the request, and with it the token, to another host; + // a validation callback is how certificate checks are switched off. + Assert.False(GiteaApi.SharedHandler.AllowAutoRedirect); + Assert.Null(GiteaApi.SharedHandler.SslOptions.RemoteCertificateValidationCallback); + } + + + + [Fact] + public void TheServer_IsNamedInMessages_ByItsHost_AndItsPortWhenThatIsNotTheUsual() + { + Assert.Equal("git.example.test", new GiteaApi(new Uri("https://git.example.test/gitea")).Host); + 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); + } +} diff --git a/gclo.Engine.Tests/GiteaServerUrlTests.cs b/gclo.Engine.Tests/GiteaServerUrlTests.cs new file mode 100644 index 0000000..3685098 --- /dev/null +++ b/gclo.Engine.Tests/GiteaServerUrlTests.cs @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine.Tests; + + +/// +/// What may be the address of a Gitea server: the place an account's token is sent +/// to and its repositories are cloned from. +/// +public sealed class GiteaServerUrlTests +{ + [Theory] + [InlineData("git.example.test", "https://git.example.test/")] // typed without a scheme: https + [InlineData("https://git.example.test", "https://git.example.test/")] + [InlineData(" https://git.example.test/ ", "https://git.example.test/")] + [InlineData("HTTPS://Git.Example.TEST", "https://git.example.test/")] + [InlineData("https://git.example.test:443/", "https://git.example.test/")] + [InlineData("https://git.example.test:8443", "https://git.example.test:8443/")] + [InlineData("git.example.test:8443/gitea", "https://git.example.test:8443/gitea/")] + [InlineData("https://example.test/gitea", "https://example.test/gitea/")] // a server under a path + [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 + public void AnAddress_BecomesTheServersBaseAddress(string typed, string expected) + { + Assert.True(GiteaServerUrl.TryNormalize(typed, out Uri? server, out string? reason)); + + Assert.Equal(expected, server.AbsoluteUri); + Assert.Null(reason); + } + + + + [Theory] + [InlineData(null, "it is empty")] + [InlineData("", "it is empty")] + [InlineData(" ", "it is empty")] + [InlineData("http://git.example.test", "it has to begin with https://")] // the token never travels unencrypted + [InlineData("ftp://git.example.test", "it has to begin with https://")] + [InlineData("ssh://git@git.example.test", "it has to begin with https://")] + [InlineData("https://user@git.example.test", "it contains a user name or a password")] + [InlineData("https://user:secret@git.example.test", "it contains a user name or a password")] + [InlineData("https://git.example.test/?next=1", "it has a '?' or a '#' part")] + [InlineData("https://git.example.test/#top", "it has a '?' or a '#' part")] + [InlineData("git example test", "it contains a space or a control character")] + [InlineData("https://git.example.test/\tx", "it contains a space or a control character")] + [InlineData("https://git.example.test/\u0007", "it contains a space or a control character")] + [InlineData("https://", "it is not an address")] + [InlineData("://nothing", "it is not an address")] + [InlineData("https://[not-an-address", "it is not an address")] + public void WhatCannotBeAServersAddress_IsRefused_WithTheReason(string? typed, string expected) + { + Assert.False(GiteaServerUrl.TryNormalize(typed, out Uri? server, out string? reason)); + + Assert.Null(server); + Assert.StartsWith(expected, reason, StringComparison.Ordinal); + } +} diff --git a/gclo.Engine.Tests/ListerContractTests.cs b/gclo.Engine.Tests/ListerContractTests.cs new file mode 100644 index 0000000..d4f02fb --- /dev/null +++ b/gclo.Engine.Tests/ListerContractTests.cs @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine.Tests; + + +/// +/// What the listers of every forge promise their callers, stated once and run against +/// each forge (see the classes that derive from this one). The sync engine, the view +/// models and the command line are written against these promises, not against a +/// forge. +/// +public abstract class ListerContractTests +{ + /// The token every call is made with. + protected const string Token = "contract-test-token"; + + + + /// The repository lister under test. + protected abstract IRepositoryLister Repositories { get; } + + /// The organization lister under test. + protected abstract IOrganizationLister Organizations { get; } + + + + /// The forge has an organization of that name whose list holds these repositories, in this order. + protected abstract void GivenAnOrganization(string name, params string[] repositories); + + + + /// The token belongs to that account, which is a member of these organizations (as the forge lists them). + protected abstract void GivenTheTokenBelongsTo(string account, params string[] organizations); + + + + /// The forge knows neither an organization nor an account of that name. + protected abstract void GivenNothingIsCalled(string name); + + + + /// The forge rejects the token. + protected abstract void GivenTheTokenIsRejected(); + + + + /// The forge fails on its side. + protected abstract void GivenTheForgeFails(); + + + + /// How many requests the forge has received. + protected abstract int RequestsMade { get; } + + + + [Fact] + public async Task Repositories_AreSortedByName_IgnoringCase_AndEachIsListedOnce() + { + GivenAnOrganization("acme", "zeta", "Alpha", "mid", "ALPHA"); + + IReadOnlyList repos = await Repositories.ListOrganizationRepositoriesAsync("acme", Token); + + Assert.Equal(["Alpha", "mid", "zeta"], repos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + [Fact] + public async Task Repositories_HaveAnHttpsCloneAddress_ThatEndsInTheirName() + { + GivenAnOrganization("acme", "tool"); + + RepoDescriptor repo = Assert.Single(await Repositories.ListOrganizationRepositoriesAsync("acme", Token)); + + var address = new Uri(repo.CloneUrl); + Assert.Equal(Uri.UriSchemeHttps, address.Scheme); + Assert.EndsWith("/tool.git", address.AbsolutePath, StringComparison.Ordinal); + } + + + + [Fact] + public async Task AnOrganizationWithoutRepositories_IsAnEmptyList_NotAFailure() + { + GivenAnOrganization("empty"); + + Assert.Empty(await Repositories.ListOrganizationRepositoriesAsync("empty", Token)); + } + + + + [Fact] + public async Task ANameNothingIsCalled_IsAForgeFailure_OfTheKindNotFound() + { + GivenTheTokenBelongsTo("me"); + GivenNothingIsCalled("nobody"); + + ForgeAccessException ex = await Assert.ThrowsAnyAsync( + () => Repositories.ListOrganizationRepositoriesAsync("nobody", Token)); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + } + + + + [Fact] + public async Task ARejectedToken_IsAForgeFailure_OfTheKindUnauthorized_FromBothListers() + { + GivenTheTokenIsRejected(); + + ForgeAccessException fromRepositories = await Assert.ThrowsAnyAsync( + () => Repositories.ListOrganizationRepositoriesAsync("acme", Token)); + ForgeAccessException fromOrganizations = await Assert.ThrowsAnyAsync( + () => Organizations.ListOrganizationsAsync(Token)); + + Assert.Equal(ForgeAccessKind.Unauthorized, fromRepositories.Kind); + Assert.Equal(ForgeAccessKind.Unauthorized, fromOrganizations.Kind); + Assert.DoesNotContain(Token, fromRepositories.Message, StringComparison.Ordinal); + Assert.DoesNotContain(Token, fromOrganizations.Message, StringComparison.Ordinal); + } + + + + [Fact] + public async Task AForgeThatFails_IsAForgeFailure_OfTheKindUnavailable_FromBothListers() + { + GivenTheForgeFails(); + + ForgeAccessException fromRepositories = await Assert.ThrowsAnyAsync( + () => Repositories.ListOrganizationRepositoriesAsync("acme", Token)); + ForgeAccessException fromOrganizations = await Assert.ThrowsAnyAsync( + () => Organizations.ListOrganizationsAsync(Token)); + + Assert.Equal(ForgeAccessKind.Unavailable, fromRepositories.Kind); + Assert.Equal(ForgeAccessKind.Unavailable, fromOrganizations.Kind); + } + + + + [Fact] + public async Task Organizations_BeginWithTheTokensOwnAccount_ThenTheRestSorted_EachOnce() + { + GivenTheTokenBelongsTo("me", "zeta", "Alpha", "ME"); + + IReadOnlyList names = await Organizations.ListOrganizationsAsync(Token); + + Assert.Equal(["me", "Alpha", "zeta"], names, StringComparer.Ordinal); + } + + + + [Fact] + public async Task ACallThatIsAlreadyCanceled_AsksTheForgeNothing() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => Repositories.ListOrganizationRepositoriesAsync("acme", Token, cts.Token)); + await Assert.ThrowsAnyAsync(() => Organizations.ListOrganizationsAsync(Token, cts.Token)); + + Assert.Equal(0, RequestsMade); + } + + + + [Theory] + [InlineData("", Token)] + [InlineData("acme", " ")] + public async Task BlankArguments_AreRefused_AsArguments(string organization, string token) + { + await Assert.ThrowsAnyAsync(() => Repositories.ListOrganizationRepositoriesAsync(organization, token)); + await Assert.ThrowsAnyAsync(() => Organizations.ListOrganizationsAsync(" ")); + + Assert.Equal(0, RequestsMade); + } + + + + [Fact] + public async Task ANameThatIsNotOnePlainName_IsNotFound_AndIsNotSentToTheForge() + { + ForgeAccessException ex = await Assert.ThrowsAnyAsync( + () => Repositories.ListOrganizationRepositoriesAsync("../elsewhere", Token)); + + Assert.Equal(ForgeAccessKind.NotFound, ex.Kind); + Assert.Equal(0, RequestsMade); + } +} diff --git a/gclo.Engine/ForgeAccessException.cs b/gclo.Engine/ForgeAccessException.cs new file mode 100644 index 0000000..aaef9f3 --- /dev/null +++ b/gclo.Engine/ForgeAccessException.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// +/// A failed request to a forge (a refusal, or the server not answering) translated +/// into an actionable message. Derives from +/// so every existing catch keeps working; lets a caller tell "fix +/// your token" from "wait and retry" without parsing the message. Listers of every +/// forge throw it; the GitHub listers throw the derived . +/// +public class ForgeAccessException : InvalidOperationException +{ + /// The category of failure. + public ForgeAccessKind Kind { get; } + + + + /// Creates the exception with its category, user-facing message, and the cause. + public ForgeAccessException(ForgeAccessKind kind, string message, Exception? innerException = null) + : base(message, innerException) + { + Kind = kind; + } +} diff --git a/gclo.Engine/ForgeAccessKind.cs b/gclo.Engine/ForgeAccessKind.cs new file mode 100644 index 0000000..74aac7e --- /dev/null +++ b/gclo.Engine/ForgeAccessKind.cs @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// +/// Why a request to a forge (GitHub, a Gitea server) failed, in the terms a caller +/// can act on. The members and their values are those of . +/// +public enum ForgeAccessKind +{ + /// The token was rejected (401): expired, revoked, deleted, or malformed. + Unauthorized, + + /// The token is valid but may not see the target, or lacks a permission the request needs. + Forbidden, + + /// The server is limiting requests from this token: retry later. + RateLimited, + + /// + /// No organization or account of that name is visible to the token; or, for a + /// server at an address the user named, nothing that answers like that forge is + /// at the address. + /// + NotFound, + + /// + /// The server could not be reached, did not answer in time, or failed on its side + /// (a network error, a timeout, HTTP 5xx): nothing is wrong with the request, retry later. + /// + Unavailable, +} diff --git a/gclo.Engine/ForgeFailures.cs b/gclo.Engine/ForgeFailures.cs new file mode 100644 index 0000000..5218c7d --- /dev/null +++ b/gclo.Engine/ForgeFailures.cs @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// +/// Words the failure translations of every forge share: a server that could not be +/// reached, and a server's own reason made safe to put into a message. +/// +internal static class ForgeFailures +{ + /// The longest reason from a server that is put into a message. + private const int MaxReasonLength = 300; + + + + /// + /// The words for a request that never got an answer. Chosen by the kind of failure, + /// never copied from the failure's own text: that text can name a proxy together + /// with the user name and password used for it. + /// + /// What could not be reached, as the message names it ("GitHub", a host). + /// The kind of failure. + public static string Unreachable(string server, HttpRequestError error) + { + string what = error switch + { + HttpRequestError.NameResolutionError => "its name could not be resolved (DNS)", + HttpRequestError.ConnectionError => "the connection could not be made", + HttpRequestError.SecureConnectionError + => "a secure (TLS) connection could not be established; a proxy or a security product that inspects traffic is a common cause", + HttpRequestError.ProxyTunnelError => "the proxy on the way refused the connection", + HttpRequestError.Unknown => "the request failed on the way", + _ => $"the request failed on the way ({error})", + }; + return $"{server} could not be reached: {what}. Check the network connection and retry."; + } + + + + /// + /// A reason from a server as one line of bounded length that ends a sentence; empty + /// when there is none. + /// + public static string OneLine(string? text) + { + string line = string.Join(' ', (text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + if(line.Length == 0) + { + return ""; + } + if(line.Length > MaxReasonLength) + { + // Cut between characters, never through a pair of code units. + int keep = char.IsHighSurrogate(line[MaxReasonLength - 1]) ? MaxReasonLength - 1 : MaxReasonLength; + return line[..keep] + "..."; + } + return line[^1] is '.' or '!' or '?' ? line : line + "."; + } +} diff --git a/gclo.Engine/ForgeKind.cs b/gclo.Engine/ForgeKind.cs new file mode 100644 index 0000000..fe940f1 --- /dev/null +++ b/gclo.Engine/ForgeKind.cs @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// The kind of server an organization or account lives on. +public enum ForgeKind +{ + /// github.com. + GitHub, + + /// A Gitea server, or one that speaks its API (Forgejo), at an address the user names. + Gitea, +} diff --git a/gclo.Engine/GitHubAccessException.cs b/gclo.Engine/GitHubAccessException.cs index 8119339..b060ded 100644 --- a/gclo.Engine/GitHubAccessException.cs +++ b/gclo.Engine/GitHubAccessException.cs @@ -8,20 +8,21 @@ namespace gclo.Engine; /// /// A failed GitHub API request (a refusal, or GitHub not answering) translated into an actionable message. Derives from -/// so every existing catch keeps working; +/// (through , +/// the type the listers of every forge throw) so every existing catch keeps working; /// lets a caller tell "fix your token" from "wait and retry" /// without parsing the message — the CLI maps it to distinct exit codes. /// -public sealed class GitHubAccessException : InvalidOperationException +public sealed class GitHubAccessException : ForgeAccessException { - /// The category of refusal. - public GitHubAccessKind Kind { get; } + /// The category of refusal. The same category, as a , is on the base type. + public new GitHubAccessKind Kind { get; } /// Creates the exception with its category, user-facing message, and the Octokit cause. public GitHubAccessException(GitHubAccessKind kind, string message, Exception? innerException = null) - : base(message, innerException) + : base((ForgeAccessKind)(int)kind, message, innerException) { Kind = kind; } diff --git a/gclo.Engine/GitHubFailures.cs b/gclo.Engine/GitHubFailures.cs index fa827f0..e1dec3e 100644 --- a/gclo.Engine/GitHubFailures.cs +++ b/gclo.Engine/GitHubFailures.cs @@ -20,11 +20,6 @@ namespace gclo.Engine; /// internal static class GitHubFailures { - /// The longest reason from GitHub that is put into a message. - private const int MaxReasonLength = 300; - - - /// /// Waits for one gateway request, or for the caller's cancellation, whichever /// comes first. The gateway cannot cancel a request, so a canceled one is left @@ -126,7 +121,7 @@ internal static class GitHubFailures exception), HttpRequestException network => new GitHubAccessException( - GitHubAccessKind.Unavailable, Unreachable(network.HttpRequestError), exception), + GitHubAccessKind.Unavailable, ForgeFailures.Unreachable("GitHub", network.HttpRequestError), exception), OperationCanceledException when !canceledByCaller => new GitHubAccessException( GitHubAccessKind.Unavailable, "GitHub did not answer in time; retry later.", exception), @@ -172,7 +167,7 @@ internal static class GitHubFailures return document.RootElement.ValueKind == JsonValueKind.Object && document.RootElement.TryGetProperty("message", out JsonElement message) && message.ValueKind == JsonValueKind.String - ? OneLine(message.GetString()) + ? ForgeFailures.OneLine(message.GetString()) : ""; } catch(JsonException) @@ -180,48 +175,4 @@ internal static class GitHubFailures return ""; } } - - - - /// - /// The words for a request that never got an answer. Chosen by the kind of failure, - /// never copied from the failure's own text: that text can name a proxy together - /// with the user name and password used for it. - /// - private static string Unreachable(HttpRequestError error) - { - string what = error switch - { - HttpRequestError.NameResolutionError => "its name could not be resolved (DNS)", - HttpRequestError.ConnectionError => "the connection could not be made", - HttpRequestError.SecureConnectionError - => "a secure (TLS) connection could not be established; a proxy or a security product that inspects traffic is a common cause", - HttpRequestError.ProxyTunnelError => "the proxy on the way refused the connection", - HttpRequestError.Unknown => "the request failed on the way", - _ => $"the request failed on the way ({error})", - }; - return $"GitHub could not be reached: {what}. Check the network connection and retry."; - } - - - - /// - /// A reason from GitHub as one line of bounded length that ends a sentence; empty - /// when there is none. - /// - private static string OneLine(string? text) - { - string line = string.Join(' ', (text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); - if(line.Length == 0) - { - return ""; - } - if(line.Length > MaxReasonLength) - { - // Cut between characters, never through a pair of code units. - int keep = char.IsHighSurrogate(line[MaxReasonLength - 1]) ? MaxReasonLength - 1 : MaxReasonLength; - return line[..keep] + "..."; - } - return line[^1] is '.' or '!' or '?' ? line : line + "."; - } } diff --git a/gclo.Engine/GiteaAccount.cs b/gclo.Engine/GiteaAccount.cs new file mode 100644 index 0000000..d1e2e0a --- /dev/null +++ b/gclo.Engine/GiteaAccount.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text.Json.Serialization; + + +namespace gclo.Engine; + + +/// +/// A user or an organization as the Gitea API describes it, as far as gclo reads it. +/// A user (and the owner of a repository) carries its name as login; an +/// organization in the list of a user's organizations carries it as username. +/// +/// The login field. +/// The username field. +internal sealed record GiteaAccount( + [property: JsonPropertyName("login")] string? Login, + [property: JsonPropertyName("username")] string? Username) +{ + /// The account's name, whichever field carries it; null when neither does. + [JsonIgnore] + public string? Name => !string.IsNullOrWhiteSpace(Login) ? Login : !string.IsNullOrWhiteSpace(Username) ? Username : null; +} diff --git a/gclo.Engine/GiteaApi.cs b/gclo.Engine/GiteaApi.cs new file mode 100644 index 0000000..f522d5a --- /dev/null +++ b/gclo.Engine/GiteaApi.cs @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + + +namespace gclo.Engine; + + +/// +/// The requests gclo makes to one Gitea server, and what their failures mean. +/// +/// +/// +/// The server's address is one a user typed, so the server is not trusted beyond +/// being the place the user chose to send their token to: +/// +/// +/// Every request goes to the address the account names (HTTPS, checked by +/// ), with the token in its Authorization header. +/// Redirects are not followed, so the token is not carried anywhere else. +/// Certificates are validated by the platform. There is no switch for that. +/// Clone addresses are built from the account's address +/// (), not read from the server's answers: the git client +/// gives the token to the host of the clone address. +/// Answers are bounded in size, lists in length, and a request in time. +/// The server's own words reach a message only as the reason of a refusal, +/// on one line and cut to a length. +/// +/// +internal sealed class GiteaApi +{ + /// Items asked for per page. The server may give fewer: it has a limit of its own. + public const int PageSize = 50; + + /// The most pages one list is read for: a server whose list does not end is not followed for ever. + public const int MaxPages = 2000; + + /// The largest answer that is read. + private const int MaxAnswerBytes = 32 * 1024 * 1024; + + /// The most that is read of a refusal to find the server's reason in it. + private const int MaxRefusalBytes = 64 * 1024; + + /// + /// 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. + /// + internal static readonly SocketsHttpHandler SharedHandler = new() + { + AllowAutoRedirect = false, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + }; + + /// + /// One client for every server: connections are pooled per host, and nothing that + /// belongs to one account (its token) is kept on it. + /// + private static readonly HttpClient s_shared = new(SharedHandler) + { + Timeout = Timeout.InfiniteTimeSpan, // each request has its own limit, which also covers reading the answer + }; + + private readonly Uri _api; + + private readonly HttpClient _http; + + private readonly TimeSpan _requestTimeout; + + + + /// The server's base address, as normalizes it. + public Uri Server { get; } + + /// The server as messages name it: its host, and its port when that is not 443. + public string Host { get; } + + + + /// Creates the client for the server at . + /// The server's address; held to the rules of . + /// Test seam: what carries the requests. The shared client when null. + /// Test seam: how long one request may take, answer included. 100 seconds when null. + /// is not an address a Gitea account can have. + public GiteaApi(Uri server, HttpClient? http = null, TimeSpan? requestTimeout = null) + { + 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)); + } + + Server = normalized; + Host = normalized.Authority; + _api = new Uri(normalized, "api/v1/"); + _http = http ?? s_shared; + _requestTimeout = requestTimeout ?? TimeSpan.FromSeconds(100); + } + + + + /// + /// The address a repository is cloned from: the account's server address, the + /// owner, the name. Built here, not taken from the server's answer, so the host + /// that gets the token for the clone is the one the account names. + /// + public string CloneUrl(string owner, string name) + => new Uri(Server, $"{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}.git").AbsoluteUri; + + + + /// The name of the account the token belongs to. + /// The answer names no account. + public async Task GetOwnNameAsync(string token, CancellationToken cancellationToken) + { + (GiteaAccount account, _) = await SendAsync("user", token, GiteaJsonContext.Default.GiteaAccount, cancellationToken) + .ConfigureAwait(false); + return account.Name ?? throw new GiteaUnexpectedAnswerException("The answer to 'who am I' names no account."); + } + + + + /// + /// Every item of the list at , page by page. The server says + /// whether there is a next page (its Link header); where it does not say, + /// 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. + /// + /// The list did not end within pages. + public async Task> ListAsync(string path, string token, JsonTypeInfo> type, CancellationToken cancellationToken) + where T : class + { + var all = new List(); + for(int page = 1; page <= MaxPages; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + (List items, bool? more) = await SendAsync($"{path}?page={page}&limit={PageSize}", token, type, cancellationToken) + .ConfigureAwait(false); + all.AddRange(items.OfType()); + if(more == false || (more is null && items.Count == 0)) + { + return all; + } + } + throw new GiteaUnexpectedAnswerException($"A list did not end after {MaxPages} pages."); + } + + + + /// + /// True when is a failure with a known meaning, which + /// then puts into words. One without stays what it is: an + /// unexpected error, or the caller's own cancellation. + /// + /// What a request threw. + /// + /// The caller's token: a canceled request is a timeout only when the caller did + /// not cancel it. This is the one moment the token is looked at. + /// + public bool Knows(Exception failure, CancellationToken cancellationToken) + => Explain(failure, canceledByCaller: cancellationToken.IsCancellationRequested) is not null; + + + + /// + /// The that says what + /// means to a caller. Only for a failure that accepted: whether + /// a canceled request was the caller's doing was decided there and is not decided + /// again here, so a cancellation that arrives in between changes nothing. + /// + /// The failure has no known meaning. + public ForgeAccessException Translate(Exception failure) + => Explain(failure, canceledByCaller: false) + ?? throw new ArgumentException("This failure has no known meaning; ask Knows first.", nameof(failure)); + + + + private ForgeAccessException? Explain(Exception failure, bool canceledByCaller) + { + const string CheckTheAddress = "Check the server address of this account."; + return failure switch + { + GiteaStatusException { Status: HttpStatusCode.Unauthorized } => new ForgeAccessException( + ForgeAccessKind.Unauthorized, + $"{Host} rejected the token (401). Check that the access token is complete and has not been deleted or expired.", + failure), + + GiteaStatusException { Status: HttpStatusCode.Forbidden } refused => new ForgeAccessException( + ForgeAccessKind.Forbidden, + (refused.Reason.Length == 0 ? $"{Host} refused access (403)." : $"{Host} refused access (403): {refused.Reason}") + + " Check what the access token is allowed to do: gclo reads repositories, organizations and the user " + + "(the scopes read:repository, read:organization and read:user).", + failure), + + GiteaStatusException { Status: HttpStatusCode.TooManyRequests } => new ForgeAccessException( + ForgeAccessKind.RateLimited, + $"{Host} is limiting requests from this token (429: too many requests); retry in a minute.", + failure), + + GiteaStatusException { Status: HttpStatusCode.ProxyAuthenticationRequired } => new ForgeAccessException( + ForgeAccessKind.Unavailable, + $"A proxy on the way to {Host} asked gclo to sign in (HTTP 407), which it cannot do. " + + "Check the proxy settings of this machine.", + failure), + + GiteaStatusException { Status: >= HttpStatusCode.InternalServerError } server => new ForgeAccessException( + ForgeAccessKind.Unavailable, + $"{Host} answered with an error on its side (HTTP {(int)server.Status}); 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. // + /////////////////////////////////////////////////////////////////////////// + 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.", + failure), + + GiteaStatusException other => new ForgeAccessException( + ForgeAccessKind.NotFound, + $"There is no Gitea API at this address of {Host} (HTTP {(int)other.Status}). {CheckTheAddress}", + failure), + + GiteaUnexpectedAnswerException => new ForgeAccessException( + ForgeAccessKind.NotFound, + $"{Host} did not answer like a Gitea server. {CheckTheAddress} " + + "A sign-in page or a proxy in front of the server answers like this too.", + failure), + + HttpRequestException network => new ForgeAccessException( + ForgeAccessKind.Unavailable, ForgeFailures.Unreachable(Host, network.HttpRequestError), failure), + + OperationCanceledException when !canceledByCaller => new ForgeAccessException( + ForgeAccessKind.Unavailable, $"{Host} did not answer in time; retry later.", failure), + + _ => null, + }; + } + + + + /// + /// One request: the answer read as , and whether the server + /// says there is a next page (null when it does not say). + /// + private async Task<(T Value, bool? More)> SendAsync( + string relative, string token, JsonTypeInfo type, CancellationToken cancellationToken) + where T : class + { + using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + limit.CancelAfter(_requestTimeout); + + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_api, relative)); + request.Headers.Authorization = new AuthenticationHeaderValue("token", token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("gclo", null)); + + using HttpResponseMessage response = await _http + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, limit.Token).ConfigureAwait(false); + if(!response.IsSuccessStatusCode) + { + throw new GiteaStatusException(response.StatusCode, await ReasonOfAsync(response, limit.Token).ConfigureAwait(false)); + } + + byte[] answer = await ReadAsync(response.Content, MaxAnswerBytes, limit.Token).ConfigureAwait(false) + ?? throw new GiteaUnexpectedAnswerException("The answer is larger than gclo reads."); + T? value; + try + { + value = JsonSerializer.Deserialize(answer, type); + } + catch(JsonException notTheApi) + { + throw new GiteaUnexpectedAnswerException("The answer is not the JSON the API gives.", notTheApi); + } + + bool? more = response.Headers.TryGetValues("Link", out IEnumerable? links) + ? links.Any(link => link.Contains("rel=\"next\"", StringComparison.OrdinalIgnoreCase)) + : null; + return (value ?? throw new GiteaUnexpectedAnswerException("The answer is empty."), more); + } + + + + /// + /// The reason the server gave for a refusal: the message of the JSON + /// document it answered with. Anything else (no body, a page of HTML, too much) + /// has no reason in it. + /// + private static async Task 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); + } + catch(JsonException) + { + return ""; + } + } + + + + /// The bytes of , or null when there are more than . + private static async Task ReadAsync(HttpContent content, int most, CancellationToken cancellationToken) + { + using Stream body = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var read = new MemoryStream(); + byte[] chunk = new byte[81920]; + while(true) + { + int count = await body.ReadAsync(chunk, cancellationToken).ConfigureAwait(false); + if(count == 0) + { + return read.ToArray(); + } + if(read.Length + count > most) + { + return null; + } + read.Write(chunk, 0, count); + } + } +} diff --git a/gclo.Engine/GiteaJsonContext.cs b/gclo.Engine/GiteaJsonContext.cs new file mode 100644 index 0000000..2964200 --- /dev/null +++ b/gclo.Engine/GiteaJsonContext.cs @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text.Json.Serialization; + + +namespace gclo.Engine; + + +/// +/// Source-generated JSON reading for the Gitea API's answers: no reflection, so the +/// engine stays trimmable, and no JSON package beyond the framework's. +/// +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(GiteaAccount))] +[JsonSerializable(typeof(GiteaRefusal))] +internal sealed partial class GiteaJsonContext : JsonSerializerContext; diff --git a/gclo.Engine/GiteaOrganizationLister.cs b/gclo.Engine/GiteaOrganizationLister.cs new file mode 100644 index 0000000..91e3cc6 --- /dev/null +++ b/gclo.Engine/GiteaOrganizationLister.cs @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Net; + + +namespace gclo.Engine; + + +/// +/// Lists the account a token belongs to and the organizations it is a member of, on +/// a Gitea server (or one that speaks its API). The server is the one given at +/// construction: the token of a call is sent there and nowhere else. +/// +public sealed class GiteaOrganizationLister : IOrganizationLister +{ + private readonly GiteaApi _api; + + + + /// Creates the lister for the server at . + /// The server's address, as accepts it (HTTPS). + /// is not an address a Gitea account can have. + public GiteaOrganizationLister(Uri server) + : this(new GiteaApi(server)) + { + } + + + + /// Test seam: the requests go through . + internal GiteaOrganizationLister(GiteaApi api) + => _api = api ?? throw new ArgumentNullException(nameof(api)); + + + + /// + public async Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + cancellationToken.ThrowIfCancellationRequested(); + + string own; + List organizations; + try + { + // The token's own account is a valid sync target too, so it heads the list. + own = await _api.GetOwnNameAsync(token, cancellationToken).ConfigureAwait(false); + + try + { + organizations = await _api + .ListAsync("user/orgs", token, GiteaJsonContext.Default.ListGiteaAccount, cancellationToken) + .ConfigureAwait(false); + } + catch(GiteaStatusException noScope) when(noScope.Status == HttpStatusCode.Forbidden) + { + /////////////////////////////////////////////////////////////////////// + // Degraded mode: a token without the read:organization scope may // + // not list organizations, and is otherwise fine. The personal // + // account is still a usable sync target, and an organization can // + // be typed by hand. // + /////////////////////////////////////////////////////////////////////// + return [own]; + } + } + catch(Exception failure) when(_api.Knows(failure, cancellationToken)) + { + // What the failure means to a caller is decided in one place, for both listers. + throw _api.Translate(failure); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var result = new List { own }; + result.AddRange(organizations + .Select(organization => organization.Name) + .OfType() + .Where(name => !string.Equals(name, own, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase)); + return result; + } +} diff --git a/gclo.Engine/GiteaRefusal.cs b/gclo.Engine/GiteaRefusal.cs new file mode 100644 index 0000000..aa06dc6 --- /dev/null +++ b/gclo.Engine/GiteaRefusal.cs @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text.Json.Serialization; + + +namespace gclo.Engine; + + +/// The document a Gitea server answers a refused request with. +/// The server's reason. +internal sealed record GiteaRefusal([property: JsonPropertyName("message")] string? Message); diff --git a/gclo.Engine/GiteaRepository.cs b/gclo.Engine/GiteaRepository.cs new file mode 100644 index 0000000..f73b995 --- /dev/null +++ b/gclo.Engine/GiteaRepository.cs @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text.Json.Serialization; + + +namespace gclo.Engine; + + +/// +/// A repository as the Gitea API describes it, as far as gclo reads it. The clone +/// address the server gives is deliberately not read: gclo builds it from the +/// address the account names (see ). +/// +/// The repository's name. +/// The account that owns it. +/// Its default branch. +/// Whether it is archived. +/// Whether it has no commits. +internal sealed record GiteaRepository( + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("owner")] GiteaAccount? Owner, + [property: JsonPropertyName("default_branch")] string? DefaultBranch, + [property: JsonPropertyName("archived")] bool Archived, + [property: JsonPropertyName("empty")] bool Empty); diff --git a/gclo.Engine/GiteaRepositoryLister.cs b/gclo.Engine/GiteaRepositoryLister.cs new file mode 100644 index 0000000..b9c19c2 --- /dev/null +++ b/gclo.Engine/GiteaRepositoryLister.cs @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Net; + + +namespace gclo.Engine; + + +/// +/// Lists the repositories of an organization or account on a Gitea server (or one +/// that speaks its API) through the server's REST API. The server is the one given +/// at construction: the token of a call is sent there and nowhere else, and the +/// clone address of every repository is on that server. +/// +public sealed class GiteaRepositoryLister : IRepositoryLister +{ + private readonly GiteaApi _api; + + + + /// Creates the lister for the server at . + /// The server's address, as accepts it (HTTPS). + /// is not an address a Gitea account can have. + public GiteaRepositoryLister(Uri server) + : this(new GiteaApi(server)) + { + } + + + + /// Test seam: the requests go through . + internal GiteaRepositoryLister(GiteaApi api) + => _api = api ?? throw new ArgumentNullException(nameof(api)); + + + + /// + public async Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(organization); + ArgumentException.ThrowIfNullOrWhiteSpace(token); + cancellationToken.ThrowIfCancellationRequested(); + + /////////////////////////////////////////////////////////////////////////// + // The name goes into request paths, into clone addresses and, with the // + // org-subfolder option and the WSL recovery, into local paths. Anything // + // that is not one plain name is refused before a request is made. // + /////////////////////////////////////////////////////////////////////////// + if(!RepositoryPathResolver.TryValidateName(organization, out string? invalidName)) + { + throw new ForgeAccessException( + ForgeAccessKind.NotFound, + $"'{organization}' is not an organization or account name ({invalidName}). Check the spelling."); + } + + string name = Uri.EscapeDataString(organization); + List repositories; + try + { + try + { + repositories = await _api + .ListAsync($"orgs/{name}/repos", token, GiteaJsonContext.Default.ListGiteaRepository, cancellationToken) + .ConfigureAwait(false); + } + catch(GiteaStatusException notAnOrganization) when(notAnOrganization.Status == HttpStatusCode.NotFound) + { + /////////////////////////////////////////////////////////////////////// + // 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. // + /////////////////////////////////////////////////////////////////////// + string own = await _api.GetOwnNameAsync(token, cancellationToken).ConfigureAwait(false); + bool isOwn = string.Equals(own, organization, StringComparison.OrdinalIgnoreCase); + try + { + repositories = await _api + .ListAsync(isOwn ? "user/repos" : $"users/{name}/repos", token, GiteaJsonContext.Default.ListGiteaRepository, cancellationToken) + .ConfigureAwait(false); + } + catch(GiteaStatusException nobody) when(nobody.Status == HttpStatusCode.NotFound && !isOwn) + { + throw new ForgeAccessException( + ForgeAccessKind.NotFound, + $"'{organization}' was found neither as an organization nor as a user account on {_api.Host} (404), " + + "or the token cannot see it. Check the spelling.", + nobody); + } + } + } + catch(Exception failure) when(failure is not ForgeAccessException && _api.Knows(failure, cancellationToken)) + { + // What the failure means to a caller is decided in one place, for both listers. + throw _api.Translate(failure); + } + + cancellationToken.ThrowIfCancellationRequested(); + + /////////////////////////////////////////////////////////////////////////// + // The list of the token's own repositories also holds repositories of // + // other owners that it may work on. Only what the named account owns // + // is its repository: the rest would be cloned under a wrong name, from // + // an address that is not theirs. // + /////////////////////////////////////////////////////////////////////////// + return [.. repositories + .Where(r => !string.IsNullOrWhiteSpace(r.Name) + && (r.Owner?.Name is null || string.Equals(r.Owner.Name, organization, StringComparison.OrdinalIgnoreCase))) + // A repository created while the list is read shifts the pages and can + // repeat an item; duplicates would race two clones into one folder. + .DistinctBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .Select(r => new RepoDescriptor( + r.Name!, + _api.CloneUrl(r.Owner?.Name ?? organization, r.Name!), + r.Empty || string.IsNullOrWhiteSpace(r.DefaultBranch) ? null : r.DefaultBranch, + r.Archived))]; + } +} diff --git a/gclo.Engine/GiteaServerUrl.cs b/gclo.Engine/GiteaServerUrl.cs new file mode 100644 index 0000000..5473bd5 --- /dev/null +++ b/gclo.Engine/GiteaServerUrl.cs @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Diagnostics.CodeAnalysis; + + +namespace gclo.Engine; + + +/// +/// Turns what a user typed as the address of a Gitea server into the server's base +/// address, or says why it cannot be one. The base address is where the token of an +/// account is sent and where its repositories are cloned from, so it is held to a +/// few rules: HTTPS only, no user name or password in it, nothing after the path. +/// +public static class GiteaServerUrl +{ + /// + /// The base address for : https, the host (and a + /// port that is not 443), and the path with one slash at its end, so that paths + /// are appended to it. A host typed without a scheme is taken as https. + /// + /// What was typed, for example git.example.com or https://example.com/gitea. + /// The base address; null when there is none. + /// Why is not a server address; null when it is one. + public static bool TryNormalize(string? text, [NotNullWhen(true)] out Uri? server, [NotNullWhen(false)] out string? reason) + { + server = null; + string typed = (text ?? "").Trim(); + if(typed.Length == 0) + { + reason = "it is empty"; + return false; + } + + if(typed.Any(c => char.IsWhiteSpace(c) || char.IsControl(c))) + { + reason = "it contains a space or a control character"; + return false; + } + + if(!typed.Contains("://", StringComparison.Ordinal)) + { + typed = "https://" + typed; + } + + if(!Uri.TryCreate(typed, UriKind.Absolute, out Uri? parsed) || parsed.Host.Length == 0) + { + reason = "it is not an address"; + return false; + } + + if(!string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + /////////////////////////////////////////////////////////////////////////// + // The token travels in every request: never over a connection that is // + // not encrypted, and there is no setting that allows it. // + /////////////////////////////////////////////////////////////////////////// + reason = "it has to begin with https://"; + return false; + } + + if(parsed.UserInfo.Length > 0) + { + reason = "it contains a user name or a password; the token is entered separately"; + return false; + } + + if(parsed.Query.Length > 0 || parsed.Fragment.Length > 0) + { + reason = "it has a '?' or a '#' part; only the server's own address is needed"; + return false; + } + + string path = parsed.AbsolutePath.EndsWith('/') ? parsed.AbsolutePath : parsed.AbsolutePath + "/"; + server = new UriBuilder(Uri.UriSchemeHttps, parsed.IdnHost, parsed.IsDefaultPort ? -1 : parsed.Port, path).Uri; + reason = null; + return true; + } +} diff --git a/gclo.Engine/GiteaStatusException.cs b/gclo.Engine/GiteaStatusException.cs new file mode 100644 index 0000000..79c9c69 --- /dev/null +++ b/gclo.Engine/GiteaStatusException.cs @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Net; + + +namespace gclo.Engine; + + +/// +/// A Gitea server answered a request with a status that is not success. Internal to +/// the Gitea listers: they decide what a status means where it occurred (a 404 for +/// an organization is "perhaps a user account"), and +/// puts the rest into words. +/// +internal sealed class GiteaStatusException : Exception +{ + /// The HTTP status of the answer. + public HttpStatusCode Status { get; } + + /// The server's own reason, on one line and of bounded length; empty when it gave none. + public string Reason { get; } + + + + /// Creates the exception for a status and the reason the server gave. + public GiteaStatusException(HttpStatusCode status, string reason) + : base($"The server answered with HTTP {(int)status}.") + { + Status = status; + Reason = reason; + } +} diff --git a/gclo.Engine/GiteaUnexpectedAnswerException.cs b/gclo.Engine/GiteaUnexpectedAnswerException.cs new file mode 100644 index 0000000..4e2d4b0 --- /dev/null +++ b/gclo.Engine/GiteaUnexpectedAnswerException.cs @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// +/// What came back from the address of a Gitea server is not what the Gitea API +/// answers: not JSON, JSON of another shape, an answer without end, a list without +/// end. Internal to the Gitea listers; puts it into words. +/// +internal sealed class GiteaUnexpectedAnswerException : Exception +{ + /// Creates the exception with what was unexpected and, where there is one, the cause. + public GiteaUnexpectedAnswerException(string what, Exception? innerException = null) + : base(what, innerException) + { + } +} diff --git a/gclo.Engine/IOrganizationLister.cs b/gclo.Engine/IOrganizationLister.cs index 7ca253f..9586ecf 100644 --- a/gclo.Engine/IOrganizationLister.cs +++ b/gclo.Engine/IOrganizationLister.cs @@ -6,14 +6,16 @@ namespace gclo.Engine; -/// Lists the GitHub organizations a token can see. +/// Lists the organizations a token can see on a forge (GitHub, a Gitea server). public interface IOrganizationLister { /// - /// Returns the login names of organizations the token's user belongs to. - /// May legitimately be empty for tokens without organization read access - /// (fine-grained PATs, classic PATs missing read:org) — callers should still - /// allow manual organization entry. + /// Returns the token's own account, then the names of the organizations its + /// user belongs to. May hold the account alone for tokens without organization + /// read access (GitHub: fine-grained PATs, classic PATs missing read:org; Gitea: + /// a token without read:organization) — callers should still allow manual + /// organization entry. /// + /// The forge refused the request, or could not be reached. Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default); } diff --git a/gclo.Engine/IRepositoryLister.cs b/gclo.Engine/IRepositoryLister.cs index a17031a..1b4bf82 100644 --- a/gclo.Engine/IRepositoryLister.cs +++ b/gclo.Engine/IRepositoryLister.cs @@ -6,10 +6,11 @@ namespace gclo.Engine; -/// Lists every repository in a GitHub organization. +/// Lists every repository of an organization or account on a forge (GitHub, a Gitea server). public interface IRepositoryLister { /// Returns all repositories in the organization, following pagination to the end. + /// The forge refused the request, or could not be reached. Task> ListOrganizationRepositoriesAsync( string organization, string token, CancellationToken cancellationToken = default); } diff --git a/gclo.Engine/PACKAGE.md b/gclo.Engine/PACKAGE.md index 7ac0ff9..fb81503 100644 --- a/gclo.Engine/PACKAGE.md +++ b/gclo.Engine/PACKAGE.md @@ -1,9 +1,10 @@ # gclo.Engine The core engine of [gclo](https://git.kof22.com/KofTwentyTwo/gclo) (Git Clone Large -Organizations). It lists every repository in a GitHub organization or user account -and clones or fast-forward-pulls all of them in parallel, using LibGit2Sharp for -git transport and Octokit for the GitHub API. On Windows it validates every tree +Organizations). It lists every repository in a GitHub organization or user account, +or in one on a Gitea server, and clones or fast-forward-pulls all of them in +parallel, using LibGit2Sharp for git transport, Octokit for the GitHub API, and +plain HTTPS for the Gitea API. On Windows it validates every tree path before checkout and offers structured rename/skip recovery for paths that git allows but Windows cannot create. diff --git a/gclo.Engine/RepoDescriptor.cs b/gclo.Engine/RepoDescriptor.cs index 4c149ca..f749d82 100644 --- a/gclo.Engine/RepoDescriptor.cs +++ b/gclo.Engine/RepoDescriptor.cs @@ -6,7 +6,7 @@ namespace gclo.Engine; -/// A repository discovered in the GitHub organization. +/// A repository discovered in an organization or account. /// Repository name (unique within the org; used as the local folder name). /// HTTPS clone URL. /// Default branch name; null for empty repositories. diff --git a/gclo.Engine/gclo.Engine.csproj b/gclo.Engine/gclo.Engine.csproj index 253301a..7900453 100644 --- a/gclo.Engine/gclo.Engine.csproj +++ b/gclo.Engine/gclo.Engine.csproj @@ -18,7 +18,7 @@ MIT https://git.kof22.com/KofTwentyTwo/gclo https://git.kof22.com/KofTwentyTwo/gclo - github;git;clone;mirror;organization;libgit2sharp;octokit + github;gitea;forgejo;git;clone;mirror;organization;libgit2sharp;octokit PACKAGE.md -- 2.54.0 From bce029dc161be8834dc50b2180e4f466db8e7472 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 11 Nov 2026 01:08:18 -0600 Subject: [PATCH 2/2] 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 Signed-off-by: James Maes --- docs/adr/0010-forges-beside-github.md | 24 ++- docs/security/threat-model.md | 2 +- gclo.Engine.Tests/GiteaListerTests.cs | 245 +++++++++++++++++++++-- gclo.Engine.Tests/GiteaServerUrlTests.cs | 38 ++++ gclo.Engine/GiteaApi.cs | 121 ++++++++--- gclo.Engine/GiteaRepositoryLister.cs | 18 +- gclo.Engine/GiteaServerUrl.cs | 43 +++- 7 files changed, 437 insertions(+), 54 deletions(-) diff --git a/docs/adr/0010-forges-beside-github.md b/docs/adr/0010-forges-beside-github.md index 287bfdf..5ef388b 100644 --- a/docs/adr/0010-forges-beside-github.md +++ b/docs/adr/0010-forges-beside-github.md @@ -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. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index b776eef..0c0f6ad 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -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://.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 diff --git a/gclo.Engine.Tests/GiteaListerTests.cs b/gclo.Engine.Tests/GiteaListerTests.cs index cd27cd2..a52c0e0 100644 --- a/gclo.Engine.Tests/GiteaListerTests.cs +++ b/gclo.Engine.Tests/GiteaListerTests.cs @@ -51,9 +51,26 @@ public sealed class GiteaListerTests : IDisposable - /// A body that never ends: a server that keeps sending. - private sealed class Endless : Stream + /// An answer's body whose every read is scripted: one that never ends, one that breaks off, one that stalls. + private sealed class Body(Func, CancellationToken, ValueTask> read) : Stream { + /// A server that keeps sending. + public static Body Endless() => new((buffer, _) => + { + buffer.Span.Fill((byte)' '); + return ValueTask.FromResult(buffer.Length); + }); + + /// A connection that ends while the answer is read. + public static Body BreaksOff() => new((_, _) => throw new IOException("The response ended prematurely.")); + + /// A server that stops sending and does not close. + 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 ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => read(buffer, cancellationToken); + + + + public override Task 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 + /// A test that would hang if a bound were missing fails instead. + private static Task Bounded(Task 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(() => List()); + ForgeAccessException ex = await Assert.ThrowsAsync(() => 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(() => 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(() => List()); + ForgeAccessException ex = await Assert.ThrowsAsync(() => 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(() => List()); + ForgeAccessException ex = await Assert.ThrowsAsync(() => 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( - () => 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(() => new GiteaRepositoryLister(new Uri("http://git.example.test"))); Assert.Contains("it has to begin with https://", plain.Message); - Assert.Throws(() => 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( + () => new GiteaOrganizationLister(new Uri("https://user:s3cret-token@git.example.test"))); + Assert.DoesNotContain("s3cret", withASecret.Message, StringComparison.Ordinal); + ArgumentException inTheQuery = Assert.Throws( + () => new GiteaRepositoryLister(new Uri("https://git.example.test/?access_token=s3cret-token"))); + Assert.DoesNotContain("s3cret", inTheQuery.Message, StringComparison.Ordinal); Assert.Throws(() => new GiteaRepositoryLister((Uri)null!)); Assert.Throws(() => new GiteaRepositoryLister((GiteaApi)null!)); Assert.Throws(() => 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("; rel=\"next\"", true)] + [InlineData("; rel=next", true)] + [InlineData("; rel=\"prev next\"", true)] + [InlineData("; REL=\"NEXT\"", true)] + [InlineData("; rel=\"first\",; rel=\"last\",; rel=\"next\"", true)] + [InlineData("; rel=\"first\",; rel=\"prev\"", false)] + [InlineData("; rel=last", false)] + [InlineData("; rel=preload; as=style", null)] // something on the way added a link of its own + [InlineData("; title=\"rel=next\"", null)] + [InlineData("", null)] + [InlineData("; 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", "; rel=preload; as=style"); + return response; + }) + .OnPage("orgs/acme/repos", 2, [Repo("b")]) + .OnPage("orgs/acme/repos", 3, []); + + IReadOnlyList 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(() => 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(() => 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(() => 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( + () => 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( + () => Repos().ListOrganizationRepositoriesAsync("acme", token)); + ForgeAccessException fromOrganizations = await Assert.ThrowsAsync( + () => 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 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); + } } diff --git a/gclo.Engine.Tests/GiteaServerUrlTests.cs b/gclo.Engine.Tests/GiteaServerUrlTests.cs index 3685098..78cb7bd 100644 --- a/gclo.Engine.Tests/GiteaServerUrlTests.cs +++ b/gclo.Engine.Tests/GiteaServerUrlTests.cs @@ -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)); + } + } + } } diff --git a/gclo.Engine/GiteaApi.cs b/gclo.Engine/GiteaApi.cs index f522d5a..e5020ae 100644 --- a/gclo.Engine/GiteaApi.cs +++ b/gclo.Engine/GiteaApi.cs @@ -29,6 +29,7 @@ namespace gclo.Engine; /// (), not read from the server's answers: the git client /// gives the token to the host of the clone address. /// Answers are bounded in size, lists in length, and a request in time. +/// Nothing a server sets is kept between requests: no cookies. /// The server's own words reach a message only as the reason of a refusal, /// on one line and cut to a length. /// @@ -49,20 +50,23 @@ internal sealed class GiteaApi /// /// 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). /// internal static readonly SocketsHttpHandler SharedHandler = new() { AllowAutoRedirect = false, + UseCookies = false, PooledConnectionLifetime = TimeSpan.FromMinutes(5), }; /// - /// 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 : connections are + /// pooled per host, and nothing that belongs to one account is kept on it. /// - 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. /// - /// The list did not end within pages. + /// + /// The list did not end within pages, or a page held more + /// than was asked for (so a list is at most times + /// items). + /// public async Task> ListAsync(string path, string token, JsonTypeInfo> type, CancellationToken cancellationToken) where T : class { @@ -142,6 +154,10 @@ internal sealed class GiteaApi cancellationToken.ThrowIfCancellationRequested(); (List 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()); 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 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? links) - ? links.Any(link => link.Contains("rel=\"next\"", StringComparison.OrdinalIgnoreCase)) - : null; + bool? more = response.Headers.TryGetValues("Link", out IEnumerable? links) ? SaysAboutANextPage(links) : null; return (value ?? throw new GiteaUnexpectedAnswerException("The answer is empty."), more); } + /// + /// What Link headers say about a next page: true when one of the links has + /// the relation next; 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. + /// + internal static bool? SaysAboutANextPage(IEnumerable 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; + } + + + /// /// The reason the server gave for a refusal: the message 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 /// private static async Task 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 ""; } } diff --git a/gclo.Engine/GiteaRepositoryLister.cs b/gclo.Engine/GiteaRepositoryLister.cs index b9c19c2..9bf3a6b 100644 --- a/gclo.Engine/GiteaRepositoryLister.cs +++ b/gclo.Engine/GiteaRepositoryLister.cs @@ -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), " diff --git a/gclo.Engine/GiteaServerUrl.cs b/gclo.Engine/GiteaServerUrl.cs index 5473bd5..f4aca95 100644 --- a/gclo.Engine/GiteaServerUrl.cs +++ b/gclo.Engine/GiteaServerUrl.cs @@ -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; } + + + + /// + /// 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. + /// + 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 port = text.AsSpan(colon + 1, (slash < 0 ? text.Length : slash) - colon - 1); + return port.Length is > 0 and <= 5 && !port.ContainsAnyExceptInRange('0', '9'); + } } -- 2.54.0