Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -727,6 +730,83 @@ 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");
Comment on lines +768 to +771
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getDescriptor() method throws UnsupportedOperationException. While this is unlikely to be called for embedded credentials, if any code in the Jenkins ecosystem attempts to retrieve the descriptor (e.g., for serialization or UI purposes), it will fail. Consider implementing a minimal descriptor or documenting why this is safe to throw an exception here.

Suggested change
@Override
@NonNull
public CredentialsDescriptor getDescriptor() {
throw new UnsupportedOperationException("Descriptor not available for embedded credentials");
private static final CredentialsDescriptor DESCRIPTOR = new CredentialsDescriptor() {
@Override
public String getDisplayName() {
return "Embedded URL credentials";
}
};
@Override
@NonNull
public CredentialsDescriptor getDescriptor() {
return DESCRIPTOR;

Copilot uses AI. Check for mistakes.
}

@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()) {
StandardUsernamePasswordCredentials embeddedCredentials =
new EmbeddedCredentials(user, pass, url.getHost());
Comment on lines +803 to +804
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EmbeddedCredentials constructor receives url.getHost() which could potentially be null for certain URL formats (e.g., file URLs or malformed URLs). While the method extractAndAddEmbeddedCredentials checks for null URL and valid user/pass, it doesn't validate that url.getHost() is non-null before creating EmbeddedCredentials. Consider adding a null check for the host or handling this case gracefully.

Suggested change
StandardUsernamePasswordCredentials embeddedCredentials =
new EmbeddedCredentials(user, pass, url.getHost());
String host = url.getHost();
if (host == null || host.isEmpty()) {
host = "unknown-host";
}
StandardUsernamePasswordCredentials embeddedCredentials =
new EmbeddedCredentials(user, pass, host);

Copilot uses AI. Check for mistakes.

addCredentials(url.toString(), embeddedCredentials);
Comment on lines +805 to +806
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When adding credentials on line 806, the URL key includes the embedded credentials (e.g., "https://user:pass@host/repo.git"). However, JGit's TransportHttp may query the credentials provider with a URL that has the credentials stripped out (e.g., "https://host/repo.git"). While the SmartCredentialsProvider has fallback logic to match by scheme/host/port (lines 132-149 in SmartCredentialsProvider.java), it would be more robust and efficient to also add the credentials using a URL without the embedded credentials as an additional key. This would avoid reliance on the fallback matching logic and improve performance.

Suggested change
addCredentials(url.toString(), embeddedCredentials);
// 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.
try {
URIish urlWithoutCredentials = new URIish(url).setUser(null).setPass(null);
addCredentials(urlWithoutCredentials.toString(), embeddedCredentials);
} catch (URISyntaxException e) {
// Fall back to the original key only if stripping credentials fails
LOGGER.log(Level.FINE, "Failed to create URI without embedded credentials for " + url, e);
}

Copilot uses AI. Check for mistakes.
}
}

/**
* fetch_.
*
Expand Down Expand Up @@ -816,6 +896,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() && !org.apache.commons.lang3.StringUtils.containsAny(url.toString(), ":@/\\")) {
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition on line 904 appears to have redundant or confusing logic. The check !url.isRemote() already determines if the URL is a remote name vs a full URL. The additional check !StringUtils.containsAny(url.toString(), ":@/\\") may be unnecessary since a remote name that passes !url.isRemote() would typically not contain these characters anyway. Consider simplifying this to just !url.isRemote() or add a comment explaining why both checks are needed.

Suggested change
if (!url.isRemote() && !org.apache.commons.lang3.StringUtils.containsAny(url.toString(), ":@/\\")) {
if (!url.isRemote()) {

Copilot uses AI. Check for mistakes.
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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,8 @@ static List<Arguments> 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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:[email protected]/repo.git");

SmartCredentialsProvider provider = gitClient.getProvider();

var credsBefore = provider.getCredentials();
assertFalse(
credsBefore.containsKey("https://testuser:[email protected]/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");
}
Comment on lines +26 to +49
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test manually calls addCredentials but doesn't verify that the extractAndAddEmbeddedCredentials method works correctly. The test description mentions "extractAndAddEmbeddedCredentials properly extracts username and password" but the method being tested is actually just the public addCredentials method. Since extractAndAddEmbeddedCredentials is private and called internally during fetch operations, this test doesn't actually validate the fix for JENKINS-69507. Consider renaming the test to better reflect what it's testing or adding integration tests that exercise the fetch operation.

Copilot uses AI. Check for mistakes.

/**
* 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://[email protected]/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:[email protected]/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");
}
Comment on lines +89 to +106
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test doesn't actually verify that the extractAndAddEmbeddedCredentials method is called or that credentials are properly extracted during a fetch operation. The test only verifies that a URL with embedded credentials can be stored in git config and retrieved. Consider adding a test that actually performs a fetch operation (or mocks it) to verify that credentials are extracted when resolving a remote name to a URL with embedded credentials. This would better align with the actual bug fix for JENKINS-69507.

Copilot uses AI. Check for mistakes.

/**
* 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);
}
}
Loading