fix(accounts): accounts on another forge are kept where an older gclo does not rewrite them, and a token is found only at the place it was saved for
From the review of #205. - A gclo from before forges reads every account in accounts.json as a GitHub account and writes the file back with the fields it knows. It dropped the forge and the server of a Gitea account, which then read as a GitHub account in every version: its token would be asked for again and sent to GitHub. Accounts on another forge are now kept in accounts-forges.json, which such a version neither reads nor writes. Both files are changed under the one lock; a change writes only the file of the account it changes. - The kind of forge is stored as text. A name this version does not know (a forge of a later version) makes that one account unusable, not the file unreadable, and is written back as it was. Fields this version does not know are written back as they were read. - The vault entry of an account on another forge is made from its id, its forge and its server address as the file states them. An account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. - An account whose stored address is spelled another way can be edited; what the file says about the place stays as it is. - accounts list --json gives the address a token is sent to, and nothing for an address that cannot be used; --forge does not repeat what it was given. - The wizard refuses the default token for an account on another forge in the view model, not only by hiding the choice. - Tests: an older version's rewrite (as it does it), the two files, the unknown forge and fields, the binding of a token to its place, the vault being put back under the account's entry, a workspace's own listers. - docs/CLI.md, ADR 0010, threat model T13 and T16, CLAUDE.md. Refs #204 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
1 parent
9bcec28285
commit
8d855cd9f2
17 files changed
+1271
-326
No files matched your search
@@ -123,8 +123,10 @@ together are the "Core": all logic lives there, under the coverage gate.
|
||||
single seam for per-user data (`%LOCALAPPDATA%\KofTwentyTwo\gclo`, overridden by
|
||||
`GCLO_DATA_DIR`, and deliberately not the Velopack install root, which Setup.exe
|
||||
deletes); `AppSettings` → `settings.json`; `AccountsStore` → `accounts.json`
|
||||
(metadata only) with tokens in an `ITokenVault` (`CredentialManagerVault`, or
|
||||
`InMemoryVault`). The file and the vault have no shared transaction, so every
|
||||
for GitHub accounts and `accounts-forges.json` for the others (an older gclo
|
||||
rewrites the first with the fields it knows, so an account on another forge is
|
||||
never put there; ADR 0010), metadata only, with tokens in an `ITokenVault`
|
||||
(`CredentialManagerVault`, or `InMemoryVault`). The file and the vault have no shared transaction, so every
|
||||
operation touching both has a fixed order and a compensating step: keep that when
|
||||
changing `AccountsStore`.
|
||||
- **`gclo.Cli`**: `Program` dispatches to `SyncCommand`, `ReposCommand`, `OrgsCommand`,
|
||||
|
||||
+16
-8
@@ -310,11 +310,17 @@ stored in **Windows Credential Manager** (under `gclo:account:<id>`, or
|
||||
metadata file, `%LOCALAPPDATA%\KofTwentyTwo\gclo\accounts.json`, never contains
|
||||
the token).
|
||||
|
||||
The token of an account on a Gitea server is kept under a different entry
|
||||
name than `<id>` (one derived from it). A version of gclo from before 1.2 knows
|
||||
only GitHub and reads every account as a GitHub account; it does not find that
|
||||
entry, so it reports the account as having no token instead of sending a Gitea
|
||||
token to GitHub.
|
||||
Accounts on a Gitea server are kept in a file of their own beside it,
|
||||
`accounts-forges.json`. A version of gclo from before 1.2 knows only GitHub: it
|
||||
reads every account in `accounts.json` as a GitHub account and writes the file
|
||||
back with what it knows. It neither reads nor writes the other file, so it does
|
||||
not list such accounts, and using it beside 1.2 leaves them as they are.
|
||||
|
||||
The token of such an account is kept under an entry name made from the account's
|
||||
id, its forge and its server address as the file states them, not under `<id>`.
|
||||
It is found only by that account at that address: when the address is changed in
|
||||
the file by hand, the account has no token there, instead of sending the one it
|
||||
had to the new address.
|
||||
Accounts are shared between the CLI and the desktop app: create them with
|
||||
`gclo accounts add` on a headless mirror server, or in the app's account
|
||||
wizard, and use them from either.
|
||||
@@ -349,9 +355,11 @@ platform platform git.example.com D:\platform own never
|
||||
With `--json` it prints a single-line JSON array with everything a script needs
|
||||
to reason about a run; `lastSync` is the UTC timestamp of the last completed
|
||||
sync and `lastSyncSummary` its summary line, both `null` until the first sync;
|
||||
`tokenSource` is `own` or `default`; `forge` is `github` or `gitea`, and
|
||||
`server` is the address of a Gitea account's server (`null` for GitHub). The
|
||||
last two fields are new in 1.2:
|
||||
`tokenSource` is `own` or `default`; `forge` is `github` or `gitea` (`unknown`
|
||||
for an account a later version of gclo added on a forge this one does not know),
|
||||
and `server` is the address of a Gitea account's server as the token is sent to
|
||||
it (`null` for GitHub, and for an account whose stored address cannot be used).
|
||||
The last two fields are new in 1.2:
|
||||
|
||||
```json
|
||||
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44.","tokenSource":"own"}]
|
||||
|
||||
@@ -71,19 +71,38 @@ were the two lister implementations and the failure type they throw.
|
||||
|
||||
### Accounts and the command line (#204)
|
||||
|
||||
- **An account names its forge and, for Gitea, its server.** Both are left out of
|
||||
`accounts.json` when they are the default, so files written before stay as they
|
||||
are and a GitHub account written now reads the same in an older gclo.
|
||||
- **An account names its forge and, for Gitea, its server.** Both are left out
|
||||
when they are the default, so a GitHub account is written as it always was.
|
||||
- **Accounts on another forge are kept in a file of their own**
|
||||
(`accounts-forges.json`, beside `accounts.json`, under the same lock). A version
|
||||
from before forges reads every account in `accounts.json` as a GitHub account
|
||||
and writes the file back with the fields it knows. It would drop the forge and
|
||||
the server of a Gitea account, and from then on that account would read as a
|
||||
GitHub account in every version: its token would be asked for again and sent to
|
||||
GitHub. Two versions on one data folder are ordinary (the app updates itself,
|
||||
the command line is a separate download), so such accounts are kept where the
|
||||
older version neither reads nor writes. The cost is two files where there was
|
||||
one: a change writes only the file of the account it changes; a name is unique
|
||||
across both, but an older version does not see the second file and can add a
|
||||
GitHub account with the name of a Gitea account, which the command line then
|
||||
finds first.
|
||||
- **What this version does not know is kept.** The kind of forge is stored as
|
||||
text; a name this version does not know (a later version's forge) makes that
|
||||
one account unusable here, not the file unreadable, and is written back as it
|
||||
was. Fields this version does not know are written back as they were read. So
|
||||
this version does not do to a later one what an older one would do to it.
|
||||
- **A token stays with the place it was saved for.** The forge and the server of
|
||||
an account are set when it is added and cannot be changed by an edit, in the
|
||||
app or on the command line: to move an account, it is removed and added again,
|
||||
and its token is entered for the new place. `gclo sync --account` takes no
|
||||
`--forge` or `--server`.
|
||||
- **An older gclo must not send a Gitea token to GitHub.** A version from before
|
||||
forges reads every account as a GitHub account and looks its token up under the
|
||||
account's id. The token of an account that is not on GitHub is therefore kept
|
||||
under another entry (the id combined with a fixed value): to the older version
|
||||
the account has no token, and it says so.
|
||||
- **A token is found only by the account and the place it was saved for.** The
|
||||
vault entry of a GitHub account is its id, as ever. For another forge it is made
|
||||
from the id, the forge and the server address exactly as the file states them
|
||||
(a hash). `AccountsStore.Save` refuses a change of place; this makes the same
|
||||
hold for a file changed by hand: at another address the account has no token.
|
||||
The price: a token whose account was edited that way stays in Credential Manager
|
||||
under a name gclo no longer derives, until the address is put back.
|
||||
- **Tokens do not cross forges by default.** The default token saved in Settings
|
||||
is a GitHub token: an account on another forge has a token of its own, and is
|
||||
never handed the default one, whatever its record says. On the command line
|
||||
|
||||
@@ -64,7 +64,7 @@ flowchart LR
|
||||
| Git HTTPS transport (libgit2) | out/in | Repository objects and trees | PAT as HTTPS basic (`x-access-token`) | `WindowsPathValidator` checks every incoming path against Windows rules before checkout; non-fast-forward is reported, never merged |
|
||||
| PAT entry | in | The token | n/a | App: in-memory or Credential Manager; CLI: env var, file, or stdin only (no `--token <value>`) |
|
||||
| Credential Manager | out/in | Tokens by `gclo[:<scope>]:account:<id>` target | Windows user session | `CredentialManagerVault`; scope derived from the data root |
|
||||
| `settings.json`, `accounts.json` (with `accounts.json.lock`) | out/in | Non-secret settings and account metadata | File ACL (user profile) | Written beside the file and put in place in one step; sanitized on load; an accounts file whose content is not valid is moved aside (`.corrupt-*`), one that cannot be read is left in place; the previous accounts file is kept as `.bak`. Changes to the accounts are serialized across gclo processes by the lock file: a process of the same user that holds it open can delay account changes (they fail after a few seconds, nothing is written), not read or alter them |
|
||||
| `settings.json`, `accounts.json`, `accounts-forges.json` (with `accounts.json.lock`) | out/in | Non-secret settings and account metadata | File ACL (user profile) | Written beside the file and put in place in one step; sanitized on load; an accounts file whose content is not valid is moved aside (`.corrupt-*`), one that cannot be read is left in place; the previous accounts file is kept as `.bak`. Changes to the accounts are serialized across gclo processes by the lock file: a process of the same user that holds it open can delay account changes (they fail after a few seconds, nothing is written), not read or alter them |
|
||||
| Activity logs | out | Actions and errors; token *lengths* only | File ACL | `IActivityLog` callers never pass tokens |
|
||||
| `.git/gclo-recovery.json`, `gclo.checkoutpending` and `gclo.recoverymanaged` config keys | out/in | Path-recovery plan; checkout-pending marker; "gclo wrote this working tree" marker | File ACL | Written atomically; read only for the repository it belongs to (the markers from the repository's own configuration, never the user's or the machine's). A repository marked as written by gclo has its working tree rewritten on sync; one that is not, and was checked out by git, is never rewritten |
|
||||
| Command line (CLI) | in | Options, org, target, filters | n/a | `OptionReader` / `RepoFilterSpec`; parallelism bounded 1..64; token options refused on argv |
|
||||
@@ -100,8 +100,8 @@ flowchart LR
|
||||
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
|
||||
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
|
||||
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
|
||||
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; 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). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; the token of an account that is not on GitHub is kept under an entry name that a gclo from before forges does not look up, so that version reports "no token" instead of sending a Gitea token to GitHub; an account whose stored forge or server cannot be used asks no server anything. | 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) |
|
||||
| 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). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. | 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`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing | Accepted (same-user boundary: whoever can write these files can read the tokens) |
|
||||
|
||||
## 7. Accepted risks
|
||||
|
||||
@@ -133,4 +133,4 @@ flowchart LR
|
||||
| 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. |
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for accounts and the command line (#204): where an account lives is fixed when it is added; tokens do not cross forges by default (the default token, the environment variables); a Gitea account's token is stored where an older gclo does not look. |
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for accounts and the command line (#204): where an account lives is fixed when it is added; tokens do not cross forges by default (the default token, the environment variables); accounts on another forge are in `accounts-forges.json`, which an older gclo does not rewrite; their token is bound to the account and its server address. T13 restated for it. |
|
||||
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
|
||||
@@ -184,7 +185,10 @@ public sealed class ForgeCliTests : IDisposable
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("Unknown forge 'gitlab'. Use github or gitea.", "--forge", "gitlab")]
|
||||
[InlineData("--forge takes github or gitea. (The address of a server goes after --server.)", "--forge", "gitlab")]
|
||||
[InlineData(
|
||||
"--forge takes github or gitea. (The address of a server goes after --server.)",
|
||||
"--forge", "https://someone:s3cret-token@git.example.test")] // an address after the wrong option: not repeated either
|
||||
[InlineData("A Gitea account needs the address of its server.", "--forge", "gitea")]
|
||||
[InlineData("GitHub has no server address to choose: it is always github.com.", "--forge", "github", "--server", Server)]
|
||||
[InlineData("The server address cannot be used: it has to begin with https://.", "--server", "http://git.example.test")]
|
||||
@@ -415,9 +419,9 @@ public sealed class ForgeCliTests : IDisposable
|
||||
[Fact]
|
||||
public async Task AnAccountThatCannotBeUsedAsItIsSaved_IsNotSynced_AndIsListedAsSuch()
|
||||
{
|
||||
// An accounts.json edited by hand: the server over plain http.
|
||||
// A file edited by hand: the server over plain http.
|
||||
Account account = AddGiteaAccount();
|
||||
string file = Path.Combine(_dir, "accounts.json");
|
||||
string file = Path.Combine(_dir, "accounts-forges.json");
|
||||
File.WriteAllText(file, File.ReadAllText(file).Replace(Server, "http://git.example.test/", StringComparison.Ordinal));
|
||||
_store = null; // read the file again
|
||||
using var console = new ConsoleCapture();
|
||||
@@ -434,4 +438,61 @@ public sealed class ForgeCliTests : IDisposable
|
||||
Assert.Contains(" gitea (not usable) ", console.Out, StringComparison.Ordinal);
|
||||
Assert.Equal("gitea-token", _vault.TryRetrieve(account.TokenEntryId)); // untouched, and sent nowhere
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheJsonListing_GivesTheAddressATokenGoesTo_AndNeverOneThatWasRefused()
|
||||
{
|
||||
// A file edited by hand: a password in the address of one account, a forge of a later version on another.
|
||||
AddGiteaAccount("edited");
|
||||
AddGiteaAccount("forge");
|
||||
AddGiteaAccount("later");
|
||||
string file = Path.Combine(_dir, "accounts-forges.json");
|
||||
string[] parts = File.ReadAllText(file).Split("\"Server\": \"https://git.example.test/\"");
|
||||
Assert.Equal(4, parts.Length);
|
||||
File.WriteAllText(
|
||||
file,
|
||||
parts[0] + "\"Server\": \"https://bot:hunter2@git.example.test/\""
|
||||
+ parts[1] + "\"Server\": \"https://git.example.test/\""
|
||||
+ parts[2].Replace("\"Forge\": \"Gitea\"", "\"Forge\": \"GitLab\"", StringComparison.Ordinal) + "\"Server\": \"https://git.example.test/\""
|
||||
+ parts[3]);
|
||||
_store = null; // read the file again
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
Assert.Equal(0, Accounts("--json"));
|
||||
|
||||
using var listed = JsonDocument.Parse(console.Out);
|
||||
var byName = listed.RootElement.EnumerateArray().ToDictionary(a => a.GetProperty("name").GetString()!, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain("hunter2", console.Out, StringComparison.Ordinal);
|
||||
Assert.Equal("gitea", byName["edited"].GetProperty("forge").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, byName["edited"].GetProperty("server").ValueKind);
|
||||
Assert.Equal("gitea", byName["forge"].GetProperty("forge").GetString());
|
||||
Assert.Equal("https://git.example.test/", byName["forge"].GetProperty("server").GetString());
|
||||
Assert.Equal("unknown", byName["later"].GetProperty("forge").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, byName["later"].GetProperty("server").ValueKind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AnAccountOnAForgeThisVersionDoesNotKnow_IsNotSynced_AndIsToldWhichVersionToUse()
|
||||
{
|
||||
AddGiteaAccount();
|
||||
string file = Path.Combine(_dir, "accounts-forges.json");
|
||||
File.WriteAllText(file, File.ReadAllText(file).Replace("\"Forge\": \"Gitea\"", "\"Forge\": \"GitLab\"", StringComparison.Ordinal));
|
||||
_store = null; // read the file again
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
CliErrorException error = await Assert.ThrowsAsync<CliErrorException>(() => Sync("--account", "forge", "--dry-run"));
|
||||
Assert.Equal(0, Accounts());
|
||||
|
||||
Assert.Equal(
|
||||
"Account 'forge' cannot be used as it is saved: it is on a forge this version of gclo does not know. "
|
||||
+ "Use the version of gclo that added it.",
|
||||
error.Message);
|
||||
Assert.Empty(_asked);
|
||||
Assert.Null(_repos.LastToken);
|
||||
Assert.Contains(" unknown (not usable) ", console.Out, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ internal static class AccountsCommand
|
||||
.Select(a => new AccountSummary(
|
||||
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
|
||||
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a),
|
||||
ForgeLabel(a.Forge), a.Server))
|
||||
ForgeLabel(a), ServerOf(a)))
|
||||
.ToList();
|
||||
Terminal.OutVerbatim(JsonSerializer.Serialize(
|
||||
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
|
||||
@@ -507,14 +507,28 @@ internal static class AccountsCommand
|
||||
|
||||
|
||||
|
||||
/// <summary>The forge as the JSON listing names it.</summary>
|
||||
private static string ForgeLabel(ForgeKind forge) => forge == ForgeKind.GitHub ? "github" : "gitea";
|
||||
/// <summary>The forge as the JSON listing names it: 'github', 'gitea', or 'unknown' for one this version does not know.</summary>
|
||||
private static string ForgeLabel(Account account)
|
||||
=> !account.IsOnAKnownForge ? "unknown"
|
||||
: account.Forge == ForgeKind.GitHub ? "github"
|
||||
: "gitea";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The server of an account as the JSON listing gives it: the address the token
|
||||
/// is sent to, as it is normalized, and nothing for an account whose stored
|
||||
/// address cannot be used. What the file says is not repeated as it is: an
|
||||
/// address that cannot be used may be one with a password or a token in it.
|
||||
/// </summary>
|
||||
private static string? ServerOf(Account account)
|
||||
=> account.TryGetEndpoint(out ForgeEndpoint? endpoint, out _) ? endpoint.Server?.AbsoluteUri : null;
|
||||
|
||||
|
||||
|
||||
/// <summary>Where an account lives, as the listing and messages name it: "GitHub", or the host of its server.</summary>
|
||||
private static string Where(Account account)
|
||||
=> account.TryGetEndpoint(out ForgeEndpoint? endpoint, out _) ? endpoint.DisplayName : $"{ForgeLabel(account.Forge)} (not usable)";
|
||||
=> account.TryGetEndpoint(out ForgeEndpoint? endpoint, out _) ? endpoint.DisplayName : $"{ForgeLabel(account)} (not usable)";
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ internal sealed class ForgeOptions
|
||||
{
|
||||
"GITHUB" => ForgeKind.GitHub,
|
||||
"GITEA" => ForgeKind.Gitea,
|
||||
_ => throw new CliUsageException($"Unknown forge '{_forge}'. Use github or gitea."),
|
||||
// What was given is not repeated: an address typed after the wrong option may hold a password or a token.
|
||||
_ => throw new CliUsageException("--forge takes github or gitea. (The address of a server goes after --server.)"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -528,7 +528,7 @@ internal static class SyncCommand
|
||||
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? unusable)
|
||||
? (account, store, vault, endpoint)
|
||||
: throw new CliErrorException(
|
||||
$"Account '{account.Name}' cannot be used as it is saved: {unusable}. Remove the account and add it again.");
|
||||
$"Account '{account.Name}' cannot be used as it is saved: {unusable}. {account.AdviceWhenUnusable("Remove")}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Where accounts are kept on disk now that an account can be on another forge
|
||||
/// (#204): GitHub accounts in accounts.json as ever, the others in a file of their
|
||||
/// own. The tests are built around what goes wrong otherwise: a version of gclo from
|
||||
/// before forges rewrites accounts.json with what it knows, and an account whose
|
||||
/// forge and server it dropped would read as a GitHub account from then on. They
|
||||
/// also cover what this version does with what it does not know itself (a forge or
|
||||
/// a field of a later version), and that a token is found only by the account and
|
||||
/// the place it was saved for.
|
||||
/// </summary>
|
||||
public sealed class AccountFilesTests : IDisposable
|
||||
{
|
||||
private const string Server = "https://git.example.test/";
|
||||
|
||||
private readonly string _root =
|
||||
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly InMemoryVault _vault = new();
|
||||
|
||||
private readonly RecordingActivityLog _log = new();
|
||||
|
||||
|
||||
|
||||
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
|
||||
|
||||
|
||||
|
||||
private AccountsStore NewStore() => new(_vault, _root, _log);
|
||||
|
||||
|
||||
|
||||
private string GitHubFile => Path.Combine(_root, "accounts.json");
|
||||
|
||||
private string ForgesFile => Path.Combine(_root, "accounts-forges.json");
|
||||
|
||||
|
||||
|
||||
private static Account GitHubAccount(string name = "Work") => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos",
|
||||
};
|
||||
|
||||
|
||||
|
||||
private static Account GiteaAccount(string name = "Forge", string? server = Server)
|
||||
=> GitHubAccount(name) with { Forge = ForgeKind.Gitea, Server = server };
|
||||
|
||||
|
||||
|
||||
/// <summary>One account as a file states it, with whatever else <paramref name="more"/> says about it.</summary>
|
||||
private static string Record(Account account, string more = "")
|
||||
=> $$"""
|
||||
{
|
||||
"Id": "{{account.Id}}",
|
||||
"Name": "{{account.Name}}",
|
||||
"Organization": "{{account.Organization}}",
|
||||
"TargetRoot": "C:\\repos"{{(more.Length == 0 ? "" : ",\n " + more)}}
|
||||
}
|
||||
""";
|
||||
|
||||
|
||||
|
||||
private void WriteFile(string path, params string[] records)
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
File.WriteAllText(path, "[\n" + string.Join(",\n", records) + "\n]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What a gclo from before forges does to accounts.json when it records a sync
|
||||
/// result: it reads the accounts with the fields it knows and writes them back.
|
||||
/// </summary>
|
||||
private void RewriteAsAVersionFromBeforeForgesDoes()
|
||||
{
|
||||
string[] known =
|
||||
[
|
||||
"Id", "TokenSource", "Name", "Description", "Organization", "TargetRoot", "CreateOrgSubfolder", "MaxConcurrency",
|
||||
"LastSyncUtc", "LastSyncSummary",
|
||||
];
|
||||
JsonArray accounts = JsonNode.Parse(File.ReadAllText(GitHubFile))!.AsArray();
|
||||
var rewritten = new JsonArray();
|
||||
foreach(JsonNode? account in accounts)
|
||||
{
|
||||
var kept = new JsonObject();
|
||||
foreach(string field in known)
|
||||
{
|
||||
if(account![field] is JsonNode value)
|
||||
{
|
||||
kept[field] = value.DeepClone();
|
||||
}
|
||||
}
|
||||
kept["LastSyncSummary"] = "synced by the older version";
|
||||
rewritten.Add(kept);
|
||||
}
|
||||
File.WriteAllText(GitHubFile, rewritten.ToJsonString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- two files
|
||||
|
||||
[Fact]
|
||||
public void AnAccountOnAnotherForge_IsKeptInAFileOfItsOwn_AndAGitHubAccountWhereItAlwaysWas()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(GiteaAccount(), "gitea_token");
|
||||
|
||||
// Nothing was put where an older version reads and rewrites.
|
||||
Assert.False(File.Exists(GitHubFile));
|
||||
Assert.Contains("\"Forge\": \"Gitea\"", File.ReadAllText(ForgesFile), StringComparison.Ordinal);
|
||||
|
||||
store.Save(GitHubAccount(), "ghp_token");
|
||||
|
||||
string gitHub = File.ReadAllText(GitHubFile);
|
||||
Assert.Contains("\"Name\": \"Work\"", gitHub, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Forge", gitHub, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Server", gitHub, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"Name\": \"Work\"", File.ReadAllText(ForgesFile), StringComparison.Ordinal);
|
||||
Assert.Equal(["Forge", "Work"], NewStore().GetAll().Select(a => a.Name), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AnOlderVersionThatRewritesAccountsJson_LeavesAnAccountOnAnotherForgeAsItIs()
|
||||
{
|
||||
Account work = GitHubAccount();
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(work, "ghp_token");
|
||||
store.Save(forge, "gitea_token");
|
||||
|
||||
RewriteAsAVersionFromBeforeForgesDoes();
|
||||
|
||||
AccountsStore after = NewStore();
|
||||
Account stillThere = Assert.Single(after.GetAll(), a => a.Id == forge.Id);
|
||||
Assert.Equal(ForgeKind.Gitea, stillThere.Forge);
|
||||
Assert.Equal(Server, stillThere.Server);
|
||||
Assert.IsType<GiteaRepositoryLister>(AccountListers.RepositoryListerFor(stillThere));
|
||||
Assert.Equal("gitea_token", new AccountTokenResolver(_vault).TryResolve(stillThere));
|
||||
|
||||
// And what the older version wrote about the GitHub account is there too.
|
||||
Assert.Equal("synced by the older version", after.FindByName("Work")!.LastSyncSummary);
|
||||
Assert.Equal("ghp_token", new AccountTokenResolver(_vault).TryResolve(after.FindByName("Work")!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AChange_WritesOnlyTheFileOfTheAccountItChanges()
|
||||
{
|
||||
Account work = GitHubAccount();
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(work, "ghp_token");
|
||||
store.Save(forge, "gitea_token");
|
||||
string gitHubBefore = File.ReadAllText(GitHubFile);
|
||||
string forgesBefore = File.ReadAllText(ForgesFile);
|
||||
|
||||
// A backup is written each time a file is replaced: none so far, each file was written once.
|
||||
Assert.False(File.Exists(GitHubFile + ".bak"));
|
||||
Assert.False(File.Exists(ForgesFile + ".bak"));
|
||||
|
||||
store.RecordSyncResult(forge.Id, DateTimeOffset.UtcNow, "3 synced");
|
||||
|
||||
Assert.Equal(gitHubBefore, File.ReadAllText(GitHubFile));
|
||||
Assert.False(File.Exists(GitHubFile + ".bak"));
|
||||
Assert.True(File.Exists(ForgesFile + ".bak"));
|
||||
|
||||
store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced");
|
||||
string forgesAfterItsChange = File.ReadAllText(ForgesFile);
|
||||
store.Save(work with { Description = "edited" }, token: null);
|
||||
|
||||
Assert.Equal(forgesAfterItsChange, File.ReadAllText(ForgesFile));
|
||||
Assert.NotEqual(forgesBefore, forgesAfterItsChange, StringComparer.Ordinal);
|
||||
Assert.True(File.Exists(GitHubFile + ".bak"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DeletingTheLastAccountOnAnotherForge_LeavesItsFileEmpty_AndTheOtherFileAlone()
|
||||
{
|
||||
Account work = GitHubAccount();
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(work, "ghp_token");
|
||||
store.Save(forge, "gitea_token");
|
||||
|
||||
store.Delete(forge.Id);
|
||||
|
||||
Assert.Equal("[]", File.ReadAllText(ForgesFile).Trim());
|
||||
Assert.Null(_vault.TryRetrieve(forge.TokenEntryId));
|
||||
Assert.Equal("Work", Assert.Single(NewStore().GetAll()).Name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AnAccountOnAnotherForge_FoundInAccountsJson_IsMovedToItsOwnFile_ByTheNextChange()
|
||||
{
|
||||
// gclo does not put it there; a person can.
|
||||
Account work = GitHubAccount();
|
||||
Account forge = GiteaAccount();
|
||||
WriteFile(GitHubFile, Record(work), Record(forge, $"\"Forge\": \"Gitea\", \"Server\": \"{Server}\""));
|
||||
AccountsStore store = NewStore();
|
||||
Assert.Equal(ForgeKind.Gitea, store.FindByName("Forge")!.Forge);
|
||||
|
||||
store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced");
|
||||
|
||||
Assert.DoesNotContain("Gitea", File.ReadAllText(GitHubFile), StringComparison.Ordinal);
|
||||
Assert.Contains("\"Forge\": \"Gitea\"", File.ReadAllText(ForgesFile), StringComparison.Ordinal);
|
||||
Assert.Equal(["Forge", "Work"], NewStore().GetAll().Select(a => a.Name), StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(_log.Messages, m => m.Contains("missing or damaged", StringComparison.Ordinal)); // nothing was
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AnAccountThatIsInBothFiles_IsTheOneOfItsOwnFile()
|
||||
{
|
||||
// The copy in accounts.json is what an older version could have made of it: a GitHub account.
|
||||
Account forge = GiteaAccount();
|
||||
WriteFile(GitHubFile, Record(forge));
|
||||
WriteFile(ForgesFile, Record(forge, $"\"Forge\": \"Gitea\", \"Server\": \"{Server}\""));
|
||||
|
||||
Account read = Assert.Single(NewStore().GetAll());
|
||||
|
||||
Assert.Equal(ForgeKind.Gitea, read.Forge);
|
||||
Assert.Equal(Server, read.Server);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ADamagedFileOfOneKind_IsMovedAside_AndTheAccountsOfTheOtherAreStillThere()
|
||||
{
|
||||
NewStore().Save(GitHubAccount(), "ghp_token");
|
||||
File.WriteAllText(ForgesFile, "{ this is not a list of accounts");
|
||||
|
||||
AccountsStore store = NewStore();
|
||||
|
||||
Assert.Equal("Work", Assert.Single(store.GetAll()).Name);
|
||||
Assert.False(File.Exists(ForgesFile));
|
||||
Assert.Single(Directory.GetFiles(_root, "accounts-forges.json.corrupt-*"));
|
||||
Assert.Empty(Directory.GetFiles(_root, "accounts.json.corrupt-*"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AFileOfOneKindThatWentMissing_IsWrittenBack_ByAChangeToTheOther()
|
||||
{
|
||||
Account work = GitHubAccount();
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(work, "ghp_token");
|
||||
store.Save(forge, "gitea_token");
|
||||
File.Delete(ForgesFile);
|
||||
|
||||
store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced");
|
||||
|
||||
Assert.Contains(
|
||||
_log.Messages,
|
||||
m => m.Contains("accounts-forges.json' is missing or damaged; the change is applied to the 1 account(s)", StringComparison.Ordinal));
|
||||
Assert.Equal(["Forge", "Work"], NewStore().GetAll().Select(a => a.Name), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AFileOfOneKindThatCannotBeRead_HidesItsAccountsOnly_AndSaysWhichFile()
|
||||
{
|
||||
Account work = GitHubAccount();
|
||||
NewStore().Save(work, "ghp_token");
|
||||
NewStore().Save(GiteaAccount(), "gitea_token");
|
||||
|
||||
using(new FileStream(ForgesFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
var store = new AccountsStore(_vault, _root, _log, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal("Work", Assert.Single(store.GetAll()).Name);
|
||||
Assert.Contains(
|
||||
_log.Messages,
|
||||
m => m.Contains("accounts-forges.json'", StringComparison.Ordinal) && m.Contains("its accounts are not shown", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- what this version does not know
|
||||
|
||||
[Fact]
|
||||
public void AForgeThisVersionDoesNotKnow_MakesThatAccountUnusable_NotTheFile_AndIsWrittenBackAsItWas()
|
||||
{
|
||||
Account later = GitHubAccount("Later");
|
||||
Account forge = GiteaAccount();
|
||||
WriteFile(
|
||||
ForgesFile,
|
||||
Record(later, "\"Forge\": \"GitLab\", \"Server\": \"https://gitlab.example.test/\""),
|
||||
Record(forge, $"\"Forge\": \"Gitea\", \"Server\": \"{Server}\""));
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
AccountsStore store = NewStore();
|
||||
Account read = store.FindByName("Later")!;
|
||||
|
||||
Assert.Equal(2, store.GetAll().Count);
|
||||
Assert.Empty(Directory.GetFiles(_root, "*.corrupt-*"));
|
||||
Assert.False(read.IsOnAKnownForge);
|
||||
Assert.NotEqual(ForgeKind.GitHub, read.Forge); // nothing takes it for a GitHub account
|
||||
Assert.NotEqual(ForgeKind.Gitea, read.Forge);
|
||||
Assert.False(read.TryGetEndpoint(out _, out string? reason));
|
||||
Assert.Equal("it is on a forge this version of gclo does not know", reason);
|
||||
Assert.Equal("a forge this version of gclo does not know", read.DescribePlace());
|
||||
Assert.NotEqual(read.Id, read.TokenEntryId);
|
||||
|
||||
// The default token is a GitHub token, and this is not a GitHub account.
|
||||
Assert.Null(new AccountTokenResolver(_vault).TryResolve(read with { TokenSource = TokenSource.Default }));
|
||||
|
||||
// A change to another account writes the file: what it says about this one stays.
|
||||
store.RecordSyncResult(forge.Id, DateTimeOffset.UtcNow, "3 synced");
|
||||
|
||||
string file = File.ReadAllText(ForgesFile);
|
||||
Assert.Contains("\"Forge\": \"GitLab\"", file, StringComparison.Ordinal);
|
||||
Assert.Contains("\"Server\": \"https://gitlab.example.test/\"", file, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AnAccountOnAForgeThisVersionDoesNotKnow_AsksNoServer_AndSaysWhichVersionToUse()
|
||||
{
|
||||
Account later = GitHubAccount("Later") with { ForgeName = "GitLab", Server = "https://gitlab.example.test/" };
|
||||
|
||||
ForgeAccessException error = await Assert.ThrowsAsync<ForgeAccessException>(
|
||||
() => AccountListers.RepositoryListerFor(later).ListOrganizationRepositoriesAsync("acme", "a-token"));
|
||||
|
||||
Assert.Equal(
|
||||
"Account 'Later' cannot be used as it is saved: it is on a forge this version of gclo does not know. "
|
||||
+ "Use the version of gclo that added it.",
|
||||
error.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, ForgeKind.GitHub)]
|
||||
[InlineData("GitHub", ForgeKind.GitHub)]
|
||||
[InlineData("github", ForgeKind.GitHub)]
|
||||
[InlineData("Gitea", ForgeKind.Gitea)]
|
||||
[InlineData("GITEA", ForgeKind.Gitea)]
|
||||
public void TheNameOfAForge_IsReadWhateverItsCase(string? stored, ForgeKind kind)
|
||||
{
|
||||
Account account = GitHubAccount() with { ForgeName = stored };
|
||||
|
||||
Assert.Equal(kind, account.Forge);
|
||||
Assert.True(account.IsOnAKnownForge);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("1")] // not a number either: a kind is named
|
||||
[InlineData("Forgejo")]
|
||||
public void ANameThatIsNoForgeThisVersionKnows_IsNoKindAtAll(string stored)
|
||||
{
|
||||
Account account = GitHubAccount() with { ForgeName = stored };
|
||||
|
||||
Assert.False(account.IsOnAKnownForge);
|
||||
Assert.False(Enum.IsDefined(account.Forge));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheKindOfForge_IsStoredByItsName_AndGitHubAsNothing()
|
||||
{
|
||||
Assert.Null((GitHubAccount() with { Forge = ForgeKind.GitHub }).ForgeName);
|
||||
Assert.Equal("Gitea", (GitHubAccount() with { Forge = ForgeKind.Gitea }).ForgeName);
|
||||
Assert.Null((GiteaAccount() with { Forge = ForgeKind.GitHub }).ForgeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void WhatALaterVersionSaysAboutAnAccount_IsWrittenBackAsItWas()
|
||||
{
|
||||
Account work = GitHubAccount();
|
||||
WriteFile(GitHubFile, Record(work, "\"Colour\": \"blue\", \"Schedule\": { \"EveryHours\": 6, \"Days\": [\"mon\", \"thu\"] }"));
|
||||
AccountsStore store = NewStore();
|
||||
|
||||
store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced");
|
||||
store.Save(store.FindByName("Work")! with { Description = "edited here" }, token: null);
|
||||
|
||||
JsonNode written = JsonNode.Parse(File.ReadAllText(GitHubFile))![0]!;
|
||||
Assert.Equal("edited here", (string?)written["Description"]);
|
||||
Assert.Equal("5 synced", (string?)written["LastSyncSummary"]);
|
||||
Assert.Equal("blue", (string?)written["Colour"]);
|
||||
Assert.Equal(6, (int?)written["Schedule"]!["EveryHours"]);
|
||||
Assert.Equal("thu", (string?)written["Schedule"]!["Days"]![1]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- a token belongs to one account at one place
|
||||
|
||||
[Fact]
|
||||
public void TheTokenEntry_IsTheAccountsIdForGitHub_AndForAnotherForgeMadeOfTheIdTheForgeAndTheServer()
|
||||
{
|
||||
Account gitHub = GitHubAccount();
|
||||
var zero = new Account { Id = Guid.Empty, Name = "z", Organization = "o", TargetRoot = "t", Forge = ForgeKind.Gitea, Server = Server };
|
||||
|
||||
Assert.Equal(gitHub.Id, gitHub.TokenEntryId);
|
||||
|
||||
// Pinned: tokens that are saved are found again by this value.
|
||||
Assert.Equal(new Guid("3b9b1ee2-9b5a-24bc-b069-daf504cf4a82"), zero.TokenEntryId);
|
||||
|
||||
// Another account, another forge name, another server: another entry each.
|
||||
Assert.NotEqual(zero.TokenEntryId, (zero with { Id = Guid.NewGuid() }).TokenEntryId);
|
||||
Assert.NotEqual(zero.TokenEntryId, (zero with { ForgeName = "GitLab" }).TokenEntryId);
|
||||
Assert.NotEqual(zero.TokenEntryId, (zero with { Server = "https://elsewhere.example.test/" }).TokenEntryId);
|
||||
Assert.NotEqual(zero.TokenEntryId, (zero with { Server = null }).TokenEntryId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AServerChangedInTheFile_DoesNotGetTheTokenThatWasSavedForTheOther()
|
||||
{
|
||||
Account forge = GiteaAccount();
|
||||
NewStore().Save(forge, "gitea_token");
|
||||
File.WriteAllText(
|
||||
ForgesFile, File.ReadAllText(ForgesFile).Replace(Server, "https://elsewhere.example.test/", StringComparison.Ordinal));
|
||||
|
||||
Account edited = Assert.Single(NewStore().GetAll());
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
|
||||
Assert.Equal("https://elsewhere.example.test/", edited.Server);
|
||||
Assert.True(edited.TryGetEndpoint(out _, out _)); // a usable address, and no token for it
|
||||
Assert.Null(resolver.TryResolve(edited));
|
||||
Assert.Equal("gitea_token", _vault.TryRetrieve(forge.TokenEntryId)); // where it was saved, for the place it was saved for
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ARecordThatNamesAnotherAccountsId_FindsNoTokenOfThatAccount()
|
||||
{
|
||||
Account work = GitHubAccount();
|
||||
NewStore().Save(work, "ghp_token");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
|
||||
Account sameId = work with { Forge = ForgeKind.Gitea, Server = "https://elsewhere.example.test/" };
|
||||
Account asDefault = GitHubAccount() with { Id = AppSettings.DefaultTokenVaultId, Forge = ForgeKind.Gitea, Server = "https://elsewhere.example.test/" };
|
||||
|
||||
Assert.Null(resolver.TryResolve(sameId));
|
||||
Assert.Null(resolver.TryResolve(asDefault));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AServerSpelledAnotherWayInTheFile_CanStillBeEdited_AndStaysAsItIsSpelled()
|
||||
{
|
||||
Account forge = GiteaAccount();
|
||||
WriteFile(ForgesFile, Record(forge, "\"Forge\": \"gitea\", \"Server\": \"https://GIT.example.test\""));
|
||||
AccountsStore store = NewStore();
|
||||
|
||||
store.Save(store.FindByName("Forge")! with { Description = "edited" }, token: null);
|
||||
store.Save(store.FindByName("Forge")! with { Server = Server, Forge = ForgeKind.Gitea, MaxConcurrency = 3 }, "gitea_token");
|
||||
|
||||
Account saved = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal("edited", saved.Description);
|
||||
Assert.Equal(3, saved.MaxConcurrency);
|
||||
|
||||
// To the letter: the token is found by what the file says about the place.
|
||||
Assert.Equal("gitea", saved.ForgeName);
|
||||
Assert.Equal("https://GIT.example.test", saved.Server);
|
||||
Assert.Equal("gitea_token", new AccountTokenResolver(_vault).TryResolve(saved));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- the vault is put back under the same entry
|
||||
|
||||
[Fact]
|
||||
public void ANewTokenForAnAccountOnAnotherForge_WhoseFileCannotBeWritten_PutsThePreviousTokenBack()
|
||||
{
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(forge, "first");
|
||||
Directory.CreateDirectory(ForgesFile + ".tmp"); // the next write of that file fails before it changes anything
|
||||
|
||||
Assert.Throws<UnauthorizedAccessException>(() => store.Save(forge with { Description = "edited" }, "second"));
|
||||
|
||||
Assert.Equal("first", _vault.TryRetrieve(forge.TokenEntryId));
|
||||
Assert.Null(_vault.TryRetrieve(forge.Id));
|
||||
Assert.Equal("", Assert.Single(store.GetAll()).Description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ANewAccountOnAnotherForge_WhoseFileCannotBeWritten_LeavesNoTokenBehind()
|
||||
{
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
Directory.CreateDirectory(_root);
|
||||
Directory.CreateDirectory(ForgesFile + ".tmp");
|
||||
|
||||
Assert.Throws<UnauthorizedAccessException>(() => store.Save(forge, "gitea_token"));
|
||||
|
||||
Assert.Null(_vault.TryRetrieve(forge.TokenEntryId));
|
||||
Assert.Null(_vault.TryRetrieve(forge.Id));
|
||||
Assert.Empty(store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DeletingAnAccountOnAnotherForge_WhoseFileCannotBeWritten_PutsItsTokenBack()
|
||||
{
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(forge, "gitea_token");
|
||||
Directory.CreateDirectory(ForgesFile + ".tmp");
|
||||
|
||||
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(forge.Id));
|
||||
|
||||
Assert.Equal("gitea_token", _vault.TryRetrieve(forge.TokenEntryId));
|
||||
Assert.Null(_vault.TryRetrieve(forge.Id));
|
||||
Assert.Single(store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- a workspace without listers of its own
|
||||
|
||||
[Fact]
|
||||
public async Task AWorkspaceThatWasGivenNoListers_HasThoseOfItsAccount()
|
||||
{
|
||||
// An account that cannot be used as it is saved has listers that ask no server
|
||||
// and say why: that is what answers here, for the organizations and for the
|
||||
// repositories, and not the listers of GitHub.
|
||||
Account broken = GiteaAccount("Broken", server: "http://git.example.test");
|
||||
_vault.Store(broken.TokenEntryId, "gitea_token");
|
||||
const string Said = "Account 'Broken' cannot be used as it is saved: the server address cannot be used: "
|
||||
+ "it has to begin with https://. Delete the account and add it again.";
|
||||
|
||||
using var workspace = new WorkspaceViewModel(
|
||||
git: new FakeGitClient(), orgLookupDebounce: TimeSpan.FromMilliseconds(1), log: _log, account: broken, tokenVault: _vault);
|
||||
|
||||
DateTime giveUp = DateTime.UtcNow.AddSeconds(10);
|
||||
while(!_log.Messages.Any(m => m.StartsWith("Organization lookup failed", StringComparison.Ordinal)) && DateTime.UtcNow < giveUp)
|
||||
{
|
||||
await Task.Delay(10);
|
||||
}
|
||||
Assert.Contains($"Organization lookup failed: {Said}", _log.Messages, StringComparer.Ordinal);
|
||||
|
||||
await workspace.LoadReposCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(Said, workspace.LoadErrorMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- the wizard
|
||||
|
||||
[Fact]
|
||||
public async Task TheWizard_NeverSendsTheDefaultTokenToAnAccountOnAnotherForge_WhateverSetTheChoice()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(forge, "gitea_token");
|
||||
var lister = new FakeOrganizationLister();
|
||||
var wizard = new AccountWizardViewModel(store, lister, new AppSettings(), forge, () => "gitea_token", defaultToken: () => "ghp_default");
|
||||
|
||||
wizard.TokenChoiceIndex = 0; // the choice is not shown for this account; this sets it all the same
|
||||
Assert.True(await wizard.TryAdvanceAsync());
|
||||
|
||||
Assert.False(await wizard.TryAdvanceAsync());
|
||||
|
||||
Assert.Equal(2, wizard.Step);
|
||||
Assert.Equal("The default token is a GitHub token. This account needs a token of its own.", wizard.TokenError);
|
||||
Assert.Equal(0, lister.Calls);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ public sealed class AccountForgeTests : IDisposable
|
||||
|
||||
private string FileText => File.ReadAllText(Path.Combine(_root, "accounts.json"));
|
||||
|
||||
/// <summary>The file of the accounts that are not on GitHub (see <see cref="AccountFilesTests"/>).</summary>
|
||||
private string ForgesFileText => File.ReadAllText(Path.Combine(_root, "accounts-forges.json"));
|
||||
|
||||
|
||||
|
||||
private static Account GitHubAccount(string name = "Work") => new()
|
||||
@@ -78,8 +81,8 @@ public sealed class AccountForgeTests : IDisposable
|
||||
{
|
||||
NewStore().Save(GiteaAccount(server: typed), "gitea_token");
|
||||
|
||||
Assert.Contains("\"Forge\": \"Gitea\"", FileText, StringComparison.Ordinal);
|
||||
Assert.Contains($"\"Server\": \"{Server}\"", FileText, StringComparison.Ordinal);
|
||||
Assert.Contains("\"Forge\": \"Gitea\"", ForgesFileText, StringComparison.Ordinal);
|
||||
Assert.Contains($"\"Server\": \"{Server}\"", ForgesFileText, StringComparison.Ordinal);
|
||||
Account reloaded = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal(ForgeKind.Gitea, reloaded.Forge);
|
||||
Assert.Equal(Server, reloaded.Server);
|
||||
@@ -108,21 +111,6 @@ public sealed class AccountForgeTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheTokenEntry_IsTheAccountsIdForGitHub_AndAFixedOtherIdForAnotherForge()
|
||||
{
|
||||
Account gitHub = GitHubAccount();
|
||||
var zero = new Account { Id = Guid.Empty, Name = "z", Organization = "o", TargetRoot = "t", Forge = ForgeKind.Gitea, Server = Server };
|
||||
|
||||
Assert.Equal(gitHub.Id, gitHub.TokenEntryId);
|
||||
|
||||
// Pinned: tokens that are saved are found again by this value.
|
||||
Assert.Equal(new Guid("6f0c2a51-9d3e-4b77-8a14-c5e2f0b39d68"), zero.TokenEntryId);
|
||||
Assert.Equal(Guid.Empty, (zero with { Forge = ForgeKind.GitHub, Server = null }).TokenEntryId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Deleting_AGiteaAccount_RemovesItsToken()
|
||||
{
|
||||
@@ -225,14 +213,14 @@ public sealed class AccountForgeTests : IDisposable
|
||||
Account account = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(account, "gitea_token");
|
||||
string before = FileText;
|
||||
string before = ForgesFileText;
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(
|
||||
() => store.Save(account with { Server = "https://elsewhere.example.test" }, token: null));
|
||||
|
||||
Assert.Contains("is on git.example.test, and that cannot be changed: its token was saved for that place", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("Delete the account and add it again", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(before, FileText);
|
||||
Assert.Equal(before, ForgesFileText);
|
||||
Assert.Equal("gitea_token", _vault.TryRetrieve(account.TokenEntryId));
|
||||
}
|
||||
|
||||
|
||||
+83
-12
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using gclo.Engine;
|
||||
|
||||
@@ -37,18 +38,39 @@ public sealed record Account
|
||||
public bool UsesDefaultToken => TokenSource == TokenSource.Default;
|
||||
|
||||
/// <summary>
|
||||
/// The kind of forge the account is on. GitHub is the default, and is left out of
|
||||
/// accounts.json, so files written before forges existed stay byte-identical and
|
||||
/// a GitHub account written now reads the same in an older gclo. Set when the
|
||||
/// account is added and not changed afterwards: see <see cref="AccountsStore.Save"/>.
|
||||
/// The kind of forge the account is on, as it is stored: its name, and nothing
|
||||
/// (null) for GitHub, so a GitHub account is written as it always was. Stored as
|
||||
/// text and kept as it was read: a name this version does not know (a forge a
|
||||
/// later version added) makes the one account unusable here, not the file
|
||||
/// unreadable, and is written back unchanged. Set when the account is added and
|
||||
/// not changed afterwards: see <see cref="AccountsStore.Save"/>.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ForgeKind>))]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ForgeKind Forge { get; init; } = ForgeKind.GitHub;
|
||||
[JsonPropertyName("Forge")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ForgeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The kind of forge the account is on; GitHub when <see cref="ForgeName"/> says
|
||||
/// nothing. For a name this version does not know it is none of the kinds
|
||||
/// (<see cref="IsOnAKnownForge"/> is false): never GitHub, so nothing takes such
|
||||
/// an account for a GitHub account.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public ForgeKind Forge
|
||||
{
|
||||
get => ForgeName is null || Names(ForgeKind.GitHub) ? ForgeKind.GitHub
|
||||
: Names(ForgeKind.Gitea) ? ForgeKind.Gitea
|
||||
: s_noKnownForge;
|
||||
init => ForgeName = value == ForgeKind.GitHub ? null : value.ToString();
|
||||
}
|
||||
|
||||
/// <summary>False when the account names a forge this version of gclo does not know.</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsOnAKnownForge => Forge != s_noKnownForge;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the account's server, for a forge that has no one well-known
|
||||
/// host (Gitea); null for GitHub, and then left out of accounts.json. It is where
|
||||
/// host (Gitea); null for GitHub, and then left out of the file. It is where
|
||||
/// the account's token is sent, so it is set when the account is added and not
|
||||
/// changed afterwards.
|
||||
/// </summary>
|
||||
@@ -57,11 +79,21 @@ public sealed record Account
|
||||
|
||||
/// <summary>
|
||||
/// The vault entry that holds the account's own token: the account's id for a
|
||||
/// GitHub account, another id for any other forge, so that a version of gclo
|
||||
/// that knows only GitHub finds no token for it (see <see cref="AccountTokenEntry"/>).
|
||||
/// GitHub account, and for any other forge an id made from the account's id, its
|
||||
/// forge and its server, so a token is found only by the account it was saved
|
||||
/// for, at the place it was saved for (see <see cref="AccountTokenEntry"/>).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Guid TokenEntryId => AccountTokenEntry.For(Id, Forge);
|
||||
public Guid TokenEntryId => AccountTokenEntry.For(this);
|
||||
|
||||
/// <summary>
|
||||
/// What the file says about the account that this version of gclo does not know
|
||||
/// (a field a later version added). Kept, and written back as it was read, so
|
||||
/// that changing an account here does not take away what a later version put
|
||||
/// there. Null when there is nothing of the kind.
|
||||
/// </summary>
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JsonElement>? FieldsOfALaterVersion { get; set; }
|
||||
|
||||
/// <summary>Display name; unique across accounts (case-insensitive).</summary>
|
||||
public required string Name { get; init; }
|
||||
@@ -112,10 +144,49 @@ public sealed record Account
|
||||
|
||||
|
||||
|
||||
/// <summary>What <see cref="Forge"/> is for a forge name this version does not know: a value that is none of the kinds.</summary>
|
||||
private static readonly ForgeKind s_noKnownForge = (ForgeKind)(-1);
|
||||
|
||||
|
||||
|
||||
private bool Names(ForgeKind kind) => string.Equals(ForgeName, kind.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Where the account lives, or the reason its forge and server cannot be used as
|
||||
/// they are stored (a file edited by hand, or a forge this version does not know).
|
||||
/// </summary>
|
||||
public bool TryGetEndpoint([NotNullWhen(true)] out ForgeEndpoint? endpoint, [NotNullWhen(false)] out string? reason)
|
||||
=> ForgeEndpoint.TryCreate(Forge, Server, out endpoint, out reason);
|
||||
{
|
||||
if(!IsOnAKnownForge)
|
||||
{
|
||||
endpoint = null;
|
||||
reason = "it is on a forge this version of gclo does not know";
|
||||
return false;
|
||||
}
|
||||
return ForgeEndpoint.TryCreate(Forge, Server, out endpoint, out reason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// How a message or a listing names the place the account is on: "GitHub", or the
|
||||
/// host of its server. When what is stored cannot be used, the kind of forge
|
||||
/// alone: such an address is not repeated, it may hold a password or a token.
|
||||
/// </summary>
|
||||
public string DescribePlace()
|
||||
=> TryGetEndpoint(out ForgeEndpoint? endpoint, out _) ? endpoint.DisplayName
|
||||
: IsOnAKnownForge ? $"{Forge}, at an address that cannot be used"
|
||||
: "a forge this version of gclo does not know";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What to do about the account when it cannot be used as it is saved, as the
|
||||
/// sentence that ends such a message. <paramref name="remove"/> is how the caller
|
||||
/// names removing an account ("Delete" in the app, "Remove" on the command line).
|
||||
/// </summary>
|
||||
public string AdviceWhenUnusable(string remove)
|
||||
=> IsOnAKnownForge ? $"{remove} the account and add it again." : "Use the version of gclo that added it.";
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public static class AccountListers
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? reason)
|
||||
? endpoint.CreateRepositoryLister()
|
||||
: new UnusableAccountLister(account.Name, reason);
|
||||
: new UnusableAccountLister(account, reason);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ public static class AccountListers
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? reason)
|
||||
? endpoint.CreateOrganizationLister()
|
||||
: new UnusableAccountLister(account.Name, reason);
|
||||
: new UnusableAccountLister(account, reason);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
@@ -11,36 +12,37 @@ namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Which vault entry holds an account's own token. For a GitHub account it is the
|
||||
/// account's id, as it always was. For an account on another forge it is a
|
||||
/// different id, derived from the account's.
|
||||
/// account's id, as it always was. For an account on another forge it is an id
|
||||
/// made from the account's id, the name of its forge and the address of its
|
||||
/// server, exactly as the account's file states them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A version of gclo from before forges existed reads every account as a GitHub
|
||||
/// account and looks its token up under the account's id. If the token of a Gitea
|
||||
/// account were kept there, that version would send it to GitHub. Kept under an id
|
||||
/// it does not look up, the account has no token as far as that version can tell,
|
||||
/// and it says so instead.
|
||||
/// <para>
|
||||
/// The entry belongs to one account at one place. An account's forge and server
|
||||
/// cannot be changed through gclo (<see cref="AccountsStore.Save"/>); when the file
|
||||
/// is changed by hand to name another server, the entry is not found there either,
|
||||
/// and the account has no token instead of sending the one it had to the new
|
||||
/// address. A record that names another account's id finds nothing for the same
|
||||
/// reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Never to be changed: saved tokens are found by it. What goes in is the text of
|
||||
/// the file, not an address that was parsed, so a later change to how an address
|
||||
/// is normalized changes no entry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class AccountTokenEntry
|
||||
{
|
||||
/// <summary>What an account's id is combined with for a forge that is not GitHub. Never to be changed: saved tokens are found by it.</summary>
|
||||
private static readonly byte[] s_otherForge = new Guid("6f0c2a51-9d3e-4b77-8a14-c5e2f0b39d68").ToByteArray();
|
||||
|
||||
|
||||
|
||||
/// <summary>The id of the vault entry for the token of the account <paramref name="accountId"/> on <paramref name="forge"/>.</summary>
|
||||
public static Guid For(Guid accountId, ForgeKind forge)
|
||||
/// <summary>The id of the vault entry for the token of <paramref name="account"/>.</summary>
|
||||
public static Guid For(Account account)
|
||||
{
|
||||
if(forge == ForgeKind.GitHub)
|
||||
if(account.ForgeName is null)
|
||||
{
|
||||
return accountId;
|
||||
return account.Id;
|
||||
}
|
||||
|
||||
byte[] id = accountId.ToByteArray();
|
||||
for(int i = 0; i < id.Length; i++)
|
||||
{
|
||||
id[i] ^= s_otherForge[i];
|
||||
}
|
||||
return new Guid(id);
|
||||
byte[] hash = SHA256.HashData(
|
||||
Encoding.UTF8.GetBytes($"gclo account token\n{account.Id:N}\n{account.ForgeName}\n{account.Server}"));
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
}
|
||||
@@ -347,6 +347,17 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
// An edit with the box left empty validates the STORED token, fetched
|
||||
// here and used for this one call only.
|
||||
string candidate;
|
||||
if(UseDefaultToken && (_existing?.Forge ?? ForgeKind.GitHub) != ForgeKind.GitHub)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// The choice is not shown for such an account. Whatever set it, //
|
||||
// the default token is a GitHub token and is not sent anywhere //
|
||||
// else. //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
TokenError = "The default token is a GitHub token. This account needs a token of its own.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(UseDefaultToken)
|
||||
{
|
||||
// The default token is fetched for this one call only; the box
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One file of accounts on disk, as <see cref="AccountsStore"/> keeps them: how it is
|
||||
/// read (tolerant of a missing or damaged file, patient with a busy one) and how it
|
||||
/// is written (in one step, with a backup). It remembers what the file last held,
|
||||
/// so a file that goes missing or is damaged can be written back, and so a change
|
||||
/// that does not concern this file leaves it alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A file that cannot be read is not a corrupt file. Only content that is not valid
|
||||
/// JSON is moved aside. A file that is in use is tried again for a short while; one
|
||||
/// that stays unreadable is left exactly where it is, and the read throws.
|
||||
/// </remarks>
|
||||
internal sealed class AccountsFile
|
||||
{
|
||||
private readonly string _filePath;
|
||||
|
||||
private readonly IActivityLog _log;
|
||||
|
||||
private readonly TimeSpan _patience;
|
||||
|
||||
private static readonly TimeSpan s_pause = TimeSpan.FromMilliseconds(15);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The file at <paramref name="filePath"/>. <paramref name="patience"/> is how long
|
||||
/// a file that something else holds for a moment is tried again.
|
||||
/// </summary>
|
||||
public AccountsFile(string filePath, IActivityLog log, TimeSpan patience)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_log = log;
|
||||
_patience = patience;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The file's full path.</summary>
|
||||
public string FilePath => _filePath;
|
||||
|
||||
/// <summary>
|
||||
/// What the file held when it was last read or written. Kept when a later read
|
||||
/// finds the file missing or damaged: it is what is then written back.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Account> Accounts { get; private set; } = [];
|
||||
|
||||
/// <summary>Whether the last read found a list of accounts in the file, or the last write put one there.</summary>
|
||||
public bool IsThere { get; private set; }
|
||||
|
||||
private TimeSpan FilePatience => _patience;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the file again. True when it holds a list of accounts, which is then
|
||||
/// what <see cref="Accounts"/> gives. False when the file is missing or its
|
||||
/// content is not a list of accounts (it is then moved aside): <see cref="Accounts"/>
|
||||
/// stays what it was. A file that cannot be read at all throws, and is not touched.
|
||||
/// </summary>
|
||||
public bool Refresh()
|
||||
{
|
||||
List<Account>? read = ReadFile();
|
||||
IsThere = read is not null;
|
||||
if(read is not null)
|
||||
{
|
||||
Accounts = read;
|
||||
}
|
||||
return IsThere;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="accounts"/> when the file does not hold exactly that
|
||||
/// already: a file that is there is rewritten only for a change, and one that is
|
||||
/// missing or damaged is written when there is an account to put in it.
|
||||
/// </summary>
|
||||
public void WriteIfChanged(List<Account> accounts)
|
||||
{
|
||||
bool needed = IsThere ? !accounts.SequenceEqual(Accounts) : accounts.Count > 0;
|
||||
if(needed)
|
||||
{
|
||||
Write(accounts);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The accounts the file holds now, or null when it holds none that can be used:
|
||||
/// the file is missing, or its content is not a list of accounts (it is then moved
|
||||
/// aside). A file that cannot be read at all throws, and is not touched.
|
||||
/// </summary>
|
||||
private List<Account>? ReadFile()
|
||||
{
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = WithPatience(() => File.ReadAllText(_filePath), FilePatience, accessDeniedIsBusy: false);
|
||||
}
|
||||
catch(Exception ex) when(ex is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<Account>? loaded = JsonSerializer.Deserialize(json, AccountsJsonContext.Default.ListAccount);
|
||||
if(loaded is not null)
|
||||
{
|
||||
return loaded;
|
||||
}
|
||||
_log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts.");
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The text was read; it is what it says that cannot be used. That, and //
|
||||
// only that, is a corrupt file. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
_log.Error($"Accounts file '{_filePath}' is not a valid list of accounts; starting with no accounts.", ex);
|
||||
}
|
||||
|
||||
PreserveCorruptFile();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs <paramref name="action"/>, trying again for <paramref name="patience"/> as long as the file is busy.</summary>
|
||||
internal static T WithPatience<T>(Func<T> action, TimeSpan patience, bool accessDeniedIsBusy)
|
||||
{
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
while(true)
|
||||
{
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
catch(Exception ex) when(IsBusy(ex, accessDeniedIsBusy) && Stopwatch.GetElapsedTime(started) < patience)
|
||||
{
|
||||
Thread.Sleep(s_pause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True for a failure that means "in use right now", which passes: on Windows a
|
||||
/// sharing or lock violation, and, where the operation reports a busy target that
|
||||
/// way (replacing a file somebody has open), a refused access. A missing file, a
|
||||
/// full disk, or a path that is too long is never that, and is not waited for.
|
||||
/// </summary>
|
||||
internal static bool IsBusy(Exception exception, bool accessDeniedIsBusy)
|
||||
{
|
||||
const int SharingViolation = 32;
|
||||
const int LockViolation = 33;
|
||||
if(exception is UnauthorizedAccessException)
|
||||
{
|
||||
return accessDeniedIsBusy;
|
||||
}
|
||||
|
||||
return exception is IOException and not (FileNotFoundException or DirectoryNotFoundException or PathTooLongException)
|
||||
&& (!OperatingSystem.IsWindows() || (exception.HResult & 0xFFFF) is SharingViolation or LockViolation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Moves an unreadable accounts file aside before the empty in-memory list can be
|
||||
/// persisted over it: the metadata is what associates vault entries with accounts,
|
||||
/// so clobbering a recoverable file would orphan every stored token.
|
||||
/// </summary>
|
||||
private void PreserveCorruptFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
if(File.Exists(_filePath))
|
||||
{
|
||||
string aside = $"{_filePath}.corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}";
|
||||
File.Move(_filePath, aside, overwrite: true);
|
||||
_log.Error($"Preserved unreadable accounts file as '{aside}'.");
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_log.Error("Could not preserve the unreadable accounts file.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Puts <paramref name="accounts"/> in the file, in one step: the file is never
|
||||
/// absent and never half written. A failure leaves the file, and what this object
|
||||
/// holds, as they were.
|
||||
/// </summary>
|
||||
private void Write(List<Account> accounts)
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_filePath);
|
||||
if(!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// Atomic replace: a crash or full disk mid-write must never truncate the live
|
||||
// file — corrupted metadata orphans every vault token (accounts are found by
|
||||
// Guid, and recreated accounts mint new ones).
|
||||
string json = JsonSerializer.Serialize(accounts, AccountsJsonContext.Default.ListAccount);
|
||||
string tempPath = _filePath + ".tmp";
|
||||
using(var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
using(var writer = new StreamWriter(stream))
|
||||
{
|
||||
writer.Write(json);
|
||||
writer.Flush();
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
KeepABackup();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// A file nobody may write to stays that way however long it is tried: say //
|
||||
// so at once instead of taking the refusal for a file that is busy. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
if(File.Exists(_filePath) && File.GetAttributes(_filePath).HasFlag(FileAttributes.ReadOnly))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"The accounts file '{_filePath}' is read-only; nothing was changed.");
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// One step puts the new file in place of the old: the name is never absent, //
|
||||
// so a process that reads without the lock always finds a whole file. //
|
||||
// Something outside gclo (a scanner, a backup, a viewer) can hold the live //
|
||||
// file for a moment; the step is then refused, and tried again for a while. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
WithPatience(
|
||||
() =>
|
||||
{
|
||||
File.Move(tempPath, _filePath, overwrite: true);
|
||||
return true;
|
||||
},
|
||||
FilePatience,
|
||||
accessDeniedIsBusy: true);
|
||||
Accounts = accounts;
|
||||
IsThere = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Copies the file that is about to be replaced to a .bak file beside it. Best effort:
|
||||
/// the backup is a convenience for a person, and failing to write it must not
|
||||
/// stop the change it is a backup for.
|
||||
/// </summary>
|
||||
private void KeepABackup()
|
||||
{
|
||||
try
|
||||
{
|
||||
if(File.Exists(_filePath))
|
||||
{
|
||||
string backup = _filePath + ".bak";
|
||||
if(File.Exists(backup))
|
||||
{
|
||||
File.SetAttributes(backup, FileAttributes.Normal);
|
||||
}
|
||||
File.Copy(_filePath, backup, overwrite: true);
|
||||
}
|
||||
}
|
||||
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Error($"Could not write the backup '{_filePath}.bak' ({ex.Message}); the change goes ahead without it.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
-235
@@ -3,8 +3,6 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
@@ -12,10 +10,11 @@ namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Persists account profiles as JSON at accounts.json under
|
||||
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\KofTwentyTwo\gclo) and their
|
||||
/// tokens in an <see cref="ITokenVault"/>; the file holds metadata only and never a
|
||||
/// token. Unlike <see cref="AppSettings"/>, accounts are primary user data, so write
|
||||
/// Persists account profiles as JSON under <see cref="GcloPaths.DataRoot"/> (default
|
||||
/// %LOCALAPPDATA%\KofTwentyTwo\gclo) and their tokens in an <see cref="ITokenVault"/>;
|
||||
/// the files hold metadata only and never a token. GitHub accounts are in
|
||||
/// accounts.json, as they always were; accounts on another forge are in
|
||||
/// accounts-forges.json (see the remarks). Unlike <see cref="AppSettings"/>, accounts are primary user data, so write
|
||||
/// failures in <see cref="Save"/>, <see cref="Delete"/>, and
|
||||
/// <see cref="RecordSyncResult"/> propagate instead of being swallowed; only loading
|
||||
/// is tolerant (a missing, corrupt, or unreadable file yields an empty list).
|
||||
@@ -58,6 +57,19 @@ namespace gclo.ViewModels;
|
||||
/// that is also what the store holds after a change that failed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two files.</b> A version of gclo from before forges reads every account in
|
||||
/// accounts.json as a GitHub account, and writes the file back with what it knows:
|
||||
/// it would drop the forge and the server of an account on a Gitea server, and from
|
||||
/// then on that account would read as a GitHub account, whose token is sent to
|
||||
/// GitHub. The desktop app updates itself and the command line is a separate
|
||||
/// download, so two versions on one data folder are ordinary. Accounts on another
|
||||
/// forge are therefore kept in a file of their own, which such a version neither
|
||||
/// reads nor writes. Both files are changed under the one lock. An account on
|
||||
/// another forge that is found in accounts.json (gclo does not put one there) is
|
||||
/// moved to its own file by the next change, and one that is in both files is read
|
||||
/// from its own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A file that cannot be read is not a corrupt file. Only content that is not valid
|
||||
/// JSON is moved aside. A file that is in use is tried again for a short while; one
|
||||
/// that stays unreadable is left exactly where it is: at construction the store then
|
||||
@@ -72,7 +84,14 @@ public sealed class AccountsStore
|
||||
|
||||
private readonly IActivityLog _log;
|
||||
|
||||
private readonly string _filePath;
|
||||
/// <summary>accounts.json: the GitHub accounts. Read and written by every version of gclo.</summary>
|
||||
private readonly AccountsFile _gitHubFile;
|
||||
|
||||
/// <summary>accounts-forges.json: the accounts on another forge. A version from before forges does not touch it.</summary>
|
||||
private readonly AccountsFile _forgesFile;
|
||||
|
||||
/// <summary>The lock every gclo process takes before it changes an accounts file.</summary>
|
||||
private readonly string _lockPath;
|
||||
|
||||
/// <summary>How long the lock another gclo process holds is waited for.</summary>
|
||||
private static readonly TimeSpan s_defaultPatience = TimeSpan.FromSeconds(5);
|
||||
@@ -85,8 +104,6 @@ public sealed class AccountsStore
|
||||
/// </summary>
|
||||
private const int FilePatienceDivisor = 5;
|
||||
|
||||
private static readonly TimeSpan s_pause = TimeSpan.FromMilliseconds(15);
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
private readonly TimeSpan _patience;
|
||||
@@ -115,8 +132,10 @@ public sealed class AccountsStore
|
||||
_vault = vault;
|
||||
_log = log ?? new NullActivityLog();
|
||||
directory ??= GcloPaths.DataRoot;
|
||||
_filePath = Path.Combine(directory, "accounts.json");
|
||||
_patience = patience;
|
||||
_gitHubFile = new AccountsFile(Path.Combine(directory, "accounts.json"), _log, patience / FilePatienceDivisor);
|
||||
_forgesFile = new AccountsFile(Path.Combine(directory, "accounts-forges.json"), _log, patience / FilePatienceDivisor);
|
||||
_lockPath = _gitHubFile.FilePath + ".lock";
|
||||
_accounts = LoadAtStart();
|
||||
}
|
||||
|
||||
@@ -432,18 +451,21 @@ public sealed class AccountsStore
|
||||
/// </summary>
|
||||
private List<Account> LoadAtStart()
|
||||
{
|
||||
try
|
||||
foreach(AccountsFile file in (AccountsFile[])[_gitHubFile, _forgesFile])
|
||||
{
|
||||
return ReadFile() ?? [];
|
||||
}
|
||||
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Error(
|
||||
$"Could not read accounts from '{_filePath}' ({ex.Message}); no accounts are shown. "
|
||||
+ "The file was left as it is.",
|
||||
ex);
|
||||
return [];
|
||||
try
|
||||
{
|
||||
file.Refresh();
|
||||
}
|
||||
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Error(
|
||||
$"Could not read accounts from '{file.FilePath}' ({ex.Message}); its accounts are not shown. "
|
||||
+ "The file was left as it is.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
return Union();
|
||||
}
|
||||
|
||||
|
||||
@@ -456,17 +478,54 @@ public sealed class AccountsStore
|
||||
/// </summary>
|
||||
private void RefreshForAChange()
|
||||
{
|
||||
List<Account>? current = ReadFile();
|
||||
if(current is not null)
|
||||
foreach(AccountsFile file in (AccountsFile[])[_gitHubFile, _forgesFile])
|
||||
{
|
||||
_accounts = current;
|
||||
}
|
||||
else if(_accounts.Count > 0)
|
||||
{
|
||||
_log.Error(
|
||||
$"Accounts file '{_filePath}' is missing or damaged; the change is applied to the {_accounts.Count} "
|
||||
+ "account(s) this session last read, which writes them back.");
|
||||
int lastRead = file.Accounts.Count;
|
||||
if(!file.Refresh() && lastRead > 0)
|
||||
{
|
||||
_log.Error(
|
||||
$"Accounts file '{file.FilePath}' is missing or damaged; the change is applied to the {lastRead} "
|
||||
+ "account(s) this session last read, which writes them back.");
|
||||
}
|
||||
}
|
||||
_accounts = Union();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The accounts of both files as one list. An account that is in both (by its
|
||||
/// id) is the one of the file for other forges: that is the file gclo writes such
|
||||
/// an account to, and the copy in accounts.json is what an older version may
|
||||
/// have rewritten.
|
||||
/// </summary>
|
||||
private List<Account> Union()
|
||||
{
|
||||
HashSet<Guid> apart = [.. _forgesFile.Accounts.Select(a => a.Id)];
|
||||
return [.. _gitHubFile.Accounts.Where(a => !apart.Contains(a.Id)), .. _forgesFile.Accounts];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether an account is kept in the file for other forges: every account that
|
||||
/// names a forge. An account that names none is a GitHub account as an older
|
||||
/// version knows it.
|
||||
/// </summary>
|
||||
private static bool IsKeptApart(Account account) => account.ForgeName is not null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes each account to the file it belongs in. Only a file whose accounts
|
||||
/// changed is written, so one change is one file put in place. (The exception is
|
||||
/// an account on another forge that was found in accounts.json: it is written to
|
||||
/// its own file first, and then leaves the other.)
|
||||
/// </summary>
|
||||
private void Persist(List<Account> accounts)
|
||||
{
|
||||
_forgesFile.WriteIfChanged(accounts.Where(IsKeptApart).ToList());
|
||||
_gitHubFile.WriteIfChanged(accounts.Where(a => !IsKeptApart(a)).ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -519,14 +578,25 @@ public sealed class AccountsStore
|
||||
{
|
||||
throw new ArgumentException($"Account '{account.Name}' cannot be saved: {reason}.", nameof(account));
|
||||
}
|
||||
account = account with { Server = endpoint.Server?.AbsoluteUri };
|
||||
|
||||
Account? onDisk = _accounts.FirstOrDefault(a => a.Id == account.Id);
|
||||
if(onDisk is not null
|
||||
&& (onDisk.Forge != account.Forge || !string.Equals(onDisk.Server, account.Server, StringComparison.Ordinal)))
|
||||
if(onDisk is null)
|
||||
{
|
||||
account = account with { Server = endpoint.Server?.AbsoluteUri };
|
||||
}
|
||||
else if(onDisk.TryGetEndpoint(out ForgeEndpoint? was, out _) && was.Equals(endpoint))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The same place, however the caller spelled it. What the file says //
|
||||
// about the place stays as it is, to the letter: the account's token is //
|
||||
// found by it (AccountTokenEntry). //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
account = account with { ForgeName = onDisk.ForgeName, Server = onDisk.Server };
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Account '{onDisk.Name}' is on {DescribeForge(onDisk)}, and that cannot be changed: its token was saved for that place. "
|
||||
$"Account '{onDisk.Name}' is on {onDisk.DescribePlace()}, and that cannot be changed: its token was saved for that place. "
|
||||
+ "Delete the account and add it again for the other one.",
|
||||
nameof(account));
|
||||
}
|
||||
@@ -542,62 +612,6 @@ public sealed class AccountsStore
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// How a message names the place an account is on, also when what is stored cannot
|
||||
/// be used. Such an address is not repeated: it may hold a password or a token.
|
||||
/// </summary>
|
||||
private static string DescribeForge(Account account)
|
||||
=> account.TryGetEndpoint(out ForgeEndpoint? endpoint, out _)
|
||||
? endpoint.DisplayName
|
||||
: $"{account.Forge}, at an address that cannot be used";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The accounts the file holds now, or null when it holds none that can be used:
|
||||
/// the file is missing, or its content is not a list of accounts (it is then moved
|
||||
/// aside). A file that cannot be read at all throws, and is not touched.
|
||||
/// </summary>
|
||||
private List<Account>? ReadFile()
|
||||
{
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = WithPatience(() => File.ReadAllText(_filePath), FilePatience, accessDeniedIsBusy: false);
|
||||
}
|
||||
catch(Exception ex) when(ex is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<Account>? loaded = JsonSerializer.Deserialize(json, AccountsJsonContext.Default.ListAccount);
|
||||
if(loaded is not null)
|
||||
{
|
||||
return loaded;
|
||||
}
|
||||
_log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts.");
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The text was read; it is what it says that cannot be used. That, and //
|
||||
// only that, is a corrupt file. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
_log.Error($"Accounts file '{_filePath}' is not a valid list of accounts; starting with no accounts.", ex);
|
||||
}
|
||||
|
||||
PreserveCorruptFile();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private TimeSpan FilePatience => _patience / FilePatienceDivisor;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Takes the lock that every gclo process takes before it changes the accounts
|
||||
/// file, waiting a short while for another process to finish. The lock is a file
|
||||
@@ -607,7 +621,7 @@ public sealed class AccountsStore
|
||||
/// <exception cref="AccountsFileBusyException">Another gclo process kept the lock for the whole wait.</exception>
|
||||
private FileStream AcquireFileLock()
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_filePath);
|
||||
string? directory = Path.GetDirectoryName(_lockPath);
|
||||
if(!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
@@ -615,157 +629,16 @@ public sealed class AccountsStore
|
||||
|
||||
try
|
||||
{
|
||||
return WithPatience(
|
||||
() => new FileStream(_filePath + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None),
|
||||
return AccountsFile.WithPatience(
|
||||
() => new FileStream(_lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None),
|
||||
_patience,
|
||||
accessDeniedIsBusy: false);
|
||||
}
|
||||
catch(IOException ex) when(IsBusy(ex, accessDeniedIsBusy: false))
|
||||
catch(IOException ex) when(AccountsFile.IsBusy(ex, accessDeniedIsBusy: false))
|
||||
{
|
||||
throw new AccountsFileBusyException(
|
||||
$"The accounts file '{_filePath}' is in use by another gclo process; nothing was changed. Try again in a moment.",
|
||||
$"The accounts file '{_gitHubFile.FilePath}' is in use by another gclo process; nothing was changed. Try again in a moment.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs <paramref name="action"/>, trying again for <paramref name="patience"/> as long as the file is busy.</summary>
|
||||
private static T WithPatience<T>(Func<T> action, TimeSpan patience, bool accessDeniedIsBusy)
|
||||
{
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
while(true)
|
||||
{
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
catch(Exception ex) when(IsBusy(ex, accessDeniedIsBusy) && Stopwatch.GetElapsedTime(started) < patience)
|
||||
{
|
||||
Thread.Sleep(s_pause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True for a failure that means "in use right now", which passes: on Windows a
|
||||
/// sharing or lock violation, and, where the operation reports a busy target that
|
||||
/// way (replacing a file somebody has open), a refused access. A missing file, a
|
||||
/// full disk, or a path that is too long is never that, and is not waited for.
|
||||
/// </summary>
|
||||
private static bool IsBusy(Exception exception, bool accessDeniedIsBusy)
|
||||
{
|
||||
const int SharingViolation = 32;
|
||||
const int LockViolation = 33;
|
||||
if(exception is UnauthorizedAccessException)
|
||||
{
|
||||
return accessDeniedIsBusy;
|
||||
}
|
||||
|
||||
return exception is IOException and not (FileNotFoundException or DirectoryNotFoundException or PathTooLongException)
|
||||
&& (!OperatingSystem.IsWindows() || (exception.HResult & 0xFFFF) is SharingViolation or LockViolation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Moves an unreadable accounts file aside before the empty in-memory list can be
|
||||
/// persisted over it: the metadata is what associates vault entries with accounts,
|
||||
/// so clobbering a recoverable file would orphan every stored token.
|
||||
/// </summary>
|
||||
private void PreserveCorruptFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
if(File.Exists(_filePath))
|
||||
{
|
||||
string aside = $"{_filePath}.corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}";
|
||||
File.Move(_filePath, aside, overwrite: true);
|
||||
_log.Error($"Preserved unreadable accounts file as '{aside}'.");
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_log.Error("Could not preserve the unreadable accounts file.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void Persist(List<Account> accounts)
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_filePath);
|
||||
if(!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// Atomic replace: a crash or full disk mid-write must never truncate the live
|
||||
// file — corrupted metadata orphans every vault token (accounts are found by
|
||||
// Guid, and recreated accounts mint new ones).
|
||||
string json = JsonSerializer.Serialize(accounts, AccountsJsonContext.Default.ListAccount);
|
||||
string tempPath = _filePath + ".tmp";
|
||||
using(var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
using(var writer = new StreamWriter(stream))
|
||||
{
|
||||
writer.Write(json);
|
||||
writer.Flush();
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
KeepABackup();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// A file nobody may write to stays that way however long it is tried: say //
|
||||
// so at once instead of taking the refusal for a file that is busy. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
if(File.Exists(_filePath) && File.GetAttributes(_filePath).HasFlag(FileAttributes.ReadOnly))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"The accounts file '{_filePath}' is read-only; nothing was changed.");
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// One step puts the new file in place of the old: the name is never absent, //
|
||||
// so a process that reads without the lock always finds a whole file. //
|
||||
// Something outside gclo (a scanner, a backup, a viewer) can hold the live //
|
||||
// file for a moment; the step is then refused, and tried again for a while. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
WithPatience(
|
||||
() =>
|
||||
{
|
||||
File.Move(tempPath, _filePath, overwrite: true);
|
||||
return true;
|
||||
},
|
||||
FilePatience,
|
||||
accessDeniedIsBusy: true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Copies the file that is about to be replaced to accounts.json.bak. Best effort:
|
||||
/// the backup is a convenience for a person, and failing to write it must not
|
||||
/// stop the change it is a backup for.
|
||||
/// </summary>
|
||||
private void KeepABackup()
|
||||
{
|
||||
try
|
||||
{
|
||||
if(File.Exists(_filePath))
|
||||
{
|
||||
string backup = _filePath + ".bak";
|
||||
if(File.Exists(backup))
|
||||
{
|
||||
File.SetAttributes(backup, FileAttributes.Normal);
|
||||
}
|
||||
File.Copy(_filePath, backup, overwrite: true);
|
||||
}
|
||||
}
|
||||
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Error($"Could not write the backup '{_filePath}.bak' ({ex.Message}); the change goes ahead without it.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The listers of an account whose forge or server cannot be used as they are
|
||||
/// stored (an accounts.json edited by hand, or written by a version that knows a
|
||||
/// stored (an accounts file edited by hand, or written by a version that knows a
|
||||
/// forge this one does not). It asks no server anything: every call fails with the
|
||||
/// reason, so the account's token goes nowhere.
|
||||
/// </summary>
|
||||
@@ -21,9 +21,9 @@ internal sealed class UnusableAccountLister : IRepositoryLister, IOrganizationLi
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the lister for the account <paramref name="account"/>, which cannot be used because of <paramref name="reason"/>.</summary>
|
||||
public UnusableAccountLister(string account, string reason)
|
||||
=> _message = $"Account '{account}' cannot be used as it is saved: {reason}. Delete the account and add it again.";
|
||||
/// <summary>Creates the lister for <paramref name="account"/>, which cannot be used because of <paramref name="reason"/>.</summary>
|
||||
public UnusableAccountLister(Account account, string reason)
|
||||
=> _message = $"Account '{account.Name}' cannot be used as it is saved: {reason}. {account.AdviceWhenUnusable("Delete")}";
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user