Skip to content

Commit 6b388f8

Browse files
Copilotkwin
andcommitted
Add GitCredentialHelperMasterSource with tests
Co-authored-by: kwin <[email protected]>
1 parent 874eefe commit 6b388f8

File tree

2 files changed

+480
-0
lines changed

2 files changed

+480
-0
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.codehaus.plexus.components.secdispatcher.internal.sources;
20+
21+
import javax.inject.Named;
22+
import javax.inject.Singleton;
23+
24+
import java.io.BufferedReader;
25+
import java.io.IOException;
26+
import java.io.InputStreamReader;
27+
import java.io.OutputStreamWriter;
28+
import java.io.PrintWriter;
29+
import java.net.URI;
30+
import java.net.URISyntaxException;
31+
import java.util.ArrayList;
32+
import java.util.HashMap;
33+
import java.util.List;
34+
import java.util.Optional;
35+
import java.util.concurrent.TimeUnit;
36+
37+
import org.codehaus.plexus.components.secdispatcher.MasterSourceMeta;
38+
import org.codehaus.plexus.components.secdispatcher.SecDispatcher;
39+
import org.codehaus.plexus.components.secdispatcher.SecDispatcherException;
40+
41+
/**
42+
* Password source that uses Git Credential Helpers.
43+
* <p>
44+
* Git credential helpers have a common interface for retrieving credentials.
45+
* This master source allows using any git credential helper to retrieve passwords.
46+
* <p>
47+
* Config: {@code git-credential:helper-name?url=protocol://host/path}
48+
* <p>
49+
* Examples:
50+
* <ul>
51+
* <li>{@code git-credential:cache?url=https://maven.apache.org/master}</li>
52+
* <li>{@code git-credential:store?url=https://maven.apache.org/master}</li>
53+
* <li>{@code git-credential:/usr/local/bin/git-credential-osxkeychain?url=https://maven.apache.org/master}</li>
54+
* </ul>
55+
*
56+
* @see <a href="https://git-scm.com/docs/gitcredentials">Git Credentials</a>
57+
* @see <a href="https://git-scm.com/doc/credential-helpers">Git Credential Helpers</a>
58+
*/
59+
@Singleton
60+
@Named(GitCredentialHelperMasterSource.NAME)
61+
public final class GitCredentialHelperMasterSource extends PrefixMasterSourceSupport implements MasterSourceMeta {
62+
public static final String NAME = "git-credential";
63+
64+
public GitCredentialHelperMasterSource() {
65+
super(NAME + ":");
66+
}
67+
68+
@Override
69+
public String description() {
70+
return "Git Credential Helper (helper name and URL should be edited)";
71+
}
72+
73+
@Override
74+
public Optional<String> configTemplate() {
75+
return Optional.of(NAME + ":helper-name?url=protocol://host/path");
76+
}
77+
78+
@Override
79+
protected String doHandle(String transformed) throws SecDispatcherException {
80+
String helperName;
81+
String url;
82+
83+
// Parse configuration: helper-name?url=protocol://host/path
84+
int queryIndex = transformed.indexOf('?');
85+
if (queryIndex < 0) {
86+
throw new SecDispatcherException(
87+
"Invalid git-credential configuration. Expected format: git-credential:helper-name?url=protocol://host/path");
88+
}
89+
90+
helperName = transformed.substring(0, queryIndex);
91+
String query = transformed.substring(queryIndex + 1);
92+
93+
if (!query.startsWith("url=")) {
94+
throw new SecDispatcherException(
95+
"Invalid git-credential configuration. Expected URL parameter: url=protocol://host/path");
96+
}
97+
98+
url = query.substring(4);
99+
100+
try {
101+
return retrievePassword(helperName, url);
102+
} catch (IOException | InterruptedException e) {
103+
throw new SecDispatcherException(
104+
String.format(
105+
"Failed to retrieve password from git credential helper '%s': %s",
106+
helperName, e.getMessage()),
107+
e);
108+
}
109+
}
110+
111+
@Override
112+
protected SecDispatcher.ValidationResponse doValidateConfiguration(String transformed) {
113+
HashMap<SecDispatcher.ValidationResponse.Level, List<String>> report = new HashMap<>();
114+
boolean isValid = false;
115+
116+
try {
117+
int queryIndex = transformed.indexOf('?');
118+
if (queryIndex < 0) {
119+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
120+
.add(
121+
"Invalid configuration format. Expected: git-credential:helper-name?url=protocol://host/path");
122+
return new SecDispatcher.ValidationResponse(getClass().getSimpleName(), false, report, List.of());
123+
}
124+
125+
String helperName = transformed.substring(0, queryIndex);
126+
String query = transformed.substring(queryIndex + 1);
127+
128+
if (!query.startsWith("url=")) {
129+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
130+
.add("Invalid configuration. Expected URL parameter: url=protocol://host/path");
131+
return new SecDispatcher.ValidationResponse(getClass().getSimpleName(), false, report, List.of());
132+
}
133+
134+
String url = query.substring(4);
135+
136+
// Validate URL format
137+
try {
138+
new URI(url);
139+
} catch (URISyntaxException e) {
140+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
141+
.add(String.format("Invalid URL format: %s", e.getMessage()));
142+
return new SecDispatcher.ValidationResponse(getClass().getSimpleName(), false, report, List.of());
143+
}
144+
145+
// Try to execute the helper to see if it's available
146+
String helperCommand = buildHelperCommand(helperName);
147+
try {
148+
Process process = new ProcessBuilder(helperCommand, "get").start();
149+
// Close stdin to prevent the helper from waiting for input
150+
process.getOutputStream().close();
151+
152+
if (!process.waitFor(2, TimeUnit.SECONDS)) {
153+
process.destroyForcibly();
154+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.WARNING, k -> new ArrayList<>())
155+
.add(String.format(
156+
"Git credential helper '%s' did not respond in time. It may still work.",
157+
helperName));
158+
isValid = true; // Still consider it valid, just warn
159+
} else if (process.exitValue() == 127 || process.exitValue() == 126) {
160+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
161+
.add(String.format("Git credential helper '%s' not found or not executable", helperName));
162+
} else {
163+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.INFO, k -> new ArrayList<>())
164+
.add(String.format("Git credential helper '%s' is available", helperName));
165+
isValid = true;
166+
}
167+
} catch (IOException e) {
168+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
169+
.add(String.format(
170+
"Failed to execute git credential helper '%s': %s", helperName, e.getMessage()));
171+
} catch (InterruptedException e) {
172+
Thread.currentThread().interrupt();
173+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
174+
.add("Validation was interrupted");
175+
}
176+
} catch (Exception e) {
177+
report.computeIfAbsent(SecDispatcher.ValidationResponse.Level.ERROR, k -> new ArrayList<>())
178+
.add(String.format("Validation error: %s", e.getMessage()));
179+
}
180+
181+
return new SecDispatcher.ValidationResponse(getClass().getSimpleName(), isValid, report, List.of());
182+
}
183+
184+
private String retrievePassword(String helperName, String url) throws IOException, InterruptedException {
185+
String helperCommand = buildHelperCommand(helperName);
186+
187+
ProcessBuilder pb = new ProcessBuilder(helperCommand, "get");
188+
Process process = pb.start();
189+
190+
// Write credential request to helper's stdin
191+
try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(process.getOutputStream()))) {
192+
URI uri = new URI(url);
193+
if (uri.getScheme() != null) {
194+
writer.println("protocol=" + uri.getScheme());
195+
}
196+
if (uri.getHost() != null && !uri.getHost().isEmpty()) {
197+
writer.println("host=" + uri.getHost());
198+
if (uri.getPort() != -1) {
199+
writer.println("host=" + uri.getHost() + ":" + uri.getPort());
200+
}
201+
}
202+
if (uri.getPath() != null && !uri.getPath().isEmpty()) {
203+
writer.println("path=" + uri.getPath());
204+
}
205+
writer.println(); // Blank line signals end of input
206+
writer.flush();
207+
} catch (URISyntaxException e) {
208+
throw new IOException("Invalid URL format: " + e.getMessage(), e);
209+
}
210+
211+
// Read response from helper's stdout
212+
String password = null;
213+
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
214+
String line;
215+
while ((line = reader.readLine()) != null) {
216+
if (line.startsWith("password=")) {
217+
password = line.substring(9);
218+
break;
219+
}
220+
}
221+
}
222+
223+
if (!process.waitFor(30, TimeUnit.SECONDS)) {
224+
process.destroyForcibly();
225+
throw new IOException("Git credential helper timed out");
226+
}
227+
228+
int exitCode = process.exitValue();
229+
if (exitCode != 0) {
230+
String errorOutput = readProcessError(process);
231+
throw new IOException(
232+
String.format("Git credential helper exited with code %d. Error: %s", exitCode, errorOutput));
233+
}
234+
235+
if (password == null || password.isEmpty()) {
236+
throw new IOException("Git credential helper did not return a password");
237+
}
238+
239+
return password;
240+
}
241+
242+
private String buildHelperCommand(String helperName) {
243+
// If helper name contains a path separator, use it as-is (absolute or relative path)
244+
// Otherwise, prefix with "git-credential-"
245+
if (helperName.contains("/") || helperName.contains("\\")) {
246+
return helperName;
247+
}
248+
return "git-credential-" + helperName;
249+
}
250+
251+
private String readProcessError(Process process) {
252+
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
253+
StringBuilder sb = new StringBuilder();
254+
String line;
255+
while ((line = reader.readLine()) != null) {
256+
if (sb.length() > 0) {
257+
sb.append("; ");
258+
}
259+
sb.append(line);
260+
}
261+
return sb.toString();
262+
} catch (IOException e) {
263+
return "(failed to read error output)";
264+
}
265+
}
266+
}

0 commit comments

Comments
 (0)