From a060c6735b89f6b8091b30262b0978f9bd556090 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 4 Jul 2026 15:29:42 -0500 Subject: [PATCH] =?UTF-8?q?Phase=201:=20accounts=20foundation=20=E2=80=94?= =?UTF-8?q?=20model,=20credential=20vault,=20store,=20CLI=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account record + AccountsStore: metadata-only accounts.json (atomic temp-file + File.Replace persistence with .bak; corrupt files are preserved aside so vault tokens can be re-associated, never clobbered) - ITokenVault with CredentialManagerVault (Win32 CredRead/CredWrite P/Invoke, spike-proven on plain net10.0; token blob zeroed while pinned) and InMemoryVault for tests - CLI: gclo accounts [--json]; gclo sync --account with explicit flags overriding account values, RecordSyncResult on completion, Windows-only guard; interactive --token-stdin now reads without echo - Security review (3 lenses, adversarially verified): 4 confirmed findings fixed pre-ship, 13 refuted - 20 new tests (146 total) incl. real Credential Manager round-trip and crash-safety pinning; plan doc updated (vault decision, Phase 3 active strip) Co-Authored-By: Claude Fable 5 --- docs/CLI.md | 87 +++++++- docs/plans/accounts-mode.md | 17 +- gclo.Cli/AccountsCommand.cs | 130 ++++++++++++ gclo.Cli/CliJson.cs | 12 ++ gclo.Cli/Program.cs | 20 +- gclo.Cli/SyncCommand.cs | 148 +++++++++++--- gclo.Cli/TokenOptions.cs | 46 ++++- gclo.Cli/gclo.Cli.csproj | 1 + gclo.Engine.Tests/AccountsStoreTests.cs | 221 ++++++++++++++++++++ gclo.Engine.Tests/VaultTests.cs | 87 ++++++++ gclo.ViewModels/Account.cs | 40 ++++ gclo.ViewModels/AccountsStore.cs | 236 ++++++++++++++++++++++ gclo.ViewModels/CredentialManagerVault.cs | 147 ++++++++++++++ gclo.ViewModels/ITokenVault.cs | 23 +++ gclo.ViewModels/InMemoryVault.cs | 27 +++ 15 files changed, 1204 insertions(+), 38 deletions(-) create mode 100644 gclo.Cli/AccountsCommand.cs create mode 100644 gclo.Engine.Tests/AccountsStoreTests.cs create mode 100644 gclo.Engine.Tests/VaultTests.cs create mode 100644 gclo.ViewModels/Account.cs create mode 100644 gclo.ViewModels/AccountsStore.cs create mode 100644 gclo.ViewModels/CredentialManagerVault.cs create mode 100644 gclo.ViewModels/ITokenVault.cs create mode 100644 gclo.ViewModels/InMemoryVault.cs diff --git a/docs/CLI.md b/docs/CLI.md index da5288f..d05d209 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -27,7 +27,9 @@ and runs on Linux/macOS hosts with the .NET 10 SDK. gclo sync --org --target [--parallel N] [--sanitize-paths] [--token-env VAR | --token-file PATH | --token-stdin] [--json] [--quiet] +gclo sync --account [same options — each one overrides the account value] gclo orgs [--token-env VAR | --token-file PATH | --token-stdin] [--json] +gclo accounts [--json] gclo --version gclo --help (each command also accepts --help) ``` @@ -128,6 +130,73 @@ A token that cannot list organizations (a fine-grained PAT, or a classic PAT without the `read:org` scope) still prints its own account login — you can pass any organization name to `gclo sync` manually. +## Accounts + +An *account* is a saved sync profile: a name, an organization, a target root +folder, a parallelism setting, an org-subfolder preference, and a token stored +in **Windows Credential Manager** (under `gclo:account:`; the metadata file, +`%LOCALAPPDATA%\gclo\accounts.json`, never contains the token). Accounts are +created and edited in the gclo desktop app's account wizard for now — a CLI +flag for creating accounts is planned. + +> **Windows-only.** Because the token lives in Windows Credential Manager, +> `gclo accounts` and `gclo sync --account` exit with code 2 on Linux/macOS. +> Plain `gclo sync` and `gclo orgs` work everywhere. + +### `gclo accounts` + +Lists the saved accounts, one per line, in aligned columns: name, organization, +target root, and last sync time (local time, `never` when the account has not +completed a sync yet): + +``` +work contoso C:\src\contoso 2026-07-04 09:12 +personal octocat D:\mirror never +``` + +With `--json` it prints a single-line JSON array instead; `lastSync` is the UTC +timestamp of the last completed sync, or `null`: + +```json +[{"name":"work","organization":"contoso","targetRoot":"C:\\src\\contoso","lastSync":"2026-07-04T14:12:03+00:00"},{"name":"personal","organization":"octocat","targetRoot":"D:\\mirror","lastSync":null}] +``` + +When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a +hint goes to stderr; the exit code is still 0. + +### `gclo sync --account ` + +Runs a sync with the account's settings and its stored token — no `--org`, +`--target`, or token option needed: + +```powershell +gclo sync --account work +``` + +Account values are **defaults**; any explicit option overrides them: + +| Setting | Account value | Overridden by | +| --- | --- | --- | +| Organization | the account's organization | `--org` | +| Target folder | the account's target root — plus an `\` subfolder when the account opts into one | `--target` (used verbatim; no subfolder is appended) | +| Parallelism | the account's max concurrency | `--parallel` | +| Token | the Windows Credential Manager entry | `--token-env`, `--token-file`, or `--token-stdin` | + +Notes: + +- When the account opts into an organization subfolder and you override + `--org`, the subfolder follows the *effective* organization: + `\`. +- When the run completes (exit code 0 or 1, including a canceled run), the + time and the summary line are recorded on the account — `gclo accounts` and + the desktop app show them as the last sync. +- An unknown account name exits with code 2 and lists the available account + names on stderr. +- An account whose Credential Manager entry is missing (deleted, or the + profile moved to another machine — the entry does not roam) exits with + code 2. Re-enter the token in the desktop app's account wizard, restore the + `gclo:account:` credential manually, or pass a token option for this run. + ## Providing the token gclo needs a GitHub Personal Access Token for both the API and the git transport. @@ -149,6 +218,10 @@ gclo needs a GitHub Personal Access Token for both the API and the git transport The options are mutually exclusive. A missing or empty token prints an error to stderr and exits with code 2. +With `gclo sync --account`, the default source is the account's token in +Windows Credential Manager instead of `GITHUB_TOKEN`; any token option above +still wins for that run (the stored token is left untouched). + ## Activity log Every invocation appends to a daily activity log file, `gclo-yyyy-MM-dd.log`, @@ -164,7 +237,7 @@ else on disk; see [SECURITY.md](../SECURITY.md). | --- | --- | | 0 | Everything succeeded. | | 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). | -| 2 | Fatal: bad arguments, missing/empty/rejected token, or organization not found. | +| 2 | Fatal: bad arguments, missing/empty/rejected token, organization not found, unknown account, missing account token, or an account command on a non-Windows OS. | A repository recovered by `--sanitize-paths` counts as a success: if every other repository also succeeds, the exit code is `0`. @@ -199,6 +272,18 @@ if ($LASTEXITCODE -ne 0) { Write-Error "sync ended with code $LASTEXITCODE" } # Legacy repos with Windows-invalid paths: rename/skip them instead of failing gclo sync --org contoso --target C:\src\contoso --sanitize-paths + +# Saved accounts (created in the desktop app; Windows only) +gclo accounts # name, organization, target root, last sync +gclo sync --account work # settings and token come from the account +gclo sync --account work --parallel 16 # explicit options override account values +gclo sync --account work --org other-org # same target root, different organization + +# Nightly job over every account +gclo accounts --json | ConvertFrom-Json | ForEach-Object { + gclo sync --account $_.name --quiet + if ($LASTEXITCODE -ne 0) { Write-Error "sync of '$($_.name)' ended with code $LASTEXITCODE" } +} ``` ### bash diff --git a/docs/plans/accounts-mode.md b/docs/plans/accounts-mode.md index c10e8b2..fb49f33 100644 --- a/docs/plans/accounts-mode.md +++ b/docs/plans/accounts-mode.md @@ -47,9 +47,14 @@ New in `gclo.ViewModels` (app-layer library, headless-testable): `MaxConcurrency`, `LastSyncUtc`, `LastSyncSummary` (counts string). Metadata JSON at `%LOCALAPPDATA%\gclo\accounts.json` — never contains tokens. - `ITokenVault` { `Store(accountId, token)`, `TryRetrieve(accountId)`, - `Delete(accountId)` } — implementation `CredentialManagerVault` using - `Windows.Security.Credentials.PasswordVault`, resource name - `gclo:account:`; `InMemoryVault` fake for tests. + `Delete(accountId)` } — implementation `CredentialManagerVault` using Win32 + `CredWrite/CredRead/CredDelete` P/Invoke (generic credentials, target name + `gclo:account:`); `InMemoryVault` fake for tests. **Decision changed from + PasswordVault after the spike**: the WinRT API would force Windows-only TFMs + onto gclo.ViewModels and the cross-platform CLI, while the Win32 store works + from plain net10.0 (spike-verified: write/read/delete round-trip without + package identity) and surfaces tokens in Credential Manager's "Windows + Credentials" UI where the owner can inspect them. - `AccountsStore`: CRUD + rename-safe persistence (same never-throw discipline as `AppSettings`); deleting an account deletes its vault entry. - CLI (`gclo.Cli`): `gclo accounts` (list: name, org, target, last sync); @@ -94,6 +99,12 @@ Inside `WorkspacePage`, replacing the stacked form: progress-honesty P2). 3. **Results InfoBar** (severity-aware): summary + inline Retry failed / Open folder; replaces permanent buttons and the stale Retry visibility bug. +3b. **In-progress visibility** (owner decision on #16): a pinned **active + strip** docked between toolbar and table showing exactly the repos currently + Cloning/Pulling with per-repo progress — bounded by the parallelism setting, + so it never scrolls and the table never moves under the user. Plus **status + filter chips** on the toolbar (All · Active · Failed · Pending); Failed + doubles as retry triage. Rejected: viewport auto-scroll and live re-sorting. 4. **Designed empty states**: no token → connect card (+ "how to create a token" link, fixing that P2); connected → "Load N repositories from {org}"; filter-no-match state. diff --git a/gclo.Cli/AccountsCommand.cs b/gclo.Cli/AccountsCommand.cs new file mode 100644 index 0000000..a8b1a30 --- /dev/null +++ b/gclo.Cli/AccountsCommand.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Text.Json; +using gclo.Engine; +using gclo.ViewModels; + +namespace gclo.Cli; + +/// 'gclo accounts': list the saved accounts and their last sync outcome. +internal static class AccountsCommand +{ + private const string HelpText = """ + Usage: gclo accounts [options] + + Lists the saved accounts, one per line, in aligned columns: + name, organization, target root, and last sync time (local time, + 'never' when the account has not completed a sync yet). + + An account pairs sync settings (organization, target root, parallelism, + org-subfolder preference) with a token stored in Windows Credential + Manager. Use one with 'gclo sync --account '. Accounts are created + and edited in the gclo desktop app for now; a CLI flag for creating them + is planned. Because the token lives in Windows Credential Manager, + accounts only work on Windows. + + Options: + --json Print a single-line JSON array instead: + [{"name":"...","organization":"...","targetRoot":"...", + "lastSync":"2026-07-04T15:30:00+00:00" or null}] + --help Show this help. + + Exit codes: + 0 listed successfully (also when no accounts exist yet) + 2 fatal: bad arguments, or not running on Windows + """; + + public static int Run(string[] args) + { + bool json = false; + + var reader = new OptionReader(args); + while (reader.MoveNext()) + { + switch (reader.Current) + { + case "--help" or "-h": + Console.Out.WriteLine(HelpText); + return 0; + case "--json": + reader.RejectValue(); + json = true; + break; + default: + throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts'."); + } + } + + // One activity log per invocation; tokens are never read here, let alone logged. + var log = new FileActivityLog(); + log.Info($"accounts started: json={json}"); + + try + { + (AccountsStore store, _) = Open(log); + IReadOnlyList accounts = store.GetAll(); + log.Info($"accounts finished: {accounts.Count} account(s) listed."); + + if (json) + { + IReadOnlyList summaries = accounts + .Select(a => new AccountSummary(a.Name, a.Organization, a.TargetRoot, a.LastSyncUtc)) + .ToList(); + Console.Out.WriteLine(JsonSerializer.Serialize( + summaries, CliJsonContext.Default.IReadOnlyListAccountSummary)); + return 0; + } + + if (accounts.Count == 0) + { + // Diagnostics go to stderr so redirected stdout stays clean (and empty). + Console.Error.WriteLine("No accounts yet. Create one in the gclo desktop app."); + return 0; + } + + int nameWidth = accounts.Max(a => a.Name.Length); + int orgWidth = accounts.Max(a => a.Organization.Length); + int targetWidth = accounts.Max(a => a.TargetRoot.Length); + foreach (Account account in accounts) + { + Console.Out.WriteLine( + $"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} " + + $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}"); + } + return 0; + } + catch (Exception ex) + { + // Fatal path: unusable accounts store or non-Windows platform. + // Program prints the message; the log keeps it. + log.Error($"accounts failed: {ex.Message}", ex); + throw; + } + } + + /// + /// Opens the accounts store backed by Windows Credential Manager. The vault is + /// returned alongside the store because token retrieval for 'gclo sync --account' + /// goes through the vault directly (the store only handles metadata). + /// Constructed lazily — only the account code paths call this — so plain + /// 'gclo sync' and 'gclo orgs' never touch the credential store. + /// + /// The current OS is not Windows. + internal static (AccountsStore Store, ITokenVault Vault) Open(IActivityLog log) + { + if (!OperatingSystem.IsWindows()) + { + throw new CliErrorException( + "Accounts require Windows credential storage; " + + "'gclo accounts' and 'gclo sync --account' only work on Windows."); + } + + ITokenVault vault = new CredentialManagerVault(); + return (new AccountsStore(vault, log: log), vault); + } + + /// 'never', or the local time of the last completed sync at minute precision. + private static string FormatLastSync(DateTimeOffset? lastSyncUtc) + => lastSyncUtc is null + ? "never" + : lastSyncUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); +} diff --git a/gclo.Cli/CliJson.cs b/gclo.Cli/CliJson.cs index 38dc493..32acb4f 100644 --- a/gclo.Cli/CliJson.cs +++ b/gclo.Cli/CliJson.cs @@ -15,6 +15,17 @@ internal sealed record SyncJsonResult( bool WasCanceled, IReadOnlyList Failures); +/// +/// One saved account in the JSON listing of 'gclo accounts --json'. +/// is the UTC time of the last completed sync, or null +/// when the account has never synced. +/// +internal sealed record AccountSummary( + string Name, + string Organization, + string TargetRoot, + DateTimeOffset? LastSync); + /// /// Source-generated System.Text.Json serialization: no runtime reflection, so the /// project stays free of trim/AOT warnings. @@ -22,4 +33,5 @@ internal sealed record SyncJsonResult( [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(SyncJsonResult))] [JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] internal sealed partial class CliJsonContext : JsonSerializerContext; diff --git a/gclo.Cli/Program.cs b/gclo.Cli/Program.cs index 9a8c6e9..33410c1 100644 --- a/gclo.Cli/Program.cs +++ b/gclo.Cli/Program.cs @@ -10,16 +10,22 @@ internal static class Program Usage: gclo sync --org --target [options] + gclo sync --account [options] gclo orgs [options] + gclo accounts [--json] gclo --version gclo --help Commands: - sync Clone every repository of an organization; fast-forward ones that - already exist locally. - orgs List the account and organization logins the token can see. + sync Clone every repository of an organization; fast-forward ones + that already exist locally. '--account ' runs it with a + saved account's settings and stored token. + orgs List the account and organization logins the token can see. + accounts List the saved accounts (name, organization, target root, last + sync). Accounts are created in the gclo desktop app and are + Windows-only. - Run 'gclo sync --help' or 'gclo orgs --help' for command options. + Run 'gclo --help' (e.g. 'gclo sync --help') for command options. Token: There is deliberately no '--token ' option: command-line arguments @@ -29,7 +35,9 @@ internal static class Program --token-file read the first non-blank line of a file --token-stdin read one line from standard input (pipe it from a secret store) - When no token option is given, the GITHUB_TOKEN environment variable is used. + When no token option is given, the GITHUB_TOKEN environment variable is + used — except with 'gclo sync --account', which reads the account's token + from Windows Credential Manager instead. A token option always wins. """; public static async Task Main(string[] args) @@ -90,6 +98,8 @@ internal static class Program return SyncCommand.RunAsync(rest, cancellationToken); case "orgs": return OrgsCommand.RunAsync(rest, cancellationToken); + case "accounts": + return Task.FromResult(AccountsCommand.Run(rest)); case "--help" or "-h" or "help": Console.Out.WriteLine(RootHelp); return Task.FromResult(0); diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs index 7180545..db0e1e4 100644 --- a/gclo.Cli/SyncCommand.cs +++ b/gclo.Cli/SyncCommand.cs @@ -1,5 +1,6 @@ using System.Text.Json; using gclo.Engine; +using gclo.ViewModels; namespace gclo.Cli; @@ -8,15 +9,27 @@ internal static class SyncCommand { private const string HelpText = """ Usage: gclo sync --org --target [options] + gclo sync --account [options] Clones every repository of into \. Repositories that already exist locally are fetched and fast-forwarded instead. Repositories fail independently; one failure never stops the rest. Options: - --org GitHub organization or user account to sync (required). + --org GitHub organization or user account to sync + (required unless --account provides it). --target Local root folder; each repository becomes a subfolder - (required; created if missing). + (required unless --account provides it; created if + missing). + --account Use a saved account (see 'gclo accounts'; Windows + only): seeds --org, --target, and --parallel from + the account and reads the token from Windows + Credential Manager. When the account opts into an + organization subfolder the target becomes + \. Explicit options override the + account's values; a token option overrides the + stored token. On completion the run's time and + summary are recorded on the account. --parallel Maximum simultaneous git operations (default 8). --sanitize-paths When a repository contains paths that are legal in git but invalid on Windows (reserved device names, @@ -53,7 +66,8 @@ internal static class SyncCommand Exit codes: 0 every repository synced 1 run completed but some repositories failed, or it was canceled - 2 fatal: bad arguments, missing or rejected token, organization not found + 2 fatal: bad arguments, missing or rejected token, organization not + found, or an unknown account or missing account token Security: There is deliberately no '--token ' option: process command lines @@ -65,7 +79,8 @@ internal static class SyncCommand { string? org = null; string? target = null; - int parallel = 8; + string? accountName = null; + int? parallel = null; bool json = false; bool quiet = false; bool sanitizePaths = false; @@ -89,13 +104,17 @@ internal static class SyncCommand case "--target": target = reader.RequireValue(); break; + case "--account": + accountName = reader.RequireValue(); + break; case "--parallel": { string value = reader.RequireValue(); - if (!int.TryParse(value, out parallel) || parallel < 1) + if (!int.TryParse(value, out int parsed) || parsed < 1) { throw new CliUsageException($"--parallel expects a positive integer, got '{value}'."); } + parallel = parsed; break; } case "--json": @@ -115,29 +134,46 @@ internal static class SyncCommand } } - if (string.IsNullOrWhiteSpace(org)) - { - throw new CliUsageException("--org is required."); - } - if (string.IsNullOrWhiteSpace(target)) - { - throw new CliUsageException("--target is required."); - } - // One activity log per invocation. Only non-secret parameters are ever // written to it; the token is not. var log = new FileActivityLog(); - log.Info($"sync started: org='{org}', target='{target}', parallel={parallel}, " - + $"sanitizePaths={sanitizePaths}, json={json}, quiet={quiet}"); try { - string token = tokenOptions.Resolve(); + // --account: seed org/target/parallel from the saved account. Explicit + // options given alongside win because ??= only fills what is still unset. + Account? account = null; + AccountsStore? store = null; + ITokenVault? vault = null; + if (accountName is not null) + { + (store, vault) = AccountsCommand.Open(log); + account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store); + org ??= account.Organization; + target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot; + parallel ??= account.MaxConcurrency; + } + + if (string.IsNullOrWhiteSpace(org)) + { + throw new CliUsageException("--org is required (or use --account)."); + } + if (string.IsNullOrWhiteSpace(target)) + { + throw new CliUsageException("--target is required (or use --account)."); + } + int effectiveParallel = parallel ?? AppSettings.DefaultConcurrency; + + log.Info($"sync started: org='{org}', target='{target}', parallel={effectiveParallel}, " + + $"sanitizePaths={sanitizePaths}, json={json}, quiet={quiet}" + + (account is null ? "" : $", account='{account.Name}'")); + + string token = ResolveToken(tokenOptions, account, vault); var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json); var git = new LibGit2GitClient(); var engine = new OrgSyncEngine(new GitHubRepositoryLister(), git); - var request = new SyncRequest(org.Trim(), token, target.Trim(), parallel); + var request = new SyncRequest(org.Trim(), token, target.Trim(), effectiveParallel); var progress = new SyncProgressHandler(printer, log, printDetails: !json, collectForSanitize: sanitizePaths); SyncSummary summary; @@ -185,6 +221,13 @@ internal static class SyncCommand + $"failed={summary.Failed}, canceled={summary.Canceled}, wasCanceled={summary.WasCanceled}" + (sanitizePaths ? $", sanitizedPaths={sanitized}" : "")); + string verb = summary.WasCanceled ? "Canceled" : "Finished"; + string clonedText = sanitized > 0 + ? $"{summary.Cloned} cloned ({sanitized} with sanitized paths)" + : $"{summary.Cloned} cloned"; + string summaryLine = $"{verb}: {clonedText}, {summary.Updated} updated, " + + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."; + if (json) { var result = new SyncJsonResult( @@ -194,13 +237,14 @@ internal static class SyncCommand } else { - string verb = summary.WasCanceled ? "Canceled" : "Finished"; - string clonedText = sanitized > 0 - ? $"{summary.Cloned} cloned ({sanitized} with sanitized paths)" - : $"{summary.Cloned} cloned"; - Console.Out.WriteLine( - $"{verb}: {clonedText}, {summary.Updated} updated, " - + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."); + Console.Out.WriteLine(summaryLine); + } + + if (account is not null) + { + // The run completed (exit 0 or 1): remember when and how it went, + // so 'gclo accounts' and the desktop app can show it. + TryRecordSyncResult(store!, account, summaryLine, log); } return summary.Failed > 0 || summary.WasCanceled ? 1 : 0; @@ -214,6 +258,60 @@ internal static class SyncCommand } } + /// + /// The token for this run. Any explicit token option wins; otherwise a saved + /// account's token comes from Windows Credential Manager; plain runs fall back + /// to the default environment variable. + /// + private static string ResolveToken(TokenOptions tokenOptions, Account? account, ITokenVault? vault) + { + if (account is null || tokenOptions.HasExplicitSource) + { + return tokenOptions.Resolve(); + } + + // vault is non-null whenever account is: both come from AccountsCommand.Open. + string? token = vault!.TryRetrieve(account.Id); + if (string.IsNullOrEmpty(token)) + { + throw new CliErrorException( + $"Account '{account.Name}' has no token in Windows Credential Manager " + + $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token in the gclo " + + "desktop app's account wizard, restore the credential entry manually, " + + "or pass a token with --token-env, --token-file, or --token-stdin."); + } + return token; + } + + /// + /// Records the completed run's time and summary on the account. A persistence + /// failure must not change the sync's exit code — the repositories are already + /// on disk — so it is reported as a warning instead of propagating. + /// + private static void TryRecordSyncResult(AccountsStore store, Account account, string summaryLine, IActivityLog log) + { + try + { + store.RecordSyncResult(account.Id, DateTimeOffset.UtcNow, summaryLine); + } + catch (Exception ex) + { + log.Error($"could not record the sync result on account '{account.Name}': {ex.Message}", ex); + Console.Error.WriteLine( + $"Warning: could not record the sync result on account '{account.Name}': {ex.Message}"); + } + } + + /// Unknown --account name: tell the user what does exist (exit code 2). + private static CliErrorException UnknownAccount(string name, AccountsStore store) + { + IReadOnlyList all = store.GetAll(); + string available = all.Count == 0 + ? "No accounts exist yet; create one in the gclo desktop app." + : "Available accounts: " + string.Join(", ", all.Select(a => a.Name)) + "."; + return new CliErrorException($"No account named '{name}'. {available}"); + } + /// /// Applies an automatic (rename to the suggested name, /// skip what has none) to one repository that failed with Windows-invalid paths. diff --git a/gclo.Cli/TokenOptions.cs b/gclo.Cli/TokenOptions.cs index afc394a..a970993 100644 --- a/gclo.Cli/TokenOptions.cs +++ b/gclo.Cli/TokenOptions.cs @@ -17,6 +17,13 @@ internal sealed class TokenOptions private string? _filePath; private bool _useStdin; + /// + /// True when any token option (--token-env, --token-file, --token-stdin) was + /// given. 'gclo sync --account' uses this to let an explicit token source + /// override the account's vault-stored token. + /// + public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin; + /// Consumes the reader's current option when it is a token option. public bool TryConsume(OptionReader reader) { @@ -98,18 +105,49 @@ internal sealed class TokenOptions private static string FromStdin() { - if (!Console.IsInputRedirected) + string? line; + if (Console.IsInputRedirected) { - // Interactive use: make it obvious the process is waiting. The prompt - // goes to stderr so redirected stdout stays clean. + line = Console.In.ReadLine()?.Trim(); + } + else + { + // Interactive use: prompt on stderr (redirected stdout stays clean) and + // read WITHOUT echo, like git/gh credential prompts — an echoed token + // would sit on screen and in the terminal scrollback. Console.Error.Write("Token: "); + line = ReadLineNoEcho().Trim(); + Console.Error.WriteLine(); } - string? line = Console.In.ReadLine()?.Trim(); if (string.IsNullOrEmpty(line)) { throw new CliErrorException("No token arrived on standard input (expected one line)."); } return line; } + + private static string ReadLineNoEcho() + { + var buffer = new System.Text.StringBuilder(); + while (true) + { + ConsoleKeyInfo key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + return buffer.ToString(); + } + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) + { + buffer.Length--; + } + } + else if (key.KeyChar != '\0') + { + buffer.Append(key.KeyChar); + } + } + } } diff --git a/gclo.Cli/gclo.Cli.csproj b/gclo.Cli/gclo.Cli.csproj index 245e19d..915edbc 100644 --- a/gclo.Cli/gclo.Cli.csproj +++ b/gclo.Cli/gclo.Cli.csproj @@ -12,6 +12,7 @@ + diff --git a/gclo.Engine.Tests/AccountsStoreTests.cs b/gclo.Engine.Tests/AccountsStoreTests.cs new file mode 100644 index 0000000..c05bcd1 --- /dev/null +++ b/gclo.Engine.Tests/AccountsStoreTests.cs @@ -0,0 +1,221 @@ +using gclo.ViewModels; + +namespace gclo.Engine.Tests; + +/// +/// Tests for : JSON round-trips across store instances, +/// case-insensitive lookups and name uniqueness, vault coordination, and tolerance +/// of a corrupt accounts file. Every store uses an and a +/// unique temp directory. +/// +public sealed class AccountsStoreTests : IDisposable +{ + private readonly string _root = + Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + private readonly InMemoryVault _vault = new(); + + public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root); + + private AccountsStore NewStore() => new(_vault, _root); + + private static Account MakeAccount(string name) => new() + { + Id = Guid.NewGuid(), + Name = name, + Organization = "acme", + TargetRoot = @"C:\repos", + }; + + // ---------------------------------------------------------------- persistence + + [Fact] + public void Crud_RoundTrips_AcrossStoreInstances() + { + var account = MakeAccount("Work") with + { + Description = "primary org", + CreateOrgSubfolder = true, + MaxConcurrency = 4, + }; + + NewStore().Save(account, token: null); + Assert.Equal(account, Assert.Single(NewStore().GetAll())); + + var edited = account with { Name = "Work (new)", Organization = "acme-2" }; + NewStore().Save(edited, token: null); + Assert.Equal(edited, Assert.Single(NewStore().GetAll())); + + NewStore().Delete(account.Id); + Assert.Empty(NewStore().GetAll()); + } + + [Fact] + public void FreshDirectory_YieldsEmptyList() + { + Assert.Empty(NewStore().GetAll()); + } + + // ---------------------------------------------------------------- queries + + [Fact] + public void GetAll_SortsByName_CaseInsensitively() + { + var store = NewStore(); + store.Save(MakeAccount("Charlie"), null); + store.Save(MakeAccount("beta"), null); + store.Save(MakeAccount("Alpha"), null); + + // Ordinal (case-sensitive) order would be Alpha, Charlie, beta. + Assert.Equal( + new[] { "Alpha", "beta", "Charlie" }, + store.GetAll().Select(a => a.Name)); + } + + [Fact] + public void FindByName_MatchesCaseInsensitively() + { + var store = NewStore(); + var account = MakeAccount("Work"); + store.Save(account, null); + + Assert.Equal(account, store.FindByName("WORK")); + Assert.Equal(account, store.FindByName("work")); + Assert.Null(store.FindByName("missing")); + } + + // ---------------------------------------------------------------- name uniqueness + + [Theory] + [InlineData("Work")] + [InlineData("WORK")] + [InlineData("work")] + public void Save_DuplicateNameOnDifferentId_ThrowsAndPersistsNothing(string duplicateName) + { + NewStore().Save(MakeAccount("Work"), null); + var duplicate = MakeAccount(duplicateName); + + Assert.Throws(() => NewStore().Save(duplicate, "secret")); + + var survivor = Assert.Single(NewStore().GetAll()); + Assert.Equal("Work", survivor.Name); + Assert.Null(_vault.TryRetrieve(duplicate.Id)); + } + + [Fact] + public void Save_SameIdWithSameName_IsAnUpdateNotADuplicate() + { + var account = MakeAccount("Work"); + var store = NewStore(); + store.Save(account, null); + + store.Save(account with { Description = "edited" }, null); + + Assert.Equal("edited", Assert.Single(store.GetAll()).Description); + } + + // ---------------------------------------------------------------- vault coordination + + [Fact] + public void Save_WithToken_StoresItInTheVault() + { + var account = MakeAccount("Work"); + + NewStore().Save(account, "ghp_secret"); + + Assert.Equal("ghp_secret", _vault.TryRetrieve(account.Id)); + } + + [Fact] + public void Save_WithNullToken_LeavesVaultUntouched() + { + var account = MakeAccount("Work"); + var store = NewStore(); + store.Save(account, "original"); + + store.Save(account with { Description = "edited" }, null); + + Assert.Equal("original", _vault.TryRetrieve(account.Id)); + } + + [Fact] + public void Delete_RemovesMetadataAndVaultEntry_AndIsIdempotent() + { + var account = MakeAccount("Work"); + var store = NewStore(); + store.Save(account, "secret"); + + store.Delete(account.Id); + + Assert.Empty(store.GetAll()); + Assert.Empty(NewStore().GetAll()); + Assert.Null(_vault.TryRetrieve(account.Id)); + + store.Delete(account.Id); // deleting again must not throw + } + + // ---------------------------------------------------------------- sync results + + [Fact] + public void RecordSyncResult_UpdatesOnlySyncFields_AndPersists() + { + var account = MakeAccount("Work") with { Description = "keep me", MaxConcurrency = 4 }; + NewStore().Save(account, null); + var finished = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero); + + NewStore().RecordSyncResult(account.Id, finished, "12 cloned, 3 updated, 0 failed"); + + var updated = Assert.Single(NewStore().GetAll()); + Assert.Equal( + account with { LastSyncUtc = finished, LastSyncSummary = "12 cloned, 3 updated, 0 failed" }, + updated); + } + + // ---------------------------------------------------------------- load tolerance + + [Fact] + public void CorruptFile_YieldsEmptyList_AndSubsequentSaveWorks() + { + Directory.CreateDirectory(_root); + File.WriteAllText(Path.Combine(_root, "accounts.json"), "{ this is not json !!"); + + var store = NewStore(); + Assert.Empty(store.GetAll()); + + var account = MakeAccount("Recovered"); + store.Save(account, null); + Assert.Equal(account, Assert.Single(NewStore().GetAll())); + } + + [Fact] + public void Load_CorruptFile_IsPreservedAside_SoSaveCannotClobberIt() + { + Directory.CreateDirectory(_root); + string path = Path.Combine(_root, "accounts.json"); + File.WriteAllText(path, "{ not valid json"); + + var store = NewStore(); + Assert.Empty(store.GetAll()); + + // The unreadable original must survive as evidence for token re-association. + string? preserved = Directory.GetFiles(_root, "accounts.json.corrupt-*").SingleOrDefault(); + Assert.NotNull(preserved); + Assert.Equal("{ not valid json", File.ReadAllText(preserved)); + + store.Save(MakeAccount("Fresh"), null); + Assert.Equal("{ not valid json", File.ReadAllText(preserved)); // untouched + } + + [Fact] + public void Persist_ReplacesAtomically_KeepingABackupOfThePreviousFile() + { + var store = NewStore(); + store.Save(MakeAccount("First"), null); + store.Save(MakeAccount("Second"), null); + + // The atomic File.Replace keeps the prior generation as .bak, and no + // temp file is left behind. + Assert.True(File.Exists(Path.Combine(_root, "accounts.json.bak"))); + Assert.False(File.Exists(Path.Combine(_root, "accounts.json.tmp"))); + Assert.Equal(2, NewStore().GetAll().Count); + } +} diff --git a/gclo.Engine.Tests/VaultTests.cs b/gclo.Engine.Tests/VaultTests.cs new file mode 100644 index 0000000..a1e61bb --- /dev/null +++ b/gclo.Engine.Tests/VaultTests.cs @@ -0,0 +1,87 @@ +using gclo.ViewModels; + +namespace gclo.Engine.Tests; + +/// +/// Contract tests for plus one real round-trip through +/// against the Windows Credential Manager. +/// +public sealed class VaultTests +{ + // ---------------------------------------------------------------- InMemoryVault + + [Fact] + public void InMemory_StoreThenRetrieve_ReturnsTheToken() + { + var vault = new InMemoryVault(); + var id = Guid.NewGuid(); + + vault.Store(id, "ghp_secret"); + + Assert.Equal("ghp_secret", vault.TryRetrieve(id)); + } + + [Fact] + public void InMemory_Store_OverwritesTheExistingToken() + { + var vault = new InMemoryVault(); + var id = Guid.NewGuid(); + vault.Store(id, "ghp_old"); + + vault.Store(id, "ghp_new"); + + Assert.Equal("ghp_new", vault.TryRetrieve(id)); + } + + [Fact] + public void InMemory_TryRetrieve_AbsentEntry_ReturnsNull() + { + Assert.Null(new InMemoryVault().TryRetrieve(Guid.NewGuid())); + } + + [Fact] + public void InMemory_Delete_RemovesTheToken_AndIsIdempotent() + { + var vault = new InMemoryVault(); + var id = Guid.NewGuid(); + vault.Store(id, "ghp_secret"); + + vault.Delete(id); + Assert.Null(vault.TryRetrieve(id)); + + vault.Delete(id); // deleting an absent entry must not throw + } + + // ---------------------------------------------------------------- CredentialManagerVault + + [Fact] + public void CredentialManager_RoundTrips_AgainstTheRealStore() + { + // xunit 2.9 has no runtime skip, so on non-Windows this silently passes; + // CI runs windows-latest, where the body always executes. + if (!OperatingSystem.IsWindows()) + { + return; + } + + var vault = new CredentialManagerVault(); + var id = Guid.NewGuid(); + try + { + vault.Store(id, "ghp_round-trip-secret"); + Assert.Equal("ghp_round-trip-secret", vault.TryRetrieve(id)); + + vault.Store(id, "ghp_rotated"); + Assert.Equal("ghp_rotated", vault.TryRetrieve(id)); + + vault.Delete(id); + Assert.Null(vault.TryRetrieve(id)); + + vault.Delete(id); // deleting an absent entry must not throw + } + finally + { + vault.Delete(id); + } + } +} diff --git a/gclo.ViewModels/Account.cs b/gclo.ViewModels/Account.cs new file mode 100644 index 0000000..0cdb693 --- /dev/null +++ b/gclo.ViewModels/Account.cs @@ -0,0 +1,40 @@ +namespace gclo.ViewModels; + +/// +/// A saved connection profile: which GitHub organization to sync, where its clones +/// live on disk, and how the sync runs. Metadata only — the access token is held +/// separately in an keyed by and is never +/// serialized alongside the account. +/// +public sealed record Account +{ + /// Stable identity: keys the vault entry and survives renames. + public required Guid Id { get; init; } + + /// Display name; unique across accounts (case-insensitive). + public required string Name { get; init; } + + /// Optional free-form note about what the account is for. + public string Description { get; init; } = ""; + + /// GitHub organization (or user) whose repositories are synced. + public required string Organization { get; init; } + + /// Folder the sync targets; see . + public required string TargetRoot { get; init; } + + /// + /// When true the effective sync target is \ + /// rather than itself. + /// + public bool CreateOrgSubfolder { get; init; } + + /// Parallel clone/pull count used when syncing this account. + public int MaxConcurrency { get; init; } = AppSettings.DefaultConcurrency; + + /// When the account's last sync finished, or null if it never ran. + public DateTimeOffset? LastSyncUtc { get; init; } + + /// One-line outcome of the last sync, or null if it never ran. + public string? LastSyncSummary { get; init; } +} diff --git a/gclo.ViewModels/AccountsStore.cs b/gclo.ViewModels/AccountsStore.cs new file mode 100644 index 0000000..b50f179 --- /dev/null +++ b/gclo.ViewModels/AccountsStore.cs @@ -0,0 +1,236 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using gclo.Engine; + +namespace gclo.ViewModels; + +/// +/// Persists account profiles as JSON at %LOCALAPPDATA%\gclo\accounts.json and their +/// tokens in an ; the file holds metadata only and never a +/// token. Unlike , accounts are primary user data, so write +/// failures in , , and +/// propagate instead of being swallowed; only loading +/// is tolerant (a missing or corrupt file yields an empty list). +/// +public sealed class AccountsStore +{ + private readonly ITokenVault _vault; + private readonly IActivityLog _log; + private readonly string _filePath; + private readonly object _gate = new(); + private List _accounts; + + /// + /// Loads existing accounts from (default: + /// %LOCALAPPDATA%\gclo). A missing file simply means no accounts yet; a corrupt + /// or unreadable one is logged (pass the app's to surface + /// that) and treated as empty rather than blocking startup. + /// + public AccountsStore(ITokenVault vault, string? directory = null, IActivityLog? log = null) + { + ArgumentNullException.ThrowIfNull(vault); + _vault = vault; + _log = log ?? new NullActivityLog(); + directory ??= Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo"); + _filePath = Path.Combine(directory, "accounts.json"); + _accounts = Load(); + } + + /// All accounts, sorted by (case-insensitive). + public IReadOnlyList GetAll() + { + lock (_gate) + { + return _accounts.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + } + + /// The account whose name matches case-insensitively, or null. + public Account? FindByName(string name) + { + lock (_gate) + { + return _accounts.FirstOrDefault( + a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)); + } + } + + /// + /// Inserts or updates (matching by ) and persists + /// immediately. Throws when another account + /// already uses the name (case-insensitive); nothing is persisted in that case. + /// A non-null goes into the vault only after the + /// metadata write succeeds, so a vault entry never points at an unsaved account; + /// a null token leaves any existing vault entry untouched. + /// + public void Save(Account account, string? token) + { + ArgumentNullException.ThrowIfNull(account); + lock (_gate) + { + bool nameTaken = _accounts.Any(a => + a.Id != account.Id + && string.Equals(a.Name, account.Name, StringComparison.OrdinalIgnoreCase)); + if (nameTaken) + { + throw new ArgumentException( + $"An account named '{account.Name}' already exists.", nameof(account)); + } + + var updated = new List(_accounts); + int index = updated.FindIndex(a => a.Id == account.Id); + if (index >= 0) + { + updated[index] = account; + } + else + { + updated.Add(account); + } + + Persist(updated); // IO failures propagate; _accounts stays unchanged then. + _accounts = updated; + + if (token is not null) + { + _vault.Store(account.Id, token); + } + } + } + + /// + /// Removes the account's metadata and vault token; an unknown id is a no-op. + /// The vault entry is deleted only after the metadata write succeeds. + /// + public void Delete(Guid id) + { + lock (_gate) + { + var remaining = _accounts.Where(a => a.Id != id).ToList(); + if (remaining.Count == _accounts.Count) + { + return; + } + + Persist(remaining); + _accounts = remaining; + _vault.Delete(id); + } + } + + /// + /// Stamps the outcome of a finished sync onto the account and persists it; every + /// other field is left untouched. An unknown id (account deleted while its sync + /// ran) is logged and ignored. + /// + public void RecordSyncResult(Guid id, DateTimeOffset lastSyncUtc, string summary) + { + lock (_gate) + { + int index = _accounts.FindIndex(a => a.Id == id); + if (index < 0) + { + _log.Error($"Cannot record a sync result: no account with id {id:N}."); + return; + } + + var updated = new List(_accounts) + { + [index] = _accounts[index] with { LastSyncUtc = lastSyncUtc, LastSyncSummary = summary }, + }; + Persist(updated); + _accounts = updated; + } + } + + private List Load() + { + try + { + if (!File.Exists(_filePath)) + { + return []; + } + + var loaded = JsonSerializer.Deserialize( + File.ReadAllText(_filePath), AccountsJsonContext.Default.ListAccount); + if (loaded is not null) + { + return loaded; + } + _log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts."); + PreserveCorruptFile(); + } + catch (Exception ex) + { + _log.Error($"Failed to load accounts from '{_filePath}'; starting with no accounts.", ex); + PreserveCorruptFile(); + } + + return []; + } + + /// + /// 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. + /// + 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 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); + } + + if (File.Exists(_filePath)) + { + File.Replace(tempPath, _filePath, _filePath + ".bak"); + } + else + { + File.Move(tempPath, _filePath); + } + } +} + +/// +/// Source-generated serializer context so account (de)serialization keeps working in +/// trimmed Release publishes without reflection warnings. +/// +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(List))] +internal sealed partial class AccountsJsonContext : JsonSerializerContext +{ +} diff --git a/gclo.ViewModels/CredentialManagerVault.cs b/gclo.ViewModels/CredentialManagerVault.cs new file mode 100644 index 0000000..f2dd284 --- /dev/null +++ b/gclo.ViewModels/CredentialManagerVault.cs @@ -0,0 +1,147 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace gclo.ViewModels; + +/// +/// An backed by the Windows Credential Manager: each token +/// is a generic credential in the per-user store (persisted across reboots) whose +/// blob is the token's UTF-16 bytes, under the target name "gclo:account:<id>". +/// Talks to advapi32 directly, so it works packaged or unpackaged. Construction on a +/// non-Windows OS throws . +/// +[SupportedOSPlatform("windows")] +public sealed class CredentialManagerVault : ITokenVault +{ + private const uint CredTypeGeneric = 1; // CRED_TYPE_GENERIC + private const uint CredPersistLocalMachine = 2; // CRED_PERSIST_LOCAL_MACHINE: per-user, survives reboot + private const int ErrorNotFound = 1168; // ERROR_NOT_FOUND + + /// Fails fast on platforms without a Credential Manager. + public CredentialManagerVault() + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "Account token storage requires the Windows Credential Manager."); + } + } + + /// + public void Store(Guid accountId, string token) + { + ArgumentNullException.ThrowIfNull(token); + + byte[] blob = Encoding.Unicode.GetBytes(token); + var pinned = GCHandle.Alloc(blob, GCHandleType.Pinned); + try + { + var credential = new CredentialW + { + Type = CredTypeGeneric, + TargetName = TargetName(accountId), + CredentialBlobSize = (uint)blob.Length, + CredentialBlob = pinned.AddrOfPinnedObject(), + Persist = CredPersistLocalMachine, + UserName = "gclo", + }; + + if (!CredWriteW(ref credential, 0)) + { + int error = Marshal.GetLastWin32Error(); + throw new Win32Exception( + error, $"CredWriteW failed (error {error}) storing the token for account {accountId:N}."); + } + } + finally + { + // The array is pinned, so the GC cannot have copied it elsewhere: clearing + // it removes the one deterministic plaintext copy of the token from the + // heap (crash dumps, pagefile) before unpinning. + Array.Clear(blob); + pinned.Free(); + } + } + + /// + public string? TryRetrieve(Guid accountId) + { + if (!CredReadW(TargetName(accountId), CredTypeGeneric, 0, out IntPtr credentialPtr)) + { + int error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) + { + return null; + } + throw new Win32Exception( + error, $"CredReadW failed (error {error}) reading the token for account {accountId:N}."); + } + + try + { + var credential = Marshal.PtrToStructure(credentialPtr); + return credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize == 0 + ? "" + : Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2); + } + finally + { + CredFree(credentialPtr); + } + } + + /// + public void Delete(Guid accountId) + { + if (!CredDeleteW(TargetName(accountId), CredTypeGeneric, 0)) + { + int error = Marshal.GetLastWin32Error(); + if (error != ErrorNotFound) + { + throw new Win32Exception( + error, $"CredDeleteW failed (error {error}) deleting the token for account {accountId:N}."); + } + } + } + + private static string TargetName(Guid accountId) => $"gclo:account:{accountId:N}"; + + /// + /// Managed mirror of the native CREDENTIALW structure. The explicit StructLayout + /// both fixes the field order for marshaling and tells the compiler the unread + /// fields (filled by CredReadW) are intentional. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CredentialW + { + public uint Flags; + public uint Type; + [MarshalAs(UnmanagedType.LPWStr)] public string? TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string? Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string? TargetAlias; + [MarshalAs(UnmanagedType.LPWStr)] public string? UserName; + } + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredWriteW(ref CredentialW credential, uint flags); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredReadW(string targetName, uint type, uint flags, out IntPtr credential); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredDeleteW(string targetName, uint type, uint flags); + + [DllImport("advapi32.dll", ExactSpelling = true)] + private static extern void CredFree(IntPtr buffer); +} diff --git a/gclo.ViewModels/ITokenVault.cs b/gclo.ViewModels/ITokenVault.cs new file mode 100644 index 0000000..93dedc6 --- /dev/null +++ b/gclo.ViewModels/ITokenVault.cs @@ -0,0 +1,23 @@ +namespace gclo.ViewModels; + +/// +/// Secure storage for account access tokens, keyed by account id. Tokens must never +/// reach accounts.json or the activity log; implementations keep them in a platform +/// secret store () or in process memory +/// (). +/// +public interface ITokenVault +{ + /// + /// Stores the token for an account, overwriting any existing entry. Throws on + /// failure — silently losing a token would leave the account unusable without + /// any warning to the user. + /// + void Store(Guid accountId, string token); + + /// The stored token, or null when the vault has no entry for the account. + string? TryRetrieve(Guid accountId); + + /// Removes the account's token. Deleting an absent entry is a no-op. + void Delete(Guid accountId); +} diff --git a/gclo.ViewModels/InMemoryVault.cs b/gclo.ViewModels/InMemoryVault.cs new file mode 100644 index 0000000..c76d9ca --- /dev/null +++ b/gclo.ViewModels/InMemoryVault.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; + +namespace gclo.ViewModels; + +/// +/// Thread-safe in-memory . Used by tests, and available for +/// scenarios where tokens must not outlive the process. +/// +public sealed class InMemoryVault : ITokenVault +{ + private readonly ConcurrentDictionary _tokens = new(); + + /// + public void Store(Guid accountId, string token) + { + ArgumentNullException.ThrowIfNull(token); + _tokens[accountId] = token; + } + + /// + public string? TryRetrieve(Guid accountId) + => _tokens.TryGetValue(accountId, out string? token) ? token : null; + + /// + public void Delete(Guid accountId) + => _tokens.TryRemove(accountId, out _); +} -- 2.54.0