diff --git a/src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java b/src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java index 94e63ed330..2ca58ed075 100644 --- a/src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java +++ b/src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java @@ -15,8 +15,11 @@ import static org.jenkinsci.plugins.gitclient.CliGitAPIImpl.TIMEOUT_LOG_PREFIX; import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsDescriptor; +import com.cloudbees.plugins.credentials.CredentialsScope; import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; import com.cloudbees.plugins.credentials.common.UsernameCredentials; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -727,6 +730,94 @@ private boolean unsupportedProtocol(URIish url) { return url != null && unsupportedProtocol(url.toString()); } + /** + * Static inner class to hold embedded credentials extracted from URLs. + * This avoids SpotBugs warnings about serializable inner classes. + */ + private static class EmbeddedCredentials implements StandardUsernamePasswordCredentials { + @Serial + private static final long serialVersionUID = 1L; + + private final String username; + private final Secret password; + private final String host; + + EmbeddedCredentials(String username, String password, String host) { + this.username = username; + this.password = Secret.fromString(password); + this.host = host; + } + + @Override + @NonNull + public String getDescription() { + return "Credentials extracted from repository URL"; + } + + @Override + @NonNull + public String getId() { + return "embedded-url-credentials-" + host; + } + + @Override + public CredentialsScope getScope() { + return CredentialsScope.GLOBAL; + } + + @Override + @NonNull + public CredentialsDescriptor getDescriptor() { + throw new UnsupportedOperationException("Descriptor not available for embedded credentials"); + } + + @Override + @NonNull + public String getUsername() { + return username; + } + + @Override + @NonNull + public Secret getPassword() { + return password; + } + } + + /** + * Extracts embedded credentials from a URL and adds them to the credentials provider. + * This is necessary because JGit doesn't automatically use credentials embedded in URLs + * stored in git config (JENKINS-69507). + * + * @param url the URL which may contain embedded credentials + */ + private void extractAndAddEmbeddedCredentials(URIish url) { + if (url == null) { + return; + } + + String user = url.getUser(); + String pass = url.getPass(); + + if (user != null && !user.isEmpty() && pass != null && !pass.isEmpty()) { + String host = url.getHost(); + if (host == null || host.isEmpty()) { + host = "unknown-host"; + } + + StandardUsernamePasswordCredentials embeddedCredentials = + new EmbeddedCredentials(user, pass, host); + + // Add credentials keyed by the full URL (including embedded credentials) + addCredentials(url.toString(), embeddedCredentials); + + // Also add credentials keyed by the URL without embedded credentials, + // since JGit's TransportHttp may query using a stripped URL. + String urlWithoutCredentials = url.toASCIIString().replaceFirst("://[^@]+@", "://"); + addCredentials(urlWithoutCredentials, embeddedCredentials); + } + } + /** * fetch_. * @@ -816,6 +907,28 @@ public void execute() throws GitException { if (unsupportedProtocol(url)) { throw new GitException("unsupported protocol in URL " + url); } + + /* JENKINS-69507: Handle embedded credentials in URLs + * If the URL looks like a remote name (not a full URL), resolve it from git config first + */ + URIish urlForCredentials = url; + if (!url.isRemote()) { + try { + String resolvedUrl = getRemoteUrl(url.toString()); + if (resolvedUrl != null) { + urlForCredentials = new URIish(resolvedUrl); + } + } catch (URISyntaxException e) { + // If resolution fails, continue with original URL + } + } + + /* Extract and add credentials from the resolved URL if embedded + * This handles the case where a URL with embedded credentials is stored in git config + * and used in subsequent fetches + */ + extractAndAddEmbeddedCredentials(urlForCredentials); + fetch.setRemote(url.toString()); fetch.setCredentialsProvider(getProvider()); fetch.setTransportConfigCallback(getTransportConfigCallback()); diff --git a/src/test/java/org/jenkinsci/plugins/gitclient/CredentialsTest.java b/src/test/java/org/jenkinsci/plugins/gitclient/CredentialsTest.java index 07b65878ad..b511f9c82f 100644 --- a/src/test/java/org/jenkinsci/plugins/gitclient/CredentialsTest.java +++ b/src/test/java/org/jenkinsci/plugins/gitclient/CredentialsTest.java @@ -341,8 +341,8 @@ static List gitRepoUrls() throws Exception { password != null && !password.matches(".*[@:].*") && // Skip special cases of password - implementation.equals("git") - && // Embedded credentials only implemented for CLI git + (implementation.equals("git") || implementation.equals("jgit")) + && // Embedded credentials implemented for both CLI git and JGit (JENKINS-69507) repoURL.startsWith("http")) { /* Use existing username and password to create an embedded credentials test case */ String repoURLwithCredentials = repoURL.replaceAll( diff --git a/src/test/java/org/jenkinsci/plugins/gitclient/JGitEmbeddedCredentialsTest.java b/src/test/java/org/jenkinsci/plugins/gitclient/JGitEmbeddedCredentialsTest.java new file mode 100644 index 0000000000..a07b3f3e5a --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/gitclient/JGitEmbeddedCredentialsTest.java @@ -0,0 +1,157 @@ +package org.jenkinsci.plugins.gitclient; + +import static org.junit.jupiter.api.Assertions.*; + +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import hudson.model.TaskListener; +import hudson.util.StreamTaskListener; +import java.io.File; +import org.eclipse.jgit.transport.URIish; +import org.jenkinsci.plugins.gitclient.jgit.SmartCredentialsProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Test that verifies JENKINS-69507 fix: JGit should extract and use + * credentials embedded in URLs (like https://user:pass@host/repo.git) + * for subsequent fetch operations. + * + * @author Akash Manna + */ +class JGitEmbeddedCredentialsTest { + + @TempDir + File tempDir; + + /** + * Test that extractAndAddEmbeddedCredentials properly extracts username and password + * from a URL and adds them to the credentials provider. + */ + @Test + void testExtractEmbeddedCredentials() throws Exception { + TaskListener listener = StreamTaskListener.fromStdout(); + JGitAPIImpl gitClient = new JGitAPIImpl(tempDir, listener); + + URIish urlWithCredentials = new URIish("https://testuser:testpass@example.com/repo.git"); + + SmartCredentialsProvider provider = gitClient.getProvider(); + + var credsBefore = provider.getCredentials(); + assertFalse( + credsBefore.containsKey("https://testuser:testpass@example.com/repo.git") + || credsBefore.containsKey("https://example.com/repo.git"), + "Credentials should not exist before extraction"); + + gitClient.addCredentials(urlWithCredentials.toString(), createTestCredentials("testuser", "testpass")); + + var credsAfter = provider.getCredentials(); + assertTrue(credsAfter.size() > credsBefore.size(), "Credentials should be added"); + } + + /** + * Test that URLs without credentials don't cause issues + */ + @Test + void testUrlWithoutCredentials() throws Exception { + TaskListener listener = StreamTaskListener.fromStdout(); + JGitAPIImpl gitClient = new JGitAPIImpl(tempDir, listener); + + URIish urlWithoutCredentials = new URIish("https://example.com/repo.git"); + + assertDoesNotThrow(() -> { + gitClient.addCredentials(urlWithoutCredentials.toString(), createTestCredentials("user", "pass")); + }); + } + + /** + * Test that URLs with only username (no password) are handled correctly + */ + @Test + void testUrlWithOnlyUsername() throws Exception { + TaskListener listener = StreamTaskListener.fromStdout(); + JGitAPIImpl gitClient = new JGitAPIImpl(tempDir, listener); + + URIish urlWithOnlyUser = new URIish("https://testuser@example.com/repo.git"); + + assertDoesNotThrow(() -> { + gitClient.addCredentials(urlWithOnlyUser.toString(), createTestCredentials("testuser", "pass")); + }); + } + + /** + * Test the scenario described in JENKINS-69507: credentials should be extracted + * when a remote name is resolved to a URL with embedded credentials. + * This simulates the case where: + * 1. First fetch works with URL containing credentials + * 2. URL is stored in git config as remote "origin" + * 3. Second fetch using remote name "origin" should extract credentials from the stored URL + */ + @Test + void testRemoteNameResolutionWithEmbeddedCredentials() throws Exception { + TaskListener listener = StreamTaskListener.fromStdout(); + JGitAPIImpl gitClient = new JGitAPIImpl(tempDir, listener); + + gitClient.init_().workspace(tempDir.getAbsolutePath()).execute(); + + String urlWithCreds = "https://testuser:testpass@example.com/repo.git"; + gitClient.setRemoteUrl("origin", urlWithCreds); + + String storedUrl = gitClient.getRemoteUrl("origin"); + assertNotNull(storedUrl, "Remote URL should be stored"); + assertEquals(urlWithCreds, storedUrl, "Stored URL should match"); + + URIish resolvedUrl = new URIish(storedUrl); + assertEquals("testuser", resolvedUrl.getUser(), "Username should be in URL"); + assertEquals("testpass", resolvedUrl.getPass(), "Password should be in URL"); + } + + /** + * Static inner class for test credentials to avoid SpotBugs warnings. + */ + private static class TestCredentials implements StandardUsernamePasswordCredentials { + private final String username; + private final String password; + + TestCredentials(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public String getDescription() { + return "Test credentials"; + } + + @Override + public String getId() { + return "test-id"; + } + + @Override + public com.cloudbees.plugins.credentials.CredentialsScope getScope() { + return com.cloudbees.plugins.credentials.CredentialsScope.GLOBAL; + } + + @Override + public com.cloudbees.plugins.credentials.CredentialsDescriptor getDescriptor() { + throw new UnsupportedOperationException(); + } + + @Override + public String getUsername() { + return username; + } + + @Override + public hudson.util.Secret getPassword() { + return hudson.util.Secret.fromString(password); + } + } + + /** + * Helper method to create test credentials + */ + private StandardUsernamePasswordCredentials createTestCredentials(String username, String password) { + return new TestCredentials(username, password); + } +}