#157: use environment git credential helper
CI / contract (pull_request) Successful in 1m3s
CI / build (pull_request) Successful in 1m37s

This commit is contained in:
Dai Ha
2026-08-28 06:02:09 +07:00
parent bcb402b688
commit 46ac6e4e38
2 changed files with 85 additions and 21 deletions
@@ -22,6 +22,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
@@ -66,8 +67,9 @@ public final class GitWorktrees implements Worktrees {
/** What {@link #isolateToolSurface} writes for {@code .autoenv}: a valid, empty env file. */
private static final String NEUTRAL_AUTOENV_CONFIG = "";
private static final String FORGE_HOST = "git.ltms.dev";
private static final int FORGE_SSH_PORT = 2224;
/** A credential helper command which reads only an environment variable at Git call time. */
private static final String ENVIRONMENT_CREDENTIAL_HELPER = "!f() { if [ \"$1\" = get ]; then "
+ "printf 'username=%s\\n\\n' \"${GITEA_TOKEN:-$WORKER_GITEA_TOKEN}\"; fi; }; f";
/**
* A tracked project config that is hostile in a provisioned worktree, and what to replace it
@@ -86,6 +88,7 @@ public final class GitWorktrees implements Worktrees {
);
private final String configuredRoot;
private final Consumer<String> afterWorktreeAdded;
private final SecureRandom random = new SecureRandom();
private final AtomicLong seq = new AtomicLong();
@@ -96,7 +99,13 @@ public final class GitWorktrees implements Worktrees {
/** @param configuredRoot nullable absolute or relative path; null/blank derives a sibling of the repo root. */
public GitWorktrees(String configuredRoot) {
this(configuredRoot, _ -> {});
}
/** Test seam for changing a real worktree between its creation and its security check. */
GitWorktrees(String configuredRoot, Consumer<String> afterWorktreeAdded) {
this.configuredRoot = configuredRoot;
this.afterWorktreeAdded = afterWorktreeAdded == null ? _ -> {} : afterWorktreeAdded;
}
@Override
@@ -112,18 +121,20 @@ public final class GitWorktrees implements Worktrees {
}
String wt = path.toAbsolutePath().toString();
log.info("adding worktree branch={} path={} base={}", branch, wt, base);
convertForgeHttpsOriginToSsh(repoRoot);
removeUserInfoFromHttpsOrigin(repoRoot);
exec("git", "-C", repoRoot, "worktree", "add", wt, "-b", branch, base);
afterWorktreeAdded.accept(wt);
requireCredentialFreeHttpsOrigin(wt);
configureEnvironmentCredentialHelper(repoRoot, wt);
isolateToolSurface(wt);
return wt;
}
/**
* A linked worktree shares its primary checkout's git config. Convert this forge's HTTPS origin
* before adding the worktree, so a credential accidentally embedded in that config cannot reach
* the member. SSH uses the host user's key at push time and stores no forge token in git config.
* A linked worktree shares its primary checkout's git config. Remove HTTPS user info before
* adding one, so a credential accidentally embedded in that config cannot reach the member.
*/
private void convertForgeHttpsOriginToSsh(String repoRoot) {
private void removeUserInfoFromHttpsOrigin(String repoRoot) {
if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) {
return;
}
@@ -134,16 +145,42 @@ public final class GitWorktrees implements Worktrees {
} catch (URISyntaxException e) {
throw new WorktreeException("origin URL is invalid; cannot provision a safe worktree", e);
}
if (!"https".equalsIgnoreCase(uri.getScheme()) || !FORGE_HOST.equalsIgnoreCase(uri.getHost())) {
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() == null) {
return;
}
if (uri.getRawPath() == null || uri.getRawPath().isBlank() || uri.getRawQuery() != null
|| uri.getRawFragment() != null) {
throw new WorktreeException("origin URL cannot be converted to the forge SSH form safely");
int schemeEnd = origin.indexOf("://") + 3;
int userInfoEnd = origin.indexOf('@', schemeEnd);
if (userInfoEnd < schemeEnd) {
throw new WorktreeException("origin URL has invalid HTTPS user info; cannot provision safely");
}
String sshOrigin = "ssh://git@" + FORGE_HOST + ":" + FORGE_SSH_PORT + uri.getRawPath();
exec("git", "-C", repoRoot, "remote", "set-url", "origin", sshOrigin);
log.info("converted forge origin to SSH before provisioning worktree");
String cleanOrigin = origin.substring(0, schemeEnd) + origin.substring(userInfoEnd + 1);
exec("git", "-C", repoRoot, "remote", "set-url", "origin", cleanOrigin);
log.info("removed HTTPS user info from forge origin before provisioning worktree");
}
/** Refuse the worktree if Git still resolves any HTTPS origin URL with embedded credentials. */
private void requireCredentialFreeHttpsOrigin(String worktreePath) {
if (exitCode("git", "-C", worktreePath, "remote", "get-url", "--all", "origin") != 0) {
return;
}
String origins = exec("git", "-C", worktreePath, "remote", "get-url", "--all", "origin");
for (String origin : origins.split("\\R")) {
try {
URI uri = new URI(origin);
if ("https".equalsIgnoreCase(uri.getScheme()) && uri.getUserInfo() != null) {
throw new WorktreeException("worktree origin contains HTTPS user info; refusing provision");
}
} catch (URISyntaxException e) {
throw new WorktreeException("worktree origin URL is invalid; refusing provision", e);
}
}
}
/** Configure a per-worktree helper that supplies a token from the member environment at call time. */
private void configureEnvironmentCredentialHelper(String repoRoot, String worktreePath) {
exec("git", "-C", repoRoot, "config", "extensions.worktreeConfig", "true");
exec("git", "-C", worktreePath, "config", "--worktree", "credential.helper",
ENVIRONMENT_CREDENTIAL_HELPER);
}
/**
@@ -55,12 +55,17 @@ class GitWorktreesTest {
}
private static void git(Path cwd, String... args) throws Exception {
gitOutput(cwd, args);
}
private static String gitOutput(Path cwd, String... args) throws Exception {
List<String> cmd = new java.util.ArrayList<>(List.of("git"));
cmd.addAll(List.of(args));
Process p = new ProcessBuilder(cmd).directory(cwd.toFile()).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes());
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git timed out: " + String.join(" ", cmd));
assertEquals(0, p.exitValue(), "git " + String.join(" ", args) + " failed:\n" + out);
return out;
}
/** Pending changes to {@code file} in {@code cwd}, empty when git considers it unmodified. */
@@ -210,20 +215,42 @@ class GitWorktreesTest {
* reads after {@link GitWorktrees#add}, rather than checking only a URL formatting helper.
*/
@Test
void aProvisionedWorktreeUsesTheForgeSshOrigin(@TempDir Path tmp) throws Exception {
void aProvisionedWorktreeUsesACleanHttpsOrigin(@TempDir Path tmp) throws Exception {
Path repo = initRepo(tmp.resolve("repo"));
git(repo, "remote", "add", "origin", "https://synthetic-test-token@git.ltms.dev/akb/kb.git");
String wt = new GitWorktrees(tmp.resolve("wts").toString())
.add(repo.toString(), "fleetd-157-safe-origin", "HEAD");
Process p = new ProcessBuilder("git", "-C", wt, "config", "--get", "remote.origin.url")
.redirectErrorStream(true).start();
String origin = new String(p.getInputStream().readAllBytes()).trim();
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git config timed out");
assertEquals(0, p.exitValue(), "git config failed");
assertEquals("ssh://git@git.ltms.dev:2224/akb/kb.git", origin);
Path worktree = Path.of(wt);
String origin = gitOutput(worktree, "config", "--get", "remote.origin.url").trim();
assertEquals("https://git.ltms.dev/akb/kb.git", origin);
assertFalse(origin.contains("synthetic-test-token"), "provisioned worktree kept user info");
assertFalse(gitOutput(worktree, "remote", "-v").contains("synthetic-test-token"),
"git remote -v exposed user info");
assertFalse(gitOutput(worktree, "config", "--list").contains("synthetic-test-token"),
"git config --list exposed user info");
String helper = gitOutput(worktree, "config", "--worktree", "--get", "credential.helper");
assertTrue(helper.contains("GITEA_TOKEN"), "credential helper does not read the member environment");
assertFalse(helper.contains("synthetic-test-token"), "credential helper stored user info");
}
@Test
void provisioningRefusesAWorktreeWhoseOriginStillHasHttpsUserInfo(@TempDir Path tmp) throws Exception {
Path repo = initRepo(tmp.resolve("repo"));
git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git");
GitWorktrees worktrees = new GitWorktrees(tmp.resolve("wts").toString(), worktreePath -> {
try {
git(Path.of(worktreePath), "remote", "set-url", "origin",
"https://synthetic-test-token@git.ltms.dev/akb/kb.git");
} catch (Exception e) {
throw new RuntimeException(e);
}
});
WorktreeException error = assertThrows(WorktreeException.class,
() -> worktrees.add(repo.toString(), "fleetd-157-refuse-origin", "HEAD"));
assertEquals("worktree origin contains HTTPS user info; refusing provision", error.getMessage());
}
/** Neutralizing must not look like work in progress, or a worker would commit it into its PR. */