diff --git a/README.md b/README.md
index a027f59..5e5e848 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md
index d676c74..c626c19 100644
--- a/docs/security/threat-model.md
+++ b/docs/security/threat-model.md
@@ -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..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://.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) |
diff --git a/gclo.Cli.Tests/CliTestSupport.cs b/gclo.Cli.Tests/CliTestSupport.cs
index 1b95cea..69c7fa1 100644
--- a/gclo.Cli.Tests/CliTestSupport.cs
+++ b/gclo.Cli.Tests/CliTestSupport.cs
@@ -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);
diff --git a/gclo.Engine.Tests/Fakes.cs b/gclo.Engine.Tests/Fakes.cs
index 576f12f..3369957 100644
--- a/gclo.Engine.Tests/Fakes.cs
+++ b/gclo.Engine.Tests/Fakes.cs
@@ -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);
}
diff --git a/gclo.Engine.Tests/GitCredentialsTests.cs b/gclo.Engine.Tests/GitCredentialsTests.cs
new file mode 100644
index 0000000..bc27e07
--- /dev/null
+++ b/gclo.Engine.Tests/GitCredentialsTests.cs
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+using LibGit2Sharp;
+using LibGit2Sharp.Handlers;
+
+
+namespace gclo.Engine.Tests;
+
+
+///
+/// The credential callback 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.
+///
+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(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(
+ () => 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(
+ () => 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(
+ () => 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(
+ () => 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(
+ () => provider("https://evil.example/x.git", null, SupportedCredentialTypes.UsernamePassword));
+ }
+
+ Assert.IsType(provider(Listed, null, SupportedCredentialTypes.UsernamePassword));
+ }
+}
diff --git a/gclo.Engine.Tests/GitHubListerTests.cs b/gclo.Engine.Tests/GitHubListerTests.cs
index 59fabc0..cd9d5fe 100644
--- a/gclo.Engine.Tests/GitHubListerTests.cs
+++ b/gclo.Engine.Tests/GitHubListerTests.cs
@@ -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>([]); };
+ _gateway.AccountKind = _ => { asked = true; return Task.FromResult(GitHubAccountKind.User); };
+
+ GitHubAccessException ex = await Assert.ThrowsAsync(
+ () => 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()
{
diff --git a/gclo.Engine.Tests/GitTestHelpers.cs b/gclo.Engine.Tests/GitTestHelpers.cs
index 39ac731..68f82d5 100644
--- a/gclo.Engine.Tests/GitTestHelpers.cs
+++ b/gclo.Engine.Tests/GitTestHelpers.cs
@@ -51,6 +51,22 @@ internal static class GitTestHelpers
+ ///
+ /// Pulls the way a sync does for a folder that is a clone
+ /// of the listed repository: with the URL of its own origin.
+ ///
+ 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);
+ }
+
+
+
/// SHA of the commit at HEAD's tip.
public static string HeadSha(string workdir)
{
diff --git a/gclo.Engine.Tests/LibGit2GitClientTests.cs b/gclo.Engine.Tests/LibGit2GitClientTests.cs
index 8eb19f3..81e2013 100644
--- a/gclo.Engine.Tests/LibGit2GitClientTests.cs
+++ b/gclo.Engine.Tests/LibGit2GitClientTests.cs
@@ -165,14 +165,14 @@ public sealed class LibGit2GitClientTests : IDisposable
_client.AfterFetchForTesting = cts.Cancel;
await Assert.ThrowsAnyAsync(
- () => _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(
- () => _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(
- () => _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(
- () => _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(
- () => _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);
diff --git a/gclo.Engine.Tests/OrgSyncEngineTests.cs b/gclo.Engine.Tests/OrgSyncEngineTests.cs
index b762f59..126ca08 100644
--- a/gclo.Engine.Tests/OrgSyncEngineTests.cs
+++ b/gclo.Engine.Tests/OrgSyncEngineTests.cs
@@ -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);
}
diff --git a/gclo.Engine.Tests/OriginVerificationTests.cs b/gclo.Engine.Tests/OriginVerificationTests.cs
new file mode 100644
index 0000000..ded7ae5
--- /dev/null
+++ b/gclo.Engine.Tests/OriginVerificationTests.cs
@@ -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;
+
+
+///
+/// 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 .
+///
+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(
+ () => _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(
+ () => _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(
+ () => _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..insteadOf = : 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(
+ () => _client.FetchAndPullAsync(source, target, Token, CancellationToken.None));
+
+ Assert.Contains("url..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(
+ () => _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;
+ }
+
+
+
+ /// Tip of the remote-tracking ref of the clone's current branch.
+ private static string TrackingTip(string workdir)
+ {
+ using var repo = new Repository(workdir);
+ return repo.Head.TrackedBranch.Tip.Sha;
+ }
+}
diff --git a/gclo.Engine.Tests/PathRecoveryTests.cs b/gclo.Engine.Tests/PathRecoveryTests.cs
index 30a8c40..381d85c 100644
--- a/gclo.Engine.Tests/PathRecoveryTests.cs
+++ b/gclo.Engine.Tests/PathRecoveryTests.cs
@@ -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(
- () => _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(
- () => _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(
- () => _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(
- () => _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(
- () => _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")));
diff --git a/gclo.Engine.Tests/RemoteUrlTests.cs b/gclo.Engine.Tests/RemoteUrlTests.cs
new file mode 100644
index 0000000..2032a8e
--- /dev/null
+++ b/gclo.Engine.Tests/RemoteUrlTests.cs
@@ -0,0 +1,207 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace gclo.Engine.Tests;
+
+
+///
+/// Pins the two decisions makes for a sync (#139): whether a
+/// folder's origin is the listed repository, and whether a host may be given the token.
+///
+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(() => 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(() => 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));
+ }
+}
diff --git a/gclo.Engine.Tests/WindowsPathValidationTests.cs b/gclo.Engine.Tests/WindowsPathValidationTests.cs
index 7c8a419..f2584fa 100644
--- a/gclo.Engine.Tests/WindowsPathValidationTests.cs
+++ b/gclo.Engine.Tests/WindowsPathValidationTests.cs
@@ -324,7 +324,7 @@ public sealed class WindowsPathValidationTests : IDisposable
AppendForgedCommit(source, ("bad:incoming.txt", "x"));
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync(
- () => _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.
diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
index 0f3df54..a713423 100644
--- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs
+++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
@@ -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(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()
{
diff --git a/gclo.Engine.Tests/WslClonerTests.cs b/gclo.Engine.Tests/WslClonerTests.cs
index 73aac60..ddc75e4 100644
--- a/gclo.Engine.Tests/WslClonerTests.cs
+++ b/gclo.Engine.Tests/WslClonerTests.cs
@@ -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(
+ () => 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(
+ () => 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()
{
diff --git a/gclo.Engine/GitHubRepositoryLister.cs b/gclo.Engine/GitHubRepositoryLister.cs
index 44961c8..ad0a333 100644
--- a/gclo.Engine/GitHubRepositoryLister.cs
+++ b/gclo.Engine/GitHubRepositoryLister.cs
@@ -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
diff --git a/gclo.Engine/IGitClient.cs b/gclo.Engine/IGitClient.cs
index 30b7397..cd00349 100644
--- a/gclo.Engine/IGitClient.cs
+++ b/gclo.Engine/IGitClient.cs
@@ -24,8 +24,19 @@ public interface IGitClient
- /// Fetches from origin and fast-forwards the current branch to its upstream.
- Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken);
+ ///
+ /// Fetches into the repository at and
+ /// fast-forwards the current branch to its upstream. The folder's origin must
+ /// name the same repository as (scheme, credentials and a
+ /// trailing .git 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 .
+ ///
+ /// The clone URL the repository was listed with.
+ /// Local directory of the existing clone.
+ /// Access token used for git transport.
+ /// Cancels the pull.
+ Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken);
diff --git a/gclo.Engine/LibGit2GitClient.cs b/gclo.Engine/LibGit2GitClient.cs
index 2c0401f..851b1b8 100644
--- a/gclo.Engine/LibGit2GitClient.cs
+++ b/gclo.Engine/LibGit2GitClient.cs
@@ -52,6 +52,26 @@ public sealed class LibGit2GitClient : IGitClient
private static readonly JsonSerializerOptions s_recoveryJsonOptions = new() { WriteIndented = true };
+ ///
+ /// 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 ).
+ ///
+ private const string SyncRemoteName = "gclo-sync";
+
+ ///
+ /// 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.
+ ///
+ private const string SyncRemoteRefSpec = "+refs/heads/*:refs/remotes/" + SyncRemoteName + "/*";
+
+ ///
+ /// 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.
+ ///
+ private const string SyncRemoteMarkerKey = "remote." + SyncRemoteName + ".gclo-temporary";
+
///
/// 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
///
- 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/ 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
+ ///
+ /// Fetches the listed into '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.
+ ///
+ ///
+ /// 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).
+ ///
+ 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);
+ }
+ }
+
+
+
+ ///
+ /// Removes the remote 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.
+ ///
+ private static void TryRemoveSyncRemote(Repository repo)
+ {
+ try
+ {
+ if(repo.Network.Remotes[SyncRemoteName] is not null && repo.Config.Get(SyncRemoteMarkerKey)?.Value == true)
+ {
+ repo.Network.Remotes.Remove(SyncRemoteName);
+ }
+ }
+ catch(LibGit2SharpException)
+ {
+ // See the summary: never mask the fetch's own result.
+ }
+ }
+
+
+
+ ///
+ /// A folder's own git configuration must not decide where a sync's fetch goes. Two
+ /// settings could: url.<base>.insteadOf, which rewrites the URL of any
+ /// remote, and (see ) 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).
+ ///
+ private static void RefuseUrlRewritesOfTheFolder(Repository repo)
+ {
+ foreach(ConfigurationEntry 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..insteadOf entry in its .git/config), "
+ + "so a fetch could go somewhere other than the listed repository. Remove that entry, then sync again.");
+ }
+ }
+ }
+
+
+
+ ///
+ /// 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 core.sshCommand in its own
+ /// configuration is refused (#139).
+ ///
+ private static void RefuseSshCommandOfTheFolder(Repository repo, string effectiveUrl)
+ {
+ if(RemoteUrl.IsHttps(effectiveUrl))
+ {
+ return;
+ }
+
+ foreach(ConfigurationEntry 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.");
+ }
+ }
+ }
+
+
+
+ ///
+ /// The folder's origin, once it is known to name the repository listed at
+ /// . 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).
+ ///
+ 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;
+ }
+
+
+
///
/// 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.
///
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 '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.
///
- 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(
diff --git a/gclo.Engine/OrgSyncEngine.cs b/gclo.Engine/OrgSyncEngine.cs
index 63c07c6..eaf5d43 100644
--- a/gclo.Engine/OrgSyncEngine.cs
+++ b/gclo.Engine/OrgSyncEngine.cs
@@ -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
diff --git a/gclo.Engine/RemoteUrl.cs b/gclo.Engine/RemoteUrl.cs
new file mode 100644
index 0000000..d8553bb
--- /dev/null
+++ b/gclo.Engine/RemoteUrl.cs
@@ -0,0 +1,238 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+
+
+namespace gclo.Engine;
+
+
+///
+/// Decides two things about git remote URLs, both from the clone URL a listing returned
+/// (the "expected" URL): whether an existing folder's origin 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).
+///
+internal static class RemoteUrl
+{
+ private static readonly char[] s_separators = ['/', '\\'];
+
+
+
+ ///
+ /// True when (a folder's configured origin) names the same
+ /// repository as (the listed clone URL). Network URLs
+ /// are compared by host and path, ignoring scheme, credentials, letter case, a
+ /// trailing slash and a trailing .git, 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.
+ ///
+ 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);
+ }
+
+
+
+ ///
+ /// True when a credential request from a remote whose URL is
+ /// may be answered with the token of a sync whose listed
+ /// clone URL is : both must be HTTPS and name the same
+ /// host and port. A remote on another host, plain HTTP, and SSH get no token.
+ ///
+ 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;
+ }
+
+
+
+ /// True when is an absolute HTTPS URL with a readable host.
+ public static bool IsHttps(string? url) => TryParseHttps(url, out _, out _);
+
+
+
+ ///
+ /// The host of an HTTPS , 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.
+ ///
+ 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);
+ }
+
+
+
+ ///
+ /// 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 @.
+ ///
+ 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);
+ }
+
+
+
+ ///
+ /// The host in its ASCII (punycode) form. 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.
+ ///
+ 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,
+ }
+
+
+
+ /// Where a git URL points: a host and path, or a local full path.
+ /// Network, local, or not readable as either.
+ /// ASCII host of a network URL; empty otherwise.
+ /// Normalized repository path, or the local full path.
+ /// 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.
+ private readonly record struct Location(LocationKind Kind, string Host, string Path, int? WebPort);
+}
diff --git a/gclo.Engine/WslCloner.cs b/gclo.Engine/WslCloner.cs
index 5443989..311173f 100644
--- a/gclo.Engine/WslCloner.cs
+++ b/gclo.Engine/WslCloner.cs
@@ -20,21 +20,29 @@ public sealed class WslCloner : IWslCloner
public const string TokenVariable = "GCLO_GIT_TOKEN";
///
- /// 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).
///
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(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);
diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs
index a2371dd..278b793 100644
--- a/gclo.ViewModels/WorkspaceViewModel.cs
+++ b/gclo.ViewModels/WorkspaceViewModel.cs
@@ -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;
diff --git a/gclo/UiTestFixture.cs b/gclo/UiTestFixture.cs
index e8ce20f..7cf2388 100644
--- a/gclo/UiTestFixture.cs
+++ b/gclo/UiTestFixture.cs
@@ -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);