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
Owner

What

A pull now takes the clone URL the repository was listed with, and only pulls a folder that is a clone of that repository.

  • IGitClient.FetchAndPullAsync(url, path, token, ct): a folder whose origin names a different repository fails its row before any request, with a message that names both (credentials stored in an origin are never shown).
  • The fetch always uses the listed URL. When the origin names the same repository in another spelling (SSH, plain HTTP, embedded credentials), the listed URL is fetched through a remote that exists only for that call; origin is never rewritten. Tags and pruning behave as before.
  • The git credential callback answers only HTTPS requests for the listed URL's host and port.
  • WslCloner: organization and repository validated as single names, HTTPS URL required and passed after --, credential helper scoped to the URL's host, an existing destination pulled only when its origin is the listed URL.
  • The repository lister refuses a name that is not one plain name before making a request.
  • New internal RemoteUrl holds the two decisions and is covered by table tests.

Why

Closes #139.

A folder with the right name is not proof of the right repository. A same-named clone of a fork, of another owner's repository, or of another host was reported Updated although the listed repository was never synced, and the transport was whatever that folder's configuration said.

Behaviour changes worth a release note

  • A same-named folder that is a clone of something else is now Failed with an explanatory message, where it used to be reported Updated. Nothing in the folder is changed.
  • A folder whose origin was switched to SSH or HTTP is now fetched over the listed HTTPS URL with the token.
  • IGitClient.FetchAndPullAsync gained a parameter (breaking for an implementer of the interface; gclo.Engine has no package consumers, ADR-0007).

How it was tested

  • gclo.Engine.Tests: 567 passed (was 480). New: RemoteUrlTests (same-repository and credential tables, redaction), OriginVerificationTests (different origin fails without fetching; differently spelled origin pulls from the listed URL, keeps origin untouched, still follows tags and prunes; a remote left by a killed run is replaced; message never shows stored credentials), lister name refusal, WSL argument and script checks, the view model passing the loaded organization, the engine passing the listed URL.
  • gclo.Cli.Tests: 142 passed. Line coverage 100% on gclo.Engine, gclo.ViewModels, gclo.
  • Zero-warning x64 build with locked restore; dotnet format --verify-no-changes clean.
  • WSL script: sh -n syntax check, and a run under Git Bash showing an existing destination with a different origin is refused before any pull.

Not run: the UI suite (it drives the desktop; the shell change is one pass-through signature). The credential callback itself cannot be reached offline, since local-path fixtures never ask for credentials; its decision logic is the covered RemoteUrl.MayReceiveCredentials. No end-to-end run against a real WSL distribution or a real HTTPS remote.

Checklist

  • Commits are signed and signed off (git commit -S -s)
  • Zero build warnings; format and lint are clean
  • Tests added or updated; the 100% coverage gate still passes
  • Docs updated for user-visible changes; threat model updated if the attack surface changed (README "How it works"; threat model T14 added, T4/T8/T9 updated)
  • No secrets, credentials, or personal data
  • New dependencies are justified below, or none were added (none)
  • Substantial AI assistance, if any, is noted here: written with Claude Code (Claude Fable 5.2)

🤖 Generated with Claude Code

## What A pull now takes the clone URL the repository was listed with, and only pulls a folder that is a clone of that repository. - `IGitClient.FetchAndPullAsync(url, path, token, ct)`: a folder whose `origin` names a different repository fails its row before any request, with a message that names both (credentials stored in an origin are never shown). - The fetch always uses the listed URL. When the origin names the same repository in another spelling (SSH, plain HTTP, embedded credentials), the listed URL is fetched through a remote that exists only for that call; `origin` is never rewritten. Tags and pruning behave as before. - The git credential callback answers only HTTPS requests for the listed URL's host and port. - `WslCloner`: organization and repository validated as single names, HTTPS URL required and passed after `--`, credential helper scoped to the URL's host, an existing destination pulled only when its origin is the listed URL. - The repository lister refuses a name that is not one plain name before making a request. - New internal `RemoteUrl` holds the two decisions and is covered by table tests. ## Why Closes #139. A folder with the right name is not proof of the right repository. A same-named clone of a fork, of another owner's repository, or of another host was reported **Updated** although the listed repository was never synced, and the transport was whatever that folder's configuration said. ## Behaviour changes worth a release note - A same-named folder that is a clone of something else is now **Failed** with an explanatory message, where it used to be reported Updated. Nothing in the folder is changed. - A folder whose origin was switched to SSH or HTTP is now fetched over the listed HTTPS URL with the token. - `IGitClient.FetchAndPullAsync` gained a parameter (breaking for an implementer of the interface; `gclo.Engine` has no package consumers, ADR-0007). ## How it was tested - `gclo.Engine.Tests`: 567 passed (was 480). New: `RemoteUrlTests` (same-repository and credential tables, redaction), `OriginVerificationTests` (different origin fails without fetching; differently spelled origin pulls from the listed URL, keeps origin untouched, still follows tags and prunes; a remote left by a killed run is replaced; message never shows stored credentials), lister name refusal, WSL argument and script checks, the view model passing the loaded organization, the engine passing the listed URL. - `gclo.Cli.Tests`: 142 passed. Line coverage 100% on `gclo.Engine`, `gclo.ViewModels`, `gclo`. - Zero-warning x64 build with locked restore; `dotnet format --verify-no-changes` clean. - WSL script: `sh -n` syntax check, and a run under Git Bash showing an existing destination with a different origin is refused before any pull. Not run: the UI suite (it drives the desktop; the shell change is one pass-through signature). The credential callback itself cannot be reached offline, since local-path fixtures never ask for credentials; its decision logic is the covered `RemoteUrl.MayReceiveCredentials`. No end-to-end run against a real WSL distribution or a real HTTPS remote. ## Checklist - [x] Commits are signed and signed off (`git commit -S -s`) - [x] Zero build warnings; format and lint are clean - [x] Tests added or updated; the 100% coverage gate still passes - [x] Docs updated for user-visible changes; threat model updated if the attack surface changed (README "How it works"; threat model T14 added, T4/T8/T9 updated) - [x] No secrets, credentials, or personal data - [x] New dependencies are justified below, or none were added (none) - [x] Substantial AI assistance, if any, is noted here: written with Claude Code (Claude Fable 5.2) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 1 commit 2026-11-10 18:29:32 +00:00
A folder with a repository's name used to be pulled through whatever
origin it had. A same-named clone of a fork, of another owner's
repository or of another host was reported Updated although the listed
repository was never synced, and the git transport was whatever that
folder's configuration said.

- IGitClient.FetchAndPullAsync takes the clone URL the repository was
  listed with. A folder whose origin names a different repository fails
  its row before any request; the message names both, without any
  credentials stored in the origin.
- The fetch always uses the listed URL. An origin that names the same
  repository in another spelling (SSH, plain HTTP, embedded credentials)
  is fetched through a remote that exists only for that call; origin is
  never rewritten, and tags and pruning behave as before.
- The git credential callback answers only HTTPS requests for the listed
  URL's host and port. The default-branch retarget gets a provider of its
  own instead of sharing the fetch's replay counter.
- WslCloner validates the organization and repository as single names,
  requires an HTTPS URL passed after "--", scopes the credential helper
  to the URL's host, and pulls an existing destination only when its
  origin is the listed URL. The view model passes the organization the
  table was loaded for.
- The repository lister refuses a name that is not one plain name before
  making a request.

New internal RemoteUrl holds the two decisions (same repository, may
receive credentials) and is covered by table tests. Threat model: T14
added, T4, T8 and T9 updated.

Closes #139

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
james.maes added 1 commit 2026-11-10 18:54:54 +00:00
Follow-up to the independent review of this branch.

- A url.<base>.insteadOf entry in the folder's own configuration redirected
  the fetch of the listed URL to another repository. It is now refused
  before anything is fetched, and so is the folder's core.sshCommand when
  the fetch is not HTTPS. The user's global configuration still applies.
- The temporary remote is marked as gclo's; a remote of that name the user
  created is never removed, and cleanup can no longer mask the fetch's own
  error. A marked leftover is removed on any later pull. The temporary
  fetch follows origin's tag option.
- The mismatch message gives the git remote set-url command for a
  repository that was renamed or moved.
- RemoteUrl: redaction rebuilds the URL from scheme, host, port and path,
  so no shape leaves user information, query or fragment behind; a
  non-default web port distinguishes two servers; an unreadable host is
  nobody's instead of an exception; IPv6 hosts keep their brackets.
- The WSL script compares the stored origin URL, not the rewritten one.
- The credential callback is tested directly. The threat model and README
  no longer claim more than the code does about redirects.

Refs #139

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
Author
Owner

Independent review of the first commit (a second reviewer session, read-only) found one blocker and several smaller defects; all are addressed in 510d3e0.

  • Blocker, fixed: a url.<base>.insteadOf entry in the folder's own .git/config redirected the fetch of the listed URL to another repository. Refused now, before anything is fetched; the folder's core.sshCommand is refused too when the fetch is not HTTPS. The user's global git configuration still applies.
  • Fixed: cleanup of the temporary remote could mask the fetch's own error and could delete a remote named gclo-sync that the user created. The remote is now marked, only marked remotes are removed, cleanup is best effort.
  • Fixed: redaction left part of a password for some URL shapes; it now rebuilds the URL from scheme, host, port and path.
  • Fixed: the WSL script compared the rewritten origin (remote get-url) and would refuse a destination gclo itself cloned under a user-level rewrite; it compares the stored URL.
  • Fixed: a renamed or transferred repository fails with a message that gives the git remote set-url command.
  • Wording corrected: the credential check is about the remote's URL; a redirect issued by the listed host is followed by libgit2. The threat model and README no longer claim otherwise.
  • Tests added: the credential callback is tested directly; the differently-spelled-origin test now asserts which URL was contacted (FETCH_HEAD).

Known and accepted: an SSH origin on ssh.github.com:443, a www. host, or an SSH origin against a sub-path install does not match its HTTPS listing and fails with the set-url hint.

Gates after the second commit: 606 engine tests, 142 CLI tests, 100% line coverage, zero-warning locked build, format clean.

Independent review of the first commit (a second reviewer session, read-only) found one blocker and several smaller defects; all are addressed in 510d3e0. - **Blocker, fixed:** a `url.<base>.insteadOf` entry in the folder's own `.git/config` redirected the fetch of the listed URL to another repository. Refused now, before anything is fetched; the folder's `core.sshCommand` is refused too when the fetch is not HTTPS. The user's global git configuration still applies. - **Fixed:** cleanup of the temporary remote could mask the fetch's own error and could delete a remote named `gclo-sync` that the user created. The remote is now marked, only marked remotes are removed, cleanup is best effort. - **Fixed:** redaction left part of a password for some URL shapes; it now rebuilds the URL from scheme, host, port and path. - **Fixed:** the WSL script compared the rewritten origin (`remote get-url`) and would refuse a destination gclo itself cloned under a user-level rewrite; it compares the stored URL. - **Fixed:** a renamed or transferred repository fails with a message that gives the `git remote set-url` command. - **Wording corrected:** the credential check is about the remote's URL; a redirect issued by the listed host is followed by libgit2. The threat model and README no longer claim otherwise. - **Tests added:** the credential callback is tested directly; the differently-spelled-origin test now asserts which URL was contacted (FETCH_HEAD). Known and accepted: an SSH origin on `ssh.github.com:443`, a `www.` host, or an SSH origin against a sub-path install does not match its HTTPS listing and fails with the set-url hint. Gates after the second commit: 606 engine tests, 142 CLI tests, 100% line coverage, zero-warning locked build, format clean.
james.maes merged commit dc8f0d3b5c into main 2026-11-10 18:55:23 +00:00
Sign in to join this conversation.