Skip to content

Commit 8186c4e

Browse files
committed
hostprovider: wire the state and continue protocol capability
Git 2.46 added the `state` capability to the credential helper protocol, gating two new attributes: state[] -- opaque per-helper key/value pairs Git stores between invocations and replays back when calling the same helper, so providers can carry context across the get + store/erase command cycle without their own sidecar storage. continue -- a boolean signal from helper to Git indicating that the credential just returned is a non-final part of a multistage authentication flow; Git is expected to call the helper again after a follow-up 401. This is the marquee feature behind issue #2057. It unlocks optimistic account selection (try one account, remember the choice in state, fall through to a different account on 401), multistage authentication for NTLM/Kerberos-style flows, and any provider scenario that benefits from per-request memory. Surface ------- * GitCapabilities.State flag added and included in the advertised set so the negotiation handshake is functional end-to-end. * GitRequest.State exposes a lazy IReadOnlyDictionary<string,string> of incoming state. Only entries whose key begins with the reserved `gcm.` prefix are kept (per the protocol's "ignore values that don't match its prefix" rule); the prefix is stripped from dictionary keys. Malformed entries are silently discarded. * GitResponse grows a fourth shape: Continue(credential) returns a response that carries a credential and signals `continue=1`. The shape matrix is now Ok / Continue / Cancel / Yield, all mutually exclusive, enforced by the constructor. * GitResponse adds a curated state surface: State -- IReadOnlyDictionary<string,string> view for reads and enumeration; standard IDictionary patterns (indexer, TryGetValue, ContainsKey, foreach) work. SetState -- the single mutation path; validates every entry and silently no-ops on Cancel/Yield. WithState -- fluent equivalent of SetState that returns the same instance for chaining at the return site. There is no IDictionary mutation surface and no GetState/TryGetState forwarder: writes always go through the validating method, reads go through the dictionary view. Smaller API surface, no duplicated semantics. * SetState always validates key and value against the wire protocol rules (no '=' in key, no newline or NUL anywhere, no empty key, no leading `gcm.` prefix) and throws ArgumentException on violations regardless of response shape: those are programming errors that should surface at the call site rather than being silently dropped on the wire. * On Cancel and Yield shapes SetState then silently no-ops: state has no meaning when no credential is being returned, so providers that build a response speculatively and then switch shape don't have to remember to strip state. * Constants.CredentialProtocol gains StateKey, ContinueKey, and GcmStatePrefix so the wire vocabulary lives in one place. Wire emission ------------- GetCommand writes its response in protocol order: capability[] directives first (the spec requires these precede any value depending on them), then scalar fields (protocol/host/path/ username/password and AdditionalProperties), then continue=1, then state[]= entries, then the terminating blank line. state[] and continue are gated on the negotiated `state` capability. If a provider sets either but the capability was not negotiated with Git, both are silently dropped with a trace message. Dropping continue is loud in the trace specifically because it changes auth semantics: Git will treat the credential as final and likely fail on the next 401. Out of scope ------------ No in-tree provider produces state or continue yet. Wiring specific scenarios (AzureRepos multi-account MSAL, GitHub account selection, etc.) is left to follow-up commits that can focus on each scenario's design without needing to also land infrastructure. Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com> Assisted-by: Claude Opus 4.7
1 parent 44715bd commit 8186c4e

12 files changed

Lines changed: 932 additions & 72 deletions

src/Core.Tests/Commands/CapabilityCommandTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ public void CapabilityCommand_Execute_WritesVersionAndAdvertisedCapabilities()
2121
// older Gits and helpers treat anything else as "no capabilities supported".
2222
Assert.StartsWith("version 0\n", actualOutput);
2323

24-
// GCM advertises no capabilities yet; only the version line should be emitted.
25-
Assert.Equal("version 0\n", actualOutput);
24+
// GCM advertises the state capability.
25+
Assert.Equal("version 0\ncapability state\n", actualOutput);
2626
}
2727

2828
[Fact]

src/Core.Tests/Commands/GetCommandTests.cs

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.IO;
34
using System.Text;
@@ -99,10 +100,12 @@ public async Task GetCommand_ExecuteAsync_AdditionalProperties_AreEmitted()
99100
}
100101

101102
[Fact]
102-
public async Task GetCommand_ExecuteAsync_NoNegotiatedCapabilities_EmitsNoCapabilityLines()
103+
public async Task GetCommand_ExecuteAsync_NegotiatedCapability_EchoesIntersection()
103104
{
104-
// GCM advertises no capabilities yet; even when Git declares some,
105-
// the intersection is empty and no capability[] lines should be emitted.
105+
// Git advertises authtype + state; GCM advertises state. The intersection
106+
// (state) MUST be echoed back via `capability[]=state` per the protocol's
107+
// capability negotiation rules. The unsupported authtype MUST NOT be
108+
// echoed.
106109
ICredential testCredential = new GitCredential("alice", "hunter2");
107110
var stdin = "protocol=https\nhost=example.com\ncapability[]=authtype\ncapability[]=state\n\n";
108111

@@ -116,6 +119,30 @@ public async Task GetCommand_ExecuteAsync_NoNegotiatedCapabilities_EmitsNoCapabi
116119

117120
await command.ExecuteAsync();
118121

122+
string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n");
123+
124+
Assert.Contains("capability[]=state\n", actualOutput);
125+
Assert.DoesNotContain("capability[]=authtype", actualOutput);
126+
}
127+
128+
[Fact]
129+
public async Task GetCommand_ExecuteAsync_NoCapabilityFromGit_EmitsNoCapabilityLines()
130+
{
131+
// Git declares no capabilities; even though GCM advertises state, the
132+
// intersection is empty and no capability[] lines should be emitted.
133+
ICredential testCredential = new GitCredential("alice", "hunter2");
134+
var stdin = "protocol=https\nhost=example.com\n\n";
135+
136+
var providerMock = new Mock<IHostProvider>();
137+
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
138+
.ReturnsAsync(new GitResponse(testCredential));
139+
var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object };
140+
var context = new TestCommandContext { Streams = { In = stdin } };
141+
142+
var command = new GetCommand(context, providerRegistry);
143+
144+
await command.ExecuteAsync();
145+
119146
string actualOutput = context.Streams.Out.ToString();
120147

121148
Assert.DoesNotContain("capability", actualOutput);
@@ -170,6 +197,137 @@ public async Task GetCommand_ExecuteAsync_YieldedResponse_EmitsEmptyResponse()
170197
Assert.Equal("\n", actualOutput);
171198
}
172199

200+
[Fact]
201+
public async Task GetCommand_ExecuteAsync_StateNegotiated_EmitsStateLinesWithGcmPrefix()
202+
{
203+
ICredential testCredential = new GitCredential("alice", "hunter2");
204+
var response = GitResponse.Ok(testCredential);
205+
response.SetState("github.account", "alice");
206+
response.SetState("azure.tenant", "contoso");
207+
208+
var stdin = "protocol=https\nhost=example.com\ncapability[]=state\n\n";
209+
210+
var providerMock = new Mock<IHostProvider>();
211+
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
212+
.ReturnsAsync(response);
213+
var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object };
214+
var context = new TestCommandContext { Streams = { In = stdin } };
215+
216+
var command = new GetCommand(context, providerRegistry);
217+
218+
await command.ExecuteAsync();
219+
220+
string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n");
221+
222+
Assert.Contains("state[]=gcm.github.account=alice\n", actualOutput);
223+
Assert.Contains("state[]=gcm.azure.tenant=contoso\n", actualOutput);
224+
}
225+
226+
[Fact]
227+
public async Task GetCommand_ExecuteAsync_StateNotNegotiated_DropsStateLines()
228+
{
229+
ICredential testCredential = new GitCredential("alice", "hunter2");
230+
var response = GitResponse.Ok(testCredential);
231+
response.SetState("github.account", "alice");
232+
233+
// Git did NOT advertise the state capability.
234+
var stdin = "protocol=https\nhost=example.com\n\n";
235+
236+
var providerMock = new Mock<IHostProvider>();
237+
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
238+
.ReturnsAsync(response);
239+
var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object };
240+
var context = new TestCommandContext { Streams = { In = stdin } };
241+
242+
var command = new GetCommand(context, providerRegistry);
243+
244+
await command.ExecuteAsync();
245+
246+
string actualOutput = context.Streams.Out.ToString();
247+
248+
Assert.DoesNotContain("state[]", actualOutput);
249+
}
250+
251+
[Fact]
252+
public async Task GetCommand_ExecuteAsync_ContinueNegotiated_EmitsContinue1AlongsideCredential()
253+
{
254+
ICredential testCredential = new GitCredential("alice", "hunter2");
255+
var stdin = "protocol=https\nhost=example.com\ncapability[]=state\n\n";
256+
257+
var providerMock = new Mock<IHostProvider>();
258+
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
259+
.ReturnsAsync(GitResponse.Continue(testCredential));
260+
var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object };
261+
var context = new TestCommandContext { Streams = { In = stdin } };
262+
263+
var command = new GetCommand(context, providerRegistry);
264+
265+
await command.ExecuteAsync();
266+
267+
string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n");
268+
269+
Assert.Contains("username=alice\n", actualOutput);
270+
Assert.Contains("password=hunter2\n", actualOutput);
271+
Assert.Contains("continue=1\n", actualOutput);
272+
}
273+
274+
[Fact]
275+
public async Task GetCommand_ExecuteAsync_ContinueNotNegotiated_DropsContinueLine()
276+
{
277+
ICredential testCredential = new GitCredential("alice", "hunter2");
278+
var stdin = "protocol=https\nhost=example.com\n\n";
279+
280+
var providerMock = new Mock<IHostProvider>();
281+
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
282+
.ReturnsAsync(GitResponse.Continue(testCredential));
283+
var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object };
284+
var context = new TestCommandContext { Streams = { In = stdin } };
285+
286+
var command = new GetCommand(context, providerRegistry);
287+
288+
await command.ExecuteAsync();
289+
290+
string actualOutput = context.Streams.Out.ToString();
291+
292+
// Credential still emitted; continue silently dropped.
293+
Assert.Contains("username=alice", actualOutput);
294+
Assert.DoesNotContain("continue=", actualOutput);
295+
}
296+
297+
[Fact]
298+
public async Task GetCommand_ExecuteAsync_OutputOrdering_CapabilitiesFirstThenScalarsThenContinueThenState()
299+
{
300+
ICredential testCredential = new GitCredential("alice", "hunter2");
301+
var response = GitResponse.Continue(testCredential);
302+
response.SetState("k", "v");
303+
304+
var stdin = "protocol=https\nhost=example.com\ncapability[]=state\n\n";
305+
306+
var providerMock = new Mock<IHostProvider>();
307+
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
308+
.ReturnsAsync(response);
309+
var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object };
310+
var context = new TestCommandContext { Streams = { In = stdin } };
311+
312+
var command = new GetCommand(context, providerRegistry);
313+
314+
await command.ExecuteAsync();
315+
316+
string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n");
317+
318+
int posCapability = actualOutput.IndexOf("capability[]=state", StringComparison.Ordinal);
319+
int posUsername = actualOutput.IndexOf("username=", StringComparison.Ordinal);
320+
int posContinue = actualOutput.IndexOf("continue=1", StringComparison.Ordinal);
321+
int posState = actualOutput.IndexOf("state[]=", StringComparison.Ordinal);
322+
323+
Assert.True(posCapability >= 0 && posUsername > posCapability,
324+
"capability[] must precede scalar fields");
325+
Assert.True(posContinue > posUsername,
326+
"continue=1 must follow scalar fields");
327+
Assert.True(posState > posContinue,
328+
"state[] must follow continue=1");
329+
}
330+
173331
#region Helpers
174332

175333
private static IDictionary<string, string> ParseDictionary(StringBuilder sb) => ParseDictionary(sb.ToString());

src/Core.Tests/GitCapabilitiesTests.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ namespace GitCredentialManager.Tests;
55
public class GitCapabilitiesTests
66
{
77
[Fact]
8-
public void Advertised_DefaultsToNone()
8+
public void Advertised_IncludesState()
99
{
10-
// Until specific capability handling is wired up piecemeal, GCM
11-
// advertises no capabilities back to Git.
12-
Assert.Equal(GitCapabilities.None, Constants.SupportedCapabilities);
10+
// GCM advertises support for the state capability so the negotiation
11+
// handshake is functional end-to-end. Individual providers opt in to
12+
// emitting state/continue piecemeal.
13+
Assert.True(Constants.SupportedCapabilities.HasFlag(GitCapabilities.State));
1314
}
1415

1516
[Fact]
@@ -27,15 +28,35 @@ public void ParseName_UnknownName_ReturnsNone()
2728
Assert.Equal(GitCapabilities.None, GitCapabilitiesUtils.ParseName("totally-unknown"));
2829
}
2930

31+
[Fact]
32+
public void ParseName_State_ReturnsStateFlag()
33+
{
34+
Assert.Equal(GitCapabilities.State, GitCapabilitiesUtils.ParseName("state"));
35+
Assert.Equal(GitCapabilities.State, GitCapabilitiesUtils.ParseName("STATE"));
36+
Assert.Equal(GitCapabilities.State, GitCapabilitiesUtils.ParseName("State"));
37+
}
38+
3039
[Fact]
3140
public void ToProtocolName_None_Throws()
3241
{
3342
Assert.Throws<System.ArgumentException>(() => GitCapabilitiesUtils.ToProtocolName(GitCapabilities.None));
3443
}
3544

45+
[Fact]
46+
public void ToProtocolName_State_ReturnsStateString()
47+
{
48+
Assert.Equal("state", GitCapabilitiesUtils.ToProtocolName(GitCapabilities.State));
49+
}
50+
3651
[Fact]
3752
public void ToProtocolNames_None_ReturnsEmpty()
3853
{
3954
Assert.Empty(GitCapabilitiesUtils.ToProtocolNames(GitCapabilities.None));
4055
}
56+
57+
[Fact]
58+
public void ToProtocolNames_State_ReturnsStateOnly()
59+
{
60+
Assert.Equal(new[] { "state" }, GitCapabilitiesUtils.ToProtocolNames(GitCapabilities.State));
61+
}
4162
}

src/Core.Tests/GitRequestTests.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,5 +387,81 @@ public void GitRequest_Capabilities_UnknownNames_AreSilentlyDiscarded()
387387

388388
Assert.Equal(GitCapabilities.None, request.Capabilities);
389389
}
390+
391+
[Fact]
392+
public void GitRequest_State_NoStateInput_IsEmpty()
393+
{
394+
var dict = new Dictionary<string, string>
395+
{
396+
["protocol"] = "https",
397+
["host"] = "example.com",
398+
};
399+
400+
var request = new GitRequest(dict);
401+
402+
Assert.Empty(request.State);
403+
}
404+
405+
[Fact]
406+
public void GitRequest_State_KeepsOnlyGcmPrefixedEntries_AndStripsPrefix()
407+
{
408+
var dict = new Dictionary<string, IList<string>>
409+
{
410+
["protocol"] = new[] { "https" },
411+
["host"] = new[] { "example.com" },
412+
["state"] = new[]
413+
{
414+
"gcm.github.account=alice",
415+
"other-helper.foo=bar", // not ours; ignored
416+
"gcm.azure.tenant=contoso",
417+
},
418+
};
419+
420+
var request = new GitRequest(dict);
421+
422+
Assert.Equal(2, request.State.Count);
423+
Assert.Equal("alice", request.State["github.account"]);
424+
Assert.Equal("contoso", request.State["azure.tenant"]);
425+
Assert.False(request.State.ContainsKey("other-helper.foo"));
426+
}
427+
428+
[Fact]
429+
public void GitRequest_State_MalformedEntries_AreSilentlyDiscarded()
430+
{
431+
var dict = new Dictionary<string, IList<string>>
432+
{
433+
["protocol"] = new[] { "https" },
434+
["host"] = new[] { "example.com" },
435+
["state"] = new[]
436+
{
437+
"gcm.valid=ok",
438+
"gcm.no-equals", // malformed: no '='
439+
"=value-without-key", // malformed: empty key
440+
"gcm.=empty-key-after-prefix", // empty key after prefix-strip
441+
},
442+
};
443+
444+
var request = new GitRequest(dict);
445+
446+
Assert.Single(request.State);
447+
Assert.Equal("ok", request.State["valid"]);
448+
}
449+
450+
[Fact]
451+
public void GitRequest_State_ValueMayContainEquals()
452+
{
453+
// Only the FIRST '=' separates key from value; the value may itself
454+
// contain additional '=' characters.
455+
var dict = new Dictionary<string, IList<string>>
456+
{
457+
["protocol"] = new[] { "https" },
458+
["host"] = new[] { "example.com" },
459+
["state"] = new[] { "gcm.token=abc=def=ghi" },
460+
};
461+
462+
var request = new GitRequest(dict);
463+
464+
Assert.Equal("abc=def=ghi", request.State["token"]);
465+
}
390466
}
391467
}

0 commit comments

Comments
 (0)