fix(sync): pull only a clone of the listed repository, over the listed URL #140

Merged
james.maes merged 2 commits from fix/139-verify-origin into main 2026-11-10 18:55:23 +00:00
23 changed files with 1360 additions and 67 deletions

No files matched your search

+1 -1
View File
@@ -186,7 +186,7 @@ Full reference — all options, output streams, Ctrl+C behavior, and more exampl
## How it works
The engine lists every repository through the GitHub REST API (Octokit, paginated 100 per page), then runs the set through `Parallel.ForEachAsync` bounded by the parallelism setting. Git operations use LibGit2Sharp: missing repos are cloned with object-transfer progress; existing repos get a fetch from `origin` followed by a **fast-forward-only** merge — if a local branch has diverged from its upstream, the repo is reported Failed rather than auto-merged, so gclo never manufactures merge commits. Archived repositories are included and sync normally. Every repo ends in exactly one terminal state — Done, Failed (with the error), or Canceled — and per-repo exceptions are contained so the rest of the run continues. The PAT is the only credential; on the git side it is sent as HTTPS basic credentials with username `x-access-token`.
The engine lists every repository through the GitHub REST API (Octokit, paginated 100 per page), then runs the set through `Parallel.ForEachAsync` bounded by the parallelism setting. Git operations use LibGit2Sharp: missing repos are cloned with object-transfer progress; existing repos get a fetch from `origin` followed by a **fast-forward-only** merge — if a local branch has diverged from its upstream, the repo is reported Failed rather than auto-merged, so gclo never manufactures merge commits. An existing folder is only updated when its `origin` is the repository gclo listed: a same-named folder that is a clone of something else (a fork, another owner's repository, another host) is reported Failed and left untouched. A repository that was renamed or moved to another owner looks the same to gclo; the failure message gives the `git remote set-url` command that points the folder at its new URL. Archived repositories are included and sync normally. Every repo ends in exactly one terminal state — Done, Failed (with the error), or Canceled — and per-repo exceptions are contained so the rest of the run continues. The PAT is the only credential; on the git side it is sent as HTTPS basic credentials with username `x-access-token`, and only to the host the repository was listed on.
## Build from source
+5 -3
View File
@@ -86,15 +86,16 @@ flowchart LR
| T1 | Update feed | Tampering | Attacker serves a malicious update | Low | Critical | HTTPS to this repository's Releases only; Velopack hash verification; immutable releases; provenance and SBOM attestations; Scorecard; Authenticode pending (EX-0002) | Mitigated (signing pending) |
| T2 | Package managers / downloads | Tampering | Modified installer or CLI zip on a mirror | Low | High | `SHA256SUMS`; Scoop and Chocolatey manifests pin the hash; "Verifying a release" instructions | Mitigated |
| T3 | Git remotes | Tampering / EoP | Repository with Windows-invalid or traversal paths (`..`, reserved names, case collisions) writes outside or corrupts the target | Medium | High | Pre-checkout validation of every incoming path; `RepositoryPathResolver` containment; recovery by rename/skip or clone in WSL; `core.longpaths` | Mitigated |
| T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees | Mitigated |
| T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees; a folder is fetched only when its `origin` names the listed repository, and the remote contacted is the listed URL, not origin's spelling of it; a `url.<base>.insteadOf` rewrite in the folder's own configuration is refused, and so is its `core.sshCommand` when the fetch is not HTTPS (`RemoteUrl`, #139). The user's global git configuration still applies, as it does to git itself | Mitigated for the remote and the transport program; other `.git` content of an existing folder is still trusted |
| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; accounts may reference the default token (`TokenSource`), resolved at use time through one resolver and never copied; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated |
| T6 | Credential Manager | Information disclosure | Another process as the same user reads the stored token | Medium | High | Documented boundary (SECURITY.md); vault scoped per data directory so test or portable instances never see the default profile's tokens | Accepted (OS boundary) |
| T7 | GitHub API | Denial of service | Rate limits or abuse detection stop a sync | Medium | Low | Secondary-rate-limit and abuse handling with backoff; per-repo failure isolation; cancellation | Mitigated |
| T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback | Mitigated |
| T9 | WSL recovery | Elevation of privilege | Hostile org/repo names injected into the shell script | Low | High | Script fed on stdin with positional parameters; names validated as repository names first; no string interpolation into the script | Mitigated |
| T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback: an existing folder whose origin is `http://` or SSH is fetched through the listed HTTPS URL instead (unless the user's own global configuration rewrites that URL) | Mitigated |
| T9 | WSL recovery | Elevation of privilege | Hostile org/repo names injected into the shell script, or used to leave `~/gclo` | Low | High | Script fed on stdin with positional parameters; organization and repository each validated as one plain name inside `WslCloner` (and the organization again before any listing request); the URL must be HTTPS and is passed after `--`; no string interpolation into the script | Mitigated |
| T10 | Logs and crash output | Information disclosure | Exception text carries a token or private path | Low | Medium | Crash net logs messages only; log review in incident handling; tokens never in exception messages (tests) | Mitigated |
| T11 | Release pipeline | Tampering | Compromised action or cache poisons a release | Low | Critical | Build runs in the standards' shared release workflow (SLSA Build L3 provenance, `--signer-repo KofTwentyTwo/standards`), SHA-pinned actions, no caches, read-only tokens, publishing tokens only in the tag-restricted `release` environment, `protect-release-tags`, required gates re-run on the tag | Mitigated |
| 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 |
| T13 | CLI / data directory | Tampering | A malicious `accounts.json` or `settings.json` redirects targets | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained | Accepted (same-user boundary) |
## 7. Accepted risks
@@ -115,3 +116,4 @@ flowchart LR
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | Local data directory moved to `%LOCALAPPDATA%\KofTwentyTwo\gclo`: the Velopack install root is deleted by Setup.exe, which destroyed accounts.json and settings.json (#91) |
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T2/T11: releases are Authenticode-signed with Azure Artifact Signing over GitHub OIDC (no signing secret; federated credential bound to this repository's `release` environment; signer role scoped to the certificate profile) and the published assets are verified with `signtool verify /pa /all` (#57, ADR 0008) |
| 2026-10-10 | v1.1.0 | James Maes (@KofTwentyTwo) | T5: accounts can reference the default token (`TokenSource = Default`) instead of holding a copy; one `AccountTokenResolver` serves the app and the CLI, so rotating the default token in Settings rotates every such account (#102, docs/plans/default-token-ux.md) |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T14 added, T4/T8/T9 updated: an existing folder is fetched only when its origin names the listed repository, through the listed URL, and the folder's own `url.*.insteadOf` and (for a non-HTTPS fetch) `core.sshCommand` are refused; the git credential callback and the WSL credential helper answer only for the listed host over HTTPS; WSL organization and repository names validated as single names (#139) |
+1 -1
View File
@@ -147,7 +147,7 @@ internal sealed class FakeGit : IGitClient
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
public Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken)
=> OnPull(path, token, cancellationToken);
+3 -3
View File
@@ -43,7 +43,7 @@ public sealed record CloneCall(string Url, string LocalPath, string Token);
public sealed record PullCall(string LocalPath, string Token);
public sealed record PullCall(string LocalPath, string Token, string Url);
@@ -126,9 +126,9 @@ public sealed class FakeGitClient : IGitClient
public async Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
public async Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken)
{
_pullCalls.Enqueue(new PullCall(path, token));
_pullCalls.Enqueue(new PullCall(path, token, url));
await FetchAndPullHandler(path, token, cancellationToken).ConfigureAwait(false);
}
+114
View File
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using LibGit2Sharp;
using LibGit2Sharp.Handlers;
namespace gclo.Engine.Tests;
/// <summary>
/// The credential callback <see cref="LibGit2GitClient"/> hands to libgit2 (#139). The
/// offline fixtures are local paths, which never ask for credentials, so the callback
/// is called here the way libgit2 calls it: with the URL of the remote that asks.
/// </summary>
public sealed class GitCredentialsTests
{
private const string Token = "ghp_secret_token_value_1234567890";
private const string Listed = "https://github.com/acme/repo.git";
[Theory]
[InlineData("https://github.com/acme/repo.git")]
[InlineData("https://GitHub.com/acme/repo.git")]
public void ListedHostOverHttps_GetsTheTokenAsThePassword(string asking)
{
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
UsernamePasswordCredentials credentials = Assert.IsType<UsernamePasswordCredentials>(provider(asking, null, SupportedCredentialTypes.UsernamePassword));
Assert.Equal("x-access-token", credentials.Username);
Assert.Equal(Token, credentials.Password);
}
[Theory]
[InlineData("https://evil.example/acme/repo.git")]
[InlineData("https://github.com.evil.example/acme/repo.git")]
[InlineData("https://github.com:8443/acme/repo.git")]
[InlineData("http://github.com/acme/repo.git")]
[InlineData("ssh://git@github.com/acme/repo.git")]
[InlineData("git@github.com:acme/repo.git")]
public void AnyOtherRemote_GetsNothing_AndTheErrorDoesNotCarryTheToken(string asking)
{
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => provider(asking, null, SupportedCredentialTypes.UsernamePassword));
Assert.Contains("Nothing was sent", ex.Message);
Assert.DoesNotContain(Token, ex.Message);
}
[Fact]
public void ListedUrlThatIsNotHttps_NobodyGetsTheToken()
{
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, "http://git.example.com/acme/repo.git");
Assert.Throws<InvalidOperationException>(
() => provider("http://git.example.com/acme/repo.git", null, SupportedCredentialTypes.UsernamePassword));
}
[Fact]
public void ErrorForAnotherRemote_DoesNotShowCredentialsInItsUrl()
{
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => provider("https://someone:hunter2@evil.example/acme/repo.git", null, SupportedCredentialTypes.UsernamePassword));
Assert.Contains("https://evil.example/acme/repo.git", ex.Message);
Assert.DoesNotContain("hunter2", ex.Message);
}
[Fact]
public void ThirdRequest_MeansTheTokenWasRejected_NotAnEndlessReplay()
{
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
provider(Listed, null, SupportedCredentialTypes.UsernamePassword);
provider(Listed, null, SupportedCredentialTypes.UsernamePassword);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => provider(Listed, null, SupportedCredentialTypes.UsernamePassword));
Assert.Contains("rejected the token", ex.Message);
Assert.DoesNotContain(Token, ex.Message);
}
[Fact]
public void RefusedRequests_DoNotCountTowardTheReplayLimit()
{
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
for(int i = 0; i < 5; i++)
{
Assert.Throws<InvalidOperationException>(
() => provider("https://evil.example/x.git", null, SupportedCredentialTypes.UsernamePassword));
}
Assert.IsType<UsernamePasswordCredentials>(provider(Listed, null, SupportedCredentialTypes.UsernamePassword));
}
}
+22
View File
@@ -206,6 +206,28 @@ public sealed class GitHubListerTests
[Theory]
[InlineData("../..")]
[InlineData("..")]
[InlineData("acme/other")]
[InlineData(@"acme\other")]
[InlineData("C:acme")]
public async Task Repos_NameThatIsNotOnePlainName_IsRefusedBeforeAnyRequest(string organization)
{
bool asked = false;
_gateway.OrgPages = (_, _) => { asked = true; return Task.FromResult<IReadOnlyList<GitHubRepo>>([]); };
_gateway.AccountKind = _ => { asked = true; return Task.FromResult(GitHubAccountKind.User); };
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync(organization, "tok-1234567890"));
Assert.Equal(GitHubAccessKind.NotFound, ex.Kind);
Assert.Contains($"'{organization}' is not an organization or account name", ex.Message);
Assert.False(asked, "no request may be made for a name that is not a name");
}
[Fact]
public async Task Repos_OrgNotFound_ButItIsAnOrganizationTheTokenCannotSee_FailsInsteadOfListingPublicRepos()
{
+16
View File
@@ -51,6 +51,22 @@ internal static class GitTestHelpers
/// <summary>
/// Pulls <paramref name="workdir"/> the way a sync does for a folder that is a clone
/// of the listed repository: with the URL of its own origin.
/// </summary>
public static Task PullAsync(this LibGit2GitClient client, string workdir, string token, CancellationToken cancellationToken)
{
string url;
using(var repo = new Repository(workdir))
{
url = repo.Network.Remotes["origin"].Url;
}
return client.FetchAndPullAsync(url, workdir, token, cancellationToken);
}
/// <summary>SHA of the commit at HEAD's tip.</summary>
public static string HeadSha(string workdir)
{
+15 -15
View File
@@ -165,14 +165,14 @@ public sealed class LibGit2GitClientTests : IDisposable
_client.AfterFetchForTesting = cts.Cancel;
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => _client.FetchAndPullAsync(target, Token, cts.Token));
() => _client.PullAsync(target, Token, cts.Token));
Assert.Equal(tipBefore, HeadSha(target));
Assert.False(File.Exists(Path.Combine(target, "update.txt")));
// The fetch itself landed: a later uncanceled pull is a plain fast-forward.
_client.AfterFetchForTesting = null;
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.True(File.Exists(Path.Combine(target, "update.txt")));
}
@@ -249,7 +249,7 @@ public sealed class LibGit2GitClientTests : IDisposable
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal(newSha, HeadSha(target));
Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt")));
@@ -265,7 +265,7 @@ public sealed class LibGit2GitClientTests : IDisposable
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
string tipBefore = HeadSha(target);
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal(tipBefore, HeadSha(target));
}
@@ -287,7 +287,7 @@ public sealed class LibGit2GitClientTests : IDisposable
SetCheckoutPending(target, true);
Assert.True(_client.IsValidRepository(target));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt")));
Assert.Equal(tipBefore, HeadSha(target));
@@ -306,7 +306,7 @@ public sealed class LibGit2GitClientTests : IDisposable
SetCheckoutPending(target, true);
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal(newSha, HeadSha(target));
Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt")));
@@ -335,7 +335,7 @@ public sealed class LibGit2GitClientTests : IDisposable
}
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Equal("bad:name.txt", Assert.Single(ex.Paths).RepoPath);
Assert.True(IsCheckoutPending(target), "still incomplete; the marker must survive the failure");
@@ -356,7 +356,7 @@ public sealed class LibGit2GitClientTests : IDisposable
CommitFile(target, "from-local.txt", "local change", "local commit");
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
// Actionable, not libgit2's "cannot fast-forward": names the branch, says what
// gclo refuses to do, and gives the three ways out (#30).
@@ -382,7 +382,7 @@ public sealed class LibGit2GitClientTests : IDisposable
}
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains("detached", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Check out a branch", ex.Message);
@@ -408,7 +408,7 @@ public sealed class LibGit2GitClientTests : IDisposable
}
string newSha = CommitFile(source, "after-rename.txt", "after", "commit on the renamed branch");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
using var after = new Repository(target);
Assert.Equal("renamed-main", after.Head.FriendlyName);
@@ -419,7 +419,7 @@ public sealed class LibGit2GitClientTests : IDisposable
Assert.Null(after.Branches["origin/" + oldName]); // and so is its pruned tracking ref
// The next pull is an ordinary up-to-date pull on the new branch.
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal(newSha, HeadSha(target));
}
@@ -439,7 +439,7 @@ public sealed class LibGit2GitClientTests : IDisposable
}
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains("local commits", ex.Message);
Assert.Contains("never discards local work", ex.Message);
@@ -467,7 +467,7 @@ public sealed class LibGit2GitClientTests : IDisposable
string remoteSha = CommitFile(source, "later.txt", "later content", "remote commit");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
using var after = new Repository(target);
Assert.Equal("local-only", after.Head.FriendlyName);
@@ -501,7 +501,7 @@ public sealed class LibGit2GitClientTests : IDisposable
repo.Network.Push(repo.Network.Remotes["origin"], repo.Head.CanonicalName, new PushOptions());
}
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
using var after = new Repository(target);
Assert.False(after.Info.IsHeadUnborn);
@@ -522,7 +522,7 @@ public sealed class LibGit2GitClientTests : IDisposable
await _client.CloneAsync(origin, target, Token, null, CancellationToken.None);
// Nothing to pull yet: there is no branch to materialize, and that is not an error.
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
using var after = new Repository(target);
Assert.True(after.Info.IsHeadUnborn);
+5 -1
View File
@@ -62,7 +62,11 @@ public sealed class OrgSyncEngineTests : IDisposable
}
foreach(PullCall call in _git.PullCalls)
{
Assert.Equal(Path.Combine(_targetRoot, Path.GetFileName(call.LocalPath)!), call.LocalPath);
string name = Path.GetFileName(call.LocalPath)!;
Assert.Equal(Path.Combine(_targetRoot, name), call.LocalPath);
// The listed clone URL travels with the pull, so the git client can refuse a
// folder that is a clone of something else (#139).
Assert.Equal($"https://example.test/acme/{name}.git", call.Url);
Assert.Equal("test-token", call.Token);
}
@@ -0,0 +1,355 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using LibGit2Sharp;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// A pull is given the clone URL the repository was listed with, and only pulls a
/// folder that is a clone of that repository (#139). Real repositories on the local
/// file system, like <see cref="LibGit2GitClientTests"/>.
/// </summary>
public sealed class OriginVerificationTests : IDisposable
{
private const string Token = "test-token";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly LibGit2GitClient _client = new();
public OriginVerificationTests()
{
Directory.CreateDirectory(_root);
}
public void Dispose() => TryDeleteDirectory(_root);
[Fact]
public async Task Pull_FolderIsACloneOfAnotherRepository_FailsWithoutFetching()
{
string listed = CreateSourceRepo("listed");
string unrelated = CreateSourceRepo("unrelated");
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(unrelated, target, Token, null, CancellationToken.None);
string tipBefore = HeadSha(target);
string trackingBefore = TrackingTip(target);
// Something to fetch from the folder's own origin, had it been contacted.
CommitFile(unrelated, "more.txt", "more", "second commit");
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(listed, target, Token, CancellationToken.None));
Assert.Contains("clone of a different repository", ex.Message);
Assert.Contains(unrelated, ex.Message);
Assert.Contains(listed, ex.Message);
// A renamed or transferred repository looks exactly like this; the message says
// how to point the folder at the new URL.
Assert.Contains($"git -C \"{target}\" remote set-url origin {listed}", ex.Message);
Assert.Equal(tipBefore, HeadSha(target));
Assert.Equal(trackingBefore, TrackingTip(target));
}
[Fact]
public async Task Pull_OriginSpelledDifferently_IsTheSameRepository_AndIsPulledFromTheListedUrl()
{
string source = CreateSourceRepo("source");
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
using(var repo = new Repository(target))
{
// The same folder, written as a file URI: what counts is which repository
// the origin names, not how the URL is spelled.
repo.Network.Remotes.Update("origin", r => r.Url = new Uri(source).AbsoluteUri);
}
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
await _client.FetchAndPullAsync(source, target, Token, CancellationToken.None);
Assert.Equal(newSha, HeadSha(target));
Assert.Equal(newSha, TrackingTip(target));
Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt")));
using(var repo = new Repository(target))
{
// Origin keeps the spelling the user gave it, and the remote used for the
// fetch is gone.
Assert.Equal(new Uri(source).AbsoluteUri, repo.Network.Remotes["origin"].Url);
Assert.Equal("origin", Assert.Single(repo.Network.Remotes).Name);
}
// What was contacted is the listed URL, not origin's spelling of it: FETCH_HEAD
// records the URL of the remote a fetch used.
string fetchHead = File.ReadAllText(Path.Combine(target, ".git", "FETCH_HEAD"));
Assert.Contains(source.Replace('\\', '/'), fetchHead.Replace('\\', '/'));
Assert.DoesNotContain("file://", fetchHead);
}
[Fact]
public async Task Pull_OriginSpelledDifferently_StillFollowsTags_AndPrunesDeletedBranches()
{
string source = CreateSourceRepo("source");
using(var repo = new Repository(source))
{
repo.CreateBranch("topic");
}
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
using(var repo = new Repository(target))
{
repo.Network.Remotes.Update("origin", r => r.Url = new Uri(source).AbsoluteUri);
}
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
using(var repo = new Repository(source))
{
repo.ApplyTag("v1.0.0");
repo.Branches.Remove("topic");
}
await _client.FetchAndPullAsync(source, target, Token, CancellationToken.None);
using(var repo = new Repository(target))
{
Assert.Equal(newSha, repo.Head.Tip.Sha);
Assert.NotNull(repo.Tags["v1.0.0"]);
Assert.Null(repo.Branches["origin/topic"]);
// Removing the fetch's remote must not take origin's tracking refs with it.
Assert.NotNull(repo.Head.TrackedBranch?.Tip);
}
}
[Fact]
public async Task Pull_OriginSpelledDifferently_ARemoteLeftByAKilledRun_IsReplaced_NotFetched()
{
string source = CreateSourceRepo("source");
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
using(var repo = new Repository(target))
{
repo.Network.Remotes.Update("origin", r => r.Url = new Uri(source).AbsoluteUri);
// What a process killed mid-fetch leaves behind, pointing somewhere stale.
repo.Network.Remotes.Add("gclo-sync", Path.Combine(_root, "gone"));
repo.Config.Set("remote.gclo-sync.gclo-temporary", true, ConfigurationLevel.Local);
}
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
await _client.FetchAndPullAsync(source, target, Token, CancellationToken.None);
Assert.Equal(newSha, HeadSha(target));
using(var repo = new Repository(target))
{
Assert.Equal("origin", Assert.Single(repo.Network.Remotes).Name);
}
}
[Fact]
public async Task Pull_StillFollowsTags_AndPrunesDeletedBranches()
{
// The fetch names the listed URL rather than the remote "origin"; it must keep
// doing what a fetch of origin does: bring tags on fetched commits and drop
// tracking refs of branches origin no longer has.
string source = CreateSourceRepo("source");
using(var repo = new Repository(source))
{
repo.CreateBranch("topic");
}
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
using(var repo = new Repository(target))
{
Assert.NotNull(repo.Branches["origin/topic"]);
}
CommitFile(source, "update.txt", "new content", "second commit");
using(var repo = new Repository(source))
{
repo.ApplyTag("v1.0.0");
repo.Branches.Remove("topic");
}
await _client.PullAsync(target, Token, CancellationToken.None);
using(var repo = new Repository(target))
{
Assert.NotNull(repo.Tags["v1.0.0"]);
Assert.Null(repo.Branches["origin/topic"]);
}
}
[Fact]
public async Task Pull_MismatchMessage_NeverShowsCredentialsStoredInTheOrigin()
{
string listed = CreateSourceRepo("listed");
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(listed, target, Token, null, CancellationToken.None);
using(var repo = new Repository(target))
{
repo.Network.Remotes.Update("origin", r => r.Url = "https://someone:hunter2@example.test/acme/other.git");
}
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(listed, target, Token, CancellationToken.None));
Assert.Contains("https://example.test/acme/other.git", ex.Message);
Assert.DoesNotContain("hunter2", ex.Message);
Assert.DoesNotContain("someone", ex.Message);
}
[Fact]
public async Task Pull_ARemoteLeftByAKilledRun_IsAlsoRemoved_WhenOriginIsTheListedUrl()
{
string source = CreateSourceRepo("source");
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
using(var repo = new Repository(target))
{
repo.Network.Remotes.Add("gclo-sync", Path.Combine(_root, "gone"));
repo.Config.Set("remote.gclo-sync.gclo-temporary", true, ConfigurationLevel.Local);
}
await _client.PullAsync(target, Token, CancellationToken.None);
using(var repo2 = new Repository(target))
{
Assert.Equal("origin", Assert.Single(repo2.Network.Remotes).Name);
}
}
[Fact]
public async Task Pull_TheUsersOwnRemoteNamedGcloSync_IsNeverRemoved()
{
string source = CreateSourceRepo("source");
string elsewhere = CreateSourceRepo("elsewhere");
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
using(var repo = new Repository(target))
{
repo.Network.Remotes.Add("gclo-sync", elsewhere); // theirs: no gclo marker
}
// Origin is the listed URL: the name is not needed, and their remote stays.
await _client.PullAsync(target, Token, CancellationToken.None);
using(var repo = new Repository(target))
{
Assert.Equal(elsewhere, repo.Network.Remotes["gclo-sync"].Url);
repo.Network.Remotes.Update("origin", r => r.Url = new Uri(source).AbsoluteUri);
}
// Origin is spelled differently: the name is needed, so the pull says so instead
// of taking it over.
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(source, target, Token, CancellationToken.None));
Assert.Contains("remote of its own named 'gclo-sync'", ex.Message);
using(var repo = new Repository(target))
{
Assert.Equal(elsewhere, repo.Network.Remotes["gclo-sync"].Url);
}
}
[Fact]
public async Task Pull_FolderConfigurationRewritesUrls_IsRefused_BeforeAnythingIsFetched()
{
string source = CreateSourceRepo("source");
string other = CreateSourceRepo("other");
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
string trackingBefore = TrackingTip(target);
using(var repo = new Repository(target))
{
// url.<other>.insteadOf = <source>: every fetch of the listed URL would
// really contact the other repository.
repo.Config.Set($"url.{other}.insteadof", source, ConfigurationLevel.Local);
}
CommitFile(source, "update.txt", "new content", "second commit");
CommitFile(other, "planted.txt", "not what was listed", "planted commit");
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(source, target, Token, CancellationToken.None));
Assert.Contains("url.<base>.insteadOf", ex.Message);
Assert.Equal(trackingBefore, TrackingTip(target));
Assert.False(File.Exists(Path.Combine(target, "planted.txt")));
}
[Fact]
public async Task Pull_FolderConfigurationNamesAnSshProgram_IsRefused_WhenTheFetchIsNotHttps()
{
// The fixtures are local paths, which stand in here for "not HTTPS": the case
// where the user's global configuration sends the fetch over SSH.
string source = CreateSourceRepo("source");
string target = Path.Combine(_root, "clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
string trackingBefore = TrackingTip(target);
using(var repo = new Repository(target))
{
repo.Config.Set("core.sshCommand", "calc.exe", ConfigurationLevel.Local);
}
CommitFile(source, "update.txt", "new content", "second commit");
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains("core.sshCommand", ex.Message);
Assert.Equal(trackingBefore, TrackingTip(target));
}
// ---------------------------------------------------------------- helpers
private string CreateSourceRepo(string name)
{
string path = Path.Combine(_root, name);
Repository.Init(path);
CommitFile(path, "readme.txt", "hello from " + name, "initial commit");
return path;
}
private static string CommitFile(string workdir, string fileName, string content, string message)
{
using var repo = new Repository(workdir);
File.WriteAllText(Path.Combine(workdir, fileName), content);
Commands.Stage(repo, fileName);
Signature signature = MakeSignature();
return repo.Commit(message, signature, signature).Sha;
}
/// <summary>Tip of the remote-tracking ref of the clone's current branch.</summary>
private static string TrackingTip(string workdir)
{
using var repo = new Repository(workdir);
return repo.Head.TrackedBranch.Tip.Sha;
}
}
+12 -12
View File
@@ -217,7 +217,7 @@ public sealed class PathRecoveryTests : IDisposable
// the pull fails typed, exactly like the original clone did.
AppendForgedCommit(source, ("worse:new.txt", "new content"));
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "worse:new.txt", StringComparison.Ordinal));
// Applying a recovery that covers ONLY the new path merges with the stored one:
@@ -245,7 +245,7 @@ public sealed class PathRecoveryTests : IDisposable
Assert.True(File.Exists(Path.Combine(target, "gone.txt")));
ReplaceForgedEntry(source, remove: "gone.txt", add: ("new/nested.txt", "new"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
// A real checkout removes what left the tree; the materialization must too.
Assert.False(File.Exists(Path.Combine(target, "gone.txt")));
@@ -254,7 +254,7 @@ public sealed class PathRecoveryTests : IDisposable
// And a directory emptied by a later deletion is pruned too.
ReplaceForgedEntry(source, remove: "new/nested.txt", add: ("other.txt", "o"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.False(Directory.Exists(Path.Combine(target, "new")));
}
@@ -269,12 +269,12 @@ public sealed class PathRecoveryTests : IDisposable
// A local edit is the witness: an unchanged tip must leave the tree alone.
File.WriteAllText(Path.Combine(target, "keep.txt"), "locally edited");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal("locally edited", File.ReadAllText(Path.Combine(target, "keep.txt")));
// A new upstream commit re-materializes through the mapping as before.
ReplaceForgedEntry(source, remove: "keep.txt", add: ("keep.txt", "upstream edit"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal("upstream edit", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
@@ -290,7 +290,7 @@ public sealed class PathRecoveryTests : IDisposable
File.WriteAllText(file, "{ \"SegmentRenames\": { truncated");
InvalidOperationException pullError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains("corrupt", pullError.Message);
Assert.Contains(file, pullError.Message);
Assert.Contains("Delete it", pullError.Message);
@@ -302,7 +302,7 @@ public sealed class PathRecoveryTests : IDisposable
// "null" is a corrupt document too.
File.WriteAllText(file, "null");
InvalidOperationException nullError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains("empty", nullError.Message);
}
@@ -337,7 +337,7 @@ public sealed class PathRecoveryTests : IDisposable
// The repo must not silently report success with an empty working tree.
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "bad:file.txt", StringComparison.Ordinal));
Assert.True(IsCheckoutPending(target));
@@ -353,7 +353,7 @@ public sealed class PathRecoveryTests : IDisposable
ReplaceForgedEntry(source, remove: "bad:file.txt", add: ("fixed.txt", "fixed content"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal("good content", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("fixed content", File.ReadAllText(Path.Combine(target, "fixed.txt")));
@@ -376,7 +376,7 @@ public sealed class PathRecoveryTests : IDisposable
// content at the mapped name.
string newSha = AppendForgedCommit(source, ("bad:file.txt", "v2"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.Equal("v2", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
Assert.Equal("g1", File.ReadAllText(Path.Combine(target, "good.txt")));
@@ -396,7 +396,7 @@ public sealed class PathRecoveryTests : IDisposable
AppendForgedCommit(source, ("worse:new.txt", "not covered"));
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "worse:new.txt", StringComparison.Ordinal));
// The previously materialized working tree is untouched.
@@ -415,7 +415,7 @@ public sealed class PathRecoveryTests : IDisposable
Assert.False(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
await _client.PullAsync(target, Token, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
Assert.Equal("hello", File.ReadAllText(Path.Combine(target, "readme.txt")));
+207
View File
@@ -0,0 +1,207 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>
/// Pins the two decisions <see cref="RemoteUrl"/> makes for a sync (#139): whether a
/// folder's origin is the listed repository, and whether a host may be given the token.
/// </summary>
public sealed class RemoteUrlTests
{
private const string Listed = "https://github.com/acme/repo.git";
// ---------------------------------------------------------------- same repository
[Theory]
[InlineData("https://github.com/acme/repo.git")]
[InlineData("https://github.com/acme/repo")]
[InlineData("https://github.com/acme/repo/")]
[InlineData("https://github.com/acme/repo.git/")]
[InlineData("https://GitHub.com/Acme/Repo.GIT")]
[InlineData(" https://github.com/acme/repo.git ")]
[InlineData("https://someone:secret@github.com/acme/repo.git")]
[InlineData("http://github.com/acme/repo.git")]
[InlineData("ssh://git@github.com/acme/repo.git")]
[InlineData("ssh://git@github.com:22/acme/repo.git")]
[InlineData("git@github.com:acme/repo.git")]
[InlineData("github.com:acme/repo")]
public void IsSameRepository_SameHostAndPath_HoweverTheOriginIsSpelled(string origin)
{
Assert.True(RemoteUrl.IsSameRepository(Listed, origin));
}
[Theory]
[InlineData("https://github.com/acme/other.git")]
[InlineData("https://github.com/someone-else/repo.git")]
[InlineData("https://github.com/acme/repo.git/extra")]
[InlineData("https://github.example.com/acme/repo.git")]
[InlineData("https://gitlab.com/acme/repo.git")]
[InlineData("https://github.com:8443/acme/repo.git")]
[InlineData("https://\u00fc-/acme/repo.git")]
[InlineData("git@bitbucket.org:acme/repo.git")]
[InlineData("ssh://git@old-host.example/acme/repo.git")]
[InlineData(@"C:\src\acme\repo")]
[InlineData("/srv/git/acme/repo.git")]
[InlineData("file:///srv/git/acme/repo.git")]
[InlineData("https://")]
[InlineData("")]
[InlineData(" ")]
public void IsSameRepository_AnotherRepository_OrNoRemoteAtAll_IsNotTheListedOne(string origin)
{
Assert.False(RemoteUrl.IsSameRepository(Listed, origin));
}
[Fact]
public void IsSameRepository_LocalPaths_CompareAsFullPaths()
{
string folder = Path.Combine(Path.GetTempPath(), "gclo-tests", "remote-url", "source");
string other = Path.Combine(Path.GetTempPath(), "gclo-tests", "remote-url", "another");
Assert.True(RemoteUrl.IsSameRepository(folder, folder));
Assert.True(RemoteUrl.IsSameRepository(folder, folder + Path.DirectorySeparatorChar));
Assert.True(RemoteUrl.IsSameRepository(folder, new Uri(folder).AbsoluteUri));
Assert.True(RemoteUrl.IsSameRepository(folder, Path.Combine(folder, "..", "source")));
Assert.False(RemoteUrl.IsSameRepository(folder, other));
// A listed local path is never satisfied by a network origin.
Assert.False(RemoteUrl.IsSameRepository(folder, Listed));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void IsSameRepository_BlankExpectedUrl_IsACallerError(string expected)
{
Assert.Throws<ArgumentException>(() => RemoteUrl.IsSameRepository(expected, Listed));
}
// ---------------------------------------------------------------- credentials
[Theory]
[InlineData("https://github.com/acme/repo.git")]
[InlineData("https://github.com/acme/repo.git/info/refs?service=git-upload-pack")]
[InlineData("https://GITHUB.com/another/path")]
[InlineData("https://github.com:443/acme/repo.git")]
public void MayReceiveCredentials_TheListedHostOverHttps_Yes(string requested)
{
Assert.True(RemoteUrl.MayReceiveCredentials(Listed, requested));
}
[Theory]
[InlineData("https://evil.example/acme/repo.git")]
[InlineData("https://github.com.evil.example/acme/repo.git")]
[InlineData("https://github.com@evil.example/acme/repo.git")]
[InlineData("https://\u00fc-/acme/repo.git")]
[InlineData("https://github.com:8443/acme/repo.git")]
[InlineData("http://github.com/acme/repo.git")]
[InlineData("ssh://git@github.com/acme/repo.git")]
[InlineData("git@github.com:acme/repo.git")]
[InlineData("github.com/acme/repo.git")]
[InlineData("")]
[InlineData(null)]
public void MayReceiveCredentials_AnyOtherHostPortOrScheme_No(string? requested)
{
Assert.False(RemoteUrl.MayReceiveCredentials(Listed, requested));
}
[Theory]
[InlineData("http://github.com/acme/repo.git")]
[InlineData(@"C:\src\acme\repo")]
[InlineData("ssh://git@github.com/acme/repo.git")]
public void MayReceiveCredentials_ListedUrlIsNotHttps_NobodyGetsTheToken(string listed)
{
Assert.False(RemoteUrl.MayReceiveCredentials(listed, listed));
Assert.False(RemoteUrl.MayReceiveCredentials(listed, "https://github.com/acme/repo.git"));
}
[Fact]
public void MayReceiveCredentials_BlankExpectedUrl_IsACallerError()
{
Assert.Throws<ArgumentException>(() => RemoteUrl.MayReceiveCredentials(" ", Listed));
}
// ---------------------------------------------------------------- authority
[Theory]
[InlineData("https://github.com/acme/repo.git", "github.com")]
[InlineData("https://GitHub.com:443/acme/repo.git", "github.com")]
[InlineData("https://git.example.com:3000/acme/repo.git", "git.example.com:3000")]
[InlineData("https://[::1]:3000/acme/repo.git", "[::1]:3000")]
[InlineData("https://\u00fc-/acme/repo.git", null)]
[InlineData("http://github.com/acme/repo.git", null)]
[InlineData("git@github.com:acme/repo.git", null)]
[InlineData("", null)]
[InlineData(null, null)]
public void HttpsAuthority_IsTheHostATokenIsScopedTo_OrNullWithoutHttps(string? url, string? expected)
{
Assert.Equal(expected, RemoteUrl.HttpsAuthority(url));
}
// ---------------------------------------------------------------- redaction
[Theory]
[InlineData("https://someone:hunter2@github.com/acme/repo.git", "https://github.com/acme/repo.git")]
[InlineData("https://token@github.com/acme/repo.git", "https://github.com/acme/repo.git")]
[InlineData(" https://someone:hunter2@github.com/acme/repo.git ", "https://github.com/acme/repo.git")]
[InlineData("https://someone:hun@ter2@github.com/acme/repo.git", "https://github.com/acme/repo.git")]
[InlineData("https://someone:hun/ter2@github.com/acme/repo.git", "https://github.com/acme/repo.git")]
[InlineData("https://github.com/acme/repo.git?access_token=hunter2", "https://github.com/acme/repo.git")]
[InlineData("https://github.com/acme/repo.git#hunter2", "https://github.com/acme/repo.git")]
[InlineData("https://someone:hunter2@git.example.com:3000/acme/repo.git", "https://git.example.com:3000/acme/repo.git")]
[InlineData("https://someone:hunter2@[::1]:3000/acme/repo.git", "https://[::1]:3000/acme/repo.git")]
[InlineData("ssh://git@github.com", "ssh://github.com/")]
[InlineData("https://github.com/acme/repo.git", "https://github.com/acme/repo.git")]
[InlineData("https://github.com/acme/user@example.git", "https://github.com/acme/user@example.git")]
[InlineData("https://github.com", "https://github.com/")]
[InlineData("https://", "https://")]
[InlineData(@"C:\src\acme\repo", @"C:\src\acme\repo")]
[InlineData("git@github.com:acme/repo.git", "github.com:acme/repo.git")]
[InlineData("someone:hunter2@github.com:acme/repo.git", "github.com:acme/repo.git")]
[InlineData("", "")]
[InlineData(null, "")]
public void Redact_NeverShowsUserInformationQueryOrFragment(string? url, string expected)
{
string redacted = RemoteUrl.Redact(url);
Assert.Equal(expected, redacted);
Assert.DoesNotContain("hunter2", redacted);
Assert.DoesNotContain("ter2", redacted);
}
[Theory]
[InlineData("https://github.com/acme/repo.git", true)]
[InlineData("HTTPS://github.com", true)]
[InlineData("http://github.com/acme/repo.git", false)]
[InlineData("ssh://git@github.com/acme/repo.git", false)]
[InlineData("git@github.com:acme/repo.git", false)]
[InlineData(@"C:\src\acme\repo", false)]
[InlineData("https://\u00fc-/acme/repo.git", false)]
[InlineData(null, false)]
public void IsHttps_OnlyAnAbsoluteHttpsUrlWithAReadableHost(string? url, bool expected)
{
Assert.Equal(expected, RemoteUrl.IsHttps(url));
}
}
@@ -324,7 +324,7 @@ public sealed class WindowsPathValidationTests : IDisposable
AppendForgedCommit(source, ("bad:incoming.txt", "x"));
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
() => _client.PullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "bad:incoming.txt", StringComparison.Ordinal));
// The valid original working tree must be untouched.
@@ -889,6 +889,27 @@ public sealed class WorkspaceViewModelTests : IDisposable
[Fact]
public async Task ResolvePaths_CloneInWsl_UsesTheOrganizationTheTableWasLoadedFor_NotAnEditedBox()
{
MakeAlphaFailWithInvalidPaths();
_wsl.Availability = new WslAvailability(true, "git version 2.53.0");
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"));
await vm.SyncCommand.ExecuteAsync(null);
RepoItemViewModel row = vm.Repos[0];
string loaded = vm.Organization;
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.CloneInWsl());
// Typed after the run: the row still belongs to the organization it was listed in.
vm.Organization = "../somewhere-else";
await vm.ResolvePathsCommand.ExecuteAsync(row);
WslCloneCall call = Assert.Single(_wsl.CloneCalls);
Assert.Equal(loaded, call.Organization);
}
[Fact]
public async Task ResolvePaths_CloneInWsl_ShowsAnIndeterminateBar_AndLocksTheWorkspace_WhileInFlight()
{
+80 -2
View File
@@ -133,7 +133,7 @@ public sealed class WslClonerTests
ProcessCall call = Assert.Single(_runner.Calls);
Assert.Equal(WslExe, call.FileName);
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://github.com/acme/repo.git"], call.Arguments);
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://github.com/acme/repo.git", "github.com"], call.Arguments);
Assert.Equal(WslCloner.CloneScript, call.StandardInput);
Assert.DoesNotContain(Token, string.Join(" ", call.Arguments));
Assert.DoesNotContain(Token, call.StandardInput);
@@ -156,7 +156,7 @@ public sealed class WslClonerTests
Assert.Contains("password=$" + WslCloner.TokenVariable, script);
Assert.Contains("credential.helper=", script);
Assert.Contains("git clone", script.Replace("-c credential.helper= -c \"credential.helper=$helper\" ", ""));
Assert.Contains("git clone", script.Replace("-c credential.helper= -c \"credential.https://$host.helper=$helper\" ", ""));
Assert.Contains("pull --ff-only", script);
Assert.Contains("$HOME/gclo/$org/$repo", script);
Assert.Contains("$WSL_DISTRO_NAME", script);
@@ -268,6 +268,84 @@ public sealed class WslClonerTests
[Fact]
public void CloneScript_ScopesTheHelperToTheUrlsHost_AndRefusesAFolderClonedFromElsewhere()
{
string script = WslCloner.CloneScript;
// Every git invocation first clears inherited helpers, then installs the token
// helper for the listed host only: nothing unscoped may remain (#139).
Assert.Equal(2, CountOf(script, "-c credential.helper= -c \"credential.https://$host.helper=$helper\""));
Assert.Equal(0, CountOf(script, "\"credential.helper=$helper\""));
Assert.Contains("host=\"$4\"", script);
// "--" ends option parsing, so a URL can never be read as an option.
Assert.Contains("clone --quiet -- \"$url\" \"$dest\"", script);
// An existing destination is only fast-forwarded when it is a clone of this URL.
// The stored URL is compared, not "remote get-url": that one applies the user's
// url.*.insteadOf rewrites and would refuse a destination gclo itself cloned.
Assert.Contains("config --get remote.origin.url)\" != \"$url\"", script);
Assert.DoesNotContain("remote get-url", script);
Assert.True(
script.IndexOf("config --get remote.origin.url", StringComparison.Ordinal) < script.IndexOf("pull --ff-only", StringComparison.Ordinal),
"the origin check must come before the pull");
}
[Fact]
public async Task Clone_PassesTheHostWithItsPort_ForAServerOnANonDefaultPort()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "Ubuntu\n/home/me/gclo/acme/repo\n", ""));
await Create().CloneAsync("https://git.example.com:3000/acme/repo.git", "acme", "repo", Token, CancellationToken.None);
ProcessCall call = Assert.Single(_runner.Calls);
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://git.example.com:3000/acme/repo.git", "git.example.com:3000"], call.Arguments);
}
[Theory]
[InlineData("../..", "repo")]
[InlineData("..", "repo")]
[InlineData("acme/other", "repo")]
[InlineData("acme", "../../.ssh")]
[InlineData("acme", "re/po")]
[InlineData("acme", @"re\po")]
public async Task Clone_NameThatWouldLeaveTheGcloFolder_IsRefused_WithoutStartingWsl(string organization, string repository)
{
WslCloneException ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://github.com/acme/repo.git", organization, repository, Token, CancellationToken.None));
Assert.StartsWith("Clone in WSL refused: '", ex.Message);
Assert.Contains("cannot be used as a folder name", ex.Message);
Assert.Empty(_runner.Calls);
}
[Theory]
[InlineData("http://github.com/acme/repo.git")]
[InlineData("ssh://git@github.com/acme/repo.git")]
[InlineData("git@github.com:acme/repo.git")]
[InlineData("--upload-pack=touch /tmp/x")]
[InlineData(@"C:\src\acme\repo")]
public async Task Clone_UrlThatIsNotHttps_IsRefused_WithoutStartingWsl(string url)
{
WslCloneException ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync(url, "acme", "repo", Token, CancellationToken.None));
Assert.Equal("Clone in WSL refused: the clone URL is not an HTTPS URL.", ex.Message);
Assert.Empty(_runner.Calls);
}
private static int CountOf(string text, string fragment)
=> (text.Length - text.Replace(fragment, "", StringComparison.Ordinal).Length) / fragment.Length;
[Fact]
public void Defaults_PointAtSystem32Wsl()
{
+12
View File
@@ -38,6 +38,18 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
ArgumentException.ThrowIfNullOrWhiteSpace(token);
cancellationToken.ThrowIfCancellationRequested();
///////////////////////////////////////////////////////////////////////////
// The name goes into request paths and, with the org-subfolder option and //
// the WSL recovery, into local paths. Anything that is not one plain name //
// is refused before a request is made (#139). //
///////////////////////////////////////////////////////////////////////////
if(!RepositoryPathResolver.TryValidateName(organization, out string? invalidName))
{
throw new GitHubAccessException(
GitHubAccessKind.NotFound,
$"'{organization}' is not an organization or account name ({invalidName}). Check the spelling.");
}
IGitHubGateway gateway = _gatewayFactory(token);
// Pages manually (instead of one GetAll* call) so cancellation is honored
+13 -2
View File
@@ -24,8 +24,19 @@ public interface IGitClient
/// <summary>Fetches from origin and fast-forwards the current branch to its upstream.</summary>
Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken);
/// <summary>
/// Fetches <paramref name="url"/> into the repository at <paramref name="path"/> and
/// fast-forwards the current branch to its upstream. The folder's <c>origin</c> must
/// name the same repository as <paramref name="url"/> (scheme, credentials and a
/// trailing <c>.git</c> aside); a folder that is a clone of something else fails
/// without being fetched, so a sync never reports a different repository as updated.
/// The token is offered only over HTTPS to the host of <paramref name="url"/>.
/// </summary>
/// <param name="url">The clone URL the repository was listed with.</param>
/// <param name="path">Local directory of the existing clone.</param>
/// <param name="token">Access token used for git transport.</param>
/// <param name="cancellationToken">Cancels the pull.</param>
Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken);
+200 -14
View File
@@ -52,6 +52,26 @@ public sealed class LibGit2GitClient : IGitClient
private static readonly JsonSerializerOptions s_recoveryJsonOptions = new() { WriteIndented = true };
/// <summary>
/// Name of the remote that exists only for the duration of one fetch, when a
/// folder's origin names the listed repository but spells its URL differently
/// (see <see cref="FetchListedUrl"/>).
/// </summary>
private const string SyncRemoteName = "gclo-sync";
/// <summary>
/// The sync remote's own refspec. It maps to a namespace nothing is ever fetched
/// into, so removing the remote (which deletes the tracking refs its refspecs
/// cover) cannot touch origin's.
/// </summary>
private const string SyncRemoteRefSpec = "+refs/heads/*:refs/remotes/" + SyncRemoteName + "/*";
/// <summary>
/// Marks the sync remote as gclo's, so cleanup never removes a remote of the same
/// name that the user created. It goes away with the remote.
/// </summary>
private const string SyncRemoteMarkerKey = "remote." + SyncRemoteName + ".gclo-temporary";
/// <summary>
/// Test seam: invoked right after a fetch completed, before the cancellation
/// check that precedes any working-tree work. Local-transport fixtures fire no
@@ -79,9 +99,9 @@ public sealed class LibGit2GitClient : IGitClient
/// <inheritdoc/>
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
public Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken)
=> Task.Factory.StartNew(
() => FetchAndPull(path, token, cancellationToken),
() => FetchAndPull(url, path, token, cancellationToken),
cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default);
@@ -106,7 +126,7 @@ public sealed class LibGit2GitClient : IGitClient
// not a libgit2 one. On other platforms phase two is a plain checkout.
Checkout = false,
};
options.FetchOptions.CredentialsProvider = MakeCredentialsProvider(token);
options.FetchOptions.CredentialsProvider = MakeCredentialsProvider(token, url);
int lastPercent = -1;
options.FetchOptions.OnTransferProgress = tp =>
{
@@ -202,7 +222,7 @@ public sealed class LibGit2GitClient : IGitClient
private void FetchAndPull(string path, string token, CancellationToken ct)
private void FetchAndPull(string url, string path, string token, CancellationToken ct)
{
using var repo = new Repository(path);
@@ -212,12 +232,11 @@ public sealed class LibGit2GitClient : IGitClient
repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local);
}
Remote remote = repo.Network.Remotes["origin"]
?? throw new InvalidOperationException("Repository has no 'origin' remote.");
Remote remote = OriginOfListedRepository(repo, url);
var fetchOptions = new FetchOptions
{
CredentialsProvider = MakeCredentialsProvider(token),
CredentialsProvider = MakeCredentialsProvider(token, url),
OnTransferProgress = _ => !ct.IsCancellationRequested,
// Drop tracking refs for branches origin deleted. Without this a renamed
// default branch leaves refs/remotes/origin/<old> frozen at its last SHA,
@@ -225,10 +244,9 @@ public sealed class LibGit2GitClient : IGitClient
Prune = true,
};
var refSpecs = remote.FetchRefSpecs.Select(s => s.Specification).ToList();
try
{
Commands.Fetch(repo, remote.Name, refSpecs, fetchOptions, logMessage: null);
FetchListedUrl(repo, remote, url, fetchOptions);
}
catch(UserCancelledException) when(ct.IsCancellationRequested)
{
@@ -296,7 +314,9 @@ public sealed class LibGit2GitClient : IGitClient
// Upstream is configured but gone from origin (the fetch pruned it): the
// default branch was renamed (master -> main) or deleted. Follow origin's
// current default instead of reporting "up to date" forever (#31).
tracked = RetargetToRemoteDefaultBranch(repo, remote, fetchOptions.CredentialsProvider, ct);
// A provider of its own: the fetch already spent one of the two requests
// the replay guard allows.
tracked = RetargetToRemoteDefaultBranch(repo, remote, url, MakeCredentialsProvider(token, url), ct);
}
if(string.Equals(repo.Head.Tip?.Sha, tracked.Tip.Sha, StringComparison.Ordinal))
@@ -346,6 +366,160 @@ public sealed class LibGit2GitClient : IGitClient
/// <summary>
/// Fetches the listed <paramref name="url"/> into <paramref name="origin"/>'s own
/// refspecs, so the tracking refs land exactly where a fetch of origin puts them
/// while the remote that is contacted is the one the listing returned.
/// </summary>
/// <remarks>
/// When origin's URL is the listed URL, which is every clone gclo made itself, this
/// is a plain fetch of origin. Otherwise the origin names the same repository in
/// another spelling: switched to SSH for pushing, plain HTTP, credentials typed
/// into it. Fetching that origin would let the folder's configuration choose the
/// transport. LibGit2Sharp cannot fetch a bare URL, so the listed URL is fetched
/// through a remote that exists only for this call; origin itself is never
/// rewritten. The user's own (global) git configuration still applies to that
/// remote, as it does to everything git does for them (#139).
/// </remarks>
private static void FetchListedUrl(Repository repo, Remote origin, string url, FetchOptions options)
{
TryRemoveSyncRemote(repo); // left behind by a run that was killed mid-fetch
var refSpecs = origin.FetchRefSpecs.Select(s => s.Specification).ToList();
if(string.Equals(origin.Url, url, StringComparison.Ordinal))
{
RefuseSshCommandOfTheFolder(repo, origin.Url);
Commands.Fetch(repo, origin.Name, refSpecs, options, logMessage: null);
return;
}
if(repo.Network.Remotes[SyncRemoteName] is not null)
{
// Not marked as gclo's, or TryRemoveSyncRemote would have removed it.
throw new InvalidOperationException(
$"This repository has a remote of its own named '{SyncRemoteName}', a name gclo needs for a moment "
+ "to fetch the listed URL. Rename that remote, then sync again.");
}
Remote sync = repo.Network.Remotes.Add(SyncRemoteName, url, SyncRemoteRefSpec);
repo.Config.Set(SyncRemoteMarkerKey, true, ConfigurationLevel.Local);
try
{
RefuseSshCommandOfTheFolder(repo, sync.Url);
// The fetch behaves like a fetch of origin in every respect but the URL.
options.TagFetchMode = origin.TagFetchMode;
Commands.Fetch(repo, SyncRemoteName, refSpecs, options, logMessage: null);
}
finally
{
TryRemoveSyncRemote(repo);
}
}
/// <summary>
/// Removes the remote <see cref="FetchListedUrl"/> adds, when it is there and carries
/// gclo's marker; a remote of that name the user made themselves is left alone.
/// Best effort: a failure here (another program holding .git/config open) must not
/// replace the outcome of the fetch, and the next sync removes what is left.
/// </summary>
private static void TryRemoveSyncRemote(Repository repo)
{
try
{
if(repo.Network.Remotes[SyncRemoteName] is not null && repo.Config.Get<bool>(SyncRemoteMarkerKey)?.Value == true)
{
repo.Network.Remotes.Remove(SyncRemoteName);
}
}
catch(LibGit2SharpException)
{
// See the summary: never mask the fetch's own result.
}
}
/// <summary>
/// A folder's own git configuration must not decide where a sync's fetch goes. Two
/// settings could: <c>url.&lt;base&gt;.insteadOf</c>, which rewrites the URL of any
/// remote, and (see <see cref="RefuseSshCommandOfTheFolder"/>) the program used for
/// SSH. Both are refused when they come from the folder's own configuration; the
/// user's global configuration is theirs and is honored (#139).
/// </summary>
private static void RefuseUrlRewritesOfTheFolder(Repository repo)
{
foreach(ConfigurationEntry<string> entry in repo.Config)
{
if(entry.Level >= ConfigurationLevel.Local
&& entry.Key.StartsWith("url.", StringComparison.OrdinalIgnoreCase)
&& entry.Key.EndsWith(".insteadof", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"This folder's own git configuration rewrites remote URLs (a url.<base>.insteadOf entry in its .git/config), "
+ "so a fetch could go somewhere other than the listed repository. Remove that entry, then sync again.");
}
}
}
/// <summary>
/// When the URL a fetch will really use is not HTTPS (the user's global git
/// configuration rewrites it to SSH), the transport is an external SSH program.
/// Which program must not be up to the folder: a <c>core.sshCommand</c> in its own
/// configuration is refused (#139).
/// </summary>
private static void RefuseSshCommandOfTheFolder(Repository repo, string effectiveUrl)
{
if(RemoteUrl.IsHttps(effectiveUrl))
{
return;
}
foreach(ConfigurationEntry<string> entry in repo.Config)
{
if(entry.Level >= ConfigurationLevel.Local && string.Equals(entry.Key, "core.sshcommand", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"This folder's own git configuration sets core.sshCommand, and this fetch would go over SSH. "
+ "gclo does not run a program chosen by the folder it is syncing. Remove that entry from its .git/config, then sync again.");
}
}
}
/// <summary>
/// The folder's <c>origin</c>, once it is known to name the repository listed at
/// <paramref name="url"/>. A folder with the right name is not proof of the right
/// repository: it may be a fork, a same-named repository of another owner or host,
/// or something another tool put there. Pulling it would report the listed
/// repository as updated when it was not, so it fails here, before any network
/// request (#139).
/// </summary>
private static Remote OriginOfListedRepository(Repository repo, string url)
{
RefuseUrlRewritesOfTheFolder(repo);
Remote remote = repo.Network.Remotes["origin"]
?? throw new InvalidOperationException("Repository has no 'origin' remote.");
if(!RemoteUrl.IsSameRepository(url, remote.Url))
{
string folder = Path.TrimEndingDirectorySeparator(repo.Info.WorkingDirectory ?? repo.Info.Path);
throw new InvalidOperationException(
$"This folder is a clone of a different repository: its origin is '{RemoteUrl.Redact(remote.Url)}', not '{url}'. "
+ "If the repository was renamed or moved to another owner, point the folder at it with "
+ $"'git -C \"{folder}\" remote set-url origin {url}'. Otherwise move or delete the folder. Then sync again.");
}
return remote;
}
/// <summary>
/// The current branch's upstream no longer exists on origin. Asks origin for its
/// current default branch (the remote HEAD symref), checks it out as the new
@@ -355,12 +529,12 @@ public sealed class LibGit2GitClient : IGitClient
/// tracking branch so the normal pull continues from it.
/// </summary>
private static Branch RetargetToRemoteDefaultBranch(
Repository repo, Remote remote, CredentialsHandler credentials, CancellationToken ct)
Repository repo, Remote remote, string url, CredentialsHandler credentials, CancellationToken ct)
{
const string Prefix = "refs/heads/";
string oldName = repo.Head.FriendlyName;
string? defaultRef = repo.Network.ListReferences(remote, credentials)
string? defaultRef = repo.Network.ListReferences(url, credentials)
.FirstOrDefault(r => string.Equals(r.CanonicalName, "HEAD", StringComparison.Ordinal))?.TargetIdentifier;
if(defaultRef is null || !defaultRef.StartsWith(Prefix, StringComparison.Ordinal))
{
@@ -803,12 +977,24 @@ public sealed class LibGit2GitClient : IGitClient
/// up with "too many redirects or authentication replays", once per repository.
/// A correct token is never asked for a third time, so the third request means the
/// token is bad and gets the same actionable message the API listers produce (#31).
/// The token is for <paramref name="expectedUrl"/>'s host only: a remote on any
/// other host, or one reached over anything but HTTPS, is refused, so the token is
/// never offered to a server the repository was not listed on (#139). libgit2 asks
/// with the remote's own URL; a redirect the listed host itself issues is that
/// host's doing, and it already holds the token.
/// </summary>
private static CredentialsHandler MakeCredentialsProvider(string token)
internal static CredentialsHandler MakeCredentialsProvider(string token, string expectedUrl)
{
int requests = 0;
return (_, _, _) =>
return (requestedUrl, _, _) =>
{
if(!RemoteUrl.MayReceiveCredentials(expectedUrl, requestedUrl))
{
throw new InvalidOperationException(
$"'{RemoteUrl.Redact(requestedUrl)}' asked for credentials, but the token is only sent over HTTPS "
+ "to the host the repository was listed on. Nothing was sent.");
}
if(Interlocked.Increment(ref requests) > 2)
{
throw new InvalidOperationException(
+1 -1
View File
@@ -141,7 +141,7 @@ public sealed class OrgSyncEngine
if(_git.IsValidRepository(path))
{
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Pulling));
await _git.FetchAndPullAsync(path, request.Token, token).WaitAsync(token).ConfigureAwait(false);
await _git.FetchAndPullAsync(repo.CloneUrl, path, request.Token, token).WaitAsync(token).ConfigureAwait(false);
Interlocked.Increment(ref updated);
}
else
+238
View File
@@ -0,0 +1,238 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace gclo.Engine;
/// <summary>
/// Decides two things about git remote URLs, both from the clone URL a listing returned
/// (the "expected" URL): whether an existing folder's <c>origin</c> names that same
/// repository, and whether a remote that asks for credentials is one the token is
/// meant for. A sync must never report a different repository as updated, and must
/// never hand the token to a remote on another host (#139).
/// </summary>
internal static class RemoteUrl
{
private static readonly char[] s_separators = ['/', '\\'];
/// <summary>
/// True when <paramref name="actual"/> (a folder's configured origin) names the same
/// repository as <paramref name="expected"/> (the listed clone URL). Network URLs
/// are compared by host and path, ignoring scheme, credentials, letter case, a
/// trailing slash and a trailing <c>.git</c>, so an origin switched to SSH still
/// matches its HTTPS listing; the port counts only between two HTTP(S) URLs, where
/// it tells two servers on one host apart. Local paths (the offline test fixtures,
/// or a caller's own mirror) are compared as full paths. A network URL never
/// matches a local path, and a URL that cannot be read matches nothing.
/// </summary>
public static bool IsSameRepository(string expected, string actual)
{
ArgumentException.ThrowIfNullOrWhiteSpace(expected);
if(string.IsNullOrWhiteSpace(actual))
{
return false;
}
Location expectedLocation = Parse(expected);
Location actualLocation = Parse(actual);
if(expectedLocation.Kind != actualLocation.Kind || expectedLocation.Kind == LocationKind.Unreadable)
{
return false;
}
if(expectedLocation.Kind == LocationKind.Local)
{
return string.Equals(
expectedLocation.Path, actualLocation.Path,
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
return string.Equals(expectedLocation.Host, actualLocation.Host, StringComparison.OrdinalIgnoreCase)
&& string.Equals(expectedLocation.Path, actualLocation.Path, StringComparison.OrdinalIgnoreCase)
&& (expectedLocation.WebPort is null || actualLocation.WebPort is null || expectedLocation.WebPort == actualLocation.WebPort);
}
/// <summary>
/// True when a credential request from a remote whose URL is
/// <paramref name="requested"/> may be answered with the token of a sync whose listed
/// clone URL is <paramref name="expected"/>: both must be HTTPS and name the same
/// host and port. A remote on another host, plain HTTP, and SSH get no token.
/// </summary>
public static bool MayReceiveCredentials(string expected, string? requested)
{
ArgumentException.ThrowIfNullOrWhiteSpace(expected);
return TryParseHttps(expected, out Uri? expectedUri, out string? expectedHost)
&& TryParseHttps(requested, out Uri? requestedUri, out string? requestedHost)
&& string.Equals(expectedHost, requestedHost, StringComparison.OrdinalIgnoreCase)
&& expectedUri.Port == requestedUri.Port;
}
/// <summary>True when <paramref name="url"/> is an absolute HTTPS URL with a readable host.</summary>
public static bool IsHttps(string? url) => TryParseHttps(url, out _, out _);
/// <summary>
/// The host of an HTTPS <paramref name="url"/>, with its port when that is not 443
/// and with the brackets of an IPv6 literal; null when the URL is not absolute HTTPS.
/// This is the scope a token may be used in.
/// </summary>
public static string? HttpsAuthority(string? url)
{
if(!TryParseHttps(url, out Uri? uri, out string? host))
{
return null;
}
string authority = uri.HostNameType == UriHostNameType.IPv6 ? $"[{host}]" : host;
return uri.IsDefaultPort ? authority : authority + ":" + uri.Port.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// <paramref name="url"/> in a form that is safe to show and log: an origin may
/// carry credentials someone typed into it. A readable network URL is rebuilt from
/// its scheme, host, port and path, so user information, query and fragment are
/// gone; anything else loses everything up to its last <c>@</c>.
/// </summary>
public static string Redact(string? url)
{
if(string.IsNullOrEmpty(url))
{
return "";
}
string trimmed = url.Trim();
int scheme = trimmed.IndexOf("://", StringComparison.Ordinal);
if(scheme > 0 && Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri) && !uri.IsFile && TryGetHost(uri, out string? host))
{
string authority = uri.HostNameType == UriHostNameType.IPv6 ? $"[{host}]" : host;
string port = uri.IsDefaultPort ? "" : ":" + uri.Port.ToString(CultureInfo.InvariantCulture);
return $"{uri.Scheme}://{authority}{port}{uri.AbsolutePath}";
}
int at = trimmed.LastIndexOf('@');
if(at < 0)
{
return trimmed;
}
return scheme > 0 ? string.Concat(trimmed.AsSpan(0, scheme + 3), trimmed.AsSpan(at + 1)) : trimmed[(at + 1)..];
}
private static bool TryParseHttps(string? url, [NotNullWhen(true)] out Uri? uri, [NotNullWhen(true)] out string? host)
{
host = null;
return Uri.TryCreate(url, UriKind.Absolute, out uri)
&& string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
&& TryGetHost(uri, out host);
}
/// <summary>
/// The host in its ASCII (punycode) form. <see cref="Uri.IdnHost"/> throws for a
/// host that parses as a URI but is not a valid internationalized name; such a
/// host is nobody's, not an error.
/// </summary>
private static bool TryGetHost(Uri uri, [NotNullWhen(true)] out string? host)
{
try
{
host = uri.IdnHost;
return host.Length > 0;
}
catch(UriFormatException)
{
host = null;
return false;
}
}
private static Location Parse(string url)
{
string trimmed = url.Trim();
int scheme = trimmed.IndexOf("://", StringComparison.Ordinal);
if(scheme > 0)
{
if(!Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri))
{
return new Location(LocationKind.Unreadable, "", "", null);
}
if(uri.IsFile)
{
return Local(uri.LocalPath);
}
bool web = uri.Scheme is "http" or "https";
return TryGetHost(uri, out string? host)
? new Location(LocationKind.Network, host, NormalizePath(Uri.UnescapeDataString(uri.AbsolutePath)), web ? (uri.IsDefaultPort ? 0 : uri.Port) : null)
: new Location(LocationKind.Unreadable, "", "", null);
}
// The scp-like form git accepts for SSH: [user@]host:path. A single letter before
// the colon is a Windows drive ("C:\src\repo"), not a host, and a separator
// before the colon means the colon belongs to a local path.
int colon = trimmed.IndexOf(':', StringComparison.Ordinal);
if(colon >= 0)
{
string host = trimmed[(trimmed.LastIndexOf('@', colon) + 1)..colon];
string path = trimmed[(colon + 1)..];
if(host.Length >= 2 && host.IndexOfAny(s_separators) < 0 && path.Length > 0 && path[0] != '\\')
{
return new Location(LocationKind.Network, host, NormalizePath(path), null);
}
}
return Local(trimmed);
}
private static Location Local(string path)
=> new(LocationKind.Local, "", Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)), null);
private static string NormalizePath(string path)
{
string trimmed = path.Trim('/');
return trimmed.EndsWith(".git", StringComparison.OrdinalIgnoreCase) ? trimmed[..^4].TrimEnd('/') : trimmed;
}
private enum LocationKind
{
Network,
Local,
Unreadable,
}
/// <summary>Where a git URL points: a host and path, or a local full path.</summary>
/// <param name="Kind">Network, local, or not readable as either.</param>
/// <param name="Host">ASCII host of a network URL; empty otherwise.</param>
/// <param name="Path">Normalized repository path, or the local full path.</param>
/// <param name="WebPort">Port of an HTTP(S) URL, 0 for the scheme's default (so HTTP and HTTPS on their usual ports agree); null for SSH, scp-like and local.</param>
private readonly record struct Location(LocationKind Kind, string Host, string Path, int? WebPort);
}
+33 -8
View File
@@ -20,21 +20,29 @@ public sealed class WslCloner : IWslCloner
public const string TokenVariable = "GCLO_GIT_TOKEN";
/// <summary>
/// The clone script. $1 organization, $2 repository, $3 clone URL. Prints the
/// distribution name and the destination as its last two lines on success. A folder
/// that already is a repository is fast-forwarded instead of failing, so the verb is
/// safe to use twice.
/// The clone script. $1 organization, $2 repository, $3 clone URL, $4 the URL's
/// host (with a non-default port). Prints the distribution name and the destination
/// as its last two lines on success. A folder that already is a clone of the same
/// URL is fast-forwarded instead of failing, so the verb is safe to use twice; a
/// folder that is a clone of anything else is refused. The credential helper is
/// scoped to $4, so git (and anything it starts, such as an LFS filter pointed at
/// another server by the repository's own configuration) gets the token for that
/// host only (#139).
/// </summary>
internal const string CloneScript =
"set -e\n"
+ "org=\"$1\"; repo=\"$2\"; url=\"$3\"\n"
+ "org=\"$1\"; repo=\"$2\"; url=\"$3\"; host=\"$4\"\n"
+ "dest=\"$HOME/gclo/$org/$repo\"\n"
+ "helper='!f() { echo username=x-access-token; echo \"password=$" + TokenVariable + "\"; }; f'\n"
+ "if [ -d \"$dest/.git\" ]; then\n"
+ " git -C \"$dest\" -c credential.helper= -c \"credential.helper=$helper\" pull --ff-only --quiet\n"
+ " if [ \"$(git -C \"$dest\" config --get remote.origin.url)\" != \"$url\" ]; then\n"
+ " echo \"$dest exists but is not a clone of this repository; move or delete it, then try again\" >&2\n"
+ " exit 1\n"
+ " fi\n"
+ " git -C \"$dest\" -c credential.helper= -c \"credential.https://$host.helper=$helper\" pull --ff-only --quiet\n"
+ "else\n"
+ " mkdir -p \"$HOME/gclo/$org\"\n"
+ " git -c credential.helper= -c \"credential.helper=$helper\" clone --quiet \"$url\" \"$dest\"\n"
+ " git -c credential.helper= -c \"credential.https://$host.helper=$helper\" clone --quiet -- \"$url\" \"$dest\"\n"
+ "fi\n"
+ "printf '%s\\n%s\\n' \"$WSL_DISTRO_NAME\" \"$dest\"\n";
@@ -102,6 +110,23 @@ public sealed class WslCloner : IWslCloner
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName);
ArgumentNullException.ThrowIfNull(token);
/////////////////////////////////////////////////////////////////////////////
// The organization and repository become path segments under ~/gclo, and //
// the URL's host is what the token is scoped to. None of the three is taken //
// on trust: a name with a separator or ".." would leave ~/gclo, and a URL //
// that is not HTTPS has no host the token could safely be tied to (#139). //
/////////////////////////////////////////////////////////////////////////////
foreach(string name in new[] { organization, repositoryName })
{
if(!RepositoryPathResolver.TryValidateName(name, out string? reason))
{
throw new WslCloneException($"Clone in WSL refused: '{name}' cannot be used as a folder name ({reason}).");
}
}
string host = RemoteUrl.HttpsAuthority(url)
?? throw new WslCloneException("Clone in WSL refused: the clone URL is not an HTTPS URL.");
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
{
[TokenVariable] = token,
@@ -115,7 +140,7 @@ public sealed class WslCloner : IWslCloner
{
result = await _runner.RunAsync(
_wslExe,
["-e", "sh", "-s", organization, repositoryName, url],
["-e", "sh", "-s", organization, repositoryName, url, host],
environment,
CloneScript,
cancellationToken).ConfigureAwait(false);
+3 -1
View File
@@ -1102,8 +1102,10 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
_log.Info($"{item.Name}: cloning in WSL instead.");
item.Status = SyncStatus.Pulling; // indeterminate bar; git in WSL reports no percentage here
// The organization the table was loaded for, not whatever the box says now:
// the row belongs to that organization, and its folder under ~/gclo should too.
WslCloneResult result = await _wsl.CloneAsync(
item.Descriptor.CloneUrl, Organization.Trim(), item.Name, Token.Trim(), _lifetimeCts.Token);
item.Descriptor.CloneUrl, LoadedOrganization ?? Organization.Trim(), item.Name, Token.Trim(), _lifetimeCts.Token);
item.Status = SyncStatus.Done;
item.Error = null;
+2 -2
View File
@@ -90,8 +90,8 @@ internal static class UiTestFixture
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
=> string.Equals(token, Token, StringComparison.Ordinal) ? Task.CompletedTask : real.FetchAndPullAsync(path, token, cancellationToken);
public Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken)
=> string.Equals(token, Token, StringComparison.Ordinal) ? Task.CompletedTask : real.FetchAndPullAsync(url, path, token, cancellationToken);