diff --git a/src/main/java/org/codehaus/plexus/components/secdispatcher/internal/sources/GitCredentialHelperMasterSource.java b/src/main/java/org/codehaus/plexus/components/secdispatcher/internal/sources/GitCredentialHelperMasterSource.java
new file mode 100644
index 0000000..2326ea8
--- /dev/null
+++ b/src/main/java/org/codehaus/plexus/components/secdispatcher/internal/sources/GitCredentialHelperMasterSource.java
@@ -0,0 +1,267 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.codehaus.plexus.components.secdispatcher.internal.sources;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import org.codehaus.plexus.components.secdispatcher.MasterSourceMeta;
+import org.codehaus.plexus.components.secdispatcher.SecDispatcher;
+import org.codehaus.plexus.components.secdispatcher.SecDispatcherException;
+
+/**
+ * Password source that uses Git Credential Helpers.
+ *
+ * Git credential helpers have a common interface for retrieving credentials.
+ * This master source allows using any git credential helper to retrieve passwords.
+ *
+ * Config: {@code git-credential:helper-name?url=protocol://host/path}
+ *
+ * Examples:
+ *
+ * - {@code git-credential:cache?url=https://maven.apache.org/master}
+ * - {@code git-credential:store?url=https://maven.apache.org/master}
+ * - {@code git-credential:/usr/local/bin/git-credential-osxkeychain?url=https://maven.apache.org/master}
+ *
+ *
+ * @see Git Credentials
+ * @see Git Credential Helpers
+ */
+@Singleton
+@Named(GitCredentialHelperMasterSource.NAME)
+public final class GitCredentialHelperMasterSource extends PrefixMasterSourceSupport implements MasterSourceMeta {
+ public static final String NAME = "git-credential";
+
+ public GitCredentialHelperMasterSource() {
+ super(NAME + ":");
+ }
+
+ @Override
+ public String description() {
+ return "Git Credential Helper (helper name and URL should be edited)";
+ }
+
+ @Override
+ public Optional configTemplate() {
+ return Optional.of(NAME + ":helper-name?url=protocol://host/path");
+ }
+
+ @Override
+ protected String doHandle(String transformed) throws SecDispatcherException {
+ String helperName;
+ String url;
+
+ // Parse configuration: helper-name?url=protocol://host/path
+ int queryIndex = transformed.indexOf('?');
+ if (queryIndex < 0) {
+ throw new SecDispatcherException(
+ "Invalid git-credential configuration. Expected format: git-credential:helper-name?url=protocol://host/path");
+ }
+
+ helperName = transformed.substring(0, queryIndex);
+ String query = transformed.substring(queryIndex + 1);
+
+ if (!query.startsWith("url=")) {
+ throw new SecDispatcherException(
+ "Invalid git-credential configuration. Expected URL parameter: url=protocol://host/path");
+ }
+
+ url = query.substring(4);
+
+ try {
+ return retrievePassword(helperName, url);
+ } catch (IOException | InterruptedException e) {
+ throw new SecDispatcherException(
+ String.format(
+ "Failed to retrieve password from git credential helper '%s': %s",
+ helperName, e.getMessage()),
+ e);
+ }
+ }
+
+ @Override
+ protected SecDispatcher.ValidationResponse doValidateConfiguration(String transformed) {
+ HashMap> report = new HashMap<>();
+ boolean isValid = false;
+
+ try {
+ int queryIndex = transformed.indexOf('?');
+ if (queryIndex < 0) {
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
+ .add(
+ "Invalid configuration format. Expected: git-credential:helper-name?url=protocol://host/path");
+ return new SecDispatcher.ValidationResponse(getClass().getSimpleName(), false, report, List.of());
+ }
+
+ String helperName = transformed.substring(0, queryIndex);
+ String query = transformed.substring(queryIndex + 1);
+
+ if (!query.startsWith("url=")) {
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
+ .add("Invalid configuration. Expected URL parameter: url=protocol://host/path");
+ return new SecDispatcher.ValidationResponse(getClass().getSimpleName(), false, report, List.of());
+ }
+
+ String url = query.substring(4);
+
+ // Validate URL format
+ try {
+ new URI(url);
+ } catch (URISyntaxException e) {
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
+ .add(String.format("Invalid URL format: %s", e.getMessage()));
+ return new SecDispatcher.ValidationResponse(getClass().getSimpleName(), false, report, List.of());
+ }
+
+ // Try to execute the helper to see if it's available
+ String helperCommand = buildHelperCommand(helperName);
+ try {
+ Process process = new ProcessBuilder(helperCommand, "get").start();
+ // Close stdin to prevent the helper from waiting for input
+ process.getOutputStream().close();
+
+ if (!process.waitFor(2, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.WARNING, k -> new ArrayList<>())
+ .add(String.format(
+ "Git credential helper '%s' did not respond in time. It may still work.",
+ helperName));
+ isValid = true; // Still consider it valid, just warn
+ } else if (process.exitValue() == 127 || process.exitValue() == 126) {
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
+ .add(String.format("Git credential helper '%s' not found or not executable", helperName));
+ } else {
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.INFO, k -> new ArrayList<>())
+ .add(String.format("Git credential helper '%s' is available", helperName));
+ isValid = true;
+ }
+ } catch (IOException e) {
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
+ .add(String.format(
+ "Failed to execute git credential helper '%s': %s", helperName, e.getMessage()));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
+ .add("Validation was interrupted");
+ }
+ } catch (Exception e) {
+ report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
+ .add(String.format("Validation error: %s", e.getMessage()));
+ }
+
+ return new SecDispatcher.ValidationResponse(getClass().getSimpleName(), isValid, report, List.of());
+ }
+
+ private String retrievePassword(String helperName, String url) throws IOException, InterruptedException {
+ String helperCommand = buildHelperCommand(helperName);
+
+ ProcessBuilder pb = new ProcessBuilder(helperCommand, "get");
+ Process process = pb.start();
+
+ // Write credential request to helper's stdin
+ try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(process.getOutputStream()))) {
+ URI uri = new URI(url);
+ if (uri.getScheme() != null) {
+ writer.println("protocol=" + uri.getScheme());
+ }
+ if (uri.getHost() != null && !uri.getHost().isEmpty()) {
+ if (uri.getPort() != -1) {
+ writer.println("host=" + uri.getHost() + ":" + uri.getPort());
+ } else {
+ writer.println("host=" + uri.getHost());
+ }
+ }
+ if (uri.getPath() != null && !uri.getPath().isEmpty()) {
+ writer.println("path=" + uri.getPath());
+ }
+ writer.println(); // Blank line signals end of input
+ writer.flush();
+ } catch (URISyntaxException e) {
+ throw new IOException("Invalid URL format: " + e.getMessage(), e);
+ }
+
+ // Read response from helper's stdout
+ String password = null;
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.startsWith("password=")) {
+ password = line.substring(9);
+ break;
+ }
+ }
+ }
+
+ if (!process.waitFor(30, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ throw new IOException("Git credential helper timed out");
+ }
+
+ int exitCode = process.exitValue();
+ if (exitCode != 0) {
+ String errorOutput = readProcessError(process);
+ throw new IOException(
+ String.format("Git credential helper exited with code %d. Error: %s", exitCode, errorOutput));
+ }
+
+ if (password == null || password.isEmpty()) {
+ throw new IOException("Git credential helper did not return a password");
+ }
+
+ return password;
+ }
+
+ static String buildHelperCommand(String helperName) {
+ // If helper name contains a path separator, use it as-is (absolute or relative path)
+ // Otherwise, prefix with "git-credential-"
+ if (helperName.contains("/") || helperName.contains("\\")) {
+ return helperName;
+ }
+ return "git-credential-" + helperName;
+ }
+
+ private String readProcessError(Process process) {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (sb.length() > 0) {
+ sb.append("; ");
+ }
+ sb.append(line);
+ }
+ return sb.toString();
+ } catch (IOException e) {
+ return "(failed to read error output)";
+ }
+ }
+}
diff --git a/src/test/java/org/codehaus/plexus/components/secdispatcher/internal/sources/GitCredentialHelperMasterSourceTest.java b/src/test/java/org/codehaus/plexus/components/secdispatcher/internal/sources/GitCredentialHelperMasterSourceTest.java
new file mode 100644
index 0000000..9b6eee0
--- /dev/null
+++ b/src/test/java/org/codehaus/plexus/components/secdispatcher/internal/sources/GitCredentialHelperMasterSourceTest.java
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.codehaus.plexus.components.secdispatcher.internal.sources;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Set;
+
+import org.codehaus.plexus.components.secdispatcher.SecDispatcher;
+import org.codehaus.plexus.components.secdispatcher.SecDispatcherException;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class GitCredentialHelperMasterSourceTest {
+
+ @TempDir
+ static Path tempDir;
+
+ static Path mockHelperPath;
+
+ @BeforeAll
+ static void setup() throws IOException {
+ // Create a mock git credential helper script
+ // On Windows, create a batch file; on Unix-like systems, create a shell script
+ if (System.getProperty("os.name").toLowerCase().contains("win")) {
+ mockHelperPath = tempDir.resolve("mock-git-credential-helper.bat");
+ String batchScript = "@echo off\r\n"
+ + "if \"%1\"==\"get\" (\r\n"
+ + " REM Read input until empty line\r\n"
+ + " :loop\r\n"
+ + " set /p line=\r\n"
+ + " if not defined line goto output\r\n"
+ + " goto loop\r\n"
+ + " :output\r\n"
+ + " echo protocol=https\r\n"
+ + " echo host=maven.apache.org\r\n"
+ + " echo username=testuser\r\n"
+ + " echo password=testPassword123\r\n"
+ + ")\r\n";
+ Files.writeString(mockHelperPath, batchScript);
+ } else {
+ mockHelperPath = tempDir.resolve("mock-git-credential-helper");
+ String script = "#!/bin/sh\n"
+ + "if [ \"$1\" = \"get\" ]; then\n"
+ + " # Read input (we don't actually parse it in this simple mock)\n"
+ + " while IFS= read -r line; do\n"
+ + " [ -z \"$line\" ] && break\n"
+ + " done\n"
+ + " # Return mock credentials\n"
+ + " echo \"protocol=https\"\n"
+ + " echo \"host=maven.apache.org\"\n"
+ + " echo \"username=testuser\"\n"
+ + " echo \"password=testPassword123\"\n"
+ + "fi\n";
+
+ Files.writeString(mockHelperPath, script);
+ // Make it executable on Unix-like systems
+ Files.setPosixFilePermissions(
+ mockHelperPath,
+ Set.of(
+ PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE,
+ PosixFilePermission.OWNER_EXECUTE));
+ }
+ }
+
+ @Test
+ void testMetadata() {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ assertNotNull(source.description());
+ assertTrue(source.description().contains("Git Credential Helper"));
+
+ assertTrue(source.configTemplate().isPresent());
+ assertEquals(
+ "git-credential:helper-name?url=protocol://host/path",
+ source.configTemplate().get());
+ }
+
+ @Test
+ void testHandleReturnsNullForNonMatchingPrefix() throws SecDispatcherException {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ assertNull(source.handle("env:SOME_VAR"));
+ assertNull(source.handle("file:/path/to/file"));
+ assertNull(source.handle("other-prefix:value"));
+ }
+
+ @Test
+ void testHandleThrowsExceptionForMissingUrlParameter() {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ SecDispatcherException exception =
+ assertThrows(SecDispatcherException.class, () -> source.handle("git-credential:helper-name"));
+
+ assertTrue(exception.getMessage().contains("Expected format"));
+ }
+
+ @Test
+ void testHandleThrowsExceptionForInvalidUrlParameter() {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ SecDispatcherException exception = assertThrows(
+ SecDispatcherException.class, () -> source.handle("git-credential:helper-name?invalid=value"));
+
+ assertTrue(exception.getMessage().contains("Expected URL parameter"));
+ }
+
+ @Test
+ @DisabledOnOs(
+ value = OS.WINDOWS,
+ disabledReason = "Windows batch files don't handle closed stdin gracefully in validation tests")
+ void testHandleWithMockHelper() throws SecDispatcherException {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ String config = mockHelperPath.toString() + "?url=https://maven.apache.org/master";
+ String password = source.handle("git-credential:" + config);
+
+ assertEquals("testPassword123", password);
+ }
+
+ @Test
+ void testValidateConfigurationWithInvalidFormat() {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ SecDispatcher.ValidationResponse response = source.validateConfiguration("git-credential:invalid-format");
+
+ assertNotNull(response);
+ assertFalse(response.isValid());
+ assertTrue(response.getReport().containsKey(SecDispatcher.ValidationResponse.Level.ERROR));
+ }
+
+ @Test
+ void testValidateConfigurationWithInvalidUrl() {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ SecDispatcher.ValidationResponse response =
+ source.validateConfiguration("git-credential:helper?url=invalid url with spaces");
+
+ assertNotNull(response);
+ assertFalse(response.isValid());
+ assertTrue(response.getReport().containsKey(SecDispatcher.ValidationResponse.Level.ERROR));
+ }
+
+ @Test
+ void testValidateConfigurationWithNonMatchingPrefix() {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ SecDispatcher.ValidationResponse response = source.validateConfiguration("env:SOME_VAR");
+
+ assertNull(response);
+ }
+
+ @Test
+ @DisabledOnOs(
+ value = OS.WINDOWS,
+ disabledReason = "Windows batch files don't handle closed stdin gracefully in validation tests")
+ void testValidateConfigurationWithMockHelper() {
+ GitCredentialHelperMasterSource source = new GitCredentialHelperMasterSource();
+
+ String config = mockHelperPath.toString() + "?url=https://maven.apache.org/master";
+ SecDispatcher.ValidationResponse response = source.validateConfiguration("git-credential:" + config);
+
+ assertNotNull(response);
+ assertTrue(response.isValid());
+ assertTrue(response.getReport().containsKey(SecDispatcher.ValidationResponse.Level.INFO));
+ }
+
+ @Test
+ void testBuildHelperCommandWithShortName() {
+ String result = GitCredentialHelperMasterSource.buildHelperCommand("cache");
+ assertEquals("git-credential-cache", result);
+
+ result = GitCredentialHelperMasterSource.buildHelperCommand("store");
+ assertEquals("git-credential-store", result);
+ }
+
+ @Test
+ void testBuildHelperCommandWithPath() {
+ String result = GitCredentialHelperMasterSource.buildHelperCommand("/usr/local/bin/git-credential-osxkeychain");
+ assertEquals("/usr/local/bin/git-credential-osxkeychain", result);
+
+ result = GitCredentialHelperMasterSource.buildHelperCommand("./relative/path/helper");
+ assertEquals("./relative/path/helper", result);
+ }
+}