Skip to content

Commit 2e312eb

Browse files
Fix GPG ID lookup and remove hardcoded test credentials (#2274)
- [x] Fix `GetGpgId` walk-up logic - [x] All 5 GnuPassCredentialStore tests pass <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/git-ecosystem/git-credential-manager/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.
2 parents 92a1fd6 + b641577 commit 2e312eb

3 files changed

Lines changed: 120 additions & 22 deletions

File tree

src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,102 @@ public void GnuPassCredentialStore_Remove_NotFound_ReturnsFalse()
8686
Assert.False(result);
8787
}
8888

89+
[PosixFact]
90+
public void GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory()
91+
{
92+
var fs = new TestFileSystem();
93+
var gpg = new TestGpg(fs);
94+
95+
string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
96+
string storePath = Path.Combine(homePath, ".password-store");
97+
const string userId = "gcm-test@example.com";
98+
99+
// Place .gpg-id only in the namespace subdirectory (not the store root),
100+
// simulating a pass store where the root has no .gpg-id but submodules do.
101+
string subDirPath = Path.Combine(storePath, TestNamespace);
102+
string gpgIdPath = Path.Combine(subDirPath, ".gpg-id");
103+
104+
gpg.GenerateKeys(userId);
105+
106+
fs.Directories.Add(storePath);
107+
fs.Directories.Add(subDirPath);
108+
fs.Files[gpgIdPath] = Encoding.UTF8.GetBytes(userId);
109+
110+
var collection = new GpgPassCredentialStore(fs, gpg, storePath, TestNamespace);
111+
112+
string service = $"https://example.com/{Guid.NewGuid():N}";
113+
const string userName = "john.doe";
114+
string password = Guid.NewGuid().ToString("N");
115+
116+
try
117+
{
118+
// Write
119+
collection.AddOrUpdate(service, userName, password);
120+
121+
// Read
122+
ICredential outCredential = collection.Get(service, userName);
123+
124+
Assert.NotNull(outCredential);
125+
Assert.Equal(userName, outCredential.Account);
126+
Assert.Equal(password, outCredential.Password);
127+
}
128+
finally
129+
{
130+
// Ensure we clean up after ourselves even in case of 'get' failures
131+
collection.Remove(service, userName);
132+
}
133+
}
134+
135+
[PosixFact]
136+
public void GnuPassCredentialStore_WriteCredential_MultipleGpgIds_UsesNearestGpgId()
137+
{
138+
// Verify that when two subdirectories each have their own .gpg-id, encrypting a credential
139+
// under one subdirectory uses that subdirectory's GPG identity, not the other one.
140+
var fs = new TestFileSystem();
141+
var gpg = new TestGpg(fs);
142+
143+
string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
144+
string storePath = Path.Combine(homePath, ".password-store");
145+
146+
const string personalUserId = "personal@example.com";
147+
const string workUserId = "work@example.com";
148+
149+
// Only register the personal key; if the wrong (work) key is picked, EncryptFile will throw.
150+
gpg.GenerateKeys(personalUserId);
151+
152+
string personalSubDir = Path.Combine(storePath, "personal");
153+
string workSubDir = Path.Combine(storePath, "work");
154+
155+
fs.Directories.Add(storePath);
156+
fs.Directories.Add(personalSubDir);
157+
fs.Directories.Add(workSubDir);
158+
fs.Files[Path.Combine(personalSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(personalUserId);
159+
fs.Files[Path.Combine(workSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(workUserId);
160+
161+
// Use "personal" namespace so credentials are stored under storePath/personal/...
162+
var collection = new GpgPassCredentialStore(fs, gpg, storePath, "personal");
163+
164+
string service = $"https://example.com/{Guid.NewGuid():N}";
165+
const string userName = "john.doe";
166+
string password = Guid.NewGuid().ToString("N");
167+
168+
try
169+
{
170+
// Write - should pick personal/.gpg-id (personalUserId), not work/.gpg-id (workUserId)
171+
collection.AddOrUpdate(service, userName, password);
172+
173+
ICredential outCredential = collection.Get(service, userName);
174+
175+
Assert.NotNull(outCredential);
176+
Assert.Equal(userName, outCredential.Account);
177+
Assert.Equal(password, outCredential.Password);
178+
}
179+
finally
180+
{
181+
collection.Remove(service, userName);
182+
}
183+
}
184+
89185
private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg)
90186
{
91187
string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);

src/shared/Core/CredentialStore.cs

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -291,18 +291,6 @@ private void ValidateGpgPass(out string storeRoot, out string execPath)
291291
storeRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".password-store");
292292
}
293293

294-
// Check we have a GPG ID to sign credential files with
295-
string gpgIdFile = Path.Combine(storeRoot, ".gpg-id");
296-
if (!_context.FileSystem.FileExists(gpgIdFile))
297-
{
298-
var format =
299-
"Password store has not been initialized at '{0}'; run `pass init <gpg-id>` to initialize the store.";
300-
var message = string.Format(format, storeRoot);
301-
_context.Trace2.WriteError(message);
302-
throw new Exception(message + Environment.NewLine +
303-
$"See {Constants.HelpUrls.GcmCredentialStores} for more information."
304-
);
305-
}
306294
}
307295

308296
private void ValidateCredentialCache(out string options)

src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,33 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot
2121

2222
protected override string CredentialFileExtension => ".gpg";
2323

24-
private string GetGpgId()
24+
private string GetGpgId(string credentialFullPath)
2525
{
26-
string gpgIdPath = Path.Combine(StoreRoot, ".gpg-id");
27-
if (!FileSystem.FileExists(gpgIdPath))
26+
// Walk up from the credential's directory to the store root, looking for a .gpg-id file.
27+
// This mimics the behaviour of GNU Pass, which uses the nearest .gpg-id in the directory hierarchy.
28+
string dir = Path.GetDirectoryName(credentialFullPath);
29+
while (dir != null)
2830
{
29-
throw new Exception($"Cannot find GPG ID in '{gpgIdPath}'; password store has not been initialized");
30-
}
31+
string gpgIdPath = Path.Combine(dir, ".gpg-id");
32+
if (FileSystem.FileExists(gpgIdPath))
33+
{
34+
using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read))
35+
using (var reader = new StreamReader(stream))
36+
{
37+
return reader.ReadLine();
38+
}
39+
}
3140

32-
using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read))
33-
using (var reader = new StreamReader(stream))
34-
{
35-
return reader.ReadLine();
41+
// Stop after checking the store root
42+
if (FileSystem.IsSamePath(dir, StoreRoot))
43+
{
44+
break;
45+
}
46+
47+
dir = Path.GetDirectoryName(dir);
3648
}
49+
50+
throw new Exception($"Cannot find GPG ID in password store at '{StoreRoot}'; run `pass init <gpg-id>` to initialize the store.");
3751
}
3852

3953
protected override bool TryDeserializeCredential(string path, out FileCredential credential)
@@ -68,7 +82,7 @@ protected override bool TryDeserializeCredential(string path, out FileCredential
6882

6983
protected override void SerializeCredential(FileCredential credential)
7084
{
71-
string gpgId = GetGpgId();
85+
string gpgId = GetGpgId(credential.FullPath);
7286

7387
var sb = new StringBuilder(credential.Password);
7488
sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine);

0 commit comments

Comments
 (0)